diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 08e2085..1b79473 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,8 +12,9 @@ on: env: img-registry: ghcr.io/h0n9 img-repository: cloud-secrets-manager - img-tag: latest + img-tags: ghcr.io/h0n9/cloud-secrets-manager:tmp img-push: "false" + img-platforms: linux/amd64 jobs: build-push: runs-on: ubuntu-22.04 @@ -33,18 +34,20 @@ jobs: if: ${{ github.ref_name == 'develop' }} shell: bash run: | - echo "img-tag=dev-${GITHUB_SHA::6}" >> $GITHUB_ENV + echo "img-tags=${{ env.img-registry }}/${{ env.img-repository }}:dev-${GITHUB_SHA::6}" >> $GITHUB_ENV echo "img-push=true" >> $GITHUB_ENV - name: "Set env vars (tag)" if: ${{ startsWith(github.ref_name, 'v') }} shell: bash run: | - echo "img-tag=${GITHUB_REF_NAME}" >> $GITHUB_ENV + echo "img-tags=${{ env.img-registry }}/${{ env.img-repository }}:${GITHUB_REF_NAME},${{ env.img-registry }}/${{ env.img-repository }}:latest" >> $GITHUB_ENV echo "img-push=true" >> $GITHUB_ENV + echo "img-platforms=linux/amd64,linux/arm64" >> $GITHUB_ENV - name: Build Docker image uses: docker/build-push-action@v2 with: + platforms: ${{ env.img-platforms }} push: ${{ env.img-push }} - tags: ${{ env.img-registry }}/${{ env.img-repository }}:${{ env.img-tag }} + tags: ${{ env.img-tags }} cache-from: type=gha,scope=cloud-secrets-manager cache-to: type=gha,mode=max,scope=cloud-secrets-manager diff --git a/README.md b/README.md index ba0b43b..32689d3 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,32 @@ to inject secrets strored on Cloud-based secrets managers into Kubernetes Pods, functioning as [HashiCorp Vault's Agent Sidecar Injector](https://www.vaultproject.io/docs/platform/k8s/injector). -## Cloud Providers - -### Currently Supported +Also, it provides a convenient CLI tool with features like `list` and `edit` to +make secret management easier than using the Cloud Console. If you want to jump +into the CLI tool, please refer to the [CLI Tool](#cli-tool) section right away. + +## Contents +- [Supported Cloud Providers](#cloud-providers) + - [Current](#current) + - [Planned](#planned) +- [Concept](#concept) + - [Constitution](#constitution) + - [Step-by-step](#step-by-step) +- [Installation](#installation) + - [Prerequisites](#prerequisites) + - [Using Helm chart](#using-helm-chart) +- [Usage](#usage) + - [Annotations](#annotations) + - [Providers](#providers) + - [CLI Tool](#cli-tool) + +## Supported Cloud Providers + +### Current - AWS(Amazon Web Services): [Secrets Manager](https://aws.amazon.com/secrets-manager/) - GCP(Google Cloud Platform): [Secret Manager](https://cloud.google.com/secret-manager) `(BETA)` -### TO-BE Supported +### Planned - Azure: [Key Vault](https://azure.microsoft.com/services/key-vault/#getting-started) - Hashicorp: [Vault](https://www.vaultproject.io) @@ -106,3 +125,47 @@ following explanation. - [AWS(Amazon Web Services)](docs/aws.md) - [GCP(Google Cloud Platform)](docs/gcp.md) + +### CLI Tool + +#### Installation + +As Cloud Secrets Manager is available as a Docker image, there is no need to +install the CLI tool. Just run the Docker container as follows: + +```bash +$ dokcer pull ghcr.io/h0n9/cloud-secrets-manager:latest +``` + +You can change the tag to a specific version if you want like following: + +```bash +$ dokcer pull ghcr.io/h0n9/cloud-secrets-manager:v0.5 +``` + +#### List Secrets + +```bash +$ docker run --rm -it ghcr.io/h0n9/cloud-secrets-manager:latest secrets list --provider aws --limit 3 +dev/hello-world +dev/very-precious-secret +dev/another-secret +``` +The `--limit` option is available to limit the number of secrets to be listed. + +#### Edit Secret + +```bash +$ docker run --rm -it ghcr.io/h0n9/cloud-secrets-manager:latest secrets edit --provider aws --secret-id dev/very-precious-secret +``` + +A text editor will be opened with the secret value. After editing, save and +close the editor to update the secret value. If you want to cancel the editing, +just close the editor without saving. + +If you want to use a specific editor, set the `EDITOR` environment variable. + +```bash +$ export EDITOR=nano +$ docker run --rm -it ghcr.io/h0n9/cloud-secrets-manager:latest secrets edit --provider aws --secret-id dev/very-precious-secret +``` diff --git a/cli/root.go b/cli/root.go index 90f495a..0f59051 100644 --- a/cli/root.go +++ b/cli/root.go @@ -9,6 +9,7 @@ import ( "github.com/h0n9/cloud-secrets-manager/cli/cert" "github.com/h0n9/cloud-secrets-manager/cli/controller" "github.com/h0n9/cloud-secrets-manager/cli/injector" + cliSecrets "github.com/h0n9/cloud-secrets-manager/cli/secrets" cliTemplate "github.com/h0n9/cloud-secrets-manager/cli/template" ) @@ -23,9 +24,10 @@ func init() { RootCmd.AddCommand( controller.Cmd, injector.Cmd, + cert.Cmd, newLineCmd, + cliSecrets.Cmd, cliTemplate.Cmd, - cert.Cmd, newLineCmd, VersionCmd, ) diff --git a/cli/secrets/edit.go b/cli/secrets/edit.go new file mode 100644 index 0000000..d044f2a --- /dev/null +++ b/cli/secrets/edit.go @@ -0,0 +1,147 @@ +package secrets + +import ( + "context" + "crypto/sha1" + "fmt" + "os" + "os/exec" + "path" + "strings" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + "github.com/h0n9/cloud-secrets-manager/provider" + "github.com/h0n9/cloud-secrets-manager/util" +) + +var ( + secretID string +) + +var editCmd = &cobra.Command{ + Use: "edit", + Short: "edit a secret", + RunE: func(cmd *cobra.Command, args []string) error { + // check secretID + if secretID == "" { + return fmt.Errorf("failed to read 'secret-id' flag") + } + + // define variables + var ( + err error + secretProvider provider.SecretProvider + ) + + // init ctx + ctx := context.Background() + + // init secret provider + switch strings.ToLower(providerName) { + case "aws": + secretProvider, err = provider.NewAWS(ctx) + case "gcp": + secretProvider, err = provider.NewGCP(ctx) + default: + return fmt.Errorf("failed to figure out secret provider") + } + if err != nil { + return err + } + defer secretProvider.Close() + + // get secret value + secretValue, err := secretProvider.GetSecretValue(secretID) + if err != nil { + return err + } + + // convert json to yaml + data, err := yaml.JSONToYAML([]byte(secretValue)) + if err != nil { + return err + } + + // write data to tmp file + UserCacheDir, err := os.UserCacheDir() + if err != nil { + return err + } + hash := sha1.Sum([]byte(secretID)) + tmpFilePath := path.Join(UserCacheDir, fmt.Sprintf("%x", hash)) + err = os.WriteFile(tmpFilePath, data, 0644) + if err != nil { + return err + } + defer os.Remove(tmpFilePath) + + // get initial stat of tmp file + initialStat, err := os.Stat(tmpFilePath) + if err != nil { + return err + } + + // open tmp file with editor(e.g. vim) + editor := util.GetEnv("EDITOR", DefaultEditor) + execCmd := exec.Command(editor, tmpFilePath) + execCmd.Stdin = os.Stdin + execCmd.Stdout = os.Stdout + err = execCmd.Run() + if err != nil { + return err + } + + // get updated stat of tmp file + updatedStat, err := os.Stat(tmpFilePath) + if err != nil { + return err + } + + // check if tmp file is updated. if not, return nil + if initialStat.ModTime().Equal(updatedStat.ModTime()) && + initialStat.Size() == updatedStat.Size() { + fmt.Println("found nothing to update") + return nil + } + + // read tmp file + data, err = os.ReadFile(tmpFilePath) + if err != nil { + return err + } + + // convert yaml to json + data, err = yaml.YAMLToJSON(data) + if err != nil { + return err + } + + // update secret value + secretValue = string(data) + + // set secret value to provider + err = secretProvider.SetSecretValue(secretID, secretValue) + if err != nil { + return err + } + + return nil + }, +} + +func init() { + editCmd.Flags().StringVar( + &providerName, + "provider", + DefaultProviderName, + "cloud provider name", + ) + editCmd.Flags().StringVar( + &secretID, + "secret-id", + "", + "secret id", + ) +} diff --git a/cli/secrets/list.go b/cli/secrets/list.go new file mode 100644 index 0000000..1bce310 --- /dev/null +++ b/cli/secrets/list.go @@ -0,0 +1,81 @@ +package secrets + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/h0n9/cloud-secrets-manager/provider" +) + +const ( + DefaultListSecretsLimit = 10 +) + +var ( + listSecretsLimit int +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "list secrets", + RunE: func(cmd *cobra.Command, args []string) error { + // define variables + var ( + err error + secretProvider provider.SecretProvider + ) + + // check constraints + if listSecretsLimit <= 0 { + return fmt.Errorf("'--limit' flag value must be greater than 0") + } + + // init ctx + ctx := context.Background() + + // init secret provider + switch strings.ToLower(providerName) { + case "aws": + secretProvider, err = provider.NewAWS(ctx) + case "gcp": + secretProvider, err = provider.NewGCP(ctx) + default: + return fmt.Errorf("failed to figure out secret provider") + } + if err != nil { + return err + } + defer secretProvider.Close() + + // list secrets + secrets, err := secretProvider.ListSecrets(listSecretsLimit) + if err != nil { + return err + } + + // print secrets + for _, secret := range secrets { + fmt.Println(secret) + } + + return nil + }, +} + +func init() { + listCmd.Flags().StringVar( + &providerName, + "provider", + DefaultProviderName, + "cloud provider name", + ) + listCmd.Flags().IntVar( + &listSecretsLimit, + "limit", + DefaultListSecretsLimit, + "limit the number of secrets to list", + ) +} diff --git a/cli/secrets/secrets.go b/cli/secrets/secrets.go new file mode 100644 index 0000000..b2713f6 --- /dev/null +++ b/cli/secrets/secrets.go @@ -0,0 +1,24 @@ +package secrets + +import ( + "github.com/spf13/cobra" +) + +const ( + DefaultProviderName = "aws" + DefaultEditor = "vim" +) + +var ( + providerName string +) + +var Cmd = &cobra.Command{ + Use: "secrets", + Short: "CLI for managing secrets", +} + +func init() { + Cmd.AddCommand(listCmd) + Cmd.AddCommand(editCmd) +} diff --git a/go.mod b/go.mod index 67a6b6b..fd73e96 100644 --- a/go.mod +++ b/go.mod @@ -4,23 +4,23 @@ go 1.18 require ( cloud.google.com/go/secretmanager v1.10.0 + github.com/aws/aws-sdk-go-v2 v1.16.4 github.com/aws/aws-sdk-go-v2/config v1.15.9 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.9 github.com/rs/zerolog v1.26.1 github.com/spf13/cobra v1.4.0 github.com/stretchr/testify v1.8.1 - google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 k8s.io/api v0.24.2 k8s.io/apimachinery v0.24.2 k8s.io/client-go v0.24.2 sigs.k8s.io/controller-runtime v0.12.1 + sigs.k8s.io/yaml v1.4.0 ) require ( cloud.google.com/go/compute v1.19.1 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/iam v0.13.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.16.4 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.12.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.5 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.11 // indirect @@ -79,6 +79,7 @@ require ( gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect google.golang.org/api v0.114.0 // indirect google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect google.golang.org/grpc v1.56.3 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -91,5 +92,4 @@ require ( k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect sigs.k8s.io/json v0.0.0-20220525155127-227cbc7cc124 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect - sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index fbf68a8..ab662b9 100644 --- a/go.sum +++ b/go.sum @@ -1044,5 +1044,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK sigs.k8s.io/structured-merge-diff/v4 v4.2.1 h1:bKCqE9GvQ5tiVHn5rfn1r+yao3aLQEaLzkkmAkf+A6Y= sigs.k8s.io/structured-merge-diff/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= -sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= -sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/provider/aws.go b/provider/aws.go index 99720a7..ea40c5a 100644 --- a/provider/aws.go +++ b/provider/aws.go @@ -3,6 +3,7 @@ package provider import ( "context" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" ) @@ -30,11 +31,56 @@ func (provider *AWS) Close() error { return nil } +func (provider *AWS) ListSecrets(limit int) ([]string, error) { + req := &secretsmanager.ListSecretsInput{} + var secrets []string + + for { + // list secrets + resp, err := provider.client.ListSecrets(provider.ctx, req) + if err != nil { + return nil, err + } + + // append secret names + for _, secret := range resp.SecretList { + secrets = append(secrets, *secret.Name) + } + + // break if no more secrets or reached the limit + if resp.NextToken == nil || len(secrets) >= limit { + break + } + + // set next token + req.NextToken = resp.NextToken + } + + // truncate secrets if exceeded the limit + if len(secrets) > limit { + secrets = secrets[:limit] + } + + return secrets, nil +} + func (provider *AWS) GetSecretValue(secretID string) (string, error) { - req := &secretsmanager.GetSecretValueInput{SecretId: &secretID} + req := &secretsmanager.GetSecretValueInput{SecretId: aws.String(secretID)} resp, err := provider.client.GetSecretValue(provider.ctx, req) if err != nil { return "", err } return *resp.SecretString, nil } + +func (provider *AWS) SetSecretValue(secretID, secretValue string) error { + req := &secretsmanager.PutSecretValueInput{ + SecretId: aws.String(secretID), + SecretString: aws.String(secretValue), + } + _, err := provider.client.PutSecretValue(provider.ctx, req) + if err != nil { + return err + } + return nil +} diff --git a/provider/gcp.go b/provider/gcp.go index fbef682..bc583cb 100644 --- a/provider/gcp.go +++ b/provider/gcp.go @@ -4,7 +4,7 @@ import ( "context" secretmanager "cloud.google.com/go/secretmanager/apiv1" - secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1" + "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" ) type GCP struct { @@ -28,6 +28,35 @@ func (provider *GCP) Close() error { return provider.client.Close() } +func (provider *GCP) ListSecrets(limit int) ([]string, error) { + req := &secretmanagerpb.ListSecretsRequest{} + var secrets []string + + secretsIterator := provider.client.ListSecrets(provider.ctx, req) + for { + // get next secret + resp, err := secretsIterator.Next() + if err != nil { + break + } + + // append secret name + secrets = append(secrets, resp.GetName()) + + // break if reached the limit + if len(secrets) >= limit { + break + } + } + + // truncate secrets if exceeded the limit + if len(secrets) > limit { + secrets = secrets[:limit] + } + + return secrets, nil +} + // The secretID in the format `projects/*/secrets/*/versions/*`. // `projects/*/secrets/*/versions/latest`: recently created func (provider *GCP) GetSecretValue(secretID string) (string, error) { @@ -38,3 +67,7 @@ func (provider *GCP) GetSecretValue(secretID string) (string, error) { } return string(resp.GetPayload().GetData()), nil } + +func (provider *GCP) SetSecretValue(secretID, secretValue string) error { + return nil +} diff --git a/provider/provider.go b/provider/provider.go index 70f52d5..e53d0bd 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -2,5 +2,7 @@ package provider type SecretProvider interface { Close() error - GetSecretValue(string) (string, error) + ListSecrets(limit int) ([]string, error) + GetSecretValue(secretID string) (string, error) + SetSecretValue(secretID string, secretValue string) error }