Skip to content

Commit

Permalink
Implementation (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
jbergknoff authored Aug 25, 2020
1 parent 1257b5d commit e2913ed
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 0 deletions.
73 changes: 73 additions & 0 deletions .github/workflows/test.yaml
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
6 changes: 6 additions & 0 deletions Dockerfile
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"]
92 changes: 92 additions & 0 deletions README.md
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
```
32 changes: 32 additions & 0 deletions action.yml
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'
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
requests
101 changes: 101 additions & 0 deletions wait_for_terraform_plan_approval.py
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)

0 comments on commit e2913ed

Please sign in to comment.