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

Add check run notification #631

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ jobs:
env:
PULL_REQUEST_BODY: "e2e-test ${{ github.repository }}#${{ github.event.pull_request.number }}"
DEPLOYMENT_URL: ${{ steps.deployment-app1.outputs.url }}
COMMIT_URL: ${{ github.api_url }}/repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha || github.sha }}
GITHUB_TOKEN: ${{ steps.octoken.outputs.token }}

- run: make -C e2e_test restart-app1
Expand All @@ -131,6 +132,7 @@ jobs:
env:
PULL_REQUEST_BODY: "e2e-test ${{ github.repository }}#${{ github.event.pull_request.number }}"
DEPLOYMENT_URL: ${{ steps.deployment-app2.outputs.url }}
COMMIT_URL: ${{ github.api_url }}/repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha || github.sha }}
GITHUB_TOKEN: ${{ steps.octoken.outputs.token }}

- run: make -C e2e_test deploy-app3
Expand Down
5 changes: 5 additions & 0 deletions PROJECT
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ resources:
group: argocdcommenter
kind: ApplicationHealthComment
version: v1
- controller: true
domain: int128.github.io
group: argocdcommenter
kind: ApplicationHealthCheckRun
version: v1
version: "3"
78 changes: 78 additions & 0 deletions controllers/applicationhealthcheckrun_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
Copyright 2021.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controllers

import (
"context"

argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
"github.com/int128/argocd-commenter/controllers/predicates"
"github.com/int128/argocd-commenter/pkg/notification"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
)

// ApplicationHealthCheckRunReconciler reconciles a ApplicationHealthCheckRun object
type ApplicationHealthCheckRunReconciler struct {
client.Client
Scheme *runtime.Scheme
Notification notification.Client
}

//+kubebuilder:rbac:groups=argoproj.io,resources=applications,verbs=get;watch;list;patch
//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;watch;list

func (r *ApplicationHealthCheckRunReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)

var app argocdv1alpha1.Application
if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
logger.Error(err, "unable to get the Application")
return ctrl.Result{}, client.IgnoreNotFound(err)
}

argoCDURL, err := findArgoCDURL(ctx, r.Client, req.Namespace)
if err != nil {
logger.Error(err, "unable to determine Argo CD URL")
}

e := notification.Event{
HealthIsChanged: true,
Application: app,
ArgoCDURL: argoCDURL,
}
if err := r.Notification.CheckRun(ctx, e); err != nil {
logger.Error(err, "unable to send a check run notification")
}
return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *ApplicationHealthCheckRunReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&argocdv1alpha1.Application{}).
WithEventFilter(predicates.ApplicationUpdate(applicationHealthCheckRunComparer{})).
Complete(r)
}

type applicationHealthCheckRunComparer struct{}

func (applicationHealthCheckRunComparer) Compare(applicationOld, applicationNew argocdv1alpha1.Application) bool {
return applicationOld.Status.Health.Status != applicationNew.Status.Health.Status
}
99 changes: 99 additions & 0 deletions controllers/applicationhealthcheckrun_controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package controllers

import (
"time"

argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
"github.com/argoproj/gitops-engine/pkg/health"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)

