diff --git a/.github/assets/Operator-Design.svg b/.github/assets/Operator-Design.svg index d25dd70..ef4e585 100644 --- a/.github/assets/Operator-Design.svg +++ b/.github/assets/Operator-Design.svg @@ -1,4 +1,4 @@ Kayroninfrastructurepreviewcontainerreferenceregistrystatusconditionalcloudformationtemplatereleasecachereadingwriting \ No newline at end of file + @font-face { font-family: Excalifont; src: url(data:font/woff2;base64,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); }Kayroninfrastructurepreviewcontainerreferenceregistryconditionalcloudformationtemplatereleasecachereadingwritingstatuslogging \ No newline at end of file diff --git a/pkg/cache/object.go b/pkg/cache/object.go index 9b0aee7..7e1e8e2 100644 --- a/pkg/cache/object.go +++ b/pkg/cache/object.go @@ -32,6 +32,8 @@ type Object struct { // Domain returns the hash based testing domain for preview deployments, or an // empty string for any other main release and non-testing environment. +// +// 1d0fd508.lite.testing.splits.org func (o Object) Domain(env string) string { // Note that we filter the domain name creation by preview deployments, // because at the time of writing we do not have any convenient way to tell @@ -58,6 +60,24 @@ func (o Object) Domain(env string) string { ) } +// Drift tries to detect a single valid state drift, in order to allow allow the +// operator chain to execute. Our current policy requires the following +// conditions to be true for a valid state drift. +// +// 1. the desired deployment must not be suspended +// +// 2. the current and desired state must not be equal +// +// 3. the desired state must not be empty +// +// 4. the container image for the desired state must be pushed +func (o Object) Drift() bool { + return !bool(o.Release.Deploy.Suspend) && + o.Artifact.Drift() && + !o.Artifact.Empty() && + o.Artifact.Valid() +} + func (o Object) Name() string { if o.kin == Infrastructure { return o.Release.Github.String() diff --git a/pkg/cache/object_test.go b/pkg/cache/object_test.go index ae47308..3c45b94 100644 --- a/pkg/cache/object_test.go +++ b/pkg/cache/object_test.go @@ -4,11 +4,113 @@ import ( "fmt" "testing" + "github.com/0xSplits/kayron/pkg/release/artifact" + "github.com/0xSplits/kayron/pkg/release/artifact/condition" + "github.com/0xSplits/kayron/pkg/release/artifact/reference" + "github.com/0xSplits/kayron/pkg/release/artifact/scheduler" "github.com/0xSplits/kayron/pkg/release/schema/release" + "github.com/0xSplits/kayron/pkg/release/schema/release/deploy" + "github.com/0xSplits/kayron/pkg/release/schema/release/deploy/suspend" "github.com/0xSplits/kayron/pkg/release/schema/release/docker" "github.com/0xSplits/kayron/pkg/release/schema/release/github" + "github.com/google/go-cmp/cmp" ) +func Test_Cache_Object_Drift(t *testing.T) { + testCases := []struct { + rel Object + dft bool + }{ + // Case 000, empty release, no drift + { + rel: Object{}, + dft: false, + }, + // Case 001, no drift + { + rel: Object{ + Artifact: artifact.Struct{ + Condition: condition.Struct{ + Success: true, + }, + Scheduler: scheduler.Struct{ + Current: "foo", + }, + Reference: reference.Struct{ + Desired: "foo", + }, + }, + }, + dft: false, + }, + // Case 002, drift, failed condition + { + rel: Object{ + Artifact: artifact.Struct{ + Condition: condition.Struct{ + Success: false, + }, + Scheduler: scheduler.Struct{ + Current: "foo", + }, + Reference: reference.Struct{ + Desired: "bar", + }, + }, + }, + dft: false, + }, + // Case 003, drift + { + rel: Object{ + Artifact: artifact.Struct{ + Condition: condition.Struct{ + Success: true, + }, + Scheduler: scheduler.Struct{ + Current: "foo", + }, + Reference: reference.Struct{ + Desired: "bar", + }, + }, + }, + dft: true, + }, + // Case 004, drift, suspended + { + rel: Object{ + Artifact: artifact.Struct{ + Condition: condition.Struct{ + Success: true, + }, + Scheduler: scheduler.Struct{ + Current: "foo", + }, + Reference: reference.Struct{ + Desired: "bar", + }, + }, + Release: release.Struct{ + Deploy: deploy.Struct{ + Suspend: suspend.Bool(true), + }, + }, + }, + dft: false, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%03d", i), func(t *testing.T) { + dft := tc.rel.Drift() + if dif := cmp.Diff(tc.dft, dft); dif != "" { + t.Fatalf("-expected +actual:\n%s", dif) + } + }) + } +} + func Test_Cache_Object_Parameter(t *testing.T) { testCases := []struct { obj Object diff --git a/pkg/operator/chain.go b/pkg/operator/chain.go index 781c346..4f37c87 100644 --- a/pkg/operator/chain.go +++ b/pkg/operator/chain.go @@ -46,9 +46,8 @@ func (o *Operator) Chain() [][]handler.Ensure { // our service releases. {o.registry}, - // Run the next steps in parallel in order to find the current and - // desired state of the release artifacts that we are tasked to - // managed. + // Run the next steps in parallel and split the execution graph based on the + // different underlying responsibilities. // // 1. Once the current and desired states of the runnable service // releases are known to have drifted apart, we can fetch the current @@ -57,10 +56,14 @@ func (o *Operator) Chain() [][]handler.Ensure { // that this operator function is only active in case of valid state // drift. // - // 2. Manage any relevant deployment status for visibility purposes. E.g. - // emit log messages and create or update pull request comments etc. + // 2. Emit log messages about the internal state of the system. This + // operator function runs on each and every reconciliation loop. + // + // 3. Manage any relevant deployment status for visibility purposes. + // E.g. create and update pull request comments about any preview + // deployment status. // - {o.infrastructure, o.status}, + {o.infrastructure, o.logging, o.status}, // With the CloudFormation templates uploaded to S3, we can construct the // payload to update the CloudFormation stack that we are responsible for. diff --git a/pkg/operator/container/task.go b/pkg/operator/container/task.go index f309a0c..00fdfdd 100644 --- a/pkg/operator/container/task.go +++ b/pkg/operator/container/task.go @@ -3,6 +3,7 @@ package container import ( "context" "slices" + "sort" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ecs" @@ -60,18 +61,73 @@ func (c *Container) task(det []detail) ([]task, error) { } for _, x := range out.Services { + // For our reconciliation of the current state here to make sense we have + // to ensure that the services being managed are actually controlled by + // ECS. If we do not check this we may end up getting the wrong kind of + // deployment information that we rely on to be provided in a certain way + // below. + + if x.DeploymentController.Type != types.DeploymentControllerTypeEcs { + c.log.Log( + "level", "warning", + "message", "skipping reconciliation for ECS service", + "reason", "service does not use ECS controller", + "cluster", *x.ClusterArn, + "service", *x.ServiceArn, + ) + + { + continue + } + } + + // There might be inactive or draining services with our desired service + // labels in case we updated CloudFormation stacks multiple times during + // with preview deployments during testing. We only want to consider the + // current state of those stacks that are still active, because the + // inactive versions have most likely been deleted already. + if aws.ToString(x.Status) != "ACTIVE" { - // There might be inactive or draining services with our desired service - // labels in case we updated CloudFormation stacks multiple times during - // with preview deployments during testing. We only want to consider the - // current state of those stacks that are still active, because the - // inactive versions have most likely been deleted already. + c.log.Log( + "level", "debug", + "message", "skipping reconciliation for ECS service", + "reason", "ECS service is inactive", + "cluster", *x.ClusterArn, + "service", *x.ServiceArn, + ) + + { + continue + } + } + + // Try to find the task definition that is successfully deployed. Here we + // prevent picking those new task definitions prematurely that are still + // transitioning during deployments. + + var arn string + { + arn = tasArn(x.Deployments) + } + + if arn == "" { + c.log.Log( + "level", "debug", + "message", "skipping reconciliation for ECS service", + "reason", "ECS service has no completed task definition", + "cluster", *x.ClusterArn, + "service", *x.ServiceArn, + ) { continue } } + // Try to identify the service and task definition based on their resource + // tags. If a service is not labelled with our desired 'service' tag, + // then we cannot work with it properly moving forward. + var pre string var ser string { @@ -97,7 +153,7 @@ func (c *Container) task(det []detail) ([]task, error) { // executes for its own index. tas[i] = task{ - arn: *x.TaskDefinition, + arn: arn, pre: pre, ser: ser, } @@ -148,3 +204,22 @@ func serTag(tag []types.Tag, key string) string { return "" } + +func tasArn(dep []types.Deployment) string { + // Make sure we can select the newest deployment first, so sort by time in + // descending direction. + + sort.Slice(dep, func(i, j int) bool { + return aws.ToTime(dep[i].CreatedAt).After(aws.ToTime(dep[j].CreatedAt)) + }) + + // Now pick the task definition ARN of the first completed deployment. + + for _, y := range dep { + if y.RolloutState == types.DeploymentRolloutStateCompleted { + return aws.ToString(y.TaskDefinition) + } + } + + return "" +} diff --git a/pkg/operator/logging/active.go b/pkg/operator/logging/active.go new file mode 100644 index 0000000..464558d --- /dev/null +++ b/pkg/operator/logging/active.go @@ -0,0 +1,6 @@ +package logging + +// Active defines this worker handler to always be executed. +func (l *Logging) Active() bool { + return true +} diff --git a/pkg/operator/logging/ensure.go b/pkg/operator/logging/ensure.go new file mode 100644 index 0000000..d9995cb --- /dev/null +++ b/pkg/operator/logging/ensure.go @@ -0,0 +1,52 @@ +package logging + +import ( + "github.com/0xSplits/kayron/pkg/cache" +) + +func (l *Logging) Ensure() error { + var can bool + { + can = l.pol.Cancel() + } + + if can { + l.log.Log( + "level", "info", + "message", "deployment in progress", + ) + + return nil + } + + var dft []cache.Object + { + dft = l.pol.Drift() + } + + // If we do not have any drifted release artifacts, then this means that all + // service releases were found to be up to date this time around. + + if len(dft) == 0 { + l.log.Log( + "level", "info", + "message", "no state drift", + ) + } + + // If we do have drifted release artifacts, then we want to create an info log + // message for each affected service release. + + for _, x := range dft { + l.log.Log( + "level", "info", + "message", "detected state drift", + "release", x.Name(), + "preview", x.Release.Labels.Hash.Upper(), + "domain", x.Domain(l.env.Environment), + "version", x.Artifact.Reference.Desired, + ) + } + + return nil +} diff --git a/pkg/operator/logging/logging.go b/pkg/operator/logging/logging.go new file mode 100644 index 0000000..c91cd61 --- /dev/null +++ b/pkg/operator/logging/logging.go @@ -0,0 +1,42 @@ +// Package logging emits log messages for each and every reconciliation loop +// about the most important aspects of the current state of the system. +package logging + +import ( + "fmt" + + "github.com/0xSplits/kayron/pkg/envvar" + "github.com/0xSplits/kayron/pkg/policy" + "github.com/xh3b4sd/logger" + "github.com/xh3b4sd/tracer" +) + +type Config struct { + Env envvar.Env + Log logger.Interface + Pol *policy.Policy +} + +type Logging struct { + env envvar.Env + log logger.Interface + pol *policy.Policy +} + +func New(c Config) *Logging { + if c.Env.Environment == "" { + tracer.Panic(tracer.Mask(fmt.Errorf("%T.Env must not be empty", c))) + } + if c.Log == nil { + tracer.Panic(tracer.Mask(fmt.Errorf("%T.Log must not be empty", c))) + } + if c.Pol == nil { + tracer.Panic(tracer.Mask(fmt.Errorf("%T.Pol must not be empty", c))) + } + + return &Logging{ + env: c.Env, + log: c.Log, + pol: c.Pol, + } +} diff --git a/pkg/operator/operator.go b/pkg/operator/operator.go index fcbca49..9c10455 100644 --- a/pkg/operator/operator.go +++ b/pkg/operator/operator.go @@ -8,6 +8,7 @@ import ( "github.com/0xSplits/kayron/pkg/operator/cloudformation" "github.com/0xSplits/kayron/pkg/operator/container" "github.com/0xSplits/kayron/pkg/operator/infrastructure" + "github.com/0xSplits/kayron/pkg/operator/logging" "github.com/0xSplits/kayron/pkg/operator/preview" "github.com/0xSplits/kayron/pkg/operator/reference" "github.com/0xSplits/kayron/pkg/operator/registry" @@ -35,6 +36,7 @@ type Operator struct { cloudFormation *cloudformation.CloudFormation container *container.Container infrastructure *infrastructure.Infrastructure + logging *logging.Logging preview *preview.Preview reference *reference.Reference release *release.Release @@ -67,11 +69,12 @@ func New(c Config) *Operator { cloudFormation: cloudformation.New(cloudformation.Config{Aws: c.Aws, Cac: c.Cac, Dry: c.Dry, Env: c.Env, Log: c.Log, Met: c.Met, Pol: c.Pol}), container: container.New(container.Config{Aws: c.Aws, Cac: c.Cac, Env: c.Env, Log: c.Log}), infrastructure: infrastructure.New(infrastructure.Config{Aws: c.Aws, Cac: c.Cac, Dry: c.Dry, Env: c.Env, Log: c.Log, Pol: c.Pol}), + logging: logging.New(logging.Config{Env: c.Env, Log: c.Log, Pol: c.Pol}), preview: preview.New(preview.Config{Cac: c.Cac, Env: c.Env, Log: c.Log}), reference: reference.New(reference.Config{Cac: c.Cac, Env: c.Env, Log: c.Log}), release: release.New(release.Config{Aws: c.Aws, Cac: c.Cac, Env: c.Env, Log: c.Log, Pol: c.Pol}), registry: registry.New(registry.Config{Aws: c.Aws, Cac: c.Cac, Env: c.Env, Log: c.Log}), - status: status.New(status.Config{Env: c.Env, Log: c.Log, Pol: c.Pol}), + status: status.New(status.Config{Cac: c.Cac, Env: c.Env, Log: c.Log, Pol: c.Pol}), template: template.New(template.Config{Cac: c.Cac, Log: c.Log, Pol: c.Pol}), } } diff --git a/pkg/operator/status/active.go b/pkg/operator/status/active.go index c2aef8d..d847b2c 100644 --- a/pkg/operator/status/active.go +++ b/pkg/operator/status/active.go @@ -1,6 +1,8 @@ package status -// Active defines this worker handler to always be executed. +// Active defines this worker handler to only be executed within the testing +// environment, because we do not allow preview deployments to be injected in +// e.g. staging nor production. func (s *Status) Active() bool { - return true + return s.env.Environment == "testing" } diff --git a/pkg/operator/status/ensure.go b/pkg/operator/status/ensure.go index 14dc245..116d900 100644 --- a/pkg/operator/status/ensure.go +++ b/pkg/operator/status/ensure.go @@ -1,54 +1,18 @@ package status import ( - "github.com/0xSplits/kayron/pkg/cache" + "github.com/xh3b4sd/tracer" ) func (s *Status) Ensure() error { - var can bool - { - can = s.pol.Cancel() - } - - if can { - s.log.Log( - "level", "info", - "message", "deployment in progress", - ) + // Manage status updates for preview deployments in Github pull requests. - return nil - } - - var dft []cache.Object { - dft = s.pol.Drift() - } - - // If we do not have any drifted release artifacts, then this means that all - // service releases were found to be up to date this time around. - - if len(dft) == 0 { - s.log.Log( - "level", "info", - "message", "no state drift", - ) - } - - // If we do have drifted release artifacts, then we want to create an info log - // message for each affected service release. - - for _, x := range dft { - s.log.Log( - "level", "info", - "message", "detected state drift", - "release", x.Name(), - "preview", x.Release.Labels.Hash.Upper(), - "domain", x.Domain(s.env.Environment), - "version", x.Artifact.Reference.Desired, - ) + err := s.preview() + if err != nil { + return tracer.Mask(err) + } } - // TODO manage Github comments/notifications - return nil } diff --git a/pkg/operator/status/preview.go b/pkg/operator/status/preview.go new file mode 100644 index 0000000..f632b32 --- /dev/null +++ b/pkg/operator/status/preview.go @@ -0,0 +1,226 @@ +package status + +import ( + "context" + "fmt" + "strings" + + "github.com/0xSplits/kayron/pkg/cache" + "github.com/google/go-github/v73/github" + "github.com/xh3b4sd/choreo/parallel" + "github.com/xh3b4sd/tracer" +) + +const ( + // marker identifies those issue comments managed by the operator. This marker + // is effectively a markdown comment that helps us find and update our status + // updates. + marker = "" +) + +func (s *Status) preview() error { + // Collect all injected preview releases, whether they have state drift or + // not. Either of those cases requires us to manage the preview deployment + // status. + + var dft []cache.Object + for _, x := range s.cac.Releases() { + if x.Preview() { + dft = append(dft, x) + } + } + + fnc := func(_ int, o cache.Object) error { + var err error + + // Try to find our marked issue comment for any given preview deployment. + + var com *github.IssueComment + { + com, err = s.issCom(o) + if err != nil { + return tracer.Mask(err) + } + } + + // Determine the status field of our status update. This also tells us + // whether we should update the issue comment if it exists. + + var sta string + var upd bool + { + sta, upd = s.comSta(o, com) + } + + // Create or update the issue comment with any given status, if any. + + if com == nil { + err = s.creCom(sta, o) + if err != nil { + return tracer.Mask(err) + } + } else if upd { + err = s.updCom(sta, o, com.GetID()) + if err != nil { + return tracer.Mask(err) + } + } + + return nil + } + + { + err := parallel.Slice(dft, fnc) + if err != nil { + return tracer.Mask(err) + } + } + + return nil +} + +func (s *Status) creCom(sta string, obj cache.Object) error { + s.log.Log( + "level", "info", + "message", "creating status update", + "pull", s.pulReq(obj), + "status", sta, + ) + + var com *github.IssueComment + { + com = &github.IssueComment{ + Body: github.Ptr(s.comBod(sta, obj)), + } + } + + { + _, _, err := s.git.Issues.CreateComment(context.Background(), s.own, obj.Release.Github.String(), obj.Release.Labels.Pull, com) + if err != nil { + return tracer.Mask(err) + } + } + + return nil +} + +// comBod returns the body content of an issue comment used to render a status +// update for preview deployments. +// +// +// Status | Commit | Endpoint +// ---|----|--- +// Updating | c8e29b2 | 1d0fd508.lite.testing.splits.org +func (s *Status) comBod(sta string, obj cache.Object) string { + var dom string + { + dom = obj.Domain(s.env.Environment) + } + + var end string + { + end = fmt.Sprintf("[%s](https://%s)", dom, dom) + } + + var com string + { + com = shoStr(obj.Artifact.Scheduler.Current, 7) + } + + return strings.Join( + []string{ + marker, + fmt.Sprintf("%s | %s | %s", "Status", "Commit", "Endpoint"), + /*********/ "---|----|---", + fmt.Sprintf("%s | %s | %s", sta, com, end), + }, + "\n") +} + +func (s *Status) comSta(obj cache.Object, com *github.IssueComment) (string, bool) { + // If the preview deployment has state drift, and if the status update is not + // marked as updating, then the preview status is "Updating". This status may + // be applied to existing and new issue comments. + + if obj.Drift() && !strings.Contains(com.GetBody(), "Updating") { + return "Updating", true + } + + // If the preview deployment has no state drift, and if the status update is + // not marked as ready, then the preview status is "Ready". This status may be + // applied to existing and new issue comments. + + if !obj.Drift() && !strings.Contains(com.GetBody(), "Ready") { + return "Ready", true + } + + return "", false +} + +func (s *Status) issCom(obj cache.Object) (*github.IssueComment, error) { + var err error + + var opt *github.IssueListCommentsOptions + { + opt = &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 5, + }, + } + } + + var com []*github.IssueComment + { + com, _, err = s.git.Issues.ListComments(context.Background(), s.own, obj.Release.Github.String(), obj.Release.Labels.Pull, opt) + if err != nil { + return nil, tracer.Mask(err) + } + } + + for _, x := range com { + if strings.Contains(x.GetBody(), marker) { + return x, nil + } + } + + return nil, nil +} + +func (s *Status) pulReq(obj cache.Object) string { + return fmt.Sprintf("https://github.com/%s/%s/pull/%d", s.own, obj.Release.Github.String(), obj.Release.Labels.Pull) +} + +func (s *Status) updCom(sta string, obj cache.Object, cid int64) error { + s.log.Log( + "level", "info", + "message", "updating status update", + "pull", s.pulReq(obj), + "status", sta, + ) + + var com *github.IssueComment + { + com = &github.IssueComment{ + Body: github.Ptr(s.comBod(sta, obj)), + } + } + + { + _, _, err := s.git.Issues.EditComment(context.Background(), s.own, obj.Release.Github.String(), cid, com) + if err != nil { + return tracer.Mask(err) + } + } + + return nil +} + +// shoStr returns teh given string with a maximum length of max, or the provided +// string itself if that string is shorter than max. +func shoStr(str string, max int) string { + if len(str) >= max { + return str[:max] + } + + return str +} diff --git a/pkg/operator/status/status.go b/pkg/operator/status/status.go index f24178b..eef915f 100644 --- a/pkg/operator/status/status.go +++ b/pkg/operator/status/status.go @@ -7,6 +7,7 @@ package status import ( "fmt" + "github.com/0xSplits/kayron/pkg/cache" "github.com/0xSplits/kayron/pkg/envvar" "github.com/0xSplits/kayron/pkg/policy" "github.com/0xSplits/roghfs" @@ -16,12 +17,14 @@ import ( ) type Config struct { + Cac *cache.Cache Env envvar.Env Log logger.Interface Pol *policy.Policy } type Status struct { + cac *cache.Cache env envvar.Env git *github.Client log logger.Interface @@ -30,6 +33,9 @@ type Status struct { } func New(c Config) *Status { + if c.Cac == nil { + tracer.Panic(tracer.Mask(fmt.Errorf("%T.Cac must not be empty", c))) + } if c.Env.Environment == "" { tracer.Panic(tracer.Mask(fmt.Errorf("%T.Env must not be empty", c))) } @@ -51,6 +57,7 @@ func New(c Config) *Status { } return &Status{ + cac: c.Cac, env: c.Env, git: github.NewClient(nil).WithAuthToken(c.Env.GithubToken), log: c.Log, diff --git a/pkg/policy/drift.go b/pkg/policy/drift.go index b259c91..3da6bb3 100644 --- a/pkg/policy/drift.go +++ b/pkg/policy/drift.go @@ -2,33 +2,17 @@ package policy import "github.com/0xSplits/kayron/pkg/cache" +// Drift returns all cached artifact releases that have valid state drift. In +// other words, the cache objects returned here indicate that their respective +// releases should be updated. func (p *Policy) Drift() []cache.Object { var lis []cache.Object for _, x := range p.cac.Releases() { - if drift(x) { + if x.Drift() { lis = append(lis, x) } } return lis } - -// drift tries to detect a single valid state drift, in order to allow -// allow the operator chain to execute. Our -// current policy requires the following conditions to be true for a valid -// state drift. -// -// 1. the desired deployment must not be suspended -// -// 2. the current and desired state must not be equal -// -// 3. the desired state must not be empty -// -// 4. the container image for the desired state must be pushed -func drift(obj cache.Object) bool { - return !bool(obj.Release.Deploy.Suspend) && - obj.Artifact.Drift() && - !obj.Artifact.Empty() && - obj.Artifact.Valid() -} diff --git a/pkg/policy/drift_test.go b/pkg/policy/drift_test.go deleted file mode 100644 index aa4ed23..0000000 --- a/pkg/policy/drift_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package policy - -import ( - "fmt" - "testing" - - "github.com/0xSplits/kayron/pkg/cache" - "github.com/0xSplits/kayron/pkg/release/artifact" - "github.com/0xSplits/kayron/pkg/release/artifact/condition" - "github.com/0xSplits/kayron/pkg/release/artifact/reference" - "github.com/0xSplits/kayron/pkg/release/artifact/scheduler" - "github.com/0xSplits/kayron/pkg/release/schema/release" - "github.com/0xSplits/kayron/pkg/release/schema/release/deploy" - "github.com/0xSplits/kayron/pkg/release/schema/release/deploy/suspend" - "github.com/google/go-cmp/cmp" -) - -func Test_Operator_Policy_drift(t *testing.T) { - testCases := []struct { - rel cache.Object - dft bool - }{ - // Case 000, empty release, no drift - { - rel: cache.Object{}, - dft: false, - }, - // Case 001, no drift - { - rel: cache.Object{ - Artifact: artifact.Struct{ - Condition: condition.Struct{ - Success: true, - }, - Scheduler: scheduler.Struct{ - Current: "foo", - }, - Reference: reference.Struct{ - Desired: "foo", - }, - }, - }, - dft: false, - }, - // Case 002, drift, failed condition - { - rel: cache.Object{ - Artifact: artifact.Struct{ - Condition: condition.Struct{ - Success: false, - }, - Scheduler: scheduler.Struct{ - Current: "foo", - }, - Reference: reference.Struct{ - Desired: "bar", - }, - }, - }, - dft: false, - }, - // Case 003, drift - { - rel: cache.Object{ - Artifact: artifact.Struct{ - Condition: condition.Struct{ - Success: true, - }, - Scheduler: scheduler.Struct{ - Current: "foo", - }, - Reference: reference.Struct{ - Desired: "bar", - }, - }, - }, - dft: true, - }, - // Case 004, drift, suspended - { - rel: cache.Object{ - Artifact: artifact.Struct{ - Condition: condition.Struct{ - Success: true, - }, - Scheduler: scheduler.Struct{ - Current: "foo", - }, - Reference: reference.Struct{ - Desired: "bar", - }, - }, - Release: release.Struct{ - Deploy: deploy.Struct{ - Suspend: suspend.Bool(true), - }, - }, - }, - dft: false, - }, - } - - for i, tc := range testCases { - t.Run(fmt.Sprintf("%03d", i), func(t *testing.T) { - dft := drift(tc.rel) - if dif := cmp.Diff(tc.dft, dft); dif != "" { - t.Fatalf("-expected +actual:\n%s", dif) - } - }) - } -} diff --git a/pkg/policy/update.go b/pkg/policy/update.go index 207b4a3..6b20c27 100644 --- a/pkg/policy/update.go +++ b/pkg/policy/update.go @@ -1,33 +1,34 @@ package policy -import ( - "slices" -) - // Update determines whether the managed CloudFormation stack should be updated // or not. We do not signal an update if the managed CloudFormation stack is // already being updated, and if there is no detectable state drift. func (p *Policy) Update() bool { // Fetch the deployment status of the underlying root stack so that we can // decide whether to proceed with the execution of writing operator functions. + var can bool { can = p.Cancel() } - // The slices.ContainsFunc version below is the equivalent of the shown for - // loop. - // - // for _, x := range p.cac.Releases() { - // if drift(x) { - // return true - // } - // } - // + // Figure out whether we have any state drift at all that has to be + // reconciled. + var dft bool { - dft = slices.ContainsFunc(p.cac.Releases(), drift) + dft = p.drift() } return !can && dft } + +func (p *Policy) drift() bool { + for _, x := range p.cac.Releases() { + if x.Drift() { + return true + } + } + + return false +} diff --git a/pkg/preview/expand.go b/pkg/preview/expand.go index 7d35609..fa8a075 100644 --- a/pkg/preview/expand.go +++ b/pkg/preview/expand.go @@ -82,8 +82,10 @@ func expand(rel release.Struct, pul []*github.PullRequest) release.Slice { } var bra string + var pul int { bra = x.GetHead().GetRef() + pul = x.GetNumber() } { @@ -99,6 +101,7 @@ func expand(rel release.Struct, pul []*github.PullRequest) release.Slice { { pre.Labels.Hash = hash.New(bra) + pre.Labels.Pull = pul } { diff --git a/pkg/release/schema/release/labels/struct.go b/pkg/release/schema/release/labels/struct.go index 7a1b691..086b2f7 100644 --- a/pkg/release/schema/release/labels/struct.go +++ b/pkg/release/schema/release/labels/struct.go @@ -14,6 +14,9 @@ type Struct struct { // Hash contains the hashed branch name for any service release of a preview // deployment. Hash hash.Hash + // Pull is the sequence of numbers identifying a pull request in Github's web + // interface. + Pull int // Source is the absolute source file path of the .yaml definition as loaded // from the underlying file system. This label may help to make error messages // more useful.