diff --git a/go.mod b/go.mod index 3b0064c..9f7d26c 100644 --- a/go.mod +++ b/go.mod @@ -5,17 +5,20 @@ go 1.17 require ( github.com/1Password/connect-sdk-go v1.5.0 github.com/google/go-github/v49 v49.1.0 - github.com/google/go-querystring v1.1.0 github.com/jamesruan/sodium v1.0.14 github.com/sirupsen/logrus v1.9.0 + github.com/stretchr/testify v1.7.1 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.3.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect github.com/uber/jaeger-lib v2.4.1+incompatible // indirect go.uber.org/atomic v1.9.0 // indirect diff --git a/main.go b/main.go index a761165..4a12b38 100644 --- a/main.go +++ b/main.go @@ -32,10 +32,16 @@ orgs: - TEST_SECRET_KEY ` -func syncSecrets(ctx context.Context, client *poiana.Client, provider poiana.SecretsProvider, orgName, repoName string, secrets []string) error { +func syncSecrets(ctx context.Context, + service poiana.ActionsSecretsService, + provider poiana.SecretsProvider, + pKey *github.PublicKey, + orgName, repoName string, + secrets []string) error { + // Step 1: load repo secrets logrus.Infof("listing secrets for repo '%s/%s'...", orgName, repoName) - secs, _, err := client.Actions.ListRepoSecrets(ctx, orgName, repoName, nil) + secs, err := service.ListRepoSecrets(ctx, orgName, repoName) if err != nil { return err } @@ -51,26 +57,19 @@ func syncSecrets(ctx context.Context, client *poiana.Client, provider poiana.Sec } if !found { logrus.Infof("deleting secret '%s' for repo '%s/%s'...", existentSec.Name, orgName, repoName) - _, err = client.Actions.DeleteRepoSecret(ctx, orgName, repoName, existentSec.Name) + err = service.DeleteRepoSecret(ctx, orgName, repoName, existentSec.Name) if err != nil { return err } } } - // Step 3: fetch encryption key - logrus.Infof("retrieving public key for repo '%s/%s'...", orgName, repoName) - pKey, _, err := client.Actions.GetRepoPublicKey(ctx, orgName, repoName) - if err != nil { - return err - } - keyBytes, err := base64.StdEncoding.DecodeString(pKey.GetKey()) if err != nil { return err } - // Step 4: add or update all conf-listed secrets + // Step 3: add or update all conf-listed secrets for _, secName := range secrets { logrus.Infof("adding/updating secret '%s' in repo '%s/%s'...", secName, orgName, repoName) secValue, err := provider.GetSecret(secName) @@ -84,7 +83,7 @@ func syncSecrets(ctx context.Context, client *poiana.Client, provider poiana.Sec if err != nil { return err } - _, err = client.Actions.CreateOrUpdateRepoSecret(ctx, orgName, repoName, &github.EncryptedSecret{ + err = service.CreateOrUpdateRepoSecret(ctx, orgName, repoName, &github.EncryptedSecret{ Name: secName, KeyID: pKey.GetKeyID(), EncryptedValue: encSecBytesB64, @@ -96,10 +95,10 @@ func syncSecrets(ctx context.Context, client *poiana.Client, provider poiana.Sec return nil } -func syncVariables(ctx context.Context, client *poiana.Client, orgName, repoName string, variables map[string]string) error { +func syncVariables(ctx context.Context, service poiana.ActionsVarsService, orgName, repoName string, variables map[string]string) error { // Step 1: load repo variables logrus.Infof("listing variables for repo '%s/%s'...", orgName, repoName) - vars, _, err := client.Actions.ListRepoVariables(ctx, orgName, repoName, nil) + vars, err := service.ListRepoVariables(ctx, orgName, repoName) if err != nil { return err } @@ -109,7 +108,7 @@ func syncVariables(ctx context.Context, client *poiana.Client, orgName, repoName _, ok := variables[existentVar.Name] if !ok { logrus.Infof("deleting variable '%s' for repo '%s/%s'...", existentVar.Name, orgName, repoName) - _, err = client.Actions.DeleteRepoVariable(ctx, orgName, repoName, existentVar.Name) + err = service.DeleteRepoVariable(ctx, orgName, repoName, existentVar.Name) if err != nil { return err } @@ -119,7 +118,7 @@ func syncVariables(ctx context.Context, client *poiana.Client, orgName, repoName // Step 3: add or update all conf-listed variables for newVarName, newVarValue := range variables { logrus.Infof("adding/updating variable '%s' in repo '%s/%s'...", newVarName, orgName, repoName) - _, err = client.Actions.CreateOrUpdateRepoVariable(ctx, orgName, repoName, &poiana.Variable{ + err = service.CreateOrUpdateRepoVariable(ctx, orgName, repoName, &poiana.Variable{ Name: newVarName, Value: newVarValue, }) @@ -160,13 +159,18 @@ func main() { // todo: also remove all secrets and vars for all repos not present // in the YAML config for repoName, repo := range org.Repos { - err = syncSecrets(ctx, client, provider, orgName, repoName, repo.Actions.Secrets) + // fetch encryption key + logrus.Infof("retrieving public key for repo '%s/%s'...", orgName, repoName) + pKey, _, err := client.Actions.GetRepoPublicKey(ctx, orgName, repoName) + if err == nil { + err = syncSecrets(ctx, client.Actions, provider, pKey, orgName, repoName, repo.Actions.Secrets) + } if err != nil { fail(err.Error()) } logrus.Infof("secrets synced for %s/%s\n", orgName, repoName) - err = syncVariables(ctx, client, orgName, repoName, repo.Actions.Variables) + err = syncVariables(ctx, client.Actions, orgName, repoName, repo.Actions.Variables) if err != nil { fail(err.Error()) } diff --git a/pkg/poiana/client.go b/pkg/poiana/client.go new file mode 100644 index 0000000..559ab3e --- /dev/null +++ b/pkg/poiana/client.go @@ -0,0 +1,38 @@ +package poiana + +import ( + "github.com/google/go-github/v49/github" +) + +// Variable represents a repository action variable. +type Variable struct { + Name string `json:"name"` + Value string `json:"value"` + CreatedAt github.Timestamp `json:"created_at"` + UpdatedAt github.Timestamp `json:"updated_at"` +} + +type Variables struct { + TotalCount int `json:"total_count"` + Variables []*Variable `json:"variables"` +} + +type actionsService struct { + *github.ActionsService + client *github.Client +} + +type Client struct { + *github.Client + Actions *actionsService +} + +func NewClient(c *github.Client) *Client { + return &Client{ + Client: c, + Actions: &actionsService{ + ActionsService: c.Actions, + client: c, + }, + } +} diff --git a/pkg/poiana/github.go b/pkg/poiana/github.go deleted file mode 100644 index 7a0a4b6..0000000 --- a/pkg/poiana/github.go +++ /dev/null @@ -1,136 +0,0 @@ -package poiana - -import ( - "context" - "fmt" - "net/http" - "net/url" - "reflect" - - "github.com/google/go-github/v49/github" - "github.com/google/go-querystring/query" -) - -// Variable represents a repository action variable. -type Variable struct { - Name string `json:"name"` - Value string `json:"value"` - CreatedAt github.Timestamp `json:"created_at"` - UpdatedAt github.Timestamp `json:"updated_at"` -} - -type Variables struct { - TotalCount int `json:"total_count"` - Variables []*Variable `json:"variables"` -} - -type actionsService struct { - *github.ActionsService - client *github.Client -} - -type Client struct { - *github.Client - Actions *actionsService -} - -func NewClient(c *github.Client) *Client { - return &Client{ - Client: c, - Actions: &actionsService{ - ActionsService: c.Actions, - client: c, - }, - } -} - -func httpAddOptions(s string, opts interface{}) (string, error) { - v := reflect.ValueOf(opts) - if v.Kind() == reflect.Ptr && v.IsNil() { - return s, nil - } - u, err := url.Parse(s) - if err != nil { - return s, err - } - qs, err := query.Values(opts) - if err != nil { - return s, err - } - u.RawQuery = qs.Encode() - return u.String(), nil -} - -// ListRepoVariables lists all variables available in a repository -// without revealing their encrypted values. -// -// GitHub API docs: https://docs.github.com/en/rest/actions/variables#list-repository-variables -func (s *actionsService) ListRepoVariables(ctx context.Context, owner, repo string, opts *github.ListOptions) (*Variables, *github.Response, error) { - url := fmt.Sprintf("repos/%v/%v/actions/variables", owner, repo) - u, err := httpAddOptions(url, opts) - if err != nil { - return nil, nil, err - } - req, err := s.client.NewRequest("GET", u, nil) - if err != nil { - return nil, nil, err - } - variables := new(Variables) - resp, err := s.client.Do(ctx, req, &variables) - if err != nil { - return nil, resp, err - } - return variables, resp, nil -} - -// GetRepoVariable gets a single repository variable without revealing its encrypted value. -// -// GitHub API docs: https://docs.github.com/en/rest/actions/variables#get-a-repository-variable -func (s *actionsService) GetRepoVariable(ctx context.Context, owner, repo, name string) (*Variable, *github.Response, error) { - url := fmt.Sprintf("repos/%v/%v/actions/variables/%v", owner, repo, name) - req, err := s.client.NewRequest("GET", url, nil) - if err != nil { - return nil, nil, err - } - variable := new(Variable) - resp, err := s.client.Do(ctx, req, variable) - if err != nil { - return nil, resp, err - } - return variable, resp, nil -} - -// CreateOrUpdateRepoVariable creates or updates a repository variable. -// -// GitHub API docs: https://docs.github.com/en/rest/actions/variables#create-or-update-a-repository-variable -func (s *actionsService) CreateOrUpdateRepoVariable(ctx context.Context, owner, repo string, variable *Variable) (*github.Response, error) { - url := fmt.Sprintf("repos/%v/%v/actions/variables/%v", owner, repo, variable.Name) - req, err := s.client.NewRequest("PATCH", url, variable) - if err != nil { - return nil, err - } - resp, err := s.client.Do(ctx, req, nil) - if err != nil { - if resp.StatusCode != http.StatusNoContent { - url = fmt.Sprintf("repos/%v/%v/actions/variables", owner, repo) - req, err = s.client.NewRequest("POST", url, variable) - if err != nil { - return nil, err - } - return s.client.Do(ctx, req, nil) - } - } - return resp, err -} - -// DeleteRepoVariable deletes a variable in a repository using the variable name. -// -// GitHub API docs: https://docs.github.com/en/rest/actions/variables#delete-a-repository-variable -func (s *actionsService) DeleteRepoVariable(ctx context.Context, owner, repo, name string) (*github.Response, error) { - url := fmt.Sprintf("repos/%v/%v/actions/variables/%v", owner, repo, name) - req, err := s.client.NewRequest("DELETE", url, nil) - if err != nil { - return nil, err - } - return s.client.Do(ctx, req, nil) -} diff --git a/pkg/poiana/secrets.go b/pkg/poiana/secrets.go index f4ea524..5ad5a59 100644 --- a/pkg/poiana/secrets.go +++ b/pkg/poiana/secrets.go @@ -1,8 +1,34 @@ package poiana +import ( + "context" + "github.com/google/go-github/v49/github" +) + // SecretsProvider retrieves secrets with a given key type SecretsProvider interface { // GetSecret returns a secret with the given key. // Returns a non-nil error in case of failure GetSecret(string) (string, error) } + +type ActionsSecretsService interface { + ListRepoSecrets(ctx context.Context, owner, repo string) (*github.Secrets, error) + DeleteRepoSecret(ctx context.Context, owner, repo, name string) error + CreateOrUpdateRepoSecret(ctx context.Context, owner, repo string, eSecret *github.EncryptedSecret) error +} + +func (s *actionsService) ListRepoSecrets(ctx context.Context, owner, repo string) (*github.Secrets, error) { + secrets, _, err := s.ActionsService.ListRepoSecrets(ctx, owner, repo, nil) + return secrets, err +} + +func (s *actionsService) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error { + _, err := s.ActionsService.DeleteRepoSecret(ctx, owner, repo, name) + return err +} + +func (s *actionsService) CreateOrUpdateRepoSecret(ctx context.Context, owner, repo string, eSecret *github.EncryptedSecret) error { + _, err := s.ActionsService.CreateOrUpdateRepoSecret(ctx, owner, repo, eSecret) + return err +} diff --git a/pkg/poiana/variables.go b/pkg/poiana/variables.go new file mode 100644 index 0000000..ea34ff1 --- /dev/null +++ b/pkg/poiana/variables.go @@ -0,0 +1,82 @@ +package poiana + +import ( + "context" + "fmt" + "net/http" +) + +type ActionsVarsService interface { + ListRepoVariables(ctx context.Context, owner, repo string) (*Variables, error) + DeleteRepoVariable(ctx context.Context, owner, repo, name string) error + CreateOrUpdateRepoVariable(ctx context.Context, owner, repo string, variable *Variable) error +} + +// ListRepoVariables lists all variables available in a repository +// without revealing their encrypted values. +// +// GitHub API docs: https://docs.github.com/en/rest/actions/variables#list-repository-variables +func (s *actionsService) ListRepoVariables(ctx context.Context, owner, repo string) (*Variables, error) { + u := fmt.Sprintf("repos/%v/%v/actions/variables", owner, repo) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, err + } + variables := new(Variables) + _, err = s.client.Do(ctx, req, &variables) + if err != nil { + return nil, err + } + return variables, nil +} + +// GetRepoVariable gets a single repository variable without revealing its encrypted value. +// +// GitHub API docs: https://docs.github.com/en/rest/actions/variables#get-a-repository-variable +func (s *actionsService) GetRepoVariable(ctx context.Context, owner, repo, name string) (*Variable, error) { + u := fmt.Sprintf("repos/%v/%v/actions/variables/%v", owner, repo, name) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, err + } + variable := new(Variable) + _, err = s.client.Do(ctx, req, variable) + if err != nil { + return nil, err + } + return variable, nil +} + +// CreateOrUpdateRepoVariable creates or updates a repository variable. +// +// GitHub API docs: https://docs.github.com/en/rest/actions/variables#create-or-update-a-repository-variable +func (s *actionsService) CreateOrUpdateRepoVariable(ctx context.Context, owner, repo string, variable *Variable) error { + u := fmt.Sprintf("repos/%v/%v/actions/variables/%v", owner, repo, variable.Name) + req, err := s.client.NewRequest("PATCH", u, variable) + if err != nil { + return err + } + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + if resp.StatusCode != http.StatusNoContent { + u = fmt.Sprintf("repos/%v/%v/actions/variables", owner, repo) + req, err = s.client.NewRequest("POST", u, variable) + if err == nil { + _, err = s.client.Do(ctx, req, nil) + } + } + } + return err +} + +// DeleteRepoVariable deletes a variable in a repository using the variable name. +// +// GitHub API docs: https://docs.github.com/en/rest/actions/variables#delete-a-repository-variable +func (s *actionsService) DeleteRepoVariable(ctx context.Context, owner, repo, name string) error { + u := fmt.Sprintf("repos/%v/%v/actions/variables/%v", owner, repo, name) + req, err := s.client.NewRequest("DELETE", u, nil) + if err == nil { + _, err = s.client.Do(ctx, req, nil) + } + return err +} diff --git a/secrets_test.go b/secrets_test.go new file mode 100644 index 0000000..d3d2a14 --- /dev/null +++ b/secrets_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "encoding/base64" + "github.com/FedeDP/GhEnvSet/pkg/poiana" + "github.com/google/go-github/v49/github" + "github.com/stretchr/testify/assert" + "testing" +) + +type MockSecretsService struct { + secrets map[string]*github.EncryptedSecret +} + +func (m MockSecretsService) ListRepoSecrets(ctx context.Context, owner, repo string) (*github.Secrets, error) { + secs := make([]*github.Secret, 0) + for key, _ := range m.secrets { + secs = append(secs, &github.Secret{ + Name: key, + }) + } + + return &github.Secrets{ + TotalCount: len(m.secrets), + Secrets: secs, + }, nil +} + +func (m MockSecretsService) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error { + delete(m.secrets, name) + return nil +} + +func (m MockSecretsService) CreateOrUpdateRepoSecret(ctx context.Context, owner, repo string, eSecret *github.EncryptedSecret) error { + m.secrets[eSecret.Name] = eSecret + return nil +} + +func newMockSecretsService() poiana.ActionsSecretsService { + mServ := &MockSecretsService{secrets: make(map[string]*github.EncryptedSecret, 0)} + _ = mServ.CreateOrUpdateRepoSecret(context.Background(), "", "", &github.EncryptedSecret{ + Name: "secret0", + KeyID: "testing", + }) + return mServ +} + +func TestSyncServices(t *testing.T) { + ctx := context.Background() + secrets := []string{ + "secret1", "secret2", + } + + mockServ := newMockSecretsService() + provider, err := poiana.NewMockSecretsProvider(map[string]string{"secret1": "value1", "secret2": "value2"}) + assert.NoError(t, err) + + keyID := "testing" + key := base64.StdEncoding.EncodeToString([]byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) // 32B key + pKey := github.PublicKey{ + KeyID: &keyID, + Key: &key, + } + err = syncSecrets(ctx, mockServ, provider, &pKey, "", "", secrets) + assert.NoError(t, err) + + secs, err := mockServ.ListRepoSecrets(ctx, "", "") + assert.NoError(t, err) + + for _, sec := range secs.Secrets { + assert.Contains(t, secrets, sec.Name) + } +} diff --git a/variables_test.go b/variables_test.go new file mode 100644 index 0000000..b837a45 --- /dev/null +++ b/variables_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "github.com/FedeDP/GhEnvSet/pkg/poiana" + "github.com/google/go-github/v49/github" + "github.com/stretchr/testify/assert" + "testing" +) + +type MockVariableService struct { + variables map[string]string +} + +func (m MockVariableService) ListRepoVariables(ctx context.Context, owner, repo string) (*poiana.Variables, error) { + vars := make([]*poiana.Variable, 0) + for key, val := range m.variables { + vars = append(vars, &poiana.Variable{ + Name: key, + Value: val, + }) + } + + return &poiana.Variables{ + TotalCount: len(m.variables), + Variables: vars, + }, nil +} + +func (m MockVariableService) DeleteRepoVariable(ctx context.Context, owner, repo, name string) error { + delete(m.variables, name) + return nil +} + +func (m MockVariableService) CreateOrUpdateRepoVariable(ctx context.Context, owner, repo string, variable *poiana.Variable) error { + m.variables[variable.Name] = variable.Value + return nil +} + +func newMockVariableService() poiana.ActionsVarsService { + mServ := &MockVariableService{variables: make(map[string]string, 0)} + // Initial variable set + _ = mServ.CreateOrUpdateRepoVariable(context.Background(), "", "", &poiana.Variable{ + Name: "test0", + Value: "value0", + CreatedAt: github.Timestamp{}, + UpdatedAt: github.Timestamp{}, + }) + return mServ +} + +func TestSyncVariables(t *testing.T) { + ctx := context.Background() + variables := map[string]string{ + "test1": "value1", + "test2": "value2", + } + + mockServ := newMockVariableService() + err := syncVariables(ctx, mockServ, "", "", variables) + assert.NoError(t, err) + + vars, err := mockServ.ListRepoVariables(ctx, "", "") + assert.NoError(t, err) + + for _, v := range vars.Variables { + assert.Equal(t, variables[v.Name], v.Value) + } +}