Skip to content

Commit 4bdbc6d

Browse files
authored
Postgres Restore (#306)
1 parent fa2e82b commit 4bdbc6d

File tree

6 files changed

+268
-23
lines changed

6 files changed

+268
-23
lines changed

api/v1/postgres_types.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"reflect"
1212
"strconv"
1313
"strings"
14+
"time"
1415

1516
"regexp"
1617

@@ -155,6 +156,9 @@ type PostgresSpec struct {
155156
// BackupSecretRef reference to the secret where the backup credentials are stored
156157
BackupSecretRef string `json:"backupSecretRef,omitempty"`
157158

159+
// PostgresRestore
160+
PostgresRestore *PostgresRestore `json:"restore,omitempty"`
161+
158162
// PostgresConnection Connection info of a streaming host, independant of the current role (leader or standby)
159163
PostgresConnection *PostgresConnection `json:"connection,omitempty"`
160164

@@ -187,6 +191,14 @@ type Size struct {
187191
StorageSize string `json:"storageSize,omitempty"`
188192
}
189193

194+
// Restore defines what to restore from where
195+
type PostgresRestore struct {
196+
// SourcePostgresID internal ID of the Postgres instance to whose backup to restore
197+
SourcePostgresID string `json:"postgresID,omitempty"`
198+
// Timestamp The point in time to recover. Must be set, or the clone with switch from WALs from the S3 to a basebackup via direct sql connection (which won't work when the source db is managed by another posgres-operator)
199+
Timestamp string `json:"timestamp,omitempty"`
200+
}
201+
190202
// PostgresStatus defines the observed state of Postgres
191203
type PostgresStatus struct {
192204
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
@@ -463,7 +475,7 @@ func (p *Postgres) ToPeripheralResourceLookupKey() types.NamespacedName {
463475
}
464476
}
465477

466-
func (p *Postgres) ToUnstructuredZalandoPostgresql(z *zalando.Postgresql, c *corev1.ConfigMap, sc string, pgParamBlockList map[string]bool) (*unstructured.Unstructured, error) {
478+
func (p *Postgres) ToUnstructuredZalandoPostgresql(z *zalando.Postgresql, c *corev1.ConfigMap, sc string, pgParamBlockList map[string]bool, rbs *BackupConfig, srcDB *Postgres) (*unstructured.Unstructured, error) {
467479
if z == nil {
468480
z = &zalando.Postgresql{}
469481
}
@@ -526,8 +538,8 @@ func (p *Postgres) ToUnstructuredZalandoPostgresql(z *zalando.Postgresql, c *cor
526538
"pgcrypto": "public",
527539
},
528540
PreparedSchemas: map[string]zalando.PreparedSchema{
529-
"data": zalando.PreparedSchema{},
530-
"history": zalando.PreparedSchema{},
541+
"data": {},
542+
"history": {},
531543
},
532544
}
533545

@@ -541,6 +553,23 @@ func (p *Postgres) ToUnstructuredZalandoPostgresql(z *zalando.Postgresql, c *cor
541553
z.Spec.AllowedSourceRanges = p.Spec.AccessList.SourceRanges
542554
}
543555

556+
if p.Spec.PostgresRestore != nil && rbs != nil && srcDB != nil {
557+
// make sure there is always a value set. The operator will fall back to CLONE_WITH_BASEBACKUP, which assumes the source db's credentials are existing within the same namespace, which is not the case with the postgreslet.
558+
if p.Spec.PostgresRestore.Timestamp == "" {
559+
// e.g. 2021-12-07T15:28:00+01:00
560+
p.Spec.PostgresRestore.Timestamp = time.Now().Format(time.RFC3339)
561+
}
562+
563+
z.Spec.Clone = &zalando.CloneDescription{
564+
ClusterName: srcDB.ToPeripheralResourceName(),
565+
EndTimestamp: p.Spec.PostgresRestore.Timestamp,
566+
S3Endpoint: rbs.S3Endpoint,
567+
S3AccessKeyId: rbs.S3AccessKey,
568+
S3SecretAccessKey: rbs.S3SecretKey,
569+
S3ForcePathStyle: pointer.Bool(true),
570+
}
571+
}
572+
544573
// Enable replication (using unstructured json)
545574
if p.IsReplicationPrimary() {
546575
// delete field

api/v1/postgres_types_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ package v1
99
import (
1010
"regexp"
1111
"testing"
12+
"time"
1213

1314
"github.com/google/uuid"
15+
corev1 "k8s.io/api/core/v1"
1416
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17+
"k8s.io/apimachinery/pkg/runtime"
1518
"k8s.io/apimachinery/pkg/types"
1619
)
1720

@@ -216,3 +219,148 @@ func TestPostgres_ToPeripheralResourceName(t *testing.T) {
216219
})
217220
}
218221
}
222+
223+
func TestPostgresRestoreTimestamp_ToUnstructuredZalandoPostgresql(t *testing.T) {
224+
tests := []struct {
225+
name string
226+
spec PostgresSpec
227+
c *corev1.ConfigMap
228+
sc string
229+
pgParamBlockList map[string]bool
230+
rbs *BackupConfig
231+
srcDB *Postgres
232+
want string
233+
wantErr bool
234+
}{
235+
{
236+
name: "empty timestamp initialized with current time",
237+
spec: PostgresSpec{
238+
Size: &Size{
239+
CPU: "1",
240+
Memory: "4Gi",
241+
SharedBuffer: "64Mi",
242+
},
243+
PostgresRestore: &PostgresRestore{
244+
Timestamp: "",
245+
},
246+
},
247+
c: nil,
248+
sc: "fake-storage-class",
249+
pgParamBlockList: map[string]bool{},
250+
rbs: &BackupConfig{},
251+
srcDB: &Postgres{
252+
ObjectMeta: v1.ObjectMeta{
253+
Name: uuid.NewString(),
254+
},
255+
Spec: PostgresSpec{
256+
Tenant: "tenant",
257+
Description: "description",
258+
},
259+
},
260+
want: time.Now().Format(time.RFC3339), // I know this is not perfect, let's just hope we always finish within the same second...
261+
wantErr: false,
262+
},
263+
{
264+
name: "undefined timestamp initialized with current time",
265+
spec: PostgresSpec{
266+
Size: &Size{
267+
CPU: "1",
268+
Memory: "4Gi",
269+
SharedBuffer: "64Mi",
270+
},
271+
PostgresRestore: &PostgresRestore{},
272+
},
273+
c: nil,
274+
sc: "fake-storage-class",
275+
pgParamBlockList: map[string]bool{},
276+
rbs: &BackupConfig{},
277+
srcDB: &Postgres{
278+
ObjectMeta: v1.ObjectMeta{
279+
Name: uuid.NewString(),
280+
},
281+
Spec: PostgresSpec{
282+
Tenant: "tenant",
283+
Description: "description",
284+
},
285+
},
286+
want: time.Now().Format(time.RFC3339), // I know this is not perfect, let's just hope we always finish within the same second...
287+
wantErr: false,
288+
},
289+
{
290+
name: "given timestamp is passed along",
291+
spec: PostgresSpec{
292+
Size: &Size{
293+
CPU: "1",
294+
Memory: "4Gi",
295+
SharedBuffer: "64Mi",
296+
},
297+
PostgresRestore: &PostgresRestore{
298+
Timestamp: "invalid but whatever",
299+
},
300+
},
301+
c: nil,
302+
sc: "fake-storage-class",
303+
pgParamBlockList: map[string]bool{},
304+
rbs: &BackupConfig{},
305+
srcDB: &Postgres{
306+
ObjectMeta: v1.ObjectMeta{
307+
Name: uuid.NewString(),
308+
},
309+
Spec: PostgresSpec{
310+
Tenant: "tenant",
311+
Description: "description",
312+
},
313+
},
314+
want: "invalid but whatever",
315+
wantErr: false,
316+
},
317+
{
318+
name: "fail on purpose",
319+
spec: PostgresSpec{
320+
Size: &Size{
321+
CPU: "1",
322+
Memory: "4Gi",
323+
SharedBuffer: "64Mi",
324+
},
325+
PostgresRestore: &PostgresRestore{
326+
Timestamp: "apples",
327+
},
328+
},
329+
c: nil,
330+
sc: "fake-storage-class",
331+
pgParamBlockList: map[string]bool{},
332+
rbs: &BackupConfig{},
333+
srcDB: &Postgres{
334+
ObjectMeta: v1.ObjectMeta{
335+
Name: uuid.NewString(),
336+
},
337+
Spec: PostgresSpec{
338+
Tenant: "tenant",
339+
Description: "description",
340+
},
341+
},
342+
want: "oranges",
343+
wantErr: true,
344+
},
345+
}
346+
for _, tt := range tests {
347+
tt := tt // pin!
348+
t.Run(tt.name, func(t *testing.T) {
349+
p := &Postgres{
350+
Spec: tt.spec,
351+
}
352+
got, _ := p.ToUnstructuredZalandoPostgresql(nil, tt.c, tt.sc, tt.pgParamBlockList, tt.rbs, tt.srcDB)
353+
354+
jsonZ, err := runtime.DefaultUnstructuredConverter.ToUnstructured(got)
355+
if err != nil {
356+
t.Errorf("failed to convert to unstructured zalando postgresql: %v", err)
357+
}
358+
jsonSpec, _ := jsonZ["spec"].(map[string]interface{})
359+
jsonClone, _ := jsonSpec["clone"].(map[string]interface{})
360+
361+
if !tt.wantErr && tt.want != jsonClone["timestamp"] {
362+
t.Errorf("Spec.Clone.Timestamp was %v, but expected %v", jsonClone["timestamp"], tt.want)
363+
}
364+
})
365+
}
366+
}

api/v1/zz_generated.deepcopy.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/database.fits.cloud_postgres.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,20 @@ spec:
126126
projectID:
127127
description: ProjectID metal project ID
128128
type: string
129+
restore:
130+
description: PostgresRestore
131+
properties:
132+
postgresID:
133+
description: SourcePostgresID internal ID of the Postgres instance
134+
to whose backup to restore
135+
type: string
136+
timestamp:
137+
description: Timestamp The point in time to recover. Must be set,
138+
or the clone with switch from WALs from the S3 to a basebackup
139+
via direct sql connection (which won't work when the source
140+
db is managed by another posgres-operator)
141+
type: string
142+
type: object
129143
size:
130144
description: Size of the database
131145
properties:

controllers/postgres_controller.go

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,32 @@ func (r *PostgresReconciler) createOrUpdateZalandoPostgresql(ctx context.Context
239239
c = nil
240240
}
241241

242+
var restoreBackupConfig *pg.BackupConfig
243+
var restoreSouceInstance *pg.Postgres
244+
if instance.Spec.PostgresRestore != nil {
245+
if instance.Spec.PostgresRestore.SourcePostgresID == "" {
246+
return fmt.Errorf("restore requested, but no source configured")
247+
}
248+
srcNs := types.NamespacedName{
249+
Namespace: instance.Namespace,
250+
Name: instance.Spec.PostgresRestore.SourcePostgresID,
251+
}
252+
src := &pg.Postgres{}
253+
if err := r.CtrlClient.Get(ctx, srcNs, src); err != nil {
254+
r.recorder.Eventf(instance, "Warning", "Error", "failed to get resource: %v", err)
255+
return err
256+
}
257+
log.Info("source for restore fetched", "postgres", instance)
258+
259+
bc, err := r.getBackupConfig(ctx, instance.Namespace, src.Spec.BackupSecretRef)
260+
if err != nil {
261+
return err
262+
}
263+
264+
restoreBackupConfig = bc
265+
restoreSouceInstance = src
266+
}
267+
242268
// Get zalando postgresql and create one if none.
243269
rawZ, err := r.getZalandoPostgresql(ctx, instance)
244270
if err != nil {
@@ -247,7 +273,7 @@ func (r *PostgresReconciler) createOrUpdateZalandoPostgresql(ctx context.Context
247273
return fmt.Errorf("failed to fetch zalando postgresql: %w", err)
248274
}
249275

250-
u, err := instance.ToUnstructuredZalandoPostgresql(nil, c, r.StorageClass, r.PgParamBlockList)
276+
u, err := instance.ToUnstructuredZalandoPostgresql(nil, c, r.StorageClass, r.PgParamBlockList, restoreBackupConfig, restoreSouceInstance)
251277
if err != nil {
252278
return fmt.Errorf("failed to convert to unstructured zalando postgresql: %w", err)
253279
}
@@ -263,7 +289,7 @@ func (r *PostgresReconciler) createOrUpdateZalandoPostgresql(ctx context.Context
263289
// Update zalando postgresql
264290
mergeFrom := client.MergeFrom(rawZ.DeepCopy())
265291

266-
u, err := instance.ToUnstructuredZalandoPostgresql(rawZ, c, r.StorageClass, r.PgParamBlockList)
292+
u, err := instance.ToUnstructuredZalandoPostgresql(rawZ, c, r.StorageClass, r.PgParamBlockList, restoreBackupConfig, restoreSouceInstance)
267293
if err != nil {
268294
return fmt.Errorf("failed to convert to unstructured zalando postgresql: %w", err)
269295
}
@@ -316,24 +342,9 @@ func (r *PostgresReconciler) updatePodEnvironmentConfigMap(ctx context.Context,
316342
return nil
317343
}
318344

319-
// fetch secret
320-
backupSecret := &corev1.Secret{}
321-
backupNamespace := types.NamespacedName{
322-
Name: p.Spec.BackupSecretRef,
323-
Namespace: p.Namespace,
324-
}
325-
if err := r.CtrlClient.Get(ctx, backupNamespace, backupSecret); err != nil {
326-
return fmt.Errorf("error while getting the backup secret from control plane cluster: %w", err)
327-
}
328-
329-
backupConfigJSON, ok := backupSecret.Data[pg.BackupConfigKey]
330-
if !ok {
331-
return fmt.Errorf("no backupConfig stored in the secret")
332-
}
333-
var backupConfig pg.BackupConfig
334-
err := json.Unmarshal(backupConfigJSON, &backupConfig)
345+
backupConfig, err := r.getBackupConfig(ctx, p.Namespace, p.Spec.BackupSecretRef)
335346
if err != nil {
336-
return fmt.Errorf("unable to unmarshal backupconfig:%w", err)
347+
return err
337348
}
338349

339350
s3url, err := url.Parse(backupConfig.S3Endpoint)
@@ -714,3 +725,26 @@ func (r *PostgresReconciler) updatePatroniConfig(ctx context.Context, instance *
714725

715726
return nil
716727
}
728+
729+
func (r *PostgresReconciler) getBackupConfig(ctx context.Context, ns, name string) (*pg.BackupConfig, error) {
730+
// fetch secret
731+
backupSecret := &corev1.Secret{}
732+
backupNamespace := types.NamespacedName{
733+
Name: name,
734+
Namespace: ns,
735+
}
736+
if err := r.CtrlClient.Get(ctx, backupNamespace, backupSecret); err != nil {
737+
return nil, fmt.Errorf("error while getting the backup secret from control plane cluster: %w", err)
738+
}
739+
740+
backupConfigJSON, ok := backupSecret.Data[pg.BackupConfigKey]
741+
if !ok {
742+
return nil, fmt.Errorf("no backupConfig stored in the secret")
743+
}
744+
var backupConfig pg.BackupConfig
745+
err := json.Unmarshal(backupConfigJSON, &backupConfig)
746+
if err != nil {
747+
return nil, fmt.Errorf("unable to unmarshal backupconfig:%w", err)
748+
}
749+
return &backupConfig, nil
750+
}

0 commit comments

Comments
 (0)