Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
20 changes: 20 additions & 0 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Run Specs
on: [push, pull_request]

jobs:
test_embedded_ruby:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
ruby: [ '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', head]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
- run: bundle install
working-directory: templatescompiler/erbrenderer/
- run: bundle exec rake
working-directory: templatescompiler/erbrenderer/
continue-on-error: ${{ matrix.ruby == 'head' }}
1 change: 1 addition & 0 deletions bin/build
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
default_version='[DEV BUILD]'
VERSION_LABEL="${VERSION_LABEL:-${default_version}}"

go version
go build \
-o "${ROOT_DIR}/out/bosh" \
-ldflags="-X 'github.com/cloudfoundry/bosh-cli/v7/cmd.VersionLabel=${VERSION_LABEL}' -X 'main.version=${VERSION_LABEL}'" \
Expand Down
23 changes: 14 additions & 9 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,21 @@ func (c DeployCmd) Run(opts DeployOpts) error {
return err
}

if opts.RecreateVMsCreatedBefore.IsSet() {
opts.Recreate = true
}

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
34 changes: 34 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,39 @@ var _ = Describe("DeployCmd", func() {
}))
})

It("deploys manifest allowing to recreate VMs created before a timestamp and automatically sets recreate", func() {
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 with both recreate and recreate-vms-created-before set explicitly", 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"`
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
41 changes: 41 additions & 0 deletions cmd/opts/time_arg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package opts

import (
"time"

bosherr "github.com/cloudfoundry/bosh-utils/errors"
)

type TimeArg struct {
time.Time
}

func (a *TimeArg) UnmarshalFlag(data string) error {
// Try RFC3339 first (with timezone)
t, err := time.Parse(time.RFC3339, data)
if err != nil {
// Try RFC3339 without timezone suffix, assume UTC
// Format: "2006-01-02T15:04:05"
t, err = time.Parse("2006-01-02T15:04:05", data)
if err != nil {
return bosherr.Errorf("Invalid timestamp '%s': expected RFC 3339 format (e.g., 2006-01-02T15:04:05Z or 2006-01-02T15:04:05)", data)
}
// Treat as UTC since no timezone was specified
t = t.UTC()
}
// Always store as UTC internally
a.Time = t.UTC()
return nil
}

func (a TimeArg) IsSet() bool {
return !a.IsZero()
}

func (a TimeArg) AsString() string {
if a.IsSet() {
// Always output in UTC with Z suffix for consistency
return a.UTC().Format(time.RFC3339)
}
return ""
}
108 changes: 108 additions & 0 deletions cmd/opts/time_arg_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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 with Z suffix", 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 and converts to UTC", func() {
var arg TimeArg
err := arg.UnmarshalFlag("2026-06-15T14:30:00-07:00")
Expect(err).ToNot(HaveOccurred())
Expect(arg.Time).To(Equal(time.Date(2026, 6, 15, 21, 30, 0, 0, time.UTC)))
})

It("parses RFC 3339 timestamps with +00:00 offset and converts to UTC", func() {
var arg TimeArg
err := arg.UnmarshalFlag("2026-01-15T10:30:00+00:00")
Expect(err).ToNot(HaveOccurred())
Expect(arg.Time).To(Equal(time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)))
})

It("parses timestamps without timezone suffix and treats as UTC", func() {
var arg TimeArg
err := arg.UnmarshalFlag("2026-01-01T00:00:00")
Expect(err).ToNot(HaveOccurred())
Expect(arg.Time).To(Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
})

It("parses timestamps without timezone with specific time and treats as UTC", func() {
var arg TimeArg
err := arg.UnmarshalFlag("2026-06-15T14:30:45")
Expect(err).ToNot(HaveOccurred())
Expect(arg.Time).To(Equal(time.Date(2026, 6, 15, 14, 30, 45, 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 timestamp"))
})

It("returns error for date-only formats (no time component)", func() {
var arg TimeArg
err := arg.UnmarshalFlag("2026-01-01")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Invalid 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 in UTC 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"))
})

It("returns UTC formatted string even when time was parsed with offset", func() {
var arg TimeArg
// Parse with -07:00 offset (14:30 PST = 21:30 UTC)
err := arg.UnmarshalFlag("2026-06-15T14:30:00-07:00")
Expect(err).ToNot(HaveOccurred())
Expect(arg.AsString()).To(Equal("2026-06-15T21:30:00Z"))
})

It("returns UTC formatted string for timestamp parsed without timezone", func() {
var arg TimeArg
err := arg.UnmarshalFlag("2026-06-15T14:30:00")
Expect(err).ToNot(HaveOccurred())
Expect(arg.AsString()).To(Equal("2026-06-15T14:30:00Z"))
})

It("returns UTC Z suffix even when TimeArg is constructed programmatically with a non-UTC location", func() {
loc := time.FixedZone("IST", 5*60*60+30*60) // UTC+05:30
arg := TimeArg{Time: time.Date(2026, 6, 15, 20, 0, 0, 0, loc)}
Expect(arg.AsString()).To(Equal("2026-06-15T14:30:00Z"))
})
})
})
20 changes: 11 additions & 9 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 All @@ -64,8 +65,9 @@ func newRecreateOpts(opts RecreateOpts) (boshdir.RecreateOpts, error) {
}

return boshdir.RecreateOpts{
Converge: false,
SkipDrain: opts.SkipDrain,
Fix: opts.Fix,
Converge: false,
SkipDrain: opts.SkipDrain,
Fix: opts.Fix,
VMsCreatedBefore: opts.VMsCreatedBefore.Time,
}, nil
}
26 changes: 26 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 Expand Up @@ -204,6 +217,19 @@ var _ = Describe("RecreateCmd", func() {
Expect(deployment.RecreateCallCount()).To(Equal(0))
})

It("allows vms-created-before flag with no-converge", func() {
recreateOpts.NoConverge = true
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))

_, directorOpts := deployment.RecreateArgsForCall(0)
Expect(directorOpts.Converge).To(BeFalse())
Expect(directorOpts.VMsCreatedBefore).To(Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
})

Context("with invalid slugs for no-converge on a deployment", func() {

BeforeEach(func() {
Expand Down
Loading
Loading