From 6e7861d552046166994417af5f30a51798bbc7fe Mon Sep 17 00:00:00 2001 From: Jonathan Wiemers Date: Thu, 23 Mar 2023 01:49:23 +0100 Subject: [PATCH] feat: Rotate Github App Token outside of Atlantis commands (#3208) Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> Co-authored-by: PePe Amengual --- .gitignore | 1 + docker-compose.yml | 4 +- server/events/apply_command_runner_test.go | 2 + .../approve_policies_command_runner_test.go | 1 + server/events/command_runner_test.go | 2 +- server/events/github_app_working_dir.go | 39 +++------ server/events/github_app_working_dir_test.go | 10 ++- server/events/import_command_runner_test.go | 1 + server/events/plan_command_runner_test.go | 1 + server/events/pull_closed_executor_test.go | 2 + server/events/vcs/gh_app_creds_rotator.go | 71 +++++++++++++++ .../events/vcs/gh_app_creds_rotator_test.go | 84 ++++++++++++++++++ server/events/{ => vcs}/git_cred_writer.go | 2 +- .../events/{ => vcs}/git_cred_writer_test.go | 33 ++++--- server/scheduled/executor_service.go | 15 +++- server/scheduled/executor_service_test.go | 48 ++++++++++ .../mocks/mock_executor_service_job.go | 87 +++++++++++++++++++ server/server.go | 34 +++++--- 18 files changed, 376 insertions(+), 61 deletions(-) create mode 100644 server/events/vcs/gh_app_creds_rotator.go create mode 100644 server/events/vcs/gh_app_creds_rotator_test.go rename server/events/{ => vcs}/git_cred_writer.go (99%) rename server/events/{ => vcs}/git_cred_writer_test.go (78%) create mode 100644 server/scheduled/executor_service_test.go create mode 100644 server/scheduled/mocks/mock_executor_service_job.go diff --git a/.gitignore b/.gitignore index fbf411260f..2d9dad12f1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ atlantis atlantis.env *.act package-lock.json +Dockerfile.local # gitreleaser dist/ diff --git a/docker-compose.yml b/docker-compose.yml index e4b756d4ee..702c980308 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,11 @@ services: - 4040:4040 environment: # https://dashboard.ngrok.com/get-started/your-authtoken - NGROK_AUTH: REPLACE-WITH-YOUR-TOKEN + # NGROK_AUTH: REPLACE-WITH-YOUR-TOKEN // set this in atlantis.env NGROK_PROTOCOL: http NGROK_PORT: atlantis:4141 + env_file: + - ./atlantis.env depends_on: - atlantis redis: diff --git a/server/events/apply_command_runner_test.go b/server/events/apply_command_runner_test.go index 5d461d2db8..6bbe25d0a1 100644 --- a/server/events/apply_command_runner_test.go +++ b/server/events/apply_command_runner_test.go @@ -19,6 +19,7 @@ import ( ) func TestApplyCommandRunner_IsLocked(t *testing.T) { + logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { @@ -78,6 +79,7 @@ func TestApplyCommandRunner_IsLocked(t *testing.T) { } func TestApplyCommandRunner_IsSilenced(t *testing.T) { + logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { diff --git a/server/events/approve_policies_command_runner_test.go b/server/events/approve_policies_command_runner_test.go index 8db1ad1216..a5ac56e57a 100644 --- a/server/events/approve_policies_command_runner_test.go +++ b/server/events/approve_policies_command_runner_test.go @@ -15,6 +15,7 @@ import ( ) func TestApproveCommandRunner_IsOwner(t *testing.T) { + logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 227dca01cc..4eab741941 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -113,7 +113,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock githubGetter = mocks.NewMockGithubPullGetter() gitlabGetter = mocks.NewMockGitlabMergeRequestGetter() azuredevopsGetter = mocks.NewMockAzureDevopsPullGetter() - logger = logging.NewNoopLogger(t) + logger := logging.NewNoopLogger(t) projectCommandRunner = mocks.NewMockProjectCommandRunner() workingDir = mocks.NewMockWorkingDir() pendingPlanFinder = mocks.NewMockPendingPlanFinder() diff --git a/server/events/github_app_working_dir.go b/server/events/github_app_working_dir.go index 33900a3510..f84373b823 100644 --- a/server/events/github_app_working_dir.go +++ b/server/events/github_app_working_dir.go @@ -1,16 +1,15 @@ package events import ( - "fmt" "strings" - "github.com/mitchellh/go-homedir" - "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/logging" ) +const redactedReplacement = "://:@" + // GithubAppWorkingDir implements WorkingDir. // It acts as a proxy to an instance of WorkingDir that refreshes the app's token // before every clone, given Github App tokens expire quickly @@ -21,35 +20,21 @@ type GithubAppWorkingDir struct { } // Clone writes a fresh token for Github App authentication -func (g *GithubAppWorkingDir) Clone(log logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string, additionalBranches []string) (string, bool, error) { - - log.Info("Refreshing git tokens for Github App") - - token, err := g.Credentials.GetToken() - if err != nil { - return "", false, errors.Wrap(err, "getting github token") - } - - home, err := homedir.Dir() - if err != nil { - return "", false, errors.Wrap(err, "getting home dir to write ~/.git-credentials file") - } - - // https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#http-based-git-access-by-an-installation - if err := WriteGitCreds("x-access-token", token, g.GithubHostname, home, log, true); err != nil { - return "", false, err - } - +func (g *GithubAppWorkingDir) Clone(log logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, bool, error) { baseRepo := &p.BaseRepo // Realistically, this is a super brittle way of supporting clones using gh app installation tokens // This URL should be built during Repo creation and the struct should be immutable going forward. // Doing this requires a larger refactor however, and can probably be coupled with supporting > 1 installation - authURL := fmt.Sprintf("://x-access-token:%s", token) - baseRepo.CloneURL = strings.Replace(baseRepo.CloneURL, "://:", authURL, 1) - baseRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, "://:", "://x-access-token:", 1) - headRepo.CloneURL = strings.Replace(headRepo.CloneURL, "://:", authURL, 1) - headRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, "://:", "://x-access-token:", 1) + + // This removes the credential part from the url and leaves us with the raw http url + // git will then pick up credentials from the credential store which is set in vcs.WriteGitCreds. + // Git credentials will then be rotated by vcs.GitCredsTokenRotator + replacement := "://" + baseRepo.CloneURL = strings.Replace(baseRepo.CloneURL, "://:@", replacement, 1) + baseRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, redactedReplacement, replacement, 1) + headRepo.CloneURL = strings.Replace(headRepo.CloneURL, "://:@", replacement, 1) + headRepo.SanitizedCloneURL = strings.Replace(baseRepo.SanitizedCloneURL, redactedReplacement, replacement, 1) return g.WorkingDir.Clone(log, headRepo, p, workspace, additionalBranches) } diff --git a/server/events/github_app_working_dir_test.go b/server/events/github_app_working_dir_test.go index 088ff306b0..333357fb60 100644 --- a/server/events/github_app_working_dir_test.go +++ b/server/events/github_app_working_dir_test.go @@ -5,6 +5,7 @@ import ( "testing" . "github.com/petergtz/pegomock" + pegomock "github.com/petergtz/pegomock" "github.com/runatlantis/atlantis/server/events" eventMocks "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" @@ -57,6 +58,8 @@ func TestClone_GithubAppNoneExisting(t *testing.T) { } func TestClone_GithubAppSetsCorrectUrl(t *testing.T) { + pegomock.RegisterMockTestingT(t) + workingDir := eventMocks.NewMockWorkingDir() credentials := vcsMocks.NewMockGithubCredentials() @@ -82,8 +85,9 @@ func TestClone_GithubAppSetsCorrectUrl(t *testing.T) { headRepo := baseRepo modifiedBaseRepo := baseRepo - modifiedBaseRepo.CloneURL = "https://x-access-token:token@github.com/runatlantis/atlantis.git" - modifiedBaseRepo.SanitizedCloneURL = "https://x-access-token:@github.com/runatlantis/atlantis.git" + // remove credentials from both urls since we want to use the credential store + modifiedBaseRepo.CloneURL = "https://github.com/runatlantis/atlantis.git" + modifiedBaseRepo.SanitizedCloneURL = "https://github.com/runatlantis/atlantis.git" When(credentials.GetToken()).ThenReturn("token", nil) When(workingDir.Clone(logger, modifiedBaseRepo, models.PullRequest{BaseRepo: modifiedBaseRepo}, "default", []string{})).ThenReturn( @@ -92,5 +96,7 @@ func TestClone_GithubAppSetsCorrectUrl(t *testing.T) { _, success, _ := ghAppWorkingDir.Clone(logger, headRepo, models.PullRequest{BaseRepo: baseRepo}, "default", []string{}) + workingDir.VerifyWasCalledOnce().Clone(logger, modifiedBaseRepo, models.PullRequest{BaseRepo: modifiedBaseRepo}, "default") + Assert(t, success == true, "clone url mutation error") } diff --git a/server/events/import_command_runner_test.go b/server/events/import_command_runner_test.go index 1acaecbfdb..2d435b692a 100644 --- a/server/events/import_command_runner_test.go +++ b/server/events/import_command_runner_test.go @@ -14,6 +14,7 @@ import ( ) func TestImportCommandRunner_Run(t *testing.T) { + logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) tests := []struct { diff --git a/server/events/plan_command_runner_test.go b/server/events/plan_command_runner_test.go index 799dd54b2a..6e5c510566 100644 --- a/server/events/plan_command_runner_test.go +++ b/server/events/plan_command_runner_test.go @@ -16,6 +16,7 @@ import ( ) func TestPlanCommandRunner_IsSilenced(t *testing.T) { + logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { diff --git a/server/events/pull_closed_executor_test.go b/server/events/pull_closed_executor_test.go index 6a49686c86..41fbd913bf 100644 --- a/server/events/pull_closed_executor_test.go +++ b/server/events/pull_closed_executor_test.go @@ -20,6 +20,7 @@ import ( "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/jobs" + "github.com/runatlantis/atlantis/server/logging" "github.com/stretchr/testify/assert" bolt "go.etcd.io/bbolt" @@ -187,6 +188,7 @@ func TestCleanUpPullComments(t *testing.T) { } func TestCleanUpLogStreaming(t *testing.T) { + logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) t.Run("Should Clean Up Log Streaming Resources When PR is closed", func(t *testing.T) { diff --git a/server/events/vcs/gh_app_creds_rotator.go b/server/events/vcs/gh_app_creds_rotator.go new file mode 100644 index 0000000000..6522f33118 --- /dev/null +++ b/server/events/vcs/gh_app_creds_rotator.go @@ -0,0 +1,71 @@ +package vcs + +import ( + "time" + + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/scheduled" +) + +// GitCredsTokenRotator continuously tries to rotate the github app access token every 30 seconds and writes the ~/.git-credentials file +type GitCredsTokenRotator interface { + Run() + GenerateJob() (scheduled.JobDefinition, error) +} + +type githubAppTokenRotator struct { + log logging.SimpleLogging + githubCredentials GithubCredentials + githubHostname string + homeDirPath string +} + +func NewGithubAppTokenRotator( + log logging.SimpleLogging, + githubCredentials GithubCredentials, + githubHostname string, + homeDirPath string) GitCredsTokenRotator { + + return &githubAppTokenRotator{ + log: log, + githubCredentials: githubCredentials, + githubHostname: githubHostname, + homeDirPath: homeDirPath, + } +} + +// make sure interface is implemented correctly +var _ GitCredsTokenRotator = (*githubAppTokenRotator)(nil) + +func (r *githubAppTokenRotator) GenerateJob() (scheduled.JobDefinition, error) { + + return scheduled.JobDefinition{ + Job: r, + Period: 30 * time.Second, + }, r.rotate() +} + +func (r *githubAppTokenRotator) Run() { + err := r.rotate() + if err != nil { + // at least log the error message here, as we want to notify the that user that the key rotation wasn't successful + r.log.Err(err.Error()) + } +} + +func (r *githubAppTokenRotator) rotate() error { + r.log.Debug("Refreshing git tokens for Github App") + + token, err := r.githubCredentials.GetToken() + if err != nil { + return errors.Wrap(err, "Getting github token") + } + r.log.Debug("token %s", token) + + // https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#http-based-git-access-by-an-installation + if err := WriteGitCreds("x-access-token", token, r.githubHostname, r.homeDirPath, r.log, true); err != nil { + return errors.Wrap(err, "Writing ~/.git-credentials file") + } + return nil +} diff --git a/server/events/vcs/gh_app_creds_rotator_test.go b/server/events/vcs/gh_app_creds_rotator_test.go new file mode 100644 index 0000000000..4c5f4eb1e2 --- /dev/null +++ b/server/events/vcs/gh_app_creds_rotator_test.go @@ -0,0 +1,84 @@ +package vcs_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/events/vcs/testdata" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +func Test_githubAppTokenRotator_GenerateJob(t *testing.T) { + defer disableSSLVerification()() + testServer, err := testdata.GithubAppTestServer(t) + Ok(t, err) + + anonCreds := &vcs.GithubAnonymousCredentials{} + anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t)) + Ok(t, err) + tempSecrets, err := anonClient.ExchangeCode("good-code") + Ok(t, err) + type fields struct { + githubCredentials vcs.GithubCredentials + } + tests := []struct { + name string + fields fields + credsFileWritten bool + wantErr bool + }{ + { + name: "Should write .git-credentials file on start", + fields: fields{&vcs.GithubAppCredentials{ + AppID: tempSecrets.ID, + Key: []byte(testdata.GithubPrivateKey), + Hostname: testServer, + }}, + credsFileWritten: true, + wantErr: false, + }, + { + name: "Should return an error if pem data is missing or wrong", + fields: fields{&vcs.GithubAppCredentials{ + AppID: tempSecrets.ID, + Key: []byte("some bad formatted pem key"), + Hostname: testServer, + }}, + credsFileWritten: false, + wantErr: true, + }, + { + name: "Should return an error if app id is missing or wrong", + fields: fields{&vcs.GithubAppCredentials{ + AppID: 3819, + Key: []byte(testdata.GithubPrivateKey), + Hostname: testServer, + }}, + credsFileWritten: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + r := vcs.NewGithubAppTokenRotator(logging.NewNoopLogger(t), tt.fields.githubCredentials, testServer, tmpDir) + got, err := r.GenerateJob() + if (err != nil) != tt.wantErr { + t.Errorf("githubAppTokenRotator.GenerateJob() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.credsFileWritten { + credsFileContent := fmt.Sprintf(`https://x-access-token:some-token@%s`, testServer) + actContents, err := os.ReadFile(filepath.Join(tmpDir, ".git-credentials")) + Ok(t, err) + Equals(t, credsFileContent, string(actContents)) + } + Equals(t, 30*time.Second, got.Period) + }) + } +} diff --git a/server/events/git_cred_writer.go b/server/events/vcs/git_cred_writer.go similarity index 99% rename from server/events/git_cred_writer.go rename to server/events/vcs/git_cred_writer.go index 945dfcecb2..6d6cf85317 100644 --- a/server/events/git_cred_writer.go +++ b/server/events/vcs/git_cred_writer.go @@ -1,4 +1,4 @@ -package events +package vcs import ( "fmt" diff --git a/server/events/git_cred_writer_test.go b/server/events/vcs/git_cred_writer_test.go similarity index 78% rename from server/events/git_cred_writer_test.go rename to server/events/vcs/git_cred_writer_test.go index c87637b037..6826c1cc84 100644 --- a/server/events/git_cred_writer_test.go +++ b/server/events/vcs/git_cred_writer_test.go @@ -1,4 +1,4 @@ -package events_test +package vcs_test import ( "fmt" @@ -7,18 +7,17 @@ import ( "path/filepath" "testing" - "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) -var logger logging.SimpleLogging - // Test that we write the file as expected func TestWriteGitCreds_WriteFile(t *testing.T) { + logger := logging.NewNoopLogger(t) tmp := t.TempDir() - err := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) expContents := `https://user:token@hostname` @@ -31,13 +30,14 @@ func TestWriteGitCreds_WriteFile(t *testing.T) { // Test that if the file already exists and it doesn't have the line we would // have written, we write it. func TestWriteGitCreds_Appends(t *testing.T) { + logger := logging.NewNoopLogger(t) tmp := t.TempDir() credsFile := filepath.Join(tmp, ".git-credentials") err := os.WriteFile(credsFile, []byte("contents"), 0600) Ok(t, err) - err = events.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + err = vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) expContents := "contents\nhttps://user:token@hostname" @@ -49,6 +49,7 @@ func TestWriteGitCreds_Appends(t *testing.T) { // Test that if the file already exists and it already has the line expected // we do nothing. func TestWriteGitCreds_NoModification(t *testing.T) { + logger := logging.NewNoopLogger(t) tmp := t.TempDir() credsFile := filepath.Join(tmp, ".git-credentials") @@ -56,7 +57,7 @@ func TestWriteGitCreds_NoModification(t *testing.T) { err := os.WriteFile(credsFile, []byte(contents), 0600) Ok(t, err) - err = events.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + err = vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) Ok(t, err) @@ -65,6 +66,7 @@ func TestWriteGitCreds_NoModification(t *testing.T) { // Test that the github app credentials get replaced. func TestWriteGitCreds_ReplaceApp(t *testing.T) { + logger := logging.NewNoopLogger(t) tmp := t.TempDir() credsFile := filepath.Join(tmp, ".git-credentials") @@ -72,7 +74,7 @@ func TestWriteGitCreds_ReplaceApp(t *testing.T) { err := os.WriteFile(credsFile, []byte(contents), 0600) Ok(t, err) - err = events.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) + err = vcs.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) Ok(t, err) expContets := "line1\nhttps://x-access-token:token@github.com\nline2" actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) @@ -82,6 +84,7 @@ func TestWriteGitCreds_ReplaceApp(t *testing.T) { // Test that the github app credentials get updated when cred file is empty. func TestWriteGitCreds_AppendApp(t *testing.T) { + logger := logging.NewNoopLogger(t) tmp := t.TempDir() credsFile := filepath.Join(tmp, ".git-credentials") @@ -89,7 +92,7 @@ func TestWriteGitCreds_AppendApp(t *testing.T) { err := os.WriteFile(credsFile, []byte(contents), 0600) Ok(t, err) - err = events.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) + err = vcs.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) Ok(t, err) expContets := "https://x-access-token:token@github.com" actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) @@ -100,6 +103,7 @@ func TestWriteGitCreds_AppendApp(t *testing.T) { // Test that if we can't read the existing file to see if the contents will be // the same that we just error out. func TestWriteGitCreds_ErrIfCannotRead(t *testing.T) { + logger := logging.NewNoopLogger(t) tmp := t.TempDir() credsFile := filepath.Join(tmp, ".git-credentials") @@ -107,23 +111,25 @@ func TestWriteGitCreds_ErrIfCannotRead(t *testing.T) { Ok(t, err) expErr := fmt.Sprintf("open %s: permission denied", credsFile) - actErr := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + actErr := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) ErrContains(t, expErr, actErr) } // Test that if we can't write, we error out. func TestWriteGitCreds_ErrIfCannotWrite(t *testing.T) { + logger := logging.NewNoopLogger(t) credsFile := "/this/dir/does/not/exist/.git-credentials" // nolint: gosec expErr := fmt.Sprintf("writing generated .git-credentials file with user, token and hostname to %s: open %s: no such file or directory", credsFile, credsFile) - actErr := events.WriteGitCreds("user", "token", "hostname", "/this/dir/does/not/exist", logger, false) + actErr := vcs.WriteGitCreds("user", "token", "hostname", "/this/dir/does/not/exist", logger, false) ErrEquals(t, expErr, actErr) } // Test that git is actually configured to use the credentials func TestWriteGitCreds_ConfigureGitCredentialHelper(t *testing.T) { + logger := logging.NewNoopLogger(t) tmp := t.TempDir() - err := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) expOutput := `store` @@ -134,9 +140,10 @@ func TestWriteGitCreds_ConfigureGitCredentialHelper(t *testing.T) { // Test that git is configured to use https instead of ssh func TestWriteGitCreds_ConfigureGitUrlOverride(t *testing.T) { + logger := logging.NewNoopLogger(t) tmp := t.TempDir() - err := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) expOutput := `ssh://git@hostname` diff --git a/server/scheduled/executor_service.go b/server/scheduled/executor_service.go index 2d522d472e..2bcdf2012d 100644 --- a/server/scheduled/executor_service.go +++ b/server/scheduled/executor_service.go @@ -16,7 +16,7 @@ type ExecutorService struct { log logging.SimpleLogging // jobs - runtimeStatsPublisher JobDefinition + jobs []JobDefinition } func NewExecutorService( @@ -33,11 +33,15 @@ func NewExecutorService( } return &ExecutorService{ - log: log, - runtimeStatsPublisher: runtimeStatsPublisherJob, + log: log, + jobs: []JobDefinition{runtimeStatsPublisherJob}, } } +func (s *ExecutorService) AddJob(jd JobDefinition) { + s.jobs = append(s.jobs, jd) +} + type JobDefinition struct { Job Job Period time.Duration @@ -50,7 +54,9 @@ func (s *ExecutorService) Run() { var wg sync.WaitGroup - s.runScheduledJob(ctx, &wg, s.runtimeStatsPublisher) + for _, jd := range s.jobs { + s.runScheduledJob(ctx, &wg, jd) + } interrupt := make(chan os.Signal, 1) @@ -96,6 +102,7 @@ func (s *ExecutorService) runScheduledJob(ctx context.Context, wg *sync.WaitGrou } +//go:generate pegomock generate -m --package mocks -o mocks/mock_executor_service_job.go Job type Job interface { Run() } diff --git a/server/scheduled/executor_service_test.go b/server/scheduled/executor_service_test.go new file mode 100644 index 0000000000..a2705815da --- /dev/null +++ b/server/scheduled/executor_service_test.go @@ -0,0 +1,48 @@ +package scheduled + +import ( + "testing" + "time" + + "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/scheduled/mocks" +) + +func TestExecutorService_Run(t *testing.T) { + pegomock.RegisterMockTestingT(t) + mockJob := mocks.NewMockJob() + type fields struct { + log logging.SimpleLogging + jobs []JobDefinition + } + tests := []struct { + name string + fields fields + }{ + { + name: "test", + fields: fields{ + log: logging.NewNoopLogger(t), + jobs: []JobDefinition{ + { + Job: mockJob, + Period: 1 * time.Second, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &ExecutorService{ + log: tt.fields.log, + jobs: make([]JobDefinition, 0), + } + s.AddJob(tt.fields.jobs[0]) + go s.Run() + time.Sleep(1050 * time.Millisecond) + mockJob.VerifyWasCalledOnce().Run() + }) + } +} diff --git a/server/scheduled/mocks/mock_executor_service_job.go b/server/scheduled/mocks/mock_executor_service_job.go new file mode 100644 index 0000000000..3266ab0a71 --- /dev/null +++ b/server/scheduled/mocks/mock_executor_service_job.go @@ -0,0 +1,87 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/scheduled (interfaces: Job) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + "reflect" + "time" +) + +type MockJob struct { + fail func(message string, callerSkip ...int) +} + +func NewMockJob(options ...pegomock.Option) *MockJob { + mock := &MockJob{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockJob) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockJob) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockJob) Run() { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockJob().") + } + params := []pegomock.Param{} + pegomock.GetGenericMockFrom(mock).Invoke("Run", params, []reflect.Type{}) +} + +func (mock *MockJob) VerifyWasCalledOnce() *VerifierMockJob { + return &VerifierMockJob{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockJob) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockJob { + return &VerifierMockJob{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockJob) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockJob { + return &VerifierMockJob{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockJob) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockJob { + return &VerifierMockJob{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockJob struct { + mock *MockJob + invocationCountMatcher pegomock.InvocationCountMatcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockJob) Run() *MockJob_Run_OngoingVerification { + params := []pegomock.Param{} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", params, verifier.timeout) + return &MockJob_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockJob_Run_OngoingVerification struct { + mock *MockJob + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockJob_Run_OngoingVerification) GetCapturedArguments() { +} + +func (c *MockJob_Run_OngoingVerification) GetAllCapturedArguments() { +} diff --git a/server/server.go b/server/server.go index 27825b6776..9613de5067 100644 --- a/server/server.go +++ b/server/server.go @@ -298,18 +298,19 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } } + home, err := homedir.Dir() + if err != nil { + return nil, errors.Wrap(err, "getting home dir to write ~/.git-credentials file") + } + if userConfig.WriteGitCreds { - home, err := homedir.Dir() - if err != nil { - return nil, errors.Wrap(err, "getting home dir to write ~/.git-credentials file") - } if userConfig.GithubUser != "" { - if err := events.WriteGitCreds(userConfig.GithubUser, userConfig.GithubToken, userConfig.GithubHostname, home, logger, false); err != nil { + if err := vcs.WriteGitCreds(userConfig.GithubUser, userConfig.GithubToken, userConfig.GithubHostname, home, logger, false); err != nil { return nil, err } } if userConfig.GitlabUser != "" { - if err := events.WriteGitCreds(userConfig.GitlabUser, userConfig.GitlabToken, userConfig.GitlabHostname, home, logger, false); err != nil { + if err := vcs.WriteGitCreds(userConfig.GitlabUser, userConfig.GitlabToken, userConfig.GitlabHostname, home, logger, false); err != nil { return nil, err } } @@ -320,12 +321,12 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { if bitbucketBaseURL == "https://api.bitbucket.org" { bitbucketBaseURL = "bitbucket.org" } - if err := events.WriteGitCreds(userConfig.BitbucketUser, userConfig.BitbucketToken, bitbucketBaseURL, home, logger, false); err != nil { + if err := vcs.WriteGitCreds(userConfig.BitbucketUser, userConfig.BitbucketToken, bitbucketBaseURL, home, logger, false); err != nil { return nil, err } } if userConfig.AzureDevopsUser != "" { - if err := events.WriteGitCreds(userConfig.AzureDevopsUser, userConfig.AzureDevopsToken, "dev.azure.com", home, logger, false); err != nil { + if err := vcs.WriteGitCreds(userConfig.AzureDevopsUser, userConfig.AzureDevopsToken, "dev.azure.com", home, logger, false); err != nil { return nil, err } } @@ -461,6 +462,12 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { CheckoutDepth: userConfig.CheckoutDepth, GithubAppEnabled: githubAppEnabled, } + + scheduledExecutorService := scheduled.NewExecutorService( + statsScope, + logger, + ) + // provide fresh tokens before clone from the GitHub Apps integration, proxy workingDir if githubAppEnabled { if !userConfig.WriteGitCreds { @@ -471,6 +478,13 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { Credentials: githubCredentials, GithubHostname: userConfig.GithubHostname, } + + githubAppTokenRotator := vcs.NewGithubAppTokenRotator(logger, githubCredentials, userConfig.GithubHostname, home) + tokenJd, err := githubAppTokenRotator.GenerateJob() + if err != nil { + return nil, errors.Wrap(err, "could not write credentials") + } + scheduledExecutorService.AddJob(tokenJd) } projectLocker := &events.DefaultProjectLocker{ @@ -861,10 +875,6 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { GithubHostname: userConfig.GithubHostname, GithubOrg: userConfig.GithubOrg, } - scheduledExecutorService := scheduled.NewExecutorService( - statsScope, - logger, - ) return &Server{ AtlantisVersion: config.AtlantisVersion,