CI/CD with GitHub Actions for a Flask site Deployed with Zappa

Posted: 11/30/23. Last Updated: 11/30/23.

Deploying a zappa site takes time. Not that much, a couple minutes, but it means that it's a pain to do regularly which means that I didn't do it regularly. Instead I developed and tested with a local version and then would push straight to production once that was where I wanted it. And that approach worked, but it had some downsides, such as having to manually deploy changes (even just once in a while) and not catching deployment issues early (like installing huge dependencies and having lambda reject the package for being too big). These are the kind of issues that scale with complexity so I figured I'd get a jumpstart and automate as much as I could now, get the infrastructure in place to go above and beyond the project's CI/CD needs. The end goal is a pipeline like this: version control -> tests/static analysis -> build & push artifact -> dev deployment -> test dev deployment -> prod deployment (on PR merge)

Starting with Simple CI

As a starting point, I need to implement some basic CI workflows, which I'm already familiar with from developing scientific software packages. I use GitHub Actions (GHA) for everything cause that's what I'm used to. Adding a unit testing workflow with PyTest is relatively straightforward:

.github/workflows/tests.yml
            
name: Tests

on:
pull_request:
    branches: [main, master]
push:
    branches: [main, master]

jobs:
run-tests:
    name: Test Codebase
    runs-on: ubuntu-latest

    steps:
    - name: Checkout Code
        uses: actions/checkout@v3

    - name: Install Dependencies
        run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r dev-requirements.txt
    
    - name: Run Tests
        run: |
        pytest tests/
            
        

With that in place, it's time to develop some unit tests for the site. And lucky for us there's very little to actually unit test. At this point there's really just the main app file, which contains routes and not much more, and a data_utils.py that has one function for retrieving Chess.com stats. Even though it integrates with a 3rd party and so shouldn't technically be included in unit testing, we're gonna call it a unit test anyways.

tests/unit/test_data_utils.py
            
from app.data_utils import get_chess_stats


def test_get_chess_stats():
    # Chess.com's "unofficial" API, meaning they recognize people use it as is
    # but don't provide guarantees that it will stay the same.
    assert not any([v == "ERR" for k, v in get_chess_stats().items()])
            
        

Upon commiting these two files, the tests workflow runs and succeeds! Now I can develop with the confidence that my makeshift Chess.com API won't break (either with my changes or theirs, again, not really a proper unit test). I also want a linting workflow to enforce formatting for my codebase. I'm going to use Super Linter cause it's a super easy way to manage linting many different languages through GitHub Actions.

.github/workflows/linter.yml
            
name: Super-Linter

on:
    pull_request:
    branches: [main, master]

jobs:
    super-linter:
    name: Lint Codebase
    runs-on: ubuntu-latest

    steps:
        - name: Checkout Code
        uses: actions/checkout@v3

        - name: Run Super-Linter
        uses: github/super-linter@v4
        env:
            VALIDATE_ALL_CODEBASE: true
            GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

            VALIDATE_PYTHON_BLACK: true
            VALIDATE_JAVASCRIPT_ES: true

            FILTER_REGEX_INCLUDE: app/|tests/
            
        

With that commited as well, I can now develop with confidence that my changes won't introduce poorly formatted code or break existing functionality! But there's one more thing I want to add before moving on and that's static analysis. Especially since I have started using AI dev tools like GitHub Copilot and ChatGPT for code generation, it's important to have constant static analysis checks to catch dangerous patterns I don't catch. Codacy links directly with GitHub to do its work without any configuration files necessary (see Codacy Docs).

Turning Focus to CD

In the interim between the last step and now, I took a course called DevOps Foundations on LinkedIn Learning, leading me to think I should expand my CI to include CD. This presents a few challenges I've never faced before: 1) interacting with GitHub's artifact repository, 2) authorizing a GitHub Action to deploy changes to AWS, and 3) performing tests and security audits on the deployed site. Turns out the first one, interacting with artifacts, is super easy! They already have prebuilt actions for doing everything you need to do (push and pull) and zappa has utilities for building and deploying zips (instead of doing it all in one command).

.github/workflows/CICD.yml
            
...
build-artifact:
needs: test
runs-on: ubuntu-latest

