diff --git a/cmd/deploy.go b/cmd/deploy.go index d96e82fe6..c5feb5472 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -102,15 +102,16 @@ func (c DeployCmd) Run(opts DeployOpts) error { } updateOpts := boshdir.UpdateOpts{ - RecreatePersistentDisks: opts.RecreatePersistentDisks, - Recreate: opts.Recreate, - Fix: opts.Fix, - SkipDrain: opts.SkipDrain, - DryRun: opts.DryRun, - Canaries: opts.Canaries, - MaxInFlight: opts.MaxInFlight, - Diff: deploymentDiff, - ForceLatestVariables: opts.ForceLatestVariables, + RecreatePersistentDisks: opts.RecreatePersistentDisks, + Recreate: opts.Recreate, + RecreateVMsCreatedBefore: opts.RecreateVMsCreatedBefore.Time, + Fix: opts.Fix, + SkipDrain: opts.SkipDrain, + DryRun: opts.DryRun, + Canaries: opts.Canaries, + MaxInFlight: opts.MaxInFlight, + Diff: deploymentDiff, + ForceLatestVariables: opts.ForceLatestVariables, } return c.deployment.Update(bytes, updateOpts) diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 28e884aa1..bd2d5a067 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -2,6 +2,7 @@ package cmd_test import ( "errors" + "time" "github.com/cppforlife/go-patch/patch" . "github.com/onsi/ginkgo/v2" @@ -87,6 +88,23 @@ var _ = Describe("DeployCmd", func() { })) }) + It("deploys manifest allowing to recreate VMs created before a timestamp", func() { + deployOpts.Recreate = true + deployOpts.RecreateVMsCreatedBefore = opts.TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + + err := act() + Expect(err).ToNot(HaveOccurred()) + + Expect(deployment.UpdateCallCount()).To(Equal(1)) + + bytes, updateOpts := deployment.UpdateArgsForCall(0) + Expect(bytes).To(Equal([]byte("name: dep\n"))) + Expect(updateOpts).To(Equal(boshdir.UpdateOpts{ + Recreate: true, + RecreateVMsCreatedBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + })) + }) + It("deploys manifest allowing to dry_run", func() { deployOpts.DryRun = true diff --git a/cmd/opts/opts.go b/cmd/opts/opts.go index 12462ea4d..0237f318b 100644 --- a/cmd/opts/opts.go +++ b/cmd/opts/opts.go @@ -503,12 +503,13 @@ type DeployOpts struct { NoRedact bool `long:"no-redact" description:"Show non-redacted manifest diff"` - Recreate bool `long:"recreate" description:"Recreate all VMs in deployment"` - RecreatePersistentDisks bool `long:"recreate-persistent-disks" description:"Recreate all persistent disks in deployment"` - Fix bool `long:"fix" description:"Recreate an instance with an unresponsive agent instead of erroring"` - FixReleases bool `long:"fix-releases" description:"Reupload releases in manifest and replace corrupt or missing jobs/packages"` - SkipDrain []boshdir.SkipDrain `long:"skip-drain" value-name:"[INSTANCE-GROUP[/INSTANCE-ID]]" description:"Skip running drain and pre-stop scripts for specific instance groups" optional:"true" optional-value:"*"` - SkipUploadReleases bool `long:"skip-upload-releases" description:"Skips the upload procedure for releases"` + Recreate bool `long:"recreate" description:"Recreate all VMs in deployment"` + RecreatePersistentDisks bool `long:"recreate-persistent-disks" description:"Recreate all persistent disks in deployment"` + RecreateVMsCreatedBefore TimeArg `long:"recreate-vms-created-before" description:"Only recreate VMs created before the given RFC 3339 timestamp (requires --recreate)"` + Fix bool `long:"fix" description:"Recreate an instance with an unresponsive agent instead of erroring"` + FixReleases bool `long:"fix-releases" description:"Reupload releases in manifest and replace corrupt or missing jobs/packages"` + SkipDrain []boshdir.SkipDrain `long:"skip-drain" value-name:"[INSTANCE-GROUP[/INSTANCE-ID]]" description:"Skip running drain and pre-stop scripts for specific instance groups" optional:"true" optional-value:"*"` + SkipUploadReleases bool `long:"skip-upload-releases" description:"Skips the upload procedure for releases"` Canaries string `long:"canaries" description:"Override manifest values for canaries"` MaxInFlight string `long:"max-in-flight" description:"Override manifest values for max_in_flight"` @@ -941,8 +942,9 @@ type RestartOpts struct { type RecreateOpts struct { Args AllOrInstanceGroupOrInstanceSlugArgs `positional-args:"true"` - SkipDrain bool `long:"skip-drain" description:"Skip running drain and pre-stop scripts"` - Fix bool `long:"fix" description:"Recreate an instance with an unresponsive agent instead of erroring"` + SkipDrain bool `long:"skip-drain" description:"Skip running drain and pre-stop scripts"` + Fix bool `long:"fix" description:"Recreate an instance with an unresponsive agent instead of erroring"` + VMsCreatedBefore TimeArg `long:"vms-created-before" description:"Only recreate VMs created before the given RFC 3339 timestamp"` Canaries string `long:"canaries" description:"Override manifest values for canaries"` MaxInFlight string `long:"max-in-flight" description:"Override manifest values for max_in_flight"` diff --git a/cmd/opts/time_arg.go b/cmd/opts/time_arg.go new file mode 100644 index 000000000..6b7bd0bad --- /dev/null +++ b/cmd/opts/time_arg.go @@ -0,0 +1,31 @@ +package opts + +import ( + "time" + + bosherr "github.com/cloudfoundry/bosh-utils/errors" +) + +type TimeArg struct { + time.Time +} + +func (a *TimeArg) UnmarshalFlag(data string) error { + t, err := time.Parse(time.RFC3339, data) + if err != nil { + return bosherr.Errorf("Invalid RFC 3339 timestamp '%s': %s", data, err) + } + a.Time = t + return nil +} + +func (a TimeArg) IsSet() bool { + return !a.IsZero() +} + +func (a TimeArg) AsString() string { + if a.IsSet() { + return a.Format(time.RFC3339) + } + return "" +} diff --git a/cmd/opts/time_arg_test.go b/cmd/opts/time_arg_test.go new file mode 100644 index 000000000..c7d8e4aa9 --- /dev/null +++ b/cmd/opts/time_arg_test.go @@ -0,0 +1,66 @@ +package opts_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/cloudfoundry/bosh-cli/v7/cmd/opts" +) + +var _ = Describe("TimeArg", func() { + Describe("UnmarshalFlag", func() { + It("parses valid RFC 3339 timestamps", func() { + var arg TimeArg + err := arg.UnmarshalFlag("2026-01-01T00:00:00Z") + Expect(err).ToNot(HaveOccurred()) + Expect(arg.Time).To(Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) + }) + + It("parses RFC 3339 timestamps with timezone offset", func() { + var arg TimeArg + err := arg.UnmarshalFlag("2026-06-15T14:30:00-07:00") + Expect(err).ToNot(HaveOccurred()) + Expect(arg.Time.UTC()).To(Equal(time.Date(2026, 6, 15, 21, 30, 0, 0, time.UTC))) + }) + + It("returns error for invalid timestamps", func() { + var arg TimeArg + err := arg.UnmarshalFlag("not-a-timestamp") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("Invalid RFC 3339 timestamp")) + }) + + It("returns error for non-RFC3339 date formats", func() { + var arg TimeArg + err := arg.UnmarshalFlag("2026-01-01") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("Invalid RFC 3339 timestamp")) + }) + }) + + Describe("IsSet", func() { + It("returns false for zero time", func() { + var arg TimeArg + Expect(arg.IsSet()).To(BeFalse()) + }) + + It("returns true for non-zero time", func() { + arg := TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + Expect(arg.IsSet()).To(BeTrue()) + }) + }) + + Describe("AsString", func() { + It("returns empty string for zero time", func() { + var arg TimeArg + Expect(arg.AsString()).To(Equal("")) + }) + + It("returns RFC 3339 formatted string for non-zero time", func() { + arg := TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + Expect(arg.AsString()).To(Equal("2026-01-01T00:00:00Z")) + }) + }) +}) diff --git a/cmd/recreate.go b/cmd/recreate.go index ecf907753..14eb903da 100644 --- a/cmd/recreate.go +++ b/cmd/recreate.go @@ -33,12 +33,13 @@ func (c RecreateCmd) Run(opts RecreateOpts) error { func newRecreateOpts(opts RecreateOpts) (boshdir.RecreateOpts, error) { if !opts.NoConverge { // converge is default, no-converge is opt-in recreateOpts := boshdir.RecreateOpts{ - SkipDrain: opts.SkipDrain, - Fix: opts.Fix, - DryRun: opts.DryRun, - Canaries: opts.Canaries, - MaxInFlight: opts.MaxInFlight, - Converge: true, + SkipDrain: opts.SkipDrain, + Fix: opts.Fix, + DryRun: opts.DryRun, + Canaries: opts.Canaries, + MaxInFlight: opts.MaxInFlight, + Converge: true, + VMsCreatedBefore: opts.VMsCreatedBefore.Time, } return recreateOpts, nil } diff --git a/cmd/recreate_test.go b/cmd/recreate_test.go index aff7d7b8c..2d2985616 100644 --- a/cmd/recreate_test.go +++ b/cmd/recreate_test.go @@ -2,6 +2,7 @@ package cmd_test import ( "errors" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -115,6 +116,18 @@ var _ = Describe("RecreateCmd", func() { Expect(recreateOpts.Fix).To(BeTrue()) }) + It("can set vms_created_before", func() { + recreateOpts.VMsCreatedBefore = opts.TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + + err := act() + Expect(err).ToNot(HaveOccurred()) + + Expect(deployment.RecreateCallCount()).To(Equal(1)) + + _, recreateOpts := deployment.RecreateArgsForCall(0) + Expect(recreateOpts.VMsCreatedBefore).To(Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))) + }) + It("does not recreate if confirmation is rejected", func() { ui.AskedConfirmationErr = errors.New("stop") diff --git a/director/deployment.go b/director/deployment.go index 99924dbf3..a739c5acf 100644 --- a/director/deployment.go +++ b/director/deployment.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "time" bosherr "github.com/cloudfoundry/bosh-utils/errors" ) @@ -138,7 +139,7 @@ func (d DeploymentImpl) Start(slug AllOrInstanceGroupOrInstanceSlug, opts StartO if !opts.Converge { return d.nonConvergingJobAction("start", slug, false, false, false) } - return d.changeJobState("started", slug, false, false, false, false, opts.Canaries, opts.MaxInFlight) + return d.changeJobState("started", slug, false, false, false, false, opts.Canaries, opts.MaxInFlight, time.Time{}) } func (d DeploymentImpl) Stop(slug AllOrInstanceGroupOrInstanceSlug, opts StopOpts) error { @@ -150,7 +151,7 @@ func (d DeploymentImpl) Stop(slug AllOrInstanceGroupOrInstanceSlug, opts StopOpt if opts.Hard { state = "detached" } - return d.changeJobState(state, slug, opts.SkipDrain, opts.Force, false, false, opts.Canaries, opts.MaxInFlight) + return d.changeJobState(state, slug, opts.SkipDrain, opts.Force, false, false, opts.Canaries, opts.MaxInFlight, time.Time{}) } func (d DeploymentImpl) Restart(slug AllOrInstanceGroupOrInstanceSlug, opts RestartOpts) error { @@ -158,7 +159,7 @@ func (d DeploymentImpl) Restart(slug AllOrInstanceGroupOrInstanceSlug, opts Rest return d.nonConvergingJobAction("restart", slug, opts.SkipDrain, false, false) } - return d.changeJobState("restart", slug, opts.SkipDrain, opts.Force, false, false, opts.Canaries, opts.MaxInFlight) + return d.changeJobState("restart", slug, opts.SkipDrain, opts.Force, false, false, opts.Canaries, opts.MaxInFlight, time.Time{}) } func (d DeploymentImpl) Recreate(slug AllOrInstanceGroupOrInstanceSlug, opts RecreateOpts) error { @@ -166,16 +167,16 @@ func (d DeploymentImpl) Recreate(slug AllOrInstanceGroupOrInstanceSlug, opts Rec return d.nonConvergingJobAction("recreate", slug, opts.SkipDrain, false, opts.Fix) } - return d.changeJobState("recreate", slug, opts.SkipDrain, opts.Force, opts.Fix, opts.DryRun, opts.Canaries, opts.MaxInFlight) + return d.changeJobState("recreate", slug, opts.SkipDrain, opts.Force, opts.Fix, opts.DryRun, opts.Canaries, opts.MaxInFlight, opts.VMsCreatedBefore) } func (d DeploymentImpl) nonConvergingJobAction(action string, slug AllOrInstanceGroupOrInstanceSlug, skipDrain bool, hard bool, ignoreUnresponsiveAgent bool) error { return d.client.NonConvergingJobAction(action, d.name, slug.Name(), slug.IndexOrID(), skipDrain, hard, ignoreUnresponsiveAgent) } -func (d DeploymentImpl) changeJobState(state string, slug AllOrInstanceGroupOrInstanceSlug, skipDrain bool, force bool, fix bool, dryRun bool, canaries string, maxInFlight string) error { +func (d DeploymentImpl) changeJobState(state string, slug AllOrInstanceGroupOrInstanceSlug, skipDrain bool, force bool, fix bool, dryRun bool, canaries string, maxInFlight string, vmsCreatedBefore time.Time) error { return d.client.ChangeJobState( - state, d.name, slug.Name(), slug.IndexOrID(), skipDrain, force, fix, dryRun, canaries, maxInFlight) + state, d.name, slug.Name(), slug.IndexOrID(), skipDrain, force, fix, dryRun, canaries, maxInFlight, vmsCreatedBefore) } func (d DeploymentImpl) ExportRelease(release ReleaseSlug, os OSVersionSlug, jobs []string) (ExportReleaseResult, error) { @@ -387,7 +388,7 @@ func (c Client) NonConvergingJobAction(action string, deployment string, instanc return nil } -func (c Client) ChangeJobState(state, deploymentName, job, indexOrID string, skipDrain bool, force bool, fix bool, dryRun bool, canaries string, maxInFlight string) error { +func (c Client) ChangeJobState(state, deploymentName, job, indexOrID string, skipDrain bool, force bool, fix bool, dryRun bool, canaries string, maxInFlight string, vmsCreatedBefore time.Time) error { if len(state) == 0 { return bosherr.Error("Expected non-empty job state") } @@ -426,6 +427,10 @@ func (c Client) ChangeJobState(state, deploymentName, job, indexOrID string, ski query.Add("max_in_flight", maxInFlight) } + if !vmsCreatedBefore.IsZero() { + query.Add("recreate_vm_created_before", vmsCreatedBefore.Format(time.RFC3339)) + } + path := fmt.Sprintf("/deployments/%s/jobs", deploymentName) if len(job) > 0 { @@ -525,6 +530,10 @@ func (c Client) UpdateDeployment(manifest []byte, opts UpdateOpts) error { query.Add("recreate_persistent_disks", "true") } + if !opts.RecreateVMsCreatedBefore.IsZero() { + query.Add("recreate_vm_created_before", opts.RecreateVMsCreatedBefore.Format(time.RFC3339)) + } + if opts.Fix { query.Add("fix", "true") } diff --git a/director/deployment_test.go b/director/deployment_test.go index 6df85a690..9b8643b58 100644 --- a/director/deployment_test.go +++ b/director/deployment_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "strings" + "time" semver "github.com/cppforlife/go-semi-semantic/version" . "github.com/onsi/ginkgo/v2" @@ -550,6 +551,28 @@ var _ = Describe("Deployment", func() { err := stateFunc(deployment) Expect(err).ToNot(HaveOccurred()) }) + + It("changes state with vms_created_before filter", func() { + timestamp := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + recreateOpts.VMsCreatedBefore = timestamp + + query := fmt.Sprintf("state=%s&recreate_vm_created_before=%s", state, url.QueryEscape(timestamp.Format(time.RFC3339))) + + ConfigureTaskResult( + ghttp.CombineHandlers( + ghttp.VerifyRequest("PUT", "/deployments/dep/jobs/*", query), + ghttp.VerifyBasicAuth("username", "password"), + ghttp.VerifyHeader(http.Header{ + "Content-Type": []string{"text/yaml"}, + }), + ghttp.VerifyBody([]byte{}), + ), + ``, + server, + ) + err := stateFunc(deployment) + Expect(err).ToNot(HaveOccurred()) + }) } if state != "started" { It("changes state with skipping drain and forcing", func() { @@ -966,6 +989,29 @@ var _ = Describe("Deployment", func() { Expect(err).ToNot(HaveOccurred()) }) + It("succeeds updating deployment with recreate_vm_created_before flag", func() { + timestamp := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + ConfigureTaskResult( + ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/deployments", "recreate=true&recreate_vm_created_before="+url.QueryEscape(timestamp.Format(time.RFC3339))), + ghttp.VerifyBasicAuth("username", "password"), + ghttp.VerifyHeader(http.Header{ + "Content-Type": []string{"text/yaml"}, + }), + ghttp.VerifyBody([]byte("manifest")), + ), + ``, + server, + ) + + updateOpts := UpdateOpts{ + Recreate: true, + RecreateVMsCreatedBefore: timestamp, + } + err := deployment.Update([]byte("manifest"), updateOpts) + Expect(err).ToNot(HaveOccurred()) + }) + It("succeeds updating deployment with diff context values", func() { context := map[string]interface{}{ "cloud_config_id": "2", diff --git a/director/interfaces.go b/director/interfaces.go index e0445105c..cdeaa0d29 100644 --- a/director/interfaces.go +++ b/director/interfaces.go @@ -208,25 +208,27 @@ type RestartOpts struct { } type RecreateOpts struct { - Canaries string - MaxInFlight string - Force bool - Fix bool - SkipDrain bool - DryRun bool - Converge bool + Canaries string + MaxInFlight string + Force bool + Fix bool + SkipDrain bool + DryRun bool + Converge bool + VMsCreatedBefore time.Time } type UpdateOpts struct { - Recreate bool - RecreatePersistentDisks bool - Fix bool - SkipDrain SkipDrains - Canaries string - MaxInFlight string - DryRun bool - Diff DeploymentDiff - ForceLatestVariables bool + Recreate bool + RecreatePersistentDisks bool + RecreateVMsCreatedBefore time.Time + Fix bool + SkipDrain SkipDrains + Canaries string + MaxInFlight string + DryRun bool + Diff DeploymentDiff + ForceLatestVariables bool } //counterfeiter:generate . ReleaseSeries