mirror of
https://github.com/zalando/postgres-operator.git
synced 2026-10-02 12:48:44 +02:00
* feat(logical-backup): add configurable job history limits and TTL Adds three new configuration options for logical backup cronjobs: - logical_backup_successful_jobs_history_limit (default: 3) - logical_backup_failed_jobs_history_limit (default: 3) - logical_backup_ttl_seconds_after_finished (default: 86400) These options control how many completed/failed backup jobs are retained by Kubernetes and when finished jobs are automatically deleted. This prevents accumulation of old backup jobs and pods in namespaces with many PostgreSQL clusters. Also updates the CronJob comparison logic to detect changes in these new fields and trigger reconciliation when needed. Closes zalando/postgres-operator#1092 * add added the 3 new fieldson crd * updated gen api --------- Co-authored-by: Jairo Llopis <jairo@moduon.team> Co-authored-by: Felix Kunde <felix-kunde@gmx.de>
2267 lines
72 KiB
Go
2267 lines
72 KiB
Go
package cluster
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/stretchr/testify/assert"
|
|
acidv1 "github.com/zalando/postgres-operator/pkg/apis/acid.zalan.do/v1"
|
|
fakeacidv1 "github.com/zalando/postgres-operator/pkg/generated/clientset/versioned/fake"
|
|
"github.com/zalando/postgres-operator/pkg/spec"
|
|
"github.com/zalando/postgres-operator/pkg/util"
|
|
"github.com/zalando/postgres-operator/pkg/util/config"
|
|
"github.com/zalando/postgres-operator/pkg/util/constants"
|
|
"github.com/zalando/postgres-operator/pkg/util/k8sutil"
|
|
"github.com/zalando/postgres-operator/pkg/util/patroni"
|
|
"github.com/zalando/postgres-operator/pkg/util/teams"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
v1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/client-go/kubernetes/fake"
|
|
"k8s.io/client-go/tools/record"
|
|
)
|
|
|
|
const (
|
|
superUserName = "postgres"
|
|
replicationUserName = "standby"
|
|
poolerUserName = "pooler"
|
|
adminUserName = "admin"
|
|
exampleSpiloConfig = `{"postgresql":{"bin_dir":"/usr/lib/postgresql/12/bin","parameters":{"autovacuum_analyze_scale_factor":"0.1"},"pg_hba":["hostssl all all 0.0.0.0/0 scram-sha-256","host all all 0.0.0.0/0 scram-sha-256"]},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"},"data-checksums",{"encoding":"UTF8"},{"locale":"en_US.UTF-8"}],"dcs":{"ttl":30,"loop_wait":10,"retry_timeout":10,"maximum_lag_on_failover":33554432,"postgresql":{"parameters":{"max_connections":"100","max_locks_per_transaction":"64","max_worker_processes":"4"}}}}}`
|
|
spiloConfigDiff = `{"postgresql":{"bin_dir":"/usr/lib/postgresql/12/bin","parameters":{"autovacuum_analyze_scale_factor":"0.1"},"pg_hba":["hostssl all all 0.0.0.0/0 scram-sha-256","host all all 0.0.0.0/0 scram-sha-256"]},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"},"data-checksums",{"encoding":"UTF8"},{"locale":"en_US.UTF-8"}],"dcs":{"loop_wait":10,"retry_timeout":10,"maximum_lag_on_failover":33554432,"postgresql":{"parameters":{"max_locks_per_transaction":"64","max_worker_processes":"4"}}}}}`
|
|
)
|
|
|
|
var logger = logrus.New().WithField("test", "cluster")
|
|
|
|
// eventRecorder needs buffer for TestCreate which emit events for
|
|
// 1 cluster, primary endpoint, 2 services, the secrets, the statefulset and pods being ready
|
|
var eventRecorder = record.NewFakeRecorder(7)
|
|
|
|
var cl = New(
|
|
Config{
|
|
OpConfig: config.Config{
|
|
PodManagementPolicy: "ordered_ready",
|
|
ProtectedRoles: []string{adminUserName, "cron_admin", "part_man"},
|
|
Auth: config.Auth{
|
|
SuperUsername: superUserName,
|
|
ReplicationUsername: replicationUserName,
|
|
AdditionalOwnerRoles: []string{"cron_admin", "part_man"},
|
|
},
|
|
Resources: config.Resources{
|
|
DownscalerAnnotations: []string{"downscaler/*"},
|
|
},
|
|
ConnectionPooler: config.ConnectionPooler{
|
|
User: poolerUserName,
|
|
},
|
|
},
|
|
},
|
|
k8sutil.NewMockKubernetesClient(),
|
|
acidv1.Postgresql{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "acid-test",
|
|
Namespace: "test",
|
|
Annotations: map[string]string{"downscaler/downtime_replicas": "0"},
|
|
},
|
|
Spec: acidv1.PostgresSpec{
|
|
EnableConnectionPooler: util.True(),
|
|
Streams: []acidv1.Stream{
|
|
{
|
|
ApplicationId: "test-app",
|
|
Database: "test_db",
|
|
Tables: map[string]acidv1.StreamTable{
|
|
"test_table": {
|
|
EventType: "test-app.test",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
logger,
|
|
eventRecorder,
|
|
)
|
|
|
|
func TestCreate(t *testing.T) {
|
|
clientSet := fake.NewSimpleClientset()
|
|
acidClientSet := fakeacidv1.NewSimpleClientset()
|
|
clusterName := "cluster-with-finalizer"
|
|
clusterNamespace := "test"
|
|
|
|
client := k8sutil.KubernetesClient{
|
|
DeploymentsGetter: clientSet.AppsV1(),
|
|
CronJobsGetter: clientSet.BatchV1(),
|
|
EndpointsGetter: clientSet.CoreV1(),
|
|
PersistentVolumeClaimsGetter: clientSet.CoreV1(),
|
|
PodDisruptionBudgetsGetter: clientSet.PolicyV1(),
|
|
PodsGetter: clientSet.CoreV1(),
|
|
PostgresqlsGetter: acidClientSet.AcidV1(),
|
|
ServicesGetter: clientSet.CoreV1(),
|
|
SecretsGetter: clientSet.CoreV1(),
|
|
StatefulSetsGetter: clientSet.AppsV1(),
|
|
}
|
|
|
|
pg := acidv1.Postgresql{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: clusterName,
|
|
Namespace: clusterNamespace,
|
|
},
|
|
Spec: acidv1.PostgresSpec{
|
|
EnableLogicalBackup: true,
|
|
Volume: acidv1.Volume{
|
|
Size: "1Gi",
|
|
},
|
|
},
|
|
}
|
|
|
|
pod := v1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: fmt.Sprintf("%s-0", clusterName),
|
|
Namespace: clusterNamespace,
|
|
Labels: map[string]string{
|
|
"application": "spilo",
|
|
"cluster-name": clusterName,
|
|
"spilo-role": "master",
|
|
},
|
|
},
|
|
}
|
|
|
|
// manually create resources which must be found by further API calls and are not created by cluster.Create()
|
|
client.Postgresqls(clusterNamespace).Create(context.TODO(), &pg, metav1.CreateOptions{})
|
|
client.Pods(clusterNamespace).Create(context.TODO(), &pod, metav1.CreateOptions{})
|
|
|
|
var cluster = New(
|
|
Config{
|
|
OpConfig: config.Config{
|
|
PodManagementPolicy: "ordered_ready",
|
|
Resources: config.Resources{
|
|
ClusterLabels: map[string]string{"application": "spilo"},
|
|
ClusterNameLabel: "cluster-name",
|
|
DefaultCPURequest: "300m",
|
|
DefaultCPULimit: "300m",
|
|
DefaultMemoryRequest: "300Mi",
|
|
DefaultMemoryLimit: "300Mi",
|
|
PodRoleLabel: "spilo-role",
|
|
ResourceCheckInterval: time.Duration(3),
|
|
ResourceCheckTimeout: time.Duration(10),
|
|
},
|
|
EnableFinalizers: util.True(),
|
|
},
|
|
}, client, pg, logger, eventRecorder)
|
|
|
|
err := cluster.Create()
|
|
assert.NoError(t, err)
|
|
|
|
if !cluster.hasFinalizer() {
|
|
t.Errorf("%s - expected finalizer not found on cluster", t.Name())
|
|
}
|
|
}
|
|
|
|
func TestStatefulSetAnnotations(t *testing.T) {
|
|
spec := acidv1.PostgresSpec{
|
|
TeamID: "myapp", NumberOfInstances: 1,
|
|
Resources: &acidv1.Resources{
|
|
ResourceRequests: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
|
|
ResourceLimits: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
|
|
},
|
|
Volume: acidv1.Volume{
|
|
Size: "1G",
|
|
},
|
|
}
|
|
ss, err := cl.generateStatefulSet(&spec)
|
|
if err != nil {
|
|
t.Errorf("in %s no statefulset created %v", t.Name(), err)
|
|
}
|
|
if ss != nil {
|
|
annotation := ss.ObjectMeta.GetAnnotations()
|
|
if _, ok := annotation["downscaler/downtime_replicas"]; !ok {
|
|
t.Errorf("in %s respective annotation not found on sts", t.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStatefulSetUpdateWithEnv(t *testing.T) {
|
|
oldSpec := &acidv1.PostgresSpec{
|
|
TeamID: "myapp", NumberOfInstances: 1,
|
|
Resources: &acidv1.Resources{
|
|
ResourceRequests: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
|
|
ResourceLimits: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
|
|
},
|
|
Volume: acidv1.Volume{
|
|
Size: "1G",
|
|
},
|
|
}
|
|
oldSS, err := cl.generateStatefulSet(oldSpec)
|
|
if err != nil {
|
|
t.Errorf("in %s no StatefulSet created %v", t.Name(), err)
|
|
}
|
|
|
|
newSpec := oldSpec.DeepCopy()
|
|
newSS, err := cl.generateStatefulSet(newSpec)
|
|
if err != nil {
|
|
t.Errorf("in %s no StatefulSet created %v", t.Name(), err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(oldSS, newSS) {
|
|
t.Errorf("in %s StatefulSet's must be equal", t.Name())
|
|
}
|
|
|
|
newSpec.Env = []v1.EnvVar{
|
|
{
|
|
Name: "CUSTOM_ENV_VARIABLE",
|
|
Value: "data",
|
|
},
|
|
}
|
|
newSS, err = cl.generateStatefulSet(newSpec)
|
|
if err != nil {
|
|
t.Errorf("in %s no StatefulSet created %v", t.Name(), err)
|
|
}
|
|
|
|
if reflect.DeepEqual(oldSS, newSS) {
|
|
t.Errorf("in %s StatefulSet's must be not equal", t.Name())
|
|
}
|
|
}
|
|
|
|
func TestInitRobotUsers(t *testing.T) {
|
|
tests := []struct {
|
|
testCase string
|
|
manifestUsers map[string]acidv1.UserFlags
|
|
infraRoles map[string]spec.PgUser
|
|
result map[string]spec.PgUser
|
|
err error
|
|
}{
|
|
{
|
|
testCase: "manifest user called like infrastructure role - latter should take percedence",
|
|
manifestUsers: map[string]acidv1.UserFlags{"foo": {"superuser", "createdb"}},
|
|
infraRoles: map[string]spec.PgUser{"foo": {Origin: spec.RoleOriginInfrastructure, Name: "foo", Namespace: cl.Namespace, Password: "bar"}},
|
|
result: map[string]spec.PgUser{"foo": {Origin: spec.RoleOriginInfrastructure, Name: "foo", Namespace: cl.Namespace, Password: "bar"}},
|
|
err: nil,
|
|
},
|
|
{
|
|
testCase: "manifest user with forbidden characters",
|
|
manifestUsers: map[string]acidv1.UserFlags{"!fooBar": {"superuser", "createdb"}},
|
|
err: fmt.Errorf(`invalid username: "!fooBar"`),
|
|
},
|
|
{
|
|
testCase: "manifest user with unknown privileges (should be catched by CRD, too)",
|
|
manifestUsers: map[string]acidv1.UserFlags{"foobar": {"!superuser", "createdb"}},
|
|
err: fmt.Errorf(`invalid flags for user "foobar": ` +
|
|
`user flag "!superuser" is not alphanumeric`),
|
|
},
|
|
{
|
|
testCase: "manifest user with unknown privileges - part 2 (should be catched by CRD, too)",
|
|
manifestUsers: map[string]acidv1.UserFlags{"foobar": {"superuser1", "createdb"}},
|
|
err: fmt.Errorf(`invalid flags for user "foobar": ` +
|
|
`user flag "SUPERUSER1" is not valid`),
|
|
},
|
|
{
|
|
testCase: "manifest user with conflicting flags",
|
|
manifestUsers: map[string]acidv1.UserFlags{"foobar": {"inherit", "noinherit"}},
|
|
err: fmt.Errorf(`invalid flags for user "foobar": ` +
|
|
`conflicting user flags: "NOINHERIT" and "INHERIT"`),
|
|
},
|
|
{
|
|
testCase: "manifest user called like Spilo system users",
|
|
manifestUsers: map[string]acidv1.UserFlags{superUserName: {"createdb"}, replicationUserName: {"replication"}},
|
|
infraRoles: map[string]spec.PgUser{},
|
|
result: map[string]spec.PgUser{},
|
|
err: nil,
|
|
},
|
|
{
|
|
testCase: "manifest user called like protected user name",
|
|
manifestUsers: map[string]acidv1.UserFlags{adminUserName: {"superuser"}},
|
|
infraRoles: map[string]spec.PgUser{},
|
|
result: map[string]spec.PgUser{},
|
|
err: nil,
|
|
},
|
|
{
|
|
testCase: "manifest user called like pooler system user",
|
|
manifestUsers: map[string]acidv1.UserFlags{poolerUserName: {}},
|
|
infraRoles: map[string]spec.PgUser{},
|
|
result: map[string]spec.PgUser{},
|
|
err: nil,
|
|
},
|
|
{
|
|
testCase: "manifest user called like stream system user",
|
|
manifestUsers: map[string]acidv1.UserFlags{"fes_user": {"replication"}},
|
|
infraRoles: map[string]spec.PgUser{},
|
|
result: map[string]spec.PgUser{},
|
|
err: nil,
|
|
},
|
|
}
|
|
cl.initSystemUsers()
|
|
for _, tt := range tests {
|
|
cl.Spec.Users = tt.manifestUsers
|
|
cl.pgUsers = tt.infraRoles
|
|
if err := cl.initRobotUsers(); err != nil {
|
|
if tt.err == nil {
|
|
t.Errorf("%s - %s: got an unexpected error: %v", tt.testCase, t.Name(), err)
|
|
}
|
|
if err.Error() != tt.err.Error() {
|
|
t.Errorf("%s - %s: expected error %v, got %v", tt.testCase, t.Name(), tt.err, err)
|
|
}
|
|
} else {
|
|
if !reflect.DeepEqual(cl.pgUsers, tt.result) {
|
|
t.Errorf("%s - %s: expected: %#v, got %#v", tt.testCase, t.Name(), tt.result, cl.pgUsers)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInitAdditionalOwnerRoles(t *testing.T) {
|
|
manifestUsers := map[string]acidv1.UserFlags{"foo_owner": {}, "bar_owner": {}, "app_user": {}}
|
|
expectedUsers := map[string]spec.PgUser{
|
|
"foo_owner": {Origin: spec.RoleOriginManifest, Name: "foo_owner", Namespace: cl.Namespace, Password: "f123", Flags: []string{"LOGIN"}, IsDbOwner: true, MemberOf: []string{"cron_admin", "part_man"}},
|
|
"bar_owner": {Origin: spec.RoleOriginManifest, Name: "bar_owner", Namespace: cl.Namespace, Password: "b123", Flags: []string{"LOGIN"}, IsDbOwner: true, MemberOf: []string{"cron_admin", "part_man"}},
|
|
"app_user": {Origin: spec.RoleOriginManifest, Name: "app_user", Namespace: cl.Namespace, Password: "a123", Flags: []string{"LOGIN"}, IsDbOwner: false},
|
|
}
|
|
|
|
cl.Spec.Databases = map[string]string{"foo_db": "foo_owner", "bar_db": "bar_owner"}
|
|
cl.Spec.Users = manifestUsers
|
|
|
|
// this should set IsDbOwner field for manifest users
|
|
if err := cl.initRobotUsers(); err != nil {
|
|
t.Errorf("%s could not init manifest users", t.Name())
|
|
}
|
|
|
|
// now assign additional roles to owners
|
|
cl.initAdditionalOwnerRoles()
|
|
|
|
// update passwords to compare with result
|
|
for username, existingPgUser := range cl.pgUsers {
|
|
expectedPgUser := expectedUsers[username]
|
|
if !util.IsEqualIgnoreOrder(expectedPgUser.MemberOf, existingPgUser.MemberOf) {
|
|
t.Errorf("%s unexpected membership of user %q: expected member of %#v, got member of %#v",
|
|
t.Name(), username, expectedPgUser.MemberOf, existingPgUser.MemberOf)
|
|
}
|
|
}
|
|
}
|
|
|
|
type mockOAuthTokenGetter struct {
|
|
}
|
|
|
|
func (m *mockOAuthTokenGetter) getOAuthToken() (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
type mockTeamsAPIClient struct {
|
|
members []string
|
|
}
|
|
|
|
func (m *mockTeamsAPIClient) TeamInfo(teamID, token string) (tm *teams.Team, statusCode int, err error) {
|
|
if len(m.members) > 0 {
|
|
return &teams.Team{Members: m.members}, http.StatusOK, nil
|
|
}
|
|
|
|
// when members are not set handle this as an error for this mock API
|
|
// makes it easier to test behavior when teams API is unavailable
|
|
return nil, http.StatusInternalServerError,
|
|
fmt.Errorf("mocked %d error of mock Teams API for team %q", http.StatusInternalServerError, teamID)
|
|
}
|
|
|
|
func (m *mockTeamsAPIClient) setMembers(members []string) {
|
|
m.members = members
|
|
}
|
|
|
|
// Test adding a member of a product team owning a particular DB cluster
|
|
func TestInitHumanUsers(t *testing.T) {
|
|
var mockTeamsAPI mockTeamsAPIClient
|
|
cl.oauthTokenGetter = &mockOAuthTokenGetter{}
|
|
cl.teamsAPIClient = &mockTeamsAPI
|
|
|
|
// members of a product team are granted superuser rights for DBs of their team
|
|
cl.OpConfig.EnableTeamSuperuser = true
|
|
cl.OpConfig.EnableTeamsAPI = true
|
|
cl.OpConfig.EnableTeamMemberDeprecation = true
|
|
cl.OpConfig.PamRoleName = "zalandos"
|
|
cl.Spec.TeamID = "test"
|
|
cl.Spec.Users = map[string]acidv1.UserFlags{"bar": []string{}}
|
|
|
|
tests := []struct {
|
|
existingRoles map[string]spec.PgUser
|
|
teamRoles []string
|
|
result map[string]spec.PgUser
|
|
err error
|
|
}{
|
|
{
|
|
existingRoles: map[string]spec.PgUser{"foo": {Name: "foo", Origin: spec.RoleOriginTeamsAPI,
|
|
Flags: []string{"LOGIN"}}, "bar": {Name: "bar", Flags: []string{"LOGIN"}}},
|
|
teamRoles: []string{"foo"},
|
|
result: map[string]spec.PgUser{"foo": {Name: "foo", Origin: spec.RoleOriginTeamsAPI,
|
|
MemberOf: []string{cl.OpConfig.PamRoleName}, Flags: []string{"LOGIN", "SUPERUSER"}},
|
|
"bar": {Name: "bar", Flags: []string{"LOGIN"}}},
|
|
err: fmt.Errorf("could not init human users: cannot initialize members for team %q who owns the Postgres cluster: could not get list of team members for team %q: could not get team info for team %q: mocked %d error of mock Teams API for team %q",
|
|
cl.Spec.TeamID, cl.Spec.TeamID, cl.Spec.TeamID, http.StatusInternalServerError, cl.Spec.TeamID),
|
|
},
|
|
{
|
|
existingRoles: map[string]spec.PgUser{},
|
|
teamRoles: []string{adminUserName, replicationUserName},
|
|
result: map[string]spec.PgUser{},
|
|
err: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
// set pgUsers so that initUsers sets up pgUsersCache with team roles
|
|
cl.pgUsers = tt.existingRoles
|
|
|
|
// initUsers calls initHumanUsers which should fail
|
|
// because no members are set for mocked teams API
|
|
if err := cl.initUsers(); err != nil {
|
|
// check that at least team roles are remembered in c.pgUsers
|
|
if len(cl.pgUsers) < len(tt.teamRoles) {
|
|
t.Errorf("%s unexpected size of pgUsers: expected at least %d, got %d", t.Name(), len(tt.teamRoles), len(cl.pgUsers))
|
|
}
|
|
if err.Error() != tt.err.Error() {
|
|
t.Errorf("%s expected error %v, got %v", t.Name(), err, tt.err)
|
|
}
|
|
}
|
|
|
|
// set pgUsers again to test initHumanUsers with working teams API
|
|
cl.pgUsers = tt.existingRoles
|
|
mockTeamsAPI.setMembers(tt.teamRoles)
|
|
if err := cl.initHumanUsers(); err != nil {
|
|
t.Errorf("%s got an unexpected error %v", t.Name(), err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(cl.pgUsers, tt.result) {
|
|
t.Errorf("%s expects %#v, got %#v", t.Name(), tt.result, cl.pgUsers)
|
|
}
|
|
}
|
|
}
|
|
|
|
type mockTeam struct {
|
|
teamID string
|
|
members []string
|
|
isPostgresSuperuserTeam bool
|
|
}
|
|
|
|
type mockTeamsAPIClientMultipleTeams struct {
|
|
teams []mockTeam
|
|
}
|
|
|
|
func (m *mockTeamsAPIClientMultipleTeams) TeamInfo(teamID, token string) (tm *teams.Team, statusCode int, err error) {
|
|
for _, team := range m.teams {
|
|
if team.teamID == teamID {
|
|
return &teams.Team{Members: team.members}, http.StatusOK, nil
|
|
}
|
|
}
|
|
|
|
// when given teamId is not found in teams return StatusNotFound
|
|
// the operator should only return a warning in this case and not error out (#1842)
|
|
return nil, http.StatusNotFound,
|
|
fmt.Errorf("mocked %d error of mock Teams API for team %q", http.StatusNotFound, teamID)
|
|
}
|
|
|
|
// Test adding members of maintenance teams that get superuser rights for all PG databases
|
|
func TestInitHumanUsersWithSuperuserTeams(t *testing.T) {
|
|
var mockTeamsAPI mockTeamsAPIClientMultipleTeams
|
|
cl.oauthTokenGetter = &mockOAuthTokenGetter{}
|
|
cl.teamsAPIClient = &mockTeamsAPI
|
|
cl.OpConfig.EnableTeamSuperuser = false
|
|
|
|
cl.OpConfig.EnableTeamsAPI = true
|
|
cl.OpConfig.PamRoleName = "zalandos"
|
|
|
|
teamA := mockTeam{
|
|
teamID: "postgres_superusers",
|
|
members: []string{"postgres_superuser"},
|
|
isPostgresSuperuserTeam: true,
|
|
}
|
|
|
|
userA := spec.PgUser{
|
|
Name: "postgres_superuser",
|
|
Origin: spec.RoleOriginTeamsAPI,
|
|
MemberOf: []string{cl.OpConfig.PamRoleName},
|
|
Flags: []string{"LOGIN", "SUPERUSER"},
|
|
}
|
|
|
|
teamB := mockTeam{
|
|
teamID: "postgres_admins",
|
|
members: []string{"postgres_admin"},
|
|
isPostgresSuperuserTeam: true,
|
|
}
|
|
|
|
userB := spec.PgUser{
|
|
Name: "postgres_admin",
|
|
Origin: spec.RoleOriginTeamsAPI,
|
|
MemberOf: []string{cl.OpConfig.PamRoleName},
|
|
Flags: []string{"LOGIN", "SUPERUSER"},
|
|
}
|
|
|
|
teamTest := mockTeam{
|
|
teamID: "test",
|
|
members: []string{"test_user"},
|
|
isPostgresSuperuserTeam: false,
|
|
}
|
|
|
|
userTest := spec.PgUser{
|
|
Name: "test_user",
|
|
Origin: spec.RoleOriginTeamsAPI,
|
|
MemberOf: []string{cl.OpConfig.PamRoleName},
|
|
Flags: []string{"LOGIN"},
|
|
}
|
|
|
|
tests := []struct {
|
|
ownerTeam string
|
|
existingRoles map[string]spec.PgUser
|
|
superuserTeams []string
|
|
teams []mockTeam
|
|
result map[string]spec.PgUser
|
|
}{
|
|
// case 1: there are two different teams of PG maintainers and one product team
|
|
{
|
|
ownerTeam: "test",
|
|
existingRoles: map[string]spec.PgUser{},
|
|
superuserTeams: []string{"postgres_superusers", "postgres_admins"},
|
|
teams: []mockTeam{teamA, teamB, teamTest},
|
|
result: map[string]spec.PgUser{
|
|
"postgres_superuser": userA,
|
|
"postgres_admin": userB,
|
|
"test_user": userTest,
|
|
},
|
|
},
|
|
// case 2: the team of superusers creates a new PG cluster
|
|
{
|
|
ownerTeam: "postgres_superusers",
|
|
existingRoles: map[string]spec.PgUser{},
|
|
superuserTeams: []string{"postgres_superusers"},
|
|
teams: []mockTeam{teamA},
|
|
result: map[string]spec.PgUser{
|
|
"postgres_superuser": userA,
|
|
},
|
|
},
|
|
// case 3: the team owning the cluster is promoted to the maintainers' status
|
|
{
|
|
ownerTeam: "postgres_superusers",
|
|
existingRoles: map[string]spec.PgUser{
|
|
// role with the name exists before w/o superuser privilege
|
|
"postgres_superuser": {
|
|
Origin: spec.RoleOriginTeamsAPI,
|
|
Name: "postgres_superuser",
|
|
Password: "",
|
|
Flags: []string{"LOGIN"},
|
|
MemberOf: []string{cl.OpConfig.PamRoleName},
|
|
Parameters: map[string]string(nil)}},
|
|
superuserTeams: []string{"postgres_superusers"},
|
|
teams: []mockTeam{teamA},
|
|
result: map[string]spec.PgUser{
|
|
"postgres_superuser": userA,
|
|
},
|
|
},
|
|
// case 4: the team does not exist which should not return an error
|
|
{
|
|
ownerTeam: "acid",
|
|
existingRoles: map[string]spec.PgUser{},
|
|
superuserTeams: []string{"postgres_superusers"},
|
|
teams: []mockTeam{teamA, teamB, teamTest},
|
|
result: map[string]spec.PgUser{
|
|
"postgres_superuser": userA,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
|
|
mockTeamsAPI.teams = tt.teams
|
|
|
|
cl.Spec.TeamID = tt.ownerTeam
|
|
cl.pgUsers = tt.existingRoles
|
|
cl.OpConfig.PostgresSuperuserTeams = tt.superuserTeams
|
|
|
|
if err := cl.initHumanUsers(); err != nil {
|
|
t.Errorf("%s got an unexpected error %v", t.Name(), err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(cl.pgUsers, tt.result) {
|
|
t.Errorf("%s expects %#v, got %#v", t.Name(), tt.result, cl.pgUsers)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPodAnnotations(t *testing.T) {
|
|
tests := []struct {
|
|
subTest string
|
|
operator map[string]string
|
|
database map[string]string
|
|
merged map[string]string
|
|
}{
|
|
{
|
|
subTest: "No Annotations",
|
|
operator: make(map[string]string),
|
|
database: make(map[string]string),
|
|
merged: make(map[string]string),
|
|
},
|
|
{
|
|
subTest: "Operator Config Annotations",
|
|
operator: map[string]string{"foo": "bar"},
|
|
database: make(map[string]string),
|
|
merged: map[string]string{"foo": "bar"},
|
|
},
|
|
{
|
|
subTest: "Database Config Annotations",
|
|
operator: make(map[string]string),
|
|
database: map[string]string{"foo": "bar"},
|
|
merged: map[string]string{"foo": "bar"},
|
|
},
|
|
{
|
|
subTest: "Both Annotations",
|
|
operator: map[string]string{"foo": "bar"},
|
|
database: map[string]string{"post": "gres"},
|
|
merged: map[string]string{"foo": "bar", "post": "gres"},
|
|
},
|
|
{
|
|
subTest: "Database Config overrides Operator Config Annotations",
|
|
operator: map[string]string{"foo": "bar", "global": "foo"},
|
|
database: map[string]string{"foo": "baz", "local": "foo"},
|
|
merged: map[string]string{"foo": "baz", "global": "foo", "local": "foo"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
cl.OpConfig.CustomPodAnnotations = tt.operator
|
|
cl.Postgresql.Spec.PodAnnotations = tt.database
|
|
|
|
annotations := cl.generatePodAnnotations(&cl.Postgresql.Spec)
|
|
for k, v := range annotations {
|
|
if observed, expected := v, tt.merged[k]; observed != expected {
|
|
t.Errorf("%v expects annotation value %v for key %v, but found %v",
|
|
t.Name()+"/"+tt.subTest, expected, observed, k)
|
|
}
|
|
}
|
|
for k, v := range tt.merged {
|
|
if observed, expected := annotations[k], v; observed != expected {
|
|
t.Errorf("%v expects annotation value %v for key %v, but found %v",
|
|
t.Name()+"/"+tt.subTest, expected, observed, k)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServiceAnnotations(t *testing.T) {
|
|
enabled := true
|
|
disabled := false
|
|
tests := []struct {
|
|
about string
|
|
role PostgresRole
|
|
enableMasterLoadBalancerSpec *bool
|
|
enableMasterLoadBalancerOC bool
|
|
enableReplicaLoadBalancerSpec *bool
|
|
enableReplicaLoadBalancerOC bool
|
|
enableTeamIdClusterPrefix bool
|
|
operatorAnnotations map[string]string
|
|
serviceAnnotations map[string]string
|
|
masterServiceAnnotations map[string]string
|
|
replicaServiceAnnotations map[string]string
|
|
expect map[string]string
|
|
}{
|
|
//MASTER
|
|
{
|
|
about: "Master with no annotations and EnableMasterLoadBalancer disabled on spec and OperatorConfig",
|
|
role: "master",
|
|
enableMasterLoadBalancerSpec: &disabled,
|
|
enableMasterLoadBalancerOC: false,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: make(map[string]string),
|
|
},
|
|
{
|
|
about: "Master with no annotations and EnableMasterLoadBalancer enabled on spec",
|
|
role: "master",
|
|
enableMasterLoadBalancerSpec: &enabled,
|
|
enableMasterLoadBalancerOC: false,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
},
|
|
},
|
|
{
|
|
about: "Master with no annotations and EnableMasterLoadBalancer enabled only on operator config",
|
|
role: "master",
|
|
enableMasterLoadBalancerSpec: &disabled,
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: make(map[string]string),
|
|
},
|
|
{
|
|
about: "Master with no annotations and EnableMasterLoadBalancer defined only on operator config",
|
|
role: "master",
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
},
|
|
},
|
|
{
|
|
about: "Master with cluster annotations and load balancer enabled",
|
|
role: "master",
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{"foo": "bar"},
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
"foo": "bar",
|
|
},
|
|
},
|
|
{
|
|
about: "Master with cluster annotations and load balancer disabled",
|
|
role: "master",
|
|
enableMasterLoadBalancerSpec: &disabled,
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{"foo": "bar"},
|
|
expect: map[string]string{"foo": "bar"},
|
|
},
|
|
{
|
|
about: "Master with operator annotations and load balancer enabled",
|
|
role: "master",
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: map[string]string{"foo": "bar"},
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
"foo": "bar",
|
|
},
|
|
},
|
|
{
|
|
about: "Master with operator annotations override default annotations",
|
|
role: "master",
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: map[string]string{
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
},
|
|
{
|
|
about: "Master with cluster annotations override default annotations",
|
|
role: "master",
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
},
|
|
{
|
|
about: "Master with cluster annotations do not override external-dns annotations",
|
|
role: "master",
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "wrong.external-dns-name.example.com",
|
|
},
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
},
|
|
},
|
|
{
|
|
about: "Master with cluster name teamId prefix enabled",
|
|
role: "master",
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: true,
|
|
serviceAnnotations: make(map[string]string),
|
|
operatorAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
},
|
|
},
|
|
{
|
|
about: "Master with master service annotations override service annotations",
|
|
role: "master",
|
|
enableMasterLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{
|
|
"service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip",
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
masterServiceAnnotations: map[string]string{
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "2000",
|
|
},
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg.test.db.example.com,test-stg.acid.db.example.com",
|
|
"service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip",
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "2000",
|
|
},
|
|
},
|
|
// REPLICA
|
|
{
|
|
about: "Replica with no annotations and EnableReplicaLoadBalancer disabled on spec and OperatorConfig",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerSpec: &disabled,
|
|
enableReplicaLoadBalancerOC: false,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: make(map[string]string),
|
|
},
|
|
{
|
|
about: "Replica with no annotations and EnableReplicaLoadBalancer enabled on spec",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerSpec: &enabled,
|
|
enableReplicaLoadBalancerOC: false,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
},
|
|
},
|
|
{
|
|
about: "Replica with no annotations and EnableReplicaLoadBalancer enabled only on operator config",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerSpec: &disabled,
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: make(map[string]string),
|
|
},
|
|
{
|
|
about: "Replica with no annotations and EnableReplicaLoadBalancer defined only on operator config",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
},
|
|
},
|
|
{
|
|
about: "Replica with cluster annotations and load balancer enabled",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{"foo": "bar"},
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
"foo": "bar",
|
|
},
|
|
},
|
|
{
|
|
about: "Replica with cluster annotations and load balancer disabled",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerSpec: &disabled,
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{"foo": "bar"},
|
|
expect: map[string]string{"foo": "bar"},
|
|
},
|
|
{
|
|
about: "Replica with operator annotations and load balancer enabled",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: map[string]string{"foo": "bar"},
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
"foo": "bar",
|
|
},
|
|
},
|
|
{
|
|
about: "Replica with operator annotations override default annotations",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: map[string]string{
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
serviceAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
},
|
|
{
|
|
about: "Replica with cluster annotations override default annotations",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
},
|
|
{
|
|
about: "Replica with cluster annotations do not override external-dns annotations",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "wrong.external-dns-name.example.com",
|
|
},
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
},
|
|
},
|
|
{
|
|
about: "Replica with cluster name teamId prefix enabled",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: true,
|
|
serviceAnnotations: make(map[string]string),
|
|
operatorAnnotations: make(map[string]string),
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
},
|
|
},
|
|
{
|
|
about: "Replica with replica service annotations override service annotations",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: true,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: make(map[string]string),
|
|
serviceAnnotations: map[string]string{
|
|
"service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip",
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "1800",
|
|
},
|
|
replicaServiceAnnotations: map[string]string{
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "2000",
|
|
},
|
|
expect: map[string]string{
|
|
"external-dns.alpha.kubernetes.io/hostname": "acid-test-stg-repl.test.db.example.com,test-stg-repl.acid.db.example.com",
|
|
"service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip",
|
|
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout": "2000",
|
|
},
|
|
},
|
|
// COMMON
|
|
{
|
|
about: "cluster annotations append to operator annotations",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: false,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: map[string]string{"foo": "bar"},
|
|
serviceAnnotations: map[string]string{"post": "gres"},
|
|
expect: map[string]string{"foo": "bar", "post": "gres"},
|
|
},
|
|
{
|
|
about: "cluster annotations override operator annotations",
|
|
role: "replica",
|
|
enableReplicaLoadBalancerOC: false,
|
|
enableTeamIdClusterPrefix: false,
|
|
operatorAnnotations: map[string]string{"foo": "bar", "post": "gres"},
|
|
serviceAnnotations: map[string]string{"post": "greSQL"},
|
|
expect: map[string]string{"foo": "bar", "post": "greSQL"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.about, func(t *testing.T) {
|
|
cl.OpConfig.EnableTeamIdClusternamePrefix = tt.enableTeamIdClusterPrefix
|
|
|
|
cl.OpConfig.CustomServiceAnnotations = tt.operatorAnnotations
|
|
cl.OpConfig.EnableMasterLoadBalancer = tt.enableMasterLoadBalancerOC
|
|
cl.OpConfig.EnableReplicaLoadBalancer = tt.enableReplicaLoadBalancerOC
|
|
cl.OpConfig.MasterDNSNameFormat = "{cluster}-stg.{namespace}.{hostedzone}"
|
|
cl.OpConfig.MasterLegacyDNSNameFormat = "{cluster}-stg.{team}.{hostedzone}"
|
|
cl.OpConfig.ReplicaDNSNameFormat = "{cluster}-stg-repl.{namespace}.{hostedzone}"
|
|
cl.OpConfig.ReplicaLegacyDNSNameFormat = "{cluster}-stg-repl.{team}.{hostedzone}"
|
|
cl.OpConfig.DbHostedZone = "db.example.com"
|
|
|
|
cl.Postgresql.Spec.ClusterName = ""
|
|
cl.Postgresql.Spec.TeamID = "acid"
|
|
cl.Postgresql.Spec.ServiceAnnotations = tt.serviceAnnotations
|
|
cl.Postgresql.Spec.MasterServiceAnnotations = tt.masterServiceAnnotations
|
|
cl.Postgresql.Spec.ReplicaServiceAnnotations = tt.replicaServiceAnnotations
|
|
cl.Postgresql.Spec.EnableMasterLoadBalancer = tt.enableMasterLoadBalancerSpec
|
|
cl.Postgresql.Spec.EnableReplicaLoadBalancer = tt.enableReplicaLoadBalancerSpec
|
|
|
|
got := cl.generateServiceAnnotations(tt.role, &cl.Postgresql.Spec)
|
|
if len(tt.expect) != len(got) {
|
|
t.Errorf("expected %d annotation(s), got %d", len(tt.expect), len(got))
|
|
return
|
|
}
|
|
for k, v := range got {
|
|
if tt.expect[k] != v {
|
|
t.Errorf("expected annotation '%v' with value '%v', got value '%v'", k, tt.expect[k], v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInitSystemUsers(t *testing.T) {
|
|
// reset system users, pooler and stream section
|
|
cl.systemUsers = make(map[string]spec.PgUser)
|
|
cl.Spec.EnableConnectionPooler = boolToPointer(false)
|
|
cl.Spec.Streams = []acidv1.Stream{}
|
|
|
|
// default cluster without connection pooler and event streams
|
|
cl.initSystemUsers()
|
|
if _, exist := cl.systemUsers[constants.ConnectionPoolerUserKeyName]; exist {
|
|
t.Errorf("%s, connection pooler user is present", t.Name())
|
|
}
|
|
if _, exist := cl.systemUsers[constants.EventStreamUserKeyName]; exist {
|
|
t.Errorf("%s, stream user is present", t.Name())
|
|
}
|
|
|
|
// cluster with connection pooler
|
|
cl.Spec.EnableConnectionPooler = boolToPointer(true)
|
|
cl.initSystemUsers()
|
|
if _, exist := cl.systemUsers[constants.ConnectionPoolerUserKeyName]; !exist {
|
|
t.Errorf("%s, connection pooler user is not present", t.Name())
|
|
}
|
|
|
|
// superuser is not allowed as connection pool user
|
|
cl.Spec.ConnectionPooler = &acidv1.ConnectionPooler{
|
|
User: superUserName,
|
|
}
|
|
cl.OpConfig.SuperUsername = superUserName
|
|
cl.OpConfig.ConnectionPooler.User = poolerUserName
|
|
|
|
cl.initSystemUsers()
|
|
if _, exist := cl.systemUsers[poolerUserName]; !exist {
|
|
t.Errorf("%s, Superuser is not allowed to be a connection pool user", t.Name())
|
|
}
|
|
|
|
// neither protected users are
|
|
delete(cl.systemUsers, poolerUserName)
|
|
cl.Spec.ConnectionPooler = &acidv1.ConnectionPooler{
|
|
User: adminUserName,
|
|
}
|
|
cl.OpConfig.ProtectedRoles = []string{adminUserName}
|
|
|
|
cl.initSystemUsers()
|
|
if _, exist := cl.systemUsers[poolerUserName]; !exist {
|
|
t.Errorf("%s, Protected user are not allowed to be a connection pool user", t.Name())
|
|
}
|
|
|
|
delete(cl.systemUsers, poolerUserName)
|
|
cl.Spec.ConnectionPooler = &acidv1.ConnectionPooler{
|
|
User: replicationUserName,
|
|
}
|
|
|
|
cl.initSystemUsers()
|
|
if _, exist := cl.systemUsers[poolerUserName]; !exist {
|
|
t.Errorf("%s, System users are not allowed to be a connection pool user", t.Name())
|
|
}
|
|
|
|
// using stream user in manifest but no streams defined should be treated like normal robot user
|
|
streamUser := fmt.Sprintf("%s%s", constants.EventStreamSourceSlotPrefix, constants.UserRoleNameSuffix)
|
|
cl.Spec.Users = map[string]acidv1.UserFlags{streamUser: []string{}}
|
|
cl.initSystemUsers()
|
|
if _, exist := cl.systemUsers[constants.EventStreamUserKeyName]; exist {
|
|
t.Errorf("%s, stream user is present", t.Name())
|
|
}
|
|
|
|
// cluster with streams
|
|
cl.Spec.Streams = []acidv1.Stream{
|
|
{
|
|
ApplicationId: "test-app",
|
|
Database: "test_db",
|
|
Tables: map[string]acidv1.StreamTable{
|
|
"test_table": {
|
|
EventType: "test-app.test",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
cl.initSystemUsers()
|
|
if _, exist := cl.systemUsers[constants.EventStreamUserKeyName]; !exist {
|
|
t.Errorf("%s, stream user is not present", t.Name())
|
|
}
|
|
}
|
|
|
|
func TestPreparedDatabases(t *testing.T) {
|
|
cl.Spec.PreparedDatabases = map[string]acidv1.PreparedDatabase{}
|
|
cl.initPreparedDatabaseRoles()
|
|
|
|
for _, role := range []string{"acid_test_owner", "acid_test_reader", "acid_test_writer",
|
|
"acid_test_data_owner", "acid_test_data_reader", "acid_test_data_writer"} {
|
|
if _, exist := cl.pgUsers[role]; !exist {
|
|
t.Errorf("%s, default role %q for prepared database not present", t.Name(), role)
|
|
}
|
|
}
|
|
|
|
testName := "TestPreparedDatabaseWithSchema"
|
|
|
|
cl.Spec.PreparedDatabases = map[string]acidv1.PreparedDatabase{
|
|
"foo": {
|
|
DefaultUsers: true,
|
|
PreparedSchemas: map[string]acidv1.PreparedSchema{
|
|
"bar": {
|
|
DefaultUsers: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
cl.initPreparedDatabaseRoles()
|
|
|
|
for _, role := range []string{
|
|
"foo_owner", "foo_reader", "foo_writer",
|
|
"foo_owner_user", "foo_reader_user", "foo_writer_user",
|
|
"foo_bar_owner", "foo_bar_reader", "foo_bar_writer",
|
|
"foo_bar_owner_user", "foo_bar_reader_user", "foo_bar_writer_user"} {
|
|
if _, exist := cl.pgUsers[role]; !exist {
|
|
t.Errorf("%s, default role %q for prepared database not present", testName, role)
|
|
}
|
|
}
|
|
|
|
roleTests := []struct {
|
|
subTest string
|
|
role string
|
|
memberOf string
|
|
admin string
|
|
}{
|
|
{
|
|
subTest: "Test admin role of owner",
|
|
role: "foo_owner",
|
|
memberOf: "",
|
|
admin: adminUserName,
|
|
},
|
|
{
|
|
subTest: "Test writer is a member of reader",
|
|
role: "foo_writer",
|
|
memberOf: "foo_reader",
|
|
admin: "foo_owner",
|
|
},
|
|
{
|
|
subTest: "Test reader LOGIN role",
|
|
role: "foo_reader_user",
|
|
memberOf: "foo_reader",
|
|
admin: "foo_owner",
|
|
},
|
|
{
|
|
subTest: "Test schema owner",
|
|
role: "foo_bar_owner",
|
|
memberOf: "",
|
|
admin: "foo_owner",
|
|
},
|
|
{
|
|
subTest: "Test schema writer LOGIN role",
|
|
role: "foo_bar_writer_user",
|
|
memberOf: "foo_bar_writer",
|
|
admin: "foo_bar_owner",
|
|
},
|
|
}
|
|
|
|
for _, tt := range roleTests {
|
|
user := cl.pgUsers[tt.role]
|
|
if (tt.memberOf == "" && len(user.MemberOf) > 0) || (tt.memberOf != "" && user.MemberOf[0] != tt.memberOf) {
|
|
t.Errorf("%s, incorrect membership for default role %q. Expected %q, got %q", tt.subTest, tt.role, tt.memberOf, user.MemberOf[0])
|
|
}
|
|
if user.AdminRole != tt.admin {
|
|
t.Errorf("%s, incorrect admin role for default role %q. Expected %q, got %q", tt.subTest, tt.role, tt.admin, user.AdminRole)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompareSpiloConfiguration(t *testing.T) {
|
|
testCases := []struct {
|
|
Config string
|
|
ExpectedResult bool
|
|
}{
|
|
{
|
|
`{"postgresql":{"bin_dir":"/usr/lib/postgresql/12/bin","parameters":{"autovacuum_analyze_scale_factor":"0.1"},"pg_hba":["hostssl all all 0.0.0.0/0 scram-sha-256","host all all 0.0.0.0/0 scram-sha-256"]},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"},"data-checksums",{"encoding":"UTF8"},{"locale":"en_US.UTF-8"}],"dcs":{"ttl":30,"loop_wait":10,"retry_timeout":10,"maximum_lag_on_failover":33554432,"postgresql":{"parameters":{"max_connections":"100","max_locks_per_transaction":"64","max_worker_processes":"4"}}}}}`,
|
|
true,
|
|
},
|
|
{
|
|
`{"postgresql":{"bin_dir":"/usr/lib/postgresql/12/bin","parameters":{"autovacuum_analyze_scale_factor":"0.1"},"pg_hba":["hostssl all all 0.0.0.0/0 scram-sha-256","host all all 0.0.0.0/0 scram-sha-256"]},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"},"data-checksums",{"encoding":"UTF8"},{"locale":"en_US.UTF-8"}],"dcs":{"ttl":30,"loop_wait":10,"retry_timeout":10,"maximum_lag_on_failover":33554432,"postgresql":{"parameters":{"max_connections":"200","max_locks_per_transaction":"64","max_worker_processes":"4"}}}}}`,
|
|
true,
|
|
},
|
|
{
|
|
`{}`,
|
|
false,
|
|
},
|
|
{
|
|
`invalidjson`,
|
|
false,
|
|
},
|
|
}
|
|
refCase := testCases[0]
|
|
for _, testCase := range testCases {
|
|
if result := compareSpiloConfiguration(refCase.Config, testCase.Config); result != testCase.ExpectedResult {
|
|
t.Errorf("expected %v got %v", testCase.ExpectedResult, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompareEnv(t *testing.T) {
|
|
testCases := []struct {
|
|
Envs []v1.EnvVar
|
|
ExpectedResult bool
|
|
}{
|
|
{
|
|
Envs: []v1.EnvVar{
|
|
{
|
|
Name: "VARIABLE1",
|
|
Value: "value1",
|
|
},
|
|
{
|
|
Name: "VARIABLE2",
|
|
Value: "value2",
|
|
},
|
|
{
|
|
Name: "VARIABLE3",
|
|
Value: "value3",
|
|
},
|
|
{
|
|
Name: "SPILO_CONFIGURATION",
|
|
Value: exampleSpiloConfig,
|
|
},
|
|
},
|
|
ExpectedResult: true,
|
|
},
|
|
{
|
|
Envs: []v1.EnvVar{
|
|
{
|
|
Name: "VARIABLE1",
|
|
Value: "value1",
|
|
},
|
|
{
|
|
Name: "VARIABLE2",
|
|
Value: "value2",
|
|
},
|
|
{
|
|
Name: "VARIABLE3",
|
|
Value: "value3",
|
|
},
|
|
{
|
|
Name: "SPILO_CONFIGURATION",
|
|
Value: spiloConfigDiff,
|
|
},
|
|
},
|
|
ExpectedResult: true,
|
|
},
|
|
{
|
|
Envs: []v1.EnvVar{
|
|
{
|
|
Name: "VARIABLE4",
|
|
Value: "value4",
|
|
},
|
|
{
|
|
Name: "VARIABLE2",
|
|
Value: "value2",
|
|
},
|
|
{
|
|
Name: "VARIABLE3",
|
|
Value: "value3",
|
|
},
|
|
{
|
|
Name: "SPILO_CONFIGURATION",
|
|
Value: exampleSpiloConfig,
|
|
},
|
|
},
|
|
ExpectedResult: false,
|
|
},
|
|
{
|
|
Envs: []v1.EnvVar{
|
|
{
|
|
Name: "VARIABLE1",
|
|
Value: "value1",
|
|
},
|
|
{
|
|
Name: "VARIABLE2",
|
|
Value: "value2",
|
|
},
|
|
{
|
|
Name: "VARIABLE3",
|
|
Value: "value3",
|
|
},
|
|
{
|
|
Name: "VARIABLE4",
|
|
Value: "value4",
|
|
},
|
|
{
|
|
Name: "SPILO_CONFIGURATION",
|
|
Value: exampleSpiloConfig,
|
|
},
|
|
},
|
|
ExpectedResult: false,
|
|
},
|
|
{
|
|
Envs: []v1.EnvVar{
|
|
{
|
|
Name: "VARIABLE1",
|
|
Value: "value1",
|
|
},
|
|
{
|
|
Name: "VARIABLE2",
|
|
Value: "value2",
|
|
},
|
|
{
|
|
Name: "SPILO_CONFIGURATION",
|
|
Value: exampleSpiloConfig,
|
|
},
|
|
},
|
|
ExpectedResult: false,
|
|
},
|
|
}
|
|
refCase := testCases[0]
|
|
for _, testCase := range testCases {
|
|
if result := compareEnv(refCase.Envs, testCase.Envs); result != testCase.ExpectedResult {
|
|
t.Errorf("expected %v got %v", testCase.ExpectedResult, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newService(
|
|
annotations map[string]string,
|
|
svcType v1.ServiceType,
|
|
sourceRanges []string,
|
|
selector map[string]string,
|
|
policy v1.ServiceExternalTrafficPolicyType,
|
|
nodePort *int32) *v1.Service {
|
|
svc := &v1.Service{
|
|
Spec: v1.ServiceSpec{
|
|
Selector: selector,
|
|
Type: svcType,
|
|
LoadBalancerSourceRanges: sourceRanges,
|
|
ExternalTrafficPolicy: policy,
|
|
},
|
|
}
|
|
svc.Annotations = annotations
|
|
|
|
if nodePort != nil {
|
|
svc.Spec.Ports = []v1.ServicePort{
|
|
{
|
|
Name: "port",
|
|
NodePort: *nodePort,
|
|
},
|
|
}
|
|
}
|
|
|
|
return svc
|
|
}
|
|
|
|
func TestCompareServices(t *testing.T) {
|
|
cluster := Cluster{
|
|
Config: Config{
|
|
OpConfig: config.Config{
|
|
Resources: config.Resources{
|
|
IgnoredAnnotations: []string{
|
|
"k8s.v1.cni.cncf.io/network-status",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
defaultPolicy := v1.ServiceExternalTrafficPolicyTypeCluster
|
|
|
|
serviceWithOwnerReference := newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{"128.141.0.0/16", "137.138.0.0/16"},
|
|
nil,
|
|
defaultPolicy,
|
|
nil,
|
|
)
|
|
|
|
ownerRef := metav1.OwnerReference{
|
|
APIVersion: "acid.zalan.do/v1",
|
|
Controller: boolToPointer(true),
|
|
Kind: "Postgresql",
|
|
Name: "clstr",
|
|
}
|
|
|
|
serviceWithOwnerReference.ObjectMeta.OwnerReferences = append(serviceWithOwnerReference.ObjectMeta.OwnerReferences, ownerRef)
|
|
|
|
portZero := int32(0)
|
|
portNotZero := int32(1337)
|
|
|
|
tests := []struct {
|
|
about string
|
|
current *v1.Service
|
|
new *v1.Service
|
|
reason string
|
|
match bool
|
|
}{
|
|
{
|
|
about: "two equal services",
|
|
current: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{"128.141.0.0/16", "137.138.0.0/16"},
|
|
nil, defaultPolicy, nil),
|
|
new: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{"128.141.0.0/16", "137.138.0.0/16"},
|
|
nil, defaultPolicy, nil),
|
|
match: true,
|
|
},
|
|
{
|
|
about: "services differ on service type",
|
|
current: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{"128.141.0.0/16", "137.138.0.0/16"},
|
|
nil, defaultPolicy, nil),
|
|
new: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeLoadBalancer,
|
|
[]string{"128.141.0.0/16", "137.138.0.0/16"},
|
|
nil, defaultPolicy, nil),
|
|
match: false,
|
|
reason: `new service's type "LoadBalancer" does not match the current one "ClusterIP"`,
|
|
},
|
|
{
|
|
about: "services differ on lb source ranges",
|
|
current: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeLoadBalancer,
|
|
[]string{"128.141.0.0/16", "137.138.0.0/16"},
|
|
nil, defaultPolicy, nil),
|
|
new: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeLoadBalancer,
|
|
[]string{"185.249.56.0/22"},
|
|
nil, defaultPolicy, nil),
|
|
match: false,
|
|
reason: `new service's LoadBalancerSourceRange does not match the current one`,
|
|
},
|
|
{
|
|
about: "new service doesn't have lb source ranges",
|
|
current: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeLoadBalancer,
|
|
[]string{"128.141.0.0/16", "137.138.0.0/16"},
|
|
nil, defaultPolicy, nil),
|
|
new: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeLoadBalancer,
|
|
[]string{},
|
|
nil, defaultPolicy, nil),
|
|
match: false,
|
|
reason: `new service's LoadBalancerSourceRange does not match the current one`,
|
|
},
|
|
{
|
|
about: "new service doesn't have owner references",
|
|
current: newService(
|
|
map[string]string{
|
|
constants.ZalandoDNSNameAnnotation: "clstr.acid.zalan.do",
|
|
},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{"128.141.0.0/16", "137.138.0.0/16"},
|
|
nil, defaultPolicy, nil),
|
|
new: serviceWithOwnerReference,
|
|
match: false,
|
|
},
|
|
{
|
|
about: "new service has a label selector",
|
|
current: newService(
|
|
map[string]string{},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{},
|
|
nil, defaultPolicy, nil),
|
|
new: newService(
|
|
map[string]string{},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{},
|
|
map[string]string{"cluster-name": "clstr", "spilo-role": "master"}, defaultPolicy, nil),
|
|
match: false,
|
|
},
|
|
{
|
|
about: "services differ on external traffic policy",
|
|
current: newService(
|
|
map[string]string{},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{},
|
|
nil, defaultPolicy, nil),
|
|
new: newService(
|
|
map[string]string{},
|
|
v1.ServiceTypeClusterIP,
|
|
[]string{},
|
|
nil, v1.ServiceExternalTrafficPolicyTypeLocal, nil),
|
|
match: false,
|
|
},
|
|
{
|
|
about: "services differ on node port",
|
|
current: newService(
|
|
map[string]string{},
|
|
v1.ServiceTypeNodePort,
|
|
[]string{},
|
|
nil, defaultPolicy, &portZero),
|
|
new: newService(
|
|
map[string]string{},
|
|
v1.ServiceTypeNodePort,
|
|
[]string{},
|
|
nil, defaultPolicy, &portNotZero),
|
|
match: false,
|
|
},
|
|
{
|
|
about: "services do not differ on node port when requesting 0",
|
|
current: newService(
|
|
map[string]string{},
|
|
v1.ServiceTypeNodePort,
|
|
[]string{},
|
|
nil, defaultPolicy, &portNotZero),
|
|
new: newService(
|
|
map[string]string{},
|
|
v1.ServiceTypeNodePort,
|
|
[]string{},
|
|
nil, defaultPolicy, &portZero),
|
|
match: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.about, func(t *testing.T) {
|
|
match, reason := cluster.compareServices(tt.current, tt.new)
|
|
if match && !tt.match {
|
|
t.Logf("match=%v current=%v, old=%v reason=%s", match, tt.current.Annotations, tt.new.Annotations, reason)
|
|
t.Errorf("%s - expected services to do not match: %q and %q", t.Name(), tt.current, tt.new)
|
|
}
|
|
if !match && tt.match {
|
|
t.Errorf("%s - expected services to be the same: %q and %q", t.Name(), tt.current, tt.new)
|
|
}
|
|
if !match && !tt.match {
|
|
if !strings.HasPrefix(reason, tt.reason) {
|
|
t.Errorf("%s - expected reason prefix %s, found %s", t.Name(), tt.reason, reason)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
var (
|
|
defaultSuccessfulJobsHistoryLimit = int32(3)
|
|
defaultFailedJobsHistoryLimit = int32(3)
|
|
defaultTTLSecondsAfterFinished = int32(86400)
|
|
)
|
|
|
|
func newCronJob(image, schedule string, vars []v1.EnvVar, mounts []v1.VolumeMount) *batchv1.CronJob {
|
|
cron := &batchv1.CronJob{
|
|
Spec: batchv1.CronJobSpec{
|
|
Schedule: schedule,
|
|
SuccessfulJobsHistoryLimit: &defaultSuccessfulJobsHistoryLimit,
|
|
FailedJobsHistoryLimit: &defaultFailedJobsHistoryLimit,
|
|
JobTemplate: batchv1.JobTemplateSpec{
|
|
Spec: batchv1.JobSpec{
|
|
TTLSecondsAfterFinished: &defaultTTLSecondsAfterFinished,
|
|
Template: v1.PodTemplateSpec{
|
|
Spec: v1.PodSpec{
|
|
Containers: []v1.Container{
|
|
{
|
|
Name: "logical-backup",
|
|
Image: image,
|
|
Env: vars,
|
|
Ports: []v1.ContainerPort{
|
|
{
|
|
ContainerPort: patroni.ApiPort,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
{
|
|
ContainerPort: pgPort,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
{
|
|
ContainerPort: operatorPort,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
},
|
|
Resources: v1.ResourceRequirements{
|
|
Requests: v1.ResourceList{
|
|
v1.ResourceCPU: resource.MustParse("100m"),
|
|
v1.ResourceMemory: resource.MustParse("100Mi"),
|
|
},
|
|
Limits: v1.ResourceList{
|
|
v1.ResourceCPU: resource.MustParse("100m"),
|
|
v1.ResourceMemory: resource.MustParse("100Mi"),
|
|
},
|
|
},
|
|
SecurityContext: &v1.SecurityContext{
|
|
AllowPrivilegeEscalation: nil,
|
|
Privileged: util.False(),
|
|
ReadOnlyRootFilesystem: util.False(),
|
|
Capabilities: nil,
|
|
},
|
|
VolumeMounts: mounts,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
return cron
|
|
}
|
|
|
|
func TestCompareLogicalBackupJob(t *testing.T) {
|
|
|
|
img1 := "ghcr.io/zalando/postgres-operator/logical-backup:v1.14.0"
|
|
img2 := "ghcr.io/zalando/postgres-operator/logical-backup:v1.15.1"
|
|
|
|
clientSet := fake.NewSimpleClientset()
|
|
acidClientSet := fakeacidv1.NewSimpleClientset()
|
|
namespace := "default"
|
|
|
|
client := k8sutil.KubernetesClient{
|
|
CronJobsGetter: clientSet.BatchV1(),
|
|
PostgresqlsGetter: acidClientSet.AcidV1(),
|
|
}
|
|
pg := acidv1.Postgresql{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "acid-cron-cluster",
|
|
Namespace: namespace,
|
|
},
|
|
Spec: acidv1.PostgresSpec{
|
|
Volume: acidv1.Volume{
|
|
Size: "1Gi",
|
|
},
|
|
EnableLogicalBackup: true,
|
|
LogicalBackupSchedule: "0 0 * * *",
|
|
LogicalBackupRetention: "3 months",
|
|
},
|
|
}
|
|
|
|
var cluster = New(
|
|
Config{
|
|
OpConfig: config.Config{
|
|
PodManagementPolicy: "ordered_ready",
|
|
Resources: config.Resources{
|
|
ClusterLabels: map[string]string{"application": "spilo"},
|
|
ClusterNameLabel: "cluster-name",
|
|
DefaultCPURequest: "300m",
|
|
DefaultCPULimit: "300m",
|
|
DefaultMemoryRequest: "300Mi",
|
|
DefaultMemoryLimit: "300Mi",
|
|
PodRoleLabel: "spilo-role",
|
|
},
|
|
LogicalBackup: config.LogicalBackup{
|
|
LogicalBackupSchedule: "30 00 * * *",
|
|
LogicalBackupDockerImage: img1,
|
|
LogicalBackupJobPrefix: "logical-backup-",
|
|
LogicalBackupCPURequest: "100m",
|
|
LogicalBackupCPULimit: "100m",
|
|
LogicalBackupMemoryRequest: "100Mi",
|
|
LogicalBackupMemoryLimit: "100Mi",
|
|
LogicalBackupProvider: "s3",
|
|
LogicalBackupS3Bucket: "testBucket",
|
|
LogicalBackupS3BucketPrefix: "spilo",
|
|
LogicalBackupS3Region: "eu-central-1",
|
|
LogicalBackupS3Endpoint: "https://s3.amazonaws.com",
|
|
LogicalBackupS3AccessKeyID: "access",
|
|
LogicalBackupS3SecretAccessKey: "secret",
|
|
LogicalBackupS3SSE: "aws:kms",
|
|
LogicalBackupS3RetentionTime: "3 months",
|
|
LogicalBackupCronjobEnvironmentSecret: "",
|
|
},
|
|
},
|
|
}, client, pg, logger, eventRecorder)
|
|
|
|
desiredCronJob, err := cluster.generateLogicalBackupJob()
|
|
if err != nil {
|
|
t.Errorf("Could not generate logical backup job with error: %v", err)
|
|
}
|
|
|
|
err = cluster.createLogicalBackupJob()
|
|
if err != nil {
|
|
t.Errorf("Could not create logical backup job with error: %v", err)
|
|
}
|
|
|
|
currentCronJob, err := cluster.KubeClient.CronJobs(namespace).Get(context.TODO(), cluster.getLogicalBackupJobName(), metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Errorf("Could not create logical backup job with error: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
about string
|
|
cronjob *batchv1.CronJob
|
|
match bool
|
|
reason string
|
|
}{
|
|
{
|
|
about: "two equal cronjobs",
|
|
cronjob: newCronJob(img1, "0 0 * * *", []v1.EnvVar{}, []v1.VolumeMount{}),
|
|
match: true,
|
|
},
|
|
{
|
|
about: "two cronjobs with different image",
|
|
cronjob: newCronJob(img2, "0 0 * * *", []v1.EnvVar{}, []v1.VolumeMount{}),
|
|
match: false,
|
|
reason: fmt.Sprintf("new job's image %q does not match the current one %q", img2, img1),
|
|
},
|
|
{
|
|
about: "two cronjobs with different schedule",
|
|
cronjob: newCronJob(img1, "0 * * * *", []v1.EnvVar{}, []v1.VolumeMount{}),
|
|
match: false,
|
|
reason: fmt.Sprintf("new job's schedule %q does not match the current one %q", "0 * * * *", "0 0 * * *"),
|
|
},
|
|
{
|
|
about: "two cronjobs with empty and nil volume mounts",
|
|
cronjob: newCronJob(img1, "0 0 * * *", []v1.EnvVar{}, nil),
|
|
match: true,
|
|
},
|
|
{
|
|
about: "two cronjobs with different environment variables",
|
|
cronjob: newCronJob(img1, "0 0 * * *", []v1.EnvVar{{Name: "LOGICAL_BACKUP_S3_BUCKET_PREFIX", Value: "logical-backup"}}, []v1.VolumeMount{}),
|
|
match: false,
|
|
reason: "logical backup container specs do not match: new cronjob container's logical-backup (index 0) environment does not match the current one",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.about, func(t *testing.T) {
|
|
desiredCronJob.Spec.Schedule = tt.cronjob.Spec.Schedule
|
|
desiredCronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image = tt.cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Image
|
|
desiredCronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].VolumeMounts = tt.cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].VolumeMounts
|
|
|
|
for _, testEnv := range tt.cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env {
|
|
for i, env := range desiredCronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env {
|
|
if env.Name == testEnv.Name {
|
|
desiredCronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Env[i] = testEnv
|
|
}
|
|
}
|
|
}
|
|
|
|
cmp := cluster.compareLogicalBackupJob(currentCronJob, desiredCronJob)
|
|
if cmp.match != tt.match {
|
|
t.Errorf("%s - unexpected match result %t when comparing cronjobs %#v and %#v", t.Name(), cmp.match, currentCronJob, desiredCronJob)
|
|
} else if !cmp.match {
|
|
found := false
|
|
for _, reason := range cmp.reasons {
|
|
if strings.HasPrefix(reason, tt.reason) {
|
|
found = true
|
|
break
|
|
}
|
|
found = false
|
|
}
|
|
if !found {
|
|
t.Errorf("%s - expected reason prefix %s, not found in %#v", t.Name(), tt.reason, cmp.reasons)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossNamespacedSecrets(t *testing.T) {
|
|
testName := "test secrets in different namespace"
|
|
clientSet := fake.NewSimpleClientset()
|
|
acidClientSet := fakeacidv1.NewSimpleClientset()
|
|
namespace := "default"
|
|
|
|
client := k8sutil.KubernetesClient{
|
|
StatefulSetsGetter: clientSet.AppsV1(),
|
|
ServicesGetter: clientSet.CoreV1(),
|
|
DeploymentsGetter: clientSet.AppsV1(),
|
|
PostgresqlsGetter: acidClientSet.AcidV1(),
|
|
SecretsGetter: clientSet.CoreV1(),
|
|
}
|
|
pg := acidv1.Postgresql{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "acid-fake-cluster",
|
|
Namespace: namespace,
|
|
},
|
|
Spec: acidv1.PostgresSpec{
|
|
Volume: acidv1.Volume{
|
|
Size: "1Gi",
|
|
},
|
|
Users: map[string]acidv1.UserFlags{
|
|
"appspace.db_user": {},
|
|
"db_user": {},
|
|
},
|
|
},
|
|
}
|
|
|
|
var cluster = New(
|
|
Config{
|
|
OpConfig: config.Config{
|
|
ConnectionPooler: config.ConnectionPooler{
|
|
ConnectionPoolerDefaultCPURequest: "100m",
|
|
ConnectionPoolerDefaultCPULimit: "100m",
|
|
ConnectionPoolerDefaultMemoryRequest: "100Mi",
|
|
ConnectionPoolerDefaultMemoryLimit: "100Mi",
|
|
NumberOfInstances: k8sutil.Int32ToPointer(1),
|
|
},
|
|
PodManagementPolicy: "ordered_ready",
|
|
Resources: config.Resources{
|
|
ClusterLabels: map[string]string{"application": "spilo"},
|
|
ClusterNameLabel: "cluster-name",
|
|
DefaultCPURequest: "300m",
|
|
DefaultCPULimit: "300m",
|
|
DefaultMemoryRequest: "300Mi",
|
|
DefaultMemoryLimit: "300Mi",
|
|
PodRoleLabel: "spilo-role",
|
|
},
|
|
EnableCrossNamespaceSecret: true,
|
|
},
|
|
}, client, pg, logger, eventRecorder)
|
|
|
|
userNamespaceMap := map[string]string{
|
|
cluster.Namespace: "db_user",
|
|
"appspace": "appspace.db_user",
|
|
}
|
|
|
|
err := cluster.initRobotUsers()
|
|
if err != nil {
|
|
t.Errorf("Could not create secret for namespaced users with error: %s", err)
|
|
}
|
|
|
|
for _, u := range cluster.pgUsers {
|
|
if u.Name != userNamespaceMap[u.Namespace] {
|
|
t.Errorf("%s: Could not create namespaced user in its correct namespaces for user %s in namespace %s", testName, u.Name, u.Namespace)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidUsernames(t *testing.T) {
|
|
testName := "test username validity"
|
|
|
|
invalidUsernames := []string{"_", ".", ".user", "appspace.", "user_", "_user", "-user", "user-", ",", "-", ",user", "user,", "namespace,user"}
|
|
validUsernames := []string{"user", "appspace.user", "appspace.dot.user", "user_name", "app_space.user_name"}
|
|
for _, username := range invalidUsernames {
|
|
if isValidUsername(username) {
|
|
t.Errorf("%s Invalid username is allowed: %s", testName, username)
|
|
}
|
|
}
|
|
for _, username := range validUsernames {
|
|
if !isValidUsername(username) {
|
|
t.Errorf("%s Valid username is not allowed: %s", testName, username)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComparePorts(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
setA []v1.ContainerPort
|
|
setB []v1.ContainerPort
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "different ports",
|
|
setA: []v1.ContainerPort{
|
|
{
|
|
Name: "metrics",
|
|
ContainerPort: 9187,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
},
|
|
|
|
setB: []v1.ContainerPort{
|
|
{
|
|
Name: "http",
|
|
ContainerPort: 80,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "no difference",
|
|
setA: []v1.ContainerPort{
|
|
{
|
|
Name: "metrics",
|
|
ContainerPort: 9187,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
},
|
|
setB: []v1.ContainerPort{
|
|
{
|
|
Name: "metrics",
|
|
ContainerPort: 9187,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "same ports, different order",
|
|
setA: []v1.ContainerPort{
|
|
{
|
|
Name: "metrics",
|
|
ContainerPort: 9187,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
{
|
|
Name: "http",
|
|
ContainerPort: 80,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
},
|
|
setB: []v1.ContainerPort{
|
|
{
|
|
Name: "http",
|
|
ContainerPort: 80,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
{
|
|
Name: "metrics",
|
|
ContainerPort: 9187,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "same ports, but one with default protocol",
|
|
setA: []v1.ContainerPort{
|
|
{
|
|
Name: "metrics",
|
|
ContainerPort: 9187,
|
|
Protocol: v1.ProtocolTCP,
|
|
},
|
|
},
|
|
setB: []v1.ContainerPort{
|
|
{
|
|
Name: "metrics",
|
|
ContainerPort: 9187,
|
|
},
|
|
},
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
got := comparePorts(testCase.setA, testCase.setB)
|
|
assert.Equal(t, testCase.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompareVolumeMounts(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
mountsA []v1.VolumeMount
|
|
mountsB []v1.VolumeMount
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "empty vs nil",
|
|
mountsA: []v1.VolumeMount{},
|
|
mountsB: nil,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "both empty",
|
|
mountsA: []v1.VolumeMount{},
|
|
mountsB: []v1.VolumeMount{},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "same mounts",
|
|
mountsA: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
},
|
|
mountsB: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "different mounts",
|
|
mountsA: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPathExpr: "$(POD_NAME)",
|
|
},
|
|
},
|
|
mountsB: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "one equal mount one different",
|
|
mountsA: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
{
|
|
Name: "poddata",
|
|
ReadOnly: false,
|
|
MountPath: "/poddata",
|
|
SubPathExpr: "$(POD_NAME)",
|
|
},
|
|
},
|
|
mountsB: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
{
|
|
Name: "etc",
|
|
ReadOnly: true,
|
|
MountPath: "/etc",
|
|
},
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "same mounts, different order",
|
|
mountsA: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
{
|
|
Name: "etc",
|
|
ReadOnly: true,
|
|
MountPath: "/etc",
|
|
},
|
|
},
|
|
mountsB: []v1.VolumeMount{
|
|
{
|
|
Name: "etc",
|
|
ReadOnly: true,
|
|
MountPath: "/etc",
|
|
},
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "new mounts added",
|
|
mountsA: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
},
|
|
mountsB: []v1.VolumeMount{
|
|
{
|
|
Name: "etc",
|
|
ReadOnly: true,
|
|
MountPath: "/etc",
|
|
},
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "one mount removed",
|
|
mountsA: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
{
|
|
Name: "etc",
|
|
ReadOnly: true,
|
|
MountPath: "/etc",
|
|
},
|
|
},
|
|
mountsB: []v1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
ReadOnly: false,
|
|
MountPath: "/data",
|
|
SubPath: "subdir",
|
|
},
|
|
},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := compareVolumeMounts(tt.mountsA, tt.mountsB)
|
|
assert.Equal(t, tt.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetSwitchoverSchedule(t *testing.T) {
|
|
now, _ := time.Parse(time.RFC3339, "2025-11-11T12:35:00Z")
|
|
|
|
futureTimeStart := now.Add(1 * time.Hour)
|
|
futureWindowTimeStart := futureTimeStart.Format("15:04")
|
|
futureWindowTimeEnd := now.Add(2 * time.Hour).Format("15:04")
|
|
pastTimeStart := now.Add(-2 * time.Hour)
|
|
pastWindowTimeStart := pastTimeStart.Format("15:04")
|
|
pastWindowTimeEnd := now.Add(-1 * time.Hour).Format("15:04")
|
|
|
|
defaultWindowStr := fmt.Sprintf("%s-%s", futureWindowTimeStart, futureWindowTimeEnd)
|
|
|
|
tests := []struct {
|
|
name string
|
|
windows []acidv1.MaintenanceWindow
|
|
defaultWindows []string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "everyday maintenance windows is later today",
|
|
windows: []acidv1.MaintenanceWindow{
|
|
{
|
|
Everyday: true,
|
|
StartTime: mustParseTime(futureWindowTimeStart),
|
|
EndTime: mustParseTime(futureWindowTimeEnd),
|
|
},
|
|
},
|
|
expected: futureTimeStart.Format("2006-01-02T15:04+00"),
|
|
},
|
|
{
|
|
name: "everyday maintenance window is tomorrow",
|
|
windows: []acidv1.MaintenanceWindow{
|
|
{
|
|
Everyday: true,
|
|
StartTime: mustParseTime(pastWindowTimeStart),
|
|
EndTime: mustParseTime(pastWindowTimeEnd),
|
|
},
|
|
},
|
|
expected: pastTimeStart.AddDate(0, 0, 1).Format("2006-01-02T15:04+00"),
|
|
},
|
|
{
|
|
name: "weekday maintenance windows is later today",
|
|
windows: []acidv1.MaintenanceWindow{
|
|
{
|
|
Weekday: now.Weekday(),
|
|
StartTime: mustParseTime(futureWindowTimeStart),
|
|
EndTime: mustParseTime(futureWindowTimeEnd),
|
|
},
|
|
},
|
|
expected: futureTimeStart.Format("2006-01-02T15:04+00"),
|
|
},
|
|
{
|
|
name: "weekday maintenance windows is passed for today",
|
|
windows: []acidv1.MaintenanceWindow{
|
|
{
|
|
Weekday: now.Weekday(),
|
|
StartTime: mustParseTime(pastWindowTimeStart),
|
|
EndTime: mustParseTime(pastWindowTimeEnd),
|
|
},
|
|
},
|
|
expected: pastTimeStart.AddDate(0, 0, 7).Format("2006-01-02T15:04+00"),
|
|
},
|
|
{
|
|
name: "choose the earliest window",
|
|
windows: []acidv1.MaintenanceWindow{
|
|
{
|
|
Weekday: now.AddDate(0, 0, 2).Weekday(),
|
|
StartTime: mustParseTime(futureWindowTimeStart),
|
|
EndTime: mustParseTime(futureWindowTimeEnd),
|
|
},
|
|
{
|
|
Everyday: true,
|
|
StartTime: mustParseTime(pastWindowTimeStart),
|
|
EndTime: mustParseTime(pastWindowTimeEnd),
|
|
},
|
|
},
|
|
expected: pastTimeStart.AddDate(0, 0, 1).Format("2006-01-02T15:04+00"),
|
|
},
|
|
{
|
|
name: "fallback to operator default window when spec is empty",
|
|
windows: []acidv1.MaintenanceWindow{},
|
|
defaultWindows: []string{defaultWindowStr},
|
|
expected: futureTimeStart.Format("2006-01-02T15:04+00"),
|
|
},
|
|
{
|
|
name: "no windows defined returns empty string",
|
|
windows: []acidv1.MaintenanceWindow{},
|
|
defaultWindows: nil,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "choose the earliest window from multiple in spec",
|
|
windows: []acidv1.MaintenanceWindow{
|
|
{
|
|
Weekday: now.AddDate(0, 0, 2).Weekday(),
|
|
StartTime: mustParseTime(futureWindowTimeStart),
|
|
EndTime: mustParseTime(futureWindowTimeEnd),
|
|
},
|
|
{
|
|
Weekday: now.AddDate(0, 0, 1).Weekday(),
|
|
StartTime: mustParseTime(pastWindowTimeStart),
|
|
EndTime: mustParseTime(pastWindowTimeEnd),
|
|
},
|
|
},
|
|
expected: pastTimeStart.AddDate(0, 0, 1).Format("2006-01-02T15:04+00"),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cluster.Spec.MaintenanceWindows = tt.windows
|
|
cluster.OpConfig.MaintenanceWindows = tt.defaultWindows
|
|
schedule := cluster.getSwitchoverScheduleAtTime(now)
|
|
if schedule != tt.expected {
|
|
t.Errorf("Expected GetSwitchoverSchedule to return %s, returned: %s", tt.expected, schedule)
|
|
}
|
|
})
|
|
}
|
|
}
|