diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8650a2..2b7a168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,4 +74,25 @@ jobs: job-secret: ${{ secrets.JOB_SECRET }} region: eu-west-3 label: ${{ steps.test-start.outputs.label }} - ec2-instance-id: ${{ steps.test-start.outputs.ec2-instance-id }} + + - name: Test start instance with profile + id: test-start-profile + uses: ./ + with: + mode: start + github-token: ${{ secrets.SLAB_ACTION_TOKEN }} + slab-url: ${{ secrets.SLAB_BASE_URL }} + job-secret: ${{ secrets.JOB_SECRET }} + profile: ci-test + + - name: Test stop instance with profile + id: test-stop-profile + if: ${{ always() }} + uses: ./ + with: + mode: stop + github-token: ${{ secrets.SLAB_ACTION_TOKEN }} + slab-url: ${{ secrets.SLAB_BASE_URL }} + job-secret: ${{ secrets.JOB_SECRET }} + profile: ci-test + label: ${{ steps.test-start-profile.outputs.label }} diff --git a/README.md b/README.md index 48e2113..b3f2860 100644 --- a/README.md +++ b/README.md @@ -21,19 +21,19 @@ See [below](#example) the YAML code of the depicted workflow. ### Inputs -| Name | Required | Description | -|----------------------|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `mode` | Always required. | Specify here which mode you want to use: `start` to start a new runner, `stop` to stop the previously created runner. | -| `github-token` | Always required. | GitHub Personal Access Token with the `repo` scope assigned. | -| `slab-url` | Always required. | URL to Slab CI server. | -| `job-secret` | Always required. | Secret key used by Slab to perform HMAC computation. | -| `region` | Always required. | AWS deploy region. | -| `ec2-image-id` | Required if you use the `start` mode. | EC2 Image ID (AMI).The new runner will be launched from this image. The action is compatible with Amazon Linux 2 images. | -| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type. | -| `subnet-id` | Optional. Used only with the `start` mode. | VPC Subnet ID.The subnet should belong to the same VPC as the specified security group. | -| `security-group-ids` | Optional. Used only with the `start` mode. | EC2 Security Group IDs.The security group should belong to the same VPC as the specified subnet.Only the outbound traffic for port 443 should be allowed. No inbound traffic is required. | -| `label` | Required if you use the `stop` mode. | Name of the unique label assigned to the runner.The label is provided by the output of the action in the `start` mode.The label is used to remove the runner from GitHub when the runner is not needed anymore. | -| `ec2-instance-id` | Required if you use the `stop` mode. | EC2 Instance ID of the created runner.The ID is provided by the output of the action in the `start` mode.The ID is used to terminate the EC2 instance when the runner is not needed anymore. | | +| Name | Required | Description | +|----------------------|-------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `mode` | Always required. | Specify here which mode you want to use: `start` to start a new runner, `stop` to stop the previously created runner. | +| `github-token` | Always required. | GitHub Personal Access Token with the `repo` scope assigned. | +| `slab-url` | Always required. | URL to Slab CI server. | +| `job-secret` | Always required. | Secret key used by Slab to perform HMAC computation. | +| `profile` | Optional. | Profile to use as described slab.toml file in repository that uses the action. | +| `region` | Required if you don't use `profile`. | AWS deploy region. | +| `ec2-image-id` | Required if you don't use `profile` with `start` mode. | EC2 Image ID (AMI).The new runner will be launched from this image. The action is compatible with Amazon Linux 2 images. | +| `ec2-instance-type` | Required if you don't use `profile` with `start` mode. | EC2 Instance Type. | +| `subnet-id` | Optional. Used only if you don't use `profile` with `start` mode. | VPC Subnet ID.The subnet should belong to the same VPC as the specified security group. | +| `security-group-ids` | Optional. Used only if you don't use `profile` with `start` mode. | EC2 Security Group IDs.The security group should belong to the same VPC as the specified subnet.Only the outbound traffic for port 443 should be allowed. No inbound traffic is required. | +| `label` | Required if you don't use `profile` with `stop` mode. | Name of the unique label assigned to the runner.The label is provided by the output of the action in the `start` mode.The label is used to remove the runner from GitHub when the runner is not needed anymore. | ### Outputs @@ -42,7 +42,7 @@ See [below](#example) the YAML code of the depicted workflow. | `label` | Name of the unique label assigned to the runner. The label is used in two cases: to use as the input of `runs-on` property for the following jobs and to remove the runner from GitHub when it is not needed anymore. | | `ec2-instance-id` | EC2 Instance ID of the created runner.The ID is used to terminate the EC2 instance when the runner is not needed anymore. | -### Example +### Examples The workflow showed in the picture above and declared in `do-the-job.yml` looks like this: @@ -90,8 +90,49 @@ jobs: with: mode: stop github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + region: eu-west-3 + label: ${{ needs.start-runner.outputs.label }} +``` + +Here's the same workflow but using a profile declared in `ci/slab.toml` within the calling repository + +```yml +name: do-the-job +on: pull_request +jobs: + start-runner: + name: Start self-hosted EC2 runner + runs-on: ubuntu-latest + outputs: + label: ${{ steps.start-ec2-runner.outputs.label }} + ec2-instance-id: ${{ steps.start-ec2-runner.outputs.ec2-instance-id }} + steps: + - name: Start EC2 runner + id: start-ec2-runner + uses: zama-ai/slab-github-runner@v1 + with: + mode: start + github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + profile: cpu-test + + do-the-job: + # ... # + + stop-runner: + name: Stop self-hosted EC2 runner + needs: + - start-runner # required to get output from the start-runner job + - do-the-job # required to wait when the main job is done + runs-on: ubuntu-latest + if: ${{ always() }} # required to stop the runner even if the error happened in the previous jobs + steps: + - name: Stop EC2 runner + uses: zama-ai/slab-github-runner@v1 + with: + mode: stop + github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + profile: cpu-test label: ${{ needs.start-runner.outputs.label }} - ec2-instance-id: ${{ needs.start-runner.outputs.ec2-instance-id }} ``` ## License Summary diff --git a/action.yaml b/action.yaml index 079fc3d..dccaea2 100644 --- a/action.yaml +++ b/action.yaml @@ -23,30 +23,39 @@ inputs: description: >- Secret key used by Slab to perform HMAC computation. required: true + profile: + description: >- + Profile to use as described slab.toml file in repository that uses the action. + required: false region: description: >- AWS deployment region. - required: true + Mutually exclusive with 'profile' input. + required: false ec2-image-id: description: >- EC2 Image Id (AMI). The new runner will be launched from this image. This input is required if you use the 'start' mode. + Mutually exclusive with 'profile' input. required: false ec2-instance-type: description: >- EC2 Instance Type. This input is required if you use the 'start' mode. + Mutually exclusive with 'profile' input. required: false subnet-id: description: >- VPC Subnet Id. The subnet should belong to the same VPC as the specified security group. This input is required if you use the 'start' mode. + Mutually exclusive with 'profile' input. required: false security-group-ids: description: >- EC2 Security Group Ids. The security group should belong to the same VPC as the specified subnet. The runner doesn't require any inbound traffic. However, outbound traffic should be allowed. + Mutually exclusive with 'profile' input. required: false label: description: >- @@ -68,10 +77,6 @@ outputs: The label is used in two cases: - to use as the input of 'runs-on' property for the following jobs; - to remove the runner from GitHub when it is not needed anymore. - ec2-instance-id: - description: >- - EC2 Instance Id of the created runner. - The id is used to terminate the EC2 instance when the runner is not needed anymore. runs: using: node20 main: ./dist/index.js diff --git a/ci/slab.toml b/ci/slab.toml new file mode 100644 index 0000000..bdee905 --- /dev/null +++ b/ci/slab.toml @@ -0,0 +1,11 @@ +# This profile is dedicated to test action behavior by replicating +#profiles that can be found on repository using this workflow. +[profile.ci-test] +region = "eu-west-3" +image_id = "ami-01d21b7be69801c2f" # Ubuntu 22.04 +instance_type = "t3.2xlarge" + +[command.placeholder] +workflow = "no-workflow.yml" +profile = "ci-test" +check_run_name = "Placeholder command to test slab action" diff --git a/dist/index.js b/dist/index.js index bd04917..6d3dd0b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -49568,13 +49568,13 @@ class Config { githubToken: core.getInput('github-token'), slabUrl: core.getInput('slab-url'), jobSecret: core.getInput('job-secret'), + profile: core.getInput('profile'), region: core.getInput('region'), ec2ImageId: core.getInput('ec2-image-id'), ec2InstanceType: core.getInput('ec2-instance-type'), subnetId: core.getInput('subnet-id'), securityGroupIds: core.getInput('security-group-ids'), - label: core.getInput('label'), - ec2InstanceId: core.getInput('ec2-instance-id') + label: core.getInput('label') } // the values of github.context.repo.owner and github.context.repo.repo are taken from @@ -49583,7 +49583,8 @@ class Config { this.githubContext = { owner: github.context.repo.owner, repo: github.context.repo.repo, - sha: github.context.sha + sha: github.context.sha, + ref: github.context.ref } // @@ -49606,12 +49607,28 @@ class Config { throw new Error(`The 'job-secret' input is not specified`) } - if (!this.input.region) { + if ( + this.input.profile && + (this.input.region || + this.input.ec2ImageId || + this.input.ec2InstanceType || + this.input.subnetId || + this.input.securityGroupIds) + ) { + throw new Error( + `The 'profile' input is mutually exclusive with any AWS related inputs` + ) + } + + if (!this.input.profile && !this.input.region) { throw new Error(`The 'region' input is not specified`) } if (this.input.mode === 'start') { - if (!this.input.ec2ImageId || !this.input.ec2InstanceType) { + if ( + !this.input.profile && + (!this.input.ec2ImageId || !this.input.ec2InstanceType) + ) { throw new Error( `Not all the required inputs are provided for the 'start' mode` ) @@ -49726,18 +49743,32 @@ function getSignature(content) { async function startInstanceRequest() { const url = config.input.slabUrl - const payload = { - region: config.input.region, - image_id: config.input.ec2ImageId, - instance_type: config.input.ec2InstanceType, - sha: config.githubContext.sha - } - if (config.input.subnetId) { - payload.subnet_id = config.input.subnetId - } - if (config.input.securityGroupIds) { - payload.security_group_ids = config.input.securityGroupIds + let payload + + if (config.input.profile) { + payload = { + details: { profile: config.input.profile } + } + } else { + payload = { + details: { + custom_start: { + region: config.input.region, + image_id: config.input.ec2ImageId, + instance_type: config.input.ec2InstanceType + } + } + } + if (config.input.subnetId) { + payload.details.custom_start.subnet_id = config.input.subnetId + } + if (config.input.securityGroupIds) { + payload.details.custom_start.security_group_ids = + config.input.securityGroupIds + } } + payload.sha = config.githubContext.sha + payload.git_ref = config.githubContext.ref const body = JSON.stringify(payload) const signature = getSignature(body) @@ -49801,12 +49832,23 @@ async function waitForInstance(taskId, taskName) { async function terminateInstanceRequest(runnerName) { const url = config.input.slabUrl - const payload = { - region: config.input.region, - runner_name: runnerName, - action: 'terminate', - sha: config.githubContext.sha + let payload + if (config.input.profile) { + payload = { + details: { + profile: config.input.profile + } + } + } else { + payload = { + details: { custom_stop: { region: config.input.region } } + } } + payload.runner_name = runnerName + payload.action = 'terminate' + payload.sha = config.githubContext.sha + payload.git_ref = config.githubContext.ref + const body = JSON.stringify(payload) const signature = getSignature(body) @@ -49841,7 +49883,7 @@ async function terminateInstanceRequest(runnerName) { async function getTask(taskId) { try { const url = config.input.slabUrl - const route = `/task_status/${config.githubContext.owner}/${config.githubContext.repo}/${config.input.region}/${taskId}` + const route = `task_status/${config.githubContext.owner}/${config.githubContext.repo}/${config.input.region}/${taskId}` const response = await fetch(url.concat(route)) if (response.ok) { @@ -49861,7 +49903,7 @@ async function getTask(taskId) { async function removeTask(taskId) { try { const url = config.input.slabUrl - const route = `/task_delete/${config.githubContext.owner}/${config.githubContext.repo}/${config.input.region}/${taskId}` + const route = `task_delete/${config.githubContext.owner}/${config.githubContext.repo}/${config.input.region}/${taskId}` const response = await fetch(url.concat(route), { method: 'DELETE' @@ -51843,9 +51885,14 @@ function setOutput(label, ec2InstanceId) { async function start() { const start_instance_response = await slab.startInstanceRequest() + // If a profile has been provided, region is empty. + // It's updated here in order to be used on task fetching. + if (!config.input.region) { + config.input.region = start_instance_response.aws_region + } const wait_instance_response = await slab.waitForInstance( start_instance_response.task_id, - 'Start' + 'start' ) setOutput( @@ -51860,7 +51907,12 @@ async function stop() { const stop_instance_response = await slab.terminateInstanceRequest( config.input.label ) - await slab.waitForInstance(stop_instance_response.task_id, 'Stop') + // If a profile has been provided, region is empty. + // It's updated here in order to be used on task fetching. + if (!config.input.region) { + config.input.region = stop_instance_response.aws_region + } + await slab.waitForInstance(stop_instance_response.task_id, 'stop') } async function run() { diff --git a/src/config.js b/src/config.js index 67b714a..a64bad7 100644 --- a/src/config.js +++ b/src/config.js @@ -8,13 +8,13 @@ class Config { githubToken: core.getInput('github-token'), slabUrl: core.getInput('slab-url'), jobSecret: core.getInput('job-secret'), + profile: core.getInput('profile'), region: core.getInput('region'), ec2ImageId: core.getInput('ec2-image-id'), ec2InstanceType: core.getInput('ec2-instance-type'), subnetId: core.getInput('subnet-id'), securityGroupIds: core.getInput('security-group-ids'), - label: core.getInput('label'), - ec2InstanceId: core.getInput('ec2-instance-id') + label: core.getInput('label') } // the values of github.context.repo.owner and github.context.repo.repo are taken from @@ -23,7 +23,8 @@ class Config { this.githubContext = { owner: github.context.repo.owner, repo: github.context.repo.repo, - sha: github.context.sha + sha: github.context.sha, + ref: github.context.ref } // @@ -46,12 +47,28 @@ class Config { throw new Error(`The 'job-secret' input is not specified`) } - if (!this.input.region) { + if ( + this.input.profile && + (this.input.region || + this.input.ec2ImageId || + this.input.ec2InstanceType || + this.input.subnetId || + this.input.securityGroupIds) + ) { + throw new Error( + `The 'profile' input is mutually exclusive with any AWS related inputs` + ) + } + + if (!this.input.profile && !this.input.region) { throw new Error(`The 'region' input is not specified`) } if (this.input.mode === 'start') { - if (!this.input.ec2ImageId || !this.input.ec2InstanceType) { + if ( + !this.input.profile && + (!this.input.ec2ImageId || !this.input.ec2InstanceType) + ) { throw new Error( `Not all the required inputs are provided for the 'start' mode` ) diff --git a/src/index.js b/src/index.js index 42af6db..6a566a1 100644 --- a/src/index.js +++ b/src/index.js @@ -10,9 +10,14 @@ function setOutput(label, ec2InstanceId) { async function start() { const start_instance_response = await slab.startInstanceRequest() + // If a profile has been provided, region is empty. + // It's updated here in order to be used on task fetching. + if (!config.input.region) { + config.input.region = start_instance_response.aws_region + } const wait_instance_response = await slab.waitForInstance( start_instance_response.task_id, - 'Start' + 'start' ) setOutput( @@ -27,7 +32,12 @@ async function stop() { const stop_instance_response = await slab.terminateInstanceRequest( config.input.label ) - await slab.waitForInstance(stop_instance_response.task_id, 'Stop') + // If a profile has been provided, region is empty. + // It's updated here in order to be used on task fetching. + if (!config.input.region) { + config.input.region = stop_instance_response.aws_region + } + await slab.waitForInstance(stop_instance_response.task_id, 'stop') } async function run() { diff --git a/src/slab.js b/src/slab.js index bfacc0b..fdb71a4 100644 --- a/src/slab.js +++ b/src/slab.js @@ -11,18 +11,32 @@ function getSignature(content) { async function startInstanceRequest() { const url = config.input.slabUrl - const payload = { - region: config.input.region, - image_id: config.input.ec2ImageId, - instance_type: config.input.ec2InstanceType, - sha: config.githubContext.sha - } - if (config.input.subnetId) { - payload.subnet_id = config.input.subnetId - } - if (config.input.securityGroupIds) { - payload.security_group_ids = config.input.securityGroupIds + let payload + + if (config.input.profile) { + payload = { + details: { profile: config.input.profile } + } + } else { + payload = { + details: { + custom_start: { + region: config.input.region, + image_id: config.input.ec2ImageId, + instance_type: config.input.ec2InstanceType + } + } + } + if (config.input.subnetId) { + payload.details.custom_start.subnet_id = config.input.subnetId + } + if (config.input.securityGroupIds) { + payload.details.custom_start.security_group_ids = + config.input.securityGroupIds + } } + payload.sha = config.githubContext.sha + payload.git_ref = config.githubContext.ref const body = JSON.stringify(payload) const signature = getSignature(body) @@ -86,12 +100,23 @@ async function waitForInstance(taskId, taskName) { async function terminateInstanceRequest(runnerName) { const url = config.input.slabUrl - const payload = { - region: config.input.region, - runner_name: runnerName, - action: 'terminate', - sha: config.githubContext.sha + let payload + if (config.input.profile) { + payload = { + details: { + profile: config.input.profile + } + } + } else { + payload = { + details: { custom_stop: { region: config.input.region } } + } } + payload.runner_name = runnerName + payload.action = 'terminate' + payload.sha = config.githubContext.sha + payload.git_ref = config.githubContext.ref + const body = JSON.stringify(payload) const signature = getSignature(body) @@ -126,7 +151,7 @@ async function terminateInstanceRequest(runnerName) { async function getTask(taskId) { try { const url = config.input.slabUrl - const route = `/task_status/${config.githubContext.owner}/${config.githubContext.repo}/${config.input.region}/${taskId}` + const route = `task_status/${config.githubContext.owner}/${config.githubContext.repo}/${config.input.region}/${taskId}` const response = await fetch(url.concat(route)) if (response.ok) { @@ -146,7 +171,7 @@ async function getTask(taskId) { async function removeTask(taskId) { try { const url = config.input.slabUrl - const route = `/task_delete/${config.githubContext.owner}/${config.githubContext.repo}/${config.input.region}/${taskId}` + const route = `task_delete/${config.githubContext.owner}/${config.githubContext.repo}/${config.input.region}/${taskId}` const response = await fetch(url.concat(route), { method: 'DELETE'