-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
1257b5d
commit e2913ed
Showing
6 changed files
with
305 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
name: Test | ||
|
||
on: push | ||
|
||
jobs: | ||
test_plan_approval: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v2 | ||
- name: Submit plan | ||
# Normally, this would be `uses: jbergknoff/github-action-wait-for-terraform-plan-approval@v1` | ||
uses: ./ | ||
id: submit_plan | ||
with: | ||
command: submit | ||
plan_contents: Hello, world! | ||
- name: Wait while plan is ignored | ||
uses: ./ | ||
id: first_wait | ||
continue-on-error: true | ||
with: | ||
command: wait | ||
plan_id: ${{steps.submit_plan.outputs.plan_id}} | ||
timeout_seconds: 10 | ||
- name: Verify wait timed out | ||
if: steps.first_wait.outputs.plan_status != 'timed out' || steps.first_wait.outcome != 'failure' | ||
run: echo "Action should have reported timeout and exited 1, but did not"; exit 1 | ||
- name: Approve plan | ||
run: |- | ||
curl -X PUT -d '{"status": "approved"}' -H 'content-type: application/json' "${{steps.submit_plan.outputs.approval_prompt_url}}"/status | ||
- name: Wait after plan is approved | ||
uses: ./ | ||
id: second_wait | ||
with: | ||
command: wait | ||
plan_id: ${{steps.submit_plan.outputs.plan_id}} | ||
timeout_seconds: 10 | ||
|
||
test_plan_rejection: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v2 | ||
- name: Submit plan | ||
uses: ./ | ||
id: submit_plan | ||
with: | ||
command: submit | ||
plan_contents: Hello, world! | ||
- name: Wait while plan is ignored | ||
uses: ./ | ||
id: first_wait | ||
continue-on-error: true | ||
with: | ||
command: wait | ||
plan_id: ${{steps.submit_plan.outputs.plan_id}} | ||
timeout_seconds: 10 | ||
- name: Verify wait timed out | ||
if: steps.first_wait.outputs.plan_status != 'timed out' || steps.first_wait.outcome != 'failure' | ||
run: echo "Action should have reported timeout and exited 1, but did not"; exit 1 | ||
- name: Reject plan | ||
run: |- | ||
curl -X PUT -d '{"status": "rejected"}' -H 'content-type: application/json' "${{steps.submit_plan.outputs.approval_prompt_url}}"/status | ||
- name: Wait after plan is rejected | ||
uses: ./ | ||
id: second_wait | ||
continue-on-error: true | ||
with: | ||
command: wait | ||
plan_id: ${{steps.submit_plan.outputs.plan_id}} | ||
timeout_seconds: 10 | ||
- name: Verify plan was rejected | ||
if: steps.second_wait.outputs.plan_status != 'rejected' || steps.second_wait.outcome != 'failure' | ||
run: echo "Action should have reported rejection and exited 1, but did not"; exit 1 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
FROM python:3.8-alpine | ||
WORKDIR /opt | ||
COPY requirements.txt requirements.txt | ||
RUN pip install -r requirements.txt | ||
COPY wait_for_terraform_plan_approval.py . | ||
ENTRYPOINT ["python", "-m", "wait_for_terraform_plan_approval"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,93 @@ | ||
# GitHub Action: Wait For Terraform Plan Approval | ||
|
||
This action uses an external service (https://terraform-plan-approval.herokuapp.com/, [GitHub](https://github.com/jbergknoff/terraform-plan-approval)) to allow a GitHub Action workflow to prompt a user to approve or reject a Terraform plan before proceeding. This is just for demonstration purposes, and should not be used for anything important because the service has no authentication/authorization and is fully public. If you need this functionality for real workloads, considering running an instance of https://github.com/jbergknoff/terraform-plan-approval on a private network. | ||
|
||
## Inputs | ||
|
||
### `command` | ||
|
||
**Required** What to do: either `submit` (submit a plan to the external service) or `wait` (wait for a human to approve/reject a previously-submitted plan). | ||
|
||
### `plan_contents` | ||
|
||
**Required when `command == submit`** The plaintext contents of the Terraform plan. ANSI color codes are supported, and will be colorized when the plan is displayed for review. | ||
|
||
### `plan_id` | ||
|
||
**Required when `command == wait`** The plan id to wait for. This will be retrieved as an output after running with `command == submit`. | ||
|
||
### `external_service_url` | ||
|
||
(Optional) Override the URL of the external service. Defaults to the insecure, underpowered https://terraform-plan-approval.herokuapp.com. | ||
|
||
### `timeout_seconds` | ||
|
||
(Optional, default `300`) For `command == wait`, give up waiting for approval/rejection after this many seconds. | ||
|
||
### `polling_period_seconds` | ||
|
||
(Optional, default `5`) For `command == wait`, the interval (in seconds) at which we'll check the plan status. | ||
|
||
## Outputs | ||
|
||
### `plan_id` | ||
|
||
When running with `command == submit`, returns the plan id generated by the external service which we will subsequently pass to the `wait` command. | ||
|
||
### `approval_prompt_url` | ||
|
||
When running with `command == submit`, returns the URL that a human should visit to review and approve/reject the plan. | ||
|
||
### `plan_status` | ||
|
||
When running with `command == wait`, returns the final status of the plan: either 'approved', 'rejected', or 'timed out'. | ||
|
||
## Example usage | ||
|
||
The [tests for this repository](/.github/workflows/test.yaml) show off how to use this Action, but with two caveats: | ||
|
||
* Those tests refer to the action with `uses: ./` which should normally be `uses: jbergknoff/github-action-wait-for-terraform-plan-approval@v1` | ||
* The tests approve/reject the plan using `curl`. The approval/rejection would normally happen when a human visits the plan page and clicks a button. | ||
|
||
Here's a more conventional example: | ||
|
||
``` | ||
name: Terraform Apply | ||
on: push | ||
jobs: | ||
terraform: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v2 | ||
- name: Setup Terraform | ||
uses: hashicorp/setup-terraform@v1 | ||
- name: Terraform Init | ||
run: terraform init | ||
- name: Terraform Plan | ||
id: plan | ||
run: terraform plan -out saved_plan | ||
- name: Submit plan for approval | ||
uses: jbergknoff/github-action-wait-for-terraform-plan-approval@v1 | ||
id: submit_plan | ||
with: | ||
command: submit | ||
plan_contents: ${{steps.plan.outputs.stdout}} | ||
# Snip: send a Slack DM asking somebody to visit `steps.submit_plan.outputs.approval_prompt_url` to approve | ||
- name: Wait for approval | ||
uses: jbergknoff/github-action-wait-for-terraform-plan-approval@v1 | ||
with: | ||
command: wait | ||
plan_id: ${{steps.submit_plan.outputs.plan_id}} | ||
timeout_seconds: 600 | ||
- name: Terraform Apply | ||
run: terraform apply -auto-approve saved_plan | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
name: Wait for Terraform Plan Approval | ||
description: Uses an external service to display a plan and wait for approval/rejection. Polls in the meantime | ||
inputs: | ||
command: | ||
description: 'What to do: either `submit` or `wait`.' | ||
required: true | ||
default: submit | ||
plan_contents: | ||
description: The contents of the plan. ANSI color codes are fine. This should be plaintext, not base64-encoded. Required when `command == submit`. | ||
plan_id: | ||
description: The plan id to wait for. Required when `command == wait`. | ||
external_service_url: | ||
description: Base URL for the external service that will display plans for approval | ||
default: https://terraform-plan-approval.herokuapp.com | ||
timeout_seconds: | ||
description: Give up waiting for approval/rejection after this many seconds | ||
default: 300 | ||
polling_period_seconds: | ||
description: The interval (in seconds) at which we'll check the plan status | ||
default: 5 | ||
|
||
outputs: | ||
plan_id: | ||
description: When `command == submit`, returns the id of the plan generated by the external service. | ||
approval_prompt_url: | ||
description: When `command == submit`, returns the URL that a human should visit to review and approve/reject the plan. | ||
plan_status: | ||
description: 'When `command == wait`, returns the final status of the plan: either "approved", "rejected", or "timed out".' | ||
|
||
runs: | ||
using: 'docker' | ||
image: 'Dockerfile' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
requests |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import base64 | ||
import os | ||
import sys | ||
import time | ||
|
||
import requests | ||
|
||
|
||
external_service_url = os.getenv('INPUT_EXTERNAL_SERVICE_URL') | ||
if not external_service_url: | ||
print('`plan_id` is required.') | ||
|
||
|
||
def submit(plan_contents: str): | ||
response = requests.post( | ||
f'{external_service_url}/plan', | ||
json={ | ||
'plan_base64': base64.b64encode(plan_contents.encode('utf8')).decode('utf8'), | ||
}, | ||
) | ||
|
||
if response.status_code != 201: | ||
print(f'Failed submitting plan. External service sent response code {response.status_code}.') | ||
sys.exit(1) | ||
|
||
try: | ||
plan_id = response.json()['id'] | ||
except: | ||
print('Response from external service was not in expected format.') | ||
sys.exit(1) | ||
|
||
print(f'Submitted plan: {external_service_url}/plan/{plan_id}') | ||
print(f'::set-output name=plan_id::{plan_id}') | ||
print(f'::set-output name=approval_prompt_url::{external_service_url}/plan/{plan_id}') | ||
|
||
|
||
def wait(plan_id: str, timeout_seconds: int, polling_period_seconds: int): | ||
print(f'Waiting up to {timeout_seconds} seconds for {external_service_url}/plan/{plan_id} to be approved or rejected') | ||
waited = 0 | ||
while waited <= timeout_seconds: | ||
response = requests.get(f'{external_service_url}/plan/{plan_id}/status') | ||
if response.status_code != 200: | ||
print(f'Failed polling plan status. External service sent response code {response.status_code}.') | ||
sys.exit(1) | ||
|
||
try: | ||
status = response.json()['status'] | ||
except: | ||
print('Response from external service was not in expected format.') | ||
sys.exit(1) | ||
|
||
if status == 'rejected': | ||
print('Plan was rejected') | ||
print('::set-output name=plan_status::rejected') | ||
sys.exit(1) | ||
elif status == 'approved': | ||
print('Plan was approved') | ||
print('::set-output name=plan_status::approved') | ||
sys.exit(0) | ||
|
||
time.sleep(polling_period_seconds) | ||
waited += polling_period_seconds | ||
|
||
print('Timed out waiting for plan approval') | ||
print('::set-output name=plan_status::timed out') | ||
sys.exit(1) | ||
|
||
|
||
if __name__ == '__main__': | ||
# GitHub Actions inputs are exposed as environment variables | ||
# cf. https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs | ||
command = os.getenv('INPUT_COMMAND') | ||
if command == 'submit': | ||
plan_contents = os.getenv('INPUT_PLAN_CONTENTS') | ||
if not plan_contents: | ||
print('Error: pass `plan_contents` if using `command = submit`') | ||
sys.exit(1) | ||
|
||
submit(plan_contents) | ||
elif command == 'wait': | ||
plan_id = os.getenv('INPUT_PLAN_ID') | ||
if not plan_id: | ||
print('`plan_id` is required.') | ||
sys.exit(1) | ||
|
||
try: | ||
timeout_seconds = int(os.getenv('INPUT_TIMEOUT_SECONDS')) | ||
except: | ||
print('`timeout_seconds` could not be parsed as integer. Falling back on 300.') | ||
timeout_seconds = 300 | ||
|
||
try: | ||
polling_period_seconds = int(os.getenv('INPUT_POLLING_PERIOD_SECONDS')) | ||
except: | ||
print('`polling_period_seconds` could not be parsed as integer. Falling back on 5.') | ||
polling_period_seconds = 5 | ||
|
||
wait(plan_id=plan_id, timeout_seconds=timeout_seconds, polling_period_seconds=polling_period_seconds) | ||
else: | ||
print(f'Unrecognized command: "{command}". Should be one of `submit` or `wait`.') | ||
sys.exit(1) |