var _ = Describe("Application health check run controller", func() {
const timeout = time.Second * 3
const interval = time.Millisecond * 250
var app argocdv1alpha1.Application
var appKey types.NamespacedName

BeforeEach(func() {
app = argocdv1alpha1.Application{
TypeMeta: metav1.TypeMeta{
APIVersion: "argoproj.io/v1alpha1",
Kind: "Application",
},
ObjectMeta: metav1.ObjectMeta{
GenerateName: "fixture-",
Namespace: "default",
},
Spec: argocdv1alpha1.ApplicationSpec{
Project: "default",
Source: argocdv1alpha1.ApplicationSource{
RepoURL: "https://github.com/int128/argocd-commenter.git",
Path: "test",
TargetRevision: "main",
},
Destination: argocdv1alpha1.ApplicationDestination{
Server: "https://kubernetes.default.svc",
Namespace: "default",
},
},
}
Expect(k8sClient.Create(ctx, &app)).Should(Succeed())
appKey = types.NamespacedName{Namespace: app.Namespace, Name: app.Name}
})

Context("When an application is changed", func() {
It("Should notify a check run", func() {
By("By updating the health status to progressing")
patch := client.MergeFrom(app.DeepCopy())
app.Status = argocdv1alpha1.ApplicationStatus{
Health: argocdv1alpha1.HealthStatus{
Status: health.HealthStatusProgressing,
},
OperationState: &argocdv1alpha1.OperationState{
StartedAt: metav1.Now(),
Operation: argocdv1alpha1.Operation{
Sync: &argocdv1alpha1.SyncOperation{
Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
},
},
}
Expect(k8sClient.Patch(ctx, &app, patch)).Should(Succeed())

Eventually(func() int {
return notificationMock.CheckRuns.CountBy(appKey)
}, timeout, interval).Should(Equal(1))

By("By updating the health status to healthy")
patch = client.MergeFrom(app.DeepCopy())
app.Status.Health.Status = health.HealthStatusHealthy
Expect(k8sClient.Patch(ctx, &app, patch)).Should(Succeed())

Eventually(func() int {
return notificationMock.CheckRuns.CountBy(appKey)
}, timeout, interval).Should(Equal(2))

By("By updating the health status to progressing")
patch = client.MergeFrom(app.DeepCopy())
app.Status.Health.Status = health.HealthStatusProgressing
Expect(k8sClient.Patch(ctx, &app, patch)).Should(Succeed())

Eventually(func() int {
return notificationMock.CheckRuns.CountBy(appKey)
}, timeout, interval).Should(Equal(3))

By("By updating the health status to healthy")
patch = client.MergeFrom(app.DeepCopy())
app.Status.Health.Status = health.HealthStatusHealthy
Expect(k8sClient.Patch(ctx, &app, patch)).Should(Succeed())

Eventually(func() int {
return notificationMock.CheckRuns.CountBy(appKey)
}, timeout, interval).Should(Equal(4))
})
})
})
3 changes: 3 additions & 0 deletions controllers/applicationphase_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func (r *ApplicationPhaseReconciler) Reconcile(ctx context.Context, req ctrl.Req
if err := r.Notification.Deployment(ctx, e); err != nil {
logger.Error(err, "unable to send a deployment status")
}
if err := r.Notification.CheckRun(ctx, e); err != nil {
logger.Error(err, "unable to send a check run notification")
}
return ctrl.Result{}, nil
}

Expand Down
10 changes: 10 additions & 0 deletions controllers/mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func (r *EventRecorder) call(event notification.Event) int {
type NotificationMock struct {
Comments EventRecorder
DeploymentStatuses EventRecorder
CheckRuns EventRecorder
}

func (n *NotificationMock) Comment(ctx context.Context, event notification.Event) error {
Expand All @@ -50,3 +51,12 @@ func (n *NotificationMock) Deployment(ctx context.Context, event notification.Ev
logger.Info("called Deployment", "nth", nth)
return nil
}

func (n *NotificationMock) CheckRun(ctx context.Context, event notification.Event) error {
logger := log.FromContext(ctx)
nth := n.CheckRuns.call(event)
logger.Info("called CheckRun", "nth", nth)
return nil
}

var _ notification.Client = &NotificationMock{}
7 changes: 7 additions & 0 deletions controllers/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ var _ = BeforeSuite(func() {
}).SetupWithManager(k8sManager)
Expect(err).ToNot(HaveOccurred())

err = (&ApplicationHealthCheckRunReconciler{
Client: k8sManager.GetClient(),
Scheme: k8sManager.GetScheme(),
Notification: &notificationMock,
}).SetupWithManager(k8sManager)
Expect(err).ToNot(HaveOccurred())

go func() {
defer GinkgoRecover()
err = k8sManager.Start(ctx)
Expand Down
3 changes: 3 additions & 0 deletions e2e_test/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ wait-for-apps:
# fixture
deploy-app1:
kubectl -n argocd annotate application app1 'argocd-commenter.int128.github.io/deployment-url=$(DEPLOYMENT_URL)'
kubectl -n argocd annotate application app1 'argocd-commenter.int128.github.io/commit-url=$(COMMIT_URL)'
$(MAKE) -C $(FIXTURE_DIR) update-manifest-app1
go run ./waitforapp -revision "`git -C $(FIXTURE_DIR) rev-parse $(FIXTURE_BASE_BRANCH)`" app1

Expand All @@ -47,11 +48,13 @@ restart-app1:

deploy-app2:
kubectl -n argocd annotate application app2 'argocd-commenter.int128.github.io/deployment-url=$(DEPLOYMENT_URL)'
kubectl -n argocd annotate application app2 'argocd-commenter.int128.github.io/commit-url=$(COMMIT_URL)'
$(MAKE) -C $(FIXTURE_DIR) update-manifest-app2
go run ./waitforapp -revision "`git -C $(FIXTURE_DIR) rev-parse $(FIXTURE_BASE_BRANCH)`" -sync OutOfSync -operation Failed app2

deploy-app3:
kubectl -n argocd annotate application app3 'argocd-commenter.int128.github.io/deployment-url=$(DEPLOYMENT_URL)'
kubectl -n argocd annotate application app3 'argocd-commenter.int128.github.io/commit-url=$(COMMIT_URL)'
$(MAKE) -C $(FIXTURE_DIR) update-manifest-app3
go run ./waitforapp -revision "`git -C $(FIXTURE_DIR) rev-parse $(FIXTURE_BASE_BRANCH)`" app3

Expand Down
9 changes: 9 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log/zap"

argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"

"github.com/int128/argocd-commenter/controllers"
"github.com/int128/argocd-commenter/pkg/github"
"github.com/int128/argocd-commenter/pkg/notification"
Expand Down Expand Up @@ -125,6 +126,14 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", "ApplicationHealthComment")
os.Exit(1)
}
if err = (&controllers.ApplicationHealthCheckRunReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Notification: notificationClient,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ApplicationHealthCheckRun")
os.Exit(1)
}
//+kubebuilder:scaffold:builder

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
Expand Down
55 changes: 55 additions & 0 deletions pkg/github/checkrun.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package github

import (
"context"
"fmt"
"regexp"

"github.com/google/go-github/v47/github"
)

type Commit struct {
Repository Repository
SHA string
}

var patternCommitURL = regexp.MustCompile(`^https://api\.github\.com/repos/(.+?)/(.+?)/commits/([0-9a-f]+)$`)

func ParseCommitURL(s string) *Commit {
m := patternCommitURL.FindStringSubmatch(s)
if len(m) != 4 {
return nil
}
return &Commit{
Repository: Repository{Owner: m[1], Name: m[2]},
SHA: m[3],
}
}

type CheckRun struct {
Name string
Status string
Conclusion string
Title string
Summary string
}

func (c *client) CreateCheckRun(ctx context.Context, commit Commit, cr CheckRun) error {
o := github.CreateCheckRunOptions{
HeadSHA: commit.SHA,
Name: cr.Name,
Status: github.String(cr.Status),
Output: &github.CheckRunOutput{
Title: github.String(cr.Title),
Summary: github.String(cr.Summary),
},
}
if cr.Conclusion != "" {
o.Conclusion = github.String(cr.Conclusion)
}
_, _, err := c.rest.Checks.CreateCheckRun(ctx, commit.Repository.Owner, commit.Repository.Name, o)
if err != nil {
return fmt.Errorf("GitHub API error: %w", err)
}
return nil
}
1 change: 1 addition & 0 deletions pkg/github/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type Client interface {
ListPullRequests(ctx context.Context, r Repository, revision string) ([]PullRequest, error)
CreateComment(ctx context.Context, r Repository, pulls []int, body string) error
CreateDeploymentStatus(ctx context.Context, d Deployment, ds DeploymentStatus) error
CreateCheckRun(ctx context.Context, cr Commit, ds CheckRun) error
}

type Repository struct {
Expand Down
Loading