From 0b57bd830f635e8dea38c49c247f9fda468cf85c Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Mon, 7 Sep 2026 15:19:50 +0200 Subject: [PATCH 1/3] persist configuration across terraform interrupts --- cmd/command/up/up.go | 32 +++++++--- cmd/command/up/up_test.go | 117 +++++++++++++++++++++++++++++++++++++ pkg/manifest/types.go | 71 ++++++++++++----------- pkg/up/deploy.go | 7 ++- pkg/up/deploy_test.go | 119 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 43 deletions(-) create mode 100644 cmd/command/up/up_test.go create mode 100644 pkg/up/deploy_test.go diff --git a/cmd/command/up/up.go b/cmd/command/up/up.go index 3cfcbc8a9..5823a0c9f 100644 --- a/cmd/command/up/up.go +++ b/cmd/command/up/up.go @@ -238,6 +238,13 @@ func (p *Plural) choseCluster() (name, url string, err error) { return } +func appDomainAlreadyConfigured(project *manifest.ProjectManifest) bool { + if project == nil { + return false + } + return project.AppDomainConfigured || project.AppDomain != "" +} + func askAppDomain(project *manifest.ProjectManifest) error { skip, ok := utils.GetEnvBoolValue("PLURAL_UP_SKIP_APP_DOMAIN") if ok && skip { @@ -248,6 +255,11 @@ func askAppDomain(project *manifest.ProjectManifest) error { return fmt.Errorf("project manifest is required to set app domain") } + if appDomainAlreadyConfigured(project) { + utils.Highlight("App domain already configured, skipping...\n") + return nil + } + var domain string switch project.Provider { @@ -256,7 +268,7 @@ func askAppDomain(project *manifest.ProjectManifest) error { if err != nil { utils.Error("Failed to fetch hosted zones from AWS: %s\n", err) fmt.Println("ignoring domain setup...") - break + return nil } if err := survey.AskOne( @@ -274,12 +286,12 @@ func askAppDomain(project *manifest.ProjectManifest) error { if err != nil { utils.Error("Failed to fetch DNS zones from Azure: %s\n", err) fmt.Println("ignoring domain setup...") - break + return nil } // Skip domain setup if no DNS zones exist in the resource group. if len(dnsZones) == 0 { - break + return nil } if err := survey.AskOne( @@ -309,10 +321,16 @@ func askAppDomain(project *manifest.ProjectManifest) error { return processAppDomain(domain, project) } +func persistAppDomain(project *manifest.ProjectManifest, domain string) error { + project.AppDomain = domain + project.AppDomainConfigured = true + return project.Flush() +} + func processAppDomain(domain string, project *manifest.ProjectManifest) error { if lo.IsEmpty(domain) { - // No domain was provided, domain checks and setup can be skipped. - return nil + // No domain was provided; persist the skip so resume does not re-prompt. + return persistAppDomain(project, "") } if project.Provider == api.ProviderGCP { @@ -357,9 +375,7 @@ func processAppDomain(domain string, project *manifest.ProjectManifest) error { project.Context["ManagedZone"] = managedZone } - // Save the domain and other changes to the project manifest. - project.AppDomain = domain - return project.Flush() + return persistAppDomain(project, domain) } func getCluster(cd *cdpkg.Plural) (id string, err error) { diff --git a/cmd/command/up/up_test.go b/cmd/command/up/up_test.go new file mode 100644 index 000000000..cc68c83cd --- /dev/null +++ b/cmd/command/up/up_test.go @@ -0,0 +1,117 @@ +package up + +import ( + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/manifest" +) + +func TestAppDomainAlreadyConfigured(t *testing.T) { + tests := []struct { + name string + project *manifest.ProjectManifest + want bool + }{ + { + name: "nil project", + project: nil, + want: false, + }, + { + name: "never asked", + project: &manifest.ProjectManifest{}, + want: false, + }, + { + name: "legacy manifest with app domain", + project: &manifest.ProjectManifest{AppDomain: "apps.example.com"}, + want: true, + }, + { + name: "explicit skip", + project: &manifest.ProjectManifest{AppDomainConfigured: true}, + want: true, + }, + { + name: "configured with domain", + project: &manifest.ProjectManifest{ + AppDomain: "apps.example.com", + AppDomainConfigured: true, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := appDomainAlreadyConfigured(tt.project); got != tt.want { + t.Errorf("appDomainAlreadyConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProcessAppDomainPersistsSkip(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + project := &manifest.ProjectManifest{Cluster: "test"} + if err := project.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + if err := processAppDomain("", project); err != nil { + t.Fatal(err) + } + + if !project.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured after skipping domain") + } + if project.AppDomain != "" { + t.Fatalf("expected empty AppDomain, got %q", project.AppDomain) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if !loaded.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured to be flushed to workspace.yaml") + } + if loaded.AppDomain != "" { + t.Fatalf("expected flushed AppDomain to be empty, got %q", loaded.AppDomain) + } +} + +func TestProcessAppDomainPersistsDomain(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + project := &manifest.ProjectManifest{Cluster: "test", Provider: "aws"} + if err := project.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + if err := processAppDomain("apps.example.com", project); err != nil { + t.Fatal(err) + } + + if !project.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured after setting domain") + } + if project.AppDomain != "apps.example.com" { + t.Fatalf("AppDomain = %q, want apps.example.com", project.AppDomain) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if !loaded.AppDomainConfigured { + t.Fatal("expected AppDomainConfigured to be flushed to workspace.yaml") + } + if loaded.AppDomain != "apps.example.com" { + t.Fatalf("flushed AppDomain = %q, want apps.example.com", loaded.AppDomain) + } +} diff --git a/pkg/manifest/types.go b/pkg/manifest/types.go index 87f08c263..3ecce80cb 100644 --- a/pkg/manifest/types.go +++ b/pkg/manifest/types.go @@ -50,47 +50,50 @@ type NetworkConfig struct { } type ProjectManifest struct { - Cluster string - Bucket string - Project string - Provider string - Region string - Owner *Owner - Network *NetworkConfig - Checkpoint string `yaml:"checkpoint,omitempty"` - AvailabilityZones []string - BucketPrefix string `yaml:"bucketPrefix"` - Context map[string]interface{} - AppDomain string `yaml:"appDomain,omitempty"` + Cluster string + Bucket string + Project string + Provider string + Region string + Owner *Owner + Network *NetworkConfig + Checkpoint string `yaml:"checkpoint,omitempty"` + AvailabilityZones []string + BucketPrefix string `yaml:"bucketPrefix"` + Context map[string]interface{} + AppDomain string `yaml:"appDomain,omitempty"` + AppDomainConfigured bool `yaml:"appDomainConfigured,omitempty"` } func (pm *ProjectManifest) MarshalJSON() ([]byte, error) { json := jsoniter.ConfigCompatibleWithStandardLibrary return json.Marshal(&struct { - Cluster string `json:"cluster"` - Bucket string `json:"bucket"` - Project string `json:"project"` - Provider string `json:"provider"` - Region string `json:"region"` - Owner *Owner `json:"owner"` - Network *NetworkConfig `json:"network"` - AvailabilityZones []string `json:"availabilityZones"` - BucketPrefix string `yaml:"bucketPrefix" json:"bucketPrefix"` - Context map[string]interface{} `json:"context"` - AppDomain string `json:"appDomain,omitempty"` + Cluster string `json:"cluster"` + Bucket string `json:"bucket"` + Project string `json:"project"` + Provider string `json:"provider"` + Region string `json:"region"` + Owner *Owner `json:"owner"` + Network *NetworkConfig `json:"network"` + AvailabilityZones []string `json:"availabilityZones"` + BucketPrefix string `yaml:"bucketPrefix" json:"bucketPrefix"` + Context map[string]interface{} `json:"context"` + AppDomain string `json:"appDomain,omitempty"` + AppDomainConfigured bool `json:"appDomainConfigured,omitempty"` }{ - Cluster: pm.Cluster, - Bucket: pm.Bucket, - Project: pm.Project, - Provider: pm.Provider, - Region: pm.Region, - Owner: pm.Owner, - Network: pm.Network, - AvailabilityZones: pm.AvailabilityZones, - BucketPrefix: pm.BucketPrefix, - Context: pm.Context, - AppDomain: pm.AppDomain, + Cluster: pm.Cluster, + Bucket: pm.Bucket, + Project: pm.Project, + Provider: pm.Provider, + Region: pm.Region, + Owner: pm.Owner, + Network: pm.Network, + AvailabilityZones: pm.AvailabilityZones, + BucketPrefix: pm.BucketPrefix, + Context: pm.Context, + AppDomain: pm.AppDomain, + AppDomainConfigured: pm.AppDomainConfigured, }) } diff --git a/pkg/up/deploy.go b/pkg/up/deploy.go index c432732fb..30ffa4375 100644 --- a/pkg/up/deploy.go +++ b/pkg/up/deploy.go @@ -49,7 +49,7 @@ func (c *Context) runCheckpoint(current, checkpoint string, fn func() error) err if current == "" || priorities[checkpoint] > priorities[current] { err := fn() if err == nil { - c.Manifest.Checkpoint = checkpoint + return c.completeCheckpoint(checkpoint) } return err } @@ -59,6 +59,11 @@ func (c *Context) runCheckpoint(current, checkpoint string, fn func() error) err return nil } +func (c *Context) completeCheckpoint(checkpoint string) error { + c.Manifest.Checkpoint = checkpoint + return c.Manifest.Flush() +} + func (c *Context) Deploy(commit func() error) error { if c.Provider.Name() == api.BYOK && c.Cloud { return nil diff --git a/pkg/up/deploy_test.go b/pkg/up/deploy_test.go new file mode 100644 index 000000000..46acf91ac --- /dev/null +++ b/pkg/up/deploy_test.go @@ -0,0 +1,119 @@ +package up + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/manifest" +) + +func TestRunCheckpointAdvancesAndFlushes(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + man := &manifest.ProjectManifest{Cluster: "test"} + if err := man.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + ctx := &Context{Manifest: man} + if err := ctx.runCheckpoint("", "init", func() error { return nil }); err != nil { + t.Fatal(err) + } + + if man.Checkpoint != "init" { + t.Fatalf("Checkpoint = %q, want init", man.Checkpoint) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if loaded.Checkpoint != "init" { + t.Fatalf("flushed Checkpoint = %q, want init", loaded.Checkpoint) + } +} + +func TestRunCheckpointDoesNotAdvanceOnError(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + man := &manifest.ProjectManifest{Cluster: "test"} + if err := man.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + ctx := &Context{Manifest: man} + err := ctx.runCheckpoint("", "init", func() error { + return errors.New("terraform failed") + }) + if err == nil { + t.Fatal("expected error") + } + + if man.Checkpoint != "" { + t.Fatalf("Checkpoint = %q, want empty", man.Checkpoint) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if loaded.Checkpoint != "" { + t.Fatalf("flushed Checkpoint = %q, want empty", loaded.Checkpoint) + } +} + +func TestRunCheckpointSkipsCompleted(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + man := &manifest.ProjectManifest{Cluster: "test", Checkpoint: "init"} + if err := man.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + called := false + ctx := &Context{Manifest: man} + if err := ctx.runCheckpoint(man.Checkpoint, "init", func() error { + called = true + return nil + }); err != nil { + t.Fatal(err) + } + + if called { + t.Fatal("expected completed checkpoint to be skipped") + } + if man.Checkpoint != "init" { + t.Fatalf("Checkpoint = %q, want init", man.Checkpoint) + } +} + +func TestRunCheckpointRunsLaterPhase(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + man := &manifest.ProjectManifest{Cluster: "test", Checkpoint: "init"} + if err := man.Write(filepath.Join(dir, "workspace.yaml")); err != nil { + t.Fatal(err) + } + + ctx := &Context{Manifest: man} + if err := ctx.runCheckpoint(man.Checkpoint, "commit", func() error { return nil }); err != nil { + t.Fatal(err) + } + + if man.Checkpoint != "commit" { + t.Fatalf("Checkpoint = %q, want commit", man.Checkpoint) + } + + loaded, err := manifest.ReadProject(filepath.Join(dir, "workspace.yaml")) + if err != nil { + t.Fatal(err) + } + if loaded.Checkpoint != "commit" { + t.Fatalf("flushed Checkpoint = %q, want commit", loaded.Checkpoint) + } +} From a4bd91981e3df79e9b5f07a2fa1b4284ed192e17 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Mon, 7 Sep 2026 15:25:28 +0200 Subject: [PATCH 2/3] add comment --- pkg/manifest/types.go | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/pkg/manifest/types.go b/pkg/manifest/types.go index 3ecce80cb..e1210ad5e 100644 --- a/pkg/manifest/types.go +++ b/pkg/manifest/types.go @@ -50,19 +50,23 @@ type NetworkConfig struct { } type ProjectManifest struct { - Cluster string - Bucket string - Project string - Provider string - Region string - Owner *Owner - Network *NetworkConfig - Checkpoint string `yaml:"checkpoint,omitempty"` - AvailabilityZones []string - BucketPrefix string `yaml:"bucketPrefix"` - Context map[string]interface{} - AppDomain string `yaml:"appDomain,omitempty"` - AppDomainConfigured bool `yaml:"appDomainConfigured,omitempty"` + Cluster string + Bucket string + Project string + Provider string + Region string + Owner *Owner + Network *NetworkConfig + Checkpoint string `yaml:"checkpoint,omitempty"` + AvailabilityZones []string + BucketPrefix string `yaml:"bucketPrefix"` + Context map[string]interface{} + AppDomain string `yaml:"appDomain,omitempty"` + // AppDomainConfigured is true after the app-domain prompt was answered, + // including when the user chose None. AppDomain alone cannot represent + // that skip: an empty value is omitted from workspace.yaml, so it looks + // the same as never asked. + AppDomainConfigured bool `yaml:"appDomainConfigured,omitempty"` } func (pm *ProjectManifest) MarshalJSON() ([]byte, error) { From fc43e1160d50557d2f35b7856d966c79d96b06f6 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Mon, 7 Sep 2026 16:08:24 +0200 Subject: [PATCH 3/3] bump grpc to v1.83.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0acbb9c8d..a711348e1 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ require ( golang.org/x/crypto v0.55.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.287.0 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.1 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 diff --git a/go.sum b/go.sum index 8d55cb9f8..83bbcdae5 100644 --- a/go.sum +++ b/go.sum @@ -928,8 +928,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=