Skip to content

Commit

Permalink
feat: simplify GitHub app installation (#25)
Browse files Browse the repository at this point in the history
1. Remove client id, client secret, and installation id configuration
2. Get installation id from webhook event to support multiple installations
3. Make it possible to automate app creation with manifests as described in #6
4. Improve status function to list installations and test their authentication

BREAKING CHANGE: backup GitHub auth secret as it will be reset to its default value
  • Loading branch information
kichik committed Jun 1, 2022
1 parent de21af1 commit 3570e63
Show file tree
Hide file tree
Showing 10 changed files with 72 additions and 48 deletions.
2 changes: 1 addition & 1 deletion API.md
Expand Up @@ -967,7 +967,7 @@ public readonly githubPrivateKey: Secret;

GitHub app private key. Not needed when using personal authentication tokens.

This secret is meant to be edited by the user after being created.
This secret is meant to be edited by the user after being created. It is separate than the main GitHub secret because inserting private keys into JSON is hard.

---

Expand Down
5 changes: 3 additions & 2 deletions README.md
Expand Up @@ -140,8 +140,9 @@ app.synth();
1. Always start with the status function, make sure no errors are reported, and confirm all status codes are OK
2. Confirm the webhook Lambda was called by visiting the URL in `troubleshooting.webhookHandlerUrl` from `status.json`
1. If it's not called or logs errors, confirm the webhook settings on the GitHub side
2. If you see too many errors, make sure you're only sending `workflow_job` events
3. Check execution details of the orchestrator step function by visiting the URL in `troubleshooting.stepFunctionUrl` from `status.json`
2. If you see too many errors, make sure you're only sending `workflow_job` events
3. When using GitHub app, make sure there are active installation in `github.auth.app.installations`
4. Check execution details of the orchestrator step function by visiting the URL in `troubleshooting.stepFunctionUrl` from `status.json`
1. Use the details tab to find the specific execution of the provider (Lambda, CodeBuild, Fargate, etc.)
2. Every step function execution should be successful, even if the runner action inside it failed

Expand Down
14 changes: 3 additions & 11 deletions SETUP_GITHUB.md
@@ -1,6 +1,6 @@
# Setup GitHub

Integration with GitHub can be done using an [app][9] or [personal access token][10]. Using an app allows more fine-grained access control. Personal access tokens are easier to set up but belong to a user instead of an organization.
Integration with GitHub can be done using an [app](#app-authentication) or [personal access token](#personal-access-token). Using an app allows more fine-grained access control. Personal access tokens are easier to set up but belong to a user instead of an organization.

## App Authentication

Expand All @@ -18,19 +18,11 @@ Integration with GitHub can be done using an [app][9] or [personal access token]
1. Workflow job
6. Under "Where can this GitHub App be installed?" select "Only on this account"
7. Click the Create button
8. From the new app page:
1. Write down the app id and client id
2. Click generate new client secret and write it down
3. Generate a private key and save the downloaded key
9. On the top left go to Install App page and:
1. Install the app on the desired account or organization
2. Copy the installation id number from the URL and write it down (e.g. if the URL is https://github.com/settings/installations/123456, your installation id is 123456)
8. From the new app page generate a private key and save the downloaded key
9. On the top left go to Install App page and install the app on the desired account or organization
10. Open the URL in `github.auth.secretUrl` from `status.json` and edit the secret value
1. If you're using a self-hosted GitHub instance, put its domain in `domain` (e.g. `github.mycompany.com`)
2. Put the new application id in `appId` (e.g. `34789562`)
3. Put the client id in `clientId` (e.g. `Iv1.0beef123456`)
4. Put the client secret in `clientSecret` (e.g. `4e2b66fab69065001500697b0d751beb033a3deb`)
5. Put the installation id you copied from the URL in `installationId` (e.g. `123456`)
6. Ignore/delete `dummy` and **leave `personalAuthToken` empty**
11. Open the URL in `github.auth.privateKeySecretUrl` from `status.json` and edit the secret value
1. Open the downloaded private key with any text editor
Expand Down
2 changes: 1 addition & 1 deletion src/lambdas/delete-runner/index.ts
Expand Up @@ -24,7 +24,7 @@ async function getRunnerId(octokit: any, owner: string, repo: string, name: stri
}

exports.handler = async function (event: any) {
const { octokit } = await getOctokit();
const { octokit } = await getOctokit(event.installationId as string);

// cancel job so it doesn't get assigned to other runners by mistake or just sit there waiting
await octokit.request('POST /repos/{owner}/{repo}/actions/runs/{runId}/cancel', {
Expand Down
4 changes: 2 additions & 2 deletions src/lambdas/github.ts
Expand Up @@ -13,7 +13,7 @@ export function baseUrlFromDomain(domain: string): string {
}


export async function getOctokit() {
export async function getOctokit(installationId?: string) {
if (!process.env.GITHUB_SECRET_ARN || !process.env.GITHUB_PRIVATE_KEY_SECRET_ARN) {
throw new Error('Missing environment variables');
}
Expand Down Expand Up @@ -49,7 +49,7 @@ export async function getOctokit() {

token = (await appOctokit.auth({
type: 'installation',
installationId: githubSecrets.installationId,
installationId: installationId,
}) as any).token;
}

Expand Down
84 changes: 58 additions & 26 deletions src/lambdas/status/index.ts
Expand Up @@ -31,6 +31,13 @@ function stepFunctionArnToUrl(arn: string) {
return `https://${region}.console.aws.amazon.com/states/home?region=${region}#/statemachines/view/${arn}`;
}

interface AppInstallation {
readonly id: number;
readonly url: string;
readonly status: string;
readonly repositories: string[];
}

interface RecentRun {
readonly owner?: string;
readonly repo?: string;
Expand Down Expand Up @@ -64,11 +71,9 @@ exports.handler = async function () {
privateKeySecretArn: process.env.GITHUB_PRIVATE_KEY_SECRET_ARN,
privateKeySecretUrl: secretArnToUrl(process.env.GITHUB_PRIVATE_KEY_SECRET_ARN),
app: {
appId: '',
// TODO get app name from appId -- appUrl: `https://github.com/settings/apps/...`,
clientId: '',
installationId: '',
installationUrl: '',
id: '',
url: '',
installations: [] as AppInstallation[],
},
personalAuthToken: '',
},
Expand Down Expand Up @@ -163,12 +168,7 @@ exports.handler = async function () {
} else {
// try authenticating with GitHub app
status.github.auth.type = 'GitHub App';
status.github.auth.app = {
appId: githubSecrets.appId,
clientId: githubSecrets.clientId,
installationId: githubSecrets.installationId,
installationUrl: `https://${githubSecrets.domain}/settings/installations/${githubSecrets.installationId}`,
};
status.github.auth.app.id = githubSecrets.appId;

let appOctokit;
try {
Expand All @@ -185,34 +185,66 @@ exports.handler = async function () {
return status;
}

let token;
// get app url
try {
token = (await appOctokit.auth({
type: 'installation',
installationId: githubSecrets.installationId,
}) as any).token;
const app = (await appOctokit.request('GET /app')).data;
status.github.auth.app.url = app.html_url;
} catch (e) {
status.github.auth.status = `Unable to authenticate app installation: ${e}`;
status.github.auth.status = `Unable to get app details: ${e}`;
return status;
}

let octokit;
// list all app installations
try {
octokit = new Octokit({ baseUrl, auth: token });
} catch (e) {
status.github.auth.status = `Unable to authenticate using app: ${e}`;
return status;
}
const installations = (await appOctokit.request('GET /app/installations')).data;
for (const installation of installations) {
let installationDetails = {
id: installation.id,
url: `https://${githubSecrets.domain}/settings/installations/${installation.id}`,
status: 'Unable to query',
repositories: [] as string[],
};

try {
JSON.stringify(await octokit.request('GET /'));
let token;
try {
token = (await appOctokit.auth({
type: 'installation',
installationId: installation.id,
}) as any).token;
} catch (e) {
installationDetails.status = `Unable to authenticate app installation: ${e}`;
continue;
}

let octokit;
try {
octokit = new Octokit({ baseUrl, auth: token });
} catch (e) {
installationDetails.status = `Unable to authenticate using app: ${e}`;
continue;
}

try {
const repositories = (await octokit.request('GET /installation/repositories')).data.repositories;
for (const repo of repositories) {
installationDetails.repositories.push(repo.full_name as string);
}
} catch (e) {
installationDetails.status = `Unable to authenticate using installation token: ${e}`;
continue;
}

installationDetails.status = 'OK';
status.github.auth.app.installations.push(installationDetails);
}
} catch (e) {
status.github.auth.status = `Unable to authenticate using installation token: ${e}`;
status.github.auth.status = 'Unable to list app installations';
return status;
}

status.github.auth.status = 'OK';

// check webhook config
try {
const response = await appOctokit.request('GET /app/hook/config', {});

Expand Down
2 changes: 1 addition & 1 deletion src/lambdas/token-retriever/index.ts
@@ -1,7 +1,7 @@
import { getOctokit } from '../github';

exports.handler = async function (event: any) {
const { githubSecrets, octokit } = await getOctokit();
const { githubSecrets, octokit } = await getOctokit(event.installationId as string);

const response = await octokit.request('POST /repos/{owner}/{repo}/actions/runners/registration-token', {
owner: event.owner,
Expand Down
1 change: 1 addition & 0 deletions src/lambdas/webhook-handler/index.ts
Expand Up @@ -104,6 +104,7 @@ exports.handler = async function (event: any) {
owner: payload.repository.owner.login,
repo: payload.repository.name,
runId: payload.workflow_job.run_id,
installationId: payload.installation?.id,
labels: labels,
}),
// name is not random so multiple execution of this webhook won't cause multiple builders to start
Expand Down
1 change: 1 addition & 0 deletions src/runner.ts
Expand Up @@ -154,6 +154,7 @@ export class GitHubRunners extends Construct {
owner: stepfunctions.JsonPath.stringAt('$.owner'),
repo: stepfunctions.JsonPath.stringAt('$.repo'),
runId: stepfunctions.JsonPath.stringAt('$.runId'),
installationId: stepfunctions.JsonPath.stringAt('$.installationId'),
}),
},
);
Expand Down
5 changes: 1 addition & 4 deletions src/secrets.ts
Expand Up @@ -22,7 +22,7 @@ export class Secrets extends Construct {
/**
* GitHub app private key. Not needed when using personal authentication tokens.
*
* This secret is meant to be edited by the user after being created.
* This secret is meant to be edited by the user after being created. It is separate than the main GitHub secret because inserting private keys into JSON is hard.
*/
readonly githubPrivateKey: secretsmanager.Secret;

Expand All @@ -49,10 +49,7 @@ export class Secrets extends Construct {
generateSecretString: {
secretStringTemplate: JSON.stringify({
domain: 'github.com',
clientSecret: '',
clientId: '',
appId: '',
installationId: '',
personalAuthToken: '',
}),
generateStringKey: 'dummy',
Expand Down

0 comments on commit 3570e63

Please sign in to comment.