From 835eb2755214725280f9e27696f7b7bc8c427401 Mon Sep 17 00:00:00 2001 From: Jordan Jacobelli Date: Wed, 28 Aug 2024 16:48:47 +0200 Subject: [PATCH] Initial commit Signed-off-by: Jordan Jacobelli --- .github/workflows/pr.yml | 22 ++++++ .github/workflows/release.yml | 14 ++++ .gitignore | 2 + Makefile | 25 +++++++ README.md | 14 ++++ cmd/org.go | 122 ++++++++++++++++++++++++++++++++ cmd/root.go | 67 ++++++++++++++++++ cmd/team.go | 128 ++++++++++++++++++++++++++++++++++ go.mod | 43 ++++++++++++ go.sum | 92 ++++++++++++++++++++++++ main.go | 13 ++++ pkg/aws/aws.go | 114 ++++++++++++++++++++++++++++++ pkg/gh/gh.go | 75 ++++++++++++++++++++ pkg/jwt/jwt.go | 54 ++++++++++++++ renovate.json | 6 ++ 15 files changed, 791 insertions(+) create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/org.go create mode 100644 cmd/root.go create mode 100644 cmd/team.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/aws/aws.go create mode 100644 pkg/gh/gh.go create mode 100644 pkg/jwt/jwt.go create mode 100644 renovate.json diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..cb7fb04 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,22 @@ +name: pr + +on: + pull_request: + +jobs: + run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Check gofmt + run: make gofmt-verify + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v5 + with: + version: latest + args: --timeout=5m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dfda64d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,14 @@ +name: release +on: + push: + tags: + - "v*" +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: cli/gh-extension-precompile@v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6065a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/gh-nv-gha-aws +/gh-nv-gha-aws.exe diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6057545 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +BIN_NAME = gh-nv-gha-aws +VERSION = $(shell git describe --tags --dirty --always) +BUILD_FLAGS = -tags osusergo,netgo \ + -ldflags "-s -extldflags=-static -X main.version=$(VERSION)" + +build: + go build -o $(BIN_NAME) $(BUILD_FLAGS) + +check: gofmt-verify ci-lint + +gofmt: + @gofmt -w -l $$(find . -name '*.go') + +gofmt-verify: + @out=`gofmt -w -l -d $$(find . -name '*.go')`; \ + if [ -n "$$out" ]; then \ + echo "$$out"; \ + exit 1; \ + fi + +ci-lint: + @docker run --pull always --rm -v $(PWD):/app -w /app golangci/golangci-lint:latest golangci-lint run + +clean: + @rm -f $(BIN_NAME) diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc9bcd7 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# gh-nv-gha-aws + +`gh-nv-gha-aws` is a `gh` extension that allows users to obtain temporary AWS credentials for preconfigured IAM roles based on GitHub organization or team membership. + +## Steps to install + +1. Please ensure that you have the `gh` CLI tool [installed](https://docs.github.com/en/github-cli/github-cli/quickstart). + +2. Login and authenticate with `gh auth login`. This is required to ensure you have correct credentials to receive the AWS Credentials. + +3. Run `gh extension install nv-gha-aws` + +More information about all of the available flags and their associated usage is available when running `gh nv-gha-aws --help` + diff --git a/cmd/org.go b/cmd/org.go new file mode 100644 index 0000000..1ce9612 --- /dev/null +++ b/cmd/org.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "context" + "fmt" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/nv-gha-runners/gh-nv-gha-aws/pkg/aws" + "github.com/nv-gha-runners/gh-nv-gha-aws/pkg/gh" + "github.com/nv-gha-runners/gh-nv-gha-aws/pkg/jwt" +) + +var orgCmd = &cobra.Command{ + Use: "org", + Short: "Receive AWS Credentials by providing an organization name", + Args: cobra.ExactArgs(1), + RunE: func(command *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + orgName := args[0] + + idpUrl, err := command.Flags().GetString("idp-url") + if err != nil { + return fmt.Errorf("failed to get --idp-url flag: %w", err) + } + + aud, err := command.Flags().GetString("aud") + if err != nil { + return fmt.Errorf("failed to get --aud flag: %w", err) + } + + roleArn, err := command.Flags().GetString("role-arn") + if err != nil { + return fmt.Errorf("failed to get --role-arn flag: %w", err) + } + + duration, err := command.Flags().GetInt32("duration") + if err != nil { + return fmt.Errorf("failed to get --duration flag: %w", err) + } + + profile, err := command.Flags().GetString("profile") + if err != nil { + return fmt.Errorf("failed to get --profile flag: %w", err) + } + + output, err := command.Flags().GetString("output") + if err != nil { + return fmt.Errorf("failed to get --output flag: %w", err) + } + + write, err := command.Flags().GetBool("write") + if err != nil { + return fmt.Errorf("failed to get --write flag: %w", err) + } + + file, err := command.Flags().GetString("file") + if err != nil { + return fmt.Errorf("failed to get --file flag: %w", err) + } + + ghToken, err := gh.GetGHToken() + if err != nil { + return err + } + + ghClient, err := gh.NewClient(ghToken) + if err != nil { + return fmt.Errorf("failed to create GH client: %w", err) + } + + username, err := ghClient.GetUsername() + if err != nil { + return fmt.Errorf("failed to get username: %w", err) + } + + orgID, err := ghClient.GetOrgID(orgName) + if err != nil { + return fmt.Errorf("failed to get org ID: %w", err) + } + + jwt, err := jwt.GetOrgJWT(&jwt.JWTInputs{ + Audience: aud, + GHToken: ghToken, + IDPUrl: idpUrl, + }, orgID) + if err != nil { + return fmt.Errorf("failed to get org JWT: %w", err) + } + + creds, err := aws.GetCreds(ctx, &aws.GetCredsInput{ + Duration: duration, + JWT: jwt, + Profile: profile, + RoleArn: roleArn, + Username: username, + }) + if err != nil { + return fmt.Errorf("failed to fet AWS credentials: %w", err) + } + + if err = creds.Print(output); err != nil { + return fmt.Errorf("failed to print credentials: %w", err) + } + + if write { + if err = creds.Write(file); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(orgCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..603d9c1 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +const ( + programName = "nv-gha-aws" +) + +var rootCmd = &cobra.Command{ + Use: programName, + Short: "A GitHub CLI Extension Tool to receive AWS Credentials", + SilenceUsage: true, +} + +func Execute(version string) { + rootCmd.Version = version + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().String("role-arn", "", "Role ARN ") + _ = rootCmd.MarkFlagRequired("role-arn") + + rootCmd.PersistentFlags().String("idp-url", "https://token.gha-runners.nvidia.com", "Identity Provider URL") + rootCmd.PersistentFlags().String("aud", "sts.amazonaws.com", "Audience of Web Identity Token") + rootCmd.PersistentFlags().Int32P("duration", "d", 43200, "The maximum session duration with the temporary AWS Credentials in seconds") + rootCmd.PersistentFlags().StringP("output", "o", "shell", "Output format of credentials in one of: shell, json, or creds-file format") + rootCmd.PersistentFlags().BoolP("write", "w", false, "Specifies if Credentials should be written to AWS Credentials file") + rootCmd.PersistentFlags().StringP("file", "f", "$HOME/.aws/credentials", "File path to write AWS Credentials") + rootCmd.PersistentFlags().StringP("profile", "p", "default", "Profile where credentials should be written to in AWS Credentials File") + + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + writeFlag, err := cmd.Flags().GetBool("write") + if err != nil { + return fmt.Errorf("failed to get --write flag: %w", err) + } + + printFormatFlag, err := cmd.Flags().GetString("output") + if err != nil { + return fmt.Errorf("failed to get --output flag: %w", err) + } + + // ensures that the writeFlag must be set in order to set the file flag + if cmd.Flags().Changed("file") && !writeFlag { + return errors.New("the write flag must be set if specifying a file path") + } + + if printFormatFlag != "creds-file" && cmd.Flags().Changed("profile") { + return errors.New("the profile can only be set if the output flag is set to creds-file") + } + + if printFormatFlag != "creds-file" && writeFlag { + return errors.New("the write flag can only be set if the output flag is set to creds-file") + } + + return nil + } +} diff --git a/cmd/team.go b/cmd/team.go new file mode 100644 index 0000000..6cb4aab --- /dev/null +++ b/cmd/team.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "context" + "fmt" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/nv-gha-runners/gh-nv-gha-aws/pkg/aws" + "github.com/nv-gha-runners/gh-nv-gha-aws/pkg/gh" + "github.com/nv-gha-runners/gh-nv-gha-aws/pkg/jwt" +) + +var teamCmd = &cobra.Command{ + Use: "team", + Short: "Receive AWS Credentials by providing both an Organization Name and a Team Name", + Args: cobra.ExactArgs(2), + RunE: func(command *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + orgName := args[0] + teamName := args[1] + + idpUrl, err := command.Flags().GetString("idp-url") + if err != nil { + return fmt.Errorf("failed to get --idp-url flag: %w", err) + } + + aud, err := command.Flags().GetString("aud") + if err != nil { + return fmt.Errorf("failed to get --aud flag: %w", err) + } + + roleArn, err := command.Flags().GetString("role-arn") + if err != nil { + return fmt.Errorf("failed to get --role-arn flag: %w", err) + } + + duration, err := command.Flags().GetInt32("duration") + if err != nil { + return fmt.Errorf("failed to get --duration flag: %w", err) + } + + profile, err := command.Flags().GetString("profile") + if err != nil { + return fmt.Errorf("failed to get --profile flag: %w", err) + } + + output, err := command.Flags().GetString("output") + if err != nil { + return fmt.Errorf("failed to get --output flag: %w", err) + } + + write, err := command.Flags().GetBool("write") + if err != nil { + return fmt.Errorf("failed to get --write flag: %w", err) + } + + file, err := command.Flags().GetString("file") + if err != nil { + return fmt.Errorf("failed to get --file flag: %w", err) + } + + ghToken, err := gh.GetGHToken() + if err != nil { + return err + } + + ghClient, err := gh.NewClient(ghToken) + if err != nil { + return fmt.Errorf("failed to create GH client: %w", err) + } + + username, err := ghClient.GetUsername() + if err != nil { + return fmt.Errorf("failed to get username: %w", err) + } + + orgID, err := ghClient.GetOrgID(orgName) + if err != nil { + return fmt.Errorf("failed to get org ID: %w", err) + } + + teamID, err := ghClient.GetTeamID(orgName, teamName) + if err != nil { + return fmt.Errorf("failed to get team ID: %w", err) + } + + jwt, err := jwt.GetTeamJWT(&jwt.JWTInputs{ + Audience: aud, + GHToken: ghToken, + IDPUrl: idpUrl, + }, orgID, teamID) + if err != nil { + return fmt.Errorf("failed to get team JWT: %w", err) + } + + creds, err := aws.GetCreds(ctx, &aws.GetCredsInput{ + Duration: duration, + JWT: jwt, + Profile: profile, + RoleArn: roleArn, + Username: username, + }) + if err != nil { + return fmt.Errorf("failed to fet AWS credentials: %w", err) + } + + if err = creds.Print(output); err != nil { + return fmt.Errorf("failed to print credentials: %w", err) + } + + if write { + if err = creds.Write(file); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(teamCmd) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1752e62 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module github.com/nv-gha-runners/gh-nv-gha-aws + +go 1.22 + +require ( + github.com/aws/aws-sdk-go-v2 v1.30.4 + github.com/aws/aws-sdk-go-v2/config v1.27.31 + github.com/aws/aws-sdk-go-v2/service/sts v1.30.5 + github.com/cli/go-gh/v2 v2.9.0 + github.com/gookit/ini v1.1.1 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/credentials v1.17.30 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 // indirect + github.com/aws/smithy-go v1.20.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cli/safeexec v1.0.1 // indirect + github.com/cli/shurcooL-graphql v0.0.4 // indirect + github.com/henvic/httpretty v0.1.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/thlib/go-timezone-local v0.0.3 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/term v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e5f62e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,92 @@ +github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= +github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= +github.com/aws/aws-sdk-go-v2/config v1.27.31 h1:kxBoRsjhT3pq0cKthgj6RU6bXTm/2SgdoUMyrVw0rAI= +github.com/aws/aws-sdk-go-v2/config v1.27.31/go.mod h1:z04nZdSWFPaDwK3DdJOG2r+scLQzMYuJeW0CujEm9FM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.30 h1:aau/oYFtibVovr2rDt8FHlU17BTicFEMAi29V1U+L5Q= +github.com/aws/aws-sdk-go-v2/credentials v1.17.30/go.mod h1:BPJ/yXV92ZVq6G8uYvbU0gSl8q94UB63nMT5ctNO38g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 h1:yjwoSyDZF8Jth+mUk5lSPJCkMC0lMy6FaCD51jm6ayE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12/go.mod h1:fuR57fAgMk7ot3WcNQfb6rSEn+SUffl7ri+aa8uKysI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.5 h1:OMsEmCyz2i89XwRwPouAJvhj81wINh+4UK+k/0Yo/q8= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.5/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0= +github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4= +github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cli/go-gh/v2 v2.9.0 h1:D3lTjEneMYl54M+WjZ+kRPrR5CEJ5BHS05isBPOV3LI= +github.com/cli/go-gh/v2 v2.9.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE= +github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= +github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= +github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= +github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gookit/ini v1.1.1 h1:wjwnhOHGdhqXY+i1OV3CzYXby/r04E3dHuG24cPADNI= +github.com/gookit/ini v1.1.1/go.mod h1:is/bsEzzUN6w/20zEfCiPjR7C3c1athtzUbTqkZTAhk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/henvic/httpretty v0.1.3 h1:4A6vigjz6Q/+yAfTD4wqipCv+Px69C7Th/NhT0ApuU8= +github.com/henvic/httpretty v0.1.3/go.mod h1:UUEv7c2kHZ5SPQ51uS3wBpzPDibg2U3Y+IaXyHy5GBg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/thlib/go-timezone-local v0.0.3 h1:ie5XtZWG5lQ4+1MtC5KZ/FeWlOKzW2nPoUnXYUbV/1s= +github.com/thlib/go-timezone-local v0.0.3/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8245bca --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "github.com/nv-gha-runners/gh-nv-gha-aws/cmd" +) + +var ( + version = "undefined" +) + +func main() { + cmd.Execute(version) +} diff --git a/pkg/aws/aws.go b/pkg/aws/aws.go new file mode 100644 index 0000000..495057c --- /dev/null +++ b/pkg/aws/aws.go @@ -0,0 +1,114 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/gookit/ini" +) + +type Credentials struct { + AccessKeyId string + SecretAccessKey string + SessionToken string + Profile string +} + +type GetCredsInput struct { + Duration int32 + JWT string + Profile string + RoleArn string + Username string +} + +func GetCreds(ctx context.Context, inputs *GetCredsInput) (*Credentials, error) { + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-2")) + if err != nil { + return nil, fmt.Errorf("failed to create config: %w", err) + } + + stsClient := sts.NewFromConfig(cfg) + output, err := stsClient.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityInput{ + RoleArn: aws.String(inputs.RoleArn), + RoleSessionName: aws.String(fmt.Sprintf("nv-gha-aws-%s", inputs.Username)), + WebIdentityToken: aws.String(inputs.JWT), + DurationSeconds: aws.Int32(inputs.Duration), + }) + if err != nil { + return nil, fmt.Errorf("failed to assume role: %w", err) + } + + return &Credentials{ + AccessKeyId: *output.Credentials.AccessKeyId, + SecretAccessKey: *output.Credentials.SecretAccessKey, + SessionToken: *output.Credentials.SessionToken, + Profile: inputs.Profile, + }, nil +} + +func (creds *Credentials) Print(outputType string) error { + switch outputType { + case "shell": + return creds.printShell() + case "json": + return creds.printJson() + case "creds-file": + return creds.printFile() + default: + return fmt.Errorf("invalid output type %s", outputType) + } +} + +func (creds *Credentials) printShell() error { + fmt.Printf("export AWS_ACCESS_KEY_ID=%s\n", creds.AccessKeyId) + fmt.Printf("export AWS_SECRET_ACCESS_KEY=%s\n", creds.SecretAccessKey) + fmt.Printf("export AWS_SESSION_TOKEN=%s\n", creds.SessionToken) + return nil +} + +func (creds *Credentials) printJson() error { + output, err := json.MarshalIndent(*creds, "", "") + if err != nil { + return fmt.Errorf("failed to marshall credentials: %w", err) + } + fmt.Println(string(output)) + return nil +} + +func (creds *Credentials) printFile() error { + fmt.Printf("[%s]\n", creds.Profile) + fmt.Printf("aws_access_key_id=%s\n", creds.AccessKeyId) + fmt.Printf("aws_secret_access_key=%s\n", creds.SecretAccessKey) + fmt.Printf("aws_session_token=%s\n", creds.SessionToken) + return nil +} + +func (creds *Credentials) Write(path string) error { + credsFile, err := ini.LoadExists(os.ExpandEnv(path)) + if err != nil { + return fmt.Errorf("failed to load file: %w", err) + } + + values := map[string]string{ + "aws_access_key_id": creds.AccessKeyId, + "aws_secret_access_key": creds.SecretAccessKey, + "aws_session_token": creds.SessionToken, + } + err = credsFile.SetSection(creds.Profile, values) + if err != nil { + return fmt.Errorf("failed to set section: %w", err) + } + + _, err = credsFile.WriteToFile(os.ExpandEnv(path)) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} diff --git a/pkg/gh/gh.go b/pkg/gh/gh.go new file mode 100644 index 0000000..5308341 --- /dev/null +++ b/pkg/gh/gh.go @@ -0,0 +1,75 @@ +package gh + +import ( + "errors" + "fmt" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/cli/go-gh/v2/pkg/auth" +) + +type client struct { + restClient *api.RESTClient +} + +func GetGHToken() (string, error) { + ghToken, _ := auth.TokenForHost("github.com") + if ghToken == "" { + return "", errors.New("failed to get GH token") + } + return ghToken, nil +} + +func NewClient(ghToken string) (*client, error) { + restClient, err := api.NewRESTClient(api.ClientOptions{ + AuthToken: ghToken, + }) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } + return &client{ + restClient: restClient, + }, nil +} + +func (c *client) GetOrgID(orgName string) (int, error) { + org := struct { + ID int `json:"id"` + }{} + + url := fmt.Sprintf("orgs/%s", orgName) + err := c.restClient.Get(url, &org) + if err != nil { + return 0, fmt.Errorf("failed to query /%s endpoint: %w", url, err) + } + + return org.ID, nil +} + +func (c *client) GetTeamID(orgName string, teamName string) (int, error) { + team := struct { + ID int `json:"id"` + }{} + + url := fmt.Sprintf("orgs/%s/teams/%s", orgName, teamName) + err := c.restClient.Get(url, &team) + if err != nil { + return 0, fmt.Errorf("failed to query /%s endpoint: %w", url, err) + } + + return team.ID, nil +} + +func (c *client) GetUsername() (string, error) { + user := struct { + Username string `json:"login"` + }{} + + url := "user" + err := c.restClient.Get(url, &user) + if err != nil { + return "", fmt.Errorf("failed to query /%s endpoint: %w", url, err) + } + + return user.Username, nil +} diff --git a/pkg/jwt/jwt.go b/pkg/jwt/jwt.go new file mode 100644 index 0000000..f40f21e --- /dev/null +++ b/pkg/jwt/jwt.go @@ -0,0 +1,54 @@ +package jwt + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +type JWTInputs struct { + Audience string + GHToken string + IDPUrl string +} + +func GetOrgJWT(inputs *JWTInputs, orgID int) (string, error) { + queryUrl := fmt.Sprintf("%s/gh/org/%d?audience=%s", inputs.IDPUrl, orgID, + inputs.Audience) + return getJWT(queryUrl, inputs.GHToken) +} + +func GetTeamJWT(inputs *JWTInputs, orgID int, teamID int) (string, error) { + queryUrl := fmt.Sprintf("%s/gh/team/%d/%d?audience=%s", inputs.IDPUrl, orgID, + teamID, inputs.Audience) + return getJWT(queryUrl, inputs.GHToken) +} + +func getJWT(queryUrl string, ghToken string) (string, error) { + req, err := http.NewRequest("GET", queryUrl, nil) + if err != nil { + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ghToken)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to query endpoint: %w", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + token := struct { + Token string `json:"token"` + }{} + if err := json.Unmarshal(body, &token); err != nil { + return "", fmt.Errorf("failed to unmarshal response body: %w", err) + } + return token.Token, nil +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +}