Skip to content

Commit 0baa91d

Browse files
authored
feat(backup): validate extra_volumes paths during database creation and update (#44)
2 parents 055b0ec + 900c31a commit 0baa91d

File tree

13 files changed

+357
-23
lines changed

13 files changed

+357
-23
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Added
2+
body: Enabled volume validation during database creation and update to verify that specified extra_volumes paths exist and are accessible.
3+
time: 2025-06-06T18:08:46.195576+05:30

server/internal/api/service.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"net/url"
9+
"strings"
910

1011
"github.com/google/uuid"
1112
"github.com/rs/zerolog"
@@ -171,6 +172,16 @@ func (s *Service) CreateDatabase(ctx context.Context, req *api.CreateDatabaseReq
171172
return nil, api.MakeInvalidInput(err)
172173
}
173174

175+
err = s.dbSvc.PopulateSpecDefaults(ctx, spec)
176+
if err != nil {
177+
return nil, api.MakeInvalidInput(fmt.Errorf("failed to validate database spec: %w", err))
178+
}
179+
180+
err = s.ValidateSpec(ctx, spec)
181+
if err != nil {
182+
return nil, api.MakeInvalidInput(fmt.Errorf("%w", err))
183+
}
184+
174185
db, err := s.dbSvc.CreateDatabase(ctx, spec)
175186
if errors.Is(err, database.ErrDatabaseAlreadyExists) {
176187
return nil, api.MakeDatabaseAlreadyExists(err)
@@ -211,6 +222,16 @@ func (s *Service) UpdateDatabase(ctx context.Context, req *api.UpdateDatabasePay
211222
return nil, api.MakeInvalidInput(err)
212223
}
213224

225+
err = s.dbSvc.PopulateSpecDefaults(ctx, spec)
226+
if err != nil {
227+
return nil, api.MakeInvalidInput(fmt.Errorf("failed to validate database spec: %w", err))
228+
}
229+
230+
err = s.ValidateSpec(ctx, spec)
231+
if err != nil {
232+
return nil, api.MakeInvalidInput(fmt.Errorf("%w", err))
233+
}
234+
214235
db, err := s.dbSvc.UpdateDatabase(ctx, database.DatabaseStateModifying, spec)
215236
if errors.Is(err, database.ErrDatabaseNotFound) {
216237
return nil, api.MakeNotFound(fmt.Errorf("database %s not found", *req.DatabaseID))
@@ -424,6 +445,12 @@ func (s *Service) RestoreDatabase(ctx context.Context, req *api.RestoreDatabaseP
424445
// Remove backup configuration from nodes that are being restored and
425446
// persist the updated spec.
426447
db.Spec.RemoveBackupConfigFrom(targetNodes...)
448+
449+
err = s.dbSvc.PopulateSpecDefaults(ctx, db.Spec)
450+
if err != nil {
451+
return nil, api.MakeInvalidInput(fmt.Errorf("failed to validate database spec: %w", err))
452+
}
453+
427454
db, err = s.dbSvc.UpdateDatabase(ctx, database.DatabaseStateRestoring, db.Spec)
428455
if err != nil {
429456
return nil, fmt.Errorf("failed to persist db spec updates: %w", err)
@@ -475,3 +502,24 @@ func (s *Service) InitCluster(ctx context.Context) (*api.ClusterJoinToken, error
475502
func (s *Service) JoinCluster(ctx context.Context, token *api.ClusterJoinToken) error {
476503
return ErrAlreadyInitialized
477504
}
505+
506+
func (s *Service) ValidateSpec(ctx context.Context, spec *database.Spec) error {
507+
if spec == nil {
508+
return errors.New("spec cannot be nil")
509+
}
510+
511+
output := s.workflowSvc.ValidateSpec(ctx, spec)
512+
if output == nil {
513+
return errors.New("failed to validate spec")
514+
515+
}
516+
if !output.Valid {
517+
return fmt.Errorf(
518+
"spec validation failed. Please ensure all required fields in the provided spec are valid.\nDetails: %s",
519+
strings.Join(output.Errors, " "),
520+
)
521+
}
522+
s.logger.Info().Msg("Spec validation succeeded")
523+
524+
return nil
525+
}

server/internal/database/orchestrator.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ type InstanceResources struct {
1919
Resources []*resource.ResourceData
2020
}
2121

22+
type ValidationResult struct {
23+
Success bool
24+
Reason string
25+
}
26+
2227
func NewInstanceResources(instance *InstanceResource, resources []resource.Resource) (*InstanceResources, error) {
2328
data := make([]*resource.ResourceData, len(resources))
2429
for i, res := range resources {
@@ -83,4 +88,5 @@ type Orchestrator interface {
8388
GenerateInstanceRestoreResources(spec *InstanceSpec, taskID uuid.UUID) (*InstanceResources, error)
8489
GetInstanceConnectionInfo(ctx context.Context, databaseID, instanceID uuid.UUID) (*ConnectionInfo, error)
8590
CreatePgBackRestBackup(ctx context.Context, w io.Writer, instanceID uuid.UUID, options *pgbackrest.BackupOptions) error
91+
ValidateVolumes(ctx context.Context, spec *InstanceSpec) (*ValidationResult, error)
8692
}

server/internal/database/service.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ func (s *Service) CreateDatabase(ctx context.Context, spec *Spec) (*Database, er
4242
return nil, ErrDatabaseAlreadyExists
4343
}
4444

45-
if err := s.populateSpecDefaults(ctx, spec); err != nil {
46-
return nil, fmt.Errorf("failed to validate database spec: %w", err)
47-
}
48-
4945
now := time.Now()
5046
db := &Database{
5147
DatabaseID: spec.DatabaseID,
@@ -87,9 +83,6 @@ func (s *Service) UpdateDatabase(ctx context.Context, state DatabaseState, spec
8783
if !DatabaseStateModifiable(currentDB.State) {
8884
return nil, ErrDatabaseNotModifiable
8985
}
90-
if err := s.populateSpecDefaults(ctx, spec); err != nil {
91-
return nil, fmt.Errorf("failed to validate database spec: %w", err)
92-
}
9386

9487
instances, err := s.GetInstances(ctx, spec.DatabaseID)
9588
if err != nil {
@@ -314,7 +307,7 @@ func (s *Service) GetAllInstances(ctx context.Context) ([]*Instance, error) {
314307
return instances, nil
315308
}
316309

317-
func (s *Service) populateSpecDefaults(ctx context.Context, spec *Spec) error {
310+
func (s *Service) PopulateSpecDefaults(ctx context.Context, spec *Spec) error {
318311
var hostIDs []uuid.UUID
319312
// First pass to build out hostID list
320313
for _, node := range spec.Nodes {

server/internal/database/spec.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,10 @@ func (s *Spec) NodeInstances() ([]*NodeInstances, error) {
360360
// Create a merged PostgreSQL configuration with node-level overrides
361361
postgresqlConf := maps.Clone(s.PostgreSQLConf)
362362
maps.Copy(node.PostgreSQLConf, postgresqlConf)
363-
extrernalVolumes := slices.Clone(s.ExtraVolumes)
363+
extraVolumes := s.ExtraVolumes
364+
if len(node.ExtraVolumes) > 0 {
365+
extraVolumes = node.ExtraVolumes
366+
}
364367

365368
instances := make([]*InstanceSpec, len(node.HostIDs))
366369
for hostIdx, hostID := range node.HostIDs {
@@ -388,7 +391,7 @@ func (s *Spec) NodeInstances() ([]*NodeInstances, error) {
388391
// cluster into this decision when we implement updates.
389392
EnableBackups: backupConfig != nil && hostIdx == len(node.HostIDs)-1,
390393
ClusterSize: clusterSize,
391-
ExtraVolumes: extrernalVolumes,
394+
ExtraVolumes: extraVolumes,
392395
}
393396
}
394397

server/internal/docker/docker.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/docker/docker/api/types"
1616
"github.com/docker/docker/api/types/container"
1717
"github.com/docker/docker/api/types/filters"
18+
"github.com/docker/docker/api/types/mount"
1819
"github.com/docker/docker/api/types/network"
1920
"github.com/docker/docker/api/types/swarm"
2021
"github.com/docker/docker/api/types/system"
@@ -139,6 +140,14 @@ func (d *Docker) ContainerRemove(ctx context.Context, containerID string, opts c
139140
return nil
140141
}
141142

143+
func (d *Docker) ContainerStop(ctx context.Context, containerID string, timeoutSeconds *int) error {
144+
err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: timeoutSeconds})
145+
if err != nil {
146+
return fmt.Errorf("failed to stop container: %w", errTranslate(err))
147+
}
148+
return nil
149+
}
150+
142151
func (d *Docker) Info(ctx context.Context) (system.Info, error) {
143152
info, err := d.client.Info(ctx)
144153
if err != nil {
@@ -480,3 +489,12 @@ func errTranslate(err error) error {
480489
}
481490
return err
482491
}
492+
493+
func BuildMount(source, target string, readOnly bool) mount.Mount {
494+
return mount.Mount{
495+
Type: mount.TypeBind,
496+
Source: source,
497+
Target: target,
498+
ReadOnly: readOnly,
499+
}
500+
}

server/internal/orchestrator/swarm/orchestrator.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package swarm
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"io"
78
"net/netip"
89
"path"
910
"path/filepath"
1011
"strconv"
12+
"strings"
13+
"time"
1114

1215
"github.com/cschleiden/go-workflows/workflow"
16+
"github.com/docker/docker/api/types/container"
17+
"github.com/docker/docker/api/types/mount"
1318
"github.com/docker/docker/api/types/network"
1419
"github.com/docker/docker/api/types/swarm"
1520
"github.com/docker/go-connections/nat"
@@ -436,3 +441,79 @@ func (o *Orchestrator) CreatePgBackRestBackup(ctx context.Context, w io.Writer,
436441

437442
return nil
438443
}
444+
445+
func (o *Orchestrator) ValidateVolumes(ctx context.Context, spec *database.InstanceSpec) (*database.ValidationResult, error) {
446+
specVersion := spec.PgEdgeVersion
447+
if specVersion == nil {
448+
o.logger.Warn().Msg("PostgresVersion not provided, using default version")
449+
specVersion = defaultVersion
450+
}
451+
452+
images, err := GetImages(o.cfg, specVersion)
453+
if err != nil {
454+
return nil, fmt.Errorf("image fetch error: %w", err)
455+
}
456+
457+
var mounts []mount.Mount
458+
var mountTargets []string
459+
for _, vol := range spec.ExtraVolumes {
460+
mounts = append(mounts, docker.BuildMount(vol.HostPath, vol.DestinationPath, false))
461+
mountTargets = append(mountTargets, vol.DestinationPath)
462+
}
463+
464+
cmd := buildVolumeCheckCommand(mountTargets)
465+
output, err := o.runVolumeValidationContainer(ctx, images.PgEdgeImage, cmd, mounts)
466+
if err != nil {
467+
return nil, err
468+
}
469+
470+
if strings.HasSuffix(output, "OK") {
471+
return &database.ValidationResult{Success: true, Reason: "All volumes are valid"}, nil
472+
}
473+
return &database.ValidationResult{Success: false, Reason: output}, nil
474+
}
475+
476+
func (o *Orchestrator) runVolumeValidationContainer(ctx context.Context, image string, cmd []string, mounts []mount.Mount) (string, error) {
477+
// Start container
478+
containerID, err := o.docker.ContainerRun(ctx, docker.ContainerRunOptions{
479+
Config: &container.Config{
480+
Image: image,
481+
Entrypoint: cmd,
482+
},
483+
Host: &container.HostConfig{
484+
Mounts: mounts,
485+
},
486+
})
487+
if err != nil {
488+
return "", fmt.Errorf("failed to start container: %w", err)
489+
}
490+
// Ensure container is removed afterward
491+
defer func() {
492+
if err := o.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {
493+
o.logger.Error().Err(err).Msg("container cleanup failed")
494+
}
495+
}()
496+
497+
// Wait for the container to complete
498+
if err := o.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning, 30*time.Second); err != nil {
499+
return "", fmt.Errorf("container wait failed: %w", err)
500+
}
501+
502+
// Capture logs
503+
buf := new(bytes.Buffer)
504+
if err := o.docker.ContainerLogs(ctx, buf, containerID, container.LogsOptions{ShowStdout: true}); err != nil {
505+
return "", fmt.Errorf("log fetch failed: %w", err)
506+
}
507+
508+
// Stop the container gracefully
509+
timeoutSeconds := 5
510+
if err := o.docker.ContainerStop(ctx, containerID, &timeoutSeconds); err != nil {
511+
o.logger.Warn().Err(err).Msg("graceful stop failed")
512+
}
513+
514+
return strings.TrimSpace(buf.String()), nil
515+
}
516+
517+
func buildVolumeCheckCommand(mountTargets []string) []string {
518+
return []string{"sh", "-c", fmt.Sprintf("for d in %s; do [ -d \"$d\" ] || { echo \"FAIL: $d not found\"; exit 1; }; done; echo OK", strings.Join(mountTargets, " "))}
519+
}

server/internal/orchestrator/swarm/spec.go

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/docker/docker/api/types/swarm"
99

1010
"github.com/pgEdge/control-plane/server/internal/database"
11+
"github.com/pgEdge/control-plane/server/internal/docker"
1112
)
1213

1314
type Paths struct {
@@ -41,16 +42,16 @@ func DatabaseServiceSpec(
4142
}
4243

4344
mounts := []mount.Mount{
44-
buildMount(options.Paths.Configs, "/opt/pgedge/configs", true),
45+
docker.BuildMount(options.Paths.Configs, "/opt/pgedge/configs", true),
4546
// We're using a mount for the certificates instead of
4647
// a secret because secrets can't be rotated without
4748
// restarting the container.
48-
buildMount(options.Paths.Certificates, "/opt/pgedge/certificates", true),
49-
buildMount(options.Paths.Data, "/opt/pgedge/data", false),
49+
docker.BuildMount(options.Paths.Certificates, "/opt/pgedge/certificates", true),
50+
docker.BuildMount(options.Paths.Data, "/opt/pgedge/data", false),
5051
}
5152

5253
for _, vol := range instance.ExtraVolumes {
53-
mounts = append(mounts, buildMount(vol.HostPath, vol.DestinationPath, false))
54+
mounts = append(mounts, docker.BuildMount(vol.HostPath, vol.DestinationPath, false))
5455
}
5556

5657
return swarm.ServiceSpec{
@@ -119,12 +120,3 @@ func DatabaseServiceSpec(
119120
},
120121
}, nil
121122
}
122-
123-
func buildMount(source, target string, readOnly bool) mount.Mount {
124-
return mount.Mount{
125-
Type: mount.TypeBind,
126-
Source: source,
127-
Target: target,
128-
ReadOnly: readOnly,
129-
}
130-
}

server/internal/workflows/activities/activities.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func (a *Activities) Register(work *worker.Worker) error {
3434
work.RegisterActivity(a.RestoreSpec),
3535
work.RegisterActivity(a.UpdateDbState),
3636
work.RegisterActivity(a.UpdateTask),
37+
work.RegisterActivity(a.ValidateVolumes),
3738
}
3839
return errors.Join(errs...)
3940
}

0 commit comments

Comments
 (0)