diff --git a/course/infrastructure-as-code/README.md b/course/infrastructure-as-code/README.md new file mode 100644 index 0000000..386a2cb --- /dev/null +++ b/course/infrastructure-as-code/README.md @@ -0,0 +1,35 @@ +# Infrastructure as Code Examples +The Terraform code in the directories under this one provide examples for provisioning infrastructure into AWS, Azure, and Google Cloud Platform (GCP). + +## aws-ec2-instance +This example provides a simple example to provision an EC2 instance running Ubuntu in AWS. + +## azure-vm +This example provides a simple example to provision an Azure Windows VM and required resources in Azure. Note that it uses a module from the public [Terraform Module Registry](https://registry.terraform.io/). + +## gcp-compute-instance +This example provides a simple example to provision a Google compute instance in GCP. + +## k8s-cluster-acs +This example illustrates how you can provision an Azure Container Service (ACS) cluster. If you use this, also check out the [k8s-services](../self-serve-infrastructure/k8s-services) directory which lets you provision a web app and redis database as Kubernetes pods to the ACS cluster. + +## k8s-cluster-aks +This example illustrates how you can provision an Azure Container Service (AKS) cluster using the new AKS service that is replacing ACS. If you use this, also check out the [k8s-services](../self-serve-infrastructure/k8s-services) directory which lets you provision a web app and redis database as Kubernetes pods to the AKS cluster. + +## k8s-cluster-gke +This example illustrates how you can provision a Google Kubernetes Engine (GKE) cluster. If you use this, also check out the [k8s-services](../self-serve-infrastructure/k8s-services) directory which lets you provision a web app and redis database as Kubernetes pods to the GKE cluster. + +## k8s-cluster-openshift-aws +This example illustrates how you can provision an OpenShift 3.7 cluster into AWS using Terraform and ansible-playbook. If you use this, also check out the [k8s-services-openshift](../self-serve-infrastructure/k8s-services-openshift) directory which lets you provision a web app and redis database as Kubernetes pods to the OpenShift cluster. + +## aws-lambda-ec2-lifecycles +This example illustrates how you can provision some AWS Lambda functions to help you terminate EC2 instances that are running longer than desired. + +## dynamic-aws-creds +This example illustrates how you can use short lived AWS keys dynamically generated by Vault in your Terraform projects. It breaks up the usage of those keys into producer and consumer roles. + +## dynamic-aws-creds-from-vault +This example also illustrates the use of short lived AWS keys dynamically generated by Vault in the context of provisioning some AWS networking infrastructure. + +## hashistack +This example illustrates how to provision a HashiStack cluster running Nomad, Consul, and Vault in AWS, Azure, and Google. diff --git a/course/infrastructure-as-code/aws-ec2-instance/README.md b/course/infrastructure-as-code/aws-ec2-instance/README.md new file mode 100644 index 0000000..5b1937b --- /dev/null +++ b/course/infrastructure-as-code/aws-ec2-instance/README.md @@ -0,0 +1,7 @@ +# Provision an EC2 instance in AWS +This Terraform configuration provisions an EC2 instance in AWS. + +## Details +By default, this configuration provisions a Ubuntu 14.04 Base Image AMI (with ID ami-2e1ef954) with type t2.micro in the us-east-1 region. The AMI ID, region, and type can all be set as variables. You can also set the name variable to determine the value set for the Name tag. + +Note that you need to set environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. diff --git a/course/infrastructure-as-code/aws-ec2-instance/main.tf b/course/infrastructure-as-code/aws-ec2-instance/main.tf new file mode 100644 index 0000000..7c002a1 --- /dev/null +++ b/course/infrastructure-as-code/aws-ec2-instance/main.tf @@ -0,0 +1,17 @@ +terraform { + required_version = ">= 0.11.0" +} + +provider "aws" { + region = "${var.aws_region}" +} + +resource "aws_instance" "ubuntu" { + ami = "${var.ami_id}" + instance_type = "${var.instance_type}" + availability_zone = "${var.aws_region}a" + + tags { + Name = "${var.name}" + } +} diff --git a/course/infrastructure-as-code/aws-ec2-instance/outputs.tf b/course/infrastructure-as-code/aws-ec2-instance/outputs.tf new file mode 100644 index 0000000..cc6ba16 --- /dev/null +++ b/course/infrastructure-as-code/aws-ec2-instance/outputs.tf @@ -0,0 +1,3 @@ +output "public_dns" { + value = "${aws_instance.ubuntu.public_dns}" +} diff --git a/course/infrastructure-as-code/aws-ec2-instance/variables.tf b/course/infrastructure-as-code/aws-ec2-instance/variables.tf new file mode 100644 index 0000000..5a8cb11 --- /dev/null +++ b/course/infrastructure-as-code/aws-ec2-instance/variables.tf @@ -0,0 +1,19 @@ +variable "aws_region" { + description = "AWS region" + default = "us-west-1" +} + +variable "ami_id" { + description = "ID of the AMI to provision. Default is Ubuntu 14.04 Base Image" + default = "ami-2e1ef954" +} + +variable "instance_type" { + description = "type of EC2 instance to provision." + default = "t2.micro" +} + +variable "name" { + description = "name to pass to Name tag" + default = "Provisioned by Terraform" +} diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/README.md b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/README.md new file mode 100644 index 0000000..4ee52bb --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/README.md @@ -0,0 +1,262 @@ +# Terraforming EC2 lifecycles with AWS Lambda & Slack +Terraform configuration for lifecycle management of AWS instances. + +![Lambda bot posting to Slack](./assets/good_morning.png) + +Are you spending too much on your AWS instances every month? Do your developers create instances and forget to turn them off? Perhaps you struggle with identifying which person or system created AWS resources? This guide is for you! + +## Reference Material + * [AWS Lambda & Slack Tutorial](https://api.slack.com/tutorials/aws-lambda) + * [Slack Integration Blueprints for AWS Lambda](https://aws.amazon.com/blogs/aws/new-slack-integration-blueprints-for-aws-lambda/) + * [Terraform aws_lambda_function resource](https://www.terraform.io/docs/providers/aws/r/lambda_function.html) + + +## Estimated Time to Complete +30-60 minutes + +## Personas +Our target persona is anyone concerned with monitoring and keeping AWS instance costs under control. This may include system administrators, cloud engineers, or solutions architects. + +## Challenge +Many organizations struggle to maintain control over spending on AWS resources. Amazon Web Services makes it very easy to spin up new applicaiton workloads in the cloud, but the user is left to their own devices to clean up any unused or expired infrastructure. Users need an easy way to enforce tagging standards and shut down or terminate instances that are no longer required. + +## Solution +This Terraform configuration deploys AWS Lambda functions that can do the following: + + - Check for mandatory tags on AWS instances and notify via Slack if untagged instances are found. + - Notify on how many of each instance type are currently running across all regions. + - Shutdown untagged instances after X days. + - Delete untagged instances after Y days. + - Delete machines whose TTL (time to live) has expired. + +### Directory Structure +A description of what each file does: +``` + main.tf - Main configuration file. REQUIRED + data_collectors.tf - Lambda functions for gathering instance data. REQUIRED + iam_roles.tf - Configures IAM role and policies for your Lambda functions. REQUIRED + notify_instance_usage.tf - sends reports about running instances. + notify_untagged.tf - sends reports about untagged instances and their key names. + instance_reaper.tf - Checks instance TTL tag, terminates instances that have expired. + untagged_janitor.tf - Cleans up untagged instances after a set number of days. + files/ - Contains all of the lambda source code, zip files, and IAM template files. +``` + +## Prerequisites +1. Admin level access to your AWS account via API. If admin access is not available you must have the ability to create, describe, and delete the following types of resources in AWS. Fine-grained configuration of IAM policies is beyond the scope of this guide. We will assume you have API keys and appropriate permissions that allow you to create the following resources using Terraform: + + aws\_cloudwatch\_event\_rule + aws\_cloudwatch\_event\_target + aws\_iam\_role + aws\_iam\_role\_policy + aws\_lambda\_function + aws\_lambda\_permission + aws\_kms\_alias + aws\_kms\_key + +2. Properly configured workstation or server for running Terraform commands. New to Terraform? Try our [Getting Started Guide](https://www.terraform.io/intro/getting-started/install.html) + +3. An [incoming webhook integration](https://api.slack.com/incoming-webhooks) in your Slack account. If you want to receive notifications about instance usage and tags you'll need to be able to create a webhook or ask your administrator to help you create one. + +## TL;DR +Below are all of the commands you'll need to run to get these lambda scripts deployed in your account: +``` +# Be sure to configure your Slack webhook and edit your variables.tf file first! +terraform init +terraform plan +terraform apply +``` + +## Steps +The following walkthrough describes in detail the steps required to enable the cleanup and 'reaper' scripts that are included in this repo. + +### Step 1: Configure incoming Slack webhook +Set up your Slack incoming webhook: https://my.slack.com/services/new/incoming-webhook/. Feel free to give your new bot a unique name, icon and description. Make note of the Webhook URL. This is a specially coded URL that allows remote applications to post data into your Slack channels. Do not share this link publicly or commit it to your source code repo. Choose the channel you want your bot to post messages to. + +![Slack Webhook Config Page](./assets/aws_bot.png) + +### Step 2: Configure your variables +Edit the `variables.tf` file and choose which region you want to run your Lambda functions in. These functions can be run from any region and manage instances in any other region. + +``` +variable "region" { + default = "us-west-2" + description = "AWS Region" +} + +variable "slack_hook_url" { + default = "https://hooks.slack.com/services/REPLACE/WITH/YOUR_WEBHOOK" + description = "Slack incoming webhook URL, get this from the slack management page." +} +``` + + * Set the `slack_hook_url` variable to the URL you generated in step #1. + * Set any tags that you want to be considered mandatory in the `mandatory_tags` variable. This is a comma separated list, with no spaces between items. + * Set the `reap_days` and `sleep_days` to your liking. These represent the number of days after launch that an untagged instance will be stopped and terminated respectively. + * Leave the `is_active` variable set to 0 for testing. You must set this to 1 or True if you want to activate the scripts. 0 or False means reporting mode where nothing is actually stopped or terminated. + * Save the `variables.tf` file. + +### Step 3: Run Terraform Plan + +#### CLI + * [Terraform Plan Docs](https://www.terraform.io/docs/commands/plan.html) + +#### Request + +``` +$ terraform plan +``` + +#### Response +``` +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + + + +Plan: 25 to add, 0 to change, 0 to destroy. + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +``` + +### Step 4: Run Terraform Apply + +#### CLI + * [Terraform Apply Docs](https://www.terraform.io/docs/commands/apply.html) + +#### Request + +``` +$ terraform apply +``` + +#### Response +``` +data.aws_caller_identity.current: Refreshing state... +data.template_file.iam_lambda_read_instances: Refreshing state... +data.template_file.iam_lambda_stop_and_terminate_instances: Refreshing state... +data.template_file.iam_lambda_notify: Refreshing state... + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + +aws_lambda_function.getRunningInstances: Creation complete after 22s (ID: getRunningInstances) +aws_lambda_function.getUntaggedInstances: Creation complete after 22s (ID: getUntaggedInstances) +aws_lambda_function.getTaggedInstances: Creation complete after 23s (ID: getTaggedInstances) + +Apply complete! Resources: 25 added, 0 changed, 0 destroyed. +``` + +### Step 4: Test your Lambda functions +Now you can test your new lambda functions. Use the test button at the top of the page to ensure they are working correctly. For your test event you can simply create a dummy event with the default JSON payload: + +![Configure test event](./assets/dummy_event.png) + +Check your slack channel to see the messages posted from your bot. + +### Step 5: Adjust Schedule +By default the reporting lambdas are set to run once per day. You can customize the schedule by adjusting the `aws_cloudwatch_event_rule` resources. The schedule follows a Unix cron-style format: `cron(0 8 * * ? *)`. The instance_reaper will be most effective if it is run every hour. + +### Step 6: Go live +_IMPORTANT_: If you want to actually stop and terminate instances in a live environment, you must uncomment/edit the code inside of `cleanUntaggedInstances.py` and `checkInstanceTTLs.py`. We have commented out the lines that do these actions so you can test before going live. This is for your own safety and protection. In order to activate these scripts you must *both* uncomment those lines *and* set the is_active variable to True. You can uncomment the lines directly in the AWS Lambda editor, or make the changes locally and re-deploy your lambdas. + +See below for the lines that handle `stop()` and `terminate()` actions. + +``` +def sleep_instance(instance_id,region): + ec2 = boto3.resource('ec2', region_name=region) + """Stops instances that have gone beyond their TTL""" + if str_to_bool(ISACTIVE) == True: + # Uncomment to make this live! + #ec2.instances.filter(InstanceIds=instance_id).stop() + logger.info("I stopped "+instance_id+" in "+region) + else: + logger.info("I would have stopped "+instance_id+" in "+region) + +def terminate_instance(instance_id,region): + ec2 = boto3.resource('ec2', region_name=region) + """Stops instances that have gone beyond their TTL""" + if str_to_bool(ISACTIVE) == True: + # Uncomment to make this live! + #ec2.instances.filter(InstanceIds=instance_id).terminate() + logger.info("I terminated "+instance_id+" in "+region) + else: + logger.info("I would have terminated "+instance_id+" in "+region) +``` + +## Next Steps + +### Optional - Enable KMS encryption +You can optionally encrypt the Slack Webhook URL so that it cannot be viewed in plaintext in the AWS console. This also allows you to commit your webhook URL to source code without worrying about it getting into the wrong hands. This also provides some extra security if you are working with a shared AWS account. Here are the additional steps you need to follow to enable encryption: + +1. Uncomment the lines in `notifyUntaggedInstances.py` and `notifyInstanceUsage.py` (or other lambdas) that enable encryption. These are the lines you'll need to uncomment. Note how we are using the b64decode Python module to decrypt the encrypted Slack Webhook: +``` +# from base64 import b64decode +# ENCRYPTED_HOOK_URL = os.environ['slackHookUrl'] +# HOOK_URL = boto3.client('kms').decrypt(CiphertextBlob=b64decode(os.environ['slackHookUrl']))['Plaintext'].decode('utf-8') +``` +2. Rename the `encryption.tf.disabled` file to `encryption.tf`. Terraform reads any file that ends with the *.tf extension. +3. Run `terraform apply` to generate a new AWS KMS key called `notify_slack`. +4. Log onto the AWS console and switch into the region where you deployed your Lambdas. Navigate to the AWS Lambda section of the dashboard. +5. Find the `notifyInstanceUsage` Lambda and click on it. +6. Scroll down to the Environment Variables section. Click the little arrow to expand the Encryption configuration options. +7. Check the box under "Enable helpers for encryption in transit". This will enable a new menu that says "KMS key to encrypt in transit". From that pull-down menu select the `notify_slack` key. This is the KMS key that Terraform created in step #3. +8. Click on the `Encrypt` button next to the webhook URL. This will encrypt your webhook URL. Now click on `Save` at the top right. If you don't save here the settings won't stick. +9. Navigate back to the AWS Lambda functions and repeat steps #1-8 for any other functions where you want to configure the encrypted URL. +10. If you want to make this configuration permanent, comment out the `aws_kms_key` and `aws_kms_alias` resources in encryption.tf. Then use the `terraform state rm` command to remove both of them from your state file. The key you created will now be persistent, and allow you to save your encrypted Slack Webhook URL in your variables file. You can fetch the encrypted URL by running `terraform show` command. + +### Optional - Edit the Slack message and formatting +If you'd like to customize the messages that get sent into your Slack channels, just edit the part of the code that calls the `send_slack_message` function. Note how you can put action buttons into your message to link your users to useful information or status pages. The Slack API guide has examples and more info: https://api.slack.com/docs/message-formatting + +``` + send_slack_message( + msg_text, + title='AWS Instance Type Usage', + text="```\n"+report+"\n```", + fallback='AWS Instance Type Usage', + color='warning', + actions = [ + { + "type": "button", + "text": ":money-burning: AWS Cost Explorer", + "url": "http://amzn.to/2EBAfQu" + }, + { + "type": "button", + "text": ":broom: AWS Console", + "url": "https://console.aws.amazon.com/ec2/v2/home" + }, + ] + ) +``` + +### Optional - Send email instead of Slack messages +If you don't have access to Slack or would rather send reports via email, simply comment out the lines in each function that run `send_slack_message` and uncomment the lines to `send_email` instead. For example, look at this section of code in `notifyInstanceUsage.py`. You will need to verify your email address first in the AWS Simple Email Service control panel. You'll also need to change the SENDER and RECIPIENT variables listed at the top of the file to your email address. + +``` + # Uncomment these lines to use email for notifications + send_email( + SENDER, + RECIPIENT, + AWS_REGION, + SUBJECT, + report, + CHARSET) + + # send_slack_message( + # msg_text, + # title=SUBJECT, +``` + +### Clean up +Cleanup is simple, just run `terraform destroy` in your workspace and all resources will be cleaned up. \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/aws_bot.png b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/aws_bot.png new file mode 100644 index 0000000..7ae2fde Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/aws_bot.png differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/dummy_event.png b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/dummy_event.png new file mode 100644 index 0000000..b866e41 Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/dummy_event.png differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/good_morning.png b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/good_morning.png new file mode 100644 index 0000000..ae0e7fc Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/assets/good_morning.png differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/data_collectors.tf b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/data_collectors.tf new file mode 100644 index 0000000..9fc9e7d --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/data_collectors.tf @@ -0,0 +1,48 @@ +# These lambda functions return dictionaries of instances. +# Use them with other functions to take action on tagged, untagged +# or running instances. + +resource "aws_lambda_function" "getUntaggedInstances" { + filename = "./files/getUntaggedInstances.zip" + function_name = "getUntaggedInstances" + role = "${aws_iam_role.lambda_read_instances.arn}" + handler = "getUntaggedInstances.lambda_handler" + source_code_hash = "${base64sha256(file("./files/getUntaggedInstances.zip"))}" + runtime = "python3.6" + timeout = "120" + description = "Gathers a list of untagged or improperly tagged instances." + + environment { + variables = { + "REQTAGS" = "${var.mandatory_tags}" + } + } +} + +resource "aws_lambda_function" "getTaggedInstances" { + filename = "./files/getTaggedInstances.zip" + function_name = "getTaggedInstances" + role = "${aws_iam_role.lambda_read_instances.arn}" + handler = "getTaggedInstances.lambda_handler" + source_code_hash = "${base64sha256(file("./files/getTaggedInstances.zip"))}" + runtime = "python3.6" + timeout = "120" + description = "Gathers a list of correctly tagged instances." + + environment { + variables = { + "REQTAGS" = "${var.mandatory_tags}" + } + } +} + +resource "aws_lambda_function" "getRunningInstances" { + filename = "./files/getRunningInstances.zip" + function_name = "getRunningInstances" + role = "${aws_iam_role.lambda_read_instances.arn}" + handler = "getRunningInstances.lambda_handler" + source_code_hash = "${base64sha256(file("./files/getRunningInstances.zip"))}" + runtime = "python3.6" + timeout = "120" + description = "Gathers a list of running instances." +} \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/encryption.tf.disabled b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/encryption.tf.disabled new file mode 100644 index 0000000..859192a --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/encryption.tf.disabled @@ -0,0 +1,32 @@ +# Optional extra resources to help encrypt your Slack Webhook URL + +# This key is used to encrypt the slack webhook URL +resource "aws_kms_key" "notify_slack" { + description = "Key for encrypting the Slack webhook URL" + enable_key_rotation = "false" + is_enabled = "true" +} + +# A human friendly alias so we can find it in the UI +resource "aws_kms_alias" "notify_slack" { + name = "alias/notify_slack" + target_key_id = "${aws_kms_key.notify_slack.key_id}" +} + +# Template for our 'decrypt_kms' lambda IAM policy +data "template_file" "iam_decrypt_kms" { + template = "${file("./files/iam_decrypt_kms.tpl")}" + + vars { + kmskey = "${aws_kms_key.notify_slack.arn}" + account_id = "${data.aws_caller_identity.current.account_id}" + region = "${var.region}" + } +} + +# Here we ingest the template and attach it to our notify_slack role +resource "aws_iam_role_policy" "decrypt_kms" { + name = "decrypt_kms" + policy = "${data.template_file.iam_decrypt_kms.rendered}" + role = "${aws_iam_role.lambda_notify_slack.id}" +} \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/checkInstanceTTLs.py b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/checkInstanceTTLs.py new file mode 100644 index 0000000..303deda --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/checkInstanceTTLs.py @@ -0,0 +1,220 @@ +# General purpose Lambda function for sending Slack messages, encrypted in transit. + +import boto3 +from botocore.exceptions import ClientError +import json +import logging +import os +import csv +import io +from datetime import datetime,timezone,timedelta +from dateutil import parser +from distutils.util import strtobool + +# Required if you want to encrypt your Slack Hook URL in the AWS console +# from base64 import b64decode +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + +SLACK_CHANNEL = os.environ['slackChannel'] +# Required if you want to encrypt your Slack hook URL in the AWS Console +# ENCRYPTED_HOOK_URL = os.environ['slackHookUrl'] +# HOOK_URL = boto3.client('kms').decrypt(CiphertextBlob=b64decode(os.environ['slackHookUrl']))['Plaintext'].decode('utf-8') +HOOK_URL = os.environ['slackHookUrl'] +ISACTIVE = os.environ['isActive'] + +############################################################################ +# These settings are only required if you are using email for notifications. +SENDER = "Cleanup Bot " +RECIPIENT = "robot@example.com" +AWS_REGION = "us-west-2" +CHARSET = "UTF-8" +############################################################################ + +logger = logging.getLogger() +logger.setLevel(logging.INFO) +lam = boto3.client('lambda') + +def lambda_handler(event, context): + """Sends out a formatted slack message. Edit to your liking.""" + + msg_text = 'The Reaper Cometh :reaper:' + tagged = get_tagged_instances() + expired = generate_expired_dict(tagged) + # logger.info(expired) + + # Create a TSV-formatted list of instances that were found + output = io.StringIO() + writer = csv.writer(output, delimiter='\t') + writer.writerow(['******************************************','','']) + writer.writerow(['The following instances will be terminated:','','']) + writer.writerow(['Instance_Id ', 'Region ', 'Expires_On']) + for key, value in expired.items(): + #value['InstanceId'] = key + writer.writerow([key, value['RegionName'], value['ExpiresOn']]) + contents = output.getvalue() + + if str_to_bool(ISACTIVE) == False: + title_text = ':reaper: Instance Reaper - TESTING MODE' + else: + title_text = ':reaper: Instance Reaper - ACTIVE MODE' + + # If there are any instances on the list, notify slack. + if expired: + send_slack_message( + msg_text, + title=title_text, + text="```\n"+str(contents)+"\n```", + fallback='Expired Instance Cleanup', + color='warning' + ) + + # Uncomment send_email to use email instead of slack + # send_email( + # SENDER, + # RECIPIENT, + # AWS_REGION, + # title_text, + # contents, + # CHARSET + # ) + + # Put expired TTL instances down + for instance,data in expired.items(): + terminate_instance(instance,data['RegionName']) + +def send_slack_message(msg_text, **kwargs): + """Sends a slack message to the slackChannel you specify. The only parameter + required here is msg_text, or the main message body text. If you want to + format your message use the attachment feature which is documented here: + https://api.slack.com/docs/messages. You simply pass in your attachment + parameters as keyword arguments, or key-value pairs. This function currently + only supports a single attachment for simplicity's sake. + """ + slack_message = { + 'channel': SLACK_CHANNEL, + 'text': msg_text, + 'attachments': [ kwargs ] + } + + req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8')) + try: + response = urlopen(req) + response.read() + logger.info("Message posted to %s", slack_message['channel']) + except HTTPError as e: + logger.error("Request failed: %d %s", e.code, e.reason) + except URLError as e: + logger.error("Server connection failed: %s", e.reason) + +def send_email(sender,recipient,aws_region,subject,body_text,charset): + """ + Sends a plaintext email to the address of your choice. Be sure to + verify your email in the SES control panel first. More documentation + here: https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-using-sdk-python.html + """ + + # Create a new SES resource and specify a region. + client = boto3.client('ses',region_name=aws_region) + + # Try to send the email. + try: + #Provide the contents of the email. + response = client.send_email( + Destination={ + 'ToAddresses': [ + recipient, + ], + }, + Message={ + 'Body': { + 'Text': { + 'Charset': charset, + 'Data': body_text, + }, + }, + 'Subject': { + 'Charset': charset, + 'Data': subject, + }, + }, + Source=sender + ) + # Display an error if something goes wrong. + except ClientError as e: + print(e.response['Error']['Message']) + else: + print("Email sent! Message ID:"), + print(response['ResponseMetadata']['RequestId']) + +def get_tagged_instances(): + """Calls the Lambda function that returns a dictionary of instances.""" + try: + response = lam.invoke(FunctionName='getTaggedInstances', InvocationType='RequestResponse') + except Exception as e: + print(e) + raise e + return response + +def generate_expired_dict(response): + """Generates a dictionary of instances that have passed their Time to Live (TTL).""" + data = json.loads(response['Payload'].read().decode('utf-8')) + data = json.loads(data) + expired_instances = {} + for key, value in data.items(): + # A value of -1 signifies that a machine should never be reaped. + if int(value['TTL']) != -1 and isInteger(value['TTL']): + launch_time = parser.parse(value['LaunchTime']) + expires_on = launch_time + timedelta(hours=int(value['TTL'])) + # If we have passed the expires_on time, add to list. + if expires_on < datetime.now(timezone.utc): + expired_instances[key] = { + 'RegionName':value['RegionName'], + 'Owner':value['Owner'], + 'TTL':value['TTL'], + 'LaunchTime':str(launch_time), + 'ExpiresOn':str(expires_on) + } + return expired_instances + +# TODO: Move these into a central file and import them +def str_to_bool(string): + return bool(strtobool(str(string))) + +def sleep_instance(instance_id,region): + ec2 = boto3.resource('ec2', region_name=region) + """Stops instances""" + if str_to_bool(ISACTIVE) == True: + try: + # Uncomment to make this live! + #ec2.instances.filter(InstanceIds=[instance_id]).stop() + logger.info("I stopped "+instance_id+" in "+region) + except Exception as e: + logger.info("Problem stopping instance: "+instance_id) + logger.info(e) + else: + logger.info("I would have stopped "+instance_id+" in "+region) + +def terminate_instance(instance_id,region): + ec2 = boto3.resource('ec2', region_name=region) + """Terminates instances""" + if str_to_bool(ISACTIVE) == True: + try: + # Uncomment to make this live! + #ec2.instances.filter(InstanceIds=[instance_id]).terminate() + logger.info("I terminated "+instance_id+" in "+region) + except Exception as e: + logger.info("Problem terminating instance: "+instance_id) + logger.info(e) + else: + logger.info("I would have terminated "+instance_id+" in "+region) + +def isInteger(s): + try: + int(s) + return True + except ValueError: + return False + +if __name__ == '__main__': + lambda_handler({}, {}) \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/checkInstanceTTLs.zip b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/checkInstanceTTLs.zip new file mode 100644 index 0000000..7dad13d Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/checkInstanceTTLs.zip differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/cleanUntaggedInstances.py b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/cleanUntaggedInstances.py new file mode 100644 index 0000000..bad321f --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/cleanUntaggedInstances.py @@ -0,0 +1,253 @@ +# This function deals with instances that are untagged. Use the environment variables +# sleepDays and reapDays to set your lifecycle policies. + +import boto3 +from botocore.exceptions import ClientError +import json +import logging +import os +import csv +import io +from datetime import datetime,timezone,timedelta +from dateutil import parser +from distutils.util import strtobool + +# Required if you want to encrypt your Slack Hook URL in the AWS console +# from base64 import b64decode +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + +SLACK_CHANNEL = os.environ['slackChannel'] +# Required if you want to encrypt your Slack hook URL in the AWS Console +# ENCRYPTED_HOOK_URL = os.environ['slackHookUrl'] +# HOOK_URL = boto3.client('kms').decrypt(CiphertextBlob=b64decode(os.environ['slackHookUrl']))['Plaintext'].decode('utf-8') +HOOK_URL = os.environ['slackHookUrl'] + +SLEEPDAYS = os.environ['sleepDays'] +REAPDAYS = os.environ['reapDays'] +ISACTIVE = os.environ['isActive'] + +############################################################################ +# These settings are only required if you are using email for notifications. +SENDER = "Cleanup Bot " +RECIPIENT = "robot@example.com" +AWS_REGION = "us-west-2" +CHARSET = "UTF-8" +############################################################################ + +logger = logging.getLogger() +logger.setLevel(logging.INFO) +lam = boto3.client('lambda') + +def lambda_handler(event, context): + """Sleeps instances after SLEEPDAYS and terminates them after REAPDAYS. Times are measured beginning from LaunchDate.""" + + msg_text = 'Enter the Sandman :sleeping:' + untagged = get_untagged_instances() + stop_dict = generate_stop_dict(untagged) + + untagged2 = get_untagged_instances() + terminate_dict = generate_terminate_dict(untagged2) + + # Create a TSV-formatted list of instances scheduled for stop or termination + output = io.StringIO() + writer = csv.writer(output, delimiter='\t') + writer.writerow(['*********************************************', '', '']) + writer.writerow(['These instances will be put to sleep:', '', '']) + writer.writerow(['Instance_Id ', 'Region ', 'Stop_On']) + for key, value in stop_dict.items(): + writer.writerow([key, value['RegionName'], value['StopOn']]) + writer.writerow(['*********************************************', '', '']) + writer.writerow(['These instances will be terminated:', '', '']) + writer.writerow(['Instance_Id ', 'Region ', 'Terminate_On']) + for key, value in terminate_dict.items(): + writer.writerow([key, value['RegionName'], value['TerminateOn']]) + contents = output.getvalue() + + if str_to_bool(ISACTIVE) == False: + title_text = ':broom: Untagged Janitor - TESTING MODE' + else: + title_text = ':broom: Untagged Janitor - ACTIVE MODE' + + send_slack_message( + msg_text, + title=title_text, + text="```\n"+str(contents)+"\n```", + fallback='Untagged Instance Report', + color='warning' + ) + + # Uncomment send_email to use email instead of slack + # send_email( + # SENDER, + # RECIPIENT, + # AWS_REGION, + # title_text, + # contents, + # CHARSET + # ) + + # Stop instances that have passed SLEEPDAYS. + for instance,data in stop_dict.items(): + sleep_instance(instance,data['RegionName']) + + # Terminate instances that have passed REAPDAYS. + for instance,data in terminate_dict.items(): + terminate_instance(instance,data['RegionName']) + +def send_slack_message(msg_text, **kwargs): + """Sends a slack message to the slackChannel you specify. The only parameter + required here is msg_text, or the main message body text. If you want to + format your message use the attachment feature which is documented here: + https://api.slack.com/docs/messages. You simply pass in your attachment + parameters as keyword arguments, or key-value pairs. This function currently + only supports a single attachment for simplicity's sake. + """ + slack_message = { + 'channel': SLACK_CHANNEL, + 'text': msg_text, + 'attachments': [ kwargs ] + } + + req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8')) + try: + response = urlopen(req) + response.read() + logger.info("Message posted to %s", slack_message['channel']) + except HTTPError as e: + logger.error("Request failed: %d %s", e.code, e.reason) + except URLError as e: + logger.error("Server connection failed: %s", e.reason) + +def send_email(sender,recipient,aws_region,subject,body_text,charset): + """ + Sends a plaintext email to the address of your choice. Be sure to + verify your email in the SES control panel first. More documentation + here: https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-using-sdk-python.html + """ + + # Create a new SES resource and specify a region. + client = boto3.client('ses',region_name=aws_region) + + # Try to send the email. + try: + #Provide the contents of the email. + response = client.send_email( + Destination={ + 'ToAddresses': [ + recipient, + ], + }, + Message={ + 'Body': { + 'Text': { + 'Charset': charset, + 'Data': body_text, + }, + }, + 'Subject': { + 'Charset': charset, + 'Data': subject, + }, + }, + Source=sender + ) + # Display an error if something goes wrong. + except ClientError as e: + print(e.response['Error']['Message']) + else: + print("Email sent! Message ID:"), + print(response['ResponseMetadata']['RequestId']) + +def get_untagged_instances(): + """Calls the Lambda function that returns a dictionary of instances.""" + try: + response = lam.invoke(FunctionName='getUntaggedInstances', InvocationType='RequestResponse') + except Exception as e: + print(e) + raise e + return response + +def generate_stop_dict(response): + """Generates a dictionary of untagged instances to stop.""" + data = json.loads(response['Payload'].read().decode('utf-8')) + data = json.loads(data) + stop_instances = {} + for key, value in data.items(): + launch_time = parser.parse(value['LaunchTime']) + stop_on = launch_time + timedelta(days=int(SLEEPDAYS)) + # Only proceed if the instance is running + if value['State'] == 'running': + # If we have passed the stop_on time, add to list. + if stop_on < datetime.now(timezone.utc): + stop_instances[key] = { + 'RegionName':value['RegionName'], + 'Owner':value['Owner'], + 'TTL':value['TTL'], + 'LaunchTime':str(launch_time), + 'StopOn':str(stop_on) + } + return stop_instances + +def generate_terminate_dict(response): + """Generates a dictionary of untagged instances to terminate.""" + data = json.loads(response['Payload'].read().decode('utf-8')) + data = json.loads(data) + #logger.info(data) + terminate_instances = {} + for key, value in data.items(): + # A value of -1 signifies that a machine should never be reaped. + launch_time = parser.parse(value['LaunchTime']) + terminate_on = launch_time + timedelta(days=int(REAPDAYS)) + # If we have passed the terminate_on time, add to list. + if terminate_on < datetime.now(timezone.utc): + terminate_instances[key] = { + 'RegionName':value['RegionName'], + 'Owner':value['Owner'], + 'TTL':value['TTL'], + 'LaunchTime':str(launch_time), + 'TerminateOn':str(terminate_on) + } + return terminate_instances + +# TODO: Move these into a central file and import them +def str_to_bool(string): + return bool(strtobool(str(string))) + +def sleep_instance(instance_id,region): + ec2 = boto3.resource('ec2', region_name=region) + """Stops instances""" + if str_to_bool(ISACTIVE) == True: + try: + # Uncomment to make this live! + #ec2.instances.filter(InstanceIds=[instance_id]).stop() + logger.info("I stopped "+instance_id+" in "+region) + except Exception as e: + logger.info("Problem stopping instance: "+instance_id) + logger.info(e) + else: + logger.info("I would have stopped "+instance_id+" in "+region) + +def terminate_instance(instance_id,region): + ec2 = boto3.resource('ec2', region_name=region) + """Terminates instances""" + if str_to_bool(ISACTIVE) == True: + try: + # Uncomment to make this live! + #ec2.instances.filter(InstanceIds=[instance_id]).terminate() + logger.info("I terminated "+instance_id+" in "+region) + except Exception as e: + logger.info("Problem terminating instance: "+instance_id) + logger.info(e) + else: + logger.info("I would have terminated "+instance_id+" in "+region) + +def isInteger(s): + try: + int(s) + return True + except ValueError: + return False + +if __name__ == '__main__': + lambda_handler({}, {}) \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/cleanUntaggedInstances.zip b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/cleanUntaggedInstances.zip new file mode 100644 index 0000000..d2cc3fd Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/cleanUntaggedInstances.zip differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getInstanceReport.py b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getInstanceReport.py new file mode 100644 index 0000000..01c1609 --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getInstanceReport.py @@ -0,0 +1,47 @@ +# Example functions for AWS reporting. Use as a base to build your own. + +import boto3 +import json +import logging +import os +import csv +import io + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +lam = boto3.client('lambda') + +def lambda_handler(event, context): + """Generates a tab-separated list of running instances.""" + # You could also use get_tagged_instances or get_untagged_instances here + running = get_running_instances() + report = generate_tsv(running) + #logger.info(report) + return(report) + +def get_running_instances(): + """Calls the Lambda function that returns a dictionary of instances.""" + try: + response = lam.invoke(FunctionName='getRunningInstances', InvocationType='RequestResponse') + except Exception as e: + print(e) + raise e + return response + +# This could be useful for generating email reports or dumping a list of running +# instances into an S3 bucket. +def generate_tsv(response): + """Ingests data from a lambda response, converts it to tab-separated format.""" + data=json.loads(response['Payload'].read().decode('utf-8')) + data=json.loads(data) + output = io.StringIO() + writer = csv.writer(output, delimiter='\t') + for key, value in data.items(): + value['InstanceId'] = key + writer.writerow(value.values()) + contents = output.getvalue() + return(contents) + +if __name__ == '__main__': + lambda_handler({}, {}) \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getInstanceReport.zip b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getInstanceReport.zip new file mode 100644 index 0000000..c89d406 Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getInstanceReport.zip differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getRunningInstances.py b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getRunningInstances.py new file mode 100644 index 0000000..58c6820 --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getRunningInstances.py @@ -0,0 +1,77 @@ +import boto3 +import json +import logging +import os + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def lambda_handler(event, context): + """Returns stringified JSON output to the requestor.""" + running = json.dumps(get_running_instance_data()) + return running + +def get_running_instances(region): + """Checks instances in a single region for required tags, returns list of instance ids.""" + ec2 = boto3.resource('ec2',region_name=region) + instances = ec2.instances.filter( + Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]) + instance_list = [] + for instance in instances: + instance_list.append(instance.id) + logger.info("Found "+str(len(instance_list))+" running instances in "+region) + return instance_list + +def get_running_instance_data(): + """ + Fetches a master list of instances across all regions, returns dictionary + that includes some identifying information as key-value pairs. + """ + global_running_instances = {} + for r in get_regions(): + #for r in ['us-east-1']: + client = boto3.client('ec2',region_name=r) + # Get our list of running instances + instance_ids = get_running_instances(r) + if len(instance_ids) != 0: + response = client.describe_instances(InstanceIds=instance_ids) + #logger.info(response) + for reservation in response['Reservations']: + for instance in reservation['Instances']: + # In case we have no tags, default to None + name = None + owner = None + ttl = None + created_by = None + if 'Tags' in instance: + for tag in instance['Tags']: + if tag['Key'] == "Name": + name = tag['Value'] + if tag['Key'] == "owner": + owner = tag['Value'] + if tag['Key'] == "TTL": + ttl = tag['Value'] + if tag['Key'] == "created-by": + created_by = tag['Value'] + # Add more data as you see fit. + global_running_instances[instance['InstanceId']] = { + 'InstanceType': instance['InstanceType'], + 'RegionName': r, + 'LaunchTime': str(instance['LaunchTime']), + 'State': instance['State']['Name'], + 'KeyName': instance.get('KeyName'), + 'Name': name, + 'Owner': owner, + 'TTL': ttl, + 'created-by': created_by + } + return global_running_instances + +def get_regions(): + """Returns a list of all AWS regions.""" + c = boto3.client('ec2') + regions = [region['RegionName'] for region in c.describe_regions()['Regions']] + return regions + +if __name__ == '__main__': + lambda_handler({}, {}) \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getRunningInstances.zip b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getRunningInstances.zip new file mode 100644 index 0000000..8df3885 Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getRunningInstances.zip differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getTaggedInstances.py b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getTaggedInstances.py new file mode 100644 index 0000000..5203b8b --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getTaggedInstances.py @@ -0,0 +1,111 @@ +import boto3 +import json +import logging +import os + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def lambda_handler(event, context): + """Returns stringified JSON output to the requestor.""" + tagged = json.dumps(get_tagged_instances()) + logger.info(tagged) + return tagged + +def check_instance_tags(region): + """Checks instances in a single region for required tags, returns list of instance ids.""" + ec2 = boto3.resource('ec2',region_name=region) + instances = ec2.instances.all() + # We should be able to check against this list: + mandatory_tags = os.environ.get("REQTAGS").split(",") + # logger.info(mandatory_tags) + nice_list = [] + for instance in instances: + if instance.tags: + # logger.info(instance.tags) + taglist = [] + for tag in instance.tags: + if tag['Key'] == 'TTL' or tag['Key'] == 'ttl': + if isInteger(tag['Value']): + taglist.append(tag['Key'].upper()) + elif tag['Key'] == 'Owner' or tag['Key'] == 'owner': + taglist.append(tag['Key'].lower()) + else: + taglist.append(tag['Key']) + # logger.info(taglist) + if set(mandatory_tags).issubset(set(taglist)): + nice_list.append(instance.id) + # logger.info("properly tagged instance") + # logger.info(instance) + else: + pass + #logger.info(instance.id) + logger.info("Found "+str(len(nice_list))+" tagged instances in "+region) + # logger.info(nice_list) + return nice_list + +def get_tagged_instances(): + """ + Fetches a master list of instances across all regions, returns dictionary + that includes some identifying information as key-value pairs. + """ + global_tagged_instances = {} + for r in get_regions(): + #for r in ['us-east-1']: + client = boto3.client('ec2',region_name=r) + # Get our list of tagged instances + instance_ids = check_instance_tags(r) + if len(instance_ids) != 0: + response = client.describe_instances(InstanceIds=instance_ids) + # logger.info(response) + for reservation in response['Reservations']: + for instance in reservation['Instances']: + # In case we have no tags, default to None + name = None + owner = None + ttl = None + created_by = None + if 'Tags' in instance: + for tag in instance['Tags']: + if tag['Key'] == "Name": + name = tag['Value'] + if tag['Key'] == "owner" or tag['Key'] == "Owner": + owner = tag['Value'] + if tag['Key'] == "TTL" or tag['Key'] == "ttl": + if isInteger(tag['Value']): + ttl = tag['Value'] + else: + logger.info("Invalid TTL found: "+tag['Value']) + ttl = None + if tag['Key'] == "created-by": + created_by = tag['Value'] + # Add more data as you see fit. + if ttl: + global_tagged_instances[instance['InstanceId']] = { + 'InstanceType': instance['InstanceType'], + 'RegionName': r, + 'LaunchTime': str(instance['LaunchTime']), + 'State': instance['State']['Name'], + 'KeyName': instance.get('KeyName'), + 'Name': name, + 'Owner': owner, + 'TTL': ttl, + 'created-by': created_by + } + return global_tagged_instances + +def get_regions(): + """Returns a list of all AWS regions.""" + c = boto3.client('ec2') + regions = [region['RegionName'] for region in c.describe_regions()['Regions']] + return regions + +def isInteger(s): + try: + int(s) + return True + except ValueError: + return False + +if __name__ == '__main__': + lambda_handler({}, {}) \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getTaggedInstances.zip b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getTaggedInstances.zip new file mode 100644 index 0000000..924d6b8 Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getTaggedInstances.zip differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getUntaggedInstances.py b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getUntaggedInstances.py new file mode 100644 index 0000000..6ee2da9 --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getUntaggedInstances.py @@ -0,0 +1,107 @@ +import boto3 +import json +import logging +import os + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def lambda_handler(event, context): + """Returns stringified JSON output to the requestor.""" + untagged = json.dumps(get_untagged_instances()) + #logger.info("I found "+str(len(get_untagged_instances()))+" untagged instances in this account.") + return untagged + +def check_instance_tags(region): + """Checks instances in a single region for required tags, returns list of instance ids.""" + ec2 = boto3.resource('ec2',region_name=region) + instances = ec2.instances.all() + # We should be able to check against this list: + mandatory_tags = os.environ.get("REQTAGS").split(",") + naughty_list = [] + for instance in instances: + if instance.tags: + # logger.info(instance.tags) + taglist = [] + for tag in instance.tags: + # Ensures that TTL is valid + if tag['Key'] == 'TTL' or tag['Key'] == 'ttl': + if isInteger(tag['Value']): + taglist.append(tag['Key'].upper()) + elif tag['Key'] == 'Owner' or tag['Key'] == 'owner': + taglist.append(tag['Key'].lower()) + else: + taglist.append(tag['Key']) + if set(mandatory_tags).issubset(set(taglist)): + pass + else: + naughty_list.append(instance.id) + else: + naughty_list.append(instance.id) + logger.info("Found "+str(len(naughty_list))+" untagged instances in "+region) + return naughty_list + +def get_untagged_instances(): + """ + Fetches a master list of instances across all regions, returns dictionary + that includes some identifying information as key-value pairs. + """ + global_untagged_instances = {} + for r in get_regions(): + #for r in ['us-east-1']: + client = boto3.client('ec2',region_name=r) + # Get our list of untagged instances + instance_ids = check_instance_tags(r) + if len(instance_ids) != 0: + response = client.describe_instances(InstanceIds=instance_ids) + #logger.info(response) + for reservation in response['Reservations']: + for instance in reservation['Instances']: + # In case we have no tags, default to None + name = None + owner = None + ttl = None + created_by = None + if 'Tags' in instance: + for tag in instance['Tags']: + if tag['Key'] == "Name": + name = tag['Value'] + if tag['Key'] == "owner" or tag['Key'] == "Owner": + owner = tag['Value'] + if tag['Key'] == "TTL" or tag['Key'] == "ttl": + if isInteger(tag['Value']): + ttl = tag['Value'] + else: + logger.info("Invalid TTL found: "+tag['Value']) + ttl = 'None' + if tag['Key'] == "created-by": + created_by = tag['Value'] + # Add more data as you see fit. + global_untagged_instances[instance['InstanceId']] = { + 'InstanceType': instance['InstanceType'], + 'RegionName': r, + 'LaunchTime': str(instance['LaunchTime']), + 'State': instance['State']['Name'], + 'KeyName': instance.get('KeyName'), + 'Name': name, + 'Owner': owner, + 'TTL': ttl, + 'created-by': created_by + } + return global_untagged_instances + +def get_regions(): + """Returns a list of all AWS regions.""" + c = boto3.client('ec2') + regions = [region['RegionName'] for region in c.describe_regions()['Regions']] + return regions + +def isInteger(s): + try: + int(s) + return True + except ValueError: + return False + +if __name__ == '__main__': + lambda_handler({}, {}) \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getUntaggedInstances.zip b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getUntaggedInstances.zip new file mode 100644 index 0000000..a75ceec Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/getUntaggedInstances.zip differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_decrypt_kms.tpl b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_decrypt_kms.tpl new file mode 100644 index 0000000..2631084 --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_decrypt_kms.tpl @@ -0,0 +1,14 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": [ + "${kmskey}" + ] + } + ] +} \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_notify.tpl b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_notify.tpl new file mode 100644 index 0000000..1d53dd8 --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_notify.tpl @@ -0,0 +1,23 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:aws:logs:${region}:${account_id}:*" + }, + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction", + "ses:SendEmail", + "ses:SendRawEmail" + ], + "Resource": "*" + } + ] +} \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_read_instances.tpl b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_read_instances.tpl new file mode 100644 index 0000000..5ed7baf --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_read_instances.tpl @@ -0,0 +1,44 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "logs:CreateLogGroup", + "Resource": "arn:aws:logs:${region}:${account_id}:*" + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": [ + "arn:aws:logs:${region}:${account_id}:*" + ] + }, + { + "Effect": "Allow", + "Action": "ec2:Describe*", + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": "elasticloadbalancing:Describe*", + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "cloudwatch:ListMetrics", + "cloudwatch:GetMetricStatistics", + "cloudwatch:Describe*" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": "autoscaling:Describe*", + "Resource": "*" + } + ] +} \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_stop_and_terminate_instances.tpl b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_stop_and_terminate_instances.tpl new file mode 100644 index 0000000..2ff0f1a --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/iam_lambda_stop_and_terminate_instances.tpl @@ -0,0 +1,30 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "logs:CreateLogGroup", + "Resource": "arn:aws:logs:${region}:${account_id}:*" + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": [ + "arn:aws:logs:${region}:${account_id}:*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction", + "ses:SendEmail", + "ses:SendRawEmail", + "ec2:*" + ], + "Resource": "*" + } + ] +} \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyInstanceUsage.py b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyInstanceUsage.py new file mode 100644 index 0000000..929bf64 --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyInstanceUsage.py @@ -0,0 +1,165 @@ +# General purpose Lambda function for sending Slack messages, encrypted in transit. + +import boto3 +from botocore.exceptions import ClientError +import json +import logging +import os +import csv +import io +from collections import Counter + +# Required if you want to encrypt your Slack Hook URL in the AWS console +# from base64 import b64decode +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + +SLACK_CHANNEL = os.environ['slackChannel'] +# Required if you want to encrypt your Slack Hook URL in the AWS console +# ENCRYPTED_HOOK_URL = os.environ['slackHookUrl'] +# HOOK_URL = boto3.client('kms').decrypt(CiphertextBlob=b64decode(os.environ['slackHookUrl']))['Plaintext'].decode('utf-8') +HOOK_URL = os.environ['slackHookUrl'] + +############################################################################ +# These settings are only required if you are using email for notifications. +SENDER = "Cleanup Bot " +RECIPIENT = "robot@example.com" +AWS_REGION = "us-west-2" +CHARSET = "UTF-8" + +# This is used as the subject for Slack messages or emails. +SUBJECT = "AWS Instance Usage Report" +############################################################################ + +logger = logging.getLogger() +logger.setLevel(logging.INFO) +lam = boto3.client('lambda') + +def lambda_handler(event, context): + """Sends out a formatted slack message. Edit to your liking.""" + + msg_text = 'Good morning humanoids. Here is your AWS instance type usage report:' + running = get_running_instances() + report = generate_instance_report(running) + + # Uncomment send_email to use email instead of slack + # send_email( + # SENDER, + # RECIPIENT, + # AWS_REGION, + # SUBJECT, + # report, + # CHARSET) + + send_slack_message( + msg_text, + title=SUBJECT, + text="```\n"+report+"\n```", + fallback=SUBJECT, + color='warning', + actions = [ + { + "type": "button", + "text": ":money-burning: AWS Cost Explorer", + "url": "http://amzn.to/2EBAfQu" + }, + { + "type": "button", + "text": ":broom: AWS Console", + "url": "https://console.aws.amazon.com/ec2/v2/home" + }, + ] + ) + +def get_running_instances(): + """Calls the Lambda function that returns a dictionary of instances.""" + try: + response = lam.invoke(FunctionName='getRunningInstances', InvocationType='RequestResponse') + except Exception as e: + print(e) + raise e + return response + +def generate_instance_report(response): + """Generates a list showing a tally of instance types in use.""" + data = json.loads(response['Payload'].read().decode('utf-8')) + data = json.loads(data) + instance_types = [] + for key, value in data.items(): + instance_types.append(value['InstanceType']) + counted_types = dict(Counter(instance_types)) + tmp = io.StringIO() + writer = csv.writer(tmp, delimiter='\t') + # This is a fancy way to say 'return # of each instance type in descending order'. + for key, value in sorted(counted_types.items(), key=lambda x: x[1], reverse=True): + writer.writerow(["{: >2}".format(value), key]) + results = tmp.getvalue() + # To keep things simple we make sure these functions always return a string. + return(results) + +def send_email(sender,recipient,aws_region,subject,body_text,charset): + """ + Sends a plaintext email to the address of your choice. Be sure to + verify your email in the SES control panel first. More documentation + here: https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-using-sdk-python.html + """ + + # Create a new SES resource and specify a region. + client = boto3.client('ses',region_name=aws_region) + + # Try to send the email. + try: + #Provide the contents of the email. + response = client.send_email( + Destination={ + 'ToAddresses': [ + recipient, + ], + }, + Message={ + 'Body': { + 'Text': { + 'Charset': charset, + 'Data': body_text, + }, + }, + 'Subject': { + 'Charset': charset, + 'Data': subject, + }, + }, + Source=sender + ) + # Display an error if something goes wrong. + except ClientError as e: + print(e.response['Error']['Message']) + else: + print("Email sent! Message ID:"), + print(response['ResponseMetadata']['RequestId']) + +def send_slack_message(msg_text, **kwargs): + """Sends a slack message to the slackChannel you specify. The only parameter + required here is msg_text, or the main message body text. If you want to + format your message use the attachment feature which is documented here: + https://api.slack.com/docs/messages. You simply pass in your attachment + parameters as keyword arguments, or key-value pairs. This function currently + only supports a single attachment for simplicity's sake. + """ + slack_message = { + 'channel': SLACK_CHANNEL, + 'text': msg_text, + 'attachments': [ kwargs ] + } + + req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8')) + try: + response = urlopen(req) + response.read() + logger.info("Message posted to %s", slack_message['channel']) + except HTTPError as e: + logger.error("Request failed: %d %s", e.code, e.reason) + except URLError as e: + logger.error("Server connection failed: %s", e.reason) + +if __name__ == '__main__': + lambda_handler({}, {}) \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyInstanceUsage.zip b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyInstanceUsage.zip new file mode 100644 index 0000000..b7f0d7f Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyInstanceUsage.zip differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyUntaggedInstances.py b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyUntaggedInstances.py new file mode 100644 index 0000000..f8b4921 --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyUntaggedInstances.py @@ -0,0 +1,169 @@ +# General purpose Lambda function for sending Slack messages, encrypted in transit. + +import boto3 +from botocore.exceptions import ClientError +import json +import logging +import os +import csv +import io +from collections import Counter + +# Required if you want to encrypt your Slack Hook URL in the AWS console +# from base64 import b64decode +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + +SLACK_CHANNEL = os.environ['slackChannel'] +# Required if you want to encrypt your Slack hook URL in the AWS Console +# ENCRYPTED_HOOK_URL = os.environ['slackHookUrl'] +# HOOK_URL = boto3.client('kms').decrypt(CiphertextBlob=b64decode(os.environ['slackHookUrl']))['Plaintext'].decode('utf-8') +HOOK_URL = os.environ['slackHookUrl'] + +############################################################################ +# These settings are only required if you are using email for notifications. +SENDER = "Cleanup Bot " +RECIPIENT = "robot@example.com" +AWS_REGION = "us-west-2" +CHARSET = "UTF-8" + +# This is used as the subject for Slack messages or emails. +SUBJECT = "The Wall Of Shame :shame: :bell:" +############################################################################ + +logger = logging.getLogger() +logger.setLevel(logging.INFO) +lam = boto3.client('lambda') + +def lambda_handler(event, context): + """Sends out a formatted slack message. Edit to your liking.""" + + msg_text = 'Hello humans. Some of you have not tagged your AWS instances yet.' + leaderboard_length = 15 + untagged = get_untagged_instances() + lb = generate_leaderboard(untagged, leaderboard_length) + + # Uncomment send_email to use email instead of slack + # send_email( + # SENDER, + # RECIPIENT, + # AWS_REGION, + # SUBJECT, + # lb, + # CHARSET) + + send_slack_message( + msg_text, + title=SUBJECT, + text="```\n"+lb+"\n```", + fallback=SUBJECT, + color='warning', + actions = [ + { + "type": "button", + "text": ":mag: Find my stuff", + "url": "https://hashicorp.slack.com/files/U8QGF2A30/F8ZNCRP41/show_all_instances_sh.sh" + }, + { + "type": "button", + "text": ":broom: Clean up my stuff", + "url": "https://console.aws.amazon.com/ec2/v2/home" + }, + ] + ) + +def send_slack_message(msg_text, **kwargs): + """Sends a slack message to the slackChannel you specify. The only parameter + required here is msg_text, or the main message body text. If you want to + format your message use the attachment feature which is documented here: + https://api.slack.com/docs/messages. You simply pass in your attachment + parameters as keyword arguments, or key-value pairs. This function currently + only supports a single attachment for simplicity's sake. + """ + slack_message = { + 'channel': SLACK_CHANNEL, + 'text': msg_text, + 'attachments': [ kwargs ] + } + + req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8')) + try: + response = urlopen(req) + response.read() + logger.info("Message posted to %s", slack_message['channel']) + except HTTPError as e: + logger.error("Request failed: %d %s", e.code, e.reason) + except URLError as e: + logger.error("Server connection failed: %s", e.reason) + +def send_email(sender,recipient,aws_region,subject,body_text,charset): + """ + Sends a plaintext email to the address of your choice. Be sure to + verify your email in the SES control panel first. More documentation + here: https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-using-sdk-python.html + """ + + # Create a new SES resource and specify a region. + client = boto3.client('ses',region_name=aws_region) + + # Try to send the email. + try: + #Provide the contents of the email. + response = client.send_email( + Destination={ + 'ToAddresses': [ + recipient, + ], + }, + Message={ + 'Body': { + 'Text': { + 'Charset': charset, + 'Data': body_text, + }, + }, + 'Subject': { + 'Charset': charset, + 'Data': subject, + }, + }, + Source=sender + ) + # Display an error if something goes wrong. + except ClientError as e: + print(e.response['Error']['Message']) + else: + print("Email sent! Message ID:"), + print(response['ResponseMetadata']['RequestId']) + +def get_untagged_instances(): + """Calls the Lambda function that returns a dictionary of instances.""" + try: + response = lam.invoke(FunctionName='getUntaggedInstances', InvocationType='RequestResponse') + except Exception as e: + print(e) + raise e + return response + +def generate_leaderboard(response,num_leaders): + """Generates a leaderboard showing KeyNames with the most untagged instances.""" + data = json.loads(response['Payload'].read().decode('utf-8')) + data = json.loads(data) + sshkeys = [] + for key, value in data.items(): + sshkeys.append(value['KeyName']) + leaders = dict(Counter(sshkeys)) + tmp = io.StringIO() + writer = csv.writer(tmp, delimiter='\t') + count=0 + # This is a fancy way to say 'return leaders in reverse numerical order'. + for key, value in sorted(leaders.items(), key=lambda x: x[1], reverse=True): + if count < num_leaders: + writer.writerow(["{: >2}".format(value), (key or 'No KeyName')]) + count = count + 1 + leaderboard = tmp.getvalue() + # To keep things simple we make sure these functions always return a string. + return(leaderboard) + +if __name__ == '__main__': + lambda_handler({}, {}) \ No newline at end of file diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyUntaggedInstances.zip b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyUntaggedInstances.zip new file mode 100644 index 0000000..b081e0e Binary files /dev/null and b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/files/notifyUntaggedInstances.zip differ diff --git a/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/iam_roles.tf b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/iam_roles.tf new file mode 100644 index 0000000..01c1cff --- /dev/null +++ b/course/infrastructure-as-code/aws-lambda-ec2-lifecycles/iam_roles.tf @@ -0,0 +1,113 @@ +# IAM roles to allow Lambda functions to access different AWS resources. + +# Fetch our own account id and region. Used in our IAM policy templates. +data "aws_caller_identity" "current" {} + +# Template for our 'notify' lambda IAM policy +data "template_file" "iam_lambda_notify" { + template = "${file("./files/iam_lambda_notify.tpl")}" + + vars { + account_id = "${data.aws_caller_identity.current.account_id}" + region = "${var.region}" + } +} + +# Template for our 'read_instances' lambda IAM policy +data "template_file" "iam_lambda_read_instances" { + template = "${file("./files/iam_lambda_read_instances.tpl")}" + + vars { + account_id = "${data.aws_caller_identity.current.account_id}" + region = "${var.region}" + } +} + +# Template for our 'stop_and_terminate_instances' lambda IAM policy +data "template_file" "iam_lambda_stop_and_terminate_instances" { + template = "${file("./files/iam_lambda_stop_and_terminate_instances.tpl")}" + + vars { + account_id = "${data.aws_caller_identity.current.account_id}" + region = "${var.region}" + } +} + +# Role for our 'notify' lambda to assume +# This role is allowed to use the data collector lambda functions. +resource "aws_iam_role" "lambda_notify" { + name = "lambda_notify" + assume_role_policy = < +export VAULT_TOKEN= +terraform init +terraform plan +terraform apply +``` +If using Terraform Enterprise, do the following: + +1. Create a workspace in an organization connected to Github.com with an OAuth app and connect your workspace to this repository or a one containing the same code. +1. Set the VAULT_ADDR and VAULT_TOKEN environment variables on the workspace. +1. Click the "Queue Plan" button in the workspace. +1. Verify that the Plan does not give any errors. +1. Click the "Confirm and Apply" button to dynamically generate your AWS keys and provision your VPC with them. + +## Cleanup +If using Terraform Open Source, execute `terraform destroy`. + +If using Terraform Enterprise, add the environment variable "CONFIRM_DESTROY" with value 1 to your workspace and then click the "Queue destroy plan" button on the Settings tab of the workspace to queue the destruction of your VPC. After the plan finishes, click the "Confirm and Apply" button to destroy your VPC and associated resources. diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/main.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/main.tf new file mode 100644 index 0000000..080af54 --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/main.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 0.11.0" +} + +// Vault provider +// Set VAULT_ADDR and VAULT_TOKEN environment variables +provider "vault" {} + +// AWS credentials from Vault +data "vault_aws_access_credentials" "aws_creds" { + backend = "aws" + role = "deploy" +} + +// Setup the core provider information. +provider "aws" { + access_key = "${data.vault_aws_access_credentials.aws_creds.access_key}" + secret_key = "${data.vault_aws_access_credentials.aws_creds.secret_key}" + region = "${var.region}" +} + +data "aws_availability_zones" "main" {} diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-firewalls-ingress.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-firewalls-ingress.tf new file mode 100644 index 0000000..69501c6 --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-firewalls-ingress.tf @@ -0,0 +1,8 @@ +resource "aws_security_group_rule" "ssh" { + security_group_id = "${aws_security_group.egress_public.id}" + type = "ingress" + protocol = "tcp" + from_port = 22 + to_port = 22 + cidr_blocks = ["0.0.0.0/0"] +} diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-firewalls.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-firewalls.tf new file mode 100644 index 0000000..bbcf87d --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-firewalls.tf @@ -0,0 +1,23 @@ +resource "aws_security_group" "egress_public" { + name = "${var.environment_name}-egress_public" + description = "${var.environment_name}-egress_public" + vpc_id = "${aws_vpc.main.id}" +} + +resource "aws_security_group_rule" "egress_public" { + security_group_id = "${aws_security_group.egress_public.id}" + type = "egress" + protocol = "-1" + from_port = 0 + to_port = 0 + cidr_blocks = ["0.0.0.0/0"] +} + +resource "aws_security_group_rule" "ingress_internal" { + security_group_id = "${aws_security_group.egress_public.id}" + type = "ingress" + protocol = "-1" + from_port = 0 + to_port = 0 + self = "true" +} diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-gateways.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-gateways.tf new file mode 100644 index 0000000..0f17c05 --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-gateways.tf @@ -0,0 +1,20 @@ +resource "aws_internet_gateway" "main" { + vpc_id = "${aws_vpc.main.id}" + + tags { + Name = "${var.environment_name}" + } +} + +resource "aws_nat_gateway" "nat" { + count = "${length(var.vpc_cidrs_public)}" + + allocation_id = "${element(aws_eip.nat.*.id,count.index)}" + subnet_id = "${element(aws_subnet.public.*.id,count.index)}" +} + +resource "aws_eip" "nat" { + count = "${length(var.vpc_cidrs_public)}" + + vpc = true +} diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-routes.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-routes.tf new file mode 100644 index 0000000..958f9f8 --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-routes.tf @@ -0,0 +1,23 @@ +# +# Public +# +resource "aws_route_table" "public" { + vpc_id = "${aws_vpc.main.id}" + + route { + cidr_block = "0.0.0.0/0" + gateway_id = "${aws_internet_gateway.main.id}" + } + + tags { + Name = "${var.environment_name}-public" + } +} + +resource "aws_route_table_association" "public" { + count = "${length(var.vpc_cidrs_public)}" + + subnet_id = "${element(aws_subnet.public.*.id,count.index)}" + route_table_id = "${aws_route_table.public.id}" +} + diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-subnets.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-subnets.tf new file mode 100644 index 0000000..633db6a --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks-subnets.tf @@ -0,0 +1,12 @@ +resource "aws_subnet" "public" { + count = "${length(var.vpc_cidrs_public)}" + + vpc_id = "${aws_vpc.main.id}" + availability_zone = "${element(data.aws_availability_zones.main.names,count.index)}" + cidr_block = "${element(var.vpc_cidrs_public,count.index)}" + map_public_ip_on_launch = true + + tags { + Name = "${var.environment_name}-public-${count.index}" + } +} diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks.tf new file mode 100644 index 0000000..9149f5e --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/networks.tf @@ -0,0 +1,8 @@ +resource "aws_vpc" "main" { + cidr_block = "${var.vpc_cidr}" + enable_dns_hostnames = true + + tags { + Name = "${var.environment_name}" + } +} diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/outputs.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/outputs.tf new file mode 100644 index 0000000..ca26e2f --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/outputs.tf @@ -0,0 +1,12 @@ +# Outputs +output "vpc_id" { + value = "${aws_vpc.main.id}" +} + +output "subnet_public_ids" { + value = ["${aws_subnet.public.*.id}"] +} + +output "security_group_apps" { + value = "${aws_security_group.egress_public.id}" +} diff --git a/course/infrastructure-as-code/dynamic-aws-creds-from-vault/variables.tf b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/variables.tf new file mode 100644 index 0000000..5ebc086 --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds-from-vault/variables.tf @@ -0,0 +1,21 @@ +# Required variables +variable "environment_name" { + description = "Environment Name" + default = "Acme" +} + +variable "region" { + description = "AWS region" + default = "us-west-2" +} + +# Optional variables +variable "vpc_cidr" { + default = "172.19.0.0/16" +} + +variable "vpc_cidrs_public" { + default = [ + "172.19.0.0/20", + ] +} diff --git a/course/infrastructure-as-code/dynamic-aws-creds/README.md b/course/infrastructure-as-code/dynamic-aws-creds/README.md new file mode 100644 index 0000000..6de9fe0 --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds/README.md @@ -0,0 +1,823 @@ +# Dynamic AWS Credentials + +Using long lived static AWS credentials for Terraform runs can be dangerous. By leveraging the [Terraform Vault provider](https://www.terraform.io/docs/providers/vault/), you can generate short lived AWS credentials for each Terraform run that are automatically revoked after the run. + +[![Dynamic AWS Credentials Video](http://img.youtube.com/vi/0c3HUdZclTQ/0.jpg)](https://www.youtube.com/watch?v=0c3HUdZclTQ) + +## Reference Material +- [HashiCorp's Vault](https://www.vaultproject.io/) +- [Terraform Vault provider](https://www.terraform.io/docs/providers/vault/) +- [Vault AWS Secret Engine](https://www.vaultproject.io/docs/secrets/aws/index.html) + +## Estimated Time to Complete + +30 minutes + +## Personas + +There are 2 different personas involved in this guide, the "Producer" and the "Consumer". + +### Producer + +The "Producer" is the operator responsible for configuring the [AWS Secrets Engine](https://www.vaultproject.io/docs/secrets/aws/index.html) in Vault and defining the policy scope for the AWS credentials dynamically generated. + +The "Producer" is generally concerned about managing the static and long lived AWS IAM credentials with varying scope required for developers to provision infrastructure in AWS. + +### Consumer + +The "Consumer" is the developer looking to safely provision infrastructure using Terraform without having to worry about managing sensitive AWS credentials locally. + +## Challenge + +"Producers" want to enable a workflow where "Consumers" can automatically retrieve short-lived AWS credentials used by Terraform to provision resources in AWS. Traditionally this has been difficult to achieve as each "Consumer" has their own set of long-lived AWS credentials they use with Terraform that remain active beyond the length of a Terraform run. + +Long-lived AWS credentials with unbounded scope on developer's local machines creates a large attack surface. + +## Solution + +Store your long-lived AWS credentials in HashiCorp's Vault's [AWS Secrets Engine](https://www.vaultproject.io/docs/secrets/aws/index.html), then leverage [Terraform's Vault provider](https://www.terraform.io/docs/providers/vault/) to dynamically generate appropriately scoped & short-lived AWS credentials to be used by Terraform to provision resources in AWS. + +This mitigates the risk of someone swiping the AWS credentials used by Terraform from a developer's machine and doing something malicious with them. + +Following [Terraform Recommended Practices](https://www.terraform.io/docs/enterprise/guides/recommended-practices/index.html), we will separate our Terraform templates into 2 [Workspaces](https://www.terraform.io/docs/state/workspaces.html). One Workspace for our "Producer" persona, and one Workspace for our "Consumer" persona. We do this to separate concerns and ensure each persona only has access to the resources required for them to perform their job. + +The "Producer" will be responsible for configuring Vault's [AWS Secrets Engine](https://www.vaultproject.io/docs/secrets/aws/index.html) using Terraform and exposing the output variables necessary for the "Consumer" to provision the resources they need in AWS. In our use case, the "Consumer" will require access to provision an [AWS EC2 Instance](https://www.terraform.io/docs/providers/aws/r/instance.html) with Terraform, and should only be given IAM credentials with permission to do so. + +## Prerequisites + +1. [Download HashiCorp's Terraform](https://www.terraform.io/downloads.html) +1. [Download HashiCorp's Vault](https://www.vaultproject.io/downloads.html) + +## TL;DR + +Below are all of the consecutive commands that need to be run to complete this guide. + +```sh +# Start Vault server +$ vault server -dev -dev-root-token-id=root + +# Export env vars +export TF_VAR_aws_access_key=${AWS_ACCESS_KEY_ID} # AWS Access Key ID - This command assumes the AWS Access Key ID is set in your environment as AWS_ACCESS_KEY_ID +export TF_VAR_aws_secret_key=${AWS_SECRET_ACCESS_KEY} # AWS Secret Access Key - This command assumes the AWS Access Key ID is set in your environment as AWS_SECRET_ACCESS_KEY +export VAULT_ADDR=http://127.0.0.1:8200 # Address of Vault server +export VAULT_TOKEN=root # Vault token + +# Provision "Producer" Workspace Vault resources +$ cd producer-workspace +$ terraform init +$ terraform plan +$ terraform apply -auto-approve + +# Provision "Consumer" Workspace AWS resources +$ cd ../consumer-workspace +$ terraform init +$ terraform plan +$ terraform apply -auto-approve + +# Destroy "Consumer" Workspace EC2 Instance +$ terraform destroy --force + +# Update "Producer" AWS IAM Policy +$ cd ../producer-workspace +$ sed -i '' -e 's/, \"ec2:\*\"//g' main.tf +$ terraform plan +$ terraform apply -auto-approve + +# Verify "Consumer" cannot provision EC2 Instance +$ cd ../consumer-workspace +$ terraform plan +``` + +## Steps + +We will now walk through step by step instructions for how to dynamically generate appropriately scoped "Consumer" AWS credentials for each Terraform run. + +### Step 1: Start a Vault Server + +We will start by starting a Vault server. Open up a separate terminal window and run the below command. + +#### CLI + +- [Starting a Vault Dev Server](https://www.vaultproject.io/intro/getting-started/dev-server.html#starting-the-dev-server) + +##### Request + +```sh +# Start Vault server +$ vault server -dev + +# export env vars +export TF_VAR_aws_access_key=${AWS_ACCESS_KEY_ID} # AWS Access Key ID - This command assumes the AWS Access Key ID is set in your environment as AWS_ACCESS_KEY_ID +export TF_VAR_aws_secret_key=${AWS_SECRET_ACCESS_KEY} # AWS Secret Access Key - This command assumes the AWS Access Key ID is set in your environment asAWS_SECRET_ACCESS_KEY +export VAULT_ADDR=http://127.0.0.1:8200 # Address of the Vault server (e.g. `http://127.0.0.1:8200` if running locally) +export VAULT_TOKEN=root # Vault token the Vault provider will use to mount and configure the [Vault AWS secret backend](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html) and [Vault AWS secret backend role](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html) - In this case we grabbed the `Root Token` token output from the above Vault dev server logs + +# Provision "Producer" Workspace +$ cd producer-workspace +$ terraform init +$ terraform plan +$ terraform apply -auto-approve + +# Provision "Consumer" Workspace +$ cd ../consumer-workspace +$ terraform init +$ terraform plan +$ terraform apply -auto-approve +$ terraform destroy --force + +# Update "Producer" Workspace IAM Policy +$ cd ../producer-workspace +$ terraform plan + + +``` + +##### Response + +``` +==> Vault server configuration: + + Cgo: disabled + Cluster Address: https://127.0.0.1:8201 + Listener 1: tcp (addr: "127.0.0.1:8200", cluster address: "127.0.0.1:8201", tls: "disabled") + Log Level: info + Mlock: supported: false, enabled: false + Redirect Address: http://127.0.0.1:8200 + Storage: inmem + Version: Vault v0.9.3 + Version Sha: 5acd6a21d5a69ab49d0f7c0bf540123a9b2c696d + +WARNING! dev mode is enabled! In this mode, Vault runs entirely in-memory +and starts unsealed with a single unseal key. The root token is already +authenticated to the CLI, so you can immediately begin using Vault. + +You may need to set the following environment variable: + + $ export VAULT_ADDR='http://127.0.0.1:8200' + +The unseal key and root token are displayed below in case you want to +seal/unseal the Vault or re-authenticate. + +Unseal Key: vfFcgKeoHUoIDNUNqQsrzl6Y0kASr9AZ1QCnsd6tF2k= +Root Token: root + +Development mode should NOT be used in production installations! + +==> Vault server started! Log data will stream in below: + +2018/02/09 18:16:46.245058 [INFO ] core: security barrier not initialized +2018/02/09 18:16:46.246399 [INFO ] core: security barrier initialized: shares=1 threshold=1 +2018/02/09 18:16:46.247021 [INFO ] core: post-unseal setup starting +2018/02/09 18:16:46.258342 [INFO ] core: loaded wrapping token key +2018/02/09 18:16:46.258351 [INFO ] core: successfully setup plugin catalog: plugin-directory= +2018/02/09 18:16:46.259352 [INFO ] core: successfully mounted backend: type=kv path=secret/ +2018/02/09 18:16:46.259382 [INFO ] core: successfully mounted backend: type=cubbyhole path=cubbyhole/ +2018/02/09 18:16:46.259705 [INFO ] core: successfully mounted backend: type=system path=sys/ +2018/02/09 18:16:46.259866 [INFO ] core: successfully mounted backend: type=identity path=identity/ +2018/02/09 18:16:46.261878 [INFO ] expiration: restoring leases +2018/02/09 18:16:46.261885 [INFO ] rollback: starting rollback manager +2018/02/09 18:16:46.262925 [INFO ] expiration: lease restore complete +2018/02/09 18:16:46.263967 [INFO ] identity: entities restored +2018/02/09 18:16:46.263982 [INFO ] identity: groups restored +2018/02/09 18:16:46.264010 [INFO ] core: post-unseal setup complete +2018/02/09 18:16:46.264555 [INFO ] core: root token generated +2018/02/09 18:16:46.264559 [INFO ] core: pre-seal teardown starting +2018/02/09 18:16:46.264564 [INFO ] core: cluster listeners not running +2018/02/09 18:16:46.264578 [INFO ] rollback: stopping rollback manager +2018/02/09 18:16:46.264616 [INFO ] core: pre-seal teardown complete +2018/02/09 18:16:46.264697 [INFO ] core: vault is unsealed +2018/02/09 18:16:46.264708 [INFO ] core: post-unseal setup starting +2018/02/09 18:16:46.264748 [INFO ] core: loaded wrapping token key +2018/02/09 18:16:46.264750 [INFO ] core: successfully setup plugin catalog: plugin-directory= +2018/02/09 18:16:46.264873 [INFO ] core: successfully mounted backend: type=kv path=secret/ +2018/02/09 18:16:46.264944 [INFO ] core: successfully mounted backend: type=system path=sys/ +2018/02/09 18:16:46.265047 [INFO ] core: successfully mounted backend: type=identity path=identity/ +2018/02/09 18:16:46.265053 [INFO ] core: successfully mounted backend: type=cubbyhole path=cubbyhole/ +2018/02/09 18:16:46.265427 [INFO ] expiration: restoring leases +2018/02/09 18:16:46.265433 [INFO ] rollback: starting rollback manager +2018/02/09 18:16:46.265518 [INFO ] expiration: lease restore complete +2018/02/09 18:16:46.265522 [INFO ] identity: entities restored +2018/02/09 18:16:46.265541 [INFO ] identity: groups restored +2018/02/09 18:16:46.265549 [INFO ] core: post-unseal setup complete +``` + +### Step 2: Configure Environment Variables + +Terraform requires a few [Environment Variables](https://www.terraform.io/docs/configuration/variables.html#environment-variables) to be set in order to function appropriately. We're passing these in as env vars instead of [Terraform Input Variables](https://www.terraform.io/docs/configuration/variables.html) because they are sensitive and we don't want them committed to our VCS. + +Notice that we're also setting the required [Vault Provider Arguments](https://www.terraform.io/docs/providers/vault/index.html#provider-arguments) as env vars: `VAULT_ADDR` & `VAULT_TOKEN`. + +#### CLI + +##### Request + +```sh +export TF_VAR_aws_access_key=${AWS_ACCESS_KEY_ID} # AWS Access Key ID - This command assumes the AWS Access Key ID is set in your environment as AWS_ACCESS_KEY_ID +export TF_VAR_aws_secret_key=${AWS_SECRET_ACCESS_KEY} # AWS Secret Access Key - This command assumes the AWS Access Key ID is set in your environment as AWS_SECRET_ACCESS_KEY +export VAULT_ADDR=http://127.0.0.1:8200 # Address of the Vault server (e.g. `http://127.0.0.1:8200` if running locally) +export VAULT_TOKEN=root # Vault token the Vault provider will use to mount and configure the [Vault AWS secret backend](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html) and [Vault AWS secret backend role](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html) - In this case we grabbed the `Root Token` token output from the above Vault dev server logs +``` + +##### Response + +You can verify that these env vars were set appropriately by using `echo`. + +```sh +$ echo ${TF_VAR_aws_access_key} +ABCDEFGHIJKLMNOPQRST + +$ echo ${TF_VAR_aws_secret_key} +abcdefghijklmnopqrstuvwxyz12345678910987 + +$ echo ${VAULT_ADDR} +http://127.0.0.1:8200 + +$ echo ${VAULT_TOKEN} +root +``` + +### Step 3: "Producer" Workspace Init + +We will start by initializing the "Producer" Workspace. This will initialize Terraform and pull down the appropriate [Terraform providers](https://www.terraform.io/docs/providers/index.html) required by the declared resources. + +Be sure you are starting in the root directory of this repository. After running the command, notice Terraform fetches the [Vault](https://www.terraform.io/docs/providers/vault/index.html) provider. + +Take a look at the [producer-workspace/main.tf](producer-workspace/main.tf) Terraform template to see the Vault resources Terraform will configure. + +#### CLI + +- [terraform init](https://www.terraform.io/docs/commands/init.html) + +##### Request + +```sh +$ cd producer-workspace +$ terraform init +``` + +##### Response + +``` +Initializing the backend... + +Successfully configured the backend "local"! Terraform will automatically +use this backend unless the backend configuration changes. + +Initializing provider plugins... +- Checking for available provider plugins on https://releases.hashicorp.com... +- Downloading plugin for provider "vault" (1.0.0)... + +The following providers do not have any version constraints in configuration, +so the latest version was installed. + +To prevent automatic upgrades to new major versions that may contain breaking +changes, it is recommended to add version = "..." constraints to the +corresponding provider blocks in configuration, with the constraint strings +suggested below. + +* provider.vault: version = "~> 1.0" + +Terraform has been successfully initialized! + +You may now begin working with Terraform. Try running "terraform plan" to see +any changes that are required for your infrastructure. All Terraform commands +should now work. + +If you ever set or change modules or backend configuration for Terraform, +rerun this command to reinitialize your working directory. If you forget, other +commands will detect it and remind you to do so if necessary. +``` + +### Step 4: "Producer" Workspace Plan + +Run a `terraform plan` to inspect what Terraform is going to provision in the "Producer" Workspace. + +Notice that Terraform's plan is to mount the [AWS Secrets Engine](https://www.vaultproject.io/docs/secrets/aws/index.html) with a [`default_lease_ttl_seconds`](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html#default_lease_ttl_seconds) of `120` seconds, a [`max_lease_ttl_seconds`](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html#max_lease_ttl_seconds) of `240` seconds, and a policy that allows the AWS IAM credentials `iam:*` and `ec2:*` permissions. Any credentials read from this role will be dynamically generated with these attributes. + +#### CLI + +- [terraform plan](https://www.terraform.io/docs/commands/plan.html) + +##### Request + +```sh +$ terraform plan +``` + +##### Response + +``` +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + vault_aws_secret_backend.aws + id: + access_key: + default_lease_ttl_seconds: "120" + max_lease_ttl_seconds: "240" + path: "dynamic-aws-creds-producer-path" + region: + secret_key: + + + vault_aws_secret_backend_role.producer + id: + backend: "dynamic-aws-creds-producer-path" + name: "dynamic-aws-creds-producer-role" + policy: "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:*\",\n \"ec2:*\"\n ],\n \"Resource\": \"*\"\n }\n ]\n}\n" + + +Plan: 2 to add, 0 to change, 0 to destroy. + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +``` + +### Step 5: "Producer" Workspace Apply + +Run the `terraform apply` to actually provision the resources in the "Producer" Workspace. Based on the plan, we expect Terraform to... + +1. Use the AWS credentials supplied by the env vars in [Step 2](#step-2-configure-environment-variables) to mount the AWS Secret Engine in Vault under the path `dynamic-aws-creds-producer-path`. +2. Configure a role for the [AWS Secrets Engine](https://www.vaultproject.io/docs/secrets/aws/index.html) named `dynamic-aws-creds-producer-role` with an IAM policy that allows it `iam:*` and `ec2:*` permissions. + - This role will be used by the "Consumer" Workspace to dynamically generate AWS credentials scoped with this IAM policy to be used by Terraform to provision an [`aws_instance`](https://www.terraform.io/docs/providers/aws/r/instance.html) resource. + +#### CLI + +- [terraform apply](https://www.terraform.io/docs/commands/apply.html) + +##### Request + +```sh +$ terraform apply -auto-approve +``` + +Notice we added the `-auto-approve` switch. This tells Terraform to just run the apply without prompting us to verify we actually wanted to apply. + +##### Response + +``` +vault_aws_secret_backend.aws: Creating... + access_key: "" => "" + default_lease_ttl_seconds: "" => "120" + max_lease_ttl_seconds: "" => "240" + path: "" => "dynamic-aws-creds-producer-path" + region: "" => "" + secret_key: "" => "" +vault_aws_secret_backend.aws: Creation complete after 0s (ID: dynamic-aws-creds-producer-path) +vault_aws_secret_backend_role.producer: Creating... + backend: "" => "dynamic-aws-creds-producer-path" + name: "" => "dynamic-aws-creds-producer-role" + policy: "" => "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:*\",\n \"ec2:*\"\n ],\n \"Resource\": \"*\"\n }\n ]\n}\n" +vault_aws_secret_backend_role.producer: Creation complete after 0s (ID: dynamic-aws-creds-producer-path/roles/dynamic-aws-creds-producer-role) + +Apply complete! Resources: 2 added, 0 changed, 0 destroyed. + +The state of your infrastructure has been saved to the path +below. This state is required to modify and destroy your +infrastructure, so keep it safe. To inspect the complete state +use the `terraform show` command. + +State path: terraform.tfstate + +Outputs: + +backend = dynamic-aws-creds-producer-path +role = dynamic-aws-creds-producer-role +``` + +Notice there are 2 [Output Variables](https://www.terraform.io/intro/getting-started/outputs.html) named `backend` & `role`. These output variables will be used by the "Consumer" workspace in a later step. + +If you go to the terminal where your Vault server is running, you should see Vault output something similar to the below. This means Terraform was successfully able to mount the AWS Secrets Engine at the specified path. Although it's not output in the logs, the role has also been configured. + +``` +2018/02/10 19:23:37.072445 [INFO ] core: successful mount: path=dynamic-aws-creds-producer-path/ type=aws +``` + +### Step 6: "Consumer" Workspace Init + +Next we will initialize the "Consumer" Workspace, similar to what we did with the "Producer" Workspace. This Workspace will consume the outputs created in the "Producer" Workspace. + +Take a look at the [consumer-workspace/main.tf](consumer-workspace/main.tf) Terraform template to see the resources Terraform will provision. + +#### CLI + +- [terraform init](https://www.terraform.io/docs/commands/init.html) + +##### Request + +```sh +$ cd ../consumer-workspace +$ terraform init +``` + +##### Response + +``` +Initializing the backend... + +Successfully configured the backend "local"! Terraform will automatically +use this backend unless the backend configuration changes. + +Initializing provider plugins... +- Checking for available provider plugins on https://releases.hashicorp.com... +- Downloading plugin for provider "aws" (1.9.0)... +- Downloading plugin for provider "vault" (1.0.0)... + +The following providers do not have any version constraints in configuration, +so the latest version was installed. + +To prevent automatic upgrades to new major versions that may contain breaking +changes, it is recommended to add version = "..." constraints to the +corresponding provider blocks in configuration, with the constraint strings +suggested below. + +* provider.aws: version = "~> 1.9" +* provider.vault: version = "~> 1.0" + +Terraform has been successfully initialized! + +You may now begin working with Terraform. Try running "terraform plan" to see +any changes that are required for your infrastructure. All Terraform commands +should now work. + +If you ever set or change modules or backend configuration for Terraform, +rerun this command to reinitialize your working directory. If you forget, other +commands will detect it and remind you to do so if necessary. +``` + +### Step 7: "Consumer" Workspace Plan to Provision EC2 Instance + +First, login to your [AWS Console](https://console.aws.amazon.com) and navigate to the IAM `Users` tab. Search for the username prefix `vault-token-terraform-dynamic-aws-creds-producer`. Nothing will show up in your initial search, but we are now prepared to do a "Refresh" after we run a `terraform plan` to verify that the dynamic IAM credentials were in fact created by Vault and used by Terraform. + +In the [consumer-workspace/main.tf#L46-L55](consumer-workspace/main.tf#L46-L55) Terraform template we've defined an [`aws_instance`](https://www.terraform.io/docs/providers/aws/r/instance.html) to be provisioned. Assuming the credentials passed into the AWS provider have access to create the EC2 Instance resource, the plan should run successfully. + +Now run a `terraform plan` to inspect what Terraform is going to provision in the "Consumer" Workspace and verify a new set of IAM credentials were created after running the plan. + +The reason the IAM credentials were created is we have a [`vault_aws_access_credentials` Data Source](https://www.terraform.io/docs/providers/vault/d/aws_access_credentials.html) in our [consumer-workspace/main.tf#L19-L27](consumer-workspace/main.tf#L19-L27) Terraform template that is requesting the Vault provider to [read AWS IAM credentials](https://www.vaultproject.io/docs/secrets/aws/index.html#usage) from the role named `dynamic-aws-creds-producer-role` in Vault's [AWS Secrets Engine](https://www.vaultproject.io/docs/secrets/aws/index.html). + +These credentials are generated by Vault with the appropriate [IAM policy](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend_role.html#policy) configured in the [`vault_aws_secret_backend_role` role](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend_role.html) resource, and a [`default_lease`](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html#default_lease_ttl_seconds) and [`max_lease_ttl`](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html#max_lease_ttl_seconds) configured on the [AWS Secret Engine](https://www.terraform.io/docs/providers/vault/r/aws_secret_backend.html). These resources were configured by the "Producer" in the [producer-workspace/main.tf#L13-L40](producer-workspace/main.tf#L13-L40) Terraform template. + +Because the `default_lease_ttl_seconds ` is set to `120` seconds, Vault will revoke those IAM credentials and they will be removed from the AWS IAM console after `120` seconds. Every Terraform run moving forward will now use it's own unique set of AWS IAM credentials that are scoped to whatever the "Producer" has defined! + +#### CLI + +- [terraform plan](https://www.terraform.io/docs/commands/plan.html) + +##### Request + +```sh +$ terraform plan +``` + +##### Response + +``` +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +data.terraform_remote_state.producer: Refreshing state... +data.vault_aws_access_credentials.creds: Refreshing state... +data.aws_ami.ubuntu: Refreshing state... + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + aws_instance.main + id: + ami: "ami-a22323d8" + associate_public_ip_address: + availability_zone: + ebs_block_device.#: + ephemeral_block_device.#: + instance_state: + instance_type: "t2.nano" + ipv6_address_count: + ipv6_addresses.#: + key_name: + network_interface.#: + network_interface_id: + placement_group: + primary_network_interface_id: + private_dns: + private_ip: + public_dns: + public_ip: + root_block_device.#: + security_groups.#: + source_dest_check: "true" + subnet_id: + tags.%: "3" + tags.Name: "dynamic-aws-creds-consumer" + tags.TTL: "1h" + tags.owner: "dynamic-aws-creds-consumer-guide" + tenancy: + volume_tags.%: + vpc_security_group_ids.#: + + +Plan: 1 to add, 0 to change, 0 to destroy. + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +``` + +![Dynamic IAM Creds](assets/dynamic-iam-creds.png) + +![Dynamic IAM Creds Policy](assets/dynamic-iam-creds-iam-ec2-policy.png) + +### Step 8: "Consumer" Workspace Apply to Provision EC2 Instance + +Now that we've run a successful plan, the "Consumer" will actually want to provision the EC2 Instance in AWS. We should expect to see yet another set of IAM credentials named with a prefix of `vault-token-terraform-dynamic-aws-creds-producer` and an appropriately scoped IAM policy attached. + +These IAM creds will be dynamically generated by Vault and used for the AWS provider in Terraform to provision the [`aws_instance`](https://www.terraform.io/docs/providers/aws/r/instance.html) resource. You will be able to see this in the AWS EC2 dashboard by searching for `Instances` with the name `dynamic-aws-creds-consumer`. + +Just like the `terraform plan`, the short lived IAM credentials used by Terraform will be revoked after `120` seconds. + +#### CLI + +- [terraform apply](https://www.terraform.io/docs/commands/apply.html) + +##### Request + +```sh +$ terraform apply -auto-approve +``` + +Notice we added the `-auto-approve` switch. This tells Terraform to just run the apply with out prompting us to verify we actually wanted to apply. + +##### Response + +``` +data.terraform_remote_state.producer: Refreshing state... +data.vault_aws_access_credentials.creds: Refreshing state... +data.aws_ami.ubuntu: Refreshing state... +aws_instance.main: Creating... + ami: "" => "ami-a22323d8" + associate_public_ip_address: "" => "" + availability_zone: "" => "" + ebs_block_device.#: "" => "" + ephemeral_block_device.#: "" => "" + instance_state: "" => "" + instance_type: "" => "t2.nano" + ipv6_address_count: "" => "" + ipv6_addresses.#: "" => "" + key_name: "" => "" + network_interface.#: "" => "" + network_interface_id: "" => "" + placement_group: "" => "" + primary_network_interface_id: "" => "" + private_dns: "" => "" + private_ip: "" => "" + public_dns: "" => "" + public_ip: "" => "" + root_block_device.#: "" => "" + security_groups.#: "" => "" + source_dest_check: "" => "true" + subnet_id: "" => "" + tags.%: "" => "3" + tags.Name: "" => "dynamic-aws-creds-consumer" + tags.TTL: "" => "1h" + tags.owner: "" => "dynamic-aws-creds-consumer-guide" + tenancy: "" => "" + volume_tags.%: "" => "" + vpc_security_group_ids.#: "" => "" +aws_instance.main: Still creating... (10s elapsed) +aws_instance.main: Still creating... (20s elapsed) +aws_instance.main: Creation complete after 25s (ID: i-0c47c6d46f0a71fb8) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. + +The state of your infrastructure has been saved to the path +below. This state is required to modify and destroy your +infrastructure, so keep it safe. To inspect the complete state +use the `terraform show` command. + +State path: terraform.tfstate +``` + +Voila, our "Consumer" has successfully created the EC2 Instance resource without ever having long-lived AWS credentials locally. + +![EC2 Instance](assets/ec2-instance.png) + +### Step 9: "Consumer" to Destroy EC2 Instance + +Now let's cleanup the EC2 Instance created by Terraform. After destroying, you can check in the AWS Console to verify they were deleted. You should also have seen another set of IAM credentials get _generated_ to run the `terraform destroy` operation. + +#### CLI + +- [terraform destroy](https://www.terraform.io/docs/commands/destroy.html) + +##### Request + +```sh +$ terraform destroy --force +``` + +Notice we're using the `--force` switch to prevent Terraform from prompting us to verify it's what we want to do. + +##### Response + +``` +data.terraform_remote_state.producer: Refreshing state... +data.vault_aws_access_credentials.creds: Refreshing state... +data.aws_ami.ubuntu: Refreshing state... +aws_instance.main: Refreshing state... (ID: i-0c47c6d46f0a71fb8) +aws_instance.main: Destroying... (ID: i-0c47c6d46f0a71fb8) +aws_instance.main: Still destroying... (ID: i-0c47c6d46f0a71fb8, 10s elapsed) +aws_instance.main: Still destroying... (ID: i-0c47c6d46f0a71fb8, 20s elapsed) +aws_instance.main: Destruction complete after 21s + +Destroy complete! Resources: 1 destroyed. +``` + +### Step 10: "Producer" IAM Policy Update Plan + +Now let's say the "Producer" wanted to scope the "Consumers" IAM policy to only allow them to create `iam` resources with Terraform, but not `ec2`. + +Previously, this would have required them to revoke every "Consumers" IAM credentials and generate new ones with the updated policy. However, because we are dynamically generating IAM credentials for each Terraform run, the "Producer" simply has to update the IAM policy in their [producer-workspace/main.tf#L27-L38](producer-workspace/main.tf#L27-L38) Terraform template and they're done. + +To prove this, we will change the IAM policy in the [producer-workspace/main.tf](producer-workspace/main.tf#L27-L39) Terraform template from... + +``` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:*", "ec2:*" + ], + "Resource": "*" + } + ] +} +``` + +to... + +``` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:*" + ], + "Resource": "*" + } + ] +} +``` + +This means that any "Consumer" should now not be allowed to provision any AWS EC2 resources. + +#### CLI + +- [terraform plan](https://www.terraform.io/docs/commands/plan.html) + +##### Request + +```sh +$ cd ../producer-workspace +$ sed -i '' -e 's/, \"ec2:\*\"//g' main.tf +$ terraform plan +``` + +##### Response + +``` +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +vault_aws_secret_backend.aws: Refreshing state... (ID: dynamic-aws-creds-producer-path) +vault_aws_secret_backend_role.producer: Refreshing state... (ID: dynamic-aws-creds-producer-path/roles/dynamic-aws-creds-producer-role) + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + ~ update in-place + +Terraform will perform the following actions: + + ~ vault_aws_secret_backend_role.producer + policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"iam:*\",\"ec2:*\"],\"Resource\":\"*\"}]}" => "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:*\"\n ],\n \"Resource\": \"*\"\n }\n ]\n}\n" + + +Plan: 0 to add, 1 to change, 0 to destroy. + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +``` + +### Step 11: "Producer" Policy Update Apply + +We will now apply those changes and update Vault role's policy. + +#### CLI + +- [terraform apply](https://www.terraform.io/docs/commands/apply.html) + +##### Request + +```sh +$ terraform apply -auto-approve +``` + +##### Response + +``` +vault_aws_secret_backend.aws: Refreshing state... (ID: dynamic-aws-creds-producer-path) +vault_aws_secret_backend_role.producer: Refreshing state... (ID: dynamic-aws-creds-producer-path/roles/dynamic-aws-creds-producer-role) +vault_aws_secret_backend_role.producer: Modifying... (ID: dynamic-aws-creds-producer-path/roles/dynamic-aws-creds-producer-role) + policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"iam:*\",\"ec2:*\"],\"Resource\":\"*\"}]}" => "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:*\"\n ],\n \"Resource\": \"*\"\n }\n ]\n}\n" +vault_aws_secret_backend_role.producer: Modifications complete after 0s (ID: dynamic-aws-creds-producer-path/roles/dynamic-aws-creds-producer-role) + +Apply complete! Resources: 0 added, 1 changed, 0 destroyed. + +The state of your infrastructure has been saved to the path +below. This state is required to modify and destroy your +infrastructure, so keep it safe. To inspect the complete state +use the `terraform show` command. + +State path: terraform.tfstate + +Outputs: + +backend = dynamic-aws-creds-producer-path +role = dynamic-aws-creds-producer-role +``` + +### Step 12: "Consumer" Workspace Plan to Provision EC2 Instance + +Now we will verify the "Consumer" is _not_ able to provision an EC2 Instance as it should no longer have the ability to do so based on the updates the "Producer" made to the IAM policy. We should expect to see the `terraform plan` fail here as the credentials generated don't have permission to provision the [`aws_instance`](https://www.terraform.io/docs/providers/aws/r/instance.html) resource. + +Let's try it. + +#### CLI + +- [terraform plan](https://www.terraform.io/docs/commands/plan.html) + +##### Request + +```sh +$ cd ../consumer-workspace +$ terraform plan +``` + +##### Response + +``` +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +data.terraform_remote_state.producer: Refreshing state... +data.vault_aws_access_credentials.creds: Refreshing state... +data.aws_ami.ubuntu: Refreshing state... + +Error: Error refreshing state: 1 error(s) occurred: + +* data.aws_ami.ubuntu: 1 error(s) occurred: + +* data.aws_ami.ubuntu: data.aws_ami.ubuntu: UnauthorizedOperation: You are not authorized to perform this operation. + status code: 403, request id: 5f25a398-9417-4e16-9bae-f336d624e017 +``` + +As expected, our plan failed! The "Producer" would need to add the `ec2:*` permission back to the IAM policy for this plan to succeed. + +![Dynamic IAM Creds Policy](assets/dynamic-iam-creds-iam-policy.png) + +## Next Steps + +Play around with the "Producer" permissions and the "Consumer" resources to get a feel for how this can work for you. + +Once finished, run `terraform destroy` in each "Producer" and "Consumer" workspace to ensure all resources are cleaned up. + +You can take your security to the next level by leveraging Terraform Enterprise's [Secure Storage of Variables](https://www.terraform.io/docs/enterprise/workspaces/variables.html#secure-storage-of-variables) to safely store sensitive variables like the Vault token used for authentication. diff --git a/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds-iam-ec2-policy.png b/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds-iam-ec2-policy.png new file mode 100644 index 0000000..043193c Binary files /dev/null and b/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds-iam-ec2-policy.png differ diff --git a/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds-iam-policy.png b/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds-iam-policy.png new file mode 100644 index 0000000..17120fb Binary files /dev/null and b/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds-iam-policy.png differ diff --git a/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds.png b/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds.png new file mode 100644 index 0000000..a9f6563 Binary files /dev/null and b/course/infrastructure-as-code/dynamic-aws-creds/assets/dynamic-iam-creds.png differ diff --git a/course/infrastructure-as-code/dynamic-aws-creds/assets/ec2-instance.png b/course/infrastructure-as-code/dynamic-aws-creds/assets/ec2-instance.png new file mode 100644 index 0000000..b594429 Binary files /dev/null and b/course/infrastructure-as-code/dynamic-aws-creds/assets/ec2-instance.png differ diff --git a/course/infrastructure-as-code/dynamic-aws-creds/consumer-workspace/main.tf b/course/infrastructure-as-code/dynamic-aws-creds/consumer-workspace/main.tf new file mode 100644 index 0000000..7f1e6b0 --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds/consumer-workspace/main.tf @@ -0,0 +1,55 @@ +variable "name" { default = "dynamic-aws-creds-consumer" } +variable "path" { default = "../producer-workspace/terraform.tfstate" } +variable "ttl" { default = "1" } + +terraform { + backend "local" { + path = "terraform.tfstate" + } +} + +data "terraform_remote_state" "producer" { + backend = "local" + + config { + path = "${var.path}" + } +} + +data "vault_aws_access_credentials" "creds" { + backend = "${data.terraform_remote_state.producer.backend}" + role = "${data.terraform_remote_state.producer.role}" +} + +provider "aws" { + access_key = "${data.vault_aws_access_credentials.creds.access_key}" + secret_key = "${data.vault_aws_access_credentials.creds.secret_key}" +} + +data "aws_ami" "ubuntu" { + most_recent = true + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + owners = ["099720109477"] # Canonical +} + +# Create AWS EC2 Instance +resource "aws_instance" "main" { + ami = "${data.aws_ami.ubuntu.id}" + instance_type = "t2.nano" + + tags { + Name = "${var.name}" + TTL = "${var.ttl}" + owner = "${var.name}-guide" + } +} diff --git a/course/infrastructure-as-code/dynamic-aws-creds/producer-workspace/main.tf b/course/infrastructure-as-code/dynamic-aws-creds/producer-workspace/main.tf new file mode 100644 index 0000000..ceaf1c8 --- /dev/null +++ b/course/infrastructure-as-code/dynamic-aws-creds/producer-workspace/main.tf @@ -0,0 +1,48 @@ +variable "aws_access_key" { } +variable "aws_secret_key" { } +variable "name" { default = "dynamic-aws-creds-producer" } + +terraform { + backend "local" { + path = "terraform.tfstate" + } +} + +provider "vault" {} + +resource "vault_aws_secret_backend" "aws" { + access_key = "${var.aws_access_key}" + secret_key = "${var.aws_secret_key}" + path = "${var.name}-path" + + default_lease_ttl_seconds = "120" + max_lease_ttl_seconds = "240" +} + +resource "vault_aws_secret_backend_role" "producer" { + backend = "${vault_aws_secret_backend.aws.path}" + name = "${var.name}-role" + + policy = < + $ consul exec -node ${var.name}-server-nomad - <= 0100 || var.hashistack_vault_url != "") ? format("Vault UI: http://%s %s", module.hashistack_aws.vault_lb_dns, var.hashistack_public ? "(Public)" : "(Internal)") : "", + ), +))} + +You can SSH into the HashiStack node by updating the "PUBLIC_IP" and running the +below command. + + $ ${format("ssh -A -i %s %s@%s", module.ssh_keypair_aws.private_key_filename, module.hashistack_aws.hashistack_username, "PUBLIC_IP")} + +${module.hashistack_aws.zREADME} +# ------------------------------------------------------------------------------ +# HashiStack Dev - Vault Integration +# ------------------------------------------------------------------------------ + +If Vault is running in -dev mode using the in-mem storage backend (default), the +Vault integration for Nomad can be enabled by simply uncommenting the +"nomad_config_override" input variable in `terraform.auto.tfvars`. + +Alternatively, you can run the below commands to enable the integration. This +is the best method if you're overridding the default -dev mode configuration +with a storage backed other than in-mem (e.g. uncommenting +"vault_config_override" input variable in `terraform.auto.tfvars`). + +"disable_remote_exec" must be set to `false` in Consul for remote exec to +work, this can be achieved by uncommenting "consul_config_override" in +`terraform.auto.tfvars`. + +`VAULT_TOKEN` is automatically set to "root" for you if running in -dev mode +with the in-mem storage backend (default), otherwise you'll need to set this +to the root token generated during `vault operator init`. + + $ echo $VAULT_TOKEN + $ export VAULT_TOKEN= + $ consul exec -service nomad - <), you can create the following SSH tunnels from your machine after adding the key to your keychain/identity (above): + +``` +$ ssh -L 8200::8200 azure-user@ +$ ssh -L 8500::8500 azure-user@ +``` + +**Credits/Contacts:** Nico Corrarello, Chad Armitstead and Teddy Sacilowski diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/_interface.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/_interface.tf new file mode 100644 index 0000000..183910e --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/_interface.tf @@ -0,0 +1,96 @@ +# Required variables + +variable "custom_image_id" { + type = "string" + description = "Azure image ID for custom Packer image" +} + +variable "auto_join_subscription_id" { + type = "string" +} + +variable "auto_join_client_id" { + type = "string" +} + +variable "auto_join_client_secret" { + type = "string" +} + +variable "auto_join_tenant_id" { + type = "string" +} + +# Optional variables +variable "environment_name" { + default = "consul" + description = "Environment Name" +} + +variable "location" { + default = "West US" + description = "Region to deploy consul cluster to, e.g. West US" +} + +/* +variable "network_cidrs_public" { + default = [ + "172.31.0.0/20", + "172.31.16.0/20", + "172.31.32.0/20", + ] +} +*/ + +variable "network_cidrs_public" { + default = [ + "172.31.0.0/20", + ] +} + +variable "network_cidrs_private" { + default = [ + "172.31.48.0/20", + "172.31.64.0/20", + "172.31.80.0/20", + ] +} + +variable "cluster_size" { + default = "3" + description = "Number of instances to launch in the cluster" +} + +variable "consul_datacenter" { + default = "consul-westus" + description = "Name to tag all cluster members with; this is used to auto-join members, e.g. 'consul-westus'" +} + +variable "consul_vm_size" { + default = "Standard_A0" + description = "Azure virtual machine size for Consul cluster" +} + +variable "os" { + # Case sensitive + # As of 20-JUL-2017, the RHEL images on Azure do not support cloud-init, so + # we have disabled support for RHEL on Azure until it is available. + # https://docs.microsoft.com/en-us/azure/virtual-machines/linux/using-cloud-init + default = "ubuntu" + + description = "Operating System to use (only 'ubuntu' for now)" +} + +variable "private_key_filename" { + default = "private_key.pem" + description = "Name of the SSH private key" +} + +# Outputs +output "jumphost_ssh_connection_strings" { + value = "${formatlist("ssh-add %s && ssh -A -i %s %s@%s", var.private_key_filename, var.private_key_filename, module.network.jumphost_username, module.network.jumphost_ips_public)}" +} + +output "consul_private_ips" { + value = "${formatlist("ssh %s@%s", module.consul_azure.os_user, module.consul_azure.consul_private_ips)}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/env.sh b/course/infrastructure-as-code/hashistack/dev/terraform-azure/env.sh new file mode 100644 index 0000000..7e45b45 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/env.sh @@ -0,0 +1,8 @@ +# Exporting variables in both cases just in case, no pun intended +export ARM_SUBSCRIPTION_ID="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +export ARM_CLIENT_ID="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +export ARM_CLIENT_SECRET="cccccccc-cccc-cccc-cccc-cccccccccccc" +export ARM_TENANT_ID="dddddddd-dddd-dddd-dddd-dddddddddddd" +export subscription_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +export client_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +export client_secret="cccccccc-cccc-cccc-cccc-cccccccccccc" diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/main.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/main.tf new file mode 100644 index 0000000..8c5e904 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/main.tf @@ -0,0 +1,46 @@ +terraform { + required_version = ">= 0.10.1" +} + +provider "azurerm" {} + +resource "azurerm_resource_group" "main" { + name = "${var.environment_name}" + location = "${var.location}" +} + +module "ssh_key" { + source = "modules/ssh-keypair-data" + + private_key_filename = "${var.private_key_filename}" +} + +module "network" { + source = "modules/network-azure" + environment_name = "${var.environment_name}" + resource_group_name = "${azurerm_resource_group.main.name}" + location = "${var.location}" + network_cidrs_private = "${var.network_cidrs_private}" + network_cidrs_public = "${var.network_cidrs_public}" + os = "${var.os}" + public_key_data = "${module.ssh_key.public_key_data}" +} + +module "consul_azure" { + source = "modules/consul-azure" + resource_group_name = "${azurerm_resource_group.main.name}" + environment_name = "${var.environment_name}" + location = "${var.location}" + cluster_size = "${var.cluster_size}" + consul_datacenter = "${var.consul_datacenter}" + custom_image_id = "${var.custom_image_id}" + os = "${var.os}" + vm_size = "${var.consul_vm_size}" + private_subnet_ids = ["${module.network.subnet_private_ids}"] + network_cidrs_private = ["${var.network_cidrs_private}"] + public_key_data = "${module.ssh_key.public_key_data}" + auto_join_subscription_id = "${var.auto_join_subscription_id}" + auto_join_tenant_id = "${var.auto_join_tenant_id}" + auto_join_client_id = "${var.auto_join_client_id}" + auto_join_client_secret = "${var.auto_join_client_secret}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/_interface.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/_interface.tf new file mode 100644 index 0000000..1fb5771 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/_interface.tf @@ -0,0 +1,70 @@ +# Required variables +variable "resource_group_name" { + description = "Azure Resource Group to provision resources into" +} + +variable "environment_name" { + description = "Environment name (used for tagging purposes)" +} + +variable "location" { + description = "Region to deploy consul cluster to, e.g. West US" +} + +variable "cluster_size" { + description = "Number of instances to launch in the cluster" +} + +variable "consul_datacenter" { + description = "Name to apply to the Consul cluster (used for tagging and auto-join purposes)" +} + +variable "os" { + type = "string" +} + +variable "custom_image_id" { + description = "The Azure managed image ID to use in the scale set" +} + +variable "vm_size" { + description = "Azure virtual machine size" +} + +variable "network_cidrs_private" { + type = "list" +} + +variable "private_subnet_ids" { + type = "list" + description = "ID(s) of pre-existing private subnet(s) ID where the scale set should be created" +} + +variable "public_key_data" { + type = "string" +} + +variable "auto_join_subscription_id" { + type = "string" +} + +variable "auto_join_client_id" { + type = "string" +} + +variable "auto_join_client_secret" { + type = "string" +} + +variable "auto_join_tenant_id" { + type = "string" +} + +# Outputs +output "consul_private_ips" { + value = ["${azurerm_network_interface.consul.*.private_ip_address}"] +} + +output "os_user" { + value = "${module.images.os_user}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/init-cluster.tpl b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/init-cluster.tpl new file mode 100644 index 0000000..c5a249b --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/init-cluster.tpl @@ -0,0 +1,34 @@ +#!/bin/bash +set -x +exec > >(tee /var/log/user-data.log|logger -t user-data ) 2>&1 + +local_ipv4="$(echo -e `hostname -I` |awk '{print $1}' | tr -d '[:space:]')" + +# stop consul so it can be configured correctly +systemctl stop consul + +# clear the consul data directory ready for a fresh start +rm -rf /opt/consul/data/* + +# seeing failed nodes listed in consul members with their solo config +# try a 2 min sleep to see if it helps with all instances wiping data +# in a similar time window +#sleep 120 + +jq ".retry_join += [\"provider=azure tag_name=consul_datacenter tag_value=${consul_datacenter} subscription_id=${auto_join_subscription_id} tenant_id=${auto_join_tenant_id} client_id=${auto_join_client_id} secret_access_key=${auto_join_secret_access_key}\"]" < /etc/consul.d/consul-default.json > /tmp/consul-default.json.tmp + +sed -i -e "s/127.0.0.1/$${local_ipv4}/" /tmp/consul-default.json.tmp +mv /tmp/consul-default.json.tmp /etc/consul.d/consul-default.json +chown consul:consul /etc/consul.d/consul-default.json + +# add the cluster instance count to the config with jq +jq ".bootstrap_expect = ${cluster_size}" < /etc/consul.d/consul-server.json > /tmp/consul-server.json.tmp + +# change 'leave_on_terminate' to false for server nodes (this is the default but we had it set to true to quickly remove nodes before configuring) +jq ".leave_on_terminate = false" < /etc/consul.d/consul-server.json > /tmp/consul-server.json.tmp + +mv /tmp/consul-server.json.tmp /etc/consul.d/consul-server.json +chown consul:consul /etc/consul.d/consul-server.json + +# start consul once it is configured correctly +systemctl start consul diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/instances-consul.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/instances-consul.tf new file mode 100644 index 0000000..99c7efe --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/instances-consul.tf @@ -0,0 +1,79 @@ +resource "azurerm_virtual_machine" "consul" { + count = "${length(var.network_cidrs_private)}" + + name = "${var.consul_datacenter}-${count.index}" + location = "${var.location}" + resource_group_name = "${var.resource_group_name}" + network_interface_ids = ["${element(azurerm_network_interface.consul.*.id,count.index)}"] + vm_size = "${var.vm_size}" + + # Uncomment this line to delete the OS disk automatically when deleting the VM + delete_os_disk_on_termination = true + + # Uncomment this line to delete the data disks automatically when deleting the VM + delete_data_disks_on_termination = true + + storage_image_reference { + id = "${var.custom_image_id}" + } + + storage_os_disk { + name = "${var.consul_datacenter}-${count.index}" + caching = "ReadWrite" + create_option = "FromImage" + managed_disk_type = "Standard_LRS" + } + + os_profile { + computer_name = "${var.consul_datacenter}-${count.index}" + admin_username = "${module.images.os_user}" + admin_password = "none" + custom_data = "${base64encode(data.template_file.init.rendered)}" + } + + os_profile_linux_config { + disable_password_authentication = true + + ssh_keys { + path = "/home/${module.images.os_user}/.ssh/authorized_keys" + key_data = "${var.public_key_data}" + } + } + + tags { + environment_name = "${var.environment_name}" + consul_datacenter = "${var.consul_datacenter}" + } +} + +resource "azurerm_network_interface" "consul" { + count = "${length(var.network_cidrs_private)}" + + name = "${var.consul_datacenter}-${count.index}" + location = "${var.location}" + resource_group_name = "${var.resource_group_name}" + + ip_configuration { + name = "${var.consul_datacenter}-${count.index}" + subnet_id = "${element(var.private_subnet_ids,count.index)}" + private_ip_address_allocation = "dynamic" + } + + tags { + environment_name = "${var.environment_name}" + consul_datacenter = "${var.consul_datacenter}" + } +} + +data "template_file" "init" { + template = "${file("${path.module}/init-cluster.tpl")}" + + vars = { + cluster_size = "${var.cluster_size}" + consul_datacenter = "${var.consul_datacenter}" + auto_join_subscription_id = "${var.auto_join_subscription_id}" + auto_join_tenant_id = "${var.auto_join_tenant_id}" + auto_join_client_id = "${var.auto_join_client_id}" + auto_join_secret_access_key = "${var.auto_join_client_secret}" + } +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/main.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/main.tf new file mode 100644 index 0000000..efbe4dc --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/consul-azure/main.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 0.10.1" +} + +module "images" { + source = "../images-azure" + + os = "${var.os}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/images-azure/_interface.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/images-azure/_interface.tf new file mode 100644 index 0000000..e6f22b2 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/images-azure/_interface.tf @@ -0,0 +1,71 @@ +// +// Variables +// +variable "os" { + type = "string" +} + +// +// Variables w/ Defaults +// +variable "user" { + default = "azure-user" +} + +################################################################ +# NOTE!! +# +# As of 2017/03/17, the RHEL images on Azure do not support cloud-init, so +# we specifically disabled support for RHEL on Azure until cloud-init is +# available. +################################################################ +variable "publisher_map" { + default = { + #rhel = "RedHat" + ubuntu = "Canonical" + } +} + +variable "offer_map" { + default = { + #rhel = "RHEL" + ubuntu = "UbuntuServer" + } +} + +variable "sku_map" { + default = { + #rhel = "7.3" + ubuntu = "16.04-LTS" + } +} + +variable "version_map" { + default = { + #rhel = "latest" + ubuntu = "latest" + } +} + +// +// Outputs +// +output "os_user" { + value = "${var.user}" +} + +output "base_publisher" { + value = "${lookup(var.publisher_map,var.os)}" +} + +output "base_offer" { + value = "${lookup(var.offer_map,var.os)}" +} + +output "base_sku" { + value = "${lookup(var.sku_map,var.os)}" +} + +output "base_version" { + value = "${lookup(var.version_map,var.os)}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/README.md b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/README.md new file mode 100644 index 0000000..d2643d9 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/README.md @@ -0,0 +1,42 @@ +# network-azure + +Creates a standard network with: +* Three public subnets +* Three private subnets +* One jumphost in each public subnet CIDR + * The default is 3 but this can be controlled by the number of CIDRs passed into `var.network_cidrs_public` + +## Requirements + +The following environment variables must be set: + +``` +AZURE_CLIENT_ID +AZURE_CLIENT_SECRET +AZURE_SUBSCRIPTION_ID +AZURE_TENANT_ID +``` + +## Usage + +``` +resource "azurerm_resource_group" "main" { + name = "${var.environment_name}" + location = "${var.location}" +} + +module "ssh_key" { + source = "github.com/hashicorp-modules/ssh-keypair-data.git" +} + +module "network" { + source = "github.com/hashicorp-modules/network-azure.git" + environment_name = "${var.environment_name}" + resource_group_name = "${azurerm_resource_group.main.name}" + location = "${var.location}" + network_cidrs_private = "${var.network_cidrs_private}" + network_cidrs_public = "${var.network_cidrs_public}" + os = "${var.os}" + public_key_data = "${module.ssh_key.public_key_data}" +} +``` diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/_interface.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/_interface.tf new file mode 100644 index 0000000..9775716 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/_interface.tf @@ -0,0 +1,63 @@ +# Required Variables +variable "environment_name" { + type = "string" +} + +variable "resource_group_name" { + type = "string" +} + +variable "location" { + type = "string" +} + +variable "os" { + type = "string" +} + +variable "public_key_data" { + type = "string" +} + +# Optional Variables +variable "network_cidr" { + default = "172.31.0.0/16" +} + +variable "network_cidrs_public" { + default = [ + "172.31.0.0/20", + "172.31.16.0/20", + "172.31.32.0/20", + ] +} + +variable "network_cidrs_private" { + default = [ + "172.31.48.0/20", + "172.31.64.0/20", + "172.31.80.0/20", + ] +} + +variable "jumphost_vm_size" { + default = "Standard_A0" + description = "Azure virtual machine size for jumphost" +} + +# Outputs +output "jumphost_ips_public" { + value = ["${azurerm_public_ip.jumphost.*.ip_address}"] +} + +output "jumphost_username" { + value = "${module.images.os_user}" +} + +output "subnet_public_ids" { + value = ["${azurerm_subnet.public.*.id}"] +} + +output "subnet_private_ids" { + value = ["${azurerm_subnet.private.*.id}"] +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/firewalls-jumphost.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/firewalls-jumphost.tf new file mode 100644 index 0000000..faa8e70 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/firewalls-jumphost.tf @@ -0,0 +1,21 @@ +resource "azurerm_network_security_group" "jumphost" { + name = "${var.environment_name}-jumphost" + location = "${var.location}" + resource_group_name = "${var.resource_group_name}" +} + +resource "azurerm_network_security_rule" "jumphost_ssh" { + name = "${var.environment_name}-jumphost-ssh" + resource_group_name = "${var.resource_group_name}" + network_security_group_name = "${azurerm_network_security_group.jumphost.name}" + + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + + source_address_prefix = "*" + source_port_range = "*" + destination_port_range = "22" + destination_address_prefix = "*" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/instances-jumphost.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/instances-jumphost.tf new file mode 100644 index 0000000..b259b99 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/instances-jumphost.tf @@ -0,0 +1,80 @@ +resource "azurerm_virtual_machine" "jumphost" { + count = "${length(var.network_cidrs_public)}" + + name = "${var.environment_name}-jumphost-${count.index}" + location = "${var.location}" + resource_group_name = "${var.resource_group_name}" + network_interface_ids = ["${element(azurerm_network_interface.jumphost.*.id,count.index)}"] + vm_size = "${var.jumphost_vm_size}" + + # Uncomment this line to delete the OS disk automatically when deleting the VM + delete_os_disk_on_termination = true + + # Uncomment this line to delete the data disks automatically when deleting the VM + delete_data_disks_on_termination = true + + storage_image_reference { + publisher = "${module.images.base_publisher}" + offer = "${module.images.base_offer}" + sku = "${module.images.base_sku}" + version = "${module.images.base_version}" + } + + storage_os_disk { + name = "${var.environment_name}-jumphost-${count.index}" + caching = "ReadWrite" + create_option = "FromImage" + managed_disk_type = "Standard_LRS" + } + + os_profile { + computer_name = "${var.environment_name}-jumphost-${count.index}" + admin_username = "${module.images.os_user}" + admin_password = "none" + } + + os_profile_linux_config { + disable_password_authentication = true + + ssh_keys { + path = "/home/${module.images.os_user}/.ssh/authorized_keys" + key_data = "${var.public_key_data}" + } + } + + tags { + environment_name = "${var.environment_name}-jumphost-${count.index}" + } +} + +resource "azurerm_network_interface" "jumphost" { + count = "${length(var.network_cidrs_public)}" + + name = "${var.environment_name}-jumphost-${count.index}" + location = "${var.location}" + resource_group_name = "${var.resource_group_name}" + + network_security_group_id = "${azurerm_network_security_group.jumphost.id}" + + ip_configuration { + name = "${var.environment_name}-jumphost-${count.index}" + subnet_id = "${element(azurerm_subnet.public.*.id,count.index)}" + public_ip_address_id = "${element(azurerm_public_ip.jumphost.*.id,count.index)}" + private_ip_address_allocation = "dynamic" + } +} + +resource "azurerm_public_ip" "jumphost" { + count = "${length(var.network_cidrs_public)}" + + name = "${var.environment_name}-jumphost-${count.index}" + location = "${var.location}" + resource_group_name = "${var.resource_group_name}" + public_ip_address_allocation = "static" +} + +resource "random_id" "jumphost" { + count = "${length(var.network_cidrs_public)}" + + byte_length = 3 +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/main.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/main.tf new file mode 100644 index 0000000..efbe4dc --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/main.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 0.10.1" +} + +module "images" { + source = "../images-azure" + + os = "${var.os}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/networks.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/networks.tf new file mode 100644 index 0000000..efed90b --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/networks.tf @@ -0,0 +1,6 @@ +resource "azurerm_virtual_network" "main" { + name = "${var.environment_name}" + address_space = ["${var.network_cidr}"] + location = "${var.location}" + resource_group_name = "${var.resource_group_name}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/subnets.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/subnets.tf new file mode 100644 index 0000000..cf47523 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/network-azure/subnets.tf @@ -0,0 +1,17 @@ +resource "azurerm_subnet" "public" { + count = "${length(var.network_cidrs_public)}" + + name = "${var.environment_name}-public-${count.index}" + resource_group_name = "${var.resource_group_name}" + virtual_network_name = "${azurerm_virtual_network.main.name}" + address_prefix = "${element(var.network_cidrs_public,count.index)}" +} + +resource "azurerm_subnet" "private" { + count = "${length(var.network_cidrs_private)}" + + name = "${var.environment_name}-private-${count.index}" + resource_group_name = "${var.resource_group_name}" + virtual_network_name = "${azurerm_virtual_network.main.name}" + address_prefix = "${element(var.network_cidrs_private,count.index)}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/ssh-keypair-data/_interface.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/ssh-keypair-data/_interface.tf new file mode 100644 index 0000000..0bb13ef --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/ssh-keypair-data/_interface.tf @@ -0,0 +1,14 @@ +# Optional Variables +variable "private_key_filename" { + default = "private_key.pem" + description = "Filename to write the private key data to eg key.pem" +} + +# Outputs +output "private_key_pem" { + value = "${tls_private_key.main.private_key_pem}" +} + +output "public_key_data" { + value = "${tls_private_key.main.public_key_openssh}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/ssh-keypair-data/main.tf b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/ssh-keypair-data/main.tf new file mode 100644 index 0000000..3bb5cd6 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/modules/ssh-keypair-data/main.tf @@ -0,0 +1,13 @@ +resource "tls_private_key" "main" { + algorithm = "RSA" +} + +resource "null_resource" "main" { + provisioner "local-exec" { + command = "echo \"${tls_private_key.main.private_key_pem}\" > ${var.private_key_filename}" + } + + provisioner "local-exec" { + command = "chmod 600 ${var.private_key_filename}" + } +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-azure/terraform.tfvars.example b/course/infrastructure-as-code/hashistack/dev/terraform-azure/terraform.tfvars.example new file mode 100644 index 0000000..0f8359f --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-azure/terraform.tfvars.example @@ -0,0 +1,9 @@ +custom_image_id = "" + +auto_join_subscription_id = "" + +auto_join_client_id = "" + +auto_join_client_secret = "" + +auto_join_tenant_id = "" diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-gcp/README.md b/course/infrastructure-as-code/hashistack/dev/terraform-gcp/README.md new file mode 100644 index 0000000..060d092 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-gcp/README.md @@ -0,0 +1,48 @@ +# Build the HashiCorp Stack on AWS + +## Usage for `terraform-gcp` +- Set up your gcp credentials locally or on TFE. You may have a file on your local machine like this when you're done: + ``` + export GOOGLE_CREDENTIALS="/home/username/.gcloud/my-project.json" + export GOOGLE_PROJECT="my-project" + export GOOGLE_REGION="us-east1" + ``` + +- Clone this repository. + ``` + $ git clone git@github.com:hashicorp-guides/hashistack.git + ``` + +- Change into the correct directory. + ``` + $ cd /path/to/hashistack/terraform-gcp + ``` + +- Make a `terraform.tfvars` file and put in the appropriate variables. + ``` + $ cp terraform.tfvars.example terraform.tfvars + $ vi terraform.tfvars + ``` + +- Run a terraform plan and an apply if the plan succeeds. + ``` + $ terraform plan + $ terraform apply + ``` + +- There will be a `.pem` file named like this that you can use to SSH to your instances: `hashistack-r4nd0m456.pem` + +- To access the UIs for Consul and Vault respectively from your local machine (on http://localhost:< port >), you can create the following SSH tunnels: + + ``` + $ ssh -i hashistack-r4nd0m456.pem -L 8200::8200 ec2-user@ + $ ssh -i hashistack-r4nd0m456.pem -L 8500::8500 ec2-user@ + ``` + +**Note:** Terraform currently does not allow specifying a network name and subnet which the Google API requires. As such you can only deploy the hashistack instances into a default network and subnet. This means you cannot use the network created by the network-gcp module. This restriction is no longer compatible with the Google API, and Terraform needs to be updated to correct this. Thus this does not work in the same way as the AWS and Azure versions, and is essentially broken at the current time. But the general structure is here. + +### Limitations noted in the the [hashistack-gcp](https://github.com/hashicorp-modules/hashistack-gcp) repository +- **This repository is currently being tested.** +- Vault is not configured to use TLS. +- Vault is not initialized. Please refer to the [Vault documentation](https://www.vaultproject.io/docs/internals/architecture.html) for instructions. +- Nomad is not configured to use Vault as it requires a Vault Token. Please refer to the [Nomad documentation](https://www.nomadproject.io/docs/vault-integration/) for information on how to configure the integration. diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-gcp/_interface.tf b/course/infrastructure-as-code/hashistack/dev/terraform-gcp/_interface.tf new file mode 100644 index 0000000..0e41873 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-gcp/_interface.tf @@ -0,0 +1,113 @@ +# Required variables +variable "account_file_json" { + description = "Path to the JSON file used to authenticate." +} + +variable "gcp_region" { + description = "Region where resources will be provisioned" +} + +variable "project_name" { + description = "The project to install into." +} + +variable "image_bucket_name" { + description = "The bucket that contains the image. See hashistack.tf for expected path structure." +} + +# Optional variables +variable "environment_name_prefix" { + default = "hashistack" + description = "Environment Name prefix eg my-hashistack-env" +} + +variable "environment" { + description = "Prod, test, QA, dev, etc" + default = "production" +} + +variable "cluster_size" { + default = "3" + description = "Number of instances to launch in the cluster" +} + +variable "consul_version" { + default = "1.0.0" + description = "Consul version to use ie 0.8.4" +} + +variable "nomad_version" { + default = "0.7.0" + description = "Nomad version to use ie 0.5.6" +} + +variable "vault_version" { + default = "0.8.3" + description = "Vault version to use ie 0.7.1" +} + +variable "machine_type" { + default = "n1-standard-1" + description = "GCP machine type to use; e.g. n1-standard-1" +} + +variable "os" { + # case sensitive for AMI lookup + default = "Ubuntu" + description = "Operating System to use ie RHEL or Ubuntu" +} + +variable "os_version" { + default = "16.04" + description = "Operating System version to use ie 7.3 (for RHEL) or 16.04 (for Ubuntu)" +} + +variable "ssh_user" { + default = "gcp-user" + description = "The name of the SSH user to provision." +} + +## Outputs +output "network_name" { + value = "${module.network-gcp.network_name}" +} + +output "subnet_public_names" { + value = ["${module.network-gcp.subnet_public_names}"] +} + +output "subnet_private_names" { + value = ["${module.network-gcp.subnet_private_names}"] +} + +output "bastion_username" { + value = "${module.network-gcp.bastion_username}" +} + +output "bastion_ips_public" { + value = ["${module.network-gcp.bastion_ips_public}"] +} + +output "nat_ips_public" { + value = ["${module.network-gcp.nat_ips_public}"] +} + +output "hashistack_instance_group" { + value = "${module.hashistack-gcp.instance_group_manager}" +} + +output "consul_client_firewall" { + value = "${module.hashistack-gcp.consul_firewall}" +} + +output "hashistack_server_firewall" { + value = "${module.hashistack-gcp.hashistack_server_firewall}" +} + +output "ssh_key" { + value = "${module.ssh-keypair-data.private_key_pem}" +} + +output "ssh_user" { + value = "${var.ssh_user}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-gcp/main.tf b/course/infrastructure-as-code/hashistack/dev/terraform-gcp/main.tf new file mode 100644 index 0000000..55bbf59 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-gcp/main.tf @@ -0,0 +1,49 @@ +# Set environment name +resource "random_id" "environment_name" { + byte_length = 4 + prefix = "${var.environment_name_prefix}-" +} + +provider "google" { + region = "${var.gcp_region}" + project = "${var.project_name}" + credentials = "${file(var.account_file_json)}" +} + +module "network-gcp" { + source = "git::ssh://git@github.com/hashicorp-modules/network-gcp" + environment_name = "${random_id.environment_name.hex}" + os = "${var.os}" + os_version = "${var.os_version}" + ssh_key_data = "${module.ssh-keypair-data.public_key_data}" + ssh_user = "${var.ssh_user}" +} + +module "hashistack-gcp" { + source = "git::ssh://git@github.com/hashicorp-modules/hashistack-gcp" + region = "${var.gcp_region}" + project_name = "${var.project_name}" + image_bucket_name = "${var.image_bucket_name}" + account_file_json = "${var.account_file_json}" + nomad_version = "${var.nomad_version}" + vault_version = "${var.vault_version}" + consul_version = "${var.consul_version}" + environment_name = "${random_id.environment_name.hex}" + cluster_name = "${random_id.environment_name.hex}" + cluster_size = "${var.cluster_size}" + os = "${var.os}" + os_version = "${var.os_version}" + ssh_user = "${var.ssh_user}" + ssh_key_data = "${module.ssh-keypair-data.public_key_data}" + # Terraform currently does not let you specify a network and subnet which the + # Google API requires. As such this only works in the default network. + #subnet = "${module.network-gcp.subnet_private_names[0]}" + #network = "${module.network-gcp.network_name}" + machine_type = "${var.machine_type}" + environment = "${var.environment}" +} + +module "ssh-keypair-data" { + source = "git::git@github.com:hashicorp-modules/ssh-keypair-data.git" + private_key_filename = "${random_id.environment_name.hex}" +} diff --git a/course/infrastructure-as-code/hashistack/dev/terraform-gcp/terraform.tfvars.example b/course/infrastructure-as-code/hashistack/dev/terraform-gcp/terraform.tfvars.example new file mode 100644 index 0000000..3887d01 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/terraform-gcp/terraform.tfvars.example @@ -0,0 +1,26 @@ +# Operating System to use ie RHEL or Ubuntu +os = "Ubuntu" +#os = "RHEL" + +# Operating System version to use ie 7.3 (for RHEL) or 16.04 (for Ubuntu) +os_version = "16.04" +#os_version = "7.3" + +# GCP Region +gcp_region = "us-east1" + +project_name = "my-hashistack-test-1" + +account_file_json = "~/.gcloud/my-image-test.json" + +cluster_name = "my-hashistack" +environment_name = "my-hashistack" + +consul_version = "0.9.2+ent" +nomad_version = "0.6.2" +vault_version = "0.8.1+ent" + +machine_type = "g1-small" + +image_bucket_name = "my-image-store" +environment = "test" diff --git a/course/infrastructure-as-code/hashistack/dev/vagrant-local/README.md b/course/infrastructure-as-code/hashistack/dev/vagrant-local/README.md new file mode 100644 index 0000000..33316b7 --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/vagrant-local/README.md @@ -0,0 +1,63 @@ +# Provision a Development HashiStack Cluster in Vagrant + +The goal of this guide is to allows users to easily provision a development HashiStack cluster in just a few commands. + +## Reference Material + +- [Vagrant Getting Started](https://www.vagrantup.com/intro/getting-started/index.html) +- [Vagrant Docs](https://www.vagrantup.com/docs/index.html) +- [Consul Getting Started](https://www.consul.io/intro/getting-started/install.html) +- [Consul Docs](https://www.consul.io/docs/index.html) +- [Vault Getting Started](https://www.vaultproject.io/intro/getting-started/install.html) +- [Vault Docs](https://www.vaultproject.io/docs/index.html) +- [Nomad Getting Started](https://www.nomadproject.io/intro/getting-started/install.html) +- [Nomad Docs](https://www.nomadproject.io/docs/index.html) + +## Estimated Time to Complete + +5 minutes. + +## Challenge + +There are many different ways to provision and configure an easily accessible development HashiStack cluster, making it difficult to get started. + +## Solution + +Provision a development HashiStack cluster in Vagrant. + +The Vagrant Development HashiStack guide is for **educational purposes only**. It's designed to allow you to quickly standup a single instance with Consul, Vault, & Nomad running in `-dev` mode. The single node is provisioned into a local VM, allowing for easy access to the instance. Because Consul, Vault, & Nomad are running in `-dev` mode, all data is in-memory and not persisted to disk. If any agent fails or the node restarts, all data will be lost. This is only mean for local use. + +## Prerequisites + +- [Download Vagrant](https://www.vagrantup.com/downloads.html) +- [Download Virtualbox](https://www.virtualbox.org/wiki/Downloads) + +## Steps + +We will now provision the development HashiStack cluster in Vagrant. + +### Step 1: Start Vagrant + +Run `vagrant up` to start the VM and configure the HashiStack. That's it! Once provisioned, view the Vagrant ouput for next steps. + +#### CLI + +[`vagrant up` Command](https://www.vagrantup.com/docs/cli/up.html) + +##### Request + +```sh +$ vagrant up +``` + +##### Response +``` +``` + +## Next Steps + +Now that you've provisioned and configured the HashiStack, start walking through the below product guides. + +- [Consul Guides](https://www.consul.io/docs/guides/index.html) +- [Vault Guides](https://www.vaultproject.io/guides/index.html) +- [Nomad Guides](https://www.nomadproject.io/guides/index.html) diff --git a/course/infrastructure-as-code/hashistack/dev/vagrant-local/Vagrantfile b/course/infrastructure-as-code/hashistack/dev/vagrant-local/Vagrantfile new file mode 100644 index 0000000..3b395ae --- /dev/null +++ b/course/infrastructure-as-code/hashistack/dev/vagrant-local/Vagrantfile @@ -0,0 +1,151 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# Networking +private_ip = ENV['PRIVATE_IP'] || "192.168.50.150" +consul_host_port = ENV['CONSUL_HOST_PORT'] || 8500 +vault_host_port = ENV['VAULT_HOST_PORT'] || 8200 +nomad_host_port = ENV['NOMAD_HOST_PORT'] || 4646 + +# Base box selection +base_box = ENV['BASE_BOX'] || "bento/ubuntu-16.04" + +# Consul variables +consul_version = ENV['CONSUL_VERSION'] || "1.2.3" +consul_ent_url = ENV['CONSUL_ENT_URL'] +consul_group = "consul" +consul_user = "consul" +consul_comment = "Consul" +consul_home = "/srv/consul" + +# Vault variables +vault_version = ENV['VAULT_VERSION'] || "0.11.3" +vault_ent_url = ENV['VAULT_ENT_URL'] +vault_group = "vault" +vault_user = "vault" +vault_comment = "Vault" +vault_home = "/srv/vault" + +# Nomad variables +nomad_version = ENV['NOMAD_VERSION'] || "0.8.6" +nomad_ent_url = ENV['NOMAD_ENT_URL'] +nomad_group = "root" +nomad_user = "root" + +# Tests & cleanup +run_tests = ENV['RUN_TESTS'] +cleanup = ENV['CLEANUP'] + +$script = < + + + +
+
+ +
+
+ + + +
+
+
{{button1}} - {{ value1 }} | {{button2}} - {{ value2 }}
+ +
+
+ + \ No newline at end of file diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/README.md b/course/terraform-guides/self-serve-infrastructure/getting-started/README.md new file mode 100644 index 0000000..d002828 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/README.md @@ -0,0 +1 @@ +This directory includes some examples of provisioning networking infrastructure in AWS, Azure, and Google. diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/_interface.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/_interface.tf new file mode 100644 index 0000000..a473bc6 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/_interface.tf @@ -0,0 +1,19 @@ +variable "region" { + default = "" + description = "The default AZ to provision to for the provider" +} + +variable "vpc_cidr_block" { + default = "" + description = "The default CIDR block for the VPC demo" +} + +variable "subnet_cidr_block" { + default = "" + description = "The default CIDR block for the subnet demo" +} + +variable "subnet_availability_zone" { + default = "" + description = "The default AZ for the subnet" +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/main.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/main.tf new file mode 100644 index 0000000..21af21e --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/main.tf @@ -0,0 +1,21 @@ +provider "aws" { + region = "${var.region}" +} + +resource "aws_vpc" "demo_vpc" { + cidr_block = "${var.vpc_cidr_block}" + + tags { + Name = "fp_demo_vpc" + } +} + +resource "aws_subnet" "demo_subnet" { + vpc_id = "${aws_vpc.demo_vpc.id}" + cidr_block = "${var.subnet_cidr_block}" + availability_zone = "${var.subnet_availability_zone}" + + tags { + Name = "fp_demo_subnet" + } +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/outputs.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/outputs.tf new file mode 100644 index 0000000..3cef362 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/outputs.tf @@ -0,0 +1,9 @@ +output "vpc_id_consumable" { + value = "${aws_vpc.demo_vpc.id}" + description = "This is the VPC ID for later use" +} + +output "demo_subnet_id" { + value = "${aws_subnet.demo_subnet.id}" + description = "This is the Subnet ID for later use" +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/terraform.auto.tfvars b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/terraform.auto.tfvars new file mode 100644 index 0000000..7b5bf6e --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-aws/terraform.auto.tfvars @@ -0,0 +1,3 @@ +# name = "self-serve-getting-started-override" # Override "name" variable default +# instance_type = "t2.small" # Override "instance_type" variable default +# tags = { foo = "bar" fizz = "buzz" } # Override "tags" variable default diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/_interface.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/_interface.tf new file mode 100644 index 0000000..b334f8f --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/_interface.tf @@ -0,0 +1,29 @@ +variable "rg_name" { + default = "" + description = "The default name for the Resource Group" +} + +variable "rg_location" { + default = "" + description = "The default name for the Resource Group" +} + +variable "vn_name" { + default = "" + description = "The default name for the Virtual Network" +} + +variable "vn_address_space" { + default = "" + description = "The default address space for the Virtual Network" +} + +variable "sb_name" { + default = "" + description = "The default name for the subnet" +} + +variable "sb_address_prefix" { + default = "" + description = "The default address prefix for the Subnet" +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/main.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/main.tf new file mode 100644 index 0000000..8fda379 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/main.tf @@ -0,0 +1,25 @@ +provider "azurerm" { + subscription_id = "" + client_id = "" + client_secret = "" + tenant_id = "" +} + +resource "azurerm_resource_group" "demo_resource_group" { + name = "${var.rg_name}" + location = "${var.rg_location}" +} + +resource "azurerm_virtual_network" "demo_virtual_network" { + name = "${var.vn_name}" + address_space = ["${var.vn_address_space}"] + location = "${azurerm_resource_group.demo_resource_group.location}" + resource_group_name = "${azurerm_resource_group.demo_resource_group.name}" +} + +resource "azurerm_subnet" "demo_subnet" { + name = "${var.sb_name}" + resource_group_name = "${azurerm_resource_group.demo_virtual_network.name}" + virtual_network_name = "${azurerm_virtual_network.demo_virtual_network.name}" + address_prefix = "${var.sb_address_prefix}" +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/outputs.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/outputs.tf new file mode 100644 index 0000000..f8fa885 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/outputs.tf @@ -0,0 +1,19 @@ +output "resource_group_consumable" { + value = "${azurerm_resource_group.demo_resource_group.name}" + description = "The Demo VPC Name for later use" +} + +output "virtual_network_consumable_name" { + value = "${azurerm_virtual_network.demo_virtual_network.name}" + description = "The Demo Virtaul Network name for later use" +} + +output "virtual_network_consumable_address_space" { + value = "${azurerm_virtual_network.demo_virtual_network.address_space}" + description = "The Demo Virtaul Network address space for later use" +} + +output "subnet_consumable" { + value = "${azurerm_subnet.demo_subnet.address_prefix}" + description = "The Demo Subnet for later use" +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/terraform.auto.tfvars b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/terraform.auto.tfvars new file mode 100644 index 0000000..26edac5 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-azure/terraform.auto.tfvars @@ -0,0 +1,4 @@ +# name = "self-serve-getting-started-override" # Override "name" variable default +# network_location = "westus" # Override "network_location" variable default +# compute_location = "West US 2" # Override "compute_location" variable default +# tags = { foo = "bar" fizz = "buzz" } # Override "tags" variable default diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/_interface.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/_interface.tf new file mode 100644 index 0000000..5800e6f --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/_interface.tf @@ -0,0 +1,19 @@ +variable "gn_name" { + default = "" + description = "The default name for the Compute Network" +} + +variable "sn_name" { + default = "" + description = "The default name for the subnet" +} + +variable "sn_region" { + default = "" + description = "The default region for the subnet" +} + +variable "sn_cidr_range" { + default = "" + description = "The default Subnet Cidr Range" +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/main.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/main.tf new file mode 100644 index 0000000..585163d --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/main.tf @@ -0,0 +1,17 @@ +provider "google" { + project = "terraform-gcp-module-test" + region = "us-central1" + project = "terraform-gcp-module-test" +} + +resource "google_compute_network" "demo_network" { + name = "${var.gn_name}" + auto_create_subnetworks = "false" +} + +resource "google_compute_subnetwork" "demo_subnetwork" { + network = "${google_compute_network.demo_network.name}" + name = "${var.sn_name}" + region = "${var.sn_region}" + ip_cidr_range = "${var.sn_cidr_range}" +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/outputs.tf b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/outputs.tf new file mode 100644 index 0000000..97dbc7a --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/outputs.tf @@ -0,0 +1,14 @@ +output "compute_network_consumable" { + value = "${google_compute_network.demo_network.name}" + description = "The Network Name" +} + +output "subnetwork_consumable_name" { + value = "${google_compute_subnetwork.demo_subnetwork.name}" + description = "The Subnet Name" +} + +output "subnetwork_consumable_ip_cidr_range" { + value = "${google_compute_subnetwork.demo_subnetwork.ip_cidr_range}" + description = "The default Cidr Range" +} diff --git a/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/terraform.auto.tfvars b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/terraform.auto.tfvars new file mode 100644 index 0000000..6197c7a --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/getting-started/terraform-gcp/terraform.auto.tfvars @@ -0,0 +1,5 @@ +# name = "self-serve-getting-started-override" # Override "name" variable default +# region = "us-west1" # Override "region" variable default +# zone = "us-west1-a" # Override "zone" variable default +# service_port = "80" # Override "service_port" variable default +# tags = { foo = "bar" fizz = "buzz" } # Override "tags" variable default diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/README.md b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/README.md new file mode 100644 index 0000000..661c945 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/README.md @@ -0,0 +1,79 @@ +# OpenShift Pods and Services +This guide gives an example of deploying OpenShift pods and services to an existing OpenShift cluster with Terraform Enterprise (TFE). It deploys two pods exposed as services: The first runs a python application called "cats-and-dogs-frontend" that lets users vote for their favorite type of pet. It stores data in the second, "cats-and-dogs-backend", which runs a redis database. Before provisioning the pods, it provisions an OpenShift project (namespace) called "cats-and-dogs" and a Kubernetes service account called "cats-and-dogs" which the pods use. The two pods retrieve a shared database password from Vault. + +## Reference Material +* [OpenShift Origin](https://www.openshift.org/): the open source version of OpenShift, Red Hat's commercial implementation of Kubernetes. +* [Kubernetes](https://kubernetes.io/): the open source system for automating deployment and management of containerized applications. +* [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/): the Kubernetes CLI. +* [Kubernetes Pods](https://kubernetes.io/docs/concepts/workloads/pods/pod-overview/): Docker containers are deployed in Kubernetes pods. +* [Kubernetes Services](https://kubernetes.io/docs/concepts/services-networking/service/): Pods are exposed as services. +* [Vault](https://www.vaultproject.io/): HashiCorp's secrets management solution. + + +## Estimated Time to Complete +20 minutes + +## Personas +Our target persona is a developer who wants to provision containerized applications to an OpenShift cluster. + +## Challenge +You would like to deploy some applications to an existing cluster that was already provisioned with Kubernetes, but you would rather not have to copy the cluster's URL, keys, and certificates to the Terraform workspace you will be using. Additionally, you need your Kubernetes applications to authenticate themselves to Vault and retrieve a shared secret. + +## Solution +Terraform's [Kubernetes Provider](https://www.terraform.io/docs/providers/kubernetes/index.html), the [terraform_remote_state](https://www.terraform.io/docs/providers/terraform/d/remote_state.html) data source, and the [Vault Kubernetes auth method](https://www.vaultproject.io/docs/auth/kubernetes.html) save you a lot of time and trouble. + +This guide uses the kubernetes_pod and kubernetes_service resources of Terraform's Kubernetes Provider to deploy the pods and services into an OpenShift cluster previously provisioned by Terraform. It also uses the terraform_remote_state data source to copy the outputs of the targeted cluster's TFE workspace directly into the Kubernetes Provider block, avoiding the need to manually copy the outputs into variables of the TFE services workspace. It also uses the vault_addr, vault_user, and vault_k8s_auth_backend outputs from the cluster workspace. + +The guide also uses a remote-exec provisioner to create an OpenShift project and a Kubernetes service account (both called "cats-and-dogs") which the pods use. It then uses additional provisioners to retrieve the JWT token of the cats-and-dogs service account from OpenShift and to expose the cats-and-dogs-frontend service via an OpenShift route. + +The frontend application and the redis database both get the redis password from a Vault server after using the Kubernetes JWT token of the cats-and-dogs service account to authenticate against Vault's Kubernetes Auth Method. This has the benefits that the redis password is not stored in any of the code (Terraform, application, or database) and that none of the application developers or DBAs will know it. Only the security team that stores the password in Vault will know. The redis_db password is stored in the Vault server under "secret//kubernetes/cats-and-dogs" where \ is the Vault username. + +## Prerequisites + +1. First deploy an OpenShift cluster with Terraform by using the Terraform code in the [k8s-cluster-openshift-aws](../../infrastructure-as-code/k8s-cluster-openshift-aws) directory of this repository and pointing a TFE workspace against it. +1. We assume you have already fulfilled all the prerequisites of that guide including configuration of your Vault server and creation of the redis_pwd key under the path "secret/\/kubernetes/cats-and-dogs". + + +## Steps +Execute the following commands to deploy the pods and services to your OpenShift cluster. + +If you want to use open source Terraform instead of TFE, you can create a copy of the included openshift.tfvars.example file, calling it openshift.auto.tfvars, set values for the variables in it, run `terraform init`, and then run `terraform apply`. + +### Step 1: Create and Configure a Workspace +1. Create a new TFE workspace called k8s-services-openshift. +1. Configure your workspace to connect to the fork of this repository in your own GitHub account. +1. Set the Terraform Working Directory to "self-serve-infrastructure/k8s-services-openshift" +1. Set the tfe-organization Terraform variable in your workspace to the name of the TFE organization containing your OpenShift cluster workspace. +1. Set the k8s-cluster-workspace Terraform variable in your workspace to the name of the workspace you used to deploy your OpenShift cluster. +1. Set the private_key_data Terraform variable in your workspace to include the contents of the private key file you used when provisioning the cluster. This is needed since the Terraform code uses a remote-exec provisioner to create the project and service account with the `oc` and `kubectl` CLIs respectively. (The service_account resource of the Kubernetes provider cannot be used in this case because OpenShift creates service accounts with two secrets while the resource expects each service account to only have one secret.) + +### Step 2: Change the Redis Password +1. Login to the Vault UI using your username and password (or token if the userpass authentication method is not enabled). +1. Change the value of the redis_pwd key under the path "secret/\/kubernetes/cats-and-dogs" to some arbitrary string containing letters and numbers. + +### Step 3: Plan and Apply the Deployment of the Pods and Services +1. Queue a plan for the k8s-services-openshift workspace in TFE. +1. Confirm that you want to apply the plan. + +### Step 4: Run the Cats-and-Dogs Application +1. Enter the cats_and_dogs_dns output in a browser. You should see the "Pets Voting App" page. +1. Vote for your favorite pets. + +### Step 5: (Optional) Check the OpenShift Pod Logs +1. If you wish, you can examine the logs of the the two pods in the OpenShift Console. +1. Point your browser to the k8s_endpoint output of your cluster workspace. +1. Login with username "admin" and password "123". +1. Select the cats-and-dogs project. +1. Select Pods from the Applications tab on the left-side menu. +1. Select Logs for the cats-and-dogs-frontend pod and verify that the redis password is the one you set in Vault. +1. Select Logs for the cats-and-dogs-backend pod and verify that the redis password is the one you set in Vault. + +## Next Steps +You can now examine the code of the [cats-and-dogs-frontend](../cats-and-dogs/frontend/azure-vote/main.py) and [cats-and-dogs-backend](../cats-and-dogs/backend/vote-db/start_redis.sh) applications to understand how they authenticate themselves to Vault and read the redis_pwd secret. + +## Cleanup +Execute the following steps to delete the cats-and-dogs pods and services from your OpenShift cluster. + +1. Define an environment variable CONFIRM_DESTROY with value 1 on the Variables tab of your k8s-services-openshift workspace. +1. Queue a Destroy plan in TFE from the Settings tab of your workspace. +1. On the Latest Run tab of your workspace, make sure that the Plan was successful and then click the "Confirm and Apply" button to actually remove the cats-and-dogs pods, and services. diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/cats-and-dogs-secret-name b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/cats-and-dogs-secret-name new file mode 100644 index 0000000..e69de29 diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/cats-and-dogs.yaml b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/cats-and-dogs.yaml new file mode 100644 index 0000000..7f219c0 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/cats-and-dogs.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cats-and-dogs + namespace: cats-and-dogs diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/main.tf b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/main.tf new file mode 100644 index 0000000..0aad844 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/main.tf @@ -0,0 +1,225 @@ +terraform { + required_version = ">= 0.11.7" +} + +data "terraform_remote_state" "k8s_cluster" { + backend = "atlas" + config { + name = "${var.tfe_organization}/${var.k8s_cluster_workspace}" + } +} + +provider "kubernetes" { + host = "${data.terraform_remote_state.k8s_cluster.k8s_endpoint}" + client_certificate = "${base64decode(data.terraform_remote_state.k8s_cluster.k8s_master_auth_client_certificate)}" + client_key = "${base64decode(data.terraform_remote_state.k8s_cluster.k8s_master_auth_client_key)}" + cluster_ca_certificate = "${base64decode(data.terraform_remote_state.k8s_cluster.k8s_master_auth_cluster_ca_certificate)}" +} + +resource "null_resource" "service_account" { + + provisioner "file" { + source = "cats-and-dogs.yaml" + destination = "~/cats-and-dogs.yaml" + } + + provisioner "remote-exec" { + inline = [ + "oc new-project cats-and-dogs --description=\"cats and dogs project\" --display-name=\"cats-and-dogs\"", + "kubectl create -f cats-and-dogs.yaml", + "kubectl get serviceaccount cats-and-dogs -o yaml > cats-and-dogs-service.yaml", + "grep \"cats-and-dogs-token\" cats-and-dogs-service.yaml | cut -d ':' -f 2 | sed 's/ //' > cats-and-dogs-secret-name" + ] + } + + connection { + host = "${data.terraform_remote_state.k8s_cluster.master_public_dns}" + type = "ssh" + agent = false + user = "ec2-user" + private_key = "${var.private_key_data}" + bastion_host = "${data.terraform_remote_state.k8s_cluster.bastion_public_dns}" + } +} + +resource "null_resource" "get_secret_name" { + provisioner "remote-exec" { + inline = [ + "scp -o StrictHostKeyChecking=no -i ~/.ssh/private-key.pem ec2-user@${data.terraform_remote_state.k8s_cluster.master_public_dns}:~/cats-and-dogs-secret-name cats-and-dogs-secret-name" + ] + + connection { + host = "${data.terraform_remote_state.k8s_cluster.bastion_public_dns}" + type = "ssh" + agent = false + user = "ec2-user" + private_key = "${var.private_key_data}" + } + } + + provisioner "local-exec" { + command = "echo \"${var.private_key_data}\" > private-key.pem" + } + + provisioner "local-exec" { + command = "chmod 400 private-key.pem" + } + + provisioner "local-exec" { + command = "scp -o StrictHostKeyChecking=no -i private-key.pem ec2-user@${data.terraform_remote_state.k8s_cluster.bastion_public_dns}:~/cats-and-dogs-secret-name cats-and-dogs-secret-name" + } + + depends_on = ["null_resource.service_account"] +} + +data "null_data_source" "retrieve_secret_name_from_file" { + inputs = { + secret_name = "${chomp(file("cats-and-dogs-secret-name"))}" + } + depends_on = ["null_resource.get_secret_name"] +} + +resource "kubernetes_pod" "cats-and-dogs-backend" { + metadata { + name = "cats-and-dogs-backend" + namespace = "cats-and-dogs" + labels { + App = "cats-and-dogs-backend" + } + } + spec { + service_account_name = "cats-and-dogs" + container { + image = "rberlind/cats-and-dogs-backend:k8s-auth" + image_pull_policy = "Always" + name = "cats-and-dogs-backend" + command = ["/app/start_redis.sh"] + env = { + name = "VAULT_ADDR" + value = "${data.terraform_remote_state.k8s_cluster.vault_addr}" + } + env = { + name = "VAULT_K8S_BACKEND" + value = "${data.terraform_remote_state.k8s_cluster.vault_k8s_auth_backend}" + } + env = { + name = "VAULT_USER" + value = "${data.terraform_remote_state.k8s_cluster.vault_user}" + } + env = { + name = "K8S_TOKEN" + value_from { + secret_key_ref { + name = "${data.null_data_source.retrieve_secret_name_from_file.outputs["secret_name"]}" + key = "token" + } + } + } + port { + container_port = 6379 + } + } + } +} + +resource "kubernetes_service" "cats-and-dogs-backend" { + metadata { + name = "cats-and-dogs-backend" + namespace = "cats-and-dogs" + } + spec { + selector { + App = "${kubernetes_pod.cats-and-dogs-backend.metadata.0.labels.App}" + } + port { + port = 6379 + target_port = 6379 + } + } +} + +resource "kubernetes_pod" "cats-and-dogs-frontend" { + metadata { + name = "cats-and-dogs-frontend" + namespace = "cats-and-dogs" + labels { + App = "cats-and-dogs-frontend" + } + } + spec { + service_account_name = "cats-and-dogs" + container { + image = "rberlind/cats-and-dogs-frontend:k8s-auth" + image_pull_policy = "Always" + name = "cats-and-dogs-frontend" + env = { + name = "REDIS" + value = "cats-and-dogs-backend" + } + env = { + name = "VAULT_ADDR" + value = "${data.terraform_remote_state.k8s_cluster.vault_addr}" + } + env = { + name = "VAULT_K8S_BACKEND" + value = "${data.terraform_remote_state.k8s_cluster.vault_k8s_auth_backend}" + } + env = { + name = "VAULT_USER" + value = "${data.terraform_remote_state.k8s_cluster.vault_user}" + } + env = { + name = "K8S_TOKEN" + value_from { + secret_key_ref { + name = "${data.null_data_source.retrieve_secret_name_from_file.outputs["secret_name"]}" + key = "token" + } + } + } + port { + container_port = 80 + } + } + } + + depends_on = ["kubernetes_service.cats-and-dogs-backend"] +} + +resource "kubernetes_service" "cats-and-dogs-frontend" { + metadata { + name = "cats-and-dogs-frontend" + namespace = "cats-and-dogs" + } + spec { + selector { + App = "${kubernetes_pod.cats-and-dogs-frontend.metadata.0.labels.App}" + } + port { + port = 80 + target_port = 80 + } + type = "LoadBalancer" + } +} + +resource "null_resource" "expose_route" { + + provisioner "remote-exec" { + inline = [ + "oc expose service cats-and-dogs-frontend --hostname=cats-and-dogs-frontend.${data.terraform_remote_state.k8s_cluster.master_public_ip}.xip.io" + ] + } + + connection { + host = "${data.terraform_remote_state.k8s_cluster.master_public_dns}" + type = "ssh" + agent = false + user = "ec2-user" + private_key = "${var.private_key_data}" + bastion_host = "${data.terraform_remote_state.k8s_cluster.bastion_public_dns}" + } + + depends_on = ["kubernetes_service.cats-and-dogs-frontend"] + +} diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/openshift.tfvars.example b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/openshift.tfvars.example new file mode 100644 index 0000000..6176d05 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/openshift.tfvars.example @@ -0,0 +1,3 @@ +tfe_organization = "" +k8s_cluster_workspace = "k8s-cluster-openshift" +private_key_data= "" diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/outputs.tf b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/outputs.tf new file mode 100644 index 0000000..588e0e6 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/outputs.tf @@ -0,0 +1,3 @@ +output "cats_and_dogs_dns" { + value = "http://cats-and-dogs-frontend.${data.terraform_remote_state.k8s_cluster.master_public_ip}.xip.io" +} diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/variables.tf b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/variables.tf new file mode 100644 index 0000000..348ad94 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services-openshift/variables.tf @@ -0,0 +1,11 @@ +variable "tfe_organization" { + description = "TFE organization" +} + +variable "k8s_cluster_workspace" { + description = "workspace to use for the k8s cluster" +} + +variable "private_key_data" { + description = "contents of the private key" +} diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services/README.md b/course/terraform-guides/self-serve-infrastructure/k8s-services/README.md new file mode 100644 index 0000000..2cb4f1a --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services/README.md @@ -0,0 +1,43 @@ +# Kubernetes Pods and Services +Terraform configuration for deploying Kubernetes pods and services to existing Kubernetes clusters in Azure Container Service (ACS) and Google Kubernetes Engine (GKE). + +## Introduction +This Terraform configuration deploys two pods exposed as services. It is meant to be used in Terraform Enterprise (TFE). The first runs a python application called "cats-and-dogs-frontend" that lets users vote for their favorite type of pet. It stores data in the second, "cats-and-dogs-backend", which runs a redis database. The Terraform configuration replicates what a user could do with the [Kubernetes CLI](https://kubernetes.io/docs/tasks/tools/install-kubectl/), `kubectl`. + +It uses the kubernetes_pod and kubernetes_service resources of Terraform's Kubernetes Provider to deploy the pods and services into a Kubernetes cluster previously provisioned by Terraform. It also uses the terraform_remote_state data source to copy the outputs of the targeted cluster's TFE workspace directly into the Kubernetes Provider block, avoiding the need to manually copy the outputs into variables of the TFE services workspace. It also uses the vault_addr, vault_user, and vault_k8s_auth_backend outputs from the cluster workspace. Note that it also creates a Kubernetes service account called "cats-and-dogs" which the pods use. + +Another important aspect of this configuration is that both the frontend application and the redis database get the redis password from a Vault server after using the Kubernetes JWT token of the cats-and-dogs service account to authenticate against Vault's Kubernetes auth method. This has the benefits that the redis password is not stored in the Terraform code and that neither the application developers nor the DBAs managing Redis need to know what the redis password is. Only the security team that stores the password in Vault know it. The redis_db password is stored in the Vault server under "secret//kubernetes/cats-and-dogs" where \ is the Vault username. + +## Deployment Prerequisites + +1. First deploy a Kubernetes cluster with Terraform Enterprise (TFE) by using one of these Terraform repositories and pointing a TFE workspace against it: + - [tfe-k8s-cluster-acs](../../infrastructure-as-code/k8s-cluster-acs) + - [tfe-k8s-cluster-gke](../../infrastructure-as-code/k8s-cluster-gke) +1. We assume that you have already satisfied all the prerequisites for deploying a Kubernetes cluster in ACS or GKE described by the above links. +1. We also assume that you have already forked this repository and cloned your fork to your laptop. +1. We also assume you have created dev and prod branches on your fork if you deployed both dev and prod clusters. + + +## Deployment Steps +Execute the following commands to deploy the pods and services to your Kubernetes cluster: + +1. Create a new TFE workspace called k8s-services-acs-dev or k8s-services-gke-dev depending on whether you are deploying to ACS or GKE. +1. Configure your workspace to connect to the fork of this repository in your own GitHub account. +1. Click the "More options" link, set the Terraform Working Directory to "self-serve-infrastructure/k8s-services" and the VCS Branch to "dev". (If you are only using one cluster and did not create a dev branch on your fork, use "master" instead or just leave the VCS Branch blank.) +1. Set the *tfe_organization* Terraform variable in your new workspace to the name of the TFE organization containing your Kubernetes cluster workspace. +1. Set the *k8s_cluster_workspace* Terraform variable in your new workspace to the name of the workspace you used to deploy your Kubernetes cluster. +1. Queue a plan for the services workspace in TFE by clicking the "Queue Plan" button in the upper right corner of your workspace. +1. On the Latest Run tab, you should see a new run. If the plan succeeds, you can view the plan and verify that the pods and services will be created when you apply your plan. +1. Click the "Confirm and Apply" button to actually deploy the pods and services. +1. Finally, enter the cats_and_dogs_ip output in a browser. You should see the "Pets Voting App" page. +1. Vote for your favorite pets. + +## Adding a Prod Environment +If you deployed a production Kubernetes cluster, you can repeat the previous steps with a second services workspace and deploy the pods and services into your production cluster too. You could then walk through the process of promoting Terraform code from a dev environment to a production environment in TFE. (See the [k8s-cluster-acs README.md](../../infrastructure-as-code/k8s-cluster-acs/README.md) or [k8s-cluster-gke README.md](../../infrastructure-as-code/k8s-cluster-gke/README.md) too see how.) You would want to set the VCS Branch of the second services workspace to "prod". + +## Cleanup +Execute the following steps to delete the cats-and-dogs pods and services from each of your Kubernetes clusters. + +1. Define an environment variable CONFIRM_DESTROY with value 1 on the Variables tab of your services workspace. +1. Queue a Destroy plan in TFE from the Settings tab of your services workspace. +1. On the Latest Run tab of your services workspace, make sure that the Plan was successful and then click the "Confirm and Apply" button to actually remove the cats-and-dogs pods and services. diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services/main.tf b/course/terraform-guides/self-serve-infrastructure/k8s-services/main.tf new file mode 100644 index 0000000..567fbda --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services/main.tf @@ -0,0 +1,143 @@ +terraform { + required_version = ">= 0.11.0" +} + +data "terraform_remote_state" "k8s_cluster" { + backend = "atlas" + config { + name = "${var.tfe_organization}/${var.k8s_cluster_workspace}" + } +} + +provider "kubernetes" { + host = "${data.terraform_remote_state.k8s_cluster.k8s_endpoint}" + client_certificate = "${base64decode(data.terraform_remote_state.k8s_cluster.k8s_master_auth_client_certificate)}" + client_key = "${base64decode(data.terraform_remote_state.k8s_cluster.k8s_master_auth_client_key)}" + cluster_ca_certificate = "${base64decode(data.terraform_remote_state.k8s_cluster.k8s_master_auth_cluster_ca_certificate)}" +} + +resource "kubernetes_service_account" "cats-and-dogs" { + metadata { + name = "cats-and-dogs" + } +} + +resource "kubernetes_pod" "cats-and-dogs-backend" { + metadata { + name = "cats-and-dogs-backend" + labels { + App = "cats-and-dogs-backend" + } + } + spec { + service_account_name = "${kubernetes_service_account.cats-and-dogs.metadata.0.name}" + container { + image = "${var.backend_image}" + image_pull_policy = "Always" + name = "cats-and-dogs-backend" + command = ["/app/start_redis.sh"] + env = { + name = "VAULT_ADDR" + value = "${data.terraform_remote_state.k8s_cluster.vault_addr}" + } + env = { + name = "VAULT_K8S_BACKEND" + value = "${data.terraform_remote_state.k8s_cluster.vault_k8s_auth_backend}" + } + env = { + name = "VAULT_USER" + value = "${data.terraform_remote_state.k8s_cluster.vault_user}" + } + env = { + name = "K8S_TOKEN" + value_from { + secret_key_ref { + name = "${kubernetes_service_account.cats-and-dogs.default_secret_name}" + key = "token" + } + } + } + port { + container_port = 6379 + } + } + } +} + +resource "kubernetes_service" "cats-and-dogs-backend" { + metadata { + name = "cats-and-dogs-backend" + } + spec { + selector { + App = "${kubernetes_pod.cats-and-dogs-backend.metadata.0.labels.App}" + } + port { + port = 6379 + target_port = 6379 + } + } +} + +resource "kubernetes_pod" "cats-and-dogs-frontend" { + metadata { + name = "cats-and-dogs-frontend" + labels { + App = "cats-and-dogs-frontend" + } + } + spec { + service_account_name = "${kubernetes_service_account.cats-and-dogs.metadata.0.name}" + container { + image = "${var.frontend_image}" + image_pull_policy = "Always" + name = "cats-and-dogs-frontend" + env = { + name = "REDIS" + value = "cats-and-dogs-backend" + } + env = { + name = "VAULT_ADDR" + value = "${data.terraform_remote_state.k8s_cluster.vault_addr}" + } + env = { + name = "VAULT_K8S_BACKEND" + value = "${data.terraform_remote_state.k8s_cluster.vault_k8s_auth_backend}" + } + env = { + name = "VAULT_USER" + value = "${data.terraform_remote_state.k8s_cluster.vault_user}" + } + env = { + name = "K8S_TOKEN" + value_from { + secret_key_ref { + name = "${kubernetes_service_account.cats-and-dogs.default_secret_name}" + key = "token" + } + } + } + port { + container_port = 80 + } + } + } + + depends_on = ["kubernetes_service.cats-and-dogs-backend"] +} + +resource "kubernetes_service" "cats-and-dogs-frontend" { + metadata { + name = "cats-and-dogs-frontend-4" + } + spec { + selector { + App = "${kubernetes_pod.cats-and-dogs-frontend.metadata.0.labels.App}" + } + port { + port = 80 + target_port = 80 + } + type = "LoadBalancer" + } +} diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services/outputs.tf b/course/terraform-guides/self-serve-infrastructure/k8s-services/outputs.tf new file mode 100644 index 0000000..97f8b21 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services/outputs.tf @@ -0,0 +1,3 @@ +output "cats_and_dogs_ip" { + value = "${kubernetes_service.cats-and-dogs-frontend.load_balancer_ingress.0.ip}" +} diff --git a/course/terraform-guides/self-serve-infrastructure/k8s-services/variables.tf b/course/terraform-guides/self-serve-infrastructure/k8s-services/variables.tf new file mode 100644 index 0000000..b6ed9b4 --- /dev/null +++ b/course/terraform-guides/self-serve-infrastructure/k8s-services/variables.tf @@ -0,0 +1,18 @@ +variable "tfe_organization" { + description = "TFE organization" + default = "RogerBerlind" +} + +variable "k8s_cluster_workspace" { + description = "workspace to use for the k8s cluster" +} + +variable "frontend_image" { + default = "rberlind/cats-and-dogs-frontend:k8s-auth" + description = "Docker image location of the frontend app" +} + +variable "backend_image" { + default = "rberlind/cats-and-dogs-backend:k8s-auth" + description = "Docker image location of the frontend app" +}