From 3cfdcf59fe4791dd0311a1f5a4ceceb982956389 Mon Sep 17 00:00:00 2001 From: Diogo Torres Date: Thu, 3 Sep 2026 08:16:06 +0100 Subject: [PATCH] Re-register the runner scale set when it is gone from the Actions service (#4571) --- .../autoscalingrunnerset_controller.go | 47 +++- .../autoscalingrunnerset_controller_test.go | 254 ++++++++++++++++++ .../multiclient/fake/client.go | 11 + go.mod | 8 +- go.sum | 15 +- 5 files changed, 322 insertions(+), 13 deletions(-) diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index e48f75ff..3f84b346 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -18,6 +18,7 @@ package actionsgithubcom import ( "context" + "errors" "fmt" "maps" "strconv" @@ -339,6 +340,16 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl ) switch { case kerrors.IsNotFound(err): + gone, err := r.runnerScaleSetGone(ctx, &autoscalingRunnerSet) + if err != nil { + log.Error(err, "Failed to confirm the runner scale set exists before creating the listener") + return ctrl.Result{}, err + } + if gone { + log.Info("Recorded runner scale set no longer exists on the Actions service. Registering it again before creating the listener") + return r.createRunnerScaleSet(ctx, &autoscalingRunnerSet, log) + } + log.Info("AutoscalingListener does not exist, creating autoscaling listener") return r.createAutoScalingListenerForRunnerSet(ctx, &autoscalingRunnerSet, &ephemeralRunnerSet, log) case err != nil: @@ -729,8 +740,12 @@ func (r *AutoscalingRunnerSetReconciler) deleteRunnerScaleSet(ctx context.Contex return err } - err = actionsClient.DeleteRunnerScaleSet(ctx, runnerScaleSetID) - if err != nil { + switch err := actionsClient.DeleteRunnerScaleSet(ctx, runnerScaleSetID); { + case errors.Is(err, scaleset.NotFoundError): + // Already gone, so the desired state is met. Returning the error instead would leave the + // finalizer in place and the autoscaling runner set stuck in Terminating. + logger.Info("Runner scale set is already deleted from the Actions service", "runnerScaleSetId", runnerScaleSetID) + case err != nil: logger.Error(err, "Failed to delete runner scale set", "runnerScaleSetId", runnerScaleSetID) return err } @@ -794,6 +809,34 @@ func (r *AutoscalingRunnerSetReconciler) createAutoScalingListenerForRunnerSet(c return ctrl.Result{}, nil } +// runnerScaleSetGone reports whether the runner scale set recorded on the autoscaling runner set is +// no longer present on the Actions service. A scale set can disappear out of band: deleted from the +// organization settings, or dropped and re-registered with a fresh ID. The listener bakes the ID in +// at startup and crash-loops when it is gone, so this is checked before the listener is created. +// +// Only a definitive not-found counts as gone. Any other error is returned so a transient Actions +// service outage never causes a scale set to be registered a second time. +func (r *AutoscalingRunnerSetReconciler) runnerScaleSetGone(ctx context.Context, autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet) (bool, error) { + runnerScaleSetID, err := strconv.Atoi(autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey]) + if err != nil { + return false, fmt.Errorf("failed to parse runner scale set ID: %w", err) + } + + actionsClient, err := r.GetActionsService(ctx, autoscalingRunnerSet) + if err != nil { + return false, fmt.Errorf("failed to initialize Actions service client: %w", err) + } + + switch _, err := actionsClient.GetRunnerScaleSetByID(ctx, runnerScaleSetID); { + case errors.Is(err, scaleset.NotFoundError): + return true, nil + case err != nil: + return false, fmt.Errorf("failed to get runner scale set %d: %w", runnerScaleSetID, err) + default: + return false, nil + } +} + // TODO: change that func shouldCreateScaleSet(autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet) bool { scaleSetIDRaw, ok := autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey] diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 11a03dc4..e3f04bf8 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -8,7 +8,9 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "sync" + "sync/atomic" "time" corev1 "k8s.io/api/core/v1" @@ -2081,3 +2083,255 @@ var _ = Describe("Test resource version and build version mismatch", func() { ).Should(BeTrue()) }) }) + +var _ = Describe("Test AutoscalingRunnerSet with a stale runner scale set", Ordered, func() { + const staleRunnerScaleSetID = 42 + const freshRunnerScaleSetID = 1 + + var originalBuildVersion string + buildVersion := "0.1.0" + + BeforeAll(func() { + originalBuildVersion = build.Version + build.Version = buildVersion + }) + + AfterAll(func() { + build.Version = originalBuildVersion + }) + + newAutoscalingRunnerSet := func(namespace, configSecretName string, annotations map[string]string) *v1alpha1.AutoscalingRunnerSet { + min := 1 + max := 10 + return &v1alpha1.AutoscalingRunnerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-asrs", + Namespace: namespace, + Labels: map[string]string{LabelKeyKubernetesVersion: buildVersion}, + Annotations: annotations, + }, + Spec: v1alpha1.AutoscalingRunnerSetSpec{ + GitHubConfigUrl: "https://github.com/owner/repo", + GitHubConfigSecret: configSecretName, + MaxRunners: &max, + MinRunners: &min, + RunnerGroup: "testgroup", + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "runner", + Image: "ghcr.io/actions/runner", + }, + }, + }, + }, + }, + } + } + + registeredAnnotations := func(runnerScaleSetID int) map[string]string { + return map[string]string{ + runnerScaleSetIDAnnotationKey: strconv.Itoa(runnerScaleSetID), + AnnotationKeyGitHubRunnerScaleSetName: "test-asrs", + AnnotationKeyGitHubRunnerGroupName: "testgroup", + } + } + + Context("When the recorded runner scale set no longer exists on the Actions service", func() { + var ctx context.Context + var mgr ctrl.Manager + var autoscalingNS *corev1.Namespace + var autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet + + BeforeEach(func() { + ctx = context.Background() + autoscalingNS, mgr = createNamespace(GinkgoT(), k8sClient) + configSecret := createDefaultSecret(GinkgoT(), k8sClient, autoscalingNS.Name) + + controller := &AutoscalingRunnerSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: logf.Log, + ControllerNamespace: autoscalingNS.Name, + DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( + scalefake.WithClient( + scalefake.NewClient( + scalefake.WithGetRunnerGroupByName(&scaleset.RunnerGroup{ID: 1, Name: "testgroup"}, nil), + scalefake.WithGetRunnerScaleSetByIDFunc(func(_ context.Context, runnerScaleSetID int) (*scaleset.RunnerScaleSet, error) { + if runnerScaleSetID == staleRunnerScaleSetID { + return nil, scaleset.NotFoundError + } + return &scaleset.RunnerScaleSet{ID: runnerScaleSetID, Name: "test-asrs", RunnerGroupID: 1, RunnerGroupName: "testgroup"}, nil + }), + scalefake.WithGetRunnerScaleSet(nil, nil), + scalefake.WithCreateRunnerScaleSet(&scaleset.RunnerScaleSet{ID: freshRunnerScaleSetID, Name: "test-asrs", RunnerGroupID: 1, RunnerGroupName: "testgroup"}, nil), + scalefake.WithDeleteRunnerScaleSet(nil), + ), + ), + )), + }, + } + Expect(controller.SetupWithManager(mgr)).To(Succeed(), "failed to setup controller") + startManagers(GinkgoT(), mgr) + + autoscalingRunnerSet = newAutoscalingRunnerSet(autoscalingNS.Name, configSecret.Name, registeredAnnotations(staleRunnerScaleSetID)) + Expect(k8sClient.Create(ctx, autoscalingRunnerSet)).To(Succeed(), "failed to create AutoScalingRunnerSet") + }) + + It("registers the runner scale set again and creates the listener with the new ID", func() { + Eventually( + func() (string, error) { + updated := new(v1alpha1.AutoscalingRunnerSet) + if err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, updated); err != nil { + return "", err + } + return updated.Annotations[runnerScaleSetIDAnnotationKey], nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeEquivalentTo(strconv.Itoa(freshRunnerScaleSetID)), "the stale runner scale set ID should be replaced") + + Eventually( + func() (int, error) { + listener := new(v1alpha1.AutoscalingListener) + if err := k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener); err != nil { + return 0, err + } + return listener.Spec.RunnerScaleSetID, nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeEquivalentTo(freshRunnerScaleSetID), "the listener should never be created with the stale runner scale set ID") + }) + }) + + Context("When the Actions service cannot confirm whether the runner scale set exists", func() { + var ctx context.Context + var mgr ctrl.Manager + var autoscalingNS *corev1.Namespace + var autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet + var createCalls atomic.Int64 + + BeforeEach(func() { + ctx = context.Background() + autoscalingNS, mgr = createNamespace(GinkgoT(), k8sClient) + configSecret := createDefaultSecret(GinkgoT(), k8sClient, autoscalingNS.Name) + createCalls.Store(0) + + controller := &AutoscalingRunnerSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: logf.Log, + ControllerNamespace: autoscalingNS.Name, + DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( + scalefake.WithClient( + scalefake.NewClient( + scalefake.WithGetRunnerGroupByName(&scaleset.RunnerGroup{ID: 1, Name: "testgroup"}, nil), + scalefake.WithGetRunnerScaleSetByIDFunc(func(_ context.Context, _ int) (*scaleset.RunnerScaleSet, error) { + return nil, fmt.Errorf("actions service is unavailable") + }), + scalefake.WithGetRunnerScaleSet(nil, nil), + scalefake.WithCreateRunnerScaleSetFunc(func(_ context.Context, rs *scaleset.RunnerScaleSet) (*scaleset.RunnerScaleSet, error) { + createCalls.Add(1) + return &scaleset.RunnerScaleSet{ID: freshRunnerScaleSetID, Name: rs.Name, RunnerGroupID: 1, RunnerGroupName: "testgroup"}, nil + }), + scalefake.WithDeleteRunnerScaleSet(nil), + ), + ), + )), + }, + } + Expect(controller.SetupWithManager(mgr)).To(Succeed(), "failed to setup controller") + startManagers(GinkgoT(), mgr) + + autoscalingRunnerSet = newAutoscalingRunnerSet(autoscalingNS.Name, configSecret.Name, registeredAnnotations(staleRunnerScaleSetID)) + Expect(k8sClient.Create(ctx, autoscalingRunnerSet)).To(Succeed(), "failed to create AutoScalingRunnerSet") + }) + + It("leaves the runner scale set alone instead of registering a second one", func() { + Consistently( + func() (string, error) { + updated := new(v1alpha1.AutoscalingRunnerSet) + if err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, updated); err != nil { + return "", err + } + return updated.Annotations[runnerScaleSetIDAnnotationKey], nil + }, + "3s", + autoscalingRunnerSetTestInterval, + ).Should(BeEquivalentTo(strconv.Itoa(staleRunnerScaleSetID)), "a transient failure should never re-register the runner scale set") + + Expect(createCalls.Load()).To(BeEquivalentTo(0), "no runner scale set should be created while the service is unavailable") + }) + }) + + Context("When the runner scale set is already deleted from the Actions service", func() { + var ctx context.Context + var mgr ctrl.Manager + var autoscalingNS *corev1.Namespace + var autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet + + BeforeEach(func() { + ctx = context.Background() + autoscalingNS, mgr = createNamespace(GinkgoT(), k8sClient) + configSecret := createDefaultSecret(GinkgoT(), k8sClient, autoscalingNS.Name) + + controller := &AutoscalingRunnerSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: logf.Log, + ControllerNamespace: autoscalingNS.Name, + DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( + scalefake.WithClient( + scalefake.NewClient( + scalefake.WithGetRunnerGroupByName(&scaleset.RunnerGroup{ID: 1, Name: "testgroup"}, nil), + scalefake.WithGetRunnerScaleSetByIDFunc(func(_ context.Context, runnerScaleSetID int) (*scaleset.RunnerScaleSet, error) { + return &scaleset.RunnerScaleSet{ID: runnerScaleSetID, Name: "test-asrs", RunnerGroupID: 1, RunnerGroupName: "testgroup"}, nil + }), + scalefake.WithGetRunnerScaleSet(nil, nil), + scalefake.WithCreateRunnerScaleSet(&scaleset.RunnerScaleSet{ID: freshRunnerScaleSetID, Name: "test-asrs", RunnerGroupID: 1, RunnerGroupName: "testgroup"}, nil), + scalefake.WithDeleteRunnerScaleSet(scaleset.NotFoundError), + ), + ), + )), + }, + } + Expect(controller.SetupWithManager(mgr)).To(Succeed(), "failed to setup controller") + startManagers(GinkgoT(), mgr) + + autoscalingRunnerSet = newAutoscalingRunnerSet(autoscalingNS.Name, configSecret.Name, nil) + Expect(k8sClient.Create(ctx, autoscalingRunnerSet)).To(Succeed(), "failed to create AutoScalingRunnerSet") + }) + + It("does not leave the AutoscalingRunnerSet stuck in Terminating", func() { + Eventually( + func() error { + return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, new(v1alpha1.AutoscalingListener)) + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(Succeed(), "Listener should be created") + + Expect(k8sClient.Delete(ctx, autoscalingRunnerSet)).To(Succeed(), "failed to delete AutoScalingRunnerSet") + + Eventually( + func() error { + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, new(v1alpha1.AutoscalingRunnerSet)) + if err != nil && errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("AutoScalingRunnerSet is not deleted") + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(Succeed(), "the finalizer should be removed even though the runner scale set is already gone") + }) + }) +}) diff --git a/controllers/actions.github.com/multiclient/fake/client.go b/controllers/actions.github.com/multiclient/fake/client.go index 737092a8..969930fa 100644 --- a/controllers/actions.github.com/multiclient/fake/client.go +++ b/controllers/actions.github.com/multiclient/fake/client.go @@ -26,6 +26,13 @@ func WithGetRunnerScaleSetByID(result *scaleset.RunnerScaleSet, err error) Clien } } +// WithGetRunnerScaleSetByIDFunc configures a function to handle GetRunnerScaleSetByID calls dynamically +func WithGetRunnerScaleSetByIDFunc(fn func(context.Context, int) (*scaleset.RunnerScaleSet, error)) ClientOption { + return func(c *Client) { + c.getRunnerScaleSetByIDFunc = fn + } +} + // WithGetRunnerGroupByName configures the result of GetRunnerGroupByName func WithGetRunnerGroupByName(result *scaleset.RunnerGroup, err error) ClientOption { return func(c *Client) { @@ -135,6 +142,7 @@ type Client struct { err error } getRunnerGroupByNameFunc func(context.Context, string) (*scaleset.RunnerGroup, error) + getRunnerScaleSetByIDFunc func(context.Context, int) (*scaleset.RunnerScaleSet, error) createRunnerScaleSetResult struct { *scaleset.RunnerScaleSet err error @@ -219,6 +227,9 @@ func (c *Client) GetRunnerScaleSet(ctx context.Context, runnerGroupID int, runne } func (c *Client) GetRunnerScaleSetByID(ctx context.Context, runnerScaleSetID int) (*scaleset.RunnerScaleSet, error) { + if c.getRunnerScaleSetByIDFunc != nil { + return c.getRunnerScaleSetByIDFunc(ctx, runnerScaleSetID) + } return c.getRunnerScaleSetByIDResult.RunnerScaleSet, c.getRunnerScaleSetByIDResult.err } diff --git a/go.mod b/go.mod index 28a0a939..49b30aac 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 - github.com/actions/scaleset v0.4.0 + github.com/actions/scaleset v0.4.1-0.20260706130337-9b2803251ede github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/evanphx/json-patch v5.9.11+incompatible @@ -24,6 +24,7 @@ require ( github.com/onsi/ginkgo/v2 v2.31.0 github.com/onsi/gomega v1.42.0 github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/stretchr/testify v1.11.1 github.com/teambition/rrule-go v1.8.2 go.uber.org/multierr v1.11.0 @@ -114,7 +115,7 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/gonvenience/bunt v1.4.3 // indirect github.com/gonvenience/idem v0.0.3 // indirect @@ -150,7 +151,7 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-zglob v0.0.6 // indirect @@ -165,7 +166,6 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pquerna/otp v1.5.0 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index ef4e3ff6..743df072 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5Qx github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/actions-runner-controller/httpcache v0.2.0 h1:hCNvYuVPJ2xxYBymqBvH0hSiQpqz4PHF/LbU3XghGNI= github.com/actions-runner-controller/httpcache v0.2.0/go.mod h1:JLu9/2M/btPz1Zu/vTZ71XzukQHn2YeISPmJoM5exBI= -github.com/actions/scaleset v0.4.0 h1:691GC2AkHb3ZGjfNvatboYoRS7CLr3+4VcZk/6w9IbM= -github.com/actions/scaleset v0.4.0/go.mod h1:2L2I6rggFWV+zprDet6y7y7Vkm3HPudaup78eSc79Uo= +github.com/actions/scaleset v0.4.1-0.20260706130337-9b2803251ede h1:75hCLFGd2GLYrFry4TpbBcEbIYZQ6NCHIl3Kd34IyOU= +github.com/actions/scaleset v0.4.1-0.20260706130337-9b2803251ede/go.mod h1:+Ylz7IYPnOTJd8dZmMziJ7J9HEfZhdoH7iliEWSb/Ms= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= @@ -127,8 +127,8 @@ github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -188,8 +188,8 @@ github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI6 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -312,8 +312,9 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 h1:BXxTozrOU8zgC5dkpn3J6NTRdoP+hjok/e+ACr4Hibk= github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3/go.mod h1:x1uk6vxTiVuNt6S5R2UYgdhpj3oKojXvOXauHZ7dEnI= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=