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

Adding Jason's image-workflow example #205

Closed
wants to merge 1 commit into from
Closed
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
82 changes: 82 additions & 0 deletions image-workflow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# `image-workflow`

This sets up a Cloud Run app to listen for `registry.push` events to a private Chainguard Registry group, and triggers a GitHub Actions workflow with that image ref as an input.

This lets you define GitHub Actions workflows to pull and test images in response to pushes.

# Usage

To start, create a GitHub workflow at `.github/workflows/workflow.yaml`, with an input named `image`:

```
on:
workflow_dispatch:
inputs:
image:
description: 'Image to test'
required: true

jobs:
test-image:
runs-on: ubuntu-latest
steps:
# This sets up auth with the registry to be able to pull the image.
- uses: chainguard-dev/actions/setup-chainctl@main
with:
identity: TODO # We'll fill this in later.

- run: |
# Your tests go here.
docker pull ${{ github.event.inputs.image }}
```

Then `terraform apply` the module (e.g., from the root of this repo):

```
module "image-workflow" {
source = "./image-workflow/iac" # TODO: move to enforce-events

# name is used to prefix resources created by this demo application
# where possible.
name = "chainguard-dev"

# This is the GCP project ID in which certain resource will live including:
# - The container image for this application,
# - The Cloud Run service hosting this application,
project_id = "<project-id>"

# The Chainguard IAM group from which we expect to receive events.
# This is used to authenticate that the Chainguard events are intended
# for you, and not another user.
# Images pushed to repos under this group will trigger workflows.
group = "<group-id>"

# These describe the GitHub organization, repository and workflow to trigger.
github_org = "my-org"
github_repo = "my-repo"
github_workflow_id = "workflow.yaml"

# Location of the Cloud Run subscriber.
# location = "us-central1" (default)
}
```

## Setting your GitHub Personal Access Token

Once things have been provisioned, this module outputs a `secret-command`
containing the command to run to upload your Github "personal access token" to
the Google Secret Manager secret the application will use, looking something
like this:

```shell
echo -n YOUR GITHUB PAT | \
gcloud --project ... secrets versions add ... --data-file=-
```

The personal access token needs `actions:write` to trigger workflows.

## Setting your puller identity

The module also outputs the identity that was created, which can be assumed by the
GitHub Actions workflow. This is the value that goes in the `setup-chainctl` step
of your workflow above.
67 changes: 67 additions & 0 deletions image-workflow/cmd/app/event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
Copyright 2022 Chainguard, Inc.
SPDX-License-Identifier: Apache-2.0
*/

package main

// NOTE: these types will eventually be made available as part of a Chainguard
// SDK along with our API clients.

// Occurrence is the CloudEvent payload for events.
type Occurrence struct {
Actor *Actor `json:"actor,omitempty"`

// Body is the resource that was created.
// For this sample, it will always be RegistryPush.
Body RegistryPush `json:"body,omitempty"`
}

// Actor is the event payload form of which identity was responsible for the
// event.
type Actor struct {
// Subject is the identity that triggered this event.
Subject string `json:"subject"`

// Actor contains the name/value pairs for each of the claims that were
// validated to assume the identity whose UIDP appears in Subject above.
Actor map[string]string `json:"act,omitempty"`
}

// ChangedEventType is the cloudevents event type for registry push events.
const PushEventType = "dev.chainguard.registry.push.v1"

// RegistryPush describes an item being pushed to the registry.
type RegistryPush struct {
// Repository identifies the repository being pushed
Repository string `json:"repository"`

// Tag holds the tag being pushed, if there is one.
Tag string `json:"tag,omitempty"`

// Digest holds the digest being pushed.
// Digest will hold the sha256 of the content being pushed, whether that is
// a blob or a manifest.
Digest string `json:"digest"`

// Type determines whether the object being pushed is a manifest or blob.
Type string `json:"type"`

// When holds when the push occurred.
//When civil.DateTime `json:"when"`

// Location holds the detected approximate location of the client who pulled.
// For example, "ColumbusOHUS" or "Minato City13JP".
Location string `json:"location"`

// UserAgent holds the user-agent of the client who pulled.
UserAgent string `json:"user_agent" bigquery:"user_agent"`

Error *Error `json:"error,omitempty"`
}

type Error struct {
Status int `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
}
117 changes: 117 additions & 0 deletions image-workflow/cmd/app/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
Copyright 2022 Chainguard, Inc.
SPDX-License-Identifier: Apache-2.0
*/

package main

import (
"context"
"log"
"net/http"
"strings"

cloudevents "github.com/cloudevents/sdk-go/v2"
cehttp "github.com/cloudevents/sdk-go/v2/protocol/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/go-github/v43/github"
"github.com/kelseyhightower/envconfig"
"golang.org/x/oauth2"
)

type envConfig struct {
Issuer string `envconfig:"ISSUER_URL" required:"true"`
Group string `envconfig:"GROUP" required:"true"`
Port int `envconfig:"PORT" default:"8080" required:"true"`
GithubOrg string `envconfig:"GITHUB_ORG" required:"true"`
GithubRepo string `envconfig:"GITHUB_REPO" required:"true"`
GithubToken string `envconfig:"GITHUB_TOKEN" required:"true"`
GithubWorkflowID string `envconfig:"GITHUB_WORKFLOW_ID" required:"true"`
}

func main() {
var env envConfig
if err := envconfig.Process("", &env); err != nil {
log.Fatalf("failed to process env var: %s", err)
}

client := github.NewClient(oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: strings.TrimSpace(env.GithubToken)},
)))

c, err := cloudevents.NewClientHTTP(cloudevents.WithPort(env.Port),
// We need to infuse the request onto context, so we can
// authenticate requests.
cehttp.WithRequestDataAtContextMiddleware())
if err != nil {
log.Fatalf("failed to create client, %v", err)
}
ctx := context.Background()

// Construct a verifier that ensures tokens are issued by the Chainguard
// issuer we expect and are intended for a customer webhook.
provider, err := oidc.NewProvider(ctx, env.Issuer)
if err != nil {
log.Fatalf("failed to create provider: %v", err)
}
verifier := provider.Verifier(&oidc.Config{
ClientID: "customer",
})

receiver := func(ctx context.Context, event cloudevents.Event) error {
// We expect Chainguard webhooks to pass an Authorization header.
auth := strings.TrimPrefix(cehttp.RequestDataFromContext(ctx).Header.Get("Authorization"), "Bearer ")
if auth == "" {
return cloudevents.NewHTTPResult(http.StatusUnauthorized, "Unauthorized")
}

// Verify that the token is well-formed, and in fact intended for us!
if tok, err := verifier.Verify(ctx, auth); err != nil {
return cloudevents.NewHTTPResult(http.StatusForbidden, "unable to verify token: %w", err)
} else if !strings.HasPrefix(tok.Subject, "webhook:") {
return cloudevents.NewHTTPResult(http.StatusForbidden, "subject should be from the Chainguard webhook component, got: %s", tok.Subject)
} else if group := strings.TrimPrefix(tok.Subject, "webhook:"); group != env.Group {
return cloudevents.NewHTTPResult(http.StatusForbidden, "this token is intended for %s, wanted one for %s", group, env.Group)
}

// We are handling a specific event type, so filter the rest.
if event.Type() != PushEventType {
return nil
}

data := Occurrence{}
if err := event.DataAs(&data); err != nil {
return cloudevents.NewHTTPResult(http.StatusInternalServerError, "unable to unmarshal data: %w", err)
}

log.Printf("got event: %+v", data)

img := "cgr.dev/" + data.Body.Repository

log.Println("got event for image:", img)
if _, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, env.GithubOrg, env.GithubRepo, env.GithubWorkflowID, github.CreateWorkflowDispatchEventRequest{
Ref: "main",
Inputs: map[string]interface{}{
"image": img,
},
}); err != nil {
log.Printf("failed to dispatch workflow: %v", err)
return cloudevents.NewHTTPResult(http.StatusInternalServerError, "failed to dispatch workflow: %w", err)
}
log.Printf("dispatched workflow %s (image=%q)", env.GithubWorkflowID, img)

return nil
}

if err := c.StartReceiver(ctx, func(ctx context.Context, event cloudevents.Event) error {
// This thunk simply wraps the main receiver in one that logs any errors
// we encounter.
err := receiver(ctx, event)
if err != nil {
log.Printf("SAW: %v", err)
}
return err
}); err != nil {
log.Fatal(err)
}
}
33 changes: 33 additions & 0 deletions image-workflow/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module github.com/imjasonh/terraform-playground/image-workflow

go 1.20

require (
github.com/cloudevents/sdk-go/v2 v2.13.0
github.com/coreos/go-oidc/v3 v3.5.0
github.com/google/go-github/v43 v43.0.0
github.com/kelseyhightower/envconfig v1.4.0
golang.org/x/oauth2 v0.6.0
)

require (
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/stretchr/testify v1.8.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/goleak v1.1.12 // indirect
go.uber.org/multierr v1.7.0 // indirect
go.uber.org/zap v1.21.0 // indirect
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.29.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
Loading
Loading