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 @@
\ 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.