Skip to content

Commit b033b93

Browse files
authored
Merge pull request #20 from dscho/self-hosted-arm64-runners
Automatically spin up/tear down self-hosted ARM64 runners
2 parents 49fb949 + 2c7f823 commit b033b93

10 files changed

Lines changed: 252 additions & 146 deletions

.eslintrc.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,9 @@
77
"parserOptions": {
88
"ecmaVersion": 12,
99
"sourceType": "module"
10+
},
11+
"rules": {
12+
"indent": ["error", 4],
13+
"no-trailing-spaces": "error"
1014
}
1115
}

GitForWindowsHelper/azure-pipelines.js

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -79,24 +79,24 @@ const getRelease = async (context, token, organization, project, releaseId) => {
7979
}
8080

8181
const createRelease = async (
82-
context,
83-
token,
84-
organization,
85-
project,
86-
releaseDefinitionId,
87-
artifactAlias,
88-
artifactBuildRunId,
89-
artifactBuildRunName,
90-
artifactBuildDefinitionId,
91-
artifactBuildDefinitionName,
92-
sourceBranch,
93-
sourceCommitId,
94-
repo
82+
context,
83+
token,
84+
organization,
85+
project,
86+
releaseDefinitionId,
87+
artifactAlias,
88+
artifactBuildRunId,
89+
artifactBuildRunName,
90+
artifactBuildDefinitionId,
91+
artifactBuildDefinitionName,
92+
sourceBranch,
93+
sourceCommitId,
94+
repo
9595
) => {
9696
const auth = Buffer.from("PAT:" + token).toString("base64");
9797
const headers = {
98-
Accept: "application/json; api-version=7.0; excludeUrls=true",
99-
Authorization: "Basic " + auth,
98+
Accept: "application/json; api-version=7.0; excludeUrls=true",
99+
Authorization: "Basic " + auth,
100100
};
101101
const body = {
102102
definitionId: releaseDefinitionId,
@@ -157,19 +157,19 @@ const releaseGitArtifacts = async (context, prNumber) => {
157157

158158
const token = process.env['AZURE_PIPELINE_TRIGGER_TOKEN']
159159
const answer2 = await createRelease(
160-
context,
161-
token,
162-
'git-for-windows',
163-
'git',
164-
1,
165-
'artifacts',
166-
artifactBuildRunId,
167-
artifactBuildRunName,
168-
artifactBuildDefinitionId,
169-
artifactBuildDefinitionName,
170-
sourceBranch,
171-
sourceCommitId,
172-
'git-for-windows/git'
160+
context,
161+
token,
162+
'git-for-windows',
163+
'git',
164+
1,
165+
'artifacts',
166+
artifactBuildRunId,
167+
artifactBuildRunName,
168+
artifactBuildDefinitionId,
169+
artifactBuildDefinitionName,
170+
sourceBranch,
171+
sourceCommitId,
172+
'git-for-windows/git'
173173
)
174174
return {
175175
id: answer2.id,

GitForWindowsHelper/check-runs.js

Lines changed: 59 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,64 +2,77 @@ const queueCheckRun = async (context, token, owner, repo, ref, checkRunName, tit
22
const githubApiRequest = require('./github-api-request')
33
// is there an existing check-run we can re-use?
44
const { check_runs } = await githubApiRequest(
5-
context,
6-
token,
7-
'GET',
8-
`/repos/${owner}/${repo}/commits/${ref}/check-runs`
5+
context,
6+
token,
7+
'GET',
8+
`/repos/${owner}/${repo}/commits/${ref}/check-runs`
99
)
1010
const filtered = check_runs
11-
.filter(e => e.name === checkRunName && e.conclusion === null).map(e => {
12-
return {
13-
id: e.id,
14-
status: e.status
15-
}
16-
})
11+
.filter(e => e.name === checkRunName && e.conclusion === null).map(e => {
12+
return {
13+
id: e.id,
14+
status: e.status
15+
}
16+
})
1717
if (filtered.length > 0) {
18-
// ensure that the check_run is set to status "in progress"
19-
if (filtered[0].status !== 'queued') {
20-
console.log(await githubApiRequest(
21-
context,
22-
token,
23-
'PATCH',
24-
`/repos/${owner}/${repo}/check-runs/${filtered[0].id}`, {
25-
status: 'queued'
26-
}
27-
))
28-
}
29-
process.stderr.write(`Returning existing ${filtered[0].id}`)
30-
return filtered[0].id
18+
// ensure that the check_run is set to status "in progress"
19+
if (filtered[0].status !== 'queued') {
20+
console.log(await githubApiRequest(
21+
context,
22+
token,
23+
'PATCH',
24+
`/repos/${owner}/${repo}/check-runs/${filtered[0].id}`, {
25+
status: 'queued'
26+
}
27+
))
28+
}
29+
process.stderr.write(`Returning existing ${filtered[0].id}`)
30+
return filtered[0].id
3131
}
3232

3333
const { id } = await githubApiRequest(
34-
context,
35-
token,
36-
'POST',
37-
`/repos/${owner}/${repo}/check-runs`, {
38-
name: checkRunName,
39-
head_sha: ref,
40-
status: 'queued',
41-
output: {
42-
title,
43-
summary
34+
context,
35+
token,
36+
'POST',
37+
`/repos/${owner}/${repo}/check-runs`, {
38+
name: checkRunName,
39+
head_sha: ref,
40+
status: 'queued',
41+
output: {
42+
title,
43+
summary
44+
}
4445
}
45-
}
4646
)
4747
return id
48-
}
48+
}
49+
50+
const updateCheckRun = async (context, token, owner, repo, checkRunId, parameters) => {
51+
const githubApiRequest = require('./github-api-request')
52+
53+
await githubApiRequest(
54+
context,
55+
token,
56+
'PATCH',
57+
`/repos/${owner}/${repo}/check-runs/${checkRunId}`,
58+
parameters
59+
)
60+
}
4961

50-
const updateCheckRun = async (context, token, owner, repo, checkRunId, parameters) => {
62+
const cancelWorkflowRun = async (context, token, owner, repo, workflowRunId) => {
5163
const githubApiRequest = require('./github-api-request')
5264

53-
await githubApiRequest(
54-
context,
55-
token,
56-
'PATCH',
57-
`/repos/${owner}/${repo}/check-runs/${checkRunId}`,
58-
parameters
65+
const answer = await githubApiRequest(
66+
context,
67+
token,
68+
'POST',
69+
`/repos/${owner}/${repo}/actions/runs/${workflowRunId}/cancel`
5970
)
60-
}
71+
console.log(answer)
72+
}
6173

62-
module.exports = {
74+
module.exports = {
6375
queueCheckRun,
64-
updateCheckRun
65-
}
76+
updateCheckRun,
77+
cancelWorkflowRun
78+
}
Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
// Gets the permission level of a collaborator on the specified repository
22
// Returns `ADMIN`, `MAINTAIN`, `READ`, `TRIAGE` or `WRITE`.
33
module.exports = async (context, token, owner, repo, collaborator) => {
4-
const gitHubAPIRequest = require('./github-api-request')
5-
const answer = await gitHubAPIRequest(
6-
context,
7-
token,
8-
'POST',
9-
'/graphql', {
10-
query: `query CollaboratorPermission($owner: String!, $repo: String!, $collaborator: String) {
4+
const gitHubAPIRequest = require('./github-api-request')
5+
const answer = await gitHubAPIRequest(
6+
context,
7+
token,
8+
'POST',
9+
'/graphql', {
10+
query: `query CollaboratorPermission($owner: String!, $repo: String!, $collaborator: String) {
1111
repository(owner:$owner, name:$repo) {
1212
collaborators(query: $collaborator) {
1313
edges {
@@ -16,13 +16,13 @@ module.exports = async (context, token, owner, repo, collaborator) => {
1616
}
1717
}
1818
}`,
19-
variables: {
20-
owner,
21-
repo,
22-
collaborator
23-
}
24-
}
25-
)
26-
if (answer.error) throw answer.error
27-
return answer.data.repository.collaborators.edges.map(e => e.permission.toString())
19+
variables: {
20+
owner,
21+
repo,
22+
collaborator
23+
}
24+
}
25+
)
26+
if (answer.error) throw answer.error
27+
return answer.data.repository.collaborators.edges.map(e => e.permission.toString())
2828
}

GitForWindowsHelper/github-api-request-as-app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ module.exports = async (context, requestMethod, requestPath, body) => {
1616
}
1717

1818
const toBase64 = (obj) => Buffer.from(JSON.stringify(obj), "utf-8").toString("base64url")
19-
const headerAndPayload = `${toBase64(header)}.${toBase64(payload)}`
19+
const headerAndPayload = `${toBase64(header)}.${toBase64(payload)}`
2020

2121
const privateKey = `-----BEGIN RSA PRIVATE KEY-----\n${process.env['GITHUB_APP_PRIVATE_KEY']}\n-----END RSA PRIVATE KEY-----\n`
2222

GitForWindowsHelper/https-request.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ module.exports = async (context, hostname, method, requestPath, body, headers) =
2828
statusCode: res.statusCode,
2929
statusMessage: res.statusMessage,
3030
headers: res.headers
31-
})
31+
})
3232

3333
const chunks = []
3434
res.on('data', data => chunks.push(data))

GitForWindowsHelper/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ module.exports = async function (context, req) {
3131
return withStatus(500, undefined, e.toString('utf-8'))
3232
}
3333

34+
try {
35+
const selfHostedARM64Runners = require('./self-hosted-arm64-runners')
36+
if (req.headers['x-github-event'] === 'workflow_job'
37+
&& req.body.repository.full_name === 'git-for-windows/git-for-windows-automation'
38+
&& ['queued', 'completed'].includes(req.body.action)
39+
&& req.body.workflow_job.labels.length === 2
40+
&& req.body.workflow_job.labels[0] === 'Windows'
41+
&& req.body.workflow_job.labels[1] === 'ARM64') return ok(await selfHostedARM64Runners(context, req))
42+
} catch (e) {
43+
context.log(e)
44+
return withStatus(500, undefined, e.toString('utf-8'))
45+
}
46+
3447
context.log("Got headers")
3548
context.log(req.headers)
3649
context.log("Got body")
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
module.exports = async (context, req) => {
2+
const action = req.body.action
3+
const owner = req.body.repository.owner.login
4+
const repo = req.body.repository.name
5+
const sender = req.body.sender.login
6+
7+
const getToken = (() => {
8+
let token
9+
10+
const get = async () => {
11+
const getInstallationIdForRepo = require('./get-installation-id-for-repo')
12+
const installationId = await getInstallationIdForRepo(context, owner, repo)
13+
const getInstallationAccessToken = require('./get-installation-access-token')
14+
return await getInstallationAccessToken(context, installationId)
15+
}
16+
17+
return async () => token || (token = await get())
18+
})()
19+
20+
const isAllowed = async (login) => {
21+
const getCollaboratorPermissions = require('./get-collaborator-permissions')
22+
const token = await getToken()
23+
const permission = await getCollaboratorPermissions(context, token, owner, repo, login)
24+
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(permission.toString())
25+
}
26+
27+
if (!isAllowed(sender)) {
28+
if (action !== 'completed') {
29+
// Cancel workflow run
30+
const { cancelWorkflowRun } = require('./check-runs')
31+
const token = await getToken()
32+
const workflowRunId = req.body.workflow_job.run_id
33+
await cancelWorkflowRun(context, token, owner, repo, workflowRunId)
34+
}
35+
throw new Error(`${sender} is not allowed to do that`)
36+
}
37+
38+
if (action === 'queued') {
39+
// Spin up a new runner
40+
const triggerWorkflowDispatch = require('./trigger-workflow-dispatch')
41+
const token = await getToken()
42+
const answer = await triggerWorkflowDispatch(
43+
context,
44+
token,
45+
'git-for-windows',
46+
'git-for-windows-automation',
47+
'create-azure-self-hosted-runners.yml',
48+
'main', {
49+
runner_scope: 'repo-level'
50+
}
51+
)
52+
53+
return `The workflow run to create the self-hosted runner VM was started at ${answer.html_url}`
54+
}
55+
56+
if (action === 'completed') {
57+
// Delete the runner
58+
const triggerWorkflowDispatch = require('./trigger-workflow-dispatch')
59+
const token = await getToken()
60+
const vmName = req.body.workflow_job.runner_name
61+
const answer = await triggerWorkflowDispatch(
62+
context,
63+
token,
64+
'git-for-windows',
65+
'git-for-windows-automation',
66+
'delete-self-hosted-runner.yml',
67+
'main', {
68+
runner_name: vmName
69+
}
70+
)
71+
72+
return `The workflow run to delete the self-hosted runner VM '${vmName}' was started at ${answer.html_url}`
73+
}
74+
75+
return `Unhandled action: ${action}`
76+
}

0 commit comments

Comments
 (0)