Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
96f6b52
Add standardrb and fix issues in embeded ruby file
aramprice Dec 5, 2025
5cb8402
Introduce minimal spec for embedded ruby script
aramprice Dec 6, 2025
3db29db
Add minimal spec for ERBRenderer#render
aramprice Dec 6, 2025
1e921fd
Update vendored dependencies
cf-rabbit-bot Jan 3, 2026
fc25fdc
ERBRenderer: ignore failures when ruby > v3.4
aramprice Dec 6, 2025
b387a21
Avoid `JSON.load_file`
aramprice Jan 5, 2026
e33764f
Merge pull request #707 from cloudfoundry/add-ruby-workflow
beyhan Jan 8, 2026
fd92882
Update vendored dependencies
cf-rabbit-bot Jan 10, 2026
85409cc
Update vendored dependencies
cf-rabbit-bot Jan 17, 2026
ef14afa
Update vendored dependencies
cf-rabbit-bot Jan 24, 2026
e7098e4
Update vendored dependencies
cf-rabbit-bot Feb 2, 2026
51c4c8f
Update vendored dependencies
cf-rabbit-bot Feb 8, 2026
8797b3f
Update go version to 1.25
cf-rabbit-bot Feb 12, 2026
8d22205
Print go version before building
selzoc Feb 12, 2026
7a5ce4a
Merge pull request #711 from cloudfoundry/print-go-version-build
selzoc Feb 12, 2026
c2e8d7c
Add recreate-vms-created-before option to recreate and deploy command…
Alphasite Feb 13, 2026
3d9af56
Update vendored dependencies
cf-rabbit-bot Feb 14, 2026
4ae681a
fix: remove ostruct dependency for Ruby 3.5+ compatibility
rubionic Dec 29, 2025
ba9761b
test: add comprehensive unit tests for PropertyStruct
rubionic Jan 8, 2026
b42e0b3
test: add Ruby version matrix testing for PropertyStruct
rubionic Jan 15, 2026
e2ac5a9
fix: PropertyStruct recursive wrapping and add Go tests to Ruby CI
rkoster Feb 20, 2026
5f4e8a3
ci: add Ruby 4.0 to test matrix
rkoster Feb 20, 2026
d5a1da5
fix: remove redundant nil check for staticcheck S1009
rkoster Feb 20, 2026
c999038
chore: remove devbox.json from PR (local dev only)
rkoster Feb 20, 2026
d6dfd38
style: fix Ruby Standard Style violations
rkoster Feb 20, 2026
e728312
fix: disable Style/ArgumentsForwarding for Ruby 2.6 compatibility
rkoster Feb 20, 2026
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
  •  
  •  
  •  
30 changes: 30 additions & 0 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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', '4.0', head]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Setup Image
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update && sudo apt-get install -y libpcap-dev
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Run Go erbrenderer tests
run: go test ./templatescompiler/erbrenderer/...
continue-on-error: ${{ matrix.ruby == 'head' }}
- 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
2 changes: 1 addition & 1 deletion deployment/instance/state/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ func (b *builder) renderJobTemplates(

func (b *builder) defaultAddress(networkRefs []NetworkRef, agentState agentclient.AgentState) (string, error) {

if (networkRefs == nil) || (len(networkRefs) == 0) {
if len(networkRefs) == 0 {
return "", errors.New("Must specify network") //nolint:staticcheck
}

Expand Down
Loading