From fe64fecaf1125a701fb1ed705772399ac293882e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Sun, 19 Jul 2026 16:15:52 +0200 Subject: [PATCH 1/3] feat: on-demand Azure VM lifecycle with start/stop workflows Provision a VM with no reserved public IP; start/stop GitHub Actions workflows (OIDC auth) create/delete the IP and arm an auto-shutdown timer so idle cost is disk-only. Services now restart: unless-stopped so the stack recovers on boot; deploy resolves the dynamic IP at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-ansible.yml | 43 +++++++++++++- .github/workflows/vm-start.yml | 83 ++++++++++++++++++++++++++++ .github/workflows/vm-stop.yml | 54 ++++++++++++++++++ infra/docker-compose.yml | 5 ++ infra/id_rsa.pub | 2 +- infra/inventory.ini | 4 +- infra/main.tf | 20 ++----- 7 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/vm-start.yml create mode 100644 .github/workflows/vm-stop.yml diff --git a/.github/workflows/deploy-ansible.yml b/.github/workflows/deploy-ansible.yml index e846235d..1f75f4c0 100644 --- a/.github/workflows/deploy-ansible.yml +++ b/.github/workflows/deploy-ansible.yml @@ -9,10 +9,18 @@ on: - 'services/**' - 'infra/**' - 'ansible/**' - - '.github/workflows/ansible-deploy.yml' + - '.github/workflows/deploy-ansible.yml' workflow_dispatch: +permissions: + id-token: write + contents: read + +env: + RESOURCE_GROUP: my-app-rg + VM_NAME: my-vm + jobs: deploy: runs-on: ubuntu-latest @@ -20,6 +28,25 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Azure login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Resolve VM public IP + run: | + IP=$(az vm show -d \ + --resource-group "$RESOURCE_GROUP" \ + --name "$VM_NAME" \ + --query publicIps -o tsv) + if [ -z "$IP" ]; then + echo "::error::VM has no public IP. Run 'Start environment' first." + exit 1 + fi + echo "VM_IP=$IP" >> "$GITHUB_ENV" + - name: Set up Python uses: actions/setup-python@v5 with: @@ -37,8 +64,20 @@ jobs: known_hosts: 'placeholder' # Will be overwritten by ssh-keyscan next - name: Scan VM Host Key + run: ssh-keyscan -H "$VM_IP" >> ~/.ssh/known_hosts + + - name: Write inventory with the resolved IP run: | - ssh-keyscan -H 4.223.70.80 >> ~/.ssh/known_hosts + cat > infra/inventory.ini <> "$GITHUB_ENV" + + - name: Publish the URL + run: | + IP=$(az network public-ip show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$PIP_NAME" \ + --query ipAddress -o tsv) + { + echo "### 🚀 Environment starting" + echo "" + echo "**URL:** http://$IP (allow ~1–2 min for containers to come up)" + echo "" + echo "Auto-shutdown armed for **${SHUTDOWN_UTC} UTC** (in ${{ github.event.inputs.hours }}h)." + echo "Run **Stop environment** to end early and release the IP." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/vm-stop.yml b/.github/workflows/vm-stop.yml new file mode 100644 index 00000000..8ef571be --- /dev/null +++ b/.github/workflows/vm-stop.yml @@ -0,0 +1,54 @@ +name: Stop environment + +# Deallocates the VM (stops compute billing) and deletes the public IP, so the +# only ongoing cost while idle is the OS disk. Safe to run anytime; the VM's +# data and containers survive on the disk and return on the next start. + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + RESOURCE_GROUP: my-app-rg + VM_NAME: my-vm + NIC_NAME: my-nic + IP_CONFIG: internal + PIP_NAME: my-vm-pip + +jobs: + stop: + runs-on: ubuntu-latest + steps: + - name: Azure login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Deallocate the VM + run: az vm deallocate --resource-group "$RESOURCE_GROUP" --name "$VM_NAME" + + - name: Detach and delete the public IP + run: | + az network nic ip-config update \ + --resource-group "$RESOURCE_GROUP" \ + --nic-name "$NIC_NAME" \ + --name "$IP_CONFIG" \ + --remove publicIpAddress \ + --only-show-errors || true + az network public-ip delete \ + --resource-group "$RESOURCE_GROUP" \ + --name "$PIP_NAME" \ + --only-show-errors || true + + - name: Summary + run: | + { + echo "### 🛑 Environment stopped" + echo "" + echo "VM deallocated and public IP released." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 122d166e..31e0476e 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -5,6 +5,7 @@ networks: services: nginx: image: nginx:alpine + restart: unless-stopped ports: - "80:80" volumes: @@ -18,6 +19,7 @@ services: py-recipe-service: build: ../services/py-recipe-service + restart: unless-stopped env_file: .env expose: - "8080" @@ -26,6 +28,7 @@ services: py-help-service: build: ../services/py-help-service + restart: unless-stopped env_file: .env expose: - "8080" @@ -54,6 +57,7 @@ services: spring-api: build: ../services/spring-api + restart: unless-stopped env_file: .env expose: - "8080" @@ -77,6 +81,7 @@ services: web-client: build: ../web-client + restart: unless-stopped expose: - "8080" networks: diff --git a/infra/id_rsa.pub b/infra/id_rsa.pub index b2c38ed1..96c2a738 100644 --- a/infra/id_rsa.pub +++ b/infra/id_rsa.pub @@ -1 +1 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCEoD/FM5r6/dGFx5/5wu29zPXq7MyYe85wgCz7yXukKMBGjqcRhyzuiBDNxUON3SL3xL1T6H9/DA0wmSBoAvvY6QeKdBz5LTLl29Ot84ZI3WivwJdOo/uqokOqU+HDGHQ46yUWe6NcBndwha3QkBU7++HaSzt9WuLozUjvWWkplYWlGtB3khqngJGCDo8Oh63ekXuJYdbfwi7g/rAO9/ipVyCGsTg8oJAItPyQYd8MgBYQMmOoO3k073XDFaBI9TO+WZ3m8eSmzyTyV8pQlFn/Yl7d63SWOLcfGwgIHlFNWhUyoB+FlBKBF3LUpi+negOWbHzL5sKzaJGj9UMvT1MVPcAmd6Fgv7bHbtdhN8x4tCrYLlVKDHTEp1m8aIaa6Y7lIsyvL8R1hH9jAQU4xgN6do7d/fiIMxQrdWIeMSbbvDRGdYI/e51zF7m7Iq5l6gvu8IWU7tX40zyXIaLdPIAYH+DyhQOCCk2CY7QUlU9+Br3SrRGbcpcXV3Gj7IiIf6IDSrr4ZXlB8VbOMpQu9yywHhsdLTiu50HNEqtKMDriAPUzZqRlHj+ZZfQY+Xmbs0lPhPMy5hY3kxcOsnG26jwbEf2iY2z5I8YNM0Jxb6KP0CSHcKHiVlopBaofre3md9Yn0Wq7FWga0h3NfM8wpbooTTq4o5NGD6Y6LrEl4n9glw== dave@wolfsburg +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQChTNMZffIK4v5uX2CCJJHHGjmSBgVfv070mPy1FdmYkpgPAHBgr8gjko5lmpAzcw4eai/xz5d/L4JNFn/H9JE1jJqVt2DCnRbtLJmHHFEMyWEpmIOJy2cLepdBHCsv8aLmo15emJCw0bCOV/YEATJKreCLZgfRGZK/XKI2Ymy1tGGKJN7TsGp84rLc5c9jRlxOiV6s8zt2d9GT3v6ksjcrAvXR1tT2GRxelTvnska4G1M7dTBlBjZa2UEyccA4ZyQGrdRjHU6OmH0Chp5nz2btSxw+J1vO7DkdYDKW8zUcpOwwCySc3Ip7dWllwalaWimRT+2H7x10xoy/g7lCjYWdL7/c9eSr2bdg4iym4ZsJ3JYspKmTgBmVcDurMdQgCWk4j0Dz4GDil+sqecpp6ZjZkydrr2lF9jiQpYChkCb8bVC7eRpnBNYLMo7LaZdj7PLZdUbME6Iiw+Ryv4djj1uWTiJsypShwjQ/VSCOHiBTynlMCZS/Gl0pK8vAQf3rckBektPPagje4FrNiWnOZ9AX5Kj4ttsE/rYJuK7QIgB6J0sD62rNwzA1SF/811wOu4EEE0pd1Vbut8iAFkxvQXkT7Llw5lLH56D+ZdzBYkfQ62UrC1ZA4ZPOZM3F1uE2bdYRlQwPRDxZI4zBjJZv1mTEcbRpP0Fz5HUzv9B+L0Q1Jw== jakob@jakob-desktop diff --git a/infra/inventory.ini b/infra/inventory.ini index a13054ed..6010977c 100644 --- a/infra/inventory.ini +++ b/infra/inventory.ini @@ -1,5 +1,7 @@ +# .github/workflows/deploy-ansible.yml) regenerates the IP at runtime. For a local Ansible +# run, replace __VM_IP__ with the IP printed by the "Start environment" workflow. [web] -4.223.70.80 +__VM_IP__ [all:vars] ansible_user=azureuser diff --git a/infra/main.tf b/infra/main.tf index f5a2ec17..01ba5595 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -1,7 +1,6 @@ -# allowed regions: ["swedencentral","polandcentral","austriaeast","spaincentral","germanywestcentral"] resource "azurerm_resource_group" "rg" { name = "my-app-rg" - location = "swedencentral" + location = "spaincentral" } resource "azurerm_virtual_network" "vnet" { @@ -18,14 +17,6 @@ resource "azurerm_subnet" "subnet" { address_prefixes = ["10.0.2.0/24"] } -resource "azurerm_public_ip" "pip" { - name = "my-vm-pip" - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - allocation_method = "Static" - sku = "Standard" -} - resource "azurerm_network_security_group" "nsg" { name = "my-nsg" location = azurerm_resource_group.rg.location @@ -92,7 +83,10 @@ resource "azurerm_network_interface" "nic" { name = "internal" subnet_id = azurerm_subnet.subnet.id private_ip_address_allocation = "Dynamic" - public_ip_address_id = azurerm_public_ip.pip.id + } + + lifecycle { + ignore_changes = [ip_configuration[0].public_ip_address_id] } } @@ -100,7 +94,3 @@ resource "azurerm_network_interface_security_group_association" "nsg_assoc" { network_interface_id = azurerm_network_interface.nic.id network_security_group_id = azurerm_network_security_group.nsg.id } - -output "public_ip" { - value = azurerm_public_ip.pip.ip_address -} From 25ca179d29a9f9b3f803171b7492650cc3a31e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Sun, 19 Jul 2026 16:24:11 +0200 Subject: [PATCH 2/3] fix: bind workflow input to env to prevent script injection; use minute-level timer The hours input was interpolated directly into run: scripts (command injection). Bind it via env and reference the shell var instead. Switch the input to minutes (default 120) for finer auto-shutdown granularity. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/vm-start.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/vm-start.yml b/.github/workflows/vm-start.yml index ed47fd8c..c2e67be8 100644 --- a/.github/workflows/vm-start.yml +++ b/.github/workflows/vm-start.yml @@ -2,16 +2,16 @@ name: Start environment # Brings the Azure VM online for a demo. Creates a public IP, attaches it, # starts the VM (containers auto-restart via `restart: unless-stopped`), and -# arms an auto-shutdown timer so the VM deallocates itself after `hours`. +# arms an auto-shutdown timer so the VM deallocates itself after `minutes`. # The public URL is printed in the run summary. on: workflow_dispatch: inputs: - hours: - description: "Hours to keep the VM running before it auto-shuts-down" + minutes: + description: "Minutes to keep the VM running before it auto-shuts-down (e.g. 5, 60, 120)" required: true - default: "2" + default: "120" permissions: id-token: write @@ -58,9 +58,11 @@ jobs: - name: Start the VM run: az vm start --resource-group "$RESOURCE_GROUP" --name "$VM_NAME" - - name: Arm auto-shutdown timer (now + hours, UTC) + - name: Arm auto-shutdown timer (now + minutes, UTC) + env: + MINUTES: ${{ github.event.inputs.minutes }} run: | - SHUTDOWN=$(date -u -d "+${{ github.event.inputs.hours }} hours" +%H%M) + SHUTDOWN=$(date -u -d "+${MINUTES} minutes" +%H%M) az vm auto-shutdown \ --resource-group "$RESOURCE_GROUP" \ --name "$VM_NAME" \ @@ -68,6 +70,8 @@ jobs: echo "SHUTDOWN_UTC=$SHUTDOWN" >> "$GITHUB_ENV" - name: Publish the URL + env: + MINUTES: ${{ github.event.inputs.minutes }} run: | IP=$(az network public-ip show \ --resource-group "$RESOURCE_GROUP" \ @@ -78,6 +82,6 @@ jobs: echo "" echo "**URL:** http://$IP (allow ~1–2 min for containers to come up)" echo "" - echo "Auto-shutdown armed for **${SHUTDOWN_UTC} UTC** (in ${{ github.event.inputs.hours }}h)." + echo "Auto-shutdown armed for **${SHUTDOWN_UTC} UTC** (in ${MINUTES} min)." echo "Run **Stop environment** to end early and release the IP." } >> "$GITHUB_STEP_SUMMARY" From 30b1a7de27e8ced4ef8dd3affa6bce56ffb993c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20Sch=C3=B6dl?= Date: Sun, 19 Jul 2026 16:24:48 +0200 Subject: [PATCH 3/3] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 168ae6d7..f2a4c42c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Rancher deployment (Kubernetes): https://devsecops.stud.k8s.aet.cit.tum.de -Azure deployment (Docker Compose): http://4.223.70.80/ +Azure deployment (Docker Compose): Unfortunately, all of us almost ran out of credits on Azure. Therefore, we can only serve it on-demand: [Run the `vm-start` workflow](https://github.com/AET-DevOps26/team-devsecops/actions/workflows/vm-start.yml) to activate an Azure instance for 2 hours (IP address will be printed in the logs). Coverage reports: https://aet-devops26.github.io/team-devsecops/