diff --git a/README.md b/README.md index 28fa011..5972d8e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,19 @@ deployment records to GitHub's artifact metadata API. API 5. Failed requests are automatically retried with exponential backoff +## Authentication + +Two modes of authentication are supported: + +1. Using a [GitHub + App](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps#building-a-github-app). +1. Using PAT + +> [!NOTE] The provisioned API token or GitHub App must have +> `artifact-metadata: write` with access to all relevant GitHub +> repositories (i.e all GitHub repositories that produces container +> images that are loaded into the cluster). + ## Command Line Options | Flag | Description | Default | @@ -45,21 +58,18 @@ deployment records to GitHub's artifact metadata API. ## Environment Variables -| Variable | Description | Default | -|------------------------|---------------------------|------------------------------------------------------| -| `ORG` | GitHub organization name | (required) | -| `BASE_URL` | API base URL | `api.github.com` | -| `DN_TEMPLATE` | Deployment name template | `{{namespace}}/{{deploymentName}}/{{containerName}}` | -| `LOGICAL_ENVIRONMENT` | Logical environment name | (required) | -| `PHYSICAL_ENVIRONMENT` | Physical environment name | `""` | -| `CLUSTER` | Cluster name | (required) | -| `API_TOKEN` | API authentication token | `""` | - -> [!NOTE] -> The provisioned API token must have `artifact-metadata: write` with -> access to all relevant GitHub repositories (i.e all GitHub -> repositories that produces container images that are loaded into the -> cluster. +| Variable | Description | Default | +|------------------------|--------------------------------------------|------------------------------------------------------| +| `ORG` | GitHub organization name | (required) | +| `BASE_URL` | API base URL | `api.github.com` | +| `DN_TEMPLATE` | Deployment name template | `{{namespace}}/{{deploymentName}}/{{containerName}}` | +| `LOGICAL_ENVIRONMENT` | Logical environment name | (required) | +| `PHYSICAL_ENVIRONMENT` | Physical environment name | `""` | +| `CLUSTER` | Cluster name | (required) | +| `API_TOKEN` | API authentication token | `""` | +| `GH_APP_ID` | GitHub App ID | `""` | +| `GH_INSTALL_ID` | GitHub App installation ID | `""` | +| `GH_APP_PRIV_KEY` | Path to the private key for the GitHub app | `""` | ### Template Variables diff --git a/cmd/deployment-tracker/main.go b/cmd/deployment-tracker/main.go index b047782..b2a18f3 100644 --- a/cmd/deployment-tracker/main.go +++ b/cmd/deployment-tracker/main.go @@ -64,6 +64,9 @@ func main() { Cluster: os.Getenv("CLUSTER"), APIToken: getEnvOrDefault("API_TOKEN", ""), BaseURL: getEnvOrDefault("BASE_URL", "api.github.com"), + GHAppID: getEnvOrDefault("GH_APP_ID", ""), + GHInstallID: getEnvOrDefault("GH_INSTALL_ID", ""), + GHAppPrivateKey: getEnvOrDefault("GH_APP_PRIV_KEY", ""), Organization: os.Getenv("GITHUB_ORG"), } diff --git a/go.mod b/go.mod index 2b41c71..b5dea87 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect + github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect @@ -19,8 +20,11 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-github/v75 v75.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index da0a6ea..a94e27b 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 h1:SmbUK/GxpAspRjSQbB6ARvH+ArzlNzTtHydNyXUQ6zg= +github.com/bradleyfalzon/ghinstallation/v2 v2.17.0/go.mod h1:vuD/xvJT9Y+ZVZRv4HQ42cMyPFIYqpc7AbB4Gvt/DlY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -24,10 +26,17 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v75 v75.0.0 h1:k7q8Bvg+W5KxRl9Tjq16a9XEgVY1pwuiG5sIL7435Ic= +github.com/google/go-github/v75 v75.0.0/go.mod h1:H3LUJEA1TCrzuUqtdAQniBNwuKiQIqdGKgBo1/M/uqI= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -115,6 +124,7 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/controller/config.go b/internal/controller/config.go index a5cb060..53d051b 100644 --- a/internal/controller/config.go +++ b/internal/controller/config.go @@ -21,6 +21,9 @@ type Config struct { Cluster string APIToken string BaseURL string + GHAppID string + GHInstallID string + GHAppPrivateKey string Organization string } diff --git a/internal/controller/controller.go b/internal/controller/controller.go index e73dbe5..54102b1 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -64,6 +64,12 @@ func New(clientset kubernetes.Interface, namespace string, cfg *Config) (*Contro if cfg.APIToken != "" { clientOpts = append(clientOpts, deploymentrecord.WithAPIToken(cfg.APIToken)) } + if cfg.GHAppID != "" && + cfg.GHInstallID != "" && + cfg.GHAppPrivateKey != "" { + clientOpts = append(clientOpts, deploymentrecord.WithGHApp(cfg.GHAppID, cfg.GHInstallID, cfg.GHAppPrivateKey)) + } + apiClient, err := deploymentrecord.NewClient( cfg.BaseURL, cfg.Organization, diff --git a/pkg/deploymentrecord/client.go b/pkg/deploymentrecord/client.go index 6eae994..34f8d0a 100644 --- a/pkg/deploymentrecord/client.go +++ b/pkg/deploymentrecord/client.go @@ -12,9 +12,11 @@ import ( "math/rand/v2" "net/http" "regexp" + "strconv" "strings" "time" + "github.com/bradleyfalzon/ghinstallation/v2" "github.com/github/deployment-tracker/pkg/metrics" "golang.org/x/time/rate" ) @@ -33,6 +35,7 @@ type Client struct { httpClient *http.Client retries int apiToken string + transport *ghinstallation.Transport rateLimiter *rate.Limiter } @@ -99,6 +102,30 @@ func WithAPIToken(token string) ClientOption { } } +// WithGHApp configures a GitHub app to use for authentication. +// If provided values are invalid, this will panic. +// If an API token is also set, the GitHub App will take precedence. +func WithGHApp(id, installID, pk string) ClientOption { + return func(c *Client) { + pid, err := strconv.Atoi(id) + if err != nil { + panic(err) + } + piid, err := strconv.Atoi(installID) + if err != nil { + panic(err) + } + c.transport, err = ghinstallation.NewKeyFromFile( + http.DefaultTransport, + int64(pid), + int64(piid), + pk) + if err != nil { + panic(err) + } + } +} + // WithRateLimiter sets a custom rate limiter for API calls. func WithRateLimiter(rps float64, burst int) ClientOption { return func(c *Client) { @@ -171,7 +198,15 @@ func (c *Client) PostOne(ctx context.Context, record *DeploymentRecord) error { } req.Header.Set("Content-Type", "application/json") - if c.apiToken != "" { + if c.transport != nil { + // Token is thread safe, so no need for external + // locking + tok, err := c.transport.Token(ctx) + if err != nil { + return fmt.Errorf("failed to get access token: %w", err) + } + req.Header.Set("Authorization", "Bearer "+tok) + } else if c.apiToken != "" { req.Header.Set("Authorization", "Bearer "+c.apiToken) }