From 7a053905e5d48c1a0e358188ea3345e920771d0c Mon Sep 17 00:00:00 2001 From: DingGGu Date: Thu, 25 Feb 2021 18:21:43 +0900 Subject: [PATCH] Support Github Container Registry --- pkg/http/github_webhook_trigger.go | 338 ++++++++---------------- pkg/http/github_webhook_trigger_test.go | 138 +++++++++- 2 files changed, 248 insertions(+), 228 deletions(-) diff --git a/pkg/http/github_webhook_trigger.go b/pkg/http/github_webhook_trigger.go index 05ef10b4..5e94fc19 100644 --- a/pkg/http/github_webhook_trigger.go +++ b/pkg/http/github_webhook_trigger.go @@ -3,14 +3,12 @@ package http import ( "encoding/json" "fmt" + "github.com/keel-hq/keel/types" + "github.com/prometheus/client_golang/prometheus" "net/http" "strings" "time" - "github.com/keel-hq/keel/types" - - "github.com/prometheus/client_golang/prometheus" - log "github.com/sirupsen/logrus" ) @@ -26,249 +24,139 @@ func init() { prometheus.MustRegister(newGithubWebhooksCounter) } -type githubWebhook struct { +type githubRegistryPackageWebhook struct { Action string `json:"action"` RegistryPackage struct { - CreatedAt string `json:"created_at"` - HTMLURL string `json:"html_url"` - ID int `json:"id"` - Name string `json:"name"` - Owner struct { - AvatarURL string `json:"avatar_url"` - EventsURL string `json:"events_url"` - FollowersURL string `json:"followers_url"` - FollowingURL string `json:"following_url"` - GistsURL string `json:"gists_url"` - GravatarID string `json:"gravatar_id"` - HTMLURL string `json:"html_url"` - ID int `json:"id"` - Login string `json:"login"` - NodeID string `json:"node_id"` - OrganizationsURL string `json:"organizations_url"` - ReceivedEventsURL string `json:"received_events_url"` - ReposURL string `json:"repos_url"` - SiteAdmin bool `json:"site_admin"` - StarredURL string `json:"starred_url"` - SubscriptionsURL string `json:"subscriptions_url"` - Type string `json:"type"` - URL string `json:"url"` - } `json:"owner"` + Name string `json:"name"` PackageType string `json:"package_type"` PackageVersion struct { - Author struct { - AvatarURL string `json:"avatar_url"` - EventsURL string `json:"events_url"` - FollowersURL string `json:"followers_url"` - FollowingURL string `json:"following_url"` - GistsURL string `json:"gists_url"` - GravatarID string `json:"gravatar_id"` - HTMLURL string `json:"html_url"` - ID int `json:"id"` - Login string `json:"login"` - NodeID string `json:"node_id"` - OrganizationsURL string `json:"organizations_url"` - ReceivedEventsURL string `json:"received_events_url"` - ReposURL string `json:"repos_url"` - SiteAdmin bool `json:"site_admin"` - StarredURL string `json:"starred_url"` - SubscriptionsURL string `json:"subscriptions_url"` - Type string `json:"type"` - URL string `json:"url"` - } `json:"author"` - Body string `json:"body"` - BodyHTML string `json:"body_html"` - CreatedAt string `json:"created_at"` - HTMLURL string `json:"html_url"` - ID int `json:"id"` - InstallationCommand string `json:"installation_command"` - Manifest string `json:"manifest"` - Metadata []interface{} `json:"metadata"` - PackageFiles []struct { - ContentType string `json:"content_type"` - CreatedAt string `json:"created_at"` - DownloadURL string `json:"download_url"` - ID int `json:"id"` - Md5 interface{} `json:"md5"` - Name string `json:"name"` - Sha1 interface{} `json:"sha1"` - Sha256 string `json:"sha256"` - Size int `json:"size"` - State string `json:"state"` - UpdatedAt string `json:"updated_at"` - } `json:"package_files"` - Summary string `json:"summary"` - TargetCommitish string `json:"target_commitish"` - TargetOid string `json:"target_oid"` - UpdatedAt string `json:"updated_at"` - Version string `json:"version"` + Version string `json:"version"` } `json:"package_version"` - Registry struct { - AboutURL string `json:"about_url"` - Name string `json:"name"` - Type string `json:"type"` - URL string `json:"url"` - Vendor string `json:"vendor"` - } `json:"registry"` UpdatedAt string `json:"updated_at"` } `json:"registry_package"` Repository struct { - ArchiveURL string `json:"archive_url"` - Archived bool `json:"archived"` - AssigneesURL string `json:"assignees_url"` - BlobsURL string `json:"blobs_url"` - BranchesURL string `json:"branches_url"` - CloneURL string `json:"clone_url"` - CollaboratorsURL string `json:"collaborators_url"` - CommentsURL string `json:"comments_url"` - CommitsURL string `json:"commits_url"` - CompareURL string `json:"compare_url"` - ContentsURL string `json:"contents_url"` - ContributorsURL string `json:"contributors_url"` - CreatedAt string `json:"created_at"` - DefaultBranch string `json:"default_branch"` - DeploymentsURL string `json:"deployments_url"` - Description string `json:"description"` - Disabled bool `json:"disabled"` - DownloadsURL string `json:"downloads_url"` - EventsURL string `json:"events_url"` - Fork bool `json:"fork"` - Forks int `json:"forks"` - ForksCount int `json:"forks_count"` - ForksURL string `json:"forks_url"` - FullName string `json:"full_name"` - GitCommitsURL string `json:"git_commits_url"` - GitRefsURL string `json:"git_refs_url"` - GitTagsURL string `json:"git_tags_url"` - GitURL string `json:"git_url"` - HasDownloads bool `json:"has_downloads"` - HasIssues bool `json:"has_issues"` - HasPages bool `json:"has_pages"` - HasProjects bool `json:"has_projects"` - HasWiki bool `json:"has_wiki"` - Homepage string `json:"homepage"` - HooksURL string `json:"hooks_url"` - HTMLURL string `json:"html_url"` - ID int `json:"id"` - IssueCommentURL string `json:"issue_comment_url"` - IssueEventsURL string `json:"issue_events_url"` - IssuesURL string `json:"issues_url"` - KeysURL string `json:"keys_url"` - LabelsURL string `json:"labels_url"` - Language string `json:"language"` - LanguagesURL string `json:"languages_url"` - License interface{} `json:"license"` - MergesURL string `json:"merges_url"` - MilestonesURL string `json:"milestones_url"` - MirrorURL interface{} `json:"mirror_url"` - Name string `json:"name"` - NodeID string `json:"node_id"` - NotificationsURL string `json:"notifications_url"` - OpenIssues int `json:"open_issues"` - OpenIssuesCount int `json:"open_issues_count"` - Owner struct { - AvatarURL string `json:"avatar_url"` - EventsURL string `json:"events_url"` - FollowersURL string `json:"followers_url"` - FollowingURL string `json:"following_url"` - GistsURL string `json:"gists_url"` - GravatarID string `json:"gravatar_id"` - HTMLURL string `json:"html_url"` - ID int `json:"id"` - Login string `json:"login"` - NodeID string `json:"node_id"` - OrganizationsURL string `json:"organizations_url"` - ReceivedEventsURL string `json:"received_events_url"` - ReposURL string `json:"repos_url"` - SiteAdmin bool `json:"site_admin"` - StarredURL string `json:"starred_url"` - SubscriptionsURL string `json:"subscriptions_url"` - Type string `json:"type"` - URL string `json:"url"` - } `json:"owner"` - Private bool `json:"private"` - PullsURL string `json:"pulls_url"` - PushedAt string `json:"pushed_at"` - ReleasesURL string `json:"releases_url"` - Size int `json:"size"` - SSHURL string `json:"ssh_url"` - StargazersCount int `json:"stargazers_count"` - StargazersURL string `json:"stargazers_url"` - StatusesURL string `json:"statuses_url"` - SubscribersURL string `json:"subscribers_url"` - SubscriptionURL string `json:"subscription_url"` - SvnURL string `json:"svn_url"` - TagsURL string `json:"tags_url"` - TeamsURL string `json:"teams_url"` - TreesURL string `json:"trees_url"` - UpdatedAt string `json:"updated_at"` - URL string `json:"url"` - Watchers int `json:"watchers"` - WatchersCount int `json:"watchers_count"` + FullName string `json:"full_name"` } `json:"repository"` - Sender struct { - AvatarURL string `json:"avatar_url"` - EventsURL string `json:"events_url"` - FollowersURL string `json:"followers_url"` - FollowingURL string `json:"following_url"` - GistsURL string `json:"gists_url"` - GravatarID string `json:"gravatar_id"` - HTMLURL string `json:"html_url"` - ID int `json:"id"` - Login string `json:"login"` - NodeID string `json:"node_id"` - OrganizationsURL string `json:"organizations_url"` - ReceivedEventsURL string `json:"received_events_url"` - ReposURL string `json:"repos_url"` - SiteAdmin bool `json:"site_admin"` - StarredURL string `json:"starred_url"` - SubscriptionsURL string `json:"subscriptions_url"` - Type string `json:"type"` - URL string `json:"url"` - } `json:"sender"` +} + +type githubPackageV2Webhook struct { + Action string `json:"action"` + Package struct { + Id int `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Ecosystem string `json:"ecosystem"` + PackageVersion struct { + Name string `json:"name"` + ContainerMetadata struct { + Tag struct { + Name string `json:"name"` + Digest string `json:"digest"` + } `json:"tag"` + } `json:"container_metadata"` + } `json:"package_version"` + } `json:"package"` } // githubHandler - used to react to github webhooks func (s *TriggerServer) githubHandler(resp http.ResponseWriter, req *http.Request) { - gw := githubWebhook{} - if err := json.NewDecoder(req.Body).Decode(&gw); err != nil { - log.WithFields(log.Fields{ - "error": err, - }).Error("trigger.githubHandler: failed to decode request") - resp.WriteHeader(http.StatusBadRequest) - return - } + // GitHub provides different webhook events for each registry. + // Github Package uses 'registry_package' + // Github Container Registry uses 'package_v2' + // events can be classified as 'X-GitHub-Event' in Request Header. + hookEvent := req.Header.Get("X-GitHub-Event") - if gw.RegistryPackage.PackageType != "docker" { - resp.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(resp, "registry package type was not docker") - } + var imageName, imageTag string - if gw.Repository.FullName == "" { // github package name - resp.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(resp, "repository full name cannot be empty") - return - } + switch hookEvent { + case "package_v2": + payload := new(githubPackageV2Webhook) + if err := json.NewDecoder(req.Body).Decode(payload); err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Error("trigger.githubHandler: failed to decode request") + resp.WriteHeader(http.StatusBadRequest) + return + } - if gw.RegistryPackage.Name == "" { // github package name - resp.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(resp, "repository package name cannot be empty") - return - } + if payload.Package.Ecosystem != "CONTAINER" { + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "registry package type was not container") + } - if gw.RegistryPackage.PackageVersion.Version == "" { // tag - resp.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(resp, "repository tag cannot be empty") - return + if payload.Package.Name == "" { // github package name + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "repository name cannot be empty") + return + } + + if payload.Package.Namespace == "" { // github package org + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "repository namespace cannot be empty") + return + } + + if payload.Package.PackageVersion.ContainerMetadata.Tag.Name == "" { // tag + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "repository tag cannot be empty") + return + } + + imageName = strings.Join( + []string{"ghcr.io", payload.Package.Namespace, payload.Package.Name}, + "/", + ) + imageTag = payload.Package.PackageVersion.ContainerMetadata.Tag.Name + + break + + case "registry_package": + payload := new(githubRegistryPackageWebhook) + if err := json.NewDecoder(req.Body).Decode(payload); err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Error("trigger.githubHandler: failed to decode request") + resp.WriteHeader(http.StatusBadRequest) + return + } + + if payload.RegistryPackage.PackageType != "docker" { + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "registry package type was not docker") + } + + if payload.Repository.FullName == "" { // github package name + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "repository full name cannot be empty") + return + } + + if payload.RegistryPackage.Name == "" { // github package name + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "repository package name cannot be empty") + return + } + + if payload.RegistryPackage.PackageVersion.Version == "" { // tag + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(resp, "repository tag cannot be empty") + return + } + + imageName = strings.Join( + []string{"docker.pkg.github.com", payload.Repository.FullName, payload.RegistryPackage.Name}, + "/", + ) + imageTag = payload.RegistryPackage.PackageVersion.Version + + break } event := types.Event{} event.CreatedAt = time.Now() event.TriggerName = "github" - event.Repository.Name = strings.Join( - []string{"docker.pkg.github.com", gw.Repository.FullName, gw.RegistryPackage.Name}, - "/", - ) - event.Repository.Tag = gw.RegistryPackage.PackageVersion.Version + event.Repository.Name = imageName + event.Repository.Tag = imageTag s.trigger(event) diff --git a/pkg/http/github_webhook_trigger_test.go b/pkg/http/github_webhook_trigger_test.go index 8494d2dd..064e0604 100644 --- a/pkg/http/github_webhook_trigger_test.go +++ b/pkg/http/github_webhook_trigger_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -var fakeGithubWebhook = `{ +var fakeGithubPackageWebhook = `{ "action": "published", "registry_package": { "id": 35087, @@ -239,16 +239,113 @@ var fakeGithubWebhook = `{ } }` -func TestGithubWebhookHandler(t *testing.T) { +var fakeGithubContainerRegistryWebhook = `{ + "action": "create", + "package": { + "id": 779666, + "name": "utaitebox-server", + "namespace": "utaitebox", + "description": "", + "ecosystem": "CONTAINER", + "html_url": "https://github.com/utaitebox/packages/779666", + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "package_version": { + "id": 1284299, + "name": "sha256:7d3848ba2f2e7f69bebb4b576e5fad0379b64a0b1512aee6ad0ec9e7c6319fed", + "description": "", + "blob_store": "s3", + "html_url": "https://github.com/utaitebox/packages/779666?version=1284299", + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "container_metadata": { + "tag": { + "name": "3.2.1", + "digest": "sha256:7d3848ba2f2e7f69bebb4b576e5fad0379b64a0b1512aee6ad0ec9e7c6319fed" + }, + "labels": { + "description": "", + "source": "", + "revision": "", + "image_url": "", + "licenses": "", + "all_labels": { + + } + }, + "manifest": { + "digest": "sha256:7d3848ba2f2e7f69bebb4b576e5fad0379b64a0b1512aee6ad0ec9e7c6319fed", + "media_type": "application/vnd.docker.distribution.manifest.v2+json", + "uri": "repositories/utaitebox/utaitebox-server/manifests/sha256:7d3848ba2f2e7f69bebb4b576e5fad0379b64a0b1512aee6ad0ec9e7c6319fed", + "size": 735, + "config": { + "digest": "sha256:2b94d3d75692e4b04dde5046ad3246fe01cc8889cb641c3e116f10e41c51e164", + "media_type": "application/vnd.docker.container.image.v1+json", + "size": 1709 + }, + "layers": [ + { + "digest": "sha256:9d48c3bd43c520dc2784e868a780e976b207cbf493eaff8c6596eb871cbd9609", + "media_type": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 2789669 + }, + { + "digest": "sha256:957045d2b582f07cdc07ebbc7d971239bb7bc19f78216fe547609ff495b007f5", + "media_type": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 350 + } + ] + } + } + } + }, + "organization": { + "login": "UtaiteBOX", + "id": 65208347, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjY1MjA4MzQ3", + "url": "https://api.github.com/orgs/UtaiteBOX", + "repos_url": "https://api.github.com/orgs/UtaiteBOX/repos", + "events_url": "https://api.github.com/orgs/UtaiteBOX/events", + "hooks_url": "https://api.github.com/orgs/UtaiteBOX/hooks", + "issues_url": "https://api.github.com/orgs/UtaiteBOX/issues", + "members_url": "https://api.github.com/orgs/UtaiteBOX/members{/member}", + "public_members_url": "https://api.github.com/orgs/UtaiteBOX/public_members{/member}", + "avatar_url": "https://avatars.githubusercontent.com/u/65208347?v=4", + "description": null + }, + "sender": { + "login": "DingGGu", + "id": 2981443, + "node_id": "MDQ6VXNlcjI5ODE0NDM=", + "avatar_url": "https://avatars.githubusercontent.com/u/2981443?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/DingGGu", + "html_url": "https://github.com/DingGGu", + "followers_url": "https://api.github.com/users/DingGGu/followers", + "following_url": "https://api.github.com/users/DingGGu/following{/other_user}", + "gists_url": "https://api.github.com/users/DingGGu/gists{/gist_id}", + "starred_url": "https://api.github.com/users/DingGGu/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/DingGGu/subscriptions", + "organizations_url": "https://api.github.com/users/DingGGu/orgs", + "repos_url": "https://api.github.com/users/DingGGu/repos", + "events_url": "https://api.github.com/users/DingGGu/events{/privacy}", + "received_events_url": "https://api.github.com/users/DingGGu/received_events", + "type": "User", + "site_admin": false + } +}` + +func TestGithubPackageWebhookHandler(t *testing.T) { fp := &fakeProvider{} srv, teardown := NewTestingServer(fp) defer teardown() - req, err := http.NewRequest("POST", "/v1/webhooks/github", bytes.NewBuffer([]byte(fakeGithubWebhook))) + req, err := http.NewRequest("POST", "/v1/webhooks/github", bytes.NewBuffer([]byte(fakeGithubPackageWebhook))) if err != nil { t.Fatalf("failed to create req: %s", err) } + req.Header.Set("X-GitHub-Event", "registry_package") //The response recorder used to record HTTP responses rec := httptest.NewRecorder() @@ -272,3 +369,38 @@ func TestGithubWebhookHandler(t *testing.T) { t.Errorf("expected 1.2.3 but got %s", fp.submitted[0].Repository.Tag) } } + +func TestGithubContainerRegistryWebhookHandler(t *testing.T) { + + fp := &fakeProvider{} + srv, teardown := NewTestingServer(fp) + defer teardown() + + req, err := http.NewRequest("POST", "/v1/webhooks/github", bytes.NewBuffer([]byte(fakeGithubContainerRegistryWebhook))) + if err != nil { + t.Fatalf("failed to create req: %s", err) + } + req.Header.Set("X-GitHub-Event", "package_v2") + + //The response recorder used to record HTTP responses + rec := httptest.NewRecorder() + + srv.router.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Errorf("unexpected status code: %d", rec.Code) + + t.Log(rec.Body.String()) + } + + if len(fp.submitted) != 1 { + t.Fatalf("unexpected number of events submitted: %d", len(fp.submitted)) + } + + if fp.submitted[0].Repository.Name != "ghcr.io/utaitebox/utaitebox-server" { + t.Errorf("expected ghcr.io/utaitebox/utaitebox-server but got %s", fp.submitted[0].Repository.Name) + } + + if fp.submitted[0].Repository.Tag != "3.2.1" { + t.Errorf("expected 3.2.1 but got %s", fp.submitted[0].Repository.Tag) + } +}