From 67f5095aa3057b8b2057958484ac2de0506e5d69 Mon Sep 17 00:00:00 2001 From: Nishad Mathur Date: Wed, 28 Jan 2026 14:45:24 -0800 Subject: [PATCH 1/2] Add recreate-vms-created-before option to recreate and deploy commands CLI counterpart to !2656 The goal is to allow resumption of failed bosh repave (recreate) from where it failed, speeding up repave operations. --- cmd/deploy.go | 19 ++++++++-------- cmd/deploy_test.go | 17 ++++++++++++++ cmd/opts/opts.go | 18 ++++++++------- cmd/recreate.go | 13 ++++++----- cmd/recreate_test.go | 12 ++++++++++ director/deployment.go | 22 ++++++++++++------ director/deployment_test.go | 45 +++++++++++++++++++++++++++++++++++++ director/interfaces.go | 34 +++++++++++++++------------- 8 files changed, 134 insertions(+), 46 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index d96e82fe6..bf1dd7117 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, + 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..d72c0cc99 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -87,6 +87,23 @@ var _ = Describe("DeployCmd", func() { })) }) + It("deploys manifest allowing to recreate VMs created before a timestamp", func() { + deployOpts.Recreate = true + deployOpts.RecreateVMsCreatedBefore = "2026-01-01T00:00:00Z" + + 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: "2026-01-01T00:00:00Z", + })) + }) + 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..5608cc16b 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 string `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 string `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/recreate.go b/cmd/recreate.go index ecf907753..321a71367 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, } return recreateOpts, nil } diff --git a/cmd/recreate_test.go b/cmd/recreate_test.go index aff7d7b8c..8e6a5f59d 100644 --- a/cmd/recreate_test.go +++ b/cmd/recreate_test.go @@ -115,6 +115,18 @@ var _ = Describe("RecreateCmd", func() { Expect(recreateOpts.Fix).To(BeTrue()) }) + It("can set vms_created_before", func() { + recreateOpts.VMsCreatedBefore = "2026-01-01T00:00:00Z" + + err := act() + Expect(err).ToNot(HaveOccurred()) + + Expect(deployment.RecreateCallCount()).To(Equal(1)) + + _, recreateOpts := deployment.RecreateArgsForCall(0) + Expect(recreateOpts.VMsCreatedBefore).To(Equal("2026-01-01T00:00:00Z")) + }) + 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..41ea91731 100644 --- a/director/deployment.go +++ b/director/deployment.go @@ -138,7 +138,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, "") } func (d DeploymentImpl) Stop(slug AllOrInstanceGroupOrInstanceSlug, opts StopOpts) error { @@ -150,7 +150,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, "") } func (d DeploymentImpl) Restart(slug AllOrInstanceGroupOrInstanceSlug, opts RestartOpts) error { @@ -158,7 +158,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, "") } func (d DeploymentImpl) Recreate(slug AllOrInstanceGroupOrInstanceSlug, opts RecreateOpts) error { @@ -166,16 +166,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 string) 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 +387,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 string) error { if len(state) == 0 { return bosherr.Error("Expected non-empty job state") } @@ -426,6 +426,10 @@ func (c Client) ChangeJobState(state, deploymentName, job, indexOrID string, ski query.Add("max_in_flight", maxInFlight) } + if vmsCreatedBefore != "" { + query.Add("recreate_vm_created_before", vmsCreatedBefore) + } + path := fmt.Sprintf("/deployments/%s/jobs", deploymentName) if len(job) > 0 { @@ -525,6 +529,10 @@ func (c Client) UpdateDeployment(manifest []byte, opts UpdateOpts) error { query.Add("recreate_persistent_disks", "true") } + if opts.RecreateVMsCreatedBefore != "" { + query.Add("recreate_vm_created_before", opts.RecreateVMsCreatedBefore) + } + if opts.Fix { query.Add("fix", "true") } diff --git a/director/deployment_test.go b/director/deployment_test.go index 6df85a690..b62598158 100644 --- a/director/deployment_test.go +++ b/director/deployment_test.go @@ -550,6 +550,28 @@ var _ = Describe("Deployment", func() { err := stateFunc(deployment) Expect(err).ToNot(HaveOccurred()) }) + + It("changes state with vms_created_before filter", func() { + timestamp := "2026-01-01T00:00:00Z" + recreateOpts.VMsCreatedBefore = timestamp + + query := fmt.Sprintf("state=%s&recreate_vm_created_before=%s", state, url.QueryEscape(timestamp)) + + 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 +988,29 @@ var _ = Describe("Deployment", func() { Expect(err).ToNot(HaveOccurred()) }) + It("succeeds updating deployment with recreate_vm_created_before flag", func() { + timestamp := "2026-01-01T00:00:00Z" + ConfigureTaskResult( + ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/deployments", "recreate=true&recreate_vm_created_before="+url.QueryEscape(timestamp)), + 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..fae7e30cc 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 string } 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 string + Fix bool + SkipDrain SkipDrains + Canaries string + MaxInFlight string + DryRun bool + Diff DeploymentDiff + ForceLatestVariables bool } //counterfeiter:generate . ReleaseSeries From 8cd5366cb846d351d1b5934a1a8ada6d196425b5 Mon Sep 17 00:00:00 2001 From: Nishad Mathur Date: Thu, 29 Jan 2026 11:55:38 -0800 Subject: [PATCH 2/2] Use native time.Time type for timestamp flags Replace opaque string types with proper time.Time for the recreate-vms-created-before and vms-created-before flags. This provides type safety, validation at parse time, and clearer error messages for invalid RFC 3339 timestamps. - Add TimeArg type with UnmarshalFlag for parsing RFC 3339 timestamps - Update DeployOpts and RecreateOpts to use TimeArg - Update director UpdateOpts and RecreateOpts to use time.Time - Add comprehensive tests for TimeArg parsing and formatting --- cmd/deploy.go | 2 +- cmd/deploy_test.go | 5 +-- cmd/opts/opts.go | 8 ++--- cmd/opts/time_arg.go | 31 +++++++++++++++++ cmd/opts/time_arg_test.go | 66 +++++++++++++++++++++++++++++++++++++ cmd/recreate.go | 2 +- cmd/recreate_test.go | 5 +-- director/deployment.go | 19 ++++++----- director/deployment_test.go | 9 ++--- director/interfaces.go | 4 +-- 10 files changed, 126 insertions(+), 25 deletions(-) create mode 100644 cmd/opts/time_arg.go create mode 100644 cmd/opts/time_arg_test.go diff --git a/cmd/deploy.go b/cmd/deploy.go index bf1dd7117..c5feb5472 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -104,7 +104,7 @@ func (c DeployCmd) Run(opts DeployOpts) error { updateOpts := boshdir.UpdateOpts{ RecreatePersistentDisks: opts.RecreatePersistentDisks, Recreate: opts.Recreate, - RecreateVMsCreatedBefore: opts.RecreateVMsCreatedBefore, + RecreateVMsCreatedBefore: opts.RecreateVMsCreatedBefore.Time, Fix: opts.Fix, SkipDrain: opts.SkipDrain, DryRun: opts.DryRun, diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index d72c0cc99..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" @@ -89,7 +90,7 @@ var _ = Describe("DeployCmd", func() { It("deploys manifest allowing to recreate VMs created before a timestamp", func() { deployOpts.Recreate = true - deployOpts.RecreateVMsCreatedBefore = "2026-01-01T00:00:00Z" + deployOpts.RecreateVMsCreatedBefore = opts.TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} err := act() Expect(err).ToNot(HaveOccurred()) @@ -100,7 +101,7 @@ var _ = Describe("DeployCmd", func() { Expect(bytes).To(Equal([]byte("name: dep\n"))) Expect(updateOpts).To(Equal(boshdir.UpdateOpts{ Recreate: true, - RecreateVMsCreatedBefore: "2026-01-01T00:00:00Z", + RecreateVMsCreatedBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), })) }) diff --git a/cmd/opts/opts.go b/cmd/opts/opts.go index 5608cc16b..0237f318b 100644 --- a/cmd/opts/opts.go +++ b/cmd/opts/opts.go @@ -505,7 +505,7 @@ type DeployOpts struct { Recreate bool `long:"recreate" description:"Recreate all VMs in deployment"` RecreatePersistentDisks bool `long:"recreate-persistent-disks" description:"Recreate all persistent disks in deployment"` - RecreateVMsCreatedBefore string `long:"recreate-vms-created-before" description:"Only recreate VMs created before the given RFC 3339 timestamp (requires --recreate)"` + 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:"*"` @@ -942,9 +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"` - VMsCreatedBefore string `long:"vms-created-before" description:"Only recreate VMs created before the given RFC 3339 timestamp"` + 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 321a71367..14eb903da 100644 --- a/cmd/recreate.go +++ b/cmd/recreate.go @@ -39,7 +39,7 @@ func newRecreateOpts(opts RecreateOpts) (boshdir.RecreateOpts, error) { Canaries: opts.Canaries, MaxInFlight: opts.MaxInFlight, Converge: true, - VMsCreatedBefore: opts.VMsCreatedBefore, + VMsCreatedBefore: opts.VMsCreatedBefore.Time, } return recreateOpts, nil } diff --git a/cmd/recreate_test.go b/cmd/recreate_test.go index 8e6a5f59d..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" @@ -116,7 +117,7 @@ var _ = Describe("RecreateCmd", func() { }) It("can set vms_created_before", func() { - recreateOpts.VMsCreatedBefore = "2026-01-01T00:00:00Z" + recreateOpts.VMsCreatedBefore = opts.TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} err := act() Expect(err).ToNot(HaveOccurred()) @@ -124,7 +125,7 @@ var _ = Describe("RecreateCmd", func() { Expect(deployment.RecreateCallCount()).To(Equal(1)) _, recreateOpts := deployment.RecreateArgsForCall(0) - Expect(recreateOpts.VMsCreatedBefore).To(Equal("2026-01-01T00:00:00Z")) + 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() { diff --git a/director/deployment.go b/director/deployment.go index 41ea91731..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 { @@ -173,7 +174,7 @@ func (d DeploymentImpl) nonConvergingJobAction(action string, slug AllOrInstance 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, vmsCreatedBefore 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, vmsCreatedBefore) } @@ -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, vmsCreatedBefore 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,8 +427,8 @@ func (c Client) ChangeJobState(state, deploymentName, job, indexOrID string, ski query.Add("max_in_flight", maxInFlight) } - if vmsCreatedBefore != "" { - query.Add("recreate_vm_created_before", vmsCreatedBefore) + if !vmsCreatedBefore.IsZero() { + query.Add("recreate_vm_created_before", vmsCreatedBefore.Format(time.RFC3339)) } path := fmt.Sprintf("/deployments/%s/jobs", deploymentName) @@ -529,8 +530,8 @@ func (c Client) UpdateDeployment(manifest []byte, opts UpdateOpts) error { query.Add("recreate_persistent_disks", "true") } - if opts.RecreateVMsCreatedBefore != "" { - query.Add("recreate_vm_created_before", opts.RecreateVMsCreatedBefore) + if !opts.RecreateVMsCreatedBefore.IsZero() { + query.Add("recreate_vm_created_before", opts.RecreateVMsCreatedBefore.Format(time.RFC3339)) } if opts.Fix { diff --git a/director/deployment_test.go b/director/deployment_test.go index b62598158..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" @@ -552,10 +553,10 @@ var _ = Describe("Deployment", func() { }) It("changes state with vms_created_before filter", func() { - timestamp := "2026-01-01T00:00:00Z" + 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)) + query := fmt.Sprintf("state=%s&recreate_vm_created_before=%s", state, url.QueryEscape(timestamp.Format(time.RFC3339))) ConfigureTaskResult( ghttp.CombineHandlers( @@ -989,10 +990,10 @@ var _ = Describe("Deployment", func() { }) It("succeeds updating deployment with recreate_vm_created_before flag", func() { - timestamp := "2026-01-01T00:00:00Z" + 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)), + 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"}, diff --git a/director/interfaces.go b/director/interfaces.go index fae7e30cc..cdeaa0d29 100644 --- a/director/interfaces.go +++ b/director/interfaces.go @@ -215,13 +215,13 @@ type RecreateOpts struct { SkipDrain bool DryRun bool Converge bool - VMsCreatedBefore string + VMsCreatedBefore time.Time } type UpdateOpts struct { Recreate bool RecreatePersistentDisks bool - RecreateVMsCreatedBefore string + RecreateVMsCreatedBefore time.Time Fix bool SkipDrain SkipDrains Canaries string