I’ve completed the DevOps mod for Chunk 1 of the Cloud Resume Challenge. I’ve learned so much, especially that there are still things I don’t know, and I’m excited to continue this project and learn a lot more!

Here is what I accomplished in this chunk:

  1. I created a static website using the Hugo static site generator.
  2. I hosted the site using Azure Storage and Azure CDN.
  3. I created a basic workflow using GitHub Actions, including:
    • Deploy Azure infrastructure using Terraform,
    • Build the Hugo static site each time I push a change to my repository,
    • Upload the new website files to Azure Storage, and
    • Clear the Azure CDN cache.
  4. I built a simple test battery using Cypress and appended it to the GitHub Actions workflow.

Challenges

Uploading Files

The first major challenge that I had to overcome was automating the uploading of my website files. I struggled to get this working, constantly getting this error:

ERROR: incorrect usage: source must be an existing directory

The solution was that I had to get my permissions straight for GitHub Actions and in Azure. On the Azure side, the security principle I created for automation needed to have Contributor permissions, and on GitHub, workflow permissions needed to be read and write, “Allow GitHub to create and approve pull requests” had to be checked, and my fine-grained personal access token needed to also be given read and write access to code.

Cypress Testing

Configuring Cypress testing was much more difficult to figure out. I though I had it set up in GitHub Actions correctly, but every time I ran the workflow I would get one of various errors, including the following:

Cannot find module 'cypress'
Require stack:
- /home/runner/work/_actions/cypress-io/github-action/v6/dist/index.js
Action failed. Missing package manager lockfile. Expecting one of package-lock.json (npm), pnpm-lock.yaml (pnpm) or yarn.lock (yarn) in working-directory /home/runner/work/techno-literate-blog/techno-literate-blog

I’m sure some of you reading this can already see the problem, but it wasn’t obvious to me and I didn’t find any documentation that directly stated what the cause was. In short, I hadn’t actually installed Cypress! The first step was to install Node.js locally, and run npm init in my local git repository. Then, I was able to use npm install cypress --save-dev to properly install Cypress.

Cypress appears to be quite powerful and capable, and I’m not sure I’ve entirely wrapped my brain around it yet. I wanted my Cypress tests to just loop through my website and run on every single page, but I don’t think that is how the software is intended to be used. I finally ended up making a couple very specific tests to run every time I update the site: First, make sure every page in the sitemap generated by Hugo is accessible, and second, make sure CSS is rendering properly on the home page (a problem I had with Hugo early on).

Final Thoughts and Code

I’m very proud of myself for getting through this chunk, and especially for tackling the optional DevOps mod. As I stated before, I can tell that there are still many things for me to learn, but I feel much more confident in my ability to do so after successfully getting this site online!

Here is the most important code from this chunk.

Github Actions workflow

name: CI/CD Release Pipeline
on: 
  push:
    branches:
      - main
jobs:

  terraform:
    name: 'Terraform'
    env:
      ARM_CLIENT_ID: ${{ secrets.AZURE_TF_CLIENT_ID }}
      ARM_CLIENT_SECRET: ${{ secrets.AZURE_TF_CLIENT_SECRET }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TF_TENANT_ID }}
      TF_VERSION: 1.6.4
    runs-on: ubuntu-latest
    environment: production
  
    defaults:
      run:
        shell: bash
        working-directory: ./terraform
  
    steps:
      - name: Checkout
        uses: actions/checkout@v4
    
      - name: 'Setup Terraform'
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TF_VERSION }}
    
      - name: 'Terraform fmt'
        id: fmt
        run: terraform fmt -check
    
      - name: 'Terraform init'
        id: init
        run: |
          set -a
          source ../.env.backend
          terraform init \
            -backend-config="resource_group_name=$TF_VAR_state_resource_group_name" \
            -backend-config="storage_account_name=$TF_VAR_state_storage_account_name"          

      - name: Terraform validate
        id: validate
        run: terraform validate -no-color
    
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -var-file="variables.tfvars"
    
      - name: Terraform apply
        id: apply
        run: terraform apply -auto-approve -var-file="variables.tfvars"

  build-hugo:
    runs-on: ubuntu-latest
    needs: terraform
    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}
    permissions:
      contents: write
    steps:
      - name: Git Checkout
        uses: actions/checkout@v4
        with:
          submodules: true
          fetch-depth: 0
          token: ${{ secrets.TOKEN }}
           
      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: 'latest'
          extended: true

      - name: Build Project
        run: hugo --minify

      - name: Release Assets
        uses: peaceiris/actions-gh-pages@v3
        if: github.ref == 'refs/heads/main'
        with:
          personal_token: ${{ secrets.TOKEN }}
          publish_dir: ./public

      - name: Azure Login
        uses: azure/[email protected]
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      
      - name: Upload to Azure Storage
        uses: azure/CLI@v1
        with:
          inlineScript: |
            az storage blob upload-batch -d "\$web" -s ./public --connection-string "${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" --overwrite true
                                   
      - name: Purge Azure CDN Resources
        run:
          az cdn endpoint purge -n ${{ secrets.AZURE_CDN_ENDPOINT }} --profile-name ${{ secrets.AZURE_CDN_PROFILE_NAME }} --content-paths "/*" --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} --no-wait
             
      - name: Dispose Azure Service Principal Session
        run: |
          az logout          
      
  cypress-run:
    name: Cypress run
    needs: build-hugo
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Cypress run
        uses: cypress-io/github-action@v6

Terraform Configuration

resource "azurerm_resource_group" "res-0" {
  location = "westus2"
  name     = "production_storage"
}
resource "azurerm_cdn_endpoint_custom_domain" "res-1" {
  cdn_endpoint_id = var.endpoint_id
  host_name       = var.domain_name
  name            = "resume-techno-literate-com"
  cdn_managed_https {
    certificate_type = "Dedicated"
    protocol_type    = "ServerNameIndication"
  }
  depends_on = [
    azurerm_cdn_endpoint.res-10,
  ]
}
resource "azurerm_storage_account" "res-3" {
  account_replication_type         = "LRS"
  account_tier                     = "Standard"
  allow_nested_items_to_be_public  = false
  cross_tenant_replication_enabled = false
  location                         = "westus2"
  name                             = "tlprodstore"
  resource_group_name              = "production_storage"
  custom_domain {
    name = "resume.techno-literate.com"
  }
  static_website {
    error_404_document = "404.html"
    index_document     = "index.html"
  }
}
resource "azurerm_storage_container" "res-5" {
  name                 = "$web"
  storage_account_name = "tlprodstore"
}
resource "azurerm_cdn_profile" "res-9" {
  location            = "global"
  name                = "technoliterate-cdn"
  resource_group_name = "production_storage"
  sku                 = "Standard_Microsoft"
}
resource "azurerm_cdn_endpoint" "res-10" {
  is_compression_enabled = true
  location               = "global"
  name                   = "techno-literate-cdn"
  origin_host_header     = var.endpoint_origin_hostname
  profile_name           = "technoliterate-cdn"
  resource_group_name    = "production_storage"
  origin {
    host_name = var.endpoint_origin_hostname
    name      = var.endpoint_origin_name
  }
  depends_on = [
    azurerm_cdn_profile.res-9,
  ]
}

Cypress Tests

describe('Sitemap Test', () => {
  let urls = [];

  before(async () => {
    const response = await cy.request('https://resume.techno-literate.com/sitemap.xml');

    urls = Cypress.$(response.body).find('loc').toArray().map(el => el.innerText);
  })

  it('should load each page successfully', () => {
    urls.forEach(cy.visit);
  });
});

describe('CSS test', () => {
  it('should have CSS', () => {
    cy.visit('https://resume.techno-literate.com/').get('.blue').should('have.css','background-color','rgb(29, 33, 44)');
  });
});