Skip to content

Commit c2e8d7c

Browse files
Alphasitemkocher
andauthored
Add recreate-vms-created-before option to recreate and deploy commands (#710)
* 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. eg: `bosh deploy example.yml -d example --recreate-vms-created-before=2026-02-13T00:55:15+00:00` Signed-off-by: Nishad Mathur <nishad.mathur@broadcom.com> Co-authored-by: Matthew Kocher <matthew.kocher@broadcom.com>
1 parent 7a5ce4a commit c2e8d7c

File tree

10 files changed

+324
-49
lines changed

10 files changed

+324
-49
lines changed

cmd/deploy.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,21 @@ func (c DeployCmd) Run(opts DeployOpts) error {
101101
return err
102102
}
103103

104+
if opts.RecreateVMsCreatedBefore.IsSet() {
105+
opts.Recreate = true
106+
}
107+
104108
updateOpts := boshdir.UpdateOpts{
105-
RecreatePersistentDisks: opts.RecreatePersistentDisks,
106-
Recreate: opts.Recreate,
107-
Fix: opts.Fix,
108-
SkipDrain: opts.SkipDrain,
109-
DryRun: opts.DryRun,
110-
Canaries: opts.Canaries,
111-
MaxInFlight: opts.MaxInFlight,
112-
Diff: deploymentDiff,
113-
ForceLatestVariables: opts.ForceLatestVariables,
109+
RecreatePersistentDisks: opts.RecreatePersistentDisks,
110+
Recreate: opts.Recreate,
111+
RecreateVMsCreatedBefore: opts.RecreateVMsCreatedBefore.Time,
112+
Fix: opts.Fix,
113+
SkipDrain: opts.SkipDrain,
114+
DryRun: opts.DryRun,
115+
Canaries: opts.Canaries,
116+
MaxInFlight: opts.MaxInFlight,
117+
Diff: deploymentDiff,
118+
ForceLatestVariables: opts.ForceLatestVariables,
114119
}
115120

116121
return c.deployment.Update(bytes, updateOpts)

cmd/deploy_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd_test
22

33
import (
44
"errors"
5+
"time"
56

67
"github.com/cppforlife/go-patch/patch"
78
. "github.com/onsi/ginkgo/v2"
@@ -87,6 +88,39 @@ var _ = Describe("DeployCmd", func() {
8788
}))
8889
})
8990

91+
It("deploys manifest allowing to recreate VMs created before a timestamp and automatically sets recreate", func() {
92+
deployOpts.RecreateVMsCreatedBefore = opts.TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
93+
94+
err := act()
95+
Expect(err).ToNot(HaveOccurred())
96+
97+
Expect(deployment.UpdateCallCount()).To(Equal(1))
98+
99+
bytes, updateOpts := deployment.UpdateArgsForCall(0)
100+
Expect(bytes).To(Equal([]byte("name: dep\n")))
101+
Expect(updateOpts).To(Equal(boshdir.UpdateOpts{
102+
Recreate: true,
103+
RecreateVMsCreatedBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
104+
}))
105+
})
106+
107+
It("deploys manifest with both recreate and recreate-vms-created-before set explicitly", func() {
108+
deployOpts.Recreate = true
109+
deployOpts.RecreateVMsCreatedBefore = opts.TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
110+
111+
err := act()
112+
Expect(err).ToNot(HaveOccurred())
113+
114+
Expect(deployment.UpdateCallCount()).To(Equal(1))
115+
116+
bytes, updateOpts := deployment.UpdateArgsForCall(0)
117+
Expect(bytes).To(Equal([]byte("name: dep\n")))
118+
Expect(updateOpts).To(Equal(boshdir.UpdateOpts{
119+
Recreate: true,
120+
RecreateVMsCreatedBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
121+
}))
122+
})
123+
90124
It("deploys manifest allowing to dry_run", func() {
91125
deployOpts.DryRun = true
92126

cmd/opts/opts.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -503,12 +503,13 @@ type DeployOpts struct {
503503

504504
NoRedact bool `long:"no-redact" description:"Show non-redacted manifest diff"`
505505

506-
Recreate bool `long:"recreate" description:"Recreate all VMs in deployment"`
507-
RecreatePersistentDisks bool `long:"recreate-persistent-disks" description:"Recreate all persistent disks in deployment"`
508-
Fix bool `long:"fix" description:"Recreate an instance with an unresponsive agent instead of erroring"`
509-
FixReleases bool `long:"fix-releases" description:"Reupload releases in manifest and replace corrupt or missing jobs/packages"`
510-
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:"*"`
511-
SkipUploadReleases bool `long:"skip-upload-releases" description:"Skips the upload procedure for releases"`
506+
Recreate bool `long:"recreate" description:"Recreate all VMs in deployment"`
507+
RecreatePersistentDisks bool `long:"recreate-persistent-disks" description:"Recreate all persistent disks in deployment"`
508+
RecreateVMsCreatedBefore TimeArg `long:"recreate-vms-created-before" description:"Only recreate VMs created before the given RFC 3339 timestamp"`
509+
Fix bool `long:"fix" description:"Recreate an instance with an unresponsive agent instead of erroring"`
510+
FixReleases bool `long:"fix-releases" description:"Reupload releases in manifest and replace corrupt or missing jobs/packages"`
511+
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:"*"`
512+
SkipUploadReleases bool `long:"skip-upload-releases" description:"Skips the upload procedure for releases"`
512513

513514
Canaries string `long:"canaries" description:"Override manifest values for canaries"`
514515
MaxInFlight string `long:"max-in-flight" description:"Override manifest values for max_in_flight"`
@@ -941,8 +942,9 @@ type RestartOpts struct {
941942
type RecreateOpts struct {
942943
Args AllOrInstanceGroupOrInstanceSlugArgs `positional-args:"true"`
943944

944-
SkipDrain bool `long:"skip-drain" description:"Skip running drain and pre-stop scripts"`
945-
Fix bool `long:"fix" description:"Recreate an instance with an unresponsive agent instead of erroring"`
945+
SkipDrain bool `long:"skip-drain" description:"Skip running drain and pre-stop scripts"`
946+
Fix bool `long:"fix" description:"Recreate an instance with an unresponsive agent instead of erroring"`
947+
VMsCreatedBefore TimeArg `long:"vms-created-before" description:"Only recreate VMs created before the given RFC 3339 timestamp"`
946948

947949
Canaries string `long:"canaries" description:"Override manifest values for canaries"`
948950
MaxInFlight string `long:"max-in-flight" description:"Override manifest values for max_in_flight"`

cmd/opts/time_arg.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package opts
2+
3+
import (
4+
"time"
5+
6+
bosherr "github.com/cloudfoundry/bosh-utils/errors"
7+
)
8+
9+
type TimeArg struct {
10+
time.Time
11+
}
12+
13+
func (a *TimeArg) UnmarshalFlag(data string) error {
14+
// Try RFC3339 first (with timezone)
15+
t, err := time.Parse(time.RFC3339, data)
16+
if err != nil {
17+
// Try RFC3339 without timezone suffix, assume UTC
18+
// Format: "2006-01-02T15:04:05"
19+
t, err = time.Parse("2006-01-02T15:04:05", data)
20+
if err != nil {
21+
return bosherr.Errorf("Invalid timestamp '%s': expected RFC 3339 format (e.g., 2006-01-02T15:04:05Z or 2006-01-02T15:04:05)", data)
22+
}
23+
// Treat as UTC since no timezone was specified
24+
t = t.UTC()
25+
}
26+
// Always store as UTC internally
27+
a.Time = t.UTC()
28+
return nil
29+
}
30+
31+
func (a TimeArg) IsSet() bool {
32+
return !a.IsZero()
33+
}
34+
35+
func (a TimeArg) AsString() string {
36+
if a.IsSet() {
37+
// Always output in UTC with Z suffix for consistency
38+
return a.UTC().Format(time.RFC3339)
39+
}
40+
return ""
41+
}

cmd/opts/time_arg_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package opts_test
2+
3+
import (
4+
"time"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
9+
. "github.com/cloudfoundry/bosh-cli/v7/cmd/opts"
10+
)
11+
12+
var _ = Describe("TimeArg", func() {
13+
Describe("UnmarshalFlag", func() {
14+
It("parses valid RFC 3339 timestamps with Z suffix", func() {
15+
var arg TimeArg
16+
err := arg.UnmarshalFlag("2026-01-01T00:00:00Z")
17+
Expect(err).ToNot(HaveOccurred())
18+
Expect(arg.Time).To(Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
19+
})
20+
21+
It("parses RFC 3339 timestamps with timezone offset and converts to UTC", func() {
22+
var arg TimeArg
23+
err := arg.UnmarshalFlag("2026-06-15T14:30:00-07:00")
24+
Expect(err).ToNot(HaveOccurred())
25+
Expect(arg.Time).To(Equal(time.Date(2026, 6, 15, 21, 30, 0, 0, time.UTC)))
26+
})
27+
28+
It("parses RFC 3339 timestamps with +00:00 offset and converts to UTC", func() {
29+
var arg TimeArg
30+
err := arg.UnmarshalFlag("2026-01-15T10:30:00+00:00")
31+
Expect(err).ToNot(HaveOccurred())
32+
Expect(arg.Time).To(Equal(time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)))
33+
})
34+
35+
It("parses timestamps without timezone suffix and treats as UTC", func() {
36+
var arg TimeArg
37+
err := arg.UnmarshalFlag("2026-01-01T00:00:00")
38+
Expect(err).ToNot(HaveOccurred())
39+
Expect(arg.Time).To(Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
40+
})
41+
42+
It("parses timestamps without timezone with specific time and treats as UTC", func() {
43+
var arg TimeArg
44+
err := arg.UnmarshalFlag("2026-06-15T14:30:45")
45+
Expect(err).ToNot(HaveOccurred())
46+
Expect(arg.Time).To(Equal(time.Date(2026, 6, 15, 14, 30, 45, 0, time.UTC)))
47+
})
48+
49+
It("returns error for invalid timestamps", func() {
50+
var arg TimeArg
51+
err := arg.UnmarshalFlag("not-a-timestamp")
52+
Expect(err).To(HaveOccurred())
53+
Expect(err.Error()).To(ContainSubstring("Invalid timestamp"))
54+
})
55+
56+
It("returns error for date-only formats (no time component)", func() {
57+
var arg TimeArg
58+
err := arg.UnmarshalFlag("2026-01-01")
59+
Expect(err).To(HaveOccurred())
60+
Expect(err.Error()).To(ContainSubstring("Invalid timestamp"))
61+
})
62+
})
63+
64+
Describe("IsSet", func() {
65+
It("returns false for zero time", func() {
66+
var arg TimeArg
67+
Expect(arg.IsSet()).To(BeFalse())
68+
})
69+
70+
It("returns true for non-zero time", func() {
71+
arg := TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
72+
Expect(arg.IsSet()).To(BeTrue())
73+
})
74+
})
75+
76+
Describe("AsString", func() {
77+
It("returns empty string for zero time", func() {
78+
var arg TimeArg
79+
Expect(arg.AsString()).To(Equal(""))
80+
})
81+
82+
It("returns RFC 3339 formatted string in UTC for non-zero time", func() {
83+
arg := TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
84+
Expect(arg.AsString()).To(Equal("2026-01-01T00:00:00Z"))
85+
})
86+
87+
It("returns UTC formatted string even when time was parsed with offset", func() {
88+
var arg TimeArg
89+
// Parse with -07:00 offset (14:30 PST = 21:30 UTC)
90+
err := arg.UnmarshalFlag("2026-06-15T14:30:00-07:00")
91+
Expect(err).ToNot(HaveOccurred())
92+
Expect(arg.AsString()).To(Equal("2026-06-15T21:30:00Z"))
93+
})
94+
95+
It("returns UTC formatted string for timestamp parsed without timezone", func() {
96+
var arg TimeArg
97+
err := arg.UnmarshalFlag("2026-06-15T14:30:00")
98+
Expect(err).ToNot(HaveOccurred())
99+
Expect(arg.AsString()).To(Equal("2026-06-15T14:30:00Z"))
100+
})
101+
102+
It("returns UTC Z suffix even when TimeArg is constructed programmatically with a non-UTC location", func() {
103+
loc := time.FixedZone("IST", 5*60*60+30*60) // UTC+05:30
104+
arg := TimeArg{Time: time.Date(2026, 6, 15, 20, 0, 0, 0, loc)}
105+
Expect(arg.AsString()).To(Equal("2026-06-15T14:30:00Z"))
106+
})
107+
})
108+
})

cmd/recreate.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@ func (c RecreateCmd) Run(opts RecreateOpts) error {
3333
func newRecreateOpts(opts RecreateOpts) (boshdir.RecreateOpts, error) {
3434
if !opts.NoConverge { // converge is default, no-converge is opt-in
3535
recreateOpts := boshdir.RecreateOpts{
36-
SkipDrain: opts.SkipDrain,
37-
Fix: opts.Fix,
38-
DryRun: opts.DryRun,
39-
Canaries: opts.Canaries,
40-
MaxInFlight: opts.MaxInFlight,
41-
Converge: true,
36+
SkipDrain: opts.SkipDrain,
37+
Fix: opts.Fix,
38+
DryRun: opts.DryRun,
39+
Canaries: opts.Canaries,
40+
MaxInFlight: opts.MaxInFlight,
41+
Converge: true,
42+
VMsCreatedBefore: opts.VMsCreatedBefore.Time,
4243
}
4344
return recreateOpts, nil
4445
}
@@ -64,8 +65,9 @@ func newRecreateOpts(opts RecreateOpts) (boshdir.RecreateOpts, error) {
6465
}
6566

6667
return boshdir.RecreateOpts{
67-
Converge: false,
68-
SkipDrain: opts.SkipDrain,
69-
Fix: opts.Fix,
68+
Converge: false,
69+
SkipDrain: opts.SkipDrain,
70+
Fix: opts.Fix,
71+
VMsCreatedBefore: opts.VMsCreatedBefore.Time,
7072
}, nil
7173
}

cmd/recreate_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd_test
22

33
import (
44
"errors"
5+
"time"
56

67
. "github.com/onsi/ginkgo/v2"
78
. "github.com/onsi/gomega"
@@ -115,6 +116,18 @@ var _ = Describe("RecreateCmd", func() {
115116
Expect(recreateOpts.Fix).To(BeTrue())
116117
})
117118

119+
It("can set vms_created_before", func() {
120+
recreateOpts.VMsCreatedBefore = opts.TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
121+
122+
err := act()
123+
Expect(err).ToNot(HaveOccurred())
124+
125+
Expect(deployment.RecreateCallCount()).To(Equal(1))
126+
127+
_, recreateOpts := deployment.RecreateArgsForCall(0)
128+
Expect(recreateOpts.VMsCreatedBefore).To(Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
129+
})
130+
118131
It("does not recreate if confirmation is rejected", func() {
119132
ui.AskedConfirmationErr = errors.New("stop")
120133

@@ -204,6 +217,19 @@ var _ = Describe("RecreateCmd", func() {
204217
Expect(deployment.RecreateCallCount()).To(Equal(0))
205218
})
206219

220+
It("allows vms-created-before flag with no-converge", func() {
221+
recreateOpts.NoConverge = true
222+
recreateOpts.VMsCreatedBefore = opts.TimeArg{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
223+
err := act()
224+
Expect(err).ToNot(HaveOccurred())
225+
226+
Expect(deployment.RecreateCallCount()).To(Equal(1))
227+
228+
_, directorOpts := deployment.RecreateArgsForCall(0)
229+
Expect(directorOpts.Converge).To(BeFalse())
230+
Expect(directorOpts.VMsCreatedBefore).To(Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
231+
})
232+
207233
Context("with invalid slugs for no-converge on a deployment", func() {
208234

209235
BeforeEach(func() {

0 commit comments

Comments
 (0)