diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..6bc9152 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,79 @@ +name: Deploy + +on: + push: + branches: [main] + paths: + - 'frontend/**' + - 'backend/**' + - 'infrastructure/**' + - '.github/workflows/deploy.yaml' + +env: + IMAGE_NAME: 'ghcr.io/${{ github.repository }}' + +jobs: + build: + name: 'Build ${{ matrix.app }}' + runs-on: ubuntu-latest + strategy: + matrix: + app: [frontend, backend] + fail-fast: false + permissions: + actions: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: 'ghcr.io' + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 'Build ${{ matrix.app }} with cache and push to registry' + uses: docker/build-push-action@v5 + with: + push: 'true' + tags: '${{ env.IMAGE_NAME }}/${{ matrix.app }}:latest,${{ env.IMAGE_NAME}}/${{ matrix.app }}:${{ github.sha }}' + cache-from: 'type=registry,ref=${{ env.IMAGE_NAME }}:latest' + cache-to: 'type=inline' + context: '${{ matrix.app }}' + + deploy: + name: 'Deploy using Terraform' + runs-on: ubuntu-latest + needs: [build] + env: + ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }} + ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }} + ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }} + ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }} + TF_VAR_revision_suffix: ${{ github.sha }} + TF_VAR_repository_owner: ${{ github.repository_owner }} + TF_VAR_api_key: ${{ secrets.API_KEY }} + TF_VAR_connection_string: ${{ secrets.SUPABASE_CONNECTION_STRING }} + defaults: + run: + working-directory: 'infrastructure' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Init Terraform + run: terraform init + + - name: Run Terraform plan + run: terraform plan + + - name: Run Terraform apply + run: terraform apply -auto-approve diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0569f8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.terraform/ +.terraform.lock.hcl +terraform.tfvars diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 0000000..c7e5617 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,70 @@ +# Infrastructure + +## Kom i gang + +Last ned: + +- WSL +- Git +- Azure CLI + +### 1. Fork repoet + +Fork dette repoet til din egen GitHub-konto (samme hvilken). + +Eventuelt opprett en GitHub-konto hvis du ikke har en fra før. + +### 2. Lag en Azure-konto + +Registrer deg for en gratis Azure-konto [her](https://azure.microsoft.com/free). + +### 3. Autentiser deg med Azure CLI + +```bash +az login +``` + +Følg flyten i nettleseren. + +### 4. Lag Terraform-state + +```bash +./create-terraform-state.sh +``` + +### 5. Lag en Azure-konto for Terraform + +```bash +./create-azure-service-principal.sh +``` + +### 5.1. Legg til secrets i GitHub for Terraform + +Du vil få ut fire verdier: + +```bash +ARM_CLIENT_ID=... +ARM_CLIENT_SECRET=... +ARM_SUBSCRIPTION_ID=... +ARM_TENANT_ID=... +``` + +Lagre disse inn som secrets i GitHub-repoet ditt. +Gå til "Settings" -> "Secrets and variables" -> "Actions" -> "New repository secret". + +### 5.2. Legg til secrets i GitHub for backend + +Du har tidligere laget en connection string for Supabase og en API-nøkkel for frontend/backend. +Lagre disse som secrets i GitHub-repoet ditt med navnene `SUPABASE_CONNECTION_STRING` og `API_KEY`. + +### 6. Push lokale endringer til GitHub + +```bash +git add . +git commit -m "Lage Terraform state og Azure Service Principal" +git push +``` + +### 7. Følg med på GitHub Actions + +Du vil til slutt få ut en URL for frontend og backend. diff --git a/infrastructure/create-azure-service-principal.sh b/infrastructure/create-azure-service-principal.sh new file mode 100755 index 0000000..2445b93 --- /dev/null +++ b/infrastructure/create-azure-service-principal.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +main() { + # Get subscription ID + local subscription_id + subscription_id=$(az account show --query id -o tsv) + + # Create a service principal with Contributor role + local service_principal + service_principal=$(az ad sp create-for-rbac --role="Contributor" --scopes="/subscriptions/$subscription_id" --name "terraform") + + echo "ARM_CLIENT_ID=$(echo "$service_principal" | jq -r '.appId')" + echo "ARM_CLIENT_SECRET=$(echo "$service_principal" | jq -r '.password')" + echo "ARM_SUBSCRIPTION_ID=$subscription_id" + echo "ARM_TENANT_ID=$(echo "$service_principal" | jq -r '.tenant')" +} + +main "$@" diff --git a/infrastructure/create-terraform-state.sh b/infrastructure/create-terraform-state.sh new file mode 100755 index 0000000..f4fde0d --- /dev/null +++ b/infrastructure/create-terraform-state.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +set -eoux pipefail + +create() { + local resource_group_name="$1" + local storage_account_name="$2" + local container_name="$3" + local location="$4" + + # Create resource group + az group create \ + --name "$resource_group_name" \ + --location "$location" + + # Enable resource providers + az provider register \ + --namespace Microsoft.Storage \ + + az provider register \ + --namespace Microsoft.App \ + + sleep 30s + + # Create storage account + az storage account create \ + --resource-group "$resource_group_name" \ + --name "$storage_account_name" \ + --sku Standard_LRS \ + --encryption-services blob + + # Create blob container + az storage container create \ + --name "$container_name" \ + --account-name "$storage_account_name" + + # Generate providers.tf file + printf "Generating providers.tf file...\n" + cat << EOF > providers.tf +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } + + backend "azurerm" { + resource_group_name = "$resource_group_name" + storage_account_name = "$storage_account_name" + container_name = "$container_name" + key = "terraform.tfstate" + } +} + +provider "azurerm" { + features {} +} +EOF + printf "Successfully generated providers.tf file.\n" + +} + +delete() { + local resource_group_name="$1" + local storage_account_name="$2" + local container_name="$3" + + # Remove the generated providers.tf file + rm -f providers.tf + + # Delete blob container + az storage container delete \ + --name "$container_name" \ + --account-name "$storage_account_name" + + # Delete storage account + az storage account delete \ + --name "$storage_account_name" \ + --resource-group "$resource_group_name" + + # Delete resource group + az group delete \ + --name "$resource_group_name" \ + --yes +} + +main() { + local resource_group_name='tfstate' + local container_name='tfstate' + local location='westeurope' + + local storage_account_name + storage_account_name="tfstate$RANDOM" + + if [[ "$1" == "create" ]]; then + create "$resource_group_name" "$storage_account_name" "$container_name" "$location" + elif [[ "$1" == "delete" ]]; then + delete "$resource_group_name" "$storage_account_name" "$container_name" + else + echo "Usage: $0 [create|delete]" + exit 1 + fi +} + +main "$@" diff --git a/infrastructure/main.tf b/infrastructure/main.tf new file mode 100644 index 0000000..d62cc7a --- /dev/null +++ b/infrastructure/main.tf @@ -0,0 +1,117 @@ +locals { + location = "westeurope" +} + +resource "azurerm_resource_group" "cv" { + name = "cv-rg" + location = local.location +} + +resource "azurerm_container_app_environment" "cv" { + name = "${azurerm_resource_group.cv.name}-env" + location = local.location + resource_group_name = azurerm_resource_group.cv.name +} + +resource "azurerm_container_app" "cv-frontend" { + name = "cv-frontend" + container_app_environment_id = azurerm_container_app_environment.cv.id + resource_group_name = azurerm_resource_group.cv.name + revision_mode = "Single" + + template { + container { + name = "frontend" + image = "ghcr.io/${var.repository_owner}/cv-workshop/frontend:latest" + cpu = "0.25" + memory = "0.5Gi" + + env { + name = "BACKEND_URL" + value = "https://${azurerm_container_app.cv-backend.ingress.0.fqdn}" + } + + env { + name = "BACKEND_API_KEY" + value = "backend-api-key" + } + } + + min_replicas = 1 + max_replicas = 1 + revision_suffix = substr(var.revision_suffix, 0, 10) + } + + secret { + name = "backend-api-key" + value = var.api_key + } + + ingress { + target_port = 3000 + external_enabled = true + + traffic_weight { + percentage = 100 + latest_revision = true + } + } +} + +resource "azurerm_container_app" "cv-backend" { + name = "cv-backend" + container_app_environment_id = azurerm_container_app_environment.cv.id + resource_group_name = azurerm_resource_group.cv.name + revision_mode = "Single" + + template { + container { + name = "backend" + image = "ghcr.io/${var.repository_owner}/cv-workshop/backend:latest" + cpu = "0.25" + memory = "0.5Gi" + + env { + name = "AppSettings__FrontendApiKey" + value = "frontend-api-key" + } + + env { + name = "ConnectionStrings__DefaultConnection" + value = "connection-string" + } + } + + min_replicas = 1 + max_replicas = 1 + revision_suffix = substr(var.revision_suffix, 0, 10) + } + + secret { + name = "fontend-api-key" + value = var.api_key + } + + secret { + name = "connection-string" + value = var.connection_string + } + + ingress { + target_port = 8080 + external_enabled = true + + traffic_weight { + percentage = 100 + latest_revision = true + } + } +} + +output "frontend_url" { + value = "https://${azurerm_container_app.cv-frontend.ingress.0.fqdn}" +} + +output "backend_url" { + value = "https://${azurerm_container_app.cv-backend.ingress.0.fqdn}" +} diff --git a/infrastructure/variables.tf b/infrastructure/variables.tf new file mode 100644 index 0000000..f92c16d --- /dev/null +++ b/infrastructure/variables.tf @@ -0,0 +1,21 @@ +variable "revision_suffix" { + description = "Suffix to append to the revision name of the container app. Should normally be git commit hash." + type = string +} + +variable "repository_owner" { + description = "GitHub repository owner for the container image." + type = string +} + +variable "api_key" { + description = "API key for frontend/backend communication." + type = string + sensitive = true +} + +variable "connection_string" { + description = "Connection string for the Supabase database." + type = string + sensitive = true +}