diff --git a/cmd/fluxctl/release_cmd.go b/cmd/fluxctl/release_cmd.go index 8e0dd158af..ab57d11662 100644 --- a/cmd/fluxctl/release_cmd.go +++ b/cmd/fluxctl/release_cmd.go @@ -22,6 +22,7 @@ type controllerReleaseOpts struct { exclude []string dryRun bool interactive bool + force bool outputOpts cause update.Cause @@ -55,6 +56,7 @@ func (opts *controllerReleaseOpts) Command() *cobra.Command { cmd.Flags().StringSliceVar(&opts.exclude, "exclude", []string{}, "List of controllers to exclude") cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Do not release anything; just report back what would have been done") cmd.Flags().BoolVar(&opts.interactive, "interactive", false, "Select interactively which containers to update") + cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Disregard locks and container image filters (has no effect when used with --all or --update-all-images)") // Deprecated cmd.Flags().StringSliceVarP(&opts.services, "service", "s", []string{}, "Service to release") @@ -80,6 +82,16 @@ func (opts *controllerReleaseOpts) RunE(cmd *cobra.Command, args []string) error return newUsageError("please supply either --all, or at least one --controller=") } + if opts.force && opts.allControllers && opts.allImages { + return newUsageError("--force has no effect when used with --all and --update-all-images") + } + if opts.force && opts.allControllers { + fmt.Fprintf(cmd.OutOrStderr(), "Warning: --force will not ignore locked controllers when used with --all\n") + } + if opts.force && opts.allImages { + fmt.Fprintf(cmd.OutOrStderr(), "Warning: --force will not ignore container image tags when used with --update-all-images\n") + } + var controllers []update.ResourceSpec if opts.allControllers { controllers = []update.ResourceSpec{update.ResourceSpecAll} @@ -133,6 +145,7 @@ func (opts *controllerReleaseOpts) RunE(cmd *cobra.Command, args []string) error ImageSpec: image, Kind: kind, Excludes: excludes, + Force: opts.force, } jobID, err := opts.API.UpdateManifests(ctx, update.Spec{ Type: update.Images, diff --git a/release/releaser_test.go b/release/releaser_test.go index 72a6e3c2e9..cf5fe1f2fa 100644 --- a/release/releaser_test.go +++ b/release/releaser_test.go @@ -70,6 +70,22 @@ var ( }, } + semverHwImg = "quay.io/weaveworks/helloworld:3.0.0" + semverHwRef, _ = image.ParseRef(semverHwImg) + semverSvcID = flux.MustParseResourceID("default:deployment/semver") + semverSvc = cluster.Controller{ + ID: semverSvcID, + Containers: cluster.ContainersOrExcuse{ + Containers: []resource.Container{ + { + Name: helloContainer, + Image: oldRef, + }, + }, + }, + } + semverSvcSpec, _ = update.ParseResourceSpec(semverSvc.ID.String()) + testSvcID = flux.MustParseResourceID("default:deployment/test-service") testSvc = cluster.Controller{ ID: testSvcID, @@ -95,10 +111,15 @@ var ( // this is what we store in the registry cache canonSidecarRef, _ = image.ParseRef("index.docker.io/weaveworks/sidecar:master-a000002") - timeNow = time.Now() + timeNow = time.Now() + timePast = timeNow.Add(-1 * time.Minute) mockRegistry = ®istryMock.Registry{ Images: []image.Info{ + { + ID: semverHwRef, + CreatedAt: timePast, + }, { ID: newHwRef, CreatedAt: timeNow, @@ -154,6 +175,11 @@ var ignoredNotInCluster = update.ControllerResult{ Error: update.NotInCluster, } +var skippedLocked = update.ControllerResult{ + Status: update.ReleaseStatusSkipped, + Error: update.Locked, +} + var skippedNotInCluster = update.ControllerResult{ Status: update.ReleaseStatusSkipped, Error: update.NotInCluster, @@ -368,6 +394,222 @@ func Test_FilterLogic(t *testing.T) { } } +func Test_Force_lockedController(t *testing.T) { + cluster := mockCluster(lockedSvc) + success := update.ControllerResult{ + Status: update.ReleaseStatusSuccess, + PerContainer: []update.ContainerUpdate{ + { + Container: lockedContainer, + Current: oldLockedRef, + Target: newLockedRef, + }, + }, + } + for _, tst := range []struct { + Name string + Spec update.ReleaseSpec + Expected update.Result + }{ + { + Name: "force ignores service lock (--controller --update-image)", + Spec: update.ReleaseSpec{ + ServiceSpecs: []update.ResourceSpec{lockedSvcSpec}, + ImageSpec: update.ImageSpecFromRef(newLockedRef), + Kind: update.ReleaseKindExecute, + Excludes: []flux.ResourceID{}, + Force: true, + }, + Expected: update.Result{ + flux.MustParseResourceID("default:deployment/locked-service"): success, + flux.MustParseResourceID("default:deployment/helloworld"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/test-service"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/multi-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/list-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/semver"): ignoredNotIncluded, + }, + }, + { + Name: "force does not ignore lock if updating all controllers (--all --update-image)", + Spec: update.ReleaseSpec{ + ServiceSpecs: []update.ResourceSpec{update.ResourceSpecAll}, + ImageSpec: update.ImageSpecFromRef(newLockedRef), + Kind: update.ReleaseKindExecute, + Excludes: []flux.ResourceID{}, + Force: true, + }, + Expected: update.Result{ + flux.MustParseResourceID("default:deployment/locked-service"): skippedLocked, + flux.MustParseResourceID("default:deployment/helloworld"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/list-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/semver"): skippedNotInCluster, + }, + }, + { + Name: "force ignores service lock (--controller --update-all-images)", + Spec: update.ReleaseSpec{ + ServiceSpecs: []update.ResourceSpec{lockedSvcSpec}, + ImageSpec: update.ImageSpecLatest, + Kind: update.ReleaseKindExecute, + Excludes: []flux.ResourceID{}, + Force: true, + }, + Expected: update.Result{ + flux.MustParseResourceID("default:deployment/locked-service"): success, + flux.MustParseResourceID("default:deployment/helloworld"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/test-service"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/multi-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/list-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/semver"): ignoredNotIncluded, + }, + }, + { + Name: "force does not ignore lock if updating all controllers (--all --update-all-images)", + Spec: update.ReleaseSpec{ + ServiceSpecs: []update.ResourceSpec{update.ResourceSpecAll}, + ImageSpec: update.ImageSpecLatest, + Kind: update.ReleaseKindExecute, + Excludes: []flux.ResourceID{}, + Force: true, + }, + Expected: update.Result{ + flux.MustParseResourceID("default:deployment/locked-service"): skippedLocked, + flux.MustParseResourceID("default:deployment/helloworld"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/list-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/semver"): skippedNotInCluster, + }, + }, + } { + t.Run(tst.Name, func(t *testing.T) { + checkout, cleanup := setup(t) + defer cleanup() + testRelease(t, &ReleaseContext{ + cluster: cluster, + manifests: mockManifests, + registry: mockRegistry, + repo: checkout, + }, tst.Spec, tst.Expected) + }) + } +} + +func Test_Force_filteredContainer(t *testing.T) { + cluster := mockCluster(semverSvc) + successNew := update.ControllerResult{ + Status: update.ReleaseStatusSuccess, + PerContainer: []update.ContainerUpdate{ + { + Container: helloContainer, + Current: oldRef, + Target: newHwRef, + }, + }, + } + successSemver := update.ControllerResult{ + Status: update.ReleaseStatusSuccess, + PerContainer: []update.ContainerUpdate{ + { + Container: helloContainer, + Current: oldRef, + Target: semverHwRef, + }, + }, + } + for _, tst := range []struct { + Name string + Spec update.ReleaseSpec + Expected update.Result + }{ + { + Name: "force ignores container tag pattern (--controller --update-image)", + Spec: update.ReleaseSpec{ + ServiceSpecs: []update.ResourceSpec{semverSvcSpec}, + ImageSpec: update.ImageSpecFromRef(newHwRef), // does not match filter + Kind: update.ReleaseKindExecute, + Excludes: []flux.ResourceID{}, + Force: true, + }, + Expected: update.Result{ + flux.MustParseResourceID("default:deployment/semver"): successNew, + flux.MustParseResourceID("default:deployment/locked-service"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/helloworld"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/test-service"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/multi-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/list-deploy"): ignoredNotIncluded, + }, + }, + { + Name: "force ignores container tag pattern (--all --update-image)", + Spec: update.ReleaseSpec{ + ServiceSpecs: []update.ResourceSpec{update.ResourceSpecAll}, + ImageSpec: update.ImageSpecFromRef(newHwRef), // does not match filter + Kind: update.ReleaseKindExecute, + Excludes: []flux.ResourceID{}, + Force: true, + }, + Expected: update.Result{ + flux.MustParseResourceID("default:deployment/semver"): successNew, + flux.MustParseResourceID("default:deployment/locked-service"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/helloworld"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/list-deploy"): skippedNotInCluster, + }, + }, + { + Name: "force complies with semver when updating all images (--controller --update-all-image)", + Spec: update.ReleaseSpec{ + ServiceSpecs: []update.ResourceSpec{semverSvcSpec}, + ImageSpec: update.ImageSpecLatest, // will filter images by semver and pick newest version + Kind: update.ReleaseKindExecute, + Excludes: []flux.ResourceID{}, + Force: true, + }, + Expected: update.Result{ + flux.MustParseResourceID("default:deployment/semver"): successSemver, + flux.MustParseResourceID("default:deployment/locked-service"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/helloworld"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/test-service"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/multi-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/list-deploy"): ignoredNotIncluded, + }, + }, + { + Name: "force complies with semver when updating all images (--all --update-all-image)", + Spec: update.ReleaseSpec{ + ServiceSpecs: []update.ResourceSpec{update.ResourceSpecAll}, + ImageSpec: update.ImageSpecLatest, + Kind: update.ReleaseKindExecute, + Excludes: []flux.ResourceID{}, + Force: true, + }, + Expected: update.Result{ + flux.MustParseResourceID("default:deployment/semver"): successSemver, + flux.MustParseResourceID("default:deployment/locked-service"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/helloworld"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/list-deploy"): skippedNotInCluster, + }, + }, + } { + t.Run(tst.Name, func(t *testing.T) { + checkout, cleanup := setup(t) + defer cleanup() + testRelease(t, &ReleaseContext{ + cluster: cluster, + manifests: mockManifests, + registry: mockRegistry, + repo: checkout, + }, tst.Spec, tst.Expected) + }) + } +} + func Test_ImageStatus(t *testing.T) { cluster := mockCluster(hwSvc, lockedSvc, testSvc) upToDateRegistry := ®istryMock.Registry{ diff --git a/update/release.go b/update/release.go index eb17b1a2ff..764857f185 100644 --- a/update/release.go +++ b/update/release.go @@ -152,13 +152,16 @@ func (s ReleaseSpec) filters(rc ReleaseContext) ([]ControllerFilter, []Controlle postfilters = append(postfilters, &SpecificImageFilter{id}) } - // Locked filter - services, err := rc.ServicesWithPolicies() - if err != nil { - return nil, nil, err + // Only consider locked images if specific controllers requested and forced + if len(ids) == 0 || !s.Force { + // Locked filter + services, err := rc.ServicesWithPolicies() + if err != nil { + return nil, nil, err + } + lockedSet := services.OnlyWithPolicy(policy.Locked) + postfilters = append(postfilters, &LockedFilter{lockedSet.ToSlice()}) } - lockedSet := services.OnlyWithPolicy(policy.Locked) - postfilters = append(postfilters, &LockedFilter{lockedSet.ToSlice()}) return prefilters, postfilters, nil } @@ -232,8 +235,11 @@ func (s ReleaseSpec) calculateImageUpdates(rc ReleaseContext, candidates []*Cont currentImageID := container.Image tagPattern := policy.PatternAll - if pattern, ok := u.Resource.Policy().Get(policy.TagPrefix(container.Name)); ok { - tagPattern = policy.NewPattern(pattern) + // Only consider _all_ images if specific image requested and forced + if s.ImageSpec == ImageSpecLatest || !s.Force { + if pattern, ok := u.Resource.Policy().Get(policy.TagPrefix(container.Name)); ok { + tagPattern = policy.NewPattern(pattern) + } } filteredImages := imageRepos.GetRepoImages(currentImageID.Name).FilterAndSort(tagPattern)