Repository: runatlantis/atlantis (language: go)

Issue:
fix: support gh-installation-id flag and fix #4578 #1229 #3545
## what

<!--
- Describe high-level what changed as a result of these commits (i.e. in plain-english, what do these changes mean?)
- Use bullet points to be concise and to the point.
-->

The documentation for [Manually Creating the GitHub app](https://www.runatlantis.io/docs/access-credentials.html#manually-creating-the-github-app) shows that Atlantis should support a `--gh-installation-id` flag that points at a pre-existing GitHub application. However, starting Atlantis with that flag results in an error, meaning that it is not possible to use Atlantis with a pre-existing GitHub application.  This PR fixes that issue. 

If a user attempts to run Atlantis specifying a single application ID and where the app has been installed into multiple orgs then an error occurs. This is because Atlantis on first use queries GitHub to fetch all installations of a specific app id and returns an error if there is more than one. The first item of the array (containing one item) is used to dynamically generate the installation id that is stored in the config for future calls to GitHub. c.f. https://github.com/runatlantis/atlantis/blob/main/server/events/vcs/github_credentials.go#L142-L152

Additionally, this PR allows for a single GitHub application to be installed into multiple GitHub organisations and have multiple Atlantis instances to be configured to operate with that single GitHub application. This addresses #1229 and #3545. 

See below for more details.

## why

<!--
- Provide the justifications for the changes (e.g. business case). 
- Describe why these changes were made (e.g. why do these commits fix the problem?)
- Use bullet points to be concise and to the point.
-->

In a large GitHub organisation, it is highly desirable for there to be a single GitHub application that provides access to Atlantis. This is typically provided by some central DevOps team. That application can then be installed into many different accounts within GitHub without those individual teams needing to build and run their own application. However, because Atlantis does not currently support one app and multiple installations, this is not possible.

This PR enables the following architecture:

1. A single, centrally managed GitHub application that runs somewhere, perhaps in Kubernetes.
2. For each installation of the application into a GitHub organisation, the application receives a web hook event. That event is used to create a new Atlantis deployment on behalf of the installing organisation, specifying the `gh-app-id` of the central application and the `gh-installation-id` of the specific installation. This is easy to do using a tool like [`ArgoCD's ApplicationSets`](https://argo-cd.readthedocs.io/en/stable/user-guide/application-set/). It is assumed that each Atlantis deployment has a Kubernetes Service associated with it, addressable via something like `<org id>.my-atlantis-namespace.svc.cluster-domain.example`, where `<org-id>` is the GitHub organisation that just installed the application.
3. The centrally managed application responds to web hook events and proxies the event to the relevant Kubernetes service by looking at the organisation field in the web hook event and using this to generate the appropriate DNS to proxy to. This is a trivial application for the central DevOps team to write using a framework such as Flask .

This approach means that individual teams do not need to host the application themselves, nor do they need to handle any secrets and Atlantis still follows the core tenet of being responsible for handling just one set of Git credentials. There is strong segregation between each Atlantis instance.



## tests

<!--
- [ ] I have tested my changes by ...
-->

The code creates the missing `--gh-installation-id` flag and passes its value down into the `GithubAppCredentials` struct that is used interacting with GitHub. If there is no value passed then the code flow is unchanged - there is a query the first time the code runs and the dynamic value of installation id is evaluated and stored for further use.

However, if a non-zero value is supplied then there is no dynamic querying of GitHub and the installation id is just used as-is.

There are additional tests for all aspects of the new code flow and the existing tests are used to ensure backwards compatibility.

## references

<!--
- Link to any supporting github issues or helpful documentation to add some context (e.g. stackoverflow). 
- Use `closes #123`, if this PR closes a GitHub issue `#123`
-->

Closes #4578 and I believe this also meaningfully addresses #1229 and #3545 in a way that means those issues can be closed too.





Discussion / hints:
@john-tipper thanks for your first contribution to atlantis! 💯 
side note (yes, I realise this is too late) - it would have been better to follow the existing Github App variable names such as:
```
GHAppInstallationIDFlag             = "gh-app-installation-id"
```
and environment variable: `ATLANTIS_GH_APP_INSTALLATION_ID` just because this is an installation ID _per application_ per organisation.


ahhh , I missed that too.
if you are up for it, lets change it to the standard name and we can do quick release
I will do a PR later tonight.

Fix patch:
diff --git a/cmd/server.go b/cmd/server.go
index ffe385246c..950c17243c 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -90,7 +90,7 @@ const (
 	GHAppKeyFlag                     = "gh-app-key"
 	GHAppKeyFileFlag                 = "gh-app-key-file"
 	GHAppSlugFlag                    = "gh-app-slug"
-	GHInstallationIDFlag             = "gh-installation-id"
+	GHAppInstallationIDFlag          = "gh-app-installation-id"
 	GHOrganizationFlag               = "gh-org"
 	GHWebhookSecretFlag              = "gh-webhook-secret"               // nolint: gosec
 	GHAllowMergeableBypassApply      = "gh-allow-mergeable-bypass-apply" // nolint: gosec
@@ -620,8 +620,8 @@ var int64Flags = map[string]int64Flag{
 		description:  "GitHub App Id. If defined, initializes the GitHub client with app-based credentials",
 		defaultValue: 0,
 	},
-	GHInstallationIDFlag: {
-		description: "GitHub Installation Id. If defined, initializes the GitHub client with app-based credentials " +
+	GHAppInstallationIDFlag: {
+		description: "GitHub App Installation Id. If defined, initializes the GitHub client with app-based credentials " +
 			"using this specific GitHub Application Installation ID, otherwise it attempts to auto-detect it. " +
 			"Note that this value must be set if you want to have one App and multiple installations of that same " +
 			"application.",
diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md
index e5253f834c..6e421c0f3d 100644
--- a/runatlantis.io/docs/server-configuration.md
+++ b/runatlantis.io/docs/server-configuration.md
@@ -687,12 +687,12 @@ and set `--autoplan-modules` to `false`.
   Hostname of your GitHub Enterprise installation. If using [GitHub.com](https://github.com),
   don't set. Defaults to `github.com`.
 
-### `--gh-installation-id`
+### `--gh-app-installation-id`
 
   ```bash
-  atlantis server --gh-installation-id="123"
+  atlantis server --gh-app-installation-id="123"
   # or
-  ATLANTIS_GH_INSTALLATION_ID="123"
+  ATLANTIS_GH_APP_INSTALLATION_ID="123"
   ```
 
 The installation ID of a specific instance of a GitHub application. Normally this value is
diff --git a/server/server.go b/server/server.go
index 859f42323d..521643df40 100644
--- a/server/server.go
+++ b/server/server.go
@@ -240,7 +240,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 			}
 			githubCredentials = &vcs.GithubAppCredentials{
 				AppID:          userConfig.GithubAppID,
-				InstallationID: userConfig.GithubInstallationID,
+				InstallationID: userConfig.GithubAppInstallationID,
 				Key:            privateKey,
 				Hostname:       userConfig.GithubHostname,
 				AppSlug:        userConfig.GithubAppSlug,
@@ -249,7 +249,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 		} else if userConfig.GithubAppID != 0 && userConfig.GithubAppKey != "" {
 			githubCredentials = &vcs.GithubAppCredentials{
 				AppID:          userConfig.GithubAppID,
-				InstallationID: userConfig.GithubInstallationID,
+				InstallationID: userConfig.GithubAppInstallationID,
 				Key:            []byte(userConfig.GithubAppKey),
 				Hostname:       userConfig.GithubHostname,
 				AppSlug:        userConfig.GithubAppSlug,
diff --git a/server/user_config.go b/server/user_config.go
index 580162ea96..31b8271efa 100644
--- a/server/user_config.go
+++ b/server/user_config.go
@@ -57,7 +57,7 @@ type UserConfig struct {
 	GithubAppKey                    string `mapstructure:"gh-app-key"`
 	GithubAppKeyFile                string `mapstructure:"gh-app-key-file"`
 	GithubAppSlug                   string `mapstructure:"gh-app-slug"`
-	GithubInstallationID            int64  `mapstructure:"gh-installation-id"`
+	GithubAppInstallationID         int64  `mapstructure:"gh-app-installation-id"`
 	GithubTeamAllowlist             string `mapstructure:"gh-team-allowlist"`
 	GiteaBaseURL                    string `mapstructure:"gitea-base-url"`
 	GiteaToken                      string `mapstructure:"gitea-token"`


Test patch:
diff --git a/cmd/server_test.go b/cmd/server_test.go
index db181f93a3..96e174c970 100644
--- a/cmd/server_test.go
+++ b/cmd/server_test.go
@@ -91,7 +91,7 @@ var testFlags = map[string]interface{}{
 	GHAppKeyFlag:                     "",
 	GHAppKeyFileFlag:                 "",
 	GHAppSlugFlag:                    "atlantis",
-	GHInstallationIDFlag:             int64(0),
+	GHAppInstallationIDFlag:          int64(0),
 	GHOrganizationFlag:               "",
 	GHWebhookSecretFlag:              "secret",
 	GiteaBaseURLFlag:                 "http://localhost",
@@ -750,16 +750,16 @@ func TestExecute_GithubApp(t *testing.T) {
 func TestExecute_GithubAppWithInstallationID(t *testing.T) {
 	t.Log("Should pass the installation ID to the config.")
 	c := setup(map[string]interface{}{
-		GHAppKeyFlag:         testdata.GithubPrivateKey,
-		GHAppIDFlag:          "1",
-		GHInstallationIDFlag: "2",
-		RepoAllowlistFlag:    "*",
+		GHAppKeyFlag:            testdata.GithubPrivateKey,
+		GHAppIDFlag:             "1",
+		GHAppInstallationIDFlag: "2",
+		RepoAllowlistFlag:       "*",
 	}, t)
 	err := c.Execute()
 	Ok(t, err)
 
 	Equals(t, int64(1), passedConfig.GithubAppID)
-	Equals(t, int64(2), passedConfig.GithubInstallationID)
+	Equals(t, int64(2), passedConfig.GithubAppInstallationID)
 }
 
 func TestExecute_GiteaUser(t *testing.T) {