diff --git a/scenarios/AksOpenAiTerraform/.gitignore b/scenarios/AksOpenAiTerraform/.gitignore new file mode 100644 index 000000000..1b2971f39 --- /dev/null +++ b/scenarios/AksOpenAiTerraform/.gitignore @@ -0,0 +1,40 @@ +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore transient lock info files created by terraform apply +.terraform.tfstate.lock.info + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +.venv +.vscode \ No newline at end of file diff --git a/scenarios/AksOpenAiTerraform/README.md b/scenarios/AksOpenAiTerraform/README.md new file mode 100644 index 000000000..d6a9fbfcc --- /dev/null +++ b/scenarios/AksOpenAiTerraform/README.md @@ -0,0 +1,71 @@ +--- +title: Deploy and run an Azure OpenAI ChatGPT application on AKS via Terraform +description: This article shows how to deploy an AKS cluster and Azure OpenAI Service via Terraform and how to deploy a ChatGPT-like application in Python. +ms.topic: quickstart +ms.date: 09/06/2024 +author: aamini7 +ms.author: ariaamini +ms.custom: innovation-engine, linux-related-content +--- + +## Provision Resources with Terraform (~5 minutes) +Run terraform to provision all the Azure resources required to setup your new OpenAI website. +```bash +# Terraform parses TF_VAR_* as vars (Ex: TF_VAR_name -> name) +export TF_VAR_location="westus3" +export TF_VAR_kubernetes_version="1.30.9" +export TF_VAR_model_name="gpt-4o-mini" +export TF_VAR_model_version="2024-07-18" +# Terraform consumes sub id as $ARM_SUBSCRIPTION_ID +export ARM_SUBSCRIPTION_ID=$SUBSCRIPTION_ID +# Run Terraform +terraform -chdir=terraform init +terraform -chdir=terraform apply -auto-approve +``` + +## Login to Cluster +In order to use the kubectl to run commands on the newly created cluster, you must first login. +```bash +RESOURCE_GROUP=$(terraform -chdir=terraform output -raw resource_group_name) +az aks get-credentials --admin --name AksCluster --resource-group $RESOURCE_GROUP --subscription $SUBSCRIPTION_ID +``` + +# Install Helm Charts +Install nginx and cert-manager through Helm +```bash +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx +helm repo add jetstack https://charts.jetstack.io +helm repo update + +STATIC_IP=$(terraform -chdir=terraform output -raw static_ip) +DNS_LABEL=$(terraform -chdir=terraform output -raw dns_label) +helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --set controller.replicaCount=2 \ + --set controller.nodeSelector."kubernetes\.io/os"=linux \ + --set defaultBackend.nodeSelector."kubernetes\.io/os"=linux \ + --set controller.service.annotations."service\.beta\.kubernetes\.io/azure-dns-label-name"=$DNS_LABEL \ + --set controller.service.loadBalancerIP=$STATIC_IP \ + --set controller.service.annotations."service\.beta\.kubernetes\.io/azure-load-balancer-health-probe-request-path"=/healthz +helm upgrade --install cert-manager jetstack/cert-manager \ + --set crds.enabled=true \ + --set nodeSelector."kubernetes\.io/os"=linux +``` + +## Deploy +Apply/Deploy Manifest File +```bash +export IMAGE="aamini8/magic8ball:latest" +# Uncomment below to manually build docker image yourself instead of using pre-built image. +# docker build -t ./magic8ball --push +export HOSTNAME=$(terraform -chdir=terraform output -raw hostname) +export WORKLOAD_IDENTITY_CLIENT_ID=$(terraform -chdir=terraform output -raw workload_identity_client_id) +export AZURE_OPENAI_DEPLOYMENT=$(terraform -chdir=terraform output -raw openai_deployment) +export AZURE_OPENAI_ENDPOINT=$(terraform -chdir=terraform output -raw openai_endpoint) +envsubst < quickstart-app.yml | kubectl apply -f - +``` + +## Wait for host to be ready +```bash +kubectl wait --for=condition=Ready certificate/tls-secret +echo "Visit: https://$HOSTNAME" +``` \ No newline at end of file diff --git a/scenarios/AksOpenAiTerraform/magic8ball/Dockerfile b/scenarios/AksOpenAiTerraform/magic8ball/Dockerfile new file mode 100644 index 000000000..fe9aa8ca5 --- /dev/null +++ b/scenarios/AksOpenAiTerraform/magic8ball/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.13-slim +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +EXPOSE 8501 +ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"] \ No newline at end of file diff --git a/scenarios/AksOpenAiTerraform/magic8ball/app.py b/scenarios/AksOpenAiTerraform/magic8ball/app.py new file mode 100644 index 000000000..a3de44d3a --- /dev/null +++ b/scenarios/AksOpenAiTerraform/magic8ball/app.py @@ -0,0 +1,65 @@ +import os +from openai import AzureOpenAI +import streamlit as st +from azure.identity import WorkloadIdentityCredential, get_bearer_token_provider + +azure_deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT") +azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") +workload_identity_client_id = os.getenv("WORKLOAD_IDENTITY_CLIENT_ID") + +client = AzureOpenAI( + api_version="2024-10-21", + azure_endpoint=azure_endpoint, + azure_ad_token_provider=get_bearer_token_provider( + WorkloadIdentityCredential(client_id=workload_identity_client_id), + "https://cognitiveservices.azure.com/.default", + ), +) + + +def ask_openai_api(messages: list[str]): + completion = client.chat.completions.create( + messages=messages, model=azure_deployment, stream=True, max_tokens=20 + ) + return completion + + +assistant_prompt = """ +Answer as a magic 8 ball and make random predictions. +If the question is not clear, respond with "Ask the Magic 8 Ball a question about your future." +""" + +# Init state +if "messages" not in st.session_state: + st.session_state.messages = [{"role": "system", "content": assistant_prompt}] +if "disabled" not in st.session_state: + st.session_state.disabled = False + +st.title(":robot_face: Magic 8 Ball") +for message in st.session_state.messages[1:]: # Print previous messages + with st.chat_message(message["role"]): + st.markdown(message["content"]) + + +def disable_chat(): + st.session_state.disabled = True + + +if prompt := st.chat_input( + "Ask your question", on_submit=disable_chat, disabled=st.session_state.disabled +): + # Print Question + st.session_state.messages.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.write(prompt) + + # Print Response + with st.chat_message("assistant"): + messages = st.session_state.messages + with st.spinner("Loading..."): + response = st.write_stream(ask_openai_api(messages)) + st.session_state.messages.append({"role": "assistant", "content": response}) + + # Re-enable textbox + st.session_state.disabled = False + st.rerun() diff --git a/scenarios/AksOpenAiTerraform/magic8ball/requirements.txt b/scenarios/AksOpenAiTerraform/magic8ball/requirements.txt new file mode 100644 index 000000000..b32480fe0 --- /dev/null +++ b/scenarios/AksOpenAiTerraform/magic8ball/requirements.txt @@ -0,0 +1,3 @@ +streamlit~=1.40.1 +azure-identity~=1.20.0 +openai~=1.65.2 \ No newline at end of file diff --git a/scenarios/AksOpenAiTerraform/quickstart-app.yml b/scenarios/AksOpenAiTerraform/quickstart-app.yml new file mode 100644 index 000000000..0f2bb4854 --- /dev/null +++ b/scenarios/AksOpenAiTerraform/quickstart-app.yml @@ -0,0 +1,95 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: magic8ball-configmap +data: + AZURE_OPENAI_ENDPOINT: $AZURE_OPENAI_ENDPOINT + AZURE_OPENAI_DEPLOYMENT: $AZURE_OPENAI_DEPLOYMENT + WORKLOAD_IDENTITY_CLIENT_ID: $WORKLOAD_IDENTITY_CLIENT_ID +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: magic8ball + labels: + app.kubernetes.io/name: magic8ball +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: magic8ball + template: + metadata: + labels: + app.kubernetes.io/name: magic8ball + azure.workload.identity/use: "true" + spec: + serviceAccountName: magic8ball-sa + containers: + - name: magic8ball + image: $IMAGE + imagePullPolicy: Always + ports: + - containerPort: 8501 + envFrom: + - configMapRef: + name: magic8ball-configmap +--- +apiVersion: v1 +kind: Service +metadata: + name: magic8ball +spec: + selector: + app.kubernetes.io/name: magic8ball + ports: + - port: 80 + targetPort: 8501 + protocol: TCP +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: magic8ball-sa + annotations: + azure.workload.identity/client-id: $WORKLOAD_IDENTITY_CLIENT_ID + azure.workload.identity/tenant-id: $TENANT_ID +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: magic8ball + annotations: + cert-manager.io/issuer: letsencrypt-dev +spec: + ingressClassName: nginx + tls: + - hosts: + - $HOSTNAME + secretName: tls-secret + rules: + - host: $HOSTNAME + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: magic8ball + port: + number: 80 +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: letsencrypt-dev +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: $EMAIL + privateKeySecretRef: + name: tls-secret + solvers: + - http01: + ingress: + ingressClassName: nginx \ No newline at end of file diff --git a/scenarios/AksOpenAiTerraform/terraform/.terraform.lock.hcl b/scenarios/AksOpenAiTerraform/terraform/.terraform.lock.hcl new file mode 100644 index 000000000..3ea2ce44c --- /dev/null +++ b/scenarios/AksOpenAiTerraform/terraform/.terraform.lock.hcl @@ -0,0 +1,41 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.20.0" + constraints = "~> 4.20.0" + hashes = [ + "h1:O7hZA85M9/G5LZt+m0bppCinoyp8C346JpI+QnMjYVo=", + "zh:0d29f06abed90da7b943690244420fe1de3e28d4c6de0db441f1af2aa91ea6b8", + "zh:2345e07e91dfec9af3df25fd5119d3a09f91e37ca10af30a344f7b3c297e9ad8", + "zh:42d77650df0238333bcce5da91b4f3d62e54b1ed456f58a9c913270d80a70262", + "zh:43ce137f2644769ceada99a2c815c9c30807e42f61f2f6ce60869411217375f9", + "zh:5e4d8f6a5212f6b7ba29846a2ff328214c7f983ce772196f8e6721edcefd4c59", + "zh:69613d671884fc568a075359e2920d7c19e6d588717b4532b90fb4a4ca8aabd0", + "zh:827ca4fcc25958c731677cb1d87cb09764e3a24ae4117fd9776429341fcdeabe", + "zh:8fad25f949dff7c6f40ea22b13a8b4de6ea0de3c5a975c4a3281529e4797e897", + "zh:b3d175e2725fe38f2a71d5fb346a9d4ff70d449a9d229c95c24f88e764dd2d47", + "zh:c53f3fef67aa64664c85bb8603b0a9730a267a76d7d84ceae16416de7ccb2437", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f7d9ff06344547232e6c84bc3f6bf9c29cf978ba7cd585c10f4c3361a4b81f22", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.7.1" + hashes = [ + "h1:/qtweZW2sk0kBNiQM02RvBXmlVdI9oYqRMCyBZ8XA98=", + "zh:3193b89b43bf5805493e290374cdda5132578de6535f8009547c8b5d7a351585", + "zh:3218320de4be943e5812ed3de995946056db86eb8d03aa3f074e0c7316599bef", + "zh:419861805a37fa443e7d63b69fb3279926ccf98a79d256c422d5d82f0f387d1d", + "zh:4df9bd9d839b8fc11a3b8098a604b9b46e2235eb65ef15f4432bde0e175f9ca6", + "zh:5814be3f9c9cc39d2955d6f083bae793050d75c572e70ca11ccceb5517ced6b1", + "zh:63c6548a06de1231c8ee5570e42ca09c4b3db336578ded39b938f2156f06dd2e", + "zh:697e434c6bdee0502cc3deb098263b8dcd63948e8a96d61722811628dce2eba1", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a0b8e44927e6327852bbfdc9d408d802569367f1e22a95bcdd7181b1c3b07601", + "zh:b7d3af018683ef22794eea9c218bc72d7c35a2b3ede9233b69653b3c782ee436", + "zh:d63b911d618a6fe446c65bfc21e793a7663e934b2fef833d42d3ccd38dd8d68d", + "zh:fa985cd0b11e6d651f47cff3055f0a9fd085ec190b6dbe99bf5448174434cdea", + ] +} diff --git a/scenarios/AksOpenAiTerraform/terraform/main.tf b/scenarios/AksOpenAiTerraform/terraform/main.tf new file mode 100644 index 000000000..cf95667e4 --- /dev/null +++ b/scenarios/AksOpenAiTerraform/terraform/main.tf @@ -0,0 +1,144 @@ +############################################################################### +# azurerm plugin setup +############################################################################### +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.20.0" + } + } +} + +provider "azurerm" { + features {} +} + +############################################################################### +# Resource Group +############################################################################### +data "azurerm_client_config" "current" { +} + +resource "random_string" "this" { + length = 8 + special = false + lower = true + upper = false + numeric = false +} + +locals { + tenant_id = data.azurerm_client_config.current.tenant_id + subscription_id = data.azurerm_client_config.current.subscription_id + random_id = random_string.this.result +} + +resource "azurerm_resource_group" "main" { + name = "${var.resource_group_name_prefix}-${local.random_id}-rg" + location = var.location + + lifecycle { + ignore_changes = [tags] + } +} + +############################################################################### +# Kubernetes +############################################################################### +resource "azurerm_kubernetes_cluster" "main" { + name = "AksCluster" + location = var.location + resource_group_name = azurerm_resource_group.main.name + + sku_tier = "Standard" + dns_prefix = "AksCluster${local.random_id}" + kubernetes_version = var.kubernetes_version + automatic_upgrade_channel = "stable" + + workload_identity_enabled = true + oidc_issuer_enabled = true + + image_cleaner_enabled = true + image_cleaner_interval_hours = 72 + + default_node_pool { + name = "agentpool" + vm_size = "Standard_DS2_v2" + node_count = 2 + + upgrade_settings { + max_surge = "10%" + drain_timeout_in_minutes = 0 + node_soak_duration_in_minutes = 0 + } + } + + identity { + type = "UserAssigned" + identity_ids = tolist([azurerm_user_assigned_identity.workload.id]) + } +} + +resource "azurerm_user_assigned_identity" "workload" { + name = "WorkloadManagedIdentity" + resource_group_name = azurerm_resource_group.main.name + location = var.location +} + +resource "azurerm_federated_identity_credential" "this" { + name = azurerm_user_assigned_identity.workload.name + resource_group_name = azurerm_user_assigned_identity.workload.resource_group_name + parent_id = azurerm_user_assigned_identity.workload.id + audience = ["api://AzureADTokenExchange"] + issuer = azurerm_kubernetes_cluster.main.oidc_issuer_url + subject = "system:serviceaccount:default:magic8ball-sa" +} + +############################################################################### +# OpenAI +############################################################################### +resource "azurerm_cognitive_account" "openai" { + name = "OpenAi-${local.random_id}" + location = var.location + resource_group_name = azurerm_resource_group.main.name + + kind = "OpenAI" + custom_subdomain_name = "magic8ball-${local.random_id}" + sku_name = "S0" +} + +resource "azurerm_cognitive_deployment" "deployment" { + name = var.model_name + cognitive_account_id = azurerm_cognitive_account.openai.id + + model { + format = "OpenAI" + name = var.model_name + version = var.model_version + } + + sku { + name = "Standard" + } +} + +resource "azurerm_role_assignment" "cognitive_services_user" { + scope = azurerm_cognitive_account.openai.id + role_definition_name = "Cognitive Services User" + principal_id = azurerm_user_assigned_identity.workload.principal_id + principal_type = "ServicePrincipal" + + skip_service_principal_aad_check = true +} + +############################################################################### +# Networking +############################################################################### +resource "azurerm_public_ip" "this" { + name = "PublicIp" + domain_name_label = "magic8ball-${local.random_id}" + location = var.location + resource_group_name = azurerm_kubernetes_cluster.main.node_resource_group + allocation_method = "Static" +} \ No newline at end of file diff --git a/scenarios/AksOpenAiTerraform/terraform/outputs.tf b/scenarios/AksOpenAiTerraform/terraform/outputs.tf new file mode 100644 index 000000000..2411dcba1 --- /dev/null +++ b/scenarios/AksOpenAiTerraform/terraform/outputs.tf @@ -0,0 +1,27 @@ +output "resource_group_name" { + value = azurerm_resource_group.main.name +} + +output "workload_identity_client_id" { + value = azurerm_user_assigned_identity.workload.client_id +} + +output "openai_endpoint" { + value = azurerm_cognitive_account.openai.endpoint +} + +output "openai_deployment" { + value = azurerm_cognitive_deployment.deployment.name +} + +output "hostname" { + value = azurerm_public_ip.this.fqdn +} + +output "static_ip" { + value = azurerm_public_ip.this.ip_address +} + +output "dns_label" { + value = azurerm_public_ip.this.domain_name_label +} \ No newline at end of file diff --git a/scenarios/AksOpenAiTerraform/terraform/variables.tf b/scenarios/AksOpenAiTerraform/terraform/variables.tf new file mode 100644 index 000000000..05ce7856e --- /dev/null +++ b/scenarios/AksOpenAiTerraform/terraform/variables.tf @@ -0,0 +1,20 @@ +variable "resource_group_name_prefix" { + type = string + default = "AksOpenAiTerraform" +} + +variable "location" { + type = string +} + +variable "kubernetes_version" { + type = string +} + +variable "model_name" { + type = string +} + +variable "model_version" { + type = string +} \ No newline at end of file diff --git a/scenarios/metadata.json b/scenarios/metadata.json index 908efc672..d2b8cad0d 100644 --- a/scenarios/metadata.json +++ b/scenarios/metadata.json @@ -930,5 +930,18 @@ "configurations": { "permissions": [] } + }, + { + "status": "inactive", + "key": "AksOpenAiTerraform/README.md", + "title": "How to deploy and run an Azure OpenAI ChatGPT application on AKS via Terraform", + "description": "This article shows how to deploy an AKS cluster and Azure OpenAI Service via Terraform and how to deploy a ChatGPT-like application in Python.", + "stackDetails": [], + "sourceUrl": "https://raw.githubusercontent.com/MicrosoftDocs/executable-docs/test_terraform/scenarios/AksOpenAiTerraform/README.md", + "documentationUrl": "", + "nextSteps": [], + "configurations": { + "permissions": [] + } } ]