diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..3e38554 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,55 @@ +name: CD + +on: + push: + branches: + - main + workflow_dispatch: + +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/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/.github/workflows/infra.yml b/.github/workflows/infra.yml new file mode 100644 index 0000000..70c47c2 --- /dev/null +++ b/.github/workflows/infra.yml @@ -0,0 +1,75 @@ +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" + ARM_USE_AZUREAD: "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/docker-compose.yml b/infra/docker-compose.yml similarity index 63% rename from docker-compose.yml rename to infra/docker-compose.yml index 1ed0c34..94c3bda 100644 --- a/docker-compose.yml +++ b/infra/docker-compose.yml @@ -1,15 +1,19 @@ +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: - "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" @@ -17,7 +21,7 @@ services: - proxy organization-service: - build: services/spring-organization + build: ../services/spring-organization container_name: organization-service expose: - 8080 @@ -31,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" @@ -41,7 +47,7 @@ services: - data member-service: - build: services/spring-member + build: ../services/spring-member container_name: member-service expose: - 8080 @@ -55,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" @@ -65,7 +73,7 @@ services: - data event-service: - build: services/spring-event + build: ../services/spring-event container_name: event-service expose: - 8080 @@ -79,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" @@ -89,7 +99,7 @@ services: - data feedback-service: - build: services/spring-feedback + build: ../services/spring-feedback container_name: feedback-service expose: - 8080 @@ -103,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" @@ -113,7 +125,7 @@ services: - data finance-service: - build: services/spring-finance + build: ../services/spring-finance container_name: finance-service expose: - 8080 @@ -127,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" @@ -137,7 +151,7 @@ services: - data letter-service: - build: services/spring-letter + build: ../services/spring-letter container_name: letter-service expose: - 8080 @@ -151,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" @@ -169,17 +185,19 @@ 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 web-client: - build: web-client/ + build: ../web-client/ container_name: web-client expose: - 8080 @@ -193,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 @@ -208,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 @@ -238,6 +269,7 @@ services: volumes: member_db_data: + letsencrypt: networks: proxy: 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..40ade52 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,168 @@ +terraform { + required_version = ">= 1.7" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } + + # 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" { + 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" + domain_name_label = local.prefix + 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..430232f --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,14 @@ +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 "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}" +} 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" +}