From f39228f46550c32503c22af23d6dec4fc6a78f59 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Tue, 26 May 2026 22:44:09 +0200 Subject: [PATCH 1/4] move docker compose to infra folder --- .github/workflows/ci.yml | 2 +- .../docker-compose.yml | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) rename docker-compose.yml => infra/docker-compose.yml (95%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fb9cd7..6c74293 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,7 +342,7 @@ jobs: run: cp services/py-genai-helper/.env.example services/py-genai-helper/.env - name: Build all images via docker compose - run: docker compose build + run: docker compose -f infra/docker-compose.yml build - name: Verify expected image tags exist # Compose tags images as `-:latest`; project name diff --git a/docker-compose.yml b/infra/docker-compose.yml similarity index 95% rename from docker-compose.yml rename to infra/docker-compose.yml index 1ed0c34..b28adde 100644 --- a/docker-compose.yml +++ b/infra/docker-compose.yml @@ -1,9 +1,11 @@ +name: team-devoops + services: py-genai-helper: - build: services/py-genai-helper/ + build: ../services/py-genai-helper/ container_name: py-genai-helper env_file: - - services/py-genai-helper/.env + - ../services/py-genai-helper/.env expose: - 5000 labels: @@ -17,7 +19,7 @@ services: - proxy organization-service: - build: services/spring-organization + build: ../services/spring-organization container_name: organization-service expose: - 8080 @@ -41,7 +43,7 @@ services: - data member-service: - build: services/spring-member + build: ../services/spring-member container_name: member-service expose: - 8080 @@ -65,7 +67,7 @@ services: - data event-service: - build: services/spring-event + build: ../services/spring-event container_name: event-service expose: - 8080 @@ -89,7 +91,7 @@ services: - data feedback-service: - build: services/spring-feedback + build: ../services/spring-feedback container_name: feedback-service expose: - 8080 @@ -113,7 +115,7 @@ services: - data finance-service: - build: services/spring-finance + build: ../services/spring-finance container_name: finance-service expose: - 8080 @@ -137,7 +139,7 @@ services: - data letter-service: - build: services/spring-letter + build: ../services/spring-letter container_name: letter-service expose: - 8080 @@ -179,7 +181,7 @@ services: - proxy web-client: - build: web-client/ + build: ../web-client/ container_name: web-client expose: - 8080 From c53195640a5c29f642324dcda07eda1c17073e40 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Fri, 29 May 2026 10:03:52 +0200 Subject: [PATCH 2/4] add cd pipeline and terraforminfrastructure --- .github/workflows/cd.yml | 54 ++++++++ .github/workflows/infra.yml | 74 ++++++++++ infra/ansible/.gitignore | 1 + infra/ansible/ansible.cfg | 4 + infra/ansible/inventory.yml.example | 9 ++ infra/ansible/playbook.yml | 91 ++++++++++++ infra/terraform/.gitignore | 9 ++ infra/terraform/.terraform.lock.hcl | 22 +++ infra/terraform/main.tf | 169 +++++++++++++++++++++++ infra/terraform/outputs.tf | 9 ++ infra/terraform/terraform.tfvars.example | 11 ++ infra/terraform/variables.tf | 35 +++++ 12 files changed, 488 insertions(+) create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/infra.yml create mode 100644 infra/ansible/.gitignore create mode 100644 infra/ansible/ansible.cfg create mode 100644 infra/ansible/inventory.yml.example create mode 100644 infra/ansible/playbook.yml create mode 100644 infra/terraform/.gitignore create mode 100644 infra/terraform/.terraform.lock.hcl create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/outputs.tf create mode 100644 infra/terraform/terraform.tfvars.example create mode 100644 infra/terraform/variables.tf diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..f48fb7f --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,54 @@ +name: CD + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + deploy: + name: deploy (Ansible) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Install Ansible + run: | + python -m pip install --upgrade pip + pip install ansible + + - name: Write SSH private key + run: | + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + + - name: Write genai .env file + env: + GENAI_ENV_CONTENT: ${{ secrets.GENAI_ENV_CONTENT }} + run: printf '%s\n' "$GENAI_ENV_CONTENT" > /tmp/genai.env + + - name: Create inventory + run: | + cat > /tmp/inventory.yml << INVENTORY + all: + hosts: + team_devoops_vm: + ansible_host: ${{ secrets.VM_HOST }} + ansible_user: azureuser + ansible_ssh_private_key_file: ~/.ssh/deploy_key + ansible_ssh_common_args: "-o StrictHostKeyChecking=no" + INVENTORY + + - name: Run Ansible playbook + run: | + ansible-playbook \ + -i /tmp/inventory.yml \ + infra/ansible/playbook.yml \ + -e "repo_url=https://github.com/${{ github.repository }}.git" \ + -e "genai_env_file=/tmp/genai.env" diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml new file mode 100644 index 0000000..d2bd76e --- /dev/null +++ b/.github/workflows/infra.yml @@ -0,0 +1,74 @@ +name: Infra (Terraform) + +on: + workflow_dispatch: + inputs: + action: + description: Terraform action to run + required: true + default: plan + type: choice + options: + - plan + - apply + - destroy + +# OIDC: allows the job to exchange a GitHub token for an Azure access token. +# No client secret is required. +permissions: + id-token: write + contents: read + +jobs: + terraform: + name: terraform (${{ inputs.action }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: infra/terraform + env: + ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + ARM_USE_OIDC: "true" + + steps: + - 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: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Write tfvars + env: + SSH_PUBLIC_KEY: ${{ secrets.VM_SSH_PUBLIC_KEY }} + run: printf 'admin_ssh_public_key = "%s"\n' "$SSH_PUBLIC_KEY" > terraform.tfvars + + - name: Terraform init + run: terraform init + + - name: Terraform plan + if: inputs.action == 'plan' || inputs.action == 'apply' + run: terraform plan -out=tfplan + + - name: Terraform apply + if: inputs.action == 'apply' + run: terraform apply -auto-approve tfplan + + - name: Show VM public IP + if: inputs.action == 'apply' + run: | + ip=$(terraform output -raw vm_public_ip) + echo "::notice::VM public IP: $ip" + echo "Add this as the VM_HOST secret in the GitHub 'production' environment." + + - name: Terraform destroy + if: inputs.action == 'destroy' + run: terraform destroy -auto-approve diff --git a/infra/ansible/.gitignore b/infra/ansible/.gitignore new file mode 100644 index 0000000..b761c7a --- /dev/null +++ b/infra/ansible/.gitignore @@ -0,0 +1 @@ +inventory.yml diff --git a/infra/ansible/ansible.cfg b/infra/ansible/ansible.cfg new file mode 100644 index 0000000..5be731f --- /dev/null +++ b/infra/ansible/ansible.cfg @@ -0,0 +1,4 @@ +[defaults] +host_key_checking = False +remote_user = azureuser +stdout_callback = yaml diff --git a/infra/ansible/inventory.yml.example b/infra/ansible/inventory.yml.example new file mode 100644 index 0000000..618eb10 --- /dev/null +++ b/infra/ansible/inventory.yml.example @@ -0,0 +1,9 @@ +# Copy this file to inventory.yml (gitignored) and fill in the VM's public IP. +# Get the IP after terraform apply: +# terraform -chdir=infra/terraform output -raw vm_public_ip +all: + hosts: + team_devoops_vm: + ansible_host: + ansible_user: azureuser + ansible_ssh_private_key_file: ~/.ssh/id_ed25519 diff --git a/infra/ansible/playbook.yml b/infra/ansible/playbook.yml new file mode 100644 index 0000000..77259c2 --- /dev/null +++ b/infra/ansible/playbook.yml @@ -0,0 +1,91 @@ +--- +- name: Deploy team-devoops to Azure VM + hosts: all + become: true + vars: + app_dir: /opt/team-devoops + repo_url: https://github.com/your-org/team-devoops.git # override via -e + branch: main + + tasks: + # -------------------------------------------------------------------------- + # Docker installation (idempotent) + # -------------------------------------------------------------------------- + - name: Install prerequisite packages + apt: + name: + - ca-certificates + - curl + - gnupg + - git + state: present + update_cache: true + + - name: Create /etc/apt/keyrings directory + file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + + - name: Download Docker GPG key + get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + force: false + + - name: Add Docker apt repository + apt_repository: + repo: >- + deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu + {{ ansible_distribution_release }} stable + state: present + filename: docker + + - name: Install Docker packages + apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + state: present + update_cache: true + + - name: Add admin user to docker group + user: + name: "{{ ansible_user }}" + groups: docker + append: true + + - name: Start and enable Docker + systemd: + name: docker + state: started + enabled: true + + # -------------------------------------------------------------------------- + # Application deployment + # -------------------------------------------------------------------------- + - name: Clone or update repository + git: + repo: "{{ repo_url }}" + dest: "{{ app_dir }}" + version: "{{ branch }}" + force: true + update: true + + - name: Write py-genai-helper .env file + copy: + src: "{{ genai_env_file }}" + dest: "{{ app_dir }}/services/py-genai-helper/.env" + mode: "0600" + + - name: Deploy with docker compose + shell: > + docker compose -f infra/docker-compose.yml up -d --build --remove-orphans + args: + chdir: "{{ app_dir }}" + environment: + COMPOSE_HTTP_TIMEOUT: "120" diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 0000000..de55290 --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,9 @@ +# Terraform state — may contain secrets, never commit +*.tfstate +*.tfstate.backup + +# Variable files — contain sensitive values like SSH keys +*.tfvars + +# Downloaded provider plugins +.terraform/ diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..9c56dce --- /dev/null +++ b/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.74.0" + constraints = "~> 4.0" + hashes = [ + "h1:yvqCYq9U8R+ujUFUFM1lHLvJqd+s5iqFO5MqCiqoHFA=", + "zh:004decccb53c332710894b890f3a5fd724aeeb440c32a8d337d6a2fe9f4427cc", + "zh:10ee232dfa76c987cbd226f81f825bbbe36786a8097fcb811ff655f2b471959c", + "zh:43ffac27efbbcc741ec6e0e96e0def151ddb04d7ce7fd0b67032dc4cdaa20e90", + "zh:459438a78b6cb43ba09e2c11832c6234848a08ab264dc2efe809db8b073057d6", + "zh:4f156cb69b8ed3d43b52fd90106d06609736019bc79faf6c1695aa942bbdade4", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:921285f6e9beb02a7482bb8036e6b032255bc0c4aa61a11def63870c1ee10672", + "zh:994cf51bdc8aefa7e8a93474a322c9231df3512e85bc603c9295343ebc31228c", + "zh:9c6136ed2ae6cbbbfc57e74cdd7b83af9faea5ca3c7e3fa82877aca0b215fd27", + "zh:a0349f105af1882bf9d795a6fdac2fbe71f2e588ca6f4587b87de7b5423a0be8", + "zh:c7a91501928e23d5d8f088909aaa633a13d5f032fbc2043c6618305beb090058", + "zh:f11cb049e16a459f799c4f394ac20f8e9b3d0ef210cf56cb0850e0beffeaf4e1", + ] +} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..ed8b6a7 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,169 @@ +terraform { + required_version = ">= 1.7" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } + + # Local state is used by default (fine for course / solo use). + # For shared team use, uncomment and provision the storage account once: + # az group create -n rg-tfstate -l germanywestcentral + # az storage account create -n stteamdevoopstf -g rg-tfstate --sku Standard_LRS + # az storage container create -n tfstate --account-name stteamdevoopstf + # + # backend "azurerm" { + # resource_group_name = "rg-tfstate" + # storage_account_name = "stteamdevoopstf" + # container_name = "tfstate" + # key = "team-devoops.tfstate" + # } +} + +provider "azurerm" { + features {} + # Credentials are supplied via environment variables: + # ARM_CLIENT_ID, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID + # and either ARM_CLIENT_SECRET or ARM_USE_OIDC=true (GitHub Actions OIDC). +} + +locals { + prefix = var.project_name + tags = { + project = var.project_name + managed_by = "terraform" + } +} + +# ------------------------------------------------------------------------------ +# Resource group +# ------------------------------------------------------------------------------ +resource "azurerm_resource_group" "main" { + name = var.resource_group_name + location = var.location + tags = local.tags +} + +# ------------------------------------------------------------------------------ +# Networking +# ------------------------------------------------------------------------------ +resource "azurerm_virtual_network" "main" { + name = "vnet-${local.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + address_space = ["10.0.0.0/16"] + tags = local.tags +} + +resource "azurerm_subnet" "main" { + name = "snet-${local.prefix}" + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.0.1.0/24"] +} + +resource "azurerm_network_security_group" "main" { + name = "nsg-${local.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + tags = local.tags + + security_rule { + name = "allow-ssh" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + security_rule { + name = "allow-http" + priority = 110 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "80" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + security_rule { + name = "allow-https" + priority = 120 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "443" + source_address_prefix = "*" + destination_address_prefix = "*" + } +} + +resource "azurerm_subnet_network_security_group_association" "main" { + subnet_id = azurerm_subnet.main.id + network_security_group_id = azurerm_network_security_group.main.id +} + +resource "azurerm_public_ip" "main" { + name = "pip-${local.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + allocation_method = "Static" + sku = "Standard" + tags = local.tags +} + +resource "azurerm_network_interface" "main" { + name = "nic-${local.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + tags = local.tags + + ip_configuration { + name = "ipconfig-${local.prefix}" + subnet_id = azurerm_subnet.main.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.main.id + } +} + +# ------------------------------------------------------------------------------ +# Virtual machine +# ------------------------------------------------------------------------------ +resource "azurerm_linux_virtual_machine" "main" { + name = "vm-${local.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + size = var.vm_size + admin_username = var.admin_username + network_interface_ids = [azurerm_network_interface.main.id] + tags = local.tags + + admin_ssh_key { + username = var.admin_username + public_key = var.admin_ssh_public_key + } + + os_disk { + caching = "ReadWrite" + storage_account_type = "StandardSSD_LRS" + disk_size_gb = 64 + } + + source_image_reference { + publisher = "Canonical" + offer = "ubuntu-24_04-lts" + sku = "server" + version = "latest" + } + + disable_password_authentication = true +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..d551285 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "vm_public_ip" { + description = "Public IP of the VM. Copy this to the VM_HOST secret in the GitHub 'production' environment." + value = azurerm_public_ip.main.ip_address +} + +output "ssh_connection" { + description = "SSH connection string for the VM" + value = "ssh ${var.admin_username}@${azurerm_public_ip.main.ip_address}" +} diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example new file mode 100644 index 0000000..6f7792a --- /dev/null +++ b/infra/terraform/terraform.tfvars.example @@ -0,0 +1,11 @@ +# Copy this file to terraform.tfvars (already gitignored) and fill in values. +# Do NOT commit terraform.tfvars — it contains the SSH public key. + +resource_group_name = "rg-team-devoops" +location = "germanywestcentral" +vm_size = "Standard_D2s_v3" +admin_username = "azureuser" +project_name = "team-devoops" + +# Paste the full content of your SSH public key (e.g. ~/.ssh/id_ed25519.pub): +admin_ssh_public_key = "ssh-ed25519 AAAA... your-key-comment" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..cae8a0a --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,35 @@ +variable "resource_group_name" { + description = "Name of the Azure resource group to create and manage" + type = string + default = "rg-team-devoops" +} + +variable "location" { + description = "Azure region for all resources" + type = string + default = "uaenorth" +} + +variable "vm_size" { + description = "Azure VM size" + type = string + default = "Standard_D2_v4" +} + +variable "admin_username" { + description = "Admin username for the VM" + type = string + default = "azureuser" +} + +variable "admin_ssh_public_key" { + description = "SSH public key content for VM admin access (paste the full key string)" + type = string + sensitive = true +} + +variable "project_name" { + description = "Short name used to prefix all Azure resources" + type = string + default = "team-devoops" +} From 21e8c75abd6680ade3a2f51423c0b961dda1125f Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Fri, 29 May 2026 10:05:45 +0200 Subject: [PATCH 3/4] add workflow_dispatch for manual cd trigger --- .github/workflows/cd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f48fb7f..3e38554 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: read From dac449c9fa32659fe0b5d31d0ddc3acaa11765f5 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Fri, 29 May 2026 12:16:00 +0200 Subject: [PATCH 4/4] add https to infrastructure and proxy --- .github/workflows/infra.yml | 1 + infra/docker-compose.yml | 68 ++++++++++++++++++++++++++----------- infra/terraform/main.tf | 23 ++++++------- infra/terraform/outputs.tf | 5 +++ 4 files changed, 66 insertions(+), 31 deletions(-) diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index d2bd76e..70c47c2 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -32,6 +32,7 @@ jobs: ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} ARM_USE_OIDC: "true" + ARM_USE_AZUREAD: "true" steps: - uses: actions/checkout@v4 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index b28adde..94c3bda 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -10,8 +10,10 @@ services: - 5000 labels: - "traefik.enable=true" - - "traefik.http.routers.py-genai-helper.entrypoints=web" - - "traefik.http.routers.py-genai-helper.rule=PathPrefix(`/api/v1/helper`)" + - "traefik.http.routers.py-genai-helper.entrypoints=websecure" + - "traefik.http.routers.py-genai-helper.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/helper`)" + - "traefik.http.routers.py-genai-helper.tls=true" + - "traefik.http.routers.py-genai-helper.tls.certresolver=le" - "traefik.http.middlewares.helper-stripprefix.stripprefix.prefixes=/api/v1/helper" - "traefik.http.routers.py-genai-helper.middlewares=helper-stripprefix" - "traefik.http.services.py-genai-helper.loadbalancer.server.port=5000" @@ -33,8 +35,10 @@ services: - SPRING_JPA_HIBERNATE_DDL_AUTO=update labels: - "traefik.enable=true" - - "traefik.http.routers.organization-service.entrypoints=web" - - "traefik.http.routers.organization-service.rule=PathPrefix(`/api/v1/organization`)" + - "traefik.http.routers.organization-service.entrypoints=websecure" + - "traefik.http.routers.organization-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/organization`)" + - "traefik.http.routers.organization-service.tls=true" + - "traefik.http.routers.organization-service.tls.certresolver=le" - "traefik.http.middlewares.organization-stripprefix.stripprefix.prefixes=/api/v1/organization" - "traefik.http.routers.organization-service.middlewares=organization-stripprefix" - "traefik.http.services.organization-service.loadbalancer.server.port=8080" @@ -57,8 +61,10 @@ services: - SPRING_JPA_HIBERNATE_DDL_AUTO=update labels: - "traefik.enable=true" - - "traefik.http.routers.member-service.entrypoints=web" - - "traefik.http.routers.member-service.rule=PathPrefix(`/api/v1/members`)" + - "traefik.http.routers.member-service.entrypoints=websecure" + - "traefik.http.routers.member-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/members`)" + - "traefik.http.routers.member-service.tls=true" + - "traefik.http.routers.member-service.tls.certresolver=le" - "traefik.http.middlewares.member-stripprefix.stripprefix.prefixes=/api/v1/members" - "traefik.http.routers.member-service.middlewares=member-stripprefix" - "traefik.http.services.member-service.loadbalancer.server.port=8080" @@ -81,8 +87,10 @@ services: - SPRING_JPA_HIBERNATE_DDL_AUTO=update labels: - "traefik.enable=true" - - "traefik.http.routers.event-service.entrypoints=web" - - "traefik.http.routers.event-service.rule=PathPrefix(`/api/v1/events`)" + - "traefik.http.routers.event-service.entrypoints=websecure" + - "traefik.http.routers.event-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/events`)" + - "traefik.http.routers.event-service.tls=true" + - "traefik.http.routers.event-service.tls.certresolver=le" - "traefik.http.middlewares.event-stripprefix.stripprefix.prefixes=/api/v1/events" - "traefik.http.routers.event-service.middlewares=event-stripprefix" - "traefik.http.services.event-service.loadbalancer.server.port=8080" @@ -105,8 +113,10 @@ services: - SPRING_JPA_HIBERNATE_DDL_AUTO=update labels: - "traefik.enable=true" - - "traefik.http.routers.feedback-service.entrypoints=web" - - "traefik.http.routers.feedback-service.rule=PathPrefix(`/api/v1/feedback`)" + - "traefik.http.routers.feedback-service.entrypoints=websecure" + - "traefik.http.routers.feedback-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/feedback`)" + - "traefik.http.routers.feedback-service.tls=true" + - "traefik.http.routers.feedback-service.tls.certresolver=le" - "traefik.http.middlewares.feedback-stripprefix.stripprefix.prefixes=/api/v1/feedback" - "traefik.http.routers.feedback-service.middlewares=feedback-stripprefix" - "traefik.http.services.feedback-service.loadbalancer.server.port=8080" @@ -129,8 +139,10 @@ services: - SPRING_JPA_HIBERNATE_DDL_AUTO=update labels: - "traefik.enable=true" - - "traefik.http.routers.finance-service.entrypoints=web" - - "traefik.http.routers.finance-service.rule=PathPrefix(`/api/v1/finance`)" + - "traefik.http.routers.finance-service.entrypoints=websecure" + - "traefik.http.routers.finance-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/finance`)" + - "traefik.http.routers.finance-service.tls=true" + - "traefik.http.routers.finance-service.tls.certresolver=le" - "traefik.http.middlewares.finance-stripprefix.stripprefix.prefixes=/api/v1/finance" - "traefik.http.routers.finance-service.middlewares=finance-stripprefix" - "traefik.http.services.finance-service.loadbalancer.server.port=8080" @@ -153,8 +165,10 @@ services: - SPRING_JPA_HIBERNATE_DDL_AUTO=update labels: - "traefik.enable=true" - - "traefik.http.routers.letter-service.entrypoints=web" - - "traefik.http.routers.letter-service.rule=PathPrefix(`/api/v1/letters`)" + - "traefik.http.routers.letter-service.entrypoints=websecure" + - "traefik.http.routers.letter-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/letters`)" + - "traefik.http.routers.letter-service.tls=true" + - "traefik.http.routers.letter-service.tls.certresolver=le" - "traefik.http.middlewares.letter-stripprefix.stripprefix.prefixes=/api/v1/letters" - "traefik.http.routers.letter-service.middlewares=letter-stripprefix" - "traefik.http.services.letter-service.loadbalancer.server.port=8080" @@ -171,11 +185,13 @@ services: - SWAGGER_JSON=/app/openapi.yaml - BASE_URL=/docs volumes: - - ./api/openapi.yaml:/app/openapi.yaml:ro + - ../api/openapi.yaml:/app/openapi.yaml:ro labels: - "traefik.enable=true" - - "traefik.http.routers.api-docs.entrypoints=web" - - "traefik.http.routers.api-docs.rule=PathPrefix(`/docs`)" + - "traefik.http.routers.api-docs.entrypoints=websecure" + - "traefik.http.routers.api-docs.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/docs`)" + - "traefik.http.routers.api-docs.tls=true" + - "traefik.http.routers.api-docs.tls.certresolver=le" - "traefik.http.services.api-docs.loadbalancer.server.port=8080" networks: - proxy @@ -195,8 +211,10 @@ services: - py-genai-helper labels: - "traefik.enable=true" - - "traefik.http.routers.web-client.entrypoints=web" - - "traefik.http.routers.web-client.rule=PathPrefix(`/`)" + - "traefik.http.routers.web-client.entrypoints=websecure" + - "traefik.http.routers.web-client.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`)" + - "traefik.http.routers.web-client.tls=true" + - "traefik.http.routers.web-client.tls.certresolver=le" - "traefik.http.services.web-client.loadbalancer.server.port=8080" networks: - proxy @@ -210,11 +228,22 @@ services: - "--providers.docker.exposedByDefault=false" - "--providers.docker.network=proxy" - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + # Redirect all HTTP traffic to HTTPS + - "--entrypoints.web.http.redirections.entrypoint.to=websecure" + - "--entrypoints.web.http.redirections.entrypoint.scheme=https" + # Let's Encrypt (production CA) via HTTP-01 challenge + - "--certificatesresolvers.le.acme.email=04.raphael.frank@tum.de" + - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json" + - "--certificatesresolvers.le.acme.httpchallenge=true" + - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web" ports: - "80:80" + - "443:443" - "8080:8080" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt:/letsencrypt networks: - proxy @@ -240,6 +269,7 @@ services: volumes: member_db_data: + letsencrypt: networks: proxy: diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index ed8b6a7..40ade52 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -8,18 +8,16 @@ terraform { } } - # Local state is used by default (fine for course / solo use). - # For shared team use, uncomment and provision the storage account once: - # az group create -n rg-tfstate -l germanywestcentral - # az storage account create -n stteamdevoopstf -g rg-tfstate --sku Standard_LRS - # az storage container create -n tfstate --account-name stteamdevoopstf - # - # backend "azurerm" { - # resource_group_name = "rg-tfstate" - # storage_account_name = "stteamdevoopstf" - # container_name = "tfstate" - # key = "team-devoops.tfstate" - # } + # Remote state in Azure Blob Storage. Auth uses the same OIDC identity as the provider. + # Bootstrap (run once, manually) — see infra/README or commit history for details. + backend "azurerm" { + resource_group_name = "rg-team-devoops-tfstate" + storage_account_name = "stteamdevoopstfstate" + container_name = "tfstate" + key = "team-devoops.tfstate" + use_oidc = true + use_azuread_auth = true + } } provider "azurerm" { @@ -118,6 +116,7 @@ resource "azurerm_public_ip" "main" { resource_group_name = azurerm_resource_group.main.name allocation_method = "Static" sku = "Standard" + domain_name_label = local.prefix tags = local.tags } diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index d551285..430232f 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -3,6 +3,11 @@ output "vm_public_ip" { value = azurerm_public_ip.main.ip_address } +output "vm_fqdn" { + description = "Public FQDN of the VM (Azure-provided). Used as the Host in Traefik routing and Let's Encrypt." + value = azurerm_public_ip.main.fqdn +} + output "ssh_connection" { description = "SSH connection string for the VM" value = "ssh ${var.admin_username}@${azurerm_public_ip.main.ip_address}"