steps:
    - name: Checkout Code
    uses: actions/checkout@v4

    - name: Install Dependencies to venv
    run: |
        python -m venv env
        source env/bin/activate
        python -m pip install --upgrade pip
        pip install -r requirements.txt
    
    - name: Build Artifact
    run: |
        source env/bin/activate
        zappa package dev -o ctbus_site.zip

    - name: Upload Artifact
    uses: actions/upload-artifact@v3
    with:
        name: ctbus_site
        path: ./ctbus_site.zip
        retention-days: 1
...
            
        

AWS and GitHub Actions

Authorizing a GHA runner to interact with AWS was not quite as straightforward, despite being relatively well documented. As with most things involving AWS IAM, there are a lot of options that introduce a lot of complexity. Most of the work on GitHub's side is covered by the Configure AWS Credentials action. My first thought was that I'd be able to just plop credentials for an AWS profile in as GHA environment variables but the recommendation is not to do that, instead preferring OpenID Connect. Through this method, an Idendity Provider (IdP) is setup in AWS IAM and then a Role is created that trusts the IdP. That Role can then be used with GHA. Setting up the IdP in AWS IAM and connecting the Role are covered in Configuring OpenID Connect in Amazon Web Services. The only setup left to do in AWS then is to create a Policy for the Role that will give zappa the necessary permissions to do its work. I couldn't find any examples of such a Policy so I've included the one I pieced together here.

policy.json
            
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "lambda:CreateFunction",
                "s3:ListAccessPointsForObjectLambda",
                "s3:GetAccessPoint",
                "lambda:ListVersionsByFunction",
                "logs:DescribeLogStreams",
                "events:PutRule",
                "route53:GetHostedZone",
                "s3:PutStorageLensConfiguration",
                "cloudformation:DescribeStackResource",
                "lambda:GetFunctionConfiguration",
                "iam:PutRolePolicy",
                "s3:ListStorageLensGroups",
                "apigateway:DELETE",
                "events:ListRuleNamesByTarget",
                "apigateway:PATCH",
                "events:ListRules",
                "cloudformation:UpdateStack",
                "events:RemoveTargets",
                "lambda:DeleteFunction",
                "logs:FilterLogEvents",
                "apigateway:GET",
                "events:ListTargetsByRule",
                "cloudformation:ListStackResources",
                "iam:GetRole",
                "events:DescribeRule",
                "s3:PutAccountPublicAccessBlock",
                "apigateway:PUT",
                "s3:ListAccessPoints",
                "lambda:GetFunction",
                "s3:CreateStorageLensGroup",
                "s3:ListJobs",
                "route53:ListHostedZones",
                "route53:ChangeResourceRecordSets",
                "s3:ListMultiRegionAccessPoints",
                "cloudformation:DescribeStacks",
                "s3:ListStorageLensConfigurations",
                "events:DeleteRule",
                "events:PutTargets",
                "lambda:UpdateFunctionCode",
                "s3:GetAccountPublicAccessBlock",
                "lambda:AddPermission",
                "s3:ListAllMyBuckets",
                "cloudformation:CreateStack",
                "cloudformation:DeleteStack",
                "s3:PutAccessPointPublicAccessBlock",
                "lambda:*",
                "apigateway:POST",
                "s3:CreateJob"
            ],
            "Resource": "*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "iam:PassRole",
                "s3:CreateBucket"
            ],
            "Resource": [
                "arn:aws:s3:::zappa-*",
                "arn:aws:iam::832242454463:role/*-ZappaLambdaExecutionRole"
            ]
        },
        {
            "Sid": "VisualEditor2",
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::zappa-*"
        }
    ]
}
            
        

To use this in your own project, you'll have to change the User identifier in the Resource spec "arn:aws:iam::832242454463:role/*-ZappaLambdaExecutionRole". Once all that is working, the rest is pretty easy, just have to configure the AWS CLI and run the right zappa commands.

.github/workflows/CICD.yml
            
...
dev-deploy:
needs: build-artifact
runs-on: ubuntu-latest
# These permissions are needed to interact with GitHub's OIDC Token endpoint
permissions:
    id-token: write
    contents: read

steps:
    - name: Checkout Code
    uses: actions/checkout@v4

    - name: Install Dependencies
    run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt

    - name: Download Zip
    uses: actions/download-artifact@v3
    with:
        name: ctbus_site
    
    - name: Configure AWS Credentials
    uses: aws-actions/configure-aws-credentials@v4
    with:
        role-to-assume: arn:aws:iam::832242454463:role/ZappaUserRole
        aws-region: us-east-1
    
    - name: Setup AWS Profile
    run: |
        aws configure set region us-east-1 --profile default
        aws configure set aws_access_key_id ${{ env.AWS_ACCESS_KEY_ID }} --profile default
        aws configure set aws_secret_access_key ${{ env.AWS_SECRET_ACCESS_KEY }} --profile default
        aws configure set aws_session_token ${{ env.AWS_SESSION_TOKEN }} --profile default
    
    - name: Deploy to Dev
    run: |
        zappa update dev --zip ctbus_site.zip --json

    - name: Dump Logs
    if: always()
    run: |
        sleep 30
        zappa tail dev --json --since 10m --disable-keep-open
...
            
        

Testing an Active Website

This is a part I have no prior experience with. I need tools which I can run in GHAs that will perform a healthy subset of the possible classes of tests for web apps. This article on How to Perform Website QA Testing is pretty helpful in creating a mental framework for this, but probably goes more in depth than I need. My thinking is that the major ones to focus on will be functionality testing, cross-browser/cross-device testing, accessibility testing, and penetration testing.

The first two, functionality and cross-browser/cross-device testing, can both be handled by Selenium, which is nice enough to have a solid Python interface for us to use (as well as every other language you could want). The core idea of selenium is that you can set up a WebDriver object that mimics the behavior of whichever browser you want and then use that object to perform actions on the website as the user would and check that the results are as expected. This allows you to abstract away problems with display, browser, os, etc. and just focus on testing critical user paths. So the first step is to setup a WebDriver in PyTest.

tests/e2e/conftest.py
            
import pytest
from selenium import webdriver

from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.chrome.options import Options as ChromeOptions
from webdriver_manager.chrome import ChromeDriverManager
from webdriver_manager.core.os_manager import ChromeType

@pytest.fixture
def arg(request):
    return request.getfixturevalue(request.param)

@pytest.fixture()
def setup_chrome():
    options = ChromeOptions()
    options_arr = [
        "--headless",
        "--disable-gpu",
        "--window-size=1920,1200",
        "--ignore-certificate-errors",
        "--disable-extensions",
        "--no-sandbox",
        "--disable-dev-shm-usage",
    ]
    for option in options_arr:
        options.add_argument(option)

    driver = webdriver.Chrome(
        service=ChromeService(ChromeDriverManager().install()), options=options
    )

    yield driver

    driver.close()


@pytest.fixture()
def setup_chrome_mobile():
    options = ChromeOptions()
    options_arr = [
        "--headless",
        "--disable-gpu",
        "--window-size=1080,1920",
        "--ignore-certificate-errors",
        "--disable-extensions",
        "--no-sandbox",
        "--disable-dev-shm-usage",
        "--user-agent=Mozilla/5.0 (Linux; Android 11; Pixel 4 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Mobile Safari/537.36",
    ]
    for option in options_arr:
        options.add_argument(option)

    driver = webdriver.Chrome(
        service=ChromeService(ChromeDriverManager().install()), options=options
    )

    yield driver

    driver.close()
            
        

OK I went overboard and setup two WebDrivers: one for testing Chrome and one for testing Chrome on a mobile device. I also created this weird arg function so that I can use PyTest fixture parameterization to run the same tests on each of my WebDriver fixtures.

tests/e2e/test_web.py
            
import pytest
from . import DEV_URL


@pytest.mark.parametrize(
    "arg",
    [
        "setup_chrome",
        "setup_chrome_mobile",
        "setup_chromium",
        "setup_edge",
        "setup_firefox",
    ],
    indirect=True,
)
def test_title(arg):
    driver = arg
    driver.get(DEV_URL)
    assert driver.title == "Charlie Bushman"
            
        

Obviously this test is little more than a PoC for running tests on multiple web drivers though. In order to actually start testing useful features, I read the Selenium docs and learned about their PageObject testing structure. Under this paradigm, each page of the site is abstracted to a PageObject that models objects on the page within the test code. These PageObjects can then be used to perform higher-level feature testing without worrying about implementation changes in the actual page. Here's an example of that with a button for hiding graphs (not the most critical, but nice to know it will work).

tests/e2e/test_web.py
            
import pytest
from . import DEV_URL
from selenium import webdriver
from selenium.webdriver.common.by import By

class Page:
    def __init__(self, driver: webdriver, url: str):
        self.driver = driver
        driver.get(url)

class Comps(Page):
def __init__(self, driver: webdriver, url: str):
    super().__init__(driver, f"{url}/projects/comps")

    self.hide_graphs_button = self.driver.find_element(
        By.CLASS_NAME, "interest-button"
    )
    self.lorenz_plots = self.driver.find_element(By.ID, "LorenzPlots")

@pytest.mark.parametrize(
"arg",
[
    "setup_chrome",
    "setup_chrome_mobile",
    "setup_chromium",
    "setup_edge",
    "setup_firefox",
],
indirect=True,
)
def test_comps(arg):
    driver = arg
    driver.get(DEV_URL)

    comps = Comps(driver, DEV_URL)
    comps.hide_graphs_button.click()
    assert not comps.lorenz_plots.is_displayed()

    comps.hide_graphs_button.click()
    assert comps.lorenz_plots.is_displayed()
            
        

This idea can be extended to any other user flows that need testing (but I don't really have any on the site right now, mostly just readable content). So it's time to move on to the next type of website testing: accessibility testing. Fortunately, there is a prebuilt GHA from A11ywatch that has all the features one could ask for and more that one didn't think to ask for. Including it in the pipeline is not quite a piece of cake though, and that's because of the crazy API Gateway URL of the dev site deployment. (This hasn't been resolved yet but I'll try to update this when it is. For now, here's example code for running their action just on the home page of my site.)

.github/workflows/CICD.yml
            
web-accessibility-eval:
needs: dev-deploy
runs-on: ubuntu-latest

steps:
    - name: Web Accessibility Eval
    uses: a11ywatch/github-action@v2.1.9
    with:
        WEBSITE_URL: https://12345abcde.execute-api.us-east-1.amazonaws.com/dev/
        SITE_WIDE: true
        SUBDOMAINS: false
        TLD: false
        SITEMAP: true
        FAIL_ERRORS_COUNT: 15
        LIST: true
        FIX: false
        UPGRADE: false
        UPLOAD: true
    env:
        DEFAULT_RUNNERS: htmlcs,axe
        PAGEMIND_IGNORE_WARNINGS: true
        AI_DISABLED: false
            
        

The final testing variant that needs to be included in the pipeline is penetration testing. Although, admittedly, I'm not super worried about the security of a website that neither stores nor accepts user data (for now) and is open source. Regardless, a super, prebuilt GHA comes to my rescue again in the form of the ZAP Scan Action. Super easy to implement and provides a very helpful report at the end with all its findings.

.github/workflows/CICD.yml
            
zap-deployment:
needs: dev-deploy
runs-on: ubuntu-latest

steps:
    - name: ZAP Scan
    uses: zaproxy/action-baseline@v0.10.0
    with:
        target: DEV_URL
            
        

Conclusions

I wasn't convinced this would actually be useful at the start of this project. It was more of a learning project and something that might come in handy if this site ever scales up significatly. But I was absolutely wrong! Just in the process of building the PR to add this workflow I caught so many things that I normally wouldn't until later down the line or at all. I could see within minutes whether or not site configuration changes had messed up the actual deployment. I made low contrast elements and images without alt text more accessible. I added Content Security Policy headers to prevent common attacks. The positive impact that this pipeline has had on my web app practices and should continue to have on this site when I forget these practices in the future or encounter new terrain is enourmous.

CI/CD Workflow Success

And it's free. As long as the project is open source on GitHub, I have as much GHA runner usage as I can handle. If you have a web app deployed through zappa, there's almost no downside to implementing a similar CI/CD pipeline to improve the quality, security, and accessibility of your app.