diff --git a/.github/terraform-modules.json b/.github/terraform-modules.json index e69de29..120572d 100644 --- a/.github/terraform-modules.json +++ b/.github/terraform-modules.json @@ -0,0 +1,22 @@ +{ + "modules": [ + { + "name": "foundation", + "path": "terraform/foundation", + "tier": 1, + "ci_enabled": true + }, + { + "name": "compute", + "path": "terraform/compute", + "tier": 2, + "ci_enabled": true + }, + { + "name": "bindings", + "path": "terraform/bindings", + "tier": 3, + "ci_enabled": true + } + ] +} \ No newline at end of file diff --git a/charts/api-service/templates/deployment.yaml b/charts/api-service/templates/deployment.yaml index 2424f01..394f416 100644 --- a/charts/api-service/templates/deployment.yaml +++ b/charts/api-service/templates/deployment.yaml @@ -29,5 +29,22 @@ spec: protocol: TCP resources: {{- toYaml .Values.resources | nindent 12 }} - # TODO: add env vars for service configuration - # TODO: add readinessProbe and livenessProbe \ No newline at end of file + env: + - name: PROCESSOR_URL + value: {{ .Values.processorUrl | quote }} + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 \ No newline at end of file diff --git a/charts/notification-service/templates/deployment.yaml b/charts/notification-service/templates/deployment.yaml index a2980c5..9f2e8ac 100644 --- a/charts/notification-service/templates/deployment.yaml +++ b/charts/notification-service/templates/deployment.yaml @@ -28,4 +28,19 @@ spec: protocol: TCP resources: {{- toYaml .Values.resources | nindent 12 }} - # TODO: add env vars, readinessProbe, livenessProbe \ No newline at end of file + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 \ No newline at end of file diff --git a/charts/processor-service/templates/deployment.yaml b/charts/processor-service/templates/deployment.yaml index 65bbf67..0ebd789 100644 --- a/charts/processor-service/templates/deployment.yaml +++ b/charts/processor-service/templates/deployment.yaml @@ -28,4 +28,19 @@ spec: protocol: TCP resources: {{- toYaml .Values.resources | nindent 12 }} - # TODO: add env vars, readinessProbe, livenessProbe \ No newline at end of file + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 \ No newline at end of file diff --git a/services/api-service/Dockerfile b/services/api-service/Dockerfile new file mode 100644 index 0000000..b13d797 --- /dev/null +++ b/services/api-service/Dockerfile @@ -0,0 +1,20 @@ +# Multi-stage build placeholder for api-service. +# Replace with actual build steps for your chosen language and runtime. +# Distroless base is recommended for production — minimal attack surface. + +FROM python:3.12-slim + +WORKDIR /app + +# Non-root user — never run application containers as root. +RUN adduser --disabled-password --gecos "" appuser + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +USER appuser +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/services/api-service/main.py b/services/api-service/main.py new file mode 100644 index 0000000..f418264 --- /dev/null +++ b/services/api-service/main.py @@ -0,0 +1,25 @@ +import os +from fastapi import FastAPI + +app = FastAPI(title="api-service") + +# In production this resolves to processor-service via Kubernetes DNS. +# Format: http://..svc.cluster.local +# The short form http://processor-service works within the same namespace. +PROCESSOR_URL = os.getenv("PROCESSOR_URL", "http://processor-service") + + +@app.get("/health") +def health(): + return {"status": "ok", "service": "api-service"} + + +@app.post("/tasks", status_code=202) +def create_task(task: dict): + # Stub: in production this calls POST processor-service/process + # and returns a task ID to the caller. + return { + "status": "accepted", + "message": "task queued for processing", + "processor_url": PROCESSOR_URL, + } \ No newline at end of file diff --git a/services/api-service/requirements.txt b/services/api-service/requirements.txt new file mode 100644 index 0000000..ec73fde --- /dev/null +++ b/services/api-service/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.111.0 +uvicorn==0.30.0 \ No newline at end of file diff --git a/services/notification-service/Dockerfile b/services/notification-service/Dockerfile new file mode 100644 index 0000000..dbc23c6 --- /dev/null +++ b/services/notification-service/Dockerfile @@ -0,0 +1,17 @@ +# Multi-stage build placeholder for notification-service. + +FROM python:3.12-slim + +WORKDIR /app + +RUN adduser --disabled-password --gecos "" appuser + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +USER appuser +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/services/notification-service/main.py b/services/notification-service/main.py new file mode 100644 index 0000000..f2ad629 --- /dev/null +++ b/services/notification-service/main.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI + +app = FastAPI(title="notification-service") + + +@app.get("/health") +def health(): + return {"status": "ok", "service": "notification-service"} + +# This service is an async consumer — it has no inbound HTTP business routes. +# In production a background task started via @app.on_event("startup") would +# open a Service Bus receiver using workload identity (DefaultAzureCredential), +# pull messages from the notification-service subscription on the task-events +# topic, and dispatch notifications. +# +# The HTTP server exists solely to serve the /health endpoint so Kubernetes +# liveness and readiness probes have a target. \ No newline at end of file diff --git a/services/notification-service/requirements.txt b/services/notification-service/requirements.txt new file mode 100644 index 0000000..d9f242e --- /dev/null +++ b/services/notification-service/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.111.0 +uvicorn==0.30.0 + \ No newline at end of file diff --git a/services/processor-service/Dockerfile b/services/processor-service/Dockerfile new file mode 100644 index 0000000..2b330b7 --- /dev/null +++ b/services/processor-service/Dockerfile @@ -0,0 +1,17 @@ +# Multi-stage build placeholder for processor-service. + +FROM python:3.12-slim + +WORKDIR /app + +RUN adduser --disabled-password --gecos "" appuser + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +USER appuser +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/services/processor-service/main.py b/services/processor-service/main.py new file mode 100644 index 0000000..e922fa0 --- /dev/null +++ b/services/processor-service/main.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI + +app = FastAPI(title="processor-service") + + +@app.get("/health") +def health(): + return {"status": "ok", "service": "processor-service"} + + +@app.post("/process") +def process_task(task: dict): + # Stub: in production this validates the task payload and publishes + # an event to the Service Bus task-events topic using workload identity. + # The notification-service subscription then receives the event. + return { + "status": "processed", + "task": task, + } \ No newline at end of file diff --git a/services/processor-service/requirements.txt b/services/processor-service/requirements.txt new file mode 100644 index 0000000..ec73fde --- /dev/null +++ b/services/processor-service/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.111.0 +uvicorn==0.30.0 \ No newline at end of file diff --git a/terraform/bindings/backend.tf b/terraform/bindings/backend.tf index e69de29..ed0c135 100644 --- a/terraform/bindings/backend.tf +++ b/terraform/bindings/backend.tf @@ -0,0 +1,8 @@ +terraform { + backend "azurerm" { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "taskflow-bindings.tfstate" + } +} diff --git a/terraform/bindings/main.tf b/terraform/bindings/main.tf index e69de29..b202325 100644 --- a/terraform/bindings/main.tf +++ b/terraform/bindings/main.tf @@ -0,0 +1,174 @@ +# ── Remote State: Foundation ────────────────────────────────────────────────── +# Reads managed identity principal IDs and client IDs, ACR ID, +# Service Bus namespace ID, Key Vault ID. + +data "terraform_remote_state" "foundation" { + backend = "azurerm" + config = { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "taskflow-foundation.tfstate" + } +} + +# ── Remote State: Compute ───────────────────────────────────────────────────── +# Reads oidc_issuer_url and kubelet_identity_object_id from the AKS cluster. +# This is why bindings cannot run until compute has applied — the OIDC URL +# does not exist until the cluster exists. + +data "terraform_remote_state" "compute" { + backend = "azurerm" + config = { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "taskflow-compute.tfstate" + } +} + +# ── Remote State: Platform Connectivity ────────────────────────────────────── +# Reads snet_compute_id for the Key Vault private endpoint. + +data "terraform_remote_state" "connectivity" { + backend = "azurerm" + config = { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "platform-connectivity.tfstate" + } +} + +# ── Locals ──────────────────────────────────────────────────────────────────── + +locals { + rg_name = data.terraform_remote_state.connectivity.outputs.rg_taskflow_name + snet_compute_id = data.terraform_remote_state.connectivity.outputs.snet_compute_id + oidc_issuer_url = data.terraform_remote_state.compute.outputs.oidc_issuer_url + kubelet_identity = data.terraform_remote_state.compute.outputs.kubelet_identity_object_id + + common_tags = { + environment = var.environment + workload = "taskflow" + managed_by = "terraform" + } +} + + +# ── AcrPull — Kubelet Identity ──────────────────────────────────────────────── +# Allows AKS nodes to pull images from ACR without credentials. +# The kubelet identity is created by AKS at cluster provisioning time — +# this is why the assignment cannot be in the foundation tier. +# +# TODO: implement azurerm_role_assignment.acr_pull +# scope = data.terraform_remote_state.foundation.outputs.acr_id +# role_definition_name = "AcrPull" +# principal_id = local.kubelet_identity + + +# ── Federated Identity Credentials ─────────────────────────────────────────── +# Links each managed identity to its Kubernetes ServiceAccount via OIDC. +# +# Subject format: system:serviceaccount:: +# The service account name must match the name in the Helm chart serviceaccount.yaml. +# +# How it works: +# 1. Pod runs with a projected ServiceAccount token (OIDC JWT). +# 2. Azure AD validates the token against the OIDC issuer URL. +# 3. Subject claim must match the federated credential subject exactly. +# 4. Azure AD returns an access token scoped to the managed identity. +# 5. Pod uses that token to authenticate to Key Vault / Service Bus. + +# TODO: implement azurerm_federated_identity_credential.api_service +# name = "fed-taskflow-api-service" +# resource_group_name = local.rg_name +# audience = ["api://AzureADTokenExchange"] +# issuer = local.oidc_issuer_url +# parent_id = data.terraform_remote_state.foundation.outputs.mi_api_service_id +# subject = "system:serviceaccount:${var.kubernetes_namespace}:api-service" + +# TODO: implement azurerm_federated_identity_credential.processor_service +# subject = "system:serviceaccount:${var.kubernetes_namespace}:processor-service" + +# TODO: implement azurerm_federated_identity_credential.notification_service +# subject = "system:serviceaccount:${var.kubernetes_namespace}:notification-service" + + +# ── Key Vault Role Assignments ──────────────────────────────────────────────── +# Grants each service identity read access to Key Vault secrets. +# Key Vault uses RBAC (enable_rbac_authorization = true) — no access policies. +# +# TODO: implement azurerm_role_assignment.kv_api_service +# scope = data.terraform_remote_state.foundation.outputs.key_vault_id +# role_definition_name = "Key Vault Secrets User" +# principal_id = data.terraform_remote_state.foundation.outputs.mi_api_service_principal_id + +# TODO: implement azurerm_role_assignment.kv_processor_service +# role_definition_name = "Key Vault Secrets User" +# principal_id = data.terraform_remote_state.foundation.outputs.mi_processor_service_principal_id + +# TODO: implement azurerm_role_assignment.kv_notification_service +# role_definition_name = "Key Vault Secrets User" +# principal_id = data.terraform_remote_state.foundation.outputs.mi_notification_service_principal_id + + +# ── Service Bus Role Assignments ────────────────────────────────────────────── +# api-service and processor-service publish events (Sender). +# notification-service consumes events (Receiver). +# +# Scope is the namespace — not the topic or subscription. +# Namespace-scoped roles cover all topics/subscriptions within it. +# +# TODO: implement azurerm_role_assignment.sb_sender_api_service +# scope = data.terraform_remote_state.foundation.outputs.service_bus_namespace_id +# role_definition_name = "Azure Service Bus Data Sender" +# principal_id = data.terraform_remote_state.foundation.outputs.mi_api_service_principal_id + +# TODO: implement azurerm_role_assignment.sb_sender_processor_service +# role_definition_name = "Azure Service Bus Data Sender" +# principal_id = data.terraform_remote_state.foundation.outputs.mi_processor_service_principal_id + +# TODO: implement azurerm_role_assignment.sb_receiver_notification_service +# scope = data.terraform_remote_state.foundation.outputs.service_bus_namespace_id +# role_definition_name = "Azure Service Bus Data Receiver" +# principal_id = data.terraform_remote_state.foundation.outputs.mi_notification_service_principal_id + + +# ── Key Vault Private Endpoint ──────────────────────────────────────────────── +# Places a private NIC for Key Vault into snet-compute (10.1.1.0/24). +# Traffic from AKS pods to Key Vault stays within the VNet. +# +# DNS note: +# A private endpoint without a DNS zone resolves to the public IP — the +# connection will be rejected because public access is disabled on the vault. +# When platform/connectivity provisions the privatelink.vaultcore.azure.net +# zone, add an azurerm_private_dns_a_record here pointing to the NIC IP +# of this endpoint. +# Until then, this endpoint is provisioned but not resolvable. +# +# TODO: implement azurerm_private_endpoint.key_vault +# name = "pe-kv-taskflow" +# resource_group_name = local.rg_name +# location = var.location +# subnet_id = local.snet_compute_id +# +# private_service_connection { +# name = "psc-kv-taskflow" +# private_connection_resource_id = data.terraform_remote_state.foundation.outputs.key_vault_id +# subresource_names = ["vault"] +# is_manual_connection = false +# } +# +# tags = local.common_tags + +# ── Private DNS A Record (on-demand) ───────────────────────────────────────── +# Add this block when platform/connectivity provisions the DNS zone. +# Until then, leave commented. +# +# TODO: implement azurerm_private_dns_a_record.key_vault (when DNS zone exists) +# name = "kv-taskflow" +# zone_name = "privatelink.vaultcore.azure.net" +# resource_group_name = +# ttl = 300 +# records = [azurerm_private_endpoint.key_vault.private_service_connection[0].private_ip_address] diff --git a/terraform/bindings/outputs.tf b/terraform/bindings/outputs.tf index e69de29..ed017ed 100644 --- a/terraform/bindings/outputs.tf +++ b/terraform/bindings/outputs.tf @@ -0,0 +1,6 @@ +# Bindings has no outputs consumed by other Terraform modules. +# It is the terminal tier in the dependency chain. +# +# Values needed by Helm charts (managed identity client IDs, Key Vault URI) +# are read directly from foundation outputs in the service pipeline, +# or set as static values in charts/*/values.yaml after first apply. diff --git a/terraform/bindings/variables.tf b/terraform/bindings/variables.tf index e69de29..8c6e64d 100644 --- a/terraform/bindings/variables.tf +++ b/terraform/bindings/variables.tf @@ -0,0 +1,17 @@ +variable "location" { + description = "Azure region for all resources" + type = string + default = "eastus2" +} + +variable "environment" { + description = "Environment tag applied to all resources" + type = string + default = "dev" +} + +variable "kubernetes_namespace" { + description = "Kubernetes namespace where taskflow services are deployed" + type = string + default = "taskflow" +} diff --git a/terraform/bindings/versions.tf b/terraform/bindings/versions.tf index e69de29..9aea025 100644 --- a/terraform/bindings/versions.tf +++ b/terraform/bindings/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.110.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/terraform/compute/backend.tf b/terraform/compute/backend.tf index e69de29..632dabb 100644 --- a/terraform/compute/backend.tf +++ b/terraform/compute/backend.tf @@ -0,0 +1,8 @@ +terraform { + backend "azurerm" { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "taskflow-compute.tfstate" + } +} diff --git a/terraform/compute/main.tf b/terraform/compute/main.tf index e69de29..abeb1f6 100644 --- a/terraform/compute/main.tf +++ b/terraform/compute/main.tf @@ -0,0 +1,188 @@ +# ── Remote State: Platform Connectivity ────────────────────────────────────── +# Reads networking outputs provisioned by platform/connectivity. +# This module consumes: rg_taskflow_name, snet_aks_id + +data "terraform_remote_state" "connectivity" { + backend = "azurerm" + config = { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "platform-connectivity.tfstate" + } +} + +# ── Remote State: Foundation ────────────────────────────────────────────────── +# Reads managed identity IDs provisioned by terraform/foundation. +# AKS kubelet identity and workload identities must exist before cluster creation. + +data "terraform_remote_state" "foundation" { + backend = "azurerm" + config = { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "taskflow-foundation.tfstate" + } +} + +# ── Remote State: Platform Management ──────────────────────────────────────── +# Reads log_analytics_workspace_id for AKS diagnostic settings. + +data "terraform_remote_state" "management" { + backend = "azurerm" + config = { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "platform-management.tfstate" + } +} + +# ── Locals ──────────────────────────────────────────────────────────────────── + +locals { + rg_name = data.terraform_remote_state.connectivity.outputs.rg_taskflow_name + snet_aks_id = data.terraform_remote_state.connectivity.outputs.snet_aks_id + law_id = data.terraform_remote_state.management.outputs.log_analytics_workspace_id + + common_tags = { + environment = var.environment + workload = "taskflow" + managed_by = "terraform" + } +} + +# ── NAT Gateway ─────────────────────────────────────────────────────────────── +# Provides a stable, predictable egress IP for AKS node traffic. +# +# Design decisions: +# - NAT Gateway lives in rg-taskflow (workload-owned), not rg-workloads. +# The taskflow SP has Contributor on rg-taskflow. +# - The subnet association attaches to snet-aks which is in rg-workloads. +# The taskflow SP has Network Contributor on rg-workloads — this permits +# the azurerm_subnet_nat_gateway_association resource. +# - AKS outbound_type must be set to userAssignedNATGateway at cluster +# creation — this cannot be changed after the cluster exists. +# - Traffic path: AKS node → snet-aks → NAT Gateway → internet +# The NVA only handles east-west (spoke-to-spoke). AKS egress bypasses it. + +# TODO: implement azurerm_public_ip.nat +# name = "pip-nat-taskflow" +# resource_group_name = local.rg_name +# location = var.location +# allocation_method = "Static" +# sku = "Standard" +# tags = local.common_tags + +# TODO: implement azurerm_nat_gateway.taskflow +# name = "nat-taskflow" +# resource_group_name = local.rg_name +# location = var.location +# sku_name = "Standard" +# tags = local.common_tags + +# TODO: implement azurerm_nat_gateway_public_ip_association.taskflow +# nat_gateway_id = azurerm_nat_gateway.taskflow.id +# public_ip_address_id = azurerm_public_ip.nat.id + +# TODO: implement azurerm_subnet_nat_gateway_association.aks +# subnet_id = local.snet_aks_id +# nat_gateway_id = azurerm_nat_gateway.taskflow.id + + +# ── AKS ─────────────────────────────────────────────────────────────────────── +# Design decisions: +# +# Azure CNI Overlay + Cilium: +# network_plugin = "azure" +# network_plugin_mode = "overlay" +# network_policy = "cilium" +# ebpf_data_plane = "cilium" +# CNI Overlay removes the pod IP exhaustion risk of standard CNI. +# Cilium replaces the deprecated azure network policy engine. +# +# Workload identity: +# oidc_issuer_enabled = true +# workload_identity_enabled = true +# Required for pod-level managed identity via OIDC federation. +# Federated credentials are wired in the bindings tier using the +# oidc_issuer_url output from this module. +# +# Egress: +# outbound_type = "userAssignedNATGateway" +# Must match NAT Gateway above. Set at cluster creation — immutable. +# +# API server: +# Public endpoint with authorized_ip_ranges. +# CI/CD access via az aks command invoke — no runner IP management needed. +# authorized_ip_ranges is for local kubectl access only. +# +# Node pools: +# System pool: only_critical_addons_enabled = true — no workload pods scheduled here. +# Workload pool: autoscaling enabled, receives all application workloads. +# Both pools use node labels to enable scheduling affinity. +# +# Identity: +# Cluster-level: SystemAssigned (manages cluster infrastructure — load balancers, etc.) +# Workload-level: UserAssigned identities from foundation tier, referenced per service. + +# TODO: implement azurerm_kubernetes_cluster.taskflow +# name = "aks-taskflow" +# resource_group_name = local.rg_name +# location = var.location +# dns_prefix = "taskflow" +# kubernetes_version = var.kubernetes_version +# sku_tier = var.aks_sku_tier +# +# oidc_issuer_enabled = true +# workload_identity_enabled = true +# +# network_profile { +# network_plugin = "azure" +# network_plugin_mode = "overlay" +# network_policy = "cilium" +# ebpf_data_plane = "cilium" +# outbound_type = "userAssignedNATGateway" +# service_cidr = "172.16.0.0/16" +# dns_service_ip = "172.16.0.10" +# } +# +# api_server_access_profile { +# authorized_ip_ranges = var.authorized_ip_ranges +# } +# +# default_node_pool { +# name = "system" +# node_count = 1 +# vm_size = var.system_node_vm_size +# vnet_subnet_id = local.snet_aks_id +# only_critical_addons_enabled = true +# node_labels = { +# "nodepool-type" = "system" +# } +# upgrade_settings { +# max_surge = "10%" +# } +# } +# +# identity { +# type = "SystemAssigned" +# } +# +# tags = local.common_tags + +# TODO: implement azurerm_kubernetes_cluster_node_pool.workload +# name = "workload" +# kubernetes_cluster_id = azurerm_kubernetes_cluster.taskflow.id +# vm_size = var.workload_node_vm_size +# vnet_subnet_id = local.snet_aks_id +# enable_auto_scaling = true +# min_count = var.workload_node_min_count +# max_count = var.workload_node_max_count +# node_labels = { +# "nodepool-type" = "workload" +# } +# upgrade_settings { +# max_surge = "33%" +# } diff --git a/terraform/compute/outputs.tf b/terraform/compute/outputs.tf index e69de29..a030eab 100644 --- a/terraform/compute/outputs.tf +++ b/terraform/compute/outputs.tf @@ -0,0 +1,31 @@ +# ── AKS ─────────────────────────────────────────────────────────────────────── + +# TODO: output "aks_id" { +# description = "Resource ID of the AKS cluster" +# value = azurerm_kubernetes_cluster.taskflow.id +# } + +# TODO: output "aks_name" { +# description = "Name of the AKS cluster — used by az aks command invoke in CI/CD" +# value = azurerm_kubernetes_cluster.taskflow.name +# } + +# TODO: output "oidc_issuer_url" { +# description = "OIDC issuer URL for the cluster. Required by bindings tier to create +# federated credentials on managed identities." +# value = azurerm_kubernetes_cluster.taskflow.oidc_issuer_url +# } + +# TODO: output "kubelet_identity_object_id" { +# description = "Object ID of the kubelet managed identity. +# Bindings tier assigns AcrPull on ACR to this identity so nodes can pull images." +# value = azurerm_kubernetes_cluster.taskflow.kubelet_identity[0].object_id +# } + + +# ── NAT Gateway ─────────────────────────────────────────────────────────────── + +# TODO: output "nat_gateway_public_ip" { +# description = "Public IP address of the NAT Gateway — stable egress IP for AKS nodes" +# value = azurerm_public_ip.nat.ip_address +# } diff --git a/terraform/compute/variables.tf b/terraform/compute/variables.tf index e69de29..39a061b 100644 --- a/terraform/compute/variables.tf +++ b/terraform/compute/variables.tf @@ -0,0 +1,58 @@ +variable "location" { + description = "Azure region for all resources" + type = string + default = "eastus2" +} + +variable "environment" { + description = "Environment tag applied to all resources" + type = string + default = "dev" +} + +variable "aks_sku_tier" { + description = "AKS control plane SKU. Free for dev, Standard for production (SLA-backed)." + type = string + default = "Free" + + validation { + condition = contains(["Free", "Standard", "Premium"], var.aks_sku_tier) + error_message = "aks_sku_tier must be Free, Standard, or Premium." + } +} + +variable "system_node_vm_size" { + description = "VM size for the system node pool" + type = string + default = "Standard_D2s_v3" +} + +variable "workload_node_vm_size" { + description = "VM size for the workload node pool" + type = string + default = "Standard_D2s_v3" +} + +variable "workload_node_min_count" { + description = "Minimum node count for the workload node pool (autoscaler)" + type = number + default = 1 +} + +variable "workload_node_max_count" { + description = "Maximum node count for the workload node pool (autoscaler)" + type = number + default = 3 +} + +variable "authorized_ip_ranges" { + description = "IP ranges permitted to reach the AKS API server. For local kubectl access only — pipeline uses az aks command invoke." + type = list(string) + default = [] +} + +variable "kubernetes_version" { + description = "Kubernetes version for the cluster and node pools" + type = string + default = null # null = use latest stable +} diff --git a/terraform/compute/versions.tf b/terraform/compute/versions.tf index e69de29..9aea025 100644 --- a/terraform/compute/versions.tf +++ b/terraform/compute/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.110.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/terraform/foundation/backend.tf b/terraform/foundation/backend.tf index e69de29..aa80d48 100644 --- a/terraform/foundation/backend.tf +++ b/terraform/foundation/backend.tf @@ -0,0 +1,8 @@ +terraform { + backend "azurerm" { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "taskflow-foundation.tfstate" + } +} diff --git a/terraform/foundation/main.tf b/terraform/foundation/main.tf index e69de29..f2f5a5d 100644 --- a/terraform/foundation/main.tf +++ b/terraform/foundation/main.tf @@ -0,0 +1,142 @@ +# ── Remote State: Platform Connectivity ────────────────────────────────────── +# Reads networking outputs provisioned by platform/connectivity. +# This module consumes: rg_taskflow_name +# Platform must be applied before this module can be initialised. + +data "terraform_remote_state" "connectivity" { + backend = "azurerm" + config = { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "platform-connectivity.tfstate" + } +} + +# ── Remote State: Platform Management ──────────────────────────────────────── +# Reads observability outputs provisioned by platform/management. +# This module consumes: log_analytics_workspace_id +# Used to wire diagnostic settings for ACR, Service Bus, Key Vault. + +data "terraform_remote_state" "management" { + backend = "azurerm" + config = { + resource_group_name = "rg-tfstate" + storage_account_name = "sttfstate7tcl" + container_name = "tfstate" + key = "platform-management.tfstate" + } +} + +# ── Locals ──────────────────────────────────────────────────────────────────── + +locals { + rg_name = data.terraform_remote_state.connectivity.outputs.rg_taskflow_name + law_id = data.terraform_remote_state.management.outputs.log_analytics_workspace_id + + common_tags = { + environment = var.environment + workload = "taskflow" + managed_by = "terraform" + } +} + +# ── ACR ─────────────────────────────────────────────────────────────────────── +# Container registry for all taskflow service images. +# +# Design decisions: +# - admin_enabled: false — images are pulled via AcrPull role assignment on +# the AKS kubelet identity. No username/password ever used. +# - public_network_access_enabled: true — Basic SKU does not support private +# endpoints. Images are pushed from GitHub Actions runners (public) and +# pulled by AKS nodes (also public egress via NAT Gateway). +# +# TODO: implement azurerm_container_registry.taskflow +# name = "crtaskflow" +# resource_group_name = local.rg_name +# location = var.location +# sku = var.acr_sku +# admin_enabled = false +# tags = local.common_tags + + +# ── Service Bus ─────────────────────────────────────────────────────────────── +# Async messaging between processor-service and notification-service. +# Topic/subscription model: processor publishes, notification-service consumes. +# +# Design decisions: +# - local_auth_enabled: false — forces workload identity authentication. +# No connection strings are used anywhere. Role assignments are in the +# bindings tier after managed identities exist. +# - Standard SKU required — Basic SKU does not support topics. +# +# TODO: implement azurerm_servicebus_namespace.taskflow +# name = "sb-taskflow-" +# resource_group_name = local.rg_name +# location = var.location +# sku = var.service_bus_sku +# local_auth_enabled = false +# tags = local.common_tags + +# TODO: implement azurerm_servicebus_topic.task_events +# name = "task-events" +# namespace_id = azurerm_servicebus_namespace.taskflow.id + +# TODO: implement azurerm_servicebus_subscription.notification_service +# name = "notification-service" +# topic_id = azurerm_servicebus_topic.task_events.id +# max_delivery_count = 10 + + +# ── Key Vault ───────────────────────────────────────────────────────────────── +# Central secret store for all taskflow services. +# +# Design decisions: +# - enable_rbac_authorization: true — no legacy access policies. +# Secrets access is granted via role assignments in the bindings tier. +# - public_network_access_enabled: false — accessed via private endpoint only. +# Private endpoint is provisioned in the bindings tier into snet-compute. +# - purge_protection_enabled: true — prevents accidental permanent deletion. +# - soft_delete_retention_days: 7 — minimum retention, sufficient for a lab. +# +# TODO: implement azurerm_key_vault.taskflow +# name = "kv-taskflow-" +# resource_group_name = local.rg_name +# location = var.location +# tenant_id = data.azurerm_client_config.current.tenant_id +# sku_name = "standard" +# enable_rbac_authorization = true +# public_network_access_enabled = false +# purge_protection_enabled = true +# soft_delete_retention_days = 7 +# tags = local.common_tags + + +# ── Managed Identities ──────────────────────────────────────────────────────── +# One User Assigned Managed Identity per service that requires Azure resource +# access (Key Vault, Service Bus). +# +# Why created here (Tier 1) and not in the bindings tier: +# - AKS must reference the identities at cluster creation time so the +# workload identity webhook can project tokens for them. +# - Federated credentials (which need the AKS OIDC issuer URL) are added +# in the bindings tier after AKS exists. The identity itself is decoupled +# from the federation — it is just a principal until bindings wires it up. +# +# TODO: implement azurerm_user_assigned_identity.api_service +# name = "mi-taskflow-api-service" +# resource_group_name = local.rg_name +# location = var.location +# tags = local.common_tags + +# TODO: implement azurerm_user_assigned_identity.processor_service +# name = "mi-taskflow-processor-service" +# resource_group_name = local.rg_name +# location = var.location +# tags = local.common_tags + +# TODO: implement azurerm_user_assigned_identity.notification_service +# name = "mi-taskflow-notification-service" +# resource_group_name = local.rg_name +# location = var.location +# tags = local.common_tags diff --git a/terraform/foundation/outputs.tf b/terraform/foundation/outputs.tf index e69de29..8fa36a9 100644 --- a/terraform/foundation/outputs.tf +++ b/terraform/foundation/outputs.tf @@ -0,0 +1,87 @@ +# ── ACR ─────────────────────────────────────────────────────────────────────── +# Consumed by: bindings (AcrPull role assignment) + +# TODO: output "acr_id" { +# description = "Resource ID of the container registry" +# value = azurerm_container_registry.taskflow.id +# } + +# TODO: output "acr_name" { +# description = "Name of the container registry" +# value = azurerm_container_registry.taskflow.name +# } + +# TODO: output "acr_login_server" { +# description = "Login server URL for the container registry" +# value = azurerm_container_registry.taskflow.login_server +# } + + +# ── Service Bus ─────────────────────────────────────────────────────────────── +# Consumed by: bindings (Service Bus role assignments) + +# TODO: output "service_bus_namespace_id" { +# description = "Resource ID of the Service Bus namespace" +# value = azurerm_servicebus_namespace.taskflow.id +# } + +# TODO: output "service_bus_namespace_name" { +# description = "Name of the Service Bus namespace" +# value = azurerm_servicebus_namespace.taskflow.name +# } + + +# ── Key Vault ───────────────────────────────────────────────────────────────── +# Consumed by: bindings (private endpoint + role assignments) + +# TODO: output "key_vault_id" { +# description = "Resource ID of the Key Vault" +# value = azurerm_key_vault.taskflow.id +# } + +# TODO: output "key_vault_uri" { +# description = "URI of the Key Vault — used by CSI driver SecretProviderClass" +# value = azurerm_key_vault.taskflow.vault_uri +# } + +# TODO: output "key_vault_name" { +# description = "Name of the Key Vault" +# value = azurerm_key_vault.taskflow.name +# } + + +# ── Managed Identities ──────────────────────────────────────────────────────── +# Consumed by: compute (passed to AKS), bindings (federated creds + role assignments) +# client_id — used in Kubernetes ServiceAccount annotation +# id — used in AKS identity block +# principal_id — used for role assignments + +# TODO: output "mi_api_service_id" { +# value = azurerm_user_assigned_identity.api_service.id +# } +# TODO: output "mi_api_service_client_id" { +# value = azurerm_user_assigned_identity.api_service.client_id +# } +# TODO: output "mi_api_service_principal_id" { +# value = azurerm_user_assigned_identity.api_service.principal_id +# } + +# TODO: output "mi_processor_service_id" { +# value = azurerm_user_assigned_identity.processor_service.id +# } +# TODO: output "mi_processor_service_client_id" { +# value = azurerm_user_assigned_identity.processor_service.client_id +# } +# TODO: output "mi_processor_service_principal_id" { +# value = azurerm_user_assigned_identity.processor_service.principal_id +# } + +# TODO: output "mi_notification_service_id" { +# value = azurerm_user_assigned_identity.notification_service.id +# } +# TODO: output "mi_notification_service_client_id" { +# value = azurerm_user_assigned_identity.notification_service.client_id +# } +# TODO: output "mi_notification_service_principal_id" { +# value = azurerm_user_assigned_identity.notification_service.principal_id +# } diff --git a/terraform/foundation/variables.tf b/terraform/foundation/variables.tf index e69de29..95a9521 100644 --- a/terraform/foundation/variables.tf +++ b/terraform/foundation/variables.tf @@ -0,0 +1,33 @@ +variable "location" { + description = "Azure region for all resources" + type = string + default = "eastus2" +} + +variable "environment" { + description = "Environment tag applied to all resources" + type = string + default = "dev" +} + +variable "acr_sku" { + description = "ACR SKU tier. Basic is sufficient for a single-cluster workload." + type = string + default = "Basic" + + validation { + condition = contains(["Basic", "Standard", "Premium"], var.acr_sku) + error_message = "acr_sku must be Basic, Standard, or Premium." + } +} + +variable "service_bus_sku" { + description = "Service Bus namespace SKU. Standard required for topics and subscriptions." + type = string + default = "Standard" + + validation { + condition = contains(["Basic", "Standard", "Premium"], var.service_bus_sku) + error_message = "service_bus_sku must be Basic, Standard, or Premium." + } +} diff --git a/terraform/foundation/versions.tf b/terraform/foundation/versions.tf index e69de29..0030f5f 100644 --- a/terraform/foundation/versions.tf +++ b/terraform/foundation/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.110.0" + } + } +} + +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = false + } + } +}