Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions cmd/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd_test

import (
"errors"
"time"

"github.com/cppforlife/go-patch/patch"
. "github.com/onsi/ginkgo/v2"
Expand Down Expand Up @@ -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

Expand Down
18 changes: 10 additions & 8 deletions cmd/opts/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
31 changes: 31 additions & 0 deletions cmd/opts/time_arg.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
66 changes: 66 additions & 0 deletions cmd/opts/time_arg_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
})
})
13 changes: 7 additions & 6 deletions cmd/recreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions cmd/recreate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd_test

import (
"errors"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
Expand Down Expand Up @@ -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")

Expand Down
23 changes: 16 additions & 7 deletions director/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strings"
"time"

bosherr "github.com/cloudfoundry/bosh-utils/errors"
)
Expand Down Expand Up @@ -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 {
Expand All @@ -150,32 +151,32 @@ 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 {
if !opts.Converge {
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 {
if !opts.Converge {
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) {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down
46 changes: 46 additions & 0 deletions director/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/url"
"strings"
"time"

semver "github.com/cppforlife/go-semi-semantic/version"
. "github.com/onsi/ginkgo/v2"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading