Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update plugin to check for a minimum required version #121

Merged
merged 20 commits into from
Jan 8, 2024
Merged
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
6168db1
fix: check deactivated plans in instances without pending upgrade
zucchinidev Dec 13, 2023
1e7f3cc
feat: check up to date also performs check deactivated plans
zucchinidev Dec 13, 2023
7d488ab
chore: check-up-to-date check that it is set even with nil value
zucchinidev Dec 13, 2023
9a2bb6c
test: improve description
zucchinidev Dec 13, 2023
f34fd9c
test: improve description
zucchinidev Dec 13, 2023
563dcae
feat: check version when using -check-up-to-date
zucchinidev Dec 13, 2023
767f4a1
fix: remove unnecessary code
zucchinidev Dec 13, 2023
8d2c8ec
chore: delete unnecessary code
zucchinidev Dec 13, 2023
f4a3da9
Merge remote-tracking branch 'origin/main' into check_instances_up_to…
zucchinidev Jan 2, 2024
a0aa252
feat: update upgrade-all plugin to check for a minimum required version
zucchinidev Jan 4, 2024
9b3bed7
test: use the real flag name
zucchinidev Jan 4, 2024
03fb9b9
chore: use method name with a correct name
zucchinidev Jan 4, 2024
b57680c
chore: follow name conventions
zucchinidev Jan 4, 2024
65e2b11
chore: use check-up-to-date flag instead of fail-if-not-up-to-date
zucchinidev Jan 5, 2024
ecb38a3
chore: use check-up-to-date in upgrader configuration
zucchinidev Jan 5, 2024
bd81435
chore: centralise validation
zucchinidev Jan 5, 2024
0cbf0a3
test: update description
zucchinidev Jan 5, 2024
ac98b26
feat: remove incorrect method to improve readability
zucchinidev Jan 5, 2024
da44dcc
fix: typo in variable
zucchinidev Jan 8, 2024
5c83394
chore: follow style conventions
zucchinidev Jan 8, 2024
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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ cf install-plugin <path_to_plugin_binary>
```

### Releasing
To create a new GitHub release, decide on a new version number [according to Semanitc Versioning](https://semver.org/), and then:
To create a new GitHub release, decide on a new version number [according to Semantic Versioning](https://semver.org/), and then:
1. Create a tag on the main branch with a leading `v`:
`git tag vX.Y.X`
1. Push the tag:
Expand All @@ -32,9 +32,10 @@ To create a new GitHub release, decide on a new version number [according to Sem
cf upgrade-all-services <broker_name> [options]

Options:
-parallel - number of upgrades to run in parallel (defaults to 10)
-loghttp - log HTTP requests and responses
-dry-run - print the service instances that would be upgraded
-check-up-to-date - checks and fails if any service instance is not up-to-date - implies a dry-run
-check-deactivated-plans - checks whether any of the plans have been deactivated. If any deactivated plans are found, the command will fail
-parallel - number of upgrades to run in parallel (defaults to 10)
-loghttp - log HTTP requests and responses
-dry-run - print the service instances that would be upgraded
-min-version-required <major.minor.patch> - checks and fails if any service instance has a version less than the minimum required <major.minor.patch>
-check-up-to-date - checks and fails if any service instance is not up-to-date. An instance is not up-to-date if it is marked as upgradable or belongs to a deactivated plan
-check-deactivated-plans - checks and fails if any of the plans have been deactivated
```
33 changes: 13 additions & 20 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Config struct {
SkipSSLValidation bool
HTTPLogging bool
DryRun bool
MinVersionRequired string
CheckUpToDate bool
CheckDeactivatedPlans bool
ParallelUpgrades int
Expand All @@ -28,41 +29,33 @@ func ParseConfig(conn CLIConnection, args []string) (Config, error) {
flagSet.BoolVar(&cfg.HTTPLogging, httpLoggingFlag, httpLoggingDefault, httpLoggingDescription)
flagSet.BoolVar(&cfg.DryRun, dryRunFlag, dryRunDefault, dryRunDescription)
flagSet.BoolVar(&cfg.CheckUpToDate, checkUpToDateFlag, checkUpToDateDefault, checkUpToDateDescription)
flagSet.StringVar(&cfg.MinVersionRequired, minVersionRequiredFlag, minVersionRequiredDefault, minVersionRequiredDescription)
flagSet.BoolVar(&cfg.CheckDeactivatedPlans, checkDeactivatedPlansFlag, checkDeactivatedPlansDefault, checkDeactivatedPlansDescription)

// This ranges over a chain of functions, each of which performs a single action and may return an error.
// The chain breaks at the first error received. It arguably reads better than repetitive error handling logic.
for _, s := range []func() error{
func() error {
return validateLoginStatus(conn)
},
func() error {
return validateAPIVersion(conn)
},
func() error {
return read("access token", conn.AccessToken, &cfg.APIToken)
},
func() error {
return read("API endpoint", conn.ApiEndpoint, &cfg.APIEndpoint)
},
func() error {
return read("skip SSL validation", conn.IsSSLDisabled, &cfg.SkipSSLValidation)
},
func() error { return validateLoginStatus(conn) },
func() error { return validateAPIVersion(conn) },
func() error { return read("access token", conn.AccessToken, &cfg.APIToken) },
func() error { return read("API endpoint", conn.ApiEndpoint, &cfg.APIEndpoint) },
func() error { return read("skip SSL validation", conn.IsSSLDisabled, &cfg.SkipSSLValidation) },
func() (err error) {
cfg.BrokerName, err = parseCommandLine(flagSet, args)
return
},
func() error {
return validateParallelUpgrades(cfg.ParallelUpgrades)
},
func() error {
return validateBrokerName(cfg.BrokerName)
func() error { return validateParallelUpgrades(cfg.ParallelUpgrades) },
func() error { return validateBrokerName(cfg.BrokerName) },
func() (err error) {
cfg.MinVersionRequired, err = validateMinVersionRequired(cfg.MinVersionRequired)
return
},
} {
if err := s(); err != nil {
return Config{}, err
}
}

return cfg, nil
}

Expand Down
39 changes: 39 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config_test

import (
"fmt"

"upgrade-all-services-cli-plugin/internal/config"
"upgrade-all-services-cli-plugin/internal/config/configfakes"

Expand Down Expand Up @@ -309,6 +310,44 @@ var _ = Describe("Config", func() {
})
})

Describe("min-version-required", func() {
When("not specified", func() {
It("is not set", func() {
Expect(cfg.MinVersionRequired).To(BeEmpty())
})
})

When("specified without version", func() {
BeforeEach(func() {
fakeArgs = append(fakeArgs, "-min-version-required=")
})

It("an empty value is set", func() {
Expect(cfg.MinVersionRequired).To(BeEmpty())
})
})

When("specified with a non-semver version", func() {
BeforeEach(func() {
fakeArgs = append(fakeArgs, "-min-version-required", "invalid version")
})

It("returns an error", func() {
Expect(cfgErr).To(MatchError(ContainSubstring("error parsing min-version-required option: Malformed version: invalid version")))
})
})

When("specified with version", func() {
BeforeEach(func() {
fakeArgs = append(fakeArgs, "-min-version-required", "1.3.0")
})

It("is set and the value is the version", func() {
Expect(cfg.MinVersionRequired).To(Equal("1.3.0"))
})
})
})

Describe("invalid combinations", func() {
When("-dry-run and -parallel are specified together", func() {
BeforeEach(func() {
Expand Down
6 changes: 5 additions & 1 deletion internal/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ const (

checkUpToDateDefault = false
checkUpToDateFlag = "check-up-to-date"
checkUpToDateDescription = "checks and fails if any service instance is not up-to-date - implies a dry-run"
checkUpToDateDescription = "checks and fails if any service instance is not up-to-date. An instance is not up-to-date if it is marked as upgradable or belongs to a deactivated plan"

minVersionRequiredDefault = ""
minVersionRequiredFlag = "min-version-required"
minVersionRequiredDescription = "--min-version-required <major.minor.patch>. Checks and fails if any service instance has a version lower than the specified"

checkDeactivatedPlansDefault = false
checkDeactivatedPlansFlag = "check-deactivated-plans"
Expand Down
1 change: 1 addition & 0 deletions internal/config/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func UsageOptions() map[string]string {
parallelFlag: parallelDescription,
httpLoggingFlag: httpLoggingDescription,
dryRunFlag: dryRunDescription,
minVersionRequiredFlag: minVersionRequiredDescription,
checkUpToDateFlag: checkUpToDateDescription,
checkDeactivatedPlansFlag: checkDeactivatedPlansDescription,
}
Expand Down
12 changes: 12 additions & 0 deletions internal/config/validations.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,15 @@ func validateBrokerName(name string) error {

return nil
}

func validateMinVersionRequired(ver string) (string, error) {
if ver == "" {
return "", nil
}

v, err := version.NewVersion(ver)
if err != nil {
return "", fmt.Errorf("error parsing min-version-required option: %w", err)
}
return v.String(), nil
}
27 changes: 27 additions & 0 deletions internal/upgrader/multi_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package upgrader

import (
"errors"
)

type MultiError struct {
Errors []error
}

// Append adds a non-nil error to the MultiError.
// If the provided error is nil, it will be ignored, preventing
// nil errors from being included in the error slice.
// This method simplifies error aggregation by centralizing nil checks.
func (m *MultiError) Append(err error) {
if err != nil {
m.Errors = append(m.Errors, err)
}
}

// Error implements the error interface. It returns a concatenated
// string of all the non-nil errors it contains. If there are no non-nil
// errors, it returns an empty string.
// This method allows MultiError to seamlessly integrate with typical error handling in Go.
func (m *MultiError) Error() string {
return errors.Join(m.Errors...).Error()
}
94 changes: 63 additions & 31 deletions internal/upgrader/upgrader.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"upgrade-all-services-cli-plugin/internal/ccapi"
"upgrade-all-services-cli-plugin/internal/versionchecker"
"upgrade-all-services-cli-plugin/internal/workers"
)

Expand Down Expand Up @@ -34,6 +35,7 @@ type UpgradeConfig struct {
BrokerName string
ParallelUpgrades int
DryRun bool
MinVersionRequired string
CheckUpToDate bool
CheckDeactivatedPlans bool
}
Expand All @@ -45,7 +47,7 @@ func Upgrade(api CFClient, log Logger, cfg UpgradeConfig) error {
}

if len(servicePlans) == 0 {
return fmt.Errorf(fmt.Sprintf("no service plans available for broker: %s", cfg.BrokerName))
return fmt.Errorf("no service plans available for broker: %s", cfg.BrokerName)
}

log.Printf("discovering service instances for broker: %s", cfg.BrokerName)
Expand All @@ -54,31 +56,75 @@ func Upgrade(api CFClient, log Logger, cfg UpgradeConfig) error {
return err
}

if cfg.CheckDeactivatedPlans {
if err := checkDeactivatedPlans(log, serviceInstances); err != nil {
upgradableInstances := discoverInstancesWithPendingUpgrade(log, serviceInstances)

switch {
case cfg.CheckDeactivatedPlans:
return checkDeactivatedPlans(log, serviceInstances)
case cfg.CheckUpToDate:
log.InitialTotals(len(serviceInstances), len(upgradableInstances))
defer log.FinalTotals()
var errs MultiError
performDryRun(upgradableInstances, log)
if len(upgradableInstances) > 0 {
errs.Append(fmt.Errorf("found %d instances which are not up-to-date", len(upgradableInstances)))
}
errs.Append(checkDeactivatedPlans(log, serviceInstances))
if len(errs.Errors) > 0 {
return &errs
}

return nil
case cfg.MinVersionRequired != "":
filteredInstances, err := filterInstancesVersionLessThanMinimumVersionRequired(serviceInstances, cfg.MinVersionRequired)
if err != nil {
return err
}
}

totalServiceInstances := len(serviceInstances)
upgradableInstances := discoverInstancesWithPendingUpgrade(log, serviceInstances)
if len(filteredInstances) == 0 {
log.Printf("no instances found with version less than required")
return nil
}

switch {
log.InitialTotals(len(serviceInstances), len(filteredInstances))
defer log.FinalTotals()
performDryRun(filteredInstances, log)
return fmt.Errorf("found %d service instances with a version less than the minimum required", len(filteredInstances))
case len(upgradableInstances) == 0:
log.Printf("no instances available to upgrade")
return nil
case cfg.CheckUpToDate:
log.InitialTotals(totalServiceInstances, len(upgradableInstances))
return performCheckUpToDate(upgradableInstances, log)
case cfg.DryRun:
log.InitialTotals(totalServiceInstances, len(upgradableInstances))
return performDryRun(upgradableInstances, log)
log.InitialTotals(len(serviceInstances), len(upgradableInstances))
defer log.FinalTotals()
performDryRun(upgradableInstances, log)
return nil
default:
log.InitialTotals(totalServiceInstances, len(upgradableInstances))
log.InitialTotals(len(serviceInstances), len(upgradableInstances))
defer log.FinalTotals()
return performUpgrade(api, upgradableInstances, cfg.ParallelUpgrades, log)
}
}

func filterInstancesVersionLessThanMinimumVersionRequired(instances []ccapi.ServiceInstance, minVersionRequired string) ([]ccapi.ServiceInstance, error) {
checker, err := versionchecker.New(minVersionRequired)
if err != nil {
return nil, err
}

var filteredInstances []ccapi.ServiceInstance
for _, instance := range instances {
is, err := checker.IsInstanceVersionLessThanMinimumRequired(instance.MaintenanceInfoVersion)
if err != nil {
return nil, err
}

if is {
filteredInstances = append(filteredInstances, instance)
}
}
return filteredInstances, nil
}

func checkDeactivatedPlans(log Logger, instances []ccapi.ServiceInstance) error {
var deactivatedPlanFound bool
for _, instance := range instances {
Expand Down Expand Up @@ -131,31 +177,17 @@ func performUpgrade(api CFClient, upgradableInstances []ccapi.ServiceInstance, p
}
})

log.FinalTotals()
if !log.HasUpgradeSucceeded() {
return errors.New("there were failures upgrading one or more instances. Review the logs for more information")
}
return nil
}

func performCheckUpToDate(upgradableInstances []ccapi.ServiceInstance, log Logger) error {
err := performDryRun(upgradableInstances, log)
if err != nil {
return fmt.Errorf("check up-to-date failed because dry-run returned the following error: %w", err)
}
if len(upgradableInstances) > 0 {
return fmt.Errorf("check up-to-date failed: found %d instances which are not up-to-date", len(upgradableInstances))
}
return nil
}

func performDryRun(upgradableInstances []ccapi.ServiceInstance, log Logger) error {
log.Printf("the following service instances would be upgraded:")
for _, i := range upgradableInstances {
log.UpgradeFailed(i, time.Duration(0), fmt.Errorf("dry-run prevented upgrade"))
func performDryRun(serviceInstances []ccapi.ServiceInstance, log Logger) {
for _, i := range serviceInstances {
dryRunErr := fmt.Errorf("dry-run prevented upgrade instance guid %s", i.GUID)
log.UpgradeFailed(i, time.Duration(0), dryRunErr)
}
log.FinalTotals()
return nil
}

func discoverInstancesWithPendingUpgrade(log Logger, serviceInstances []ccapi.ServiceInstance) []ccapi.ServiceInstance {
Expand Down
Loading
Loading