Skip to content

Commit

Permalink
Refactoring to github app code
Browse files Browse the repository at this point in the history
- rename gh-app-key to gh-app-key-file for clarity
- change git credentials writer to append a line if there is an existing
.git-credentials file and in the case of the github app to replace the
old github app line
- removed automatically setting --write-git-creds to true when using a
github app and instead requiring this is set specifically
  • Loading branch information
lkysow committed Jun 19, 2020
1 parent 2b74dc9 commit 875abf1
Show file tree
Hide file tree
Showing 15 changed files with 255 additions and 157 deletions.
6 changes: 3 additions & 3 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const (
GHTokenFlag = "gh-token"
GHUserFlag = "gh-user"
GHAppIDFlag = "gh-app-id"
GHAppKeyFlag = "gh-app-key"
GHAppKeyFileFlag = "gh-app-key-file"
GHOrganizationFlag = "gh-org"
GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec
GitlabHostnameFlag = "gitlab-hostname"
Expand Down Expand Up @@ -168,7 +168,7 @@ var stringFlags = map[string]stringFlag{
GHTokenFlag: {
description: "GitHub token of API user. Can also be specified via the ATLANTIS_GH_TOKEN environment variable.",
},
GHAppKeyFlag: {
GHAppKeyFileFlag: {
description: "A path to a file containing the GitHub App's private key",
defaultValue: "",
},
Expand Down Expand Up @@ -565,7 +565,7 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
// 3. bitbucket user and token set
// 4. azuredevops user and token set
// 5. any combination of the above
vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s must be set", GHUserFlag, GHTokenFlag, GHAppIDFlag, GHAppKeyFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag, ADUserFlag, ADTokenFlag)
vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s must be set", GHUserFlag, GHTokenFlag, GHAppIDFlag, GHAppKeyFileFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag, ADUserFlag, ADTokenFlag)
if ((userConfig.GithubUser == "") != (userConfig.GithubToken == "")) || ((userConfig.GithubAppID == 0) != (userConfig.GithubAppKey == "")) || ((userConfig.GitlabUser == "") != (userConfig.GitlabToken == "")) || ((userConfig.BitbucketUser == "") != (userConfig.BitbucketToken == "")) || ((userConfig.AzureDevopsUser == "") != (userConfig.AzureDevopsToken == "")) {
return vcsErr
}
Expand Down
12 changes: 6 additions & 6 deletions cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ var testFlags = map[string]interface{}{
GHTokenFlag: "token",
GHUserFlag: "user",
GHAppIDFlag: int64(0),
GHAppKeyFlag: "",
GHAppKeyFileFlag: "",
GHOrganizationFlag: "",
GHWebhookSecretFlag: "secret",
GitlabHostnameFlag: "gitlab-hostname",
Expand Down Expand Up @@ -350,7 +350,7 @@ func TestExecute_ValidateSSLConfig(t *testing.T) {
}

func TestExecute_ValidateVCSConfig(t *testing.T) {
expErr := "--gh-user/--gh-token or --gh-app-id/--gh-app-key or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token must be set"
expErr := "--gh-user/--gh-token or --gh-app-id/--gh-app-key-file or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token must be set"
cases := []struct {
description string
flags map[string]interface{}
Expand Down Expand Up @@ -406,7 +406,7 @@ func TestExecute_ValidateVCSConfig(t *testing.T) {
{
"just github app key set",
map[string]interface{}{
GHAppKeyFlag: "key.pem",
GHAppKeyFileFlag: "key.pem",
},
true,
},
Expand Down Expand Up @@ -466,8 +466,8 @@ func TestExecute_ValidateVCSConfig(t *testing.T) {
{
"github app and key set and should be successful",
map[string]interface{}{
GHAppIDFlag: "1",
GHAppKeyFlag: "key.pem",
GHAppIDFlag: "1",
GHAppKeyFileFlag: "key.pem",
},
false,
},
Expand Down Expand Up @@ -572,7 +572,7 @@ func TestExecute_GithubUser(t *testing.T) {
func TestExecute_GithubApp(t *testing.T) {
t.Log("Should remove the @ from the github username if it's passed.")
c := setup(map[string]interface{}{
GHAppKeyFlag: "key.pem",
GHAppKeyFileFlag: "key.pem",
GHAppIDFlag: "1",
RepoWhitelistFlag: "*",
})
Expand Down
11 changes: 7 additions & 4 deletions runatlantis.io/docs/access-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,13 @@ Your Atlantis user must also have "Write permissions" (for repos in an organizat
:::

### GitHub app
- Start Atlantis with fake github username and token (`--gh-user fake --gh-token fake`)
- Visit `$ATLANTIS_HOST/github-app/setup` and click on **Setup** to create the app on Github. You'll be redirected back to Atlantis
- A link to install your app, along its secrets, will be shown on the screen. Record your app's credentials and install your app for your user/org by following said link.
- Restart Atlantis
- Start Atlantis with fake github username and token (`atlantis server --gh-user fake --gh-token fake --repo-whitelist 'github.com/your-org/*' --atlantis-url https://$ATLANTIS_HOST`)
- Visit `https://$ATLANTIS_HOST/github-app/setup` and click on **Setup** to create the app on Github. You'll be redirected back to Atlantis
- A link to install your app, along with its secrets, will be shown on the screen. Record your app's credentials and install your app for your user/org by following said link.
- Create a file with the contents of the GitHub App Key, e.g. `atlantis-app-key.pem`
- Restart Atlantis with new flags: `atlantis server --gh-app-id <your id> --gh-app-key-file atlantis-app-key.pem --gh-webhook-secret <your secret> --write-git-creds --repo-whitelist 'github.com/your-org/*' --atlantis-url https://$ATLANTIS_HOST`.

NOTE: You can also create a config file instead of using flags. See [Server Configuration](/docs/server-configuration.html#config-file).

::: warning
Only a single installation per Github App is supported at the moment.
Expand Down
6 changes: 3 additions & 3 deletions runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,12 @@ Values are chosen in this order:
$(hostname)/github-app/exchange-code?code=some-code
```
After which Atlantis will display your new app's credentials: your app's ID, its generated `--gh-webhook-secret` and the contents of the file for `--gh-app-key`. Update your Atlantis config accordingly, and restart the server.
After which Atlantis will display your new app's credentials: your app's ID, its generated `--gh-webhook-secret` and the contents of the file for `--gh-app-key-file`. Update your Atlantis config accordingly, and restart the server.
:::
- ### `--gh-app-key`
- ### `--gh-app-key-file`
```bash
atlantis server --gh-app-key="path/to/app-key.pem"
atlantis server --gh-app-key-file="path/to/app-key.pem"
```
Path to a GitHub App PEM encoded private key file. If set, GitHub authentication will be performed as [an installation](https://developer.github.com/v3/apps/installations/).

Expand Down
91 changes: 71 additions & 20 deletions server/events/git_cred_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,44 @@ import (
// WriteGitCreds generates a .git-credentials file containing the username and token
// used for authenticating with git over HTTPS
// It will create the file in home/.git-credentials
func WriteGitCreds(gitUser string, gitToken string, gitHostname string, home string, logger *logging.SimpleLogger, ignoreExisting bool) error {
// If ghAccessToken is true we will look for a line starting with https://x-access-token and ending with gitHostname and replace it.
func WriteGitCreds(gitUser string, gitToken string, gitHostname string, home string, logger *logging.SimpleLogger, ghAccessToken bool) error {
const credsFilename = ".git-credentials"
credsFile := filepath.Join(home, credsFilename)
credsFileContents := `https://%s:%s@%s`
config := fmt.Sprintf(credsFileContents, gitUser, gitToken, gitHostname)

// If there is already a .git-credentials file and its contents aren't exactly
// what we would have written to it, then we error out because we don't
// want to overwrite anything
if _, err := os.Stat(credsFile); err == nil && !ignoreExisting {
currContents, err := ioutil.ReadFile(credsFile) // nolint: gosec
credsFileContentsPattern := `https://%s:%s@%s`
config := fmt.Sprintf(credsFileContentsPattern, gitUser, gitToken, gitHostname)

// If the file doesn't exist, write it.
if _, err := os.Stat(credsFile); err != nil {
if err := ioutil.WriteFile(credsFile, []byte(config), 0600); err != nil {
return errors.Wrapf(err, "writing generated %s file with user, token and hostname to %s", credsFilename, credsFile)
}
logger.Info("wrote git credentials to %s", credsFile)
} else {
hasLine, err := fileHasLine(config, credsFile)
if err != nil {
return errors.Wrapf(err, "trying to read %s to ensure we're not overwriting it", credsFile)
return err
}
if config != string(currContents) {
return fmt.Errorf("can't write git-credentials to %s because that file has contents that would be overwritten", credsFile)
if hasLine {
logger.Debug("git credentials file has expected contents, not modifying")
return nil
}
// Otherwise we don't need to write the file because it already has
// what we need.
return nil
}

if err := ioutil.WriteFile(credsFile, []byte(config), 0600); err != nil {
return errors.Wrapf(err, "writing generated %s file with user, token and hostname to %s", credsFilename, credsFile)
if ghAccessToken {
// Need to replace the line.
if err := fileLineReplace(config, gitUser, gitHostname, credsFile); err != nil {
return errors.Wrap(err, "replacing git credentials line for github app")
}
logger.Info("updated git app credentials in %s", credsFile)
} else {
// Otherwise we need to append the line.
if err := fileAppend(config, credsFile); err != nil {
return err
}
logger.Info("wrote git credentials to %s", credsFile)
}
}

logger.Info("wrote git credentials to %s", credsFile)

credentialCmd := exec.Command("git", "config", "--global", "credential.helper", "store")
if out, err := credentialCmd.CombinedOutput(); err != nil {
return errors.Wrapf(err, "There was an error running %s: %s", strings.Join(credentialCmd.Args, " "), string(out))
Expand All @@ -56,3 +66,44 @@ func WriteGitCreds(gitUser string, gitToken string, gitHostname string, home str
logger.Info("successfully ran %s", strings.Join(urlCmd.Args, " "))
return nil
}

func fileHasLine(line string, filename string) (bool, error) {
currContents, err := ioutil.ReadFile(filename) // nolint: gosec
if err != nil {
return false, errors.Wrapf(err, "reading %s", filename)
}
for _, l := range strings.Split(string(currContents), "\n") {
if l == line {
return true, nil
}
}
return false, nil
}

func fileAppend(line string, filename string) error {
currContents, err := ioutil.ReadFile(filename) // nolint: gosec
if err != nil {
return err
}
if len(currContents) > 0 && !strings.HasSuffix(string(currContents), "\n") {
line = "\n" + line
}
return ioutil.WriteFile(filename, []byte(string(currContents)+line), 0600)
}

func fileLineReplace(line, user, host, filename string) error {
currContents, err := ioutil.ReadFile(filename) // nolint: gosec
if err != nil {
return err
}
prevLines := strings.Split(string(currContents), "\n")
var newLines []string
for _, l := range prevLines {
if strings.HasPrefix(l, "https://"+user) && strings.HasSuffix(l, host) {
newLines = append(newLines, line)
} else {
newLines = append(newLines, l)
}
}
return ioutil.WriteFile(filename, []byte(strings.Join(newLines, "\n")), 0600)
}
50 changes: 37 additions & 13 deletions server/events/git_cred_writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,35 +29,59 @@ func TestWriteGitCreds_WriteFile(t *testing.T) {
Equals(t, expContents, string(actContents))
}

// Test that if the file already exists and its contents will be modified if
// we write our config that we error out
func TestWriteGitCreds_WillNotOverwrite(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) {
tmp, cleanup := TempDir(t)
defer cleanup()

credsFile := filepath.Join(tmp, ".git-credentials")
err := ioutil.WriteFile(credsFile, []byte("contents"), 0600)
Ok(t, err)

actErr := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
expErr := fmt.Sprintf("can't write git-credentials to %s because that file has contents that would be overwritten", tmp+"/.git-credentials")
ErrEquals(t, expErr, actErr)
err = events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
Ok(t, err)

expContents := "contents\nhttps://user:token@hostname"
actContents, err := ioutil.ReadFile(filepath.Join(tmp, ".git-credentials"))
Ok(t, err)
Equals(t, expContents, string(actContents))
}

// Test that if the file already exists and its contents will NOT be modified if
// we write our config that we don't error.
func TestWriteGitCreds_NoErrIfContentsSame(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) {
tmp, cleanup := TempDir(t)
defer cleanup()

credsFile := filepath.Join(tmp, ".git-credentials")
contents := `https://user:token@hostname`

contents := "line1\nhttps://user:token@hostname\nline2"
err := ioutil.WriteFile(credsFile, []byte(contents), 0600)
Ok(t, err)

err = events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
Ok(t, err)
actContents, err := ioutil.ReadFile(filepath.Join(tmp, ".git-credentials"))
Ok(t, err)
Equals(t, contents, string(actContents))
}

// Test that the github app credentials get replaced.
func TestWriteGitCreds_ReplaceApp(t *testing.T) {
tmp, cleanup := TempDir(t)
defer cleanup()

credsFile := filepath.Join(tmp, ".git-credentials")
contents := "line1\nhttps://x-access-token:[email protected]\nline2"
err := ioutil.WriteFile(credsFile, []byte(contents), 0600)
Ok(t, err)

err = events.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true)
Ok(t, err)
expContets := "line1\nhttps://x-access-token:[email protected]\nline2"
actContents, err := ioutil.ReadFile(filepath.Join(tmp, ".git-credentials"))
Ok(t, err)
Equals(t, expContets, string(actContents))
}

// Test that if we can't read the existing file to see if the contents will be
Expand All @@ -70,9 +94,9 @@ func TestWriteGitCreds_ErrIfCannotRead(t *testing.T) {
err := ioutil.WriteFile(credsFile, []byte("can't see me!"), 0000)
Ok(t, err)

expErr := fmt.Sprintf("trying to read %s to ensure we're not overwriting it: open %s: permission denied", credsFile, credsFile)
expErr := fmt.Sprintf("open %s: permission denied", credsFile)
actErr := events.WriteGitCreds("user", "token", "hostname", tmp, logger, false)
ErrEquals(t, expErr, actErr)
ErrContains(t, expErr, actErr)
}

// Test that if we can't write, we error out.
Expand Down
47 changes: 47 additions & 0 deletions server/events/github_app_working_dir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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"
)

// 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
type GithubAppWorkingDir struct {
WorkingDir
Credentials vcs.GithubCredentials
GithubHostname string
}

// Clone writes a fresh token for Github App authentication
func (g *GithubAppWorkingDir) Clone(log *logging.SimpleLogger, baseRepo models.Repo, headRepo models.Repo, p models.PullRequest, workspace 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
}

authURL := fmt.Sprintf("://x-access-token:%s", token)
baseRepo.CloneURL = strings.Replace(baseRepo.CloneURL, "://:", authURL, 1)
headRepo.CloneURL = strings.Replace(headRepo.CloneURL, "://:", authURL, 1)
return g.WorkingDir.Clone(log, baseRepo, headRepo, p, workspace)
}
Loading

0 comments on commit 875abf1

Please sign in to comment.