diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d1572d95..e6ce0513 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,6 +23,7 @@ "bpruitt-goddard.mermaid-markdown-syntax-highlighting", "eamodio.gitlens", "esbenp.prettier-vscode", + "hashicorp.terraform", "mhutchie.git-graph", "ms-python.python", "ms-python.vscode-pylance" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f62ec266..4ab2eec5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,11 +1,10 @@ - # https://codeql.github.com/ name: CodeQL on: push: pull_request: - branches: [ main ] + branches: [dev] schedule: - cron: "24 9 * * 6" @@ -21,20 +20,20 @@ jobs: strategy: fail-fast: false matrix: - language: [ "python" ] + language: ["python"] steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 46a7f0e2..488c2efd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,43 +1,48 @@ -name: Build and publish Docker image +name: Deploy on: - release: - types: [ published ] + workflow_dispatch: push: branches: - - main - paths: - - 'data/**' - - 'eligibility_server/**' - - 'keys/**' - - '.github/workflows/docker-publish.yml' - - 'Dockerfile' - - 'requirements.txt' + - dev + - test + - prod + +defaults: + run: + shell: bash jobs: - build_push_docker: - name: Package Docker image + deploy: runs-on: ubuntu-latest + environment: ${{ github.ref_name }} + concurrency: ${{ github.ref_name }} + steps: - - name: Login to GitHub Container Registry + - name: Checkout + uses: actions/checkout@v3 + + - name: Docker Login to GitHub Container Registry uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push release - uses: docker/build-push-action@v3 - if: ${{ github.event_name == 'release' && github.event.action == 'published' }} - with: - push: true - tags: | - ghcr.io/${{github.repository}}:${{ github.event.release.tag_name }} - ghcr.io/${{github.repository}}:latest + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 - - name: Build and push main + - name: Build, tag, and push image to GitHub Container Registry uses: docker/build-push-action@v3 - if: ${{ github.event_name != 'release' }} with: + builder: ${{ steps.buildx.outputs.name }} + build-args: GIT-SHA=${{ github.sha }} + cache-from: type=gha,scope=cal-itp + cache-to: type=gha,scope=cal-itp,mode=max + context: . + file: Dockerfile push: true - tags: ghcr.io/${{github.repository}}:main + tags: | + ghcr.io/${{ github.repository }}:${{ github.ref_name }} + ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 298a0a61..0751c71a 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -3,11 +3,11 @@ on: workflow_dispatch: push: branches: - - main + - dev paths: - - 'docs/**' - - '.github/workflows/mkdocs.yml' - - 'mkdocs.yml' + - "docs/**" + - ".github/workflows/mkdocs.yml" + - "mkdocs.yml" jobs: mkdocs: name: Publish docs diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index d8b4f743..bd3df3cb 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -2,9 +2,9 @@ name: Pre-commit checks on: push: - branches: [main] + branches: [dev] pull_request: - branches: [main] + branches: [dev] jobs: pre-commit: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da41c4cc..f8d34266 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,10 +2,17 @@ ci: autofix_commit_msg: "chore(pre-commit): autofix run" autoupdate_commit_msg: "chore(pre-commit): autoupdate hooks" +# by default, install these hook types +# https://pre-commit.com/#top_level-default_install_hook_types default_install_hook_types: - pre-commit - commit-msg +# by default, confine hooks to these git stages (except for hooks that specify their own stages) +# https://pre-commit.com/#top_level-default_stages +default_stages: + - commit + repos: - repo: https://github.com/compilerla/conventional-pre-commit rev: v2.1.1 diff --git a/.vscode/settings.json b/.vscode/settings.json index 86c82d8e..a8b8d514 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,11 @@ "python.linting.enabled": true, "python.testing.pytestArgs": ["tests"], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "[terraform]": { + "editor.defaultFormatter": "hashicorp.terraform" + }, + "[terraform-vars]": { + "editor.defaultFormatter": "hashicorp.terraform" + } } diff --git a/docs/README.md b/docs/README.md index 50d425fb..b88c5212 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ This website provides technical documentation for the `eligibility-server` application, a part of the [`benefits`](https://docs.calitp.org/benefits) application, from the [California Integrated Travel Project (Cal-ITP)](https://www.calitp.org). -Documentation for the `main` (default) branch is available [online](https://docs.calitp.org/eligibility-server). +Documentation for the `dev` (default) branch is available [online](https://docs.calitp.org/eligibility-server). ## Overview @@ -30,5 +30,5 @@ docker compose build server ### Use the Docker container locally ```bash -docker pull ghcr.io/cal-itp/eligibility-server:main +docker pull ghcr.io/cal-itp/eligibility-server:dev ``` diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 2a2d0604..621808f5 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -4,12 +4,12 @@ The [Getting Started](./getting-started) section mentions [copying `.env.sample` If you want to run with different settings, you should: - 1. Create a new Python configuration file in the `config` directory - 1. Provide a value for [`IMPORT_FILE_PATH`](./settings.md#import_file_path) (required) and any other settings you want to override (optional) - 1. Set the `ELIGIBILITY_SERVER_SETTINGS` environment variable to the path of your new file +1. Create a new Python configuration file in the `config` directory +1. Provide a value for [`IMPORT_FILE_PATH`](./settings.md#import_file_path) (required) and any other settings you want to override (optional) +1. Set the `ELIGIBILITY_SERVER_SETTINGS` environment variable to the path of your new file !!! note - The Eligibility server loads in settings using Flask's methods for [Configuration Handling](https://flask.palletsprojects.com/en/2.2.x/config/). +The Eligibility server loads in settings using Flask's methods for [Configuration Handling](https://flask.palletsprojects.com/en/2.2.x/config/). !!! important - The default settings that will always be loaded are in [eligibility_server/settings.py](https://github.com/cal-itp/eligibility-server/blob/main/eligibility_server/settings.py) +The default settings that will always be loaded are in [eligibility_server/settings.py](https://github.com/cal-itp/eligibility-server/blob/dev/eligibility_server/settings.py) diff --git a/docs/getting-started.md b/docs/getting-started.md index 871dd032..63dc8e55 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -61,9 +61,9 @@ This repository comes with a [VS Code Remote Containers](https://code.visualstud Once you clone the repository locally, open it within VS Code, which will prompt you to re-open the repository within the Remote Container. - 1. Build and Open the Dev Container - 2. Start the `eligibility-server` Flask app and database with `F5` - 3. Now you can run tests from the container. +1. Build and Open the Dev Container +2. Start the `eligibility-server` Flask app and database with `F5` +3. Now you can run tests from the container. Starting the Dev Container will run `bin/init.sh`, which runs a command to initialize the database. More specifically, it creates the database and imports and saves users based on the configured settings. @@ -71,7 +71,7 @@ Starting the Dev Container will run `bin/init.sh`, which runs a command to initi ### Run unit tests -Unit tests are implemented with [`pytest`](https://docs.pytest.org/en/6.2.x/) and can be found in the [`tests/`](https://github.com/cal-itp/eligibility-server/tree/main/tests) directory in the repository. `pytest` is installed and available to run directly in the devcontainer. +Unit tests are implemented with [`pytest`](https://docs.pytest.org/en/6.2.x/) and can be found in the [`tests/`](https://github.com/cal-itp/eligibility-server/tree/dev/tests) directory in the repository. `pytest` is installed and available to run directly in the devcontainer. The test suite runs against every pull request via a GitHub Action. diff --git a/docs/releases.md b/docs/releases.md index c37ef639..5f10e0f8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,7 +2,7 @@ The `eligibility-server` is published as a Docker image on the GitHub Container Registry. It can be accessed from the [repository package page](https://github.com/cal-itp/eligibility-server/pkgs/container/eligibility-server). -Every push to the `main` (default) branch that changes files relevant to the application builds and updates the `main` package, via the [`docker-publish`](https://github.com/cal-itp/eligibility-server/blob/main/.github/workflows/docker-publish.yml) GitHub Action. +Every push to the `dev` (default) branch that changes files relevant to the application builds and updates the `dev` package, via the [`docker-publish`](https://github.com/cal-itp/eligibility-server/blob/dev/.github/workflows/docker-publish.yml) GitHub Action. Every release created also pushes a new package publication. @@ -10,6 +10,6 @@ Every release created also pushes a new package publication. All versions of the package may be viewed on the [package all versions page](https://github.com/cal-itp/eligibility-server/pkgs/container/eligibility-server/versions). -The `main` (default) branch is published at the `main` tag: +The `dev` (default) branch is published at the `dev` tag: The official releases will be tagged with a version number and the `latest` tag. diff --git a/eligibility_server/verify.py b/eligibility_server/verify.py index c5081725..4329d8d3 100644 --- a/eligibility_server/verify.py +++ b/eligibility_server/verify.py @@ -6,7 +6,6 @@ import json import logging import re -import time from flask import abort from flask_restful import Resource, reqparse @@ -156,9 +155,6 @@ def _check_user(self, sub, name, types): def get(self): """Respond to a verification request.""" - # introduce small fake delay - time.sleep(2) - headers = {} # verify required headers and API key check diff --git a/mkdocs.yml b/mkdocs.yml index d4b25677..a1152c46 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: "cal-itp/eligibility-server: documentation" site_url: https://docs.calitp.org/eligibility-server repo_url: https://github.com/cal-itp/eligibility-server -edit_uri: edit/main/docs +edit_uri: edit/dev/docs theme: name: material diff --git a/setup.py b/setup.py index 88dcabd8..53d3614c 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ setup( name="eligibility-server", + version="2022.11.1", description="Server implementation of the Eligibility Verification API", long_description=long_description, long_description_content_type="text/markdown", diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 00000000..304142f7 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,36 @@ +# https://github.com/github/gitignore/blob/e5323759e387ba347a9d50f8b0ddd16502eb71d4/Terraform.gitignore + +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 00000000..81623cac --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "3.28.0" + constraints = ">= 3.0.0, < 4.0.0" + hashes = [ + "h1:jnr5G4X8apGMF63WeMQn6gMGEoM87mI2lwTIj8D36Vg=", + "h1:kCnbPOpHvjbMumJ6eeyyJ8r4VkD80bMz3C1OApc1yTI=", + "zh:1c01bc8cba03c642d108df034744253ac7e625d7528d77ae57b65809cd08e519", + "zh:52e8a26edde4e9254063b0961079497defc4d3255c2adcc00caca8b960347571", + "zh:694e4f0e6c79265ebfe62656dfd7b30e245e3adba513f70711a050f46819f1a5", + "zh:6ff9fddb694afa04851dcd38e1865bf7afe02690a33b717137a27dc48c049dd1", + "zh:77152823230857b1b0f3b66ff0e38ccc1cca245d531813e8ad4ec0e7dee64b6e", + "zh:7fb273228e63de7846ae64539cf0836eac652a045b915e9cc470c63131cbf88f", + "zh:8f3c784f3b953a6de44c23828a8a47a77bfdb63b09af7cca9ae3175b2870151e", + "zh:92f5feaf7a109dd30d6982af5c68d914c62b294c872f84fa0e1ab24ffea55d1c", + "zh:b74a67fc97966184e7d111fd66316313dfa0ff592ebd9fc1ae4672df317cf3bf", + "zh:f15e22acf5a9186d8647e1539bb904c5605606094b7ebec7d8eb479735573b37", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f91ddbf9310b0f815477a88bdf9969b06a50752c330250b9a6626d89e620ee23", + ] +} diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 00000000..cf1c045b --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,118 @@ +# Infrastructure + +The infrastructure is configured as code via [Terraform](https://www.terraform.io/), for [various reasons](https://techcommunity.microsoft.com/t5/fasttrack-for-azure/the-benefits-of-infrastructure-as-code/ba-p/2069350). + +## Architecture + +## Resources outside of Terraform + +The following things in Azure are managed outside of Terraform: + +- Subcriptions +- Active Directory (users, groups, service principals, etc.) +- Service connections +- Configuration files, stored as blobs + +## Environments + +| Environment | Azure Resource Group | Terraform Workspace | Git Branch | +| ----------- | --------------------------------- | ------------------- | ---------- | +| Dev | `courtesy-cards-eligibility-dev` | `dev` | `dev` | +| Test | `courtesy-cards-eligibility-test` | `test` | `test` | +| Prod | `courtesy-cards-eligibility-prod` | `default` | `prod` | + +All resources in these Resource Groups should be reflected in Terraform in this repository. The exceptions are: + +- Secrets, such as values under [Key Vault](https://azure.microsoft.com/en-us/services/key-vault/). [`prevent_destroy`](https://developer.hashicorp.com/terraform/tutorials/state/resource-lifecycle#prevent-resource-deletion) is used on these Resources. +- Things managed outside of [Terraform](#resources-outside-of-terraform) + +For browsing the [Azure portal](https://portal.azure.com), you can [switch your `Default subscription filter`](https://docs.microsoft.com/en-us/azure/azure-portal/set-preferences). + +## Monitoring + +We have [ping tests](https://docs.microsoft.com/en-us/azure/azure-monitor/app/monitor-web-app-availability) set up to notify about availability of each environment. Alerts go to [#benefits-notify](https://cal-itp.slack.com/archives/C022HHSEE3F). + +## Logs + +Logs can be found a couple of places: + +### Azure App Service Logs + +[Open the `Logs` for the environment you are interested in.](https://docs.google.com/document/d/11EPDIROBvg7cRtU2V42c6VBxcW_o8HhcyORALNtL_XY/edit#heading=h.6pxjhslhxwvj) The following tables are likely of interest: + +- `AppServiceConsoleLogs`: `stdout` and `stderr` coming from the container +- `AppServiceHTTPLogs`: requests coming through App Service +- `AppServicePlatformLogs`: deployment information + +For some pre-defined queries, click `Queries`, then `Group by: Query type`, and look under `Query pack queries`. + +### [Azure Monitor Logs](https://docs.microsoft.com/en-us/azure/azure-monitor/logs/data-platform-logs) + +[Open the `Logs` for the environment you are interested in.](https://docs.google.com/document/d/11EPDIROBvg7cRtU2V42c6VBxcW_o8HhcyORALNtL_XY/edit#heading=h.n0oq4r1jo7zs) + +The following [tables](https://docs.microsoft.com/en-us/azure/azure-monitor/app/opencensus-python#telemetry-type-mappings) are likely of interest: + +- `requests` +- `traces` + +In the latter two, you should see recent log output. Note [there is some latency](https://docs.microsoft.com/en-us/azure/azure-monitor/logs/data-ingestion-time). + +See [`Failures`](https://docs.microsoft.com/en-us/azure/azure-monitor/app/asp-net-exceptions#diagnose-failures-using-the-azure-portal) in the sidebar (or `exceptions` under `Logs`) for application errors/exceptions. + +### Live tail + +After [setting up the Azure CLI](#making-changes), you can use the following command to [stream live logs](https://docs.microsoft.com/en-us/azure/app-service/troubleshoot-diagnostic-logs#in-local-terminal): + +```sh +az webapp log tail --resource-group courtesy-cards-eligibility-prod --name mst-courtesy-cards-eligibility-server-prod 2>&1 | grep -v /healthcheck +``` + +### SCM + +[Docker logs](https://mst-courtesy-cards-eligibility-server-dev.scm.azurewebsites.net/api/logs/docker) + +## Making changes + +[![Build Status](https://dev.azure.com/mstransit/courtesy-cards/_apis/build/status/cal-itp.eligibility-server?branchName=dev)](https://dev.azure.com/mstransit/courtesy-cards/_build/latest?definitionId=1&branchName=dev) + +Terraform is [`plan`](https://www.terraform.io/cli/commands/plan)'d when code is pushed to any branch on GitHub, then [`apply`](https://www.terraform.io/cli/commands/apply)'d when merged to `dev`. While other automation for this project is done through GitHub Actions, we use an Azure Pipeline (above) for a couple of reasons: + +- Easier authentication with the Azure API using a service connnection +- Log output is hidden, avoiding accidentally leaking secrets + +### Local development + +1. Get access to the Azure account. +1. Install dependencies: + + - [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) + - [Terraform](https://www.terraform.io/downloads) - see exact version in [`pipeline/azure-pipelines.yml`](pipeline/azure-pipelines.yml) + +1. [Authenticate using the Azure CLI](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/azure_cli). + + ```sh + az login + ``` + +1. Outside the [dev container](https://docs.calitp.org/eligibility-server/getting-started/), navigate to the `terraform/` directory. +1. Create a [`terraform.tfvars` file](https://developer.hashicorp.com/terraform/language/values/variables#variable-definitions-tfvars-files) and specify the [variables](variables.tf). +1. [Initialize Terraform.](https://www.terraform.io/cli/commands/init) You can also use this script later to switch between [environments](#environments). + + ```sh + ./init.sh + ``` + +1. Make changes to Terraform files. +1. Preview the changes, as necessary. + + ```sh + terraform plan + ``` + +1. Submit the changes via pull request. + +## Azure environment setup + +The steps we took to set up MST's environment are documented in [a separate Google Doc](https://docs.google.com/document/d/12uzuKyvyabHAOaeQc6k2jQIG5pQprdEyBpfST_dY2ME/edit#heading=h.1vs880ltbo58). + +This is not a complete step-by-step guide; more a list of things to remember. This may be useful as part of incident response. diff --git a/terraform/app_service.tf b/terraform/app_service.tf new file mode 100644 index 00000000..f22e255a --- /dev/null +++ b/terraform/app_service.tf @@ -0,0 +1,77 @@ +resource "azurerm_service_plan" "main" { + name = "eligibility-server" + location = data.azurerm_resource_group.main.location + resource_group_name = data.azurerm_resource_group.main.name + os_type = "Linux" + sku_name = "P2v2" +} + +locals { + mount_path = "/home/calitp/app/config" +} + +resource "azurerm_linux_web_app" "main" { + # name needs to be globally unique and is more specific because it's used in the app URL + name = "mst-courtesy-cards-eligibility-server-${local.env_name}" + location = data.azurerm_resource_group.main.location + resource_group_name = data.azurerm_resource_group.main.name + service_plan_id = azurerm_service_plan.main.id + https_only = true + + site_config { + ftps_state = "Disabled" + dynamic "ip_restriction" { + for_each = var.IP_ADDRESS_WHITELIST + content { + ip_address = ip_restriction.value + } + } + vnet_route_all_enabled = true + application_stack { + docker_image = "ghcr.io/cal-itp/eligibility-server" + docker_image_tag = local.env_name + } + } + + app_settings = { + "DOCKER_REGISTRY_SERVER_URL" = "https://ghcr.io/" + "ELIGIBILITY_SERVER_SETTINGS" = "${local.mount_path}/settings.py" + # this prevents the filesystem from being obscured by a mount + "WEBSITES_ENABLE_APP_SERVICE_STORAGE" = "false" + "WEBSITES_PORT" = "8000" + } + + identity { + identity_ids = [] + type = "SystemAssigned" + } + + logs { + detailed_error_messages = false + failed_request_tracing = false + + http_logs { + file_system { + retention_in_days = 99999 + retention_in_mb = 100 + } + } + } + + storage_account { + access_key = azurerm_storage_account.main.primary_access_key + account_name = azurerm_storage_account.main.name + name = "eligibility-server-config" + type = "AzureBlob" + share_name = azurerm_storage_container.config.name + mount_path = local.mount_path + } + + lifecycle { + ignore_changes = [ + # tags get created for Application Insights + # https://github.com/hashicorp/terraform-provider-azurerm/issues/16569 + tags + ] + } +} diff --git a/terraform/environment.tf b/terraform/environment.tf new file mode 100644 index 00000000..f3264b27 --- /dev/null +++ b/terraform/environment.tf @@ -0,0 +1,8 @@ +locals { + is_prod = terraform.workspace == "default" + env_name = local.is_prod ? "prod" : terraform.workspace +} + +data "azurerm_resource_group" "main" { + name = "courtesy-cards-eligibility-${local.env_name}" +} diff --git a/terraform/init.sh b/terraform/init.sh new file mode 100755 index 00000000..ece98f21 --- /dev/null +++ b/terraform/init.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + + +ENV=$1 + +if [ $# -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Setting the subscription for the Azure CLI..." +az account set --subscription="MST IT" + +printf "Intializing Terraform...\n\n" +terraform init + +printf "\n\nSelecting the Terraform workspace...\n" +# matching logic in pipeline/workspace.py +if [ "$ENV" = "prod" ]; then + terraform workspace select default +else + terraform workspace select "$ENV" +fi + +echo "Done!" diff --git a/terraform/key_vault.tf b/terraform/key_vault.tf new file mode 100644 index 00000000..97cc656e --- /dev/null +++ b/terraform/key_vault.tf @@ -0,0 +1,39 @@ +resource "azurerm_key_vault" "main" { + # name needs to be globally unique + name = "eligibility-server-${local.env_name}" + location = data.azurerm_resource_group.main.location + resource_group_name = data.azurerm_resource_group.main.name + sku_name = "standard" + tenant_id = data.azurerm_client_config.current.tenant_id + + # allow engineers to fully manage secrets + access_policy { + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = var.ENGINEERING_GROUP_OBJECT_ID + + secret_permissions = [ + "Backup", + "Delete", + "Get", + "List", + "Purge", + "Recover", + "Restore", + "Set" + ] + } + + # allow the Pipeline to read secrets + access_policy { + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = var.DEPLOYER_APP_OBJECT_ID + + secret_permissions = [ + "Get" + ] + } + + lifecycle { + prevent_destroy = true + } +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 00000000..0403446e --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,24 @@ +terraform { + // see version in pipeline/azure-pipelines.yml + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0.0, < 4.0.0" + } + } + + backend "azurerm" { + # needs to match pipeline/azure-pipelines.yml + resource_group_name = "courtesy-cards-eligibility-terraform" + storage_account_name = "courtesycardsterraform" + container_name = "tfstate" + key = "terraform.tfstate" + } +} + +provider "azurerm" { + features {} +} + +data "azurerm_client_config" "current" {} diff --git a/terraform/monitor.tf b/terraform/monitor.tf new file mode 100644 index 00000000..48752060 --- /dev/null +++ b/terraform/monitor.tf @@ -0,0 +1,33 @@ +resource "azurerm_log_analytics_workspace" "main" { + name = "eligibility-server" + location = data.azurerm_resource_group.main.location + resource_group_name = data.azurerm_resource_group.main.name +} + + +resource "azurerm_application_insights" "main" { + name = "eligibility-server" + application_type = "web" + location = data.azurerm_resource_group.main.location + resource_group_name = data.azurerm_resource_group.main.name + sampling_percentage = 0 + workspace_id = azurerm_log_analytics_workspace.main.id +} + +# created manually +# https://slack.com/help/articles/206819278-Send-emails-to-Slack +data "azurerm_key_vault_secret" "slack_benefits_notify_email" { + name = "slack-benefits-notify-email" + key_vault_id = azurerm_key_vault.main.id +} + +resource "azurerm_monitor_action_group" "eng_email" { + name = "benefits-notify Slack channel email" + resource_group_name = data.azurerm_resource_group.main.name + short_name = "slack-notify" + + email_receiver { + name = "Benefits engineering team" + email_address = data.azurerm_key_vault_secret.slack_benefits_notify_email.value + } +} diff --git a/terraform/pipeline/azure-pipelines.yml b/terraform/pipeline/azure-pipelines.yml new file mode 100644 index 00000000..75ca8b65 --- /dev/null +++ b/terraform/pipeline/azure-pipelines.yml @@ -0,0 +1,88 @@ +trigger: + # automatically runs on pull requests + # https://docs.microsoft.com/en-us/azure/devops/pipelines/repos/github?view=azure-devops&tabs=yaml#pr-triggers + branches: + include: + - dev + - test + - prod + # only run for changes to Terraform files + paths: + include: + - terraform/* +stages: + - stage: plan + pool: + vmImage: ubuntu-latest + jobs: + - job: plan + variables: + - name: OTHER_SOURCE + value: $[variables['System.PullRequest.SourceBranch']] + - name: INDIVIDUAL_SOURCE + value: $[variables['Build.SourceBranchName']] + - name: TARGET + value: $[variables['System.PullRequest.TargetBranch']] + steps: + # set the workspace variable at runtime (rather than build time) so that all the necessary variables are available, and we can use Python + # https://learn.microsoft.com/en-us/azure/devops/pipelines/process/set-variables-scripts?view=azure-devops&tabs=bash#about-tasksetvariable + - bash: | + WORKSPACE=$(python terraform/pipeline/workspace.py) + echo "##vso[task.setvariable variable=workspace]$WORKSPACE" + displayName: Determine deployment environment + env: + REASON: $(Build.Reason) + # https://github.com/microsoft/azure-pipelines-terraform/tree/main/Tasks/TerraformInstaller#readme + - task: TerraformInstaller@0 + displayName: Install Terraform + inputs: + terraformVersion: 1.3.1 + # https://github.com/microsoft/azure-pipelines-terraform/tree/main/Tasks/TerraformTask/TerraformTaskV3#readme + - task: TerraformTaskV3@3 + displayName: Terraform init + inputs: + provider: azurerm + command: init + workingDirectory: "$(System.DefaultWorkingDirectory)/terraform" + # https://developer.hashicorp.com/terraform/tutorials/automation/automate-terraform#automated-terraform-cli-workflow + commandOptions: -input=false + # service connection + backendServiceArm: deployer + # needs to match main.tf + backendAzureRmResourceGroupName: courtesy-cards-eligibility-terraform + backendAzureRmStorageAccountName: courtesycardsterraform + backendAzureRmContainerName: tfstate + backendAzureRmKey: terraform.tfstate + - task: TerraformTaskV3@3 + displayName: Select environment + inputs: + provider: azurerm + command: custom + customCommand: workspace + commandOptions: select $(workspace) + workingDirectory: "$(System.DefaultWorkingDirectory)/terraform" + # service connection + environmentServiceNameAzureRM: deployer + - task: TerraformTaskV3@3 + displayName: Terraform plan + inputs: + provider: azurerm + command: plan + # wait for lock to be released, in case being used by another pipeline run + # https://discuss.hashicorp.com/t/terraform-plan-wait-for-lock-to-be-released/6870/2 + commandOptions: -input=false -lock-timeout=2m + workingDirectory: "$(System.DefaultWorkingDirectory)/terraform" + # service connection + environmentServiceNameAzureRM: deployer + - task: TerraformTaskV3@3 + displayName: Terraform apply + inputs: + provider: azurerm + command: apply + # (ditto the lock comment above) + commandOptions: -input=false -lock-timeout=2m + workingDirectory: "$(System.DefaultWorkingDirectory)/terraform" + # service connection + environmentServiceNameAzureRM: deployer + # only run on certain branches + condition: in(variables['Build.SourceBranchName'], 'dev', 'test', 'prod') diff --git a/terraform/pipeline/workspace.py b/terraform/pipeline/workspace.py new file mode 100644 index 00000000..4915d03f --- /dev/null +++ b/terraform/pipeline/workspace.py @@ -0,0 +1,32 @@ +import os +import sys + +REASON = os.environ["REASON"] +# the name of the variable that Azure Pipelines uses for the source branch depends on the type of run, so need to check both +SOURCE = os.environ.get("OTHER_SOURCE") or os.environ["INDIVIDUAL_SOURCE"] +TARGET = os.environ["TARGET"] + +# the branches that correspond to environments +ENV_BRANCHES = ["dev", "test", "prod"] + +if REASON == "PullRequest" and TARGET in ENV_BRANCHES: + # it's a pull request against one of the environment branches, so use the target branch + environment = TARGET +elif REASON == "IndividualCI" and SOURCE in ENV_BRANCHES: + # it's being run on one of the environment branches, so use that + environment = SOURCE +else: + # default to running against dev + environment = "dev" + +# matching logic in ../init.sh +workspace = "default" if environment == "prod" else environment + +# just for troubleshooting +if TARGET is not None: + deployment_description = f"from {SOURCE} to {TARGET}" +else: + deployment_description = f"for {SOURCE}" +print(f"Deploying {deployment_description} as a result of {REASON} using workspace {workspace}", file=sys.stderr) + +print(workspace) diff --git a/terraform/roles.tf b/terraform/roles.tf new file mode 100644 index 00000000..87f02868 --- /dev/null +++ b/terraform/roles.tf @@ -0,0 +1,16 @@ +resource "azurerm_role_assignment" "velocity_etl" { + count = local.is_prod ? 1 : 0 + + description = "This role assignment gives write access only for the path of the hashed data file." + scope = azurerm_storage_container.config.resource_manager_id + role_definition_name = "Storage Blob Data Contributor" + principal_id = var.VELOCITY_ETL_APP_OBJECT_ID + condition = < + + + + + diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..d3332c98 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,23 @@ +# needs to be uppercase "because Azure DevOps will always transform pipeline variables to uppercase environment variables" +# https://gaunacode.com/terraform-input-variables-using-azure-devops + +variable "DEPLOYER_APP_OBJECT_ID" { + description = "Object ID from the Azure DevOps deployer service principal application in Active Directory" + type = string +} + +variable "ENGINEERING_GROUP_OBJECT_ID" { + description = "Object ID from the engineering group (cal-itp-compiler) in Azure Active Directory" + type = string +} + +variable "VELOCITY_ETL_APP_OBJECT_ID" { + description = "Object ID from the registered application for the Velocity server ETL uploading: https://cloudsight.zendesk.com/hc/en-us/articles/360016785598-Azure-finding-your-service-principal-object-ID" + type = string +} + +variable "IP_ADDRESS_WHITELIST" { + description = "List of IP addresses allowed to connect to the app service, in CIDR notation: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_web_app#ip_address. By default, all IP addresses are allowed." + type = list(string) + default = [] +} diff --git a/tests/test_keypair.py b/tests/test_keypair.py index 3437aee3..697d8600 100644 --- a/tests/test_keypair.py +++ b/tests/test_keypair.py @@ -14,7 +14,7 @@ def sample_key_path_local(): @pytest.fixture def sample_key_path_remote(): - return "https://raw.githubusercontent.com/cal-itp/eligibility-server/main/keys/server.pub" + return "https://raw.githubusercontent.com/cal-itp/eligibility-server/dev/keys/server.pub" @pytest.fixture