From a40553c9cf7a8df99df1b9b0aea53e911e12e8c0 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 17:03:05 +0000 Subject: [PATCH 001/163] new version alpha development --- .github/dependabot.yml | 4 +- ...s.yml => auto-dependency-test-release.yml} | 0 ...tests.yml => manual-test-release copy.yml} | 0 .github/workflows/manual-test.yml | 39 ++ data.tf | 12 + main.tf | 76 ++-- modules/networking/README.md | 11 + modules/networking/main.tf | 34 ++ modules/networking/outputs.tf | 9 + modules/networking/variables.tf | 66 ++++ tests/auto_test1/main.tf | 31 +- tests/auto_test1/testing.auto.tfvars | 141 ++++--- tests/auto_test1/variables.tf | 341 +++++++++------- variables.tf | 364 ++++++++++-------- 14 files changed, 728 insertions(+), 400 deletions(-) rename .github/workflows/{dependency-tests.yml => auto-dependency-test-release.yml} (100%) rename .github/workflows/{manual-tests.yml => manual-test-release copy.yml} (100%) create mode 100644 .github/workflows/manual-test.yml create mode 100644 data.tf create mode 100644 modules/networking/README.md create mode 100644 modules/networking/main.tf create mode 100644 modules/networking/outputs.tf create mode 100644 modules/networking/variables.tf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 332f269..5034c6e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,8 +11,8 @@ updates: interval: "daily" timezone: "Europe/London" - - package-ecosystem: "github-actions" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" day: "sunday" diff --git a/.github/workflows/dependency-tests.yml b/.github/workflows/auto-dependency-test-release.yml similarity index 100% rename from .github/workflows/dependency-tests.yml rename to .github/workflows/auto-dependency-test-release.yml diff --git a/.github/workflows/manual-tests.yml b/.github/workflows/manual-test-release copy.yml similarity index 100% rename from .github/workflows/manual-tests.yml rename to .github/workflows/manual-test-release copy.yml diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml new file mode 100644 index 0000000..e906018 --- /dev/null +++ b/.github/workflows/manual-test.yml @@ -0,0 +1,39 @@ +### This workflow will run only when triggered manually ### +### Full integration test is done by doing a plan and build of config under ./tests/auto_test1 ### +### No release nor documentation updates are part of this test ### + +################################################################## +### RESOURCES HAVE TO BE DELETED MANUALLY AFTER TESTS ARE DONE ### +################################################################## + +name: "Manual-Tests" +on: + workflow_dispatch: + +jobs: + manual_plan_apply_destroy: + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + + - name: Run Dependency Tests - Plan AND Apply AND Destroy + uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 + with: + test_type: plan-apply ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" + path: "tests/auto_test1" ## (Optional) Specify path to test module to run. + tf_version: latest ## (Optional) Specifies version of Terraform to use. e.g: 1.1.0 Default="latest" + tf_vars_file: testing.auto.tfvars ## (Required) Specifies Terraform TFVARS file name inside module path (Testing vars) + tf_key: tf-mod-tests-openai-gptui ## (Required) AZ backend - Specifies name that will be given to terraform state file and plan artifact (testing state) + az_resource_group: Terraform-GitHub-Backend ## (Required) AZ backend - AZURE Resource Group hosting terraform backend storage account + az_storage_acc: tfgithubbackendsa ## (Required) AZ backend - AZURE terraform backend storage account + az_container_name: gh-openai-gpt ## (Required) AZ backend - AZURE storage container hosting state files + arm_client_id: ${{ secrets.ARM_CLIENT_ID }} ## (Required - Actions Secrets) ARM Client ID + arm_client_secret: ${{ secrets.ARM_CLIENT_SECRET }} ## (Required - Actions Secrets) ARM Client Secret + arm_subscription_id: ${{ secrets.ARM_SUBSCRIPTION_ID }} ## (Required - Actions Secrets) ARM Subscription ID + arm_tenant_id: ${{ secrets.ARM_TENANT_ID }} ## (Required - Actions Secrets) ARM Tenant ID + github_token: ${{ secrets.GITHUB_TOKEN }} ## (Required) Needed to comment output on PR's. ${{ secrets.GITHUB_TOKEN }} already has permissions. diff --git a/data.tf b/data.tf new file mode 100644 index 0000000..9622618 --- /dev/null +++ b/data.tf @@ -0,0 +1,12 @@ +################################################## +# DATA # +################################################## + +# Data sources to get Subnet ID/ss for CosmosDB and App Service +# Usage in Module example: subnet_id = data.azurerm_subnet.subnet["app-cosmos-sub"].id +data "azurerm_subnet" "subnet" { + for_each = { for each in subnet_config : each.subnet_name => each } + name = each.value.subnet_name + virtual_network_name = var.virtual_network_name + resource_group_name = var.network_resource_group_name +} diff --git a/main.tf b/main.tf index 03129a0..0f91b7e 100644 --- a/main.tf +++ b/main.tf @@ -38,41 +38,59 @@ module "openai" { model_deployment = var.model_deployment } +### Create openai networking for CosmosDB and Web App (Optional) ### +# 5.) Create networking for CosmosDB and Web App (Optional) +module "openai_networking" { + count = var.create_openai_networking ? 1 : 0 + source = "./modules/networking" + network_resource_group_name = var.network_resource_group_name + location = var.location + virtual_network_name = var.virtual_network_name + vnet_address_space = var.vnet_address_space + subnet_config = var.subnet_config + tags = var.tags +} + +### Create a CosmosDB account running MongoDB to store chat data ### + +### Vreate the Web App ### + ### Create a container app ChatBot UI linked with OpenAI service hosted in Azure ### # 5.) Create a container app log analytics workspace. # 6.) Create a container app environment. # 7.) Create a container app instance. # 8.) grant the container app access a the key vault (optional). -module "privategpt_chatbot_container_apps" { - source = "./modules/container_app" - - #common - ca_resource_group_name = var.ca_resource_group_name - location = var.location - tags = var.tags - #log analytics workspace - laws_name = var.laws_name - laws_sku = var.laws_sku - laws_retention_in_days = var.laws_retention_in_days - - #container app environment - cae_name = var.cae_name - - #container app - ca_name = var.ca_name - ca_revision_mode = var.ca_revision_mode - ca_identity = var.ca_identity - ca_ingress = var.ca_ingress - ca_container_config = var.ca_container_config - ca_secrets = var.ca_secrets - - #key vault access - key_vault_access_permission = var.key_vault_access_permission #Set to `null` if no Key Vault access is needed on CA identity. - key_vault_id = var.key_vault_id #Provide the key vault id if key_vault_access_permission is not null. - - depends_on = [module.openai] -} +##module "privategpt_chatbot_container_apps" { +## source = "./modules/container_app" +## +## #common +## ca_resource_group_name = var.ca_resource_group_name +## location = var.location +## tags = var.tags +## +## #log analytics workspace +## laws_name = var.laws_name +## laws_sku = var.laws_sku +## laws_retention_in_days = var.laws_retention_in_days +## +## #container app environment +## cae_name = var.cae_name +## +## #container app +## ca_name = var.ca_name +## ca_revision_mode = var.ca_revision_mode +## ca_identity = var.ca_identity +## ca_ingress = var.ca_ingress +## ca_container_config = var.ca_container_config +## ca_secrets = var.ca_secrets +## +## #key vault access +## key_vault_access_permission = var.key_vault_access_permission #Set to `null` if no Key Vault access is needed on CA identity. +## key_vault_id = var.key_vault_id #Provide the key vault id if key_vault_access_permission is not null. +## +## depends_on = [module.openai] +##} ### Front solution with an Azure front door (optional) ### # 9.) Deploy Azure Front Door. diff --git a/modules/networking/README.md b/modules/networking/README.md new file mode 100644 index 0000000..e1d9a2b --- /dev/null +++ b/modules/networking/README.md @@ -0,0 +1,11 @@ +# Module: Azure Networking Resources (Optional) + +Create a new VNET and subnet/s for the CosmosDB and Web App resources to use. (Optional) +If existing networking resources are to be used, then the variables/names of the existing VNET and subnets must be provided as input variables to root the module (data sources): + +- Create a VNET. +- Create a Delegated Subnet for App Service + CosmosDB + Service Endpoint. + + + + \ No newline at end of file diff --git a/modules/networking/main.tf b/modules/networking/main.tf new file mode 100644 index 0000000..2333245 --- /dev/null +++ b/modules/networking/main.tf @@ -0,0 +1,34 @@ +resource "azurerm_virtual_network" "vnet" { + name = var.virtual_network_name + location = var.location + resource_group_name = var.network_resource_group_name + address_space = var.vnet_address_space + tags = var.tags +} + +# Azure Virtual Network Subnets +resource "azurerm_subnet" "subnet" { + for_each = { for each in var.subnet_config : each.subnet_name => each } + + resource_group_name = var.network_resource_group_name + virtual_network_name = azurerm_virtual_network.vnet.name + name = each.value.subnet_name + address_prefixes = each.value.subnet_address_space + service_endpoints = each.value.service_endpoints + private_link_service_network_policies_enabled = each.value.private_link_service_network_policies_enabled + private_endpoint_network_policies_enabled = each.value.private_endpoint_network_policies_enabled + + dynamic "delegation" { + for_each = each.value.subnets_delegation_settings + content { + name = delegation.key + dynamic "service_delegation" { + for_each = toset(delegation.value) + content { + name = service_delegation.value.name + actions = service_delegation.value.actions + } + } + } + } +} diff --git a/modules/networking/outputs.tf b/modules/networking/outputs.tf new file mode 100644 index 0000000..66ae07e --- /dev/null +++ b/modules/networking/outputs.tf @@ -0,0 +1,9 @@ +output "virtual_network_id" { + description = "The ID of the Virtual Network" + value = azurerm_virtual_network.vnet.id +} + +output "subnet_ids" { + description = "The IDs of the Subnets" + value = { for each in azurerm_subnet.subnet : each.name => each.id } +} \ No newline at end of file diff --git a/modules/networking/variables.tf b/modules/networking/variables.tf new file mode 100644 index 0000000..584dc39 --- /dev/null +++ b/modules/networking/variables.tf @@ -0,0 +1,66 @@ +variable "network_resource_group_name" { + type = string + description = "Name of the resource group to where networking resources will be hosted." + nullable = false +} + +variable "location" { + type = string + default = "uksouth" + description = "Azure region where resources will be hosted." +} + +variable "tags" { + type = map(string) + default = { + Terraform = "True" + Description = "OpenAI Private Networking Resource." + Author = "Marcel Lupo" + GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" + } + description = "A map of key value pairs that is used to tag resources created." +} + +variable "virtual_network_name" { + type = string + default = "openai-vnet" + description = "Name of the virtual network to create." +} + +variable "vnet_address_space" { + type = list(string) + default = ["10.4.0.0/16"] + description = "value of the address space for the virtual network." +} + +variable "subnet_config" { + type = list(object({ + subnet_name = string + subnet_address_space = list(string) + service_endpoints = list(string) + private_endpoint_network_policies_enabled = bool + private_link_service_network_policies_enabled = bool + subnets_delegation_settings = map(list(object({ + name = string + actions = list(string) + }))) + })) + default = [ + { + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] + } + } + ] + description = "A list of subnet configuration objects to create subnets in the virtual network." +} \ No newline at end of file diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 578718d..c622728 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -60,27 +60,34 @@ module "private-chatgpt-openai" { create_model_deployment = var.create_model_deployment model_deployment = var.model_deployment + #Create networking for CosmosDB and Web App (Optional) + create_openai_networking = var.create_openai_networking + network_resource_group_name = azurerm_resource_group.rg.name + virtual_network_name = var.virtual_network_name + vnet_address_space = var.vnet_address_space + subnet_config = var.subnet_config + #Create a solution log analytics workspace to store logs from our container apps instance - laws_name = "${var.laws_name}${random_integer.number.result}" - laws_sku = var.laws_sku - laws_retention_in_days = var.laws_retention_in_days + #laws_name = "${var.laws_name}${random_integer.number.result}" + #laws_sku = var.laws_sku + #laws_retention_in_days = var.laws_retention_in_days #Create Container App Enviornment - cae_name = "${var.cae_name}${random_integer.number.result}" + #cae_name = "${var.cae_name}${random_integer.number.result}" #Create a container app instance - ca_resource_group_name = azurerm_resource_group.rg.name - ca_name = "${var.ca_name}${random_integer.number.result}" - ca_revision_mode = var.ca_revision_mode - ca_identity = var.ca_identity - ca_container_config = var.ca_container_config + #ca_resource_group_name = azurerm_resource_group.rg.name + #ca_name = "${var.ca_name}${random_integer.number.result}" + #ca_revision_mode = var.ca_revision_mode + #ca_identity = var.ca_identity + #ca_container_config = var.ca_container_config #Create a container app secrets - ca_secrets = local.ca_secrets + #ca_secrets = local.ca_secrets #key vault access - key_vault_access_permission = var.key_vault_access_permission - key_vault_id = data.azurerm_key_vault.gpt.id + #key_vault_access_permission = var.key_vault_access_permission + #key_vault_id = data.azurerm_key_vault.gpt.id #Create front door CDN create_front_door_cdn = var.create_front_door_cdn diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 3a3395a..855cf32 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -30,82 +30,97 @@ openai_identity = { create_model_deployment = true model_deployment = [ { - deployment_id = "gpt35turbo16k" - model_name = "gpt-35-turbo-16k" + deployment_id = "gpt4p1106" + model_name = "gpt-4" model_format = "OpenAI" - model_version = "0613" + model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 34 - }, + scale_capacity = 34 # 34K == Roughly 204 RPM (Requests per minute) + } +] + +### networking ### +create_openai_networking = true +network_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" +virtual_network_name = "openai-vnet" +vnet_address_space = ["10.4.0.0/16"] +subnet_config = [ { - deployment_id = "gpt432k" - model_name = "gpt-4-32k" - model_format = "OpenAI" - model_version = "0613" - scale_type = "Standard" - scale_capacity = 26 + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] + } } ] ### log analytics workspace for container apps ### -laws_name = "gptlaws" -laws_sku = "PerGB2018" -laws_retention_in_days = 30 +#laws_name = "gptlaws" +#laws_sku = "PerGB2018" +#laws_retention_in_days = 30 ### Container App Enviornment ### -cae_name = "gptcae" +#cae_name = "gptcae" ### Container App ### -ca_name = "gptca" -ca_revision_mode = "Single" -ca_identity = { - type = "SystemAssigned" -} -ca_ingress = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - latest_revision = true - percentage = 100 - } -} -ca_container_config = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 2 - memory = "4Gi" - min_replicas = 0 - max_replicas = 5 - - ## Environment Variables (Required)## - env = [ - { - name = "OPENAI_API_KEY" - secret_name = "openai-api-key" #see locals.tf (Can also be added from key vault created by module, or existing key) - }, - { - name = "OPENAI_API_HOST" - secret_name = "openai-api-host" #see locals.tf (Can also be added from key vault created by module, or existing host/endpoint) - }, - { - name = "OPENAI_API_TYPE" - value = "azure" - }, - { - name = "AZURE_DEPLOYMENT_ID" #see model_deployment variable (deployment_id) - value = "gpt432k" - }, - { - name = "DEFAULT_MODEL" #see model_deployment variable (model_name) - value = "gpt-4-32k" - } - ] -} +#ca_name = "gptca" +#ca_revision_mode = "Single" +#ca_identity = { +# type = "SystemAssigned" +#} +#ca_ingress = { +# allow_insecure_connections = false +# external_enabled = true +# target_port = 3000 +# transport = "auto" +# traffic_weight = { +# latest_revision = true +# percentage = 100 +# } +#} +#ca_container_config = { +# name = "gpt-chatbot-ui" +# image = "ghcr.io/pwd9000-ml/chatbot-ui:main" +# cpu = 2 +# memory = "4Gi" +# min_replicas = 0 +# max_replicas = 5 + +## Environment Variables (Required)## +# env = [ +# { +# name = "OPENAI_API_KEY" +# secret_name = "openai-api-key" #see locals.tf (Can also be added from key vault created by module, or existing key) +# }, +# { +# name = "OPENAI_API_HOST" +# secret_name = "openai-api-host" #see locals.tf (Can also be added from key vault created by module, or existing host/endpoint) +# }, +# { +# name = "OPENAI_API_TYPE" +# value = "azure" +# }, +# { +# name = "AZURE_DEPLOYMENT_ID" #see model_deployment variable (deployment_id) +# value = "gpt432k" +# }, +# { +# name = "DEFAULT_MODEL" #see model_deployment variable (model_name) +# value = "gpt-4-32k" +# } +# ] +#} ### key vault access ### -key_vault_access_permission = ["Key Vault Secrets User"] # set to `null` to ignore permission grant to a key vault +#key_vault_access_permission = ["Key Vault Secrets User"] # set to `null` to ignore permission grant to a key vault #key_vault_id = "kv-to-grant-permission-to" (See `data.tf`) Only required if `var.key_vault_access_permission` not `null`) ### CDN - Front Door ### diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index b94d752..ea62f02 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -157,169 +157,226 @@ variable "model_deployment" { nullable = false } -### log analytics workspace ### -variable "laws_name" { - type = string - description = "Name of the log analytics workspace to create." - default = "gptlaws" -} - -variable "laws_sku" { - type = string - description = "SKU of the log analytics workspace to create." - default = "PerGB2018" -} - -variable "laws_retention_in_days" { - type = number - description = "Retention in days of the log analytics workspace to create." - default = 30 -} - -### container app environment ### -variable "cae_name" { - type = string - description = "Name of the container app environment to create." - default = "gptcae" +### networking ### +variable "create_openai_networking" { + description = "Create a virtual network and subnet/s for networked services" + type = bool + default = false } -### container app ### -variable "ca_name" { +variable "network_resource_group_name" { type = string - description = "Name of the container app to create." - default = "gptca" + description = "Name of the resource group to where networking resources will be hosted." + nullable = false } -variable "ca_revision_mode" { +variable "virtual_network_name" { type = string - description = "Revision mode of the container app to create." - default = "Single" -} - -variable "ca_identity" { - type = object({ - type = string - identity_ids = optional(list(string)) - }) default = null - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -variable "ca_ingress" { - type = object({ - allow_insecure_connections = optional(bool) - external_enabled = optional(bool) - target_port = number - transport = optional(string) - traffic_weight = optional(object({ - percentage = number - latest_revision = optional(bool) - })) - }) - default = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - percentage = 100 - latest_revision = true - } - } - description = <<-DESCRIPTION - type = object({ - allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. - external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. - target_port = (Required) The port to use for the container app. Defaults to `3000`. - transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. - type = object({ - percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. - latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. - }) - DESCRIPTION + description = "Name of the virtual network where resources are attached." } -variable "ca_container_config" { - type = object({ - name = string - image = string - cpu = number - memory = string - min_replicas = optional(number, 0) - max_replicas = optional(number, 10) - env = optional(list(object({ - name = string - secret_name = optional(string) - value = optional(string) - }))) - }) - default = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 1 - memory = "2Gi" - min_replicas = 0 - max_replicas = 10 - env = [] - } - description = <<-DESCRIPTION - type = object({ - name = (Required) The name of the container. - image = (Required) The name of the container image. - cpu = (Required) The number of CPU cores to allocate to the container. - memory = (Required) The amount of memory to allocate to the container in GB. - min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. - max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. - env = list(object({ - name = (Required) The name of the environment variable. - secret_name = (Optional) The name of the secret to use for the environment variable. - value = (Optional) The value of the environment variable. - })) - }) - DESCRIPTION +variable "vnet_address_space" { + type = list(string) + default = null + description = "value of the address space for the virtual network." } -variable "ca_secrets" { +variable "subnet_config" { type = list(object({ - name = string - value = string + subnet_name = string + subnet_address_space = list(string) + service_endpoints = list(string) + private_endpoint_network_policies_enabled = bool + private_link_service_network_policies_enabled = bool + subnets_delegation_settings = map(list(object({ + name = string + actions = list(string) + }))) })) default = [ { - name = "secret1" - value = "value1" - }, - { - name = "secret2" - value = "value2" + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] + } } ] - description = <<-DESCRIPTION - type = list(object({ - name = (Required) The name of the secret. - value = (Required) The value of the secret. - })) - DESCRIPTION + description = "A list of subnet configuration objects to create subnets in the virtual network." } +### log analytics workspace ### +#variable "laws_name" { +# type = string +# description = "Name of the log analytics workspace to create." +# default = "gptlaws" +#} + +#variable "laws_sku" { +# type = string +# description = "SKU of the log analytics workspace to create." +# default = "PerGB2018" +#} + +#variable "laws_retention_in_days" { +# type = number +# description = "Retention in days of the log analytics workspace to create." +# default = 30 +#} + +### container app environment ### +#variable "cae_name" { +# type = string +# description = "Name of the container app environment to create." +# default = "gptcae" +#} + +### container app ### +#variable "ca_name" { +# type = string +# description = "Name of the container app to create." +# default = "gptca" +#} + +#variable "ca_revision_mode" { +# type = string +# description = "Revision mode of the container app to create." +# default = "Single" +#} + +#variable "ca_identity" { +# type = object({ +# type = string +# identity_ids = optional(list(string)) +# }) +# default = null +# description = <<-DESCRIPTION +# type = object({ +# type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. +# identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. +# }) +# DESCRIPTION +#} + +#variable "ca_ingress" { +# type = object({ +# allow_insecure_connections = optional(bool) +# external_enabled = optional(bool) +# target_port = number +# transport = optional(string) +# traffic_weight = optional(object({ +# percentage = number +# latest_revision = optional(bool) +# })) +# }) +# default = { +# allow_insecure_connections = false +# external_enabled = true +# target_port = 3000 +# transport = "auto" +# traffic_weight = { +# percentage = 100 +# latest_revision = true +# } +# } +# description = <<-DESCRIPTION +# type = object({ +# allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. +# external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. +# target_port = (Required) The port to use for the container app. Defaults to `3000`. +# transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. +# type = object({ +# percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. +# latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. +# }) +# DESCRIPTION +#} + +#variable "ca_container_config" { +# type = object({ +# name = string +# image = string +# cpu = number +# memory = string +# min_replicas = optional(number, 0) +# max_replicas = optional(number, 10) +# env = optional(list(object({ +# name = string +# secret_name = optional(string) +# value = optional(string) +# }))) +# }) +# default = { +# name = "gpt-chatbot-ui" +# image = "ghcr.io/pwd9000-ml/chatbot-ui:main" +# cpu = 1 +# memory = "2Gi" +# min_replicas = 0 +# max_replicas = 10 +# env = [] +# } +# description = <<-DESCRIPTION +# type = object({ +# name = (Required) The name of the container. +# image = (Required) The name of the container image. +# cpu = (Required) The number of CPU cores to allocate to the container. +# memory = (Required) The amount of memory to allocate to the container in GB. +# min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. +# max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. +# env = list(object({ +# name = (Required) The name of the environment variable. +# secret_name = (Optional) The name of the secret to use for the environment variable. +# value = (Optional) The value of the environment variable. +# })) +# }) +# DESCRIPTION +#} + +#variable "ca_secrets" { +# type = list(object({ +# name = string +# value = string +# })) +# default = [ +# { +# name = "secret1" +# value = "value1" +# }, +# { +# name = "secret2" +# value = "value2" +# } +# ] +# description = <<-DESCRIPTION +# type = list(object({ +# name = (Required) The name of the secret. +# value = (Required) The value of the secret. +# })) +# DESCRIPTION +#} + # Key Vault Access # ### key vault access ### -variable "key_vault_access_permission" { - type = list(string) - default = ["Key Vault Secrets User"] - description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -} - -variable "key_vault_id" { - type = string - default = "" - description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." -} +#variable "key_vault_access_permission" { +# type = list(string) +# default = ["Key Vault Secrets User"] +# description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." +#} + +#variable "key_vault_id" { +# type = string +# default = "" +# description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." +#} # DNS zone # variable "create_dns_zone" { diff --git a/variables.tf b/variables.tf index 40a191a..1c204ae 100644 --- a/variables.tf +++ b/variables.tf @@ -167,183 +167,243 @@ variable "model_deployment" { nullable = false } -################################### -### Container App Module params ### -################################### -variable "ca_resource_group_name" { - type = string - description = "Name of the resource group to create the Container App and supporting solution resources in." - nullable = false -} +################## +### Networking ### +################## -### log analytics workspace ### -variable "laws_name" { - type = string - description = "Name of the log analytics workspace to create." - default = "gptlaws" -} - -variable "laws_sku" { - type = string - description = "SKU of the log analytics workspace to create." - default = "PerGB2018" -} - -variable "laws_retention_in_days" { - type = number - description = "Retention in days of the log analytics workspace to create." - default = 30 -} - -### container app environment ### -variable "cae_name" { - type = string - description = "Name of the container app environment to create." - default = "gptcae" +variable "create_openai_networking" { + description = "Create a virtual network and subnet/s for networked services" + type = bool + default = false } - -### container app ### -variable "ca_name" { +variable "network_resource_group_name" { type = string - description = "Name of the container app to create." - default = "gptca" + description = "Name of the resource group to where networking resources will be hosted." + nullable = false } -variable "ca_revision_mode" { +variable "virtual_network_name" { type = string - description = "Revision mode of the container app to create." - default = "Single" -} - -variable "ca_identity" { - type = object({ - type = string - identity_ids = optional(list(string)) - }) default = null - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -variable "ca_ingress" { - type = object({ - allow_insecure_connections = optional(bool) - external_enabled = optional(bool) - target_port = number - transport = optional(string) - traffic_weight = optional(object({ - percentage = number - latest_revision = optional(bool) - })) - }) - default = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - percentage = 100 - latest_revision = true - } - } - description = <<-DESCRIPTION - type = object({ - allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. - external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. - target_port = (Required) The port to use for the container app. Defaults to `3000`. - transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. - type = object({ - percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. - latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. - }) - DESCRIPTION + description = "Name of the virtual network where resources are attached." } -variable "ca_container_config" { - type = object({ - name = string - image = string - cpu = number - memory = string - min_replicas = optional(number, 0) - max_replicas = optional(number, 10) - env = optional(list(object({ - name = string - secret_name = optional(string) - value = optional(string) - }))) - }) - default = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 1 - memory = "2Gi" - min_replicas = 0 - max_replicas = 10 - env = [] - } - description = <<-DESCRIPTION - type = object({ - name = (Required) The name of the container. - image = (Required) The name of the container image. - cpu = (Required) The number of CPU cores to allocate to the container. - memory = (Required) The amount of memory to allocate to the container in GB. - min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. - max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. - env = list(object({ - name = (Required) The name of the environment variable. - secret_name = (Optional) The name of the secret to use for the environment variable. - value = (Optional) The value of the environment variable. - })) - }) - DESCRIPTION +variable "vnet_address_space" { + type = list(string) + default = null + description = "value of the address space for the virtual network." } -variable "ca_secrets" { +variable "subnet_config" { type = list(object({ - name = string - value = string + subnet_name = string + subnet_address_space = list(string) + service_endpoints = list(string) + private_endpoint_network_policies_enabled = bool + private_link_service_network_policies_enabled = bool + subnets_delegation_settings = map(list(object({ + name = string + actions = list(string) + }))) })) default = [ { - name = "secret1" - value = "value1" - }, - { - name = "secret2" - value = "value2" + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] + } } ] - description = <<-DESCRIPTION - type = list(object({ - name = (Required) The name of the secret. - value = (Required) The value of the secret. - })) - DESCRIPTION + description = "A list of subnet configuration objects to create subnets in the virtual network." } -### key vault access ### -variable "key_vault_access_permission" { - type = list(string) - default = ["Key Vault Secrets User"] - description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -} +################################### +### Container App Module params ### +################################### +#variable "ca_resource_group_name" { +# type = string +# description = "Name of the resource group to create the Container App and supporting solution resources in." +# nullable = false +#} -variable "key_vault_id" { - type = string - description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." - default = "" -} +### log analytics workspace ### +#variable "laws_name" { +# type = string +# description = "Name of the log analytics workspace to create." +# default = "gptlaws" +#} + +#variable "laws_sku" { +# type = string +# description = "SKU of the log analytics workspace to create." +# default = "PerGB2018" +#} + +#variable "laws_retention_in_days" { +# type = number +# description = "Retention in days of the log analytics workspace to create." +# default = 30 +#} + +### container app environment ### +#variable "cae_name" { +# type = string +# description = "Name of the container app environment to create." +# default = "gptcae" +#} + + +### container app ### +#variable "ca_name" { +# type = string +# description = "Name of the container app to create." +# default = "gptca" +#} + +#variable "ca_revision_mode" { +# type = string +# description = "Revision mode of the container app to create." +# default = "Single" +#} + +#variable "ca_identity" { +# type = object({ +# type = string +# identity_ids = optional(list(string)) +# }) +# default = null +# description = <<-DESCRIPTION +# type = object({ +# type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. +# identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. +# }) +# DESCRIPTION +#} + +#variable "ca_ingress" { +# type = object({ +# allow_insecure_connections = optional(bool) +# external_enabled = optional(bool) +# target_port = number +# transport = optional(string) +# traffic_weight = optional(object({ +# percentage = number +# latest_revision = optional(bool) +# })) +# }) +# default = { +# allow_insecure_connections = false +# external_enabled = true +# target_port = 3000 +# transport = "auto" +# traffic_weight = { +# percentage = 100 +# latest_revision = true +# } +# } +# description = <<-DESCRIPTION +# type = object({ +# allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. +# external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. +# target_port = (Required) The port to use for the container app. Defaults to `3000`. +# transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. +# type = object({ +# percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. +# latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. +# }) +# DESCRIPTION +#} + +#variable "ca_container_config" { +# type = object({ +# name = string +# image = string +# cpu = number +# memory = string +# min_replicas = optional(number, 0) +# max_replicas = optional(number, 10) +# env = optional(list(object({ +# name = string +# secret_name = optional(string) +# value = optional(string) +# }))) +# }) +# default = { +# name = "gpt-chatbot-ui" +# image = "ghcr.io/pwd9000-ml/chatbot-ui:main" +# cpu = 1 +# memory = "2Gi" +# min_replicas = 0 +# max_replicas = 10 +# env = [] +# } +# description = <<-DESCRIPTION +# type = object({ +# name = (Required) The name of the container. +# image = (Required) The name of the container image. +# cpu = (Required) The number of CPU cores to allocate to the container. +# memory = (Required) The amount of memory to allocate to the container in GB. +# min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. +# max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. +# env = list(object({ +# name = (Required) The name of the environment variable. +# secret_name = (Optional) The name of the secret to use for the environment variable. +# value = (Optional) The value of the environment variable. +# })) +# }) +# DESCRIPTION +#} + +#variable "ca_secrets" { +# type = list(object({ +# name = string +# value = string +# })) +# default = [ +# { +# name = "secret1" +# value = "value1" +# }, +# { +# name = "secret2" +# value = "value2" +# } +# ] +# description = <<-DESCRIPTION +# type = list(object({ +# name = (Required) The name of the secret. +# value = (Required) The value of the secret. +# })) +# DESCRIPTION +#} + +### key vault access ### +#variable "key_vault_access_permission" { +# type = list(string) +# default = ["Key Vault Secrets User"] +# description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." +#} + +#variable "key_vault_id" { +# type = string +# description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." +# default = "" +#} #################################### ### CDN Front Door Module params ### #################################### -# DNS zone ## DNS zone # +# DNS zone ## variable "create_dns_zone" { description = "Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided." type = bool From f85576dd0a4680b4b99207ae1f71194ec8a6856e Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 17:12:37 +0000 Subject: [PATCH 002/163] fix workflows --- .../{manual-test-release copy.yml => manual-test-release.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{manual-test-release copy.yml => manual-test-release.yml} (100%) diff --git a/.github/workflows/manual-test-release copy.yml b/.github/workflows/manual-test-release.yml similarity index 100% rename from .github/workflows/manual-test-release copy.yml rename to .github/workflows/manual-test-release.yml From 1d00a97bea2cec42cf2500f3b35b761841a550fb Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 17:25:14 +0000 Subject: [PATCH 003/163] remove workflows --- .../auto-dependency-test-release.yml | 116 ------------------ .github/workflows/manual-test-release.yml | 81 ------------ .github/workflows/manual-test.yml | 39 ------ 3 files changed, 236 deletions(-) delete mode 100644 .github/workflows/auto-dependency-test-release.yml delete mode 100644 .github/workflows/manual-test-release.yml delete mode 100644 .github/workflows/manual-test.yml diff --git a/.github/workflows/auto-dependency-test-release.yml b/.github/workflows/auto-dependency-test-release.yml deleted file mode 100644 index 6e0187b..0000000 --- a/.github/workflows/auto-dependency-test-release.yml +++ /dev/null @@ -1,116 +0,0 @@ -### This workflow will run only when Dependabot opens a PR on master ### -### Full integration test is done by doing a plan, build and destroy of config under ./tests/auto_test1 ### -### If tests are successful the PR is automatically merged to master ### -### If the merge was completed the next patch version is released and the patch is bumped and pushed to terraform registry ### - -name: "Automated-Dependency-Tests-and-Release" -on: - workflow_dispatch: - pull_request: - branches: - - master - -jobs: -# Dependabot will open a PR on terraform version changes, this 'dependabot' job is only used to test TF version changes by running a plan, apply and destroy in sequence. - dependabot_plan_apply_destroy: - runs-on: ubuntu-latest - permissions: - pull-requests: write - issues: write - actions: read - if: ${{ github.actor == 'dependabot[bot]' }} - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - - - name: Run Dependency Tests - Plan AND Apply AND Destroy - uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 - with: - test_type: plan-apply-destroy ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" - path: "tests/auto_test1" ## (Optional) Specify path to test module to run. - tf_version: latest ## (Optional) Specifies version of Terraform to use. e.g: 1.1.0 Default="latest" - tf_vars_file: testing.auto.tfvars ## (Required) Specifies Terraform TFVARS file name inside module path (Testing vars) - tf_key: tf-mod-tests-openai-gptui ## (Required) AZ backend - Specifies name that will be given to terraform state file and plan artifact (testing state) - az_resource_group: Terraform-GitHub-Backend ## (Required) AZ backend - AZURE Resource Group hosting terraform backend storage account - az_storage_acc: tfgithubbackendsa ## (Required) AZ backend - AZURE terraform backend storage account - az_container_name: gh-openai-gpt ## (Required) AZ backend - AZURE storage container hosting state files - arm_client_id: ${{ secrets.ARM_CLIENT_ID }} ## (Required - Dependabot Secrets) ARM Client ID - arm_client_secret: ${{ secrets.ARM_CLIENT_SECRET }} ## (Required - Dependabot Secrets) ARM Client Secret - arm_subscription_id: ${{ secrets.ARM_SUBSCRIPTION_ID }} ## (Required - Dependabot Secrets) ARM Subscription ID - arm_tenant_id: ${{ secrets.ARM_TENANT_ID }} ## (Required - Dependabot Secrets) ARM Tenant ID - github_token: ${{ secrets.GITHUB_TOKEN }} ## (Required) Needed to comment output on PR's. ${{ secrets.GITHUB_TOKEN }} already has permissions. - -##### If dependency tests are successful update all readme documentation using terraform-docs ##### - update_docs: - needs: dependabot_plan_apply_destroy - runs-on: ubuntu-latest - permissions: - pull-requests: write - repository-projects: write - contents: write - if: ${{ github.actor == 'dependabot[bot]' }} - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - with: - ref: ${{ github.event.pull_request.head.ref }} - - - name: Render terraform docs inside the README.md and push changes back to PR branch - uses: terraform-docs/gh-actions@v1.0.0 - with: - find-dir: . - output-file: README.md - output-method: inject - git-push: "true" - -##### If dependency tests are successful merge the pull request ##### - merge_pr: - needs: update_docs - runs-on: ubuntu-latest - permissions: - pull-requests: write - repository-projects: write - contents: write - if: ${{ github.actor == 'dependabot[bot]' }} - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - with: - token: ${{secrets.GITHUB_TOKEN}} - - - name: Dependabot metadata - id: metadata - uses: dependabot/fetch-metadata@v1.6.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - - name: Auto-merge PR after tests - run: gh pr merge --auto --merge "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - -##### Create and automate new release based on next patch version of releases ##### - release_new_version: - needs: merge_pr - runs-on: ubuntu-latest - permissions: - contents: write - if: ${{ github.actor == 'dependabot[bot]' }} - steps: - - name: Determine version - id: version - uses: zwaldowski/semver-release-action@v3 - with: - bump: patch - dry_run: true - github_token: ${{secrets.GITHUB_TOKEN}} - - - name: Create new release and push to registry - id: release - uses: ncipollo/release-action@v1.13.0 - with: - generateReleaseNotes: true - name: "v${{ steps.version.outputs.version }}" - tag: ${{ steps.version.outputs.version }} - token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/manual-test-release.yml b/.github/workflows/manual-test-release.yml deleted file mode 100644 index 248af47..0000000 --- a/.github/workflows/manual-test-release.yml +++ /dev/null @@ -1,81 +0,0 @@ -### This workflow will run only when triggered manually ### -### Full integration test is done by doing a plan, build and destroy of config under ./tests/auto_test1 ### -### If tests are successful a new version is released ### - -name: "Manual-Tests-and-Release" -on: - workflow_dispatch: - -jobs: - manual_plan_apply_destroy: - runs-on: ubuntu-latest - permissions: - pull-requests: write - issues: write - actions: read - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - - - name: Run Dependency Tests - Plan AND Apply AND Destroy - uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 - with: - test_type: plan-apply-destroy ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" - path: "tests/auto_test1" ## (Optional) Specify path to test module to run. - tf_version: latest ## (Optional) Specifies version of Terraform to use. e.g: 1.1.0 Default="latest" - tf_vars_file: testing.auto.tfvars ## (Required) Specifies Terraform TFVARS file name inside module path (Testing vars) - tf_key: tf-mod-tests-openai-gptui ## (Required) AZ backend - Specifies name that will be given to terraform state file and plan artifact (testing state) - az_resource_group: Terraform-GitHub-Backend ## (Required) AZ backend - AZURE Resource Group hosting terraform backend storage account - az_storage_acc: tfgithubbackendsa ## (Required) AZ backend - AZURE terraform backend storage account - az_container_name: gh-openai-gpt ## (Required) AZ backend - AZURE storage container hosting state files - arm_client_id: ${{ secrets.ARM_CLIENT_ID }} ## (Required - Actions Secrets) ARM Client ID - arm_client_secret: ${{ secrets.ARM_CLIENT_SECRET }} ## (Required - Actions Secrets) ARM Client Secret - arm_subscription_id: ${{ secrets.ARM_SUBSCRIPTION_ID }} ## (Required - Actions Secrets) ARM Subscription ID - arm_tenant_id: ${{ secrets.ARM_TENANT_ID }} ## (Required - Actions Secrets) ARM Tenant ID - github_token: ${{ secrets.GITHUB_TOKEN }} ## (Required) Needed to comment output on PR's. ${{ secrets.GITHUB_TOKEN }} already has permissions. - -##### If tests are successful update all readme documentation using terraform-docs ##### - update_docs: - needs: manual_plan_apply_destroy - runs-on: ubuntu-latest - permissions: - pull-requests: write - repository-projects: write - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - with: - ref: ${{ github.event.pull_request.head.ref }} - - - name: Render terraform docs inside the README.md and push changes back to PR branch - uses: terraform-docs/gh-actions@v1.0.0 - with: - find-dir: . - output-file: README.md - output-method: inject - git-push: "true" - -##### Create and automate new release based on next patch version of releases ##### - release_new_version: - needs: update_docs - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Determine version - id: version - uses: zwaldowski/semver-release-action@v3 - with: - bump: patch - dry_run: true - github_token: ${{secrets.GITHUB_TOKEN}} - - - name: Create new release and push to registry - id: release - uses: ncipollo/release-action@v1.13.0 - with: - generateReleaseNotes: true - name: "v${{ steps.version.outputs.version }}" - tag: ${{ steps.version.outputs.version }} - token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml deleted file mode 100644 index e906018..0000000 --- a/.github/workflows/manual-test.yml +++ /dev/null @@ -1,39 +0,0 @@ -### This workflow will run only when triggered manually ### -### Full integration test is done by doing a plan and build of config under ./tests/auto_test1 ### -### No release nor documentation updates are part of this test ### - -################################################################## -### RESOURCES HAVE TO BE DELETED MANUALLY AFTER TESTS ARE DONE ### -################################################################## - -name: "Manual-Tests" -on: - workflow_dispatch: - -jobs: - manual_plan_apply_destroy: - runs-on: ubuntu-latest - permissions: - pull-requests: write - issues: write - actions: read - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - - - name: Run Dependency Tests - Plan AND Apply AND Destroy - uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 - with: - test_type: plan-apply ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" - path: "tests/auto_test1" ## (Optional) Specify path to test module to run. - tf_version: latest ## (Optional) Specifies version of Terraform to use. e.g: 1.1.0 Default="latest" - tf_vars_file: testing.auto.tfvars ## (Required) Specifies Terraform TFVARS file name inside module path (Testing vars) - tf_key: tf-mod-tests-openai-gptui ## (Required) AZ backend - Specifies name that will be given to terraform state file and plan artifact (testing state) - az_resource_group: Terraform-GitHub-Backend ## (Required) AZ backend - AZURE Resource Group hosting terraform backend storage account - az_storage_acc: tfgithubbackendsa ## (Required) AZ backend - AZURE terraform backend storage account - az_container_name: gh-openai-gpt ## (Required) AZ backend - AZURE storage container hosting state files - arm_client_id: ${{ secrets.ARM_CLIENT_ID }} ## (Required - Actions Secrets) ARM Client ID - arm_client_secret: ${{ secrets.ARM_CLIENT_SECRET }} ## (Required - Actions Secrets) ARM Client Secret - arm_subscription_id: ${{ secrets.ARM_SUBSCRIPTION_ID }} ## (Required - Actions Secrets) ARM Subscription ID - arm_tenant_id: ${{ secrets.ARM_TENANT_ID }} ## (Required - Actions Secrets) ARM Tenant ID - github_token: ${{ secrets.GITHUB_TOKEN }} ## (Required) Needed to comment output on PR's. ${{ secrets.GITHUB_TOKEN }} already has permissions. From 32451eb078c92729b5bf0a6bb0c8779431f39aba Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 17:28:00 +0000 Subject: [PATCH 004/163] rem --- .github/workflows/manual-test.yml | 39 ------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/manual-test.yml diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml deleted file mode 100644 index e906018..0000000 --- a/.github/workflows/manual-test.yml +++ /dev/null @@ -1,39 +0,0 @@ -### This workflow will run only when triggered manually ### -### Full integration test is done by doing a plan and build of config under ./tests/auto_test1 ### -### No release nor documentation updates are part of this test ### - -################################################################## -### RESOURCES HAVE TO BE DELETED MANUALLY AFTER TESTS ARE DONE ### -################################################################## - -name: "Manual-Tests" -on: - workflow_dispatch: - -jobs: - manual_plan_apply_destroy: - runs-on: ubuntu-latest - permissions: - pull-requests: write - issues: write - actions: read - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - - - name: Run Dependency Tests - Plan AND Apply AND Destroy - uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 - with: - test_type: plan-apply ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" - path: "tests/auto_test1" ## (Optional) Specify path to test module to run. - tf_version: latest ## (Optional) Specifies version of Terraform to use. e.g: 1.1.0 Default="latest" - tf_vars_file: testing.auto.tfvars ## (Required) Specifies Terraform TFVARS file name inside module path (Testing vars) - tf_key: tf-mod-tests-openai-gptui ## (Required) AZ backend - Specifies name that will be given to terraform state file and plan artifact (testing state) - az_resource_group: Terraform-GitHub-Backend ## (Required) AZ backend - AZURE Resource Group hosting terraform backend storage account - az_storage_acc: tfgithubbackendsa ## (Required) AZ backend - AZURE terraform backend storage account - az_container_name: gh-openai-gpt ## (Required) AZ backend - AZURE storage container hosting state files - arm_client_id: ${{ secrets.ARM_CLIENT_ID }} ## (Required - Actions Secrets) ARM Client ID - arm_client_secret: ${{ secrets.ARM_CLIENT_SECRET }} ## (Required - Actions Secrets) ARM Client Secret - arm_subscription_id: ${{ secrets.ARM_SUBSCRIPTION_ID }} ## (Required - Actions Secrets) ARM Subscription ID - arm_tenant_id: ${{ secrets.ARM_TENANT_ID }} ## (Required - Actions Secrets) ARM Tenant ID - github_token: ${{ secrets.GITHUB_TOKEN }} ## (Required) Needed to comment output on PR's. ${{ secrets.GITHUB_TOKEN }} already has permissions. From 09e80c752429612cfa9e73f248ba7e1edd6ddb72 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 17:29:35 +0000 Subject: [PATCH 005/163] up --- .github/workflows/dependency-tests.yml | 116 ++++++++++++++++++++++ .github/workflows/manual-test-release.yml | 81 +++++++++++++++ .github/workflows/manual-test.yml | 39 ++++++++ 3 files changed, 236 insertions(+) create mode 100644 .github/workflows/dependency-tests.yml create mode 100644 .github/workflows/manual-test-release.yml create mode 100644 .github/workflows/manual-test.yml diff --git a/.github/workflows/dependency-tests.yml b/.github/workflows/dependency-tests.yml new file mode 100644 index 0000000..6e0187b --- /dev/null +++ b/.github/workflows/dependency-tests.yml @@ -0,0 +1,116 @@ +### This workflow will run only when Dependabot opens a PR on master ### +### Full integration test is done by doing a plan, build and destroy of config under ./tests/auto_test1 ### +### If tests are successful the PR is automatically merged to master ### +### If the merge was completed the next patch version is released and the patch is bumped and pushed to terraform registry ### + +name: "Automated-Dependency-Tests-and-Release" +on: + workflow_dispatch: + pull_request: + branches: + - master + +jobs: +# Dependabot will open a PR on terraform version changes, this 'dependabot' job is only used to test TF version changes by running a plan, apply and destroy in sequence. + dependabot_plan_apply_destroy: + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + actions: read + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + + - name: Run Dependency Tests - Plan AND Apply AND Destroy + uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 + with: + test_type: plan-apply-destroy ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" + path: "tests/auto_test1" ## (Optional) Specify path to test module to run. + tf_version: latest ## (Optional) Specifies version of Terraform to use. e.g: 1.1.0 Default="latest" + tf_vars_file: testing.auto.tfvars ## (Required) Specifies Terraform TFVARS file name inside module path (Testing vars) + tf_key: tf-mod-tests-openai-gptui ## (Required) AZ backend - Specifies name that will be given to terraform state file and plan artifact (testing state) + az_resource_group: Terraform-GitHub-Backend ## (Required) AZ backend - AZURE Resource Group hosting terraform backend storage account + az_storage_acc: tfgithubbackendsa ## (Required) AZ backend - AZURE terraform backend storage account + az_container_name: gh-openai-gpt ## (Required) AZ backend - AZURE storage container hosting state files + arm_client_id: ${{ secrets.ARM_CLIENT_ID }} ## (Required - Dependabot Secrets) ARM Client ID + arm_client_secret: ${{ secrets.ARM_CLIENT_SECRET }} ## (Required - Dependabot Secrets) ARM Client Secret + arm_subscription_id: ${{ secrets.ARM_SUBSCRIPTION_ID }} ## (Required - Dependabot Secrets) ARM Subscription ID + arm_tenant_id: ${{ secrets.ARM_TENANT_ID }} ## (Required - Dependabot Secrets) ARM Tenant ID + github_token: ${{ secrets.GITHUB_TOKEN }} ## (Required) Needed to comment output on PR's. ${{ secrets.GITHUB_TOKEN }} already has permissions. + +##### If dependency tests are successful update all readme documentation using terraform-docs ##### + update_docs: + needs: dependabot_plan_apply_destroy + runs-on: ubuntu-latest + permissions: + pull-requests: write + repository-projects: write + contents: write + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - name: Render terraform docs inside the README.md and push changes back to PR branch + uses: terraform-docs/gh-actions@v1.0.0 + with: + find-dir: . + output-file: README.md + output-method: inject + git-push: "true" + +##### If dependency tests are successful merge the pull request ##### + merge_pr: + needs: update_docs + runs-on: ubuntu-latest + permissions: + pull-requests: write + repository-projects: write + contents: write + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + with: + token: ${{secrets.GITHUB_TOKEN}} + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.6.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge PR after tests + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + +##### Create and automate new release based on next patch version of releases ##### + release_new_version: + needs: merge_pr + runs-on: ubuntu-latest + permissions: + contents: write + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Determine version + id: version + uses: zwaldowski/semver-release-action@v3 + with: + bump: patch + dry_run: true + github_token: ${{secrets.GITHUB_TOKEN}} + + - name: Create new release and push to registry + id: release + uses: ncipollo/release-action@v1.13.0 + with: + generateReleaseNotes: true + name: "v${{ steps.version.outputs.version }}" + tag: ${{ steps.version.outputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/manual-test-release.yml b/.github/workflows/manual-test-release.yml new file mode 100644 index 0000000..248af47 --- /dev/null +++ b/.github/workflows/manual-test-release.yml @@ -0,0 +1,81 @@ +### This workflow will run only when triggered manually ### +### Full integration test is done by doing a plan, build and destroy of config under ./tests/auto_test1 ### +### If tests are successful a new version is released ### + +name: "Manual-Tests-and-Release" +on: + workflow_dispatch: + +jobs: + manual_plan_apply_destroy: + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + + - name: Run Dependency Tests - Plan AND Apply AND Destroy + uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 + with: + test_type: plan-apply-destroy ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" + path: "tests/auto_test1" ## (Optional) Specify path to test module to run. + tf_version: latest ## (Optional) Specifies version of Terraform to use. e.g: 1.1.0 Default="latest" + tf_vars_file: testing.auto.tfvars ## (Required) Specifies Terraform TFVARS file name inside module path (Testing vars) + tf_key: tf-mod-tests-openai-gptui ## (Required) AZ backend - Specifies name that will be given to terraform state file and plan artifact (testing state) + az_resource_group: Terraform-GitHub-Backend ## (Required) AZ backend - AZURE Resource Group hosting terraform backend storage account + az_storage_acc: tfgithubbackendsa ## (Required) AZ backend - AZURE terraform backend storage account + az_container_name: gh-openai-gpt ## (Required) AZ backend - AZURE storage container hosting state files + arm_client_id: ${{ secrets.ARM_CLIENT_ID }} ## (Required - Actions Secrets) ARM Client ID + arm_client_secret: ${{ secrets.ARM_CLIENT_SECRET }} ## (Required - Actions Secrets) ARM Client Secret + arm_subscription_id: ${{ secrets.ARM_SUBSCRIPTION_ID }} ## (Required - Actions Secrets) ARM Subscription ID + arm_tenant_id: ${{ secrets.ARM_TENANT_ID }} ## (Required - Actions Secrets) ARM Tenant ID + github_token: ${{ secrets.GITHUB_TOKEN }} ## (Required) Needed to comment output on PR's. ${{ secrets.GITHUB_TOKEN }} already has permissions. + +##### If tests are successful update all readme documentation using terraform-docs ##### + update_docs: + needs: manual_plan_apply_destroy + runs-on: ubuntu-latest + permissions: + pull-requests: write + repository-projects: write + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - name: Render terraform docs inside the README.md and push changes back to PR branch + uses: terraform-docs/gh-actions@v1.0.0 + with: + find-dir: . + output-file: README.md + output-method: inject + git-push: "true" + +##### Create and automate new release based on next patch version of releases ##### + release_new_version: + needs: update_docs + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Determine version + id: version + uses: zwaldowski/semver-release-action@v3 + with: + bump: patch + dry_run: true + github_token: ${{secrets.GITHUB_TOKEN}} + + - name: Create new release and push to registry + id: release + uses: ncipollo/release-action@v1.13.0 + with: + generateReleaseNotes: true + name: "v${{ steps.version.outputs.version }}" + tag: ${{ steps.version.outputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml new file mode 100644 index 0000000..e906018 --- /dev/null +++ b/.github/workflows/manual-test.yml @@ -0,0 +1,39 @@ +### This workflow will run only when triggered manually ### +### Full integration test is done by doing a plan and build of config under ./tests/auto_test1 ### +### No release nor documentation updates are part of this test ### + +################################################################## +### RESOURCES HAVE TO BE DELETED MANUALLY AFTER TESTS ARE DONE ### +################################################################## + +name: "Manual-Tests" +on: + workflow_dispatch: + +jobs: + manual_plan_apply_destroy: + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + + - name: Run Dependency Tests - Plan AND Apply AND Destroy + uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 + with: + test_type: plan-apply ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" + path: "tests/auto_test1" ## (Optional) Specify path to test module to run. + tf_version: latest ## (Optional) Specifies version of Terraform to use. e.g: 1.1.0 Default="latest" + tf_vars_file: testing.auto.tfvars ## (Required) Specifies Terraform TFVARS file name inside module path (Testing vars) + tf_key: tf-mod-tests-openai-gptui ## (Required) AZ backend - Specifies name that will be given to terraform state file and plan artifact (testing state) + az_resource_group: Terraform-GitHub-Backend ## (Required) AZ backend - AZURE Resource Group hosting terraform backend storage account + az_storage_acc: tfgithubbackendsa ## (Required) AZ backend - AZURE terraform backend storage account + az_container_name: gh-openai-gpt ## (Required) AZ backend - AZURE storage container hosting state files + arm_client_id: ${{ secrets.ARM_CLIENT_ID }} ## (Required - Actions Secrets) ARM Client ID + arm_client_secret: ${{ secrets.ARM_CLIENT_SECRET }} ## (Required - Actions Secrets) ARM Client Secret + arm_subscription_id: ${{ secrets.ARM_SUBSCRIPTION_ID }} ## (Required - Actions Secrets) ARM Subscription ID + arm_tenant_id: ${{ secrets.ARM_TENANT_ID }} ## (Required - Actions Secrets) ARM Tenant ID + github_token: ${{ secrets.GITHUB_TOKEN }} ## (Required) Needed to comment output on PR's. ${{ secrets.GITHUB_TOKEN }} already has permissions. From 6b254a79e0842806bbdbdea573319ae64e0bd4c9 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 17:44:22 +0000 Subject: [PATCH 006/163] temp comment out --- data.tf | 2 +- locals.tf | 18 +++++++++--------- main.tf | 36 ++++++++++++++++++------------------ outputs.tf | 36 ++++++++++++++++++------------------ 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/data.tf b/data.tf index 9622618..469e34d 100644 --- a/data.tf +++ b/data.tf @@ -5,7 +5,7 @@ # Data sources to get Subnet ID/ss for CosmosDB and App Service # Usage in Module example: subnet_id = data.azurerm_subnet.subnet["app-cosmos-sub"].id data "azurerm_subnet" "subnet" { - for_each = { for each in subnet_config : each.subnet_name => each } + for_each = { for each in var.subnet_config : each.subnet_name => each } name = each.value.subnet_name virtual_network_name = var.virtual_network_name resource_group_name = var.network_resource_group_name diff --git a/locals.tf b/locals.tf index fd79e81..8324906 100644 --- a/locals.tf +++ b/locals.tf @@ -1,9 +1,9 @@ -locals { - cdn_gpt_origin = merge( - var.cdn_gpt_origin, - { - host_name = module.privategpt_chatbot_container_apps.latest_revision_fqdn - origin_host_header = module.privategpt_chatbot_container_apps.latest_revision_fqdn - } - ) -} \ No newline at end of file +#locals { +# cdn_gpt_origin = merge( +# var.cdn_gpt_origin, +# { +# host_name = module.privategpt_chatbot_container_apps.latest_revision_fqdn +# origin_host_header = module.privategpt_chatbot_container_apps.latest_revision_fqdn +# } +# ) +#} \ No newline at end of file diff --git a/main.tf b/main.tf index 0f91b7e..6b208ac 100644 --- a/main.tf +++ b/main.tf @@ -98,27 +98,27 @@ module "openai_networking" { # 11.) Optionally create an Azure DNS Zone or use an existing one for the custom domain. (e.g PrivateGPT.mydomain.com) # 12.) Create a CNAME and TXT record in the custom DNS zone. # 13.) Setup and apply AFD WAF policy for the front door with allowed IPs custom rule. (Optional) -module "azure_frontdoor_cdn" { - count = var.create_front_door_cdn ? 1 : 0 - source = "./modules/cdn_frontdoor" +#module "azure_frontdoor_cdn" { +# count = var.create_front_door_cdn ? 1 : 0 +# source = "./modules/cdn_frontdoor" #create_dns_zone - create_dns_zone = var.create_dns_zone - dns_resource_group_name = var.dns_resource_group_name - custom_domain_config = var.custom_domain_config +# create_dns_zone = var.create_dns_zone +# dns_resource_group_name = var.dns_resource_group_name +# custom_domain_config = var.custom_domain_config #deploy front door - cdn_resource_group_name = var.cdn_resource_group_name - cdn_profile_name = var.cdn_profile_name - cdn_sku_name = var.cdn_sku_name - cdn_endpoint = var.cdn_endpoint - cdn_origin_groups = var.cdn_origin_groups - cdn_gpt_origin = local.cdn_gpt_origin - cdn_route = var.cdn_route +# cdn_resource_group_name = var.cdn_resource_group_name +# cdn_profile_name = var.cdn_profile_name +# cdn_sku_name = var.cdn_sku_name +## cdn_endpoint = var.cdn_endpoint + # cdn_origin_groups = var.cdn_origin_groups + # cdn_gpt_origin = local.cdn_gpt_origin + # cdn_route = var.cdn_route #deploy firewall policy - cdn_firewall_policy = var.cdn_firewall_policy - cdn_security_policy = var.cdn_security_policy - tags = var.tags - depends_on = [module.privategpt_chatbot_container_apps] -} \ No newline at end of file +# cdn_firewall_policy = var.cdn_firewall_policy +# cdn_security_policy = var.cdn_security_policy +# tags = var.tags +# depends_on = [module.privategpt_chatbot_container_apps] +#} \ No newline at end of file diff --git a/outputs.tf b/outputs.tf index 2d3a757..55e84b4 100644 --- a/outputs.tf +++ b/outputs.tf @@ -41,23 +41,23 @@ output "key_vault_uri" { } ## Container App Enviornment -output "container_app_enviornment_id" { - description = "The ID of the container app enviornment." - value = module.privategpt_chatbot_container_apps.container_app_environment_id -} +#output "container_app_enviornment_id" { +# description = "The ID of the container app enviornment." +# value = module.privategpt_chatbot_container_apps.container_app_environment_id +#} ## Container App -output "container_app_id" { - description = "The ID of the container app." - value = module.privategpt_chatbot_container_apps.container_app_id -} - -output "latest_revision_fqdn" { - description = "The FQDN of the Latest Revision of the Container App." - value = module.privategpt_chatbot_container_apps.latest_revision_fqdn -} - -output "latest_revision_name" { - description = "The Name of the Latest Revision of the Container App." - value = module.privategpt_chatbot_container_apps.latest_revision_name -} +#output "container_app_id" { +# description = "The ID of the container app." +# value = module.privategpt_chatbot_container_apps.container_app_id +#} + +#output "latest_revision_fqdn" { +# description = "The FQDN of the Latest Revision of the Container App." +# value = module.privategpt_chatbot_container_apps.latest_revision_fqdn +#} + +#output "latest_revision_name" { +# description = "The Name of the Latest Revision of the Container App." +# value = module.privategpt_chatbot_container_apps.latest_revision_name +#} From f9f5592bad6e4f008cd7cc14a2613e664d58880d Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 17:46:07 +0000 Subject: [PATCH 007/163] lint --- main.tf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/main.tf b/main.tf index 6b208ac..33627e6 100644 --- a/main.tf +++ b/main.tf @@ -102,21 +102,21 @@ module "openai_networking" { # count = var.create_front_door_cdn ? 1 : 0 # source = "./modules/cdn_frontdoor" - #create_dns_zone +#create_dns_zone # create_dns_zone = var.create_dns_zone # dns_resource_group_name = var.dns_resource_group_name # custom_domain_config = var.custom_domain_config - #deploy front door +#deploy front door # cdn_resource_group_name = var.cdn_resource_group_name # cdn_profile_name = var.cdn_profile_name # cdn_sku_name = var.cdn_sku_name ## cdn_endpoint = var.cdn_endpoint - # cdn_origin_groups = var.cdn_origin_groups - # cdn_gpt_origin = local.cdn_gpt_origin - # cdn_route = var.cdn_route +# cdn_origin_groups = var.cdn_origin_groups +# cdn_gpt_origin = local.cdn_gpt_origin +# cdn_route = var.cdn_route - #deploy firewall policy +#deploy firewall policy # cdn_firewall_policy = var.cdn_firewall_policy # cdn_security_policy = var.cdn_security_policy # tags = var.tags From 5d889ef368892b8d07bd4219fac190a49238a7ef Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 17:48:16 +0000 Subject: [PATCH 008/163] fixes on data block --- data.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data.tf b/data.tf index 469e34d..56b61a7 100644 --- a/data.tf +++ b/data.tf @@ -5,7 +5,7 @@ # Data sources to get Subnet ID/ss for CosmosDB and App Service # Usage in Module example: subnet_id = data.azurerm_subnet.subnet["app-cosmos-sub"].id data "azurerm_subnet" "subnet" { - for_each = { for each in var.subnet_config : each.subnet_name => each } + for_each = { for each in var.subnet_config : each.subnet_name => each if var.create_openai_networking == false } name = each.value.subnet_name virtual_network_name = var.virtual_network_name resource_group_name = var.network_resource_group_name From 870194266b0cd185a7a799156ea8e7e81a94ec5f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 19:05:54 +0000 Subject: [PATCH 009/163] cosmos --- data.tf | 6 ++ main.tf | 23 +++++- modules/cosmosdb/README.md | 10 +++ modules/cosmosdb/main.tf | 41 ++++++++++ modules/cosmosdb/outputs.tf | 25 ++++++ modules/cosmosdb/variables.tf | 110 +++++++++++++++++++++++++++ tests/auto_test1/main.tf | 19 ++++- tests/auto_test1/testing.auto.tfvars | 22 ++++++ tests/auto_test1/variables.tf | 100 ++++++++++++++++++++++++ variables.tf | 104 ++++++++++++++++++++++++- 10 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 modules/cosmosdb/README.md create mode 100644 modules/cosmosdb/main.tf create mode 100644 modules/cosmosdb/outputs.tf create mode 100644 modules/cosmosdb/variables.tf diff --git a/data.tf b/data.tf index 56b61a7..25b8fc2 100644 --- a/data.tf +++ b/data.tf @@ -10,3 +10,9 @@ data "azurerm_subnet" "subnet" { virtual_network_name = var.virtual_network_name resource_group_name = var.network_resource_group_name } + +data "azurerm_cosmosdb_account" "mongo" { + for_each = { for each in var.cosmosdb_name : each.value => each if var.create_cosmosdb == false } + name = each.value + resource_group_name = var.cosmosdb_resource_group_name +} \ No newline at end of file diff --git a/main.tf b/main.tf index 33627e6..f32401e 100644 --- a/main.tf +++ b/main.tf @@ -51,7 +51,28 @@ module "openai_networking" { tags = var.tags } -### Create a CosmosDB account running MongoDB to store chat data ### +### Create a CosmosDB account running MongoDB to store chat data (Optional) ### +# 6.) Create a CosmosDB account running MongoDB to store chat data (Optional). +module "openai_cosmosdb" { + count = var.create_cosmosdb ? 1 : 0 + source = "./modules/cosmosdb" + cosmosdb_name = join("", var.cosmosdb_name) + cosmosdb_resource_group_name = var.cosmosdb_resource_group_name + location = var.location + cosmosdb_offer_type = var.cosmosdb_offer_type + cosmosdb_kind = var.cosmosdb_kind + cosmosdb_automatic_failover = var.cosmosdb_automatic_failover + use_cosmosdb_free_tier = var.use_cosmosdb_free_tier + cosmosdb_consistency_level = var.cosmosdb_consistency_level + cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds + cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix + geo_locations = var.geo_locations + capabilities = var.capabilities + virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking.subnet_ids[var.cosmosdb_subnet_name].id : data.azurerm_subnet.subnet[var.cosmosdb_subnet_name].id + is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled + public_network_access_enabled = var.public_network_access_enabled + tags = var.tags +} ### Vreate the Web App ### diff --git a/modules/cosmosdb/README.md b/modules/cosmosdb/README.md new file mode 100644 index 0000000..671b2c2 --- /dev/null +++ b/modules/cosmosdb/README.md @@ -0,0 +1,10 @@ +# Module: Azure CosmosDB (Optional) + +Create a new CosmosDB. (Optional) +If existing an existing CosmosDB instance is to be used, then the variables/names of the existing DB must be provided as input variables to root the module (data sources): + +- Create an Azure CosmosDB instance running MongoDB API. + + + + \ No newline at end of file diff --git a/modules/cosmosdb/main.tf b/modules/cosmosdb/main.tf new file mode 100644 index 0000000..b2c570c --- /dev/null +++ b/modules/cosmosdb/main.tf @@ -0,0 +1,41 @@ +resource "azurerm_cosmosdb_account" "mongo" { + name = var.cosmosdb_name + resource_group_name = var.cosmosdb_resource_group_name + location = var.location + offer_type = var.cosmosdb_offer_type + kind = var.cosmosdb_kind + enable_automatic_failover = var.cosmosdb_automatic_failover + enable_free_tier = var.use_cosmosdb_free_tier + tags = var.tags + + consistency_policy { + consistency_level = var.cosmosdb_consistency_level + max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds + max_staleness_prefix = var.cosmosdb_max_staleness_prefix + } + + dynamic "geo_location" { + for_each = var.geo_locations + content { + location = geo_location.value.location + failover_priority = geo_location.value.failover_priority + } + } + + dynamic "capabilities" { + for_each = var.capabilities + content { + name = capabilities.value + } + } + + dynamic "virtual_network_rule" { + for_each = var.virtual_network_subnets + content { + id = virtual_network_rule.value + } + } + + is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled + public_network_access_enabled = var.public_network_access_enabled +} \ No newline at end of file diff --git a/modules/cosmosdb/outputs.tf b/modules/cosmosdb/outputs.tf new file mode 100644 index 0000000..2b3c4be --- /dev/null +++ b/modules/cosmosdb/outputs.tf @@ -0,0 +1,25 @@ +output "cosmosdb_account_id" { + description = "The ID of the Cosmos DB account" + value = azurerm_cosmosdb_account.mongo.id +} + +output "cosmosdb_account_endpoint" { + description = "The endpoint of the Cosmos DB account" + value = azurerm_cosmosdb_account.mongo.endpoint +} + +output "cosmosdb_account_primary_master_key" { + description = "The primary master key of the Cosmos DB account" + value = azurerm_cosmosdb_account.mongo.primary_key + sensitive = true +} + +output "cosmosdb_account_read_endpoints" { + description = "The read endpoints of the Cosmos DB account" + value = azurerm_cosmosdb_account.mongo.read_endpoints +} + +output "cosmosdb_account_write_endpoints" { + description = "The write endpoints of the Cosmos DB account" + value = azurerm_cosmosdb_account.mongo.write_endpoints +} \ No newline at end of file diff --git a/modules/cosmosdb/variables.tf b/modules/cosmosdb/variables.tf new file mode 100644 index 0000000..bcb9dc7 --- /dev/null +++ b/modules/cosmosdb/variables.tf @@ -0,0 +1,110 @@ +variable "cosmosdb_name" { + description = "The name of the Cosmos DB account" + type = string + default = "openaicosmosdb" +} + +variable "cosmosdb_resource_group_name" { + description = "The name of the resource group in which to create the Cosmos DB account" + type = string + nullable = false +} + +variable "location" { + description = "The location/region in which to create the Cosmos DB account" + type = string + default = "uksouth" +} + +variable "cosmosdb_offer_type" { + description = "The offer type to use for the Cosmos DB account" + type = string + default = "Standard" +} + +variable "cosmosdb_kind" { + description = "The kind of Cosmos DB to create" + type = string + default = "MongoDB" +} + +variable "cosmosdb_automatic_failover" { + description = "Whether to enable automatic failover for the Cosmos DB account" + type = bool + default = false +} + +variable "use_cosmosdb_free_tier" { + description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." + type = bool + default = true +} + +variable "cosmosdb_consistency_level" { + description = "The consistency level of the Cosmos DB account" + type = string + default = "BoundedStaleness" +} + +variable "cosmosdb_max_interval_in_seconds" { + description = "The maximum staleness interval in seconds for the Cosmos DB account" + type = number + default = 10 +} + +variable "cosmosdb_max_staleness_prefix" { + description = "The maximum staleness prefix for the Cosmos DB account" + type = number + default = 200 +} + +variable "geo_locations" { + description = "The geo-locations for the Cosmos DB account" + type = list(object({ + location = string + failover_priority = number + })) + default = [ + { + location = "uksouth" + failover_priority = 0 + } + ] +} + +variable "capabilities" { + description = "The capabilities for the Cosmos DB account" + type = list(string) + default = [ + "MongoDB" + ] +} + +variable "virtual_network_subnets" { + description = "The virtual network subnet ID for the Cosmos DB account" + type = list(string) + default = [] +} + +variable "is_virtual_network_filter_enabled" { + description = "Whether to enable virtual network filtering for the Cosmos DB account" + type = bool + default = true +} + +variable "public_network_access_enabled" { + description = "Whether to enable public network access for the Cosmos DB account" + type = bool + default = true +} + +variable "tags" { + type = map(string) + default = { + Terraform = "True" + Description = "OpenAI CosmosDB Resource." + Author = "Marcel Lupo" + GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" + } + description = "A map of key value pairs that is used to tag resources created." +} \ No newline at end of file diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index c622728..59624e0 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -63,10 +63,27 @@ module "private-chatgpt-openai" { #Create networking for CosmosDB and Web App (Optional) create_openai_networking = var.create_openai_networking network_resource_group_name = azurerm_resource_group.rg.name - virtual_network_name = var.virtual_network_name + virtual_network_name = "${var.virtual_network_name}${random_integer.number.result}" vnet_address_space = var.vnet_address_space subnet_config = var.subnet_config + #Create a CosmosDB account running MongoDB to store chat data (Optional) + create_cosmosdb = var.create_cosmosdb + cosmosdb_name = var.cosmosdb_name + cosmosdb_resource_group_name = var.cosmosdb_resource_group_name + cosmosdb_offer_type = var.cosmosdb_offer_type + cosmosdb_kind = var.cosmosdb_kind + cosmosdb_automatic_failover = var.cosmosdb_automatic_failover + use_cosmosdb_free_tier = var.use_cosmosdb_free_tier + cosmosdb_consistency_level = var.cosmosdb_consistency_level + cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds + cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix + geo_locations = var.geo_locations + capabilities = var.capabilities + is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled + public_network_access_enabled = var.public_network_access_enabled + cosmosdb_subnet_name = var.cosmosdb_subnet_name + #Create a solution log analytics workspace to store logs from our container apps instance #laws_name = "${var.laws_name}${random_integer.number.result}" #laws_sku = var.laws_sku diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 855cf32..0a4b321 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -62,6 +62,28 @@ subnet_config = [ } ] +### cosmosdb ### +create_cosmosdb = true +cosmosdb_name = ["gptcosmos251"] +cosmosdb_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" +cosmosdb_offer_type = "Standard" +cosmosdb_kind = "MongoDB" +cosmosdb_automatic_failover = false +use_cosmosdb_free_tier = true +cosmosdb_consistency_level = "BoundedStaleness" +cosmosdb_max_interval_in_seconds = 10 +cosmosdb_max_staleness_prefix = 200 +geo_locations = [ + { + location = "uksouth" + failover_priority = 0 + } +] +capabilities = ["MongoDB"] +is_virtual_network_filter_enabled = true +public_network_access_enabled = true +cosmosdb_subnet_name = "app-cosmos-sub" + ### log analytics workspace for container apps ### #laws_name = "gptlaws" #laws_sku = "PerGB2018" diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index ea62f02..c136f69 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -214,6 +214,106 @@ variable "subnet_config" { description = "A list of subnet configuration objects to create subnets in the virtual network." } +### cosmosdb ### +variable "create_cosmosdb" { + description = "Create a CosmosDB account running MongoDB to store chat data." + type = bool + default = false +} + +variable "cosmosdb_name" { + description = "The name of the Cosmos DB account" + type = list(string) + default = ["openaicosmosdb"] +} + +variable "cosmosdb_resource_group_name" { + description = "The name of the resource group in which to create the Cosmos DB account" + type = string + nullable = false +} + +variable "cosmosdb_offer_type" { + description = "The offer type to use for the Cosmos DB account" + type = string + default = "Standard" +} + +variable "cosmosdb_kind" { + description = "The kind of Cosmos DB to create" + type = string + default = "MongoDB" +} + +variable "cosmosdb_automatic_failover" { + description = "Whether to enable automatic failover for the Cosmos DB account" + type = bool + default = false +} + +variable "use_cosmosdb_free_tier" { + description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." + type = bool + default = true +} + +variable "cosmosdb_consistency_level" { + description = "The consistency level of the Cosmos DB account" + type = string + default = "BoundedStaleness" +} + +variable "cosmosdb_max_interval_in_seconds" { + description = "The maximum staleness interval in seconds for the Cosmos DB account" + type = number + default = 10 +} + +variable "cosmosdb_max_staleness_prefix" { + description = "The maximum staleness prefix for the Cosmos DB account" + type = number + default = 200 +} + +variable "geo_locations" { + description = "The geo-locations for the Cosmos DB account" + type = list(object({ + location = string + failover_priority = number + })) + default = [ + { + location = "uksouth" + failover_priority = 0 + } + ] +} + +variable "capabilities" { + description = "The capabilities for the Cosmos DB account" + type = list(string) + default = [ + "MongoDB" + ] +} + +variable "is_virtual_network_filter_enabled" { + description = "Whether to enable virtual network filtering for the Cosmos DB account" + type = bool + default = true +} + +variable "public_network_access_enabled" { + description = "Whether to enable public network access for the Cosmos DB account" + type = bool + default = true +} +variable "cosmosdb_subnet_name" { + description = "The name of the subnet to create the Cosmos DB account in" + type = string + default = "app-cosmos-sub" +} + ### log analytics workspace ### #variable "laws_name" { # type = string diff --git a/variables.tf b/variables.tf index 1c204ae..6f02048 100644 --- a/variables.tf +++ b/variables.tf @@ -167,10 +167,7 @@ variable "model_deployment" { nullable = false } -################## ### Networking ### -################## - variable "create_openai_networking" { description = "Create a virtual network and subnet/s for networked services" type = bool @@ -227,6 +224,107 @@ variable "subnet_config" { description = "A list of subnet configuration objects to create subnets in the virtual network." } +### CosmosDB ### +variable "create_cosmosdb" { + description = "Create a CosmosDB account running MongoDB to store chat data." + type = bool + default = false +} + +variable "cosmosdb_name" { + description = "The name of the Cosmos DB account" + type = list(string) + default = ["openaicosmosdb"] +} + +variable "cosmosdb_resource_group_name" { + description = "The name of the resource group in which to create the Cosmos DB account" + type = string + nullable = false +} + +variable "cosmosdb_offer_type" { + description = "The offer type to use for the Cosmos DB account" + type = string + default = "Standard" +} + +variable "cosmosdb_kind" { + description = "The kind of Cosmos DB to create" + type = string + default = "MongoDB" +} + +variable "cosmosdb_automatic_failover" { + description = "Whether to enable automatic failover for the Cosmos DB account" + type = bool + default = false +} + +variable "use_cosmosdb_free_tier" { + description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." + type = bool + default = true +} + +variable "cosmosdb_consistency_level" { + description = "The consistency level of the Cosmos DB account" + type = string + default = "BoundedStaleness" +} + +variable "cosmosdb_max_interval_in_seconds" { + description = "The maximum staleness interval in seconds for the Cosmos DB account" + type = number + default = 10 +} + +variable "cosmosdb_max_staleness_prefix" { + description = "The maximum staleness prefix for the Cosmos DB account" + type = number + default = 200 +} + +variable "geo_locations" { + description = "The geo-locations for the Cosmos DB account" + type = list(object({ + location = string + failover_priority = number + })) + default = [ + { + location = "uksouth" + failover_priority = 0 + } + ] +} + +variable "capabilities" { + description = "The capabilities for the Cosmos DB account" + type = list(string) + default = [ + "MongoDB" + ] +} + +variable "is_virtual_network_filter_enabled" { + description = "Whether to enable virtual network filtering for the Cosmos DB account" + type = bool + default = true +} + +variable "public_network_access_enabled" { + description = "Whether to enable public network access for the Cosmos DB account" + type = bool + default = true +} + +variable "cosmosdb_subnet_name" { + description = "The name of the subnet to create the Cosmos DB account in" + type = string + default = "app-cosmos-sub" +} + ################################### ### Container App Module params ### ################################### From 580218f246e324b8f07a4e67a6ae29b5067200e0 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 19:09:48 +0000 Subject: [PATCH 010/163] test --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index f32401e..5ff7648 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.geo_locations capabilities = var.capabilities - virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking.subnet_ids[var.cosmosdb_subnet_name].id : data.azurerm_subnet.subnet[var.cosmosdb_subnet_name].id + virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking[0].subnet_ids[var.cosmosdb_subnet_name].id : data.azurerm_subnet.subnet[var.cosmosdb_subnet_name].id is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled public_network_access_enabled = var.public_network_access_enabled tags = var.tags From eb6372e81ef1d4d4416682efa0c6e8a4d359cefe Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 19:13:39 +0000 Subject: [PATCH 011/163] up --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index 5ff7648..dd809d3 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.geo_locations capabilities = var.capabilities - virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking[0].subnet_ids[var.cosmosdb_subnet_name].id : data.azurerm_subnet.subnet[var.cosmosdb_subnet_name].id + virtual_network_subnets = var.create_openai_networking == true ? [module.openai_networking[0].subnet_ids[var.cosmosdb_subnet_name].id] : [data.azurerm_subnet.subnet[var.cosmosdb_subnet_name].id] is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled public_network_access_enabled = var.public_network_access_enabled tags = var.tags From a848a6c65c3a08a1723ff8e15baedeea474daa7e Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 19:16:22 +0000 Subject: [PATCH 012/163] test --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index dd809d3..4a58ae4 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.geo_locations capabilities = var.capabilities - virtual_network_subnets = var.create_openai_networking == true ? [module.openai_networking[0].subnet_ids[var.cosmosdb_subnet_name].id] : [data.azurerm_subnet.subnet[var.cosmosdb_subnet_name].id] + virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking[0].subnet_ids : [data.azurerm_subnet.subnet[var.cosmosdb_subnet_name].id] is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled public_network_access_enabled = var.public_network_access_enabled tags = var.tags From 398adf0b388111ef850412a63186b510ea3c9cb3 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 19:22:36 +0000 Subject: [PATCH 013/163] tst --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index 4a58ae4..2c213c1 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.geo_locations capabilities = var.capabilities - virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking[0].subnet_ids : [data.azurerm_subnet.subnet[var.cosmosdb_subnet_name].id] + virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking[0].subnet_ids : data.azurerm_subnet.subnet.*.id is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled public_network_access_enabled = var.public_network_access_enabled tags = var.tags From e4562f8204c9c3abb557661334ccf5dccf64f078 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 19:24:08 +0000 Subject: [PATCH 014/163] tst --- .github/workflows/manual-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml index e906018..fac5b1d 100644 --- a/.github/workflows/manual-test.yml +++ b/.github/workflows/manual-test.yml @@ -11,7 +11,7 @@ on: workflow_dispatch: jobs: - manual_plan_apply_destroy: + manual_plan_apply: runs-on: ubuntu-latest permissions: pull-requests: write From 4cb1f0cade421dc8f8c823f5e4b91907835d26de Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:22:13 +0000 Subject: [PATCH 015/163] test --- data.tf | 22 ++++++++++----------- main.tf | 13 +++++++------ modules/cosmosdb/variables.tf | 2 +- tests/auto_test1/main.tf | 29 ++++++++++++++-------------- tests/auto_test1/testing.auto.tfvars | 11 +++++------ tests/auto_test1/variables.tf | 27 +++++++++++++------------- variables.tf | 28 +++++++++++++-------------- 7 files changed, 64 insertions(+), 68 deletions(-) diff --git a/data.tf b/data.tf index 25b8fc2..39da1e9 100644 --- a/data.tf +++ b/data.tf @@ -4,15 +4,15 @@ # Data sources to get Subnet ID/ss for CosmosDB and App Service # Usage in Module example: subnet_id = data.azurerm_subnet.subnet["app-cosmos-sub"].id -data "azurerm_subnet" "subnet" { - for_each = { for each in var.subnet_config : each.subnet_name => each if var.create_openai_networking == false } - name = each.value.subnet_name - virtual_network_name = var.virtual_network_name - resource_group_name = var.network_resource_group_name -} +# data "azurerm_subnet" "subnet" { +# for_each = { for each in var.subnet_config : each.subnet_name => each if var.create_openai_networking == false } +# name = each.value.subnet_name +# virtual_network_name = var.virtual_network_name +# resource_group_name = var.network_resource_group_name +# } -data "azurerm_cosmosdb_account" "mongo" { - for_each = { for each in var.cosmosdb_name : each.value => each if var.create_cosmosdb == false } - name = each.value - resource_group_name = var.cosmosdb_resource_group_name -} \ No newline at end of file +# data "azurerm_cosmosdb_account" "mongo" { +# for_each = { for each in var.cosmosdb_name : each.value => each if var.create_cosmosdb == false } +# name = each.value +# resource_group_name = var.cosmosdb_resource_group_name +# } \ No newline at end of file diff --git a/main.tf b/main.tf index 2c213c1..c6c5c7a 100644 --- a/main.tf +++ b/main.tf @@ -56,7 +56,7 @@ module "openai_networking" { module "openai_cosmosdb" { count = var.create_cosmosdb ? 1 : 0 source = "./modules/cosmosdb" - cosmosdb_name = join("", var.cosmosdb_name) + cosmosdb_name = var.cosmosdb_name cosmosdb_resource_group_name = var.cosmosdb_resource_group_name location = var.location cosmosdb_offer_type = var.cosmosdb_offer_type @@ -66,12 +66,13 @@ module "openai_cosmosdb" { cosmosdb_consistency_level = var.cosmosdb_consistency_level cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix - geo_locations = var.geo_locations - capabilities = var.capabilities - virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking[0].subnet_ids : data.azurerm_subnet.subnet.*.id - is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled - public_network_access_enabled = var.public_network_access_enabled + geo_locations = var.cosmosdb_geo_locations + capabilities = var.cosmosdb_capabilities + virtual_network_subnets = var.create_openai_networking == true ? [module.openai_networking.subnet_ids[0]] : var.cosmosdb_virtual_network_subnets + is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled + public_network_access_enabled = var.cosmosdb_public_network_access_enabled tags = var.tags + depends_on = [module.openai_networking] } ### Vreate the Web App ### diff --git a/modules/cosmosdb/variables.tf b/modules/cosmosdb/variables.tf index bcb9dc7..0fcc911 100644 --- a/modules/cosmosdb/variables.tf +++ b/modules/cosmosdb/variables.tf @@ -81,7 +81,7 @@ variable "capabilities" { } variable "virtual_network_subnets" { - description = "The virtual network subnet ID for the Cosmos DB account" + description = "The virtual network subnet ID for the Cosmos DB account (Service Endpoint)" type = list(string) default = [] } diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 59624e0..b1a011b 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -68,21 +68,20 @@ module "private-chatgpt-openai" { subnet_config = var.subnet_config #Create a CosmosDB account running MongoDB to store chat data (Optional) - create_cosmosdb = var.create_cosmosdb - cosmosdb_name = var.cosmosdb_name - cosmosdb_resource_group_name = var.cosmosdb_resource_group_name - cosmosdb_offer_type = var.cosmosdb_offer_type - cosmosdb_kind = var.cosmosdb_kind - cosmosdb_automatic_failover = var.cosmosdb_automatic_failover - use_cosmosdb_free_tier = var.use_cosmosdb_free_tier - cosmosdb_consistency_level = var.cosmosdb_consistency_level - cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds - cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix - geo_locations = var.geo_locations - capabilities = var.capabilities - is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled - public_network_access_enabled = var.public_network_access_enabled - cosmosdb_subnet_name = var.cosmosdb_subnet_name + create_cosmosdb = var.create_cosmosdb + cosmosdb_name = "${var.cosmosdb_name}${random_integer.number.result}" + cosmosdb_resource_group_name = var.cosmosdb_resource_group_name + cosmosdb_offer_type = var.cosmosdb_offer_type + cosmosdb_kind = var.cosmosdb_kind + cosmosdb_automatic_failover = var.cosmosdb_automatic_failover + use_cosmosdb_free_tier = var.use_cosmosdb_free_tier + cosmosdb_consistency_level = var.cosmosdb_consistency_level + cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds + cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix + cosmosdb_geo_locations = var.cosmosdb_geo_locations + cosmosdb_capabilities = var.cosmosdb_capabilities + cosmosdb_is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled + cosmosdb_public_network_access_enabled = var.cosmosdb_public_network_access_enabled #Create a solution log analytics workspace to store logs from our container apps instance #laws_name = "${var.laws_name}${random_integer.number.result}" diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 0a4b321..890184d 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -64,7 +64,7 @@ subnet_config = [ ### cosmosdb ### create_cosmosdb = true -cosmosdb_name = ["gptcosmos251"] +cosmosdb_name = "gptcosmos" cosmosdb_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" cosmosdb_offer_type = "Standard" cosmosdb_kind = "MongoDB" @@ -73,16 +73,15 @@ use_cosmosdb_free_tier = true cosmosdb_consistency_level = "BoundedStaleness" cosmosdb_max_interval_in_seconds = 10 cosmosdb_max_staleness_prefix = 200 -geo_locations = [ +cosmosdb_geo_locations = [ { location = "uksouth" failover_priority = 0 } ] -capabilities = ["MongoDB"] -is_virtual_network_filter_enabled = true -public_network_access_enabled = true -cosmosdb_subnet_name = "app-cosmos-sub" +cosmosdb_capabilities = ["MongoDB"] +cosmosdb_is_virtual_network_filter_enabled = true +cosmosdb_public_network_access_enabled = true ### log analytics workspace for container apps ### #laws_name = "gptlaws" diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index c136f69..6466cbd 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -223,8 +223,8 @@ variable "create_cosmosdb" { variable "cosmosdb_name" { description = "The name of the Cosmos DB account" - type = list(string) - default = ["openaicosmosdb"] + type = string + default = "openaicosmosdb" } variable "cosmosdb_resource_group_name" { @@ -275,7 +275,7 @@ variable "cosmosdb_max_staleness_prefix" { default = 200 } -variable "geo_locations" { +variable "cosmosdb_geo_locations" { description = "The geo-locations for the Cosmos DB account" type = list(object({ location = string @@ -289,30 +289,29 @@ variable "geo_locations" { ] } -variable "capabilities" { +variable "cosmosdb_capabilities" { description = "The capabilities for the Cosmos DB account" type = list(string) - default = [ - "MongoDB" - ] + default = ["MongoDB"] +} + +variable "cosmosdb_virtual_network_subnets" { + description = "The virtual network subnets to associate with the Cosmos DB account" + type = list(string) + default = null } -variable "is_virtual_network_filter_enabled" { +variable "cosmosdb_is_virtual_network_filter_enabled" { description = "Whether to enable virtual network filtering for the Cosmos DB account" type = bool default = true } -variable "public_network_access_enabled" { +variable "cosmosdb_public_network_access_enabled" { description = "Whether to enable public network access for the Cosmos DB account" type = bool default = true } -variable "cosmosdb_subnet_name" { - description = "The name of the subnet to create the Cosmos DB account in" - type = string - default = "app-cosmos-sub" -} ### log analytics workspace ### #variable "laws_name" { diff --git a/variables.tf b/variables.tf index 6f02048..66a2ac1 100644 --- a/variables.tf +++ b/variables.tf @@ -233,8 +233,8 @@ variable "create_cosmosdb" { variable "cosmosdb_name" { description = "The name of the Cosmos DB account" - type = list(string) - default = ["openaicosmosdb"] + type = string + default = "openaicosmosdb" } variable "cosmosdb_resource_group_name" { @@ -285,7 +285,7 @@ variable "cosmosdb_max_staleness_prefix" { default = 200 } -variable "geo_locations" { +variable "cosmosdb_geo_locations" { description = "The geo-locations for the Cosmos DB account" type = list(object({ location = string @@ -299,32 +299,30 @@ variable "geo_locations" { ] } -variable "capabilities" { +variable "cosmosdb_capabilities" { description = "The capabilities for the Cosmos DB account" type = list(string) - default = [ - "MongoDB" - ] + default = ["MongoDB"] } -variable "is_virtual_network_filter_enabled" { +variable "cosmosdb_virtual_network_subnets" { + description = "The virtual network subnets to associate with the Cosmos DB account" + type = list(string) + default = null +} + +variable "cosmosdb_is_virtual_network_filter_enabled" { description = "Whether to enable virtual network filtering for the Cosmos DB account" type = bool default = true } -variable "public_network_access_enabled" { +variable "cosmosdb_public_network_access_enabled" { description = "Whether to enable public network access for the Cosmos DB account" type = bool default = true } -variable "cosmosdb_subnet_name" { - description = "The name of the subnet to create the Cosmos DB account in" - type = string - default = "app-cosmos-sub" -} - ################################### ### Container App Module params ### ################################### From c31d65b0147330adb1c41a3a6823d519d1cc1126 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:25:00 +0000 Subject: [PATCH 016/163] test --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index c6c5c7a..f199474 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.cosmosdb_geo_locations capabilities = var.cosmosdb_capabilities - virtual_network_subnets = var.create_openai_networking == true ? [module.openai_networking.subnet_ids[0]] : var.cosmosdb_virtual_network_subnets + virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking.subnet_ids[0] : var.cosmosdb_virtual_network_subnets is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled tags = var.tags From bd97aa419102da3214504d7ede1fb390979ef625 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:26:18 +0000 Subject: [PATCH 017/163] d --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index f199474..66b51d8 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.cosmosdb_geo_locations capabilities = var.cosmosdb_capabilities - virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking.subnet_ids[0] : var.cosmosdb_virtual_network_subnets + virtual_network_subnets = var.create_openai_networking == true ? toset(module.openai_networking.subnet_ids[0]) : var.cosmosdb_virtual_network_subnets is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled tags = var.tags From ec52d9bad060853d94edd8784e9efca9729b96a9 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:29:10 +0000 Subject: [PATCH 018/163] test --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index 66b51d8..46dd9fd 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.cosmosdb_geo_locations capabilities = var.cosmosdb_capabilities - virtual_network_subnets = var.create_openai_networking == true ? toset(module.openai_networking.subnet_ids[0]) : var.cosmosdb_virtual_network_subnets + virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking[0].subnet_ids : var.cosmosdb_virtual_network_subnets is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled tags = var.tags From 65b10699cdc84385e0d6592d3777b9b3132dfe77 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:31:04 +0000 Subject: [PATCH 019/163] test --- .github/workflows/manual-test.yml | 2 +- main.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml index fac5b1d..cc436a7 100644 --- a/.github/workflows/manual-test.yml +++ b/.github/workflows/manual-test.yml @@ -21,7 +21,7 @@ jobs: - name: Checkout uses: actions/checkout@v4.1.1 - - name: Run Dependency Tests - Plan AND Apply AND Destroy + - name: Run Dependency Tests - Plan AND Apply Only uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 with: test_type: plan-apply ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" diff --git a/main.tf b/main.tf index 46dd9fd..a4d1f92 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.cosmosdb_geo_locations capabilities = var.cosmosdb_capabilities - virtual_network_subnets = var.create_openai_networking == true ? module.openai_networking[0].subnet_ids : var.cosmosdb_virtual_network_subnets + virtual_network_subnets = var.create_openai_networking == true ? toset(module.openai_networking[0].subnet_ids) : var.cosmosdb_virtual_network_subnets is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled tags = var.tags From b23bae9a66f3730c8e745a24815ba080f1992847 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:33:58 +0000 Subject: [PATCH 020/163] tst --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index a4d1f92..4030eec 100644 --- a/main.tf +++ b/main.tf @@ -68,7 +68,7 @@ module "openai_cosmosdb" { cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix geo_locations = var.cosmosdb_geo_locations capabilities = var.cosmosdb_capabilities - virtual_network_subnets = var.create_openai_networking == true ? toset(module.openai_networking[0].subnet_ids) : var.cosmosdb_virtual_network_subnets + virtual_network_subnets = var.create_openai_networking == true ? toset(values(module.openai_networking[0].subnet_ids)) : var.cosmosdb_virtual_network_subnets is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled tags = var.tags From e42b69eefb4a7d81e880b5bc9f5245b465b9b25a Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:37:05 +0000 Subject: [PATCH 021/163] update --- .github/workflows/manual-test-release.yml | 2 +- .github/workflows/manual-test.yml | 2 +- modules/cosmosdb/variables.tf | 4 +--- tests/auto_test1/testing.auto.tfvars | 2 +- tests/auto_test1/variables.tf | 2 +- variables.tf | 2 +- 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/manual-test-release.yml b/.github/workflows/manual-test-release.yml index 248af47..766b33e 100644 --- a/.github/workflows/manual-test-release.yml +++ b/.github/workflows/manual-test-release.yml @@ -17,7 +17,7 @@ jobs: - name: Checkout uses: actions/checkout@v4.1.1 - - name: Run Dependency Tests - Plan AND Apply AND Destroy + - name: Plan AND Apply AND Destroy uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 with: test_type: plan-apply-destroy ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" diff --git a/.github/workflows/manual-test.yml b/.github/workflows/manual-test.yml index cc436a7..dd14cdc 100644 --- a/.github/workflows/manual-test.yml +++ b/.github/workflows/manual-test.yml @@ -21,7 +21,7 @@ jobs: - name: Checkout uses: actions/checkout@v4.1.1 - - name: Run Dependency Tests - Plan AND Apply Only + - name: Plan AND Apply Only uses: Pwd9000-ML/terraform-azurerm-tests@v1.0.6 with: test_type: plan-apply ## (Required) Valid options are "plan", "plan-apply", "plan-apply-destroy". Default="plan" diff --git a/modules/cosmosdb/variables.tf b/modules/cosmosdb/variables.tf index 0fcc911..dd00fe2 100644 --- a/modules/cosmosdb/variables.tf +++ b/modules/cosmosdb/variables.tf @@ -75,9 +75,7 @@ variable "geo_locations" { variable "capabilities" { description = "The capabilities for the Cosmos DB account" type = list(string) - default = [ - "MongoDB" - ] + default = ["MongoDBv3.4"] } variable "virtual_network_subnets" { diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 890184d..f5165bc 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -79,7 +79,7 @@ cosmosdb_geo_locations = [ failover_priority = 0 } ] -cosmosdb_capabilities = ["MongoDB"] +cosmosdb_capabilities = ["MongoDBv3.4"] cosmosdb_is_virtual_network_filter_enabled = true cosmosdb_public_network_access_enabled = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 6466cbd..8f6ad79 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -292,7 +292,7 @@ variable "cosmosdb_geo_locations" { variable "cosmosdb_capabilities" { description = "The capabilities for the Cosmos DB account" type = list(string) - default = ["MongoDB"] + default = ["MongoDBv3.4"] } variable "cosmosdb_virtual_network_subnets" { diff --git a/variables.tf b/variables.tf index 66a2ac1..63cdf4e 100644 --- a/variables.tf +++ b/variables.tf @@ -302,7 +302,7 @@ variable "cosmosdb_geo_locations" { variable "cosmosdb_capabilities" { description = "The capabilities for the Cosmos DB account" type = list(string) - default = ["MongoDB"] + default = ["MongoDBv3.4"] } variable "cosmosdb_virtual_network_subnets" { From 2fc0d6b94ed5192a12e887041faa3d85f802bdf8 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:39:34 +0000 Subject: [PATCH 022/163] test --- modules/cosmosdb/variables.tf | 2 +- tests/auto_test1/testing.auto.tfvars | 2 +- tests/auto_test1/variables.tf | 2 +- variables.tf | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/cosmosdb/variables.tf b/modules/cosmosdb/variables.tf index dd00fe2..5a9e7c2 100644 --- a/modules/cosmosdb/variables.tf +++ b/modules/cosmosdb/variables.tf @@ -75,7 +75,7 @@ variable "geo_locations" { variable "capabilities" { description = "The capabilities for the Cosmos DB account" type = list(string) - default = ["MongoDBv3.4"] + default = ["EnableMongo", "MongoDBv3.4"] } variable "virtual_network_subnets" { diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index f5165bc..6962b9f 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -79,7 +79,7 @@ cosmosdb_geo_locations = [ failover_priority = 0 } ] -cosmosdb_capabilities = ["MongoDBv3.4"] +cosmosdb_capabilities = ["EnableMongo", "MongoDBv3.4"] cosmosdb_is_virtual_network_filter_enabled = true cosmosdb_public_network_access_enabled = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 8f6ad79..2786bb6 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -292,7 +292,7 @@ variable "cosmosdb_geo_locations" { variable "cosmosdb_capabilities" { description = "The capabilities for the Cosmos DB account" type = list(string) - default = ["MongoDBv3.4"] + default = ["EnableMongo", "MongoDBv3.4"] } variable "cosmosdb_virtual_network_subnets" { diff --git a/variables.tf b/variables.tf index 63cdf4e..9d47713 100644 --- a/variables.tf +++ b/variables.tf @@ -302,7 +302,7 @@ variable "cosmosdb_geo_locations" { variable "cosmosdb_capabilities" { description = "The capabilities for the Cosmos DB account" type = list(string) - default = ["MongoDBv3.4"] + default = ["EnableMongo", "MongoDBv3.4"] } variable "cosmosdb_virtual_network_subnets" { From 66b6ec4f8f28966618792c625856d1a4f2135edf Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:42:26 +0000 Subject: [PATCH 023/163] test --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index 4030eec..7b36681 100644 --- a/main.tf +++ b/main.tf @@ -72,7 +72,7 @@ module "openai_cosmosdb" { is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled tags = var.tags - depends_on = [module.openai_networking] + #depends_on = [module.openai_networking] } ### Vreate the Web App ### From 59013455650a202b2cd93f53244b7d9bc0104552 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 15 Jan 2024 20:50:56 +0000 Subject: [PATCH 024/163] test --- variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variables.tf b/variables.tf index 9d47713..042b390 100644 --- a/variables.tf +++ b/variables.tf @@ -306,7 +306,7 @@ variable "cosmosdb_capabilities" { } variable "cosmosdb_virtual_network_subnets" { - description = "The virtual network subnets to associate with the Cosmos DB account" + description = "The virtual network subnets to associate with the Cosmos DB account (Service Endpoint). If networking is created as part of the module, this will be automatically populated." type = list(string) default = null } From deaa05c917bf408fb6af0e8fd7792f48e0a24d82 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Tue, 16 Jan 2024 16:44:03 +0000 Subject: [PATCH 025/163] test --- main.tf | 13 +- modules/App/README.md | 15 ++ modules/App/main.tf | 376 +++++++++++++++++++++++++++++++++++++++ modules/App/outputs.tf | 0 modules/App/variables.tf | 55 ++++++ variables.tf | 8 +- 6 files changed, 459 insertions(+), 8 deletions(-) create mode 100644 modules/App/README.md create mode 100644 modules/App/main.tf create mode 100644 modules/App/outputs.tf create mode 100644 modules/App/variables.tf diff --git a/main.tf b/main.tf index 7b36681..e17e5a1 100644 --- a/main.tf +++ b/main.tf @@ -72,15 +72,16 @@ module "openai_cosmosdb" { is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled tags = var.tags - #depends_on = [module.openai_networking] } -### Vreate the Web App ### +### Create the Web App ### +# 7.) Create a Linux Web App running chatbot container. +module "openai_app" { + source = "./modules/app" + app_resource_group_name = var.cosmosdb_resource_group_name +} + -### Create a container app ChatBot UI linked with OpenAI service hosted in Azure ### -# 5.) Create a container app log analytics workspace. -# 6.) Create a container app environment. -# 7.) Create a container app instance. # 8.) grant the container app access a the key vault (optional). ##module "privategpt_chatbot_container_apps" { diff --git a/modules/App/README.md b/modules/App/README.md new file mode 100644 index 0000000..690e5c1 --- /dev/null +++ b/modules/App/README.md @@ -0,0 +1,15 @@ +# Module: Azure App Service Resources + +Create an App Service plan and Linux Web app running a chatbot container image: + +- Create App Service Plan +- Create Linux Web App + +## Environment Variables (Azure) + +The following environment variables are required to operate on Azure OpenAI: + + + + + \ No newline at end of file diff --git a/modules/App/main.tf b/modules/App/main.tf new file mode 100644 index 0000000..0c5924d --- /dev/null +++ b/modules/App/main.tf @@ -0,0 +1,376 @@ +resource "azurerm_service_plan" "openai" { + name = var.app_service_name + location = var.location + resource_group_name = var.app_resource_group_name + os_type = "Linux" + sku_name = var.app_service_sku_name +} + +resource "azurerm_linux_web_app" "openai" { + name = var.app_name + location = var.location + resource_group_name = var.app_resource_group_name + service_plan_id = azurerm_service_plan.openai.id + public_network_access_enabled = true + https_only = true + + site_config { + minimum_tls_version = "1.2" + } + + logs { + http_logs { + file_system { + retention_in_days = 7 + retention_in_mb = 35 + } + } + application_logs { + file_system_level = "Information" + } + } + + app_settings = { + + #==================================================# + # Server Configuration # + #==================================================# + APP_TITLE = var.app_title + # CUSTOM_FOOTER="My custom footer" + HOST = "0.0.0.0" + PORT = 80 + MONGO_URI = var.mongodb_connection_string #azurerm_cosmosdb_account.librechat.connection_strings[0] + DOMAIN_CLIENT = "http://localhost:3080" + DOMAIN_SERVER = "http://localhost:3080" + + #===============# + # Debug Logging # + #===============# + DEBUG_LOGGING = true + DEBUG_CONSOLE = false + + #=============# + # Permissions # + #=============# + # UID=1000 + # GID=1000 + + #===================================================# + # Endpoints # + #===================================================# + ENDPOINTS = "azureOpenAI" #openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic + # PROXY= + + #============# + # Anthropic # + #============# + # ANTHROPIC_API_KEY = "user_provided" + # ANTHROPIC_MODELS = "claude-1,claude-instant-1,claude-2" + # ANTHROPIC_REVERSE_PROXY= + + #============# + # Azure # + #============# + AZURE_API_KEY = "value" + AZURE_OPENAI_MODELS = "gpt-4" + # AZURE_OPENAI_DEFAULT_MODEL = "gpt-3.5-turbo" + # PLUGINS_USE_AZURE = true + AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = false + AZURE_OPENAI_API_INSTANCE_NAME = "gpt9000" #split("//", split(".", module.openai.openai_endpoint)[0])[1] + AZURE_OPENAI_API_DEPLOYMENT_NAME = "gpt4p1106" + AZURE_OPENAI_API_VERSION = "1106-Preview" + # AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = + # AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = + + #============# + # BingAI # + #============# + #BINGAI_TOKEN = var.bingai_token + # BINGAI_HOST = "https://cn.bing.com" + + #============# + # ChatGPT # + #============# + #CHATGPT_TOKEN = var.chatgpt_token + #CHATGPT_MODELS = "text-davinci-002-render-sha" + # CHATGPT_REVERSE_PROXY = "" + + #============# + # Google # + #============# + #GOOGLE_KEY = "user_provided" + # GOOGLE_MODELS="gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k" + # GOOGLE_REVERSE_PROXY= "" + + #============# + # OpenAI # + #============# + # OPENAI_API_KEY = var.openai_key + # OPENAI_MODELS = "gpt-3.5-turbo-1106,gpt-4-1106-preview,gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,text-davinci-003,gpt-4,gpt-4-0314,gpt-4-0613" + # DEBUG_OPENAI = false + # TITLE_CONVO = false + # OPENAI_TITLE_MODEL = "gpt-3.5-turbo" + # OPENAI_SUMMARIZE = true + # OPENAI_SUMMARY_MODEL = "gpt-3.5-turbo" + # OPENAI_FORCE_PROMPT = true + # OPENAI_REVERSE_PROXY = "" + + #============# + # OpenRouter # + #============# + # OPENROUTER_API_KEY = + + #============# + # Plugins # + #============# + # PLUGIN_MODELS = "gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,gpt-4,gpt-4-0314,gpt-4-0613" + DEBUG_PLUGINS = true + CREDS_KEY = "dflkghehiuggh" + CREDS_IV = "dflkghehiuggh" + + # Azure AI Search + #----------------- + # AZURE_AI_SEARCH_SERVICE_ENDPOINT= + # AZURE_AI_SEARCH_INDEX_NAME= + # AZURE_AI_SEARCH_API_KEY= + # AZURE_AI_SEARCH_API_VERSION= + # AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE= + # AZURE_AI_SEARCH_SEARCH_OPTION_TOP= + # AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= + + # DALL·E 3 + #---------------- + # DALLE_API_KEY= + # DALLE3_SYSTEM_PROMPT="Your System Prompt here" + # DALLE_REVERSE_PROXY= + + # Google + #----------------- + # GOOGLE_API_KEY= + # GOOGLE_CSE_ID= + + # SerpAPI + #----------------- + # SERPAPI_API_KEY= + + # Stable Diffusion + #----------------- + # SD_WEBUI_URL=http://host.docker.internal:7860 + + # WolframAlpha + #----------------- + # WOLFRAM_APP_ID= + + # Zapier + #----------------- + # ZAPIER_NLA_API_KEY= + + #==================================================# + # Search # + #==================================================# + # SEARCH = true + # MEILI_NO_ANALYTICS = true + # MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" + # MEILI_HTTP_ADDR=0.0.0.0:7700 + # MEILI_MASTER_KEY = random_string.meilisearch_master_key.result + + #===================================================# + # User System # + #===================================================# + #========================# + # Moderation # + #========================# + BAN_VIOLATIONS = true + BAN_DURATION = 1000 * 60 * 60 * 2 + BAN_INTERVAL = 20 + + LOGIN_VIOLATION_SCORE = 1 + REGISTRATION_VIOLATION_SCORE = 1 + CONCURRENT_VIOLATION_SCORE = 1 + MESSAGE_VIOLATION_SCORE = 1 + NON_BROWSER_VIOLATION_SCORE = 20 + + LOGIN_MAX = 7 + LOGIN_WINDOW = 5 + REGISTER_MAX = 5 + REGISTER_WINDOW = 60 + + LIMIT_CONCURRENT_MESSAGES = true + CONCURRENT_MESSAGE_MAX = 2 + + LIMIT_MESSAGE_IP = true + MESSAGE_IP_MAX = 40 + MESSAGE_IP_WINDOW = 1 + + LIMIT_MESSAGE_USER = false + MESSAGE_USER_MAX = 40 + MESSAGE_USER_WINDOW = 1 + + #========================# + # Balance # + #========================# + CHECK_BALANCE = false + + #========================# + # Registration and Login # + #========================# + ALLOW_EMAIL_LOGIN = true + ALLOW_REGISTRATION = true + ALLOW_SOCIAL_LOGIN = false + ALLOW_SOCIAL_REGISTRATION = false + SESSION_EXPIRY = 1000 * 60 * 15 + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 + JWT_SECRET = "sdgdsgsd" + JWT_REFRESH_SECRET = "dffgdfgh" + + # Discord + # DISCORD_CLIENT_ID= + # DISCORD_CLIENT_SECRET= + # DISCORD_CALLBACK_URL=/oauth/discord/callback + + # Facebook + # FACEBOOK_CLIENT_ID= + # FACEBOOK_CLIENT_SECRET= + # FACEBOOK_CALLBACK_URL=/oauth/facebook/callback + + # GitHub + # GITHUB_CLIENT_ID= + # GITHUB_CLIENT_SECRET= + # GITHUB_CALLBACK_URL=/oauth/github/callback + + # Google + # GOOGLE_CLIENT_ID= + # GOOGLE_CLIENT_SECRET= + # GOOGLE_CALLBACK_URL=/oauth/google/callback + + # OpenID + # OPENID_CLIENT_ID= + # OPENID_CLIENT_SECRET= + # OPENID_ISSUER= + # OPENID_SESSION_SECRET= + # OPENID_SCOPE="openid profile email" + # OPENID_CALLBACK_URL=/oauth/openid/callback + + # OPENID_BUTTON_LABEL= + # OPENID_IMAGE_URL= + + #========================# + # Email Password Reset # + #========================# + + # EMAIL_SERVICE= + # EMAIL_HOST= + # EMAIL_PORT=25 + # EMAIL_ENCRYPTION= + # EMAIL_ENCRYPTION_HOSTNAME= + # EMAIL_ALLOW_SELFSIGNED= + # EMAIL_USERNAME= + # EMAIL_PASSWORD= + # EMAIL_FROM_NAME= + # EMAIL_FROM=noreply@librechat.ai + + #==================================================# + # Others # + #==================================================# + # You should leave the following commented out # + + # NODE_ENV= + + # REDIS_URI= + # USE_REDIS= + + # E2E_USER_EMAIL= + # E2E_USER_PASSWORD= + + #=============================================================# + # Azure App Service Configuration # + #=============================================================# + + WEBSITE_RUN_FROM_PACKAGE = "1" + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + DOCKER_ENABLE_CI = false + WEBSITES_PORT = 80 + PORT = 80 + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + NODE_ENV = "production" + } + # virtual_network_subnet_id = azurerm_subnet.librechat_subnet.id + + # depends_on = [azurerm_linux_web_app.meilisearch, azurerm_cosmosdb_account.librechat, module.openai] + + # depends_on = [azurerm_linux_web_app.meilisearch] +} + +# Deploy code from a public GitHub repo +# resource "azurerm_app_service_source_control" "sourcecontrol" { +# app_id = azurerm_linux_web_app.librechat.id +# repo_url = "https://github.com/danny-avila/LibreChat" +# branch = "main" +# type = "Github" + +# # use_manual_integration = true +# # use_mercurial = false +# depends_on = [ +# azurerm_linux_web_app.librechat, +# ] +# } + +# resource "azurerm_app_service_virtual_network_swift_connection" "librechat" { +# app_service_id = azurerm_linux_web_app.librechat.id +# subnet_id = module.vnet.vnet_subnets_name_id["subnet0"] + +# depends_on = [ +# azurerm_linux_web_app.librechat, +# module.vnet +# ] +# } + +# #TODO: privately communicate between librechat and meilisearch, right now it is via public internet +# resource "azurerm_linux_web_app" "meilisearch" { +# name = "meilisearchapp${random_string.random_postfix.result}" +# location = azurerm_resource_group.this.location +# resource_group_name = azurerm_resource_group.this.name +# service_plan_id = azurerm_service_plan.librechat.id + +# app_settings = { +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + +# MEILI_MASTER_KEY = random_string.meilisearch_master_key.result +# MEILI_NO_ANALYTICS = true + +# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false +# DOCKER_ENABLE_CI = false +# WEBSITES_PORT = 7700 +# PORT = 7700 +# DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" +# } + +# site_config { +# always_on = "true" +# ip_restriction { +# virtual_network_subnet_id = azurerm_subnet.librechat_subnet.id +# priority = 100 +# name = "Allow from LibreChat subnet" +# action = "Allow" +# } +# } + +# logs { +# http_logs { +# file_system { +# retention_in_days = 7 +# retention_in_mb = 35 +# } +# } +# application_logs { +# file_system_level = "Information" +# } +# } + +# # identity { +# # type = "SystemAssigned" +# # } + +# } \ No newline at end of file diff --git a/modules/App/outputs.tf b/modules/App/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/modules/App/variables.tf b/modules/App/variables.tf new file mode 100644 index 0000000..540ac5c --- /dev/null +++ b/modules/App/variables.tf @@ -0,0 +1,55 @@ +variable "app_resource_group_name" { + type = string + description = "Name of the resource group to where networking resources will be hosted." + nullable = false +} + +variable "location" { + type = string + default = "uksouth" + description = "Azure region where resources will be hosted." +} + +variable "tags" { + type = map(string) + default = { + Terraform = "True" + Description = "OpenAI App Resource." + Author = "Marcel Lupo" + GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" + } + description = "A map of key value pairs that is used to tag resources created." +} + +variable "app_service_name" { + type = string + description = "Name of the App Service." + default = "openai-app" +} + +variable "app_service_sku_name" { + type = string + description = "The SKU name of the App Service Plan." + default = "B1" +} + +variable "app_name" { + type = string + description = "Name of the App." + default = "openai-app" +} + +##Pull from KV potentially +variable "app_title" { + type = string + description = "Title of the App." + default = "PrivateGPT" +} + +variable "mongodb_connection_string" { + type = string + description = "Connection string to the MongoDB database." + sensitive = true + default = "value" +} + diff --git a/variables.tf b/variables.tf index 042b390..4ee015e 100644 --- a/variables.tf +++ b/variables.tf @@ -167,7 +167,9 @@ variable "model_deployment" { nullable = false } -### Networking ### +##################################### +### Network service Module params ### +##################################### variable "create_openai_networking" { description = "Create a virtual network and subnet/s for networked services" type = bool @@ -224,7 +226,9 @@ variable "subnet_config" { description = "A list of subnet configuration objects to create subnets in the virtual network." } -### CosmosDB ### +###################################### +### CosmosDB service Module params ### +###################################### variable "create_cosmosdb" { description = "Create a CosmosDB account running MongoDB to store chat data." type = bool From f783862234de0aee066b3c817c9b69f213973f8e Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Tue, 16 Jan 2024 16:54:57 +0000 Subject: [PATCH 026/163] test --- main.tf | 2 +- modules/{App => gpt_app}/README.md | 0 modules/{App => gpt_app}/main.tf | 0 modules/{App => gpt_app}/outputs.tf | 0 modules/{App => gpt_app}/variables.tf | 0 5 files changed, 1 insertion(+), 1 deletion(-) rename modules/{App => gpt_app}/README.md (100%) rename modules/{App => gpt_app}/main.tf (100%) rename modules/{App => gpt_app}/outputs.tf (100%) rename modules/{App => gpt_app}/variables.tf (100%) diff --git a/main.tf b/main.tf index e17e5a1..90d7612 100644 --- a/main.tf +++ b/main.tf @@ -77,7 +77,7 @@ module "openai_cosmosdb" { ### Create the Web App ### # 7.) Create a Linux Web App running chatbot container. module "openai_app" { - source = "./modules/app" + source = "./modules/gpt_app" app_resource_group_name = var.cosmosdb_resource_group_name } diff --git a/modules/App/README.md b/modules/gpt_app/README.md similarity index 100% rename from modules/App/README.md rename to modules/gpt_app/README.md diff --git a/modules/App/main.tf b/modules/gpt_app/main.tf similarity index 100% rename from modules/App/main.tf rename to modules/gpt_app/main.tf diff --git a/modules/App/outputs.tf b/modules/gpt_app/outputs.tf similarity index 100% rename from modules/App/outputs.tf rename to modules/gpt_app/outputs.tf diff --git a/modules/App/variables.tf b/modules/gpt_app/variables.tf similarity index 100% rename from modules/App/variables.tf rename to modules/gpt_app/variables.tf From 0bbbe97560bbdf818d7bab37a764f5cdd15e506d Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Tue, 16 Jan 2024 17:14:37 +0000 Subject: [PATCH 027/163] test --- main.tf | 5 +++++ modules/gpt_app/variables.tf | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/main.tf b/main.tf index 90d7612..feb8b18 100644 --- a/main.tf +++ b/main.tf @@ -79,6 +79,11 @@ module "openai_cosmosdb" { module "openai_app" { source = "./modules/gpt_app" app_resource_group_name = var.cosmosdb_resource_group_name + location = var.location + tags = var.tags + app_service_name = "openai-asp90221" + app_service_sku_name = "B1" + app_name = "openai-app90221" } diff --git a/modules/gpt_app/variables.tf b/modules/gpt_app/variables.tf index 540ac5c..8cdb998 100644 --- a/modules/gpt_app/variables.tf +++ b/modules/gpt_app/variables.tf @@ -24,7 +24,7 @@ variable "tags" { variable "app_service_name" { type = string description = "Name of the App Service." - default = "openai-app" + default = "openai-asp" } variable "app_service_sku_name" { From 1f9aa2eb143db5e4cc0c84a8646af881e1b4ec00 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Tue, 16 Jan 2024 19:44:00 +0000 Subject: [PATCH 028/163] test --- main.tf | 14 ++-- modules/gpt_app/main.tf | 142 ++++++++++++++++++++-------------------- 2 files changed, 79 insertions(+), 77 deletions(-) diff --git a/main.tf b/main.tf index feb8b18..7fee502 100644 --- a/main.tf +++ b/main.tf @@ -77,13 +77,13 @@ module "openai_cosmosdb" { ### Create the Web App ### # 7.) Create a Linux Web App running chatbot container. module "openai_app" { - source = "./modules/gpt_app" - app_resource_group_name = var.cosmosdb_resource_group_name - location = var.location - tags = var.tags - app_service_name = "openai-asp90221" - app_service_sku_name = "B1" - app_name = "openai-app90221" + source = "./modules/gpt_app" + app_resource_group_name = var.cosmosdb_resource_group_name + location = var.location + tags = var.tags + app_service_name = "openai-asp90221" + app_service_sku_name = "B1" + app_name = "openai-app90221" } diff --git a/modules/gpt_app/main.tf b/modules/gpt_app/main.tf index 0c5924d..db92d2f 100644 --- a/modules/gpt_app/main.tf +++ b/modules/gpt_app/main.tf @@ -31,7 +31,6 @@ resource "azurerm_linux_web_app" "openai" { } app_settings = { - #==================================================# # Server Configuration # #==================================================# @@ -39,7 +38,7 @@ resource "azurerm_linux_web_app" "openai" { # CUSTOM_FOOTER="My custom footer" HOST = "0.0.0.0" PORT = 80 - MONGO_URI = var.mongodb_connection_string #azurerm_cosmosdb_account.librechat.connection_strings[0] + MONGO_URI = "" DOMAIN_CLIENT = "http://localhost:3080" DOMAIN_SERVER = "http://localhost:3080" @@ -71,14 +70,15 @@ resource "azurerm_linux_web_app" "openai" { #============# # Azure # #============# - AZURE_API_KEY = "value" - AZURE_OPENAI_MODELS = "gpt-4" + AZURE_API_KEY = "" + AZURE_OPENAI_MODELS = "gpt-4-1106-preview,gpt-4,gpt-3.5-turbo,gpt-3.5-turbo-1106,gpt-4-vision-preview" # AZURE_OPENAI_DEFAULT_MODEL = "gpt-3.5-turbo" # PLUGINS_USE_AZURE = true - AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = false - AZURE_OPENAI_API_INSTANCE_NAME = "gpt9000" #split("//", split(".", module.openai.openai_endpoint)[0])[1] - AZURE_OPENAI_API_DEPLOYMENT_NAME = "gpt4p1106" - AZURE_OPENAI_API_VERSION = "1106-Preview" + + AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true + AZURE_OPENAI_API_INSTANCE_NAME = "gpt9000" + # AZURE_OPENAI_API_DEPLOYMENT_NAME = + AZURE_OPENAI_API_VERSION = "2023-07-01-preview" # AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = # AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = @@ -107,7 +107,7 @@ resource "azurerm_linux_web_app" "openai" { #============# # OPENAI_API_KEY = var.openai_key # OPENAI_MODELS = "gpt-3.5-turbo-1106,gpt-4-1106-preview,gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,text-davinci-003,gpt-4,gpt-4-0314,gpt-4-0613" - # DEBUG_OPENAI = false + #DEBUG_OPENAI = false # TITLE_CONVO = false # OPENAI_TITLE_MODEL = "gpt-3.5-turbo" # OPENAI_SUMMARIZE = true @@ -125,8 +125,8 @@ resource "azurerm_linux_web_app" "openai" { #============# # PLUGIN_MODELS = "gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,gpt-4,gpt-4-0314,gpt-4-0613" DEBUG_PLUGINS = true - CREDS_KEY = "dflkghehiuggh" - CREDS_IV = "dflkghehiuggh" + CREDS_KEY = "dfsdgdsffgdsfgds" + CREDS_IV = "dfsdgdsffgdsfgds" # Azure AI Search #----------------- @@ -168,15 +168,16 @@ resource "azurerm_linux_web_app" "openai" { #==================================================# # Search # #==================================================# - # SEARCH = true - # MEILI_NO_ANALYTICS = true - # MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" + SEARCH = true + MEILI_NO_ANALYTICS = true + MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" # MEILI_HTTP_ADDR=0.0.0.0:7700 - # MEILI_MASTER_KEY = random_string.meilisearch_master_key.result + MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" #===================================================# # User System # #===================================================# + #========================# # Moderation # #========================# @@ -218,10 +219,12 @@ resource "azurerm_linux_web_app" "openai" { ALLOW_REGISTRATION = true ALLOW_SOCIAL_LOGIN = false ALLOW_SOCIAL_REGISTRATION = false - SESSION_EXPIRY = 1000 * 60 * 15 - REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 - JWT_SECRET = "sdgdsgsd" - JWT_REFRESH_SECRET = "dffgdfgh" + + SESSION_EXPIRY = 1000 * 60 * 15 + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 + + JWT_SECRET = "dfsdgdsffgdsfgds" + JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" # Discord # DISCORD_CLIENT_ID= @@ -295,10 +298,9 @@ resource "azurerm_linux_web_app" "openai" { DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" NODE_ENV = "production" } - # virtual_network_subnet_id = azurerm_subnet.librechat_subnet.id - - # depends_on = [azurerm_linux_web_app.meilisearch, azurerm_cosmosdb_account.librechat, module.openai] + virtual_network_subnet_id = "/subscriptions/829efd7e-aa80-4c0d-9c1c-7aa2557f8e07/resourceGroups/TF-Module-Automated-Tests-Cognitive-GPT/providers/Microsoft.Network/virtualNetworks/openai-vnet2698/subnets/app-cosmos-sub" + depends_on = [azurerm_linux_web_app.meilisearch] # depends_on = [azurerm_linux_web_app.meilisearch] } @@ -326,51 +328,51 @@ resource "azurerm_linux_web_app" "openai" { # ] # } -# #TODO: privately communicate between librechat and meilisearch, right now it is via public internet -# resource "azurerm_linux_web_app" "meilisearch" { -# name = "meilisearchapp${random_string.random_postfix.result}" -# location = azurerm_resource_group.this.location -# resource_group_name = azurerm_resource_group.this.name -# service_plan_id = azurerm_service_plan.librechat.id - -# app_settings = { -# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - -# MEILI_MASTER_KEY = random_string.meilisearch_master_key.result -# MEILI_NO_ANALYTICS = true - -# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" -# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false -# DOCKER_ENABLE_CI = false -# WEBSITES_PORT = 7700 -# PORT = 7700 -# DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" -# } - -# site_config { -# always_on = "true" -# ip_restriction { -# virtual_network_subnet_id = azurerm_subnet.librechat_subnet.id -# priority = 100 -# name = "Allow from LibreChat subnet" -# action = "Allow" -# } -# } - -# logs { -# http_logs { -# file_system { -# retention_in_days = 7 -# retention_in_mb = 35 -# } -# } -# application_logs { -# file_system_level = "Information" -# } -# } - -# # identity { -# # type = "SystemAssigned" -# # } - -# } \ No newline at end of file +#TODO: privately communicate between librechat and meilisearch, right now it is via public internet +resource "azurerm_linux_web_app" "meilisearch" { + name = "meilisearchapp453454345" + location = var.location + resource_group_name = var.app_resource_group_name + service_plan_id = azurerm_service_plan.openai.id + + app_settings = { + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + + MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" + MEILI_NO_ANALYTICS = true + + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + DOCKER_ENABLE_CI = false + WEBSITES_PORT = 7700 + PORT = 7700 + DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" + } + + site_config { + always_on = "true" + ip_restriction { + virtual_network_subnet_id = "/subscriptions/829efd7e-aa80-4c0d-9c1c-7aa2557f8e07/resourceGroups/TF-Module-Automated-Tests-Cognitive-GPT/providers/Microsoft.Network/virtualNetworks/openai-vnet2698/subnets/app-cosmos-sub" + priority = 100 + name = "Allow from LibreChat subnet" + action = "Allow" + } + } + + logs { + http_logs { + file_system { + retention_in_days = 7 + retention_in_mb = 35 + } + } + application_logs { + file_system_level = "Information" + } + } + + # identity { + # type = "SystemAssigned" + # } + +} \ No newline at end of file From da22e920d3035fe20a2535aab50e861c2f9a9409 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Tue, 16 Jan 2024 19:48:14 +0000 Subject: [PATCH 029/163] up --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 6962b9f..d40fc5e 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -15,7 +15,7 @@ keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purpos keyvault_firewall_virtual_network_subnet_ids = [] ### Create OpenAI Service ### -create_openai_service = true +create_openai_service = false openai_account_name = "gptopenai" openai_custom_subdomain_name = "gptopenai" openai_sku_name = "S0" From 301089771f123ceb9433019fa1cfa0873eb7a68c Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Tue, 16 Jan 2024 19:50:35 +0000 Subject: [PATCH 030/163] test --- tests/auto_test1/testing.auto.tfvars | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index d40fc5e..948a9a3 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -15,7 +15,7 @@ keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purpos keyvault_firewall_virtual_network_subnet_ids = [] ### Create OpenAI Service ### -create_openai_service = false +create_openai_service = true openai_account_name = "gptopenai" openai_custom_subdomain_name = "gptopenai" openai_sku_name = "S0" @@ -30,12 +30,12 @@ openai_identity = { create_model_deployment = true model_deployment = [ { - deployment_id = "gpt4p1106" + deployment_id = "gpt-4" model_name = "gpt-4" model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 34 # 34K == Roughly 204 RPM (Requests per minute) + scale_capacity = 10 # 34K == Roughly 204 RPM (Requests per minute) } ] From 7b6e634985545f1bda20df02cba01b03fa0651c8 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 00:08:16 +0000 Subject: [PATCH 031/163] update modules --- main.tf | 6 +- modules/{gpt_app => librechat_app}/README.md | 0 modules/{gpt_app => librechat_app}/main.tf | 0 modules/{gpt_app => librechat_app}/outputs.tf | 0 .../{gpt_app => librechat_app}/variables.tf | 0 modules/openai/README.md | 11 + modules/openai/data.tf | 11 + modules/openai/locals.tf | 11 + modules/openai/main.tf | 104 ++++++++ modules/openai/outputs.tf | 46 ++++ modules/openai/variables.tf | 236 ++++++++++++++++++ 11 files changed, 421 insertions(+), 4 deletions(-) rename modules/{gpt_app => librechat_app}/README.md (100%) rename modules/{gpt_app => librechat_app}/main.tf (100%) rename modules/{gpt_app => librechat_app}/outputs.tf (100%) rename modules/{gpt_app => librechat_app}/variables.tf (100%) create mode 100644 modules/openai/README.md create mode 100644 modules/openai/data.tf create mode 100644 modules/openai/locals.tf create mode 100644 modules/openai/main.tf create mode 100644 modules/openai/outputs.tf create mode 100644 modules/openai/variables.tf diff --git a/main.tf b/main.tf index 7fee502..fdae24d 100644 --- a/main.tf +++ b/main.tf @@ -7,9 +7,7 @@ # 3.) Create an OpenAI language model deployments. (GPT-3, GPT-4, etc.) # 4.) Store the OpenAI account and model details in the key vault. module "openai" { - source = "Pwd9000-ML/openai-service/azurerm" - version = ">= 1.1.0" - +source = "./modules/openai" #common location = var.location tags = var.tags @@ -77,7 +75,7 @@ module "openai_cosmosdb" { ### Create the Web App ### # 7.) Create a Linux Web App running chatbot container. module "openai_app" { - source = "./modules/gpt_app" + source = "./modules/librechat_app" app_resource_group_name = var.cosmosdb_resource_group_name location = var.location tags = var.tags diff --git a/modules/gpt_app/README.md b/modules/librechat_app/README.md similarity index 100% rename from modules/gpt_app/README.md rename to modules/librechat_app/README.md diff --git a/modules/gpt_app/main.tf b/modules/librechat_app/main.tf similarity index 100% rename from modules/gpt_app/main.tf rename to modules/librechat_app/main.tf diff --git a/modules/gpt_app/outputs.tf b/modules/librechat_app/outputs.tf similarity index 100% rename from modules/gpt_app/outputs.tf rename to modules/librechat_app/outputs.tf diff --git a/modules/gpt_app/variables.tf b/modules/librechat_app/variables.tf similarity index 100% rename from modules/gpt_app/variables.tf rename to modules/librechat_app/variables.tf diff --git a/modules/openai/README.md b/modules/openai/README.md new file mode 100644 index 0000000..e1d9a2b --- /dev/null +++ b/modules/openai/README.md @@ -0,0 +1,11 @@ +# Module: Azure Networking Resources (Optional) + +Create a new VNET and subnet/s for the CosmosDB and Web App resources to use. (Optional) +If existing networking resources are to be used, then the variables/names of the existing VNET and subnets must be provided as input variables to root the module (data sources): + +- Create a VNET. +- Create a Delegated Subnet for App Service + CosmosDB + Service Endpoint. + + + + \ No newline at end of file diff --git a/modules/openai/data.tf b/modules/openai/data.tf new file mode 100644 index 0000000..6ba33a9 --- /dev/null +++ b/modules/openai/data.tf @@ -0,0 +1,11 @@ +################################################## +# DATA # +################################################## +data "azurerm_client_config" "current" {} + +# Get OpenAI Service details +data "azurerm_cognitive_account" "openai" { + count = var.create_openai_service ? 0 : 1 + name = var.openai_account_name + resource_group_name = var.openai_resource_group_name +} \ No newline at end of file diff --git a/modules/openai/locals.tf b/modules/openai/locals.tf new file mode 100644 index 0000000..437d142 --- /dev/null +++ b/modules/openai/locals.tf @@ -0,0 +1,11 @@ +locals { + ## locals config for key vault firewall rules ## + kv_net_rules = [ + { + default_action = var.keyvault_firewall_default_action + bypass = var.keyvault_firewall_bypass + ip_rules = var.keyvault_firewall_allowed_ips + virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids + } + ] +} \ No newline at end of file diff --git a/modules/openai/main.tf b/modules/openai/main.tf new file mode 100644 index 0000000..fc00d5b --- /dev/null +++ b/modules/openai/main.tf @@ -0,0 +1,104 @@ +########################## +### Solution resources ### +########################## +# Key Vault - Create Key Vault to save cognitive account details +resource "azurerm_key_vault" "openai_kv" { + resource_group_name = var.keyvault_resource_group_name + location = var.location + #values from variable kv_config object + name = lower(var.kv_config.name) + sku_name = var.kv_config.sku + enable_rbac_authorization = true + tenant_id = data.azurerm_client_config.current.tenant_id + dynamic "network_acls" { + for_each = local.kv_net_rules + content { + default_action = network_acls.value.default_action + bypass = network_acls.value.bypass + ip_rules = network_acls.value.ip_rules + virtual_network_subnet_ids = network_acls.value.virtual_network_subnet_ids + } + } + tags = var.tags +} + +# Add "self" permission to key vault RBAC (to manange key vault secrets) +resource "azurerm_role_assignment" "kv_role_assigment" { + for_each = toset(["Key Vault Administrator"]) + role_definition_name = each.key + scope = azurerm_key_vault.openai_kv.id + principal_id = data.azurerm_client_config.current.object_id +} + + +################################################## +# CREATE OPENAI Service and Model Deployment # +################################################## +# IMPORTANT: If existing service and model exist # +# set 'var.create_model_deployment' = false # +# set 'var.create_openai_service' = false # +################################################## + +### OpenAI Service +module "create_openai_service" { + source = "./modules/openai_service" + # Only deploy a new openai service 'var.create_openai_service' is true + count = var.create_openai_service == true ? 1 : 0 + resource_group_name = var.openai_resource_group_name + location = var.location + account_name = var.openai_account_name + sku_name = var.openai_sku_name + custom_subdomain_name = var.openai_custom_subdomain_name + dynamic_throttling_enabled = var.openai_dynamic_throttling_enabled + fqdns = var.openai_fqdns + local_auth_enabled = var.openai_local_auth_enabled + outbound_network_access_restricted = var.openai_outbound_network_access_restricted + public_network_access_enabled = var.openai_public_network_access_enabled + customer_managed_key = var.openai_customer_managed_key + identity = var.openai_identity + network_acls = var.openai_network_acls + storage = var.openai_storage + tags = var.tags +} + +### Model Deployments +module "create_model_deployment" { + source = "./modules/model_deployment" + # Only deploy new model if 'var.create_model_deployment' is true (else use existing cognitive account) + count = var.create_model_deployment == true ? 1 : 0 + openai_resource_group_name = var.create_openai_service == true ? module.create_openai_service[0].openai_resource_group_name : var.openai_resource_group_name + openai_account_name = var.create_openai_service == true ? module.create_openai_service[0].openai_account_name : var.openai_account_name + model_deployment = var.model_deployment + depends_on = [module.create_openai_service] +} + +### Save OpenAI Cognitive Account details to Key Vault for consumption by other services +resource "azurerm_key_vault_secret" "openai_endpoint" { + name = "${var.openai_account_name}-openai-endpoint" + value = var.create_openai_service == true ? module.create_openai_service[0].openai_endpoint : data.azurerm_cognitive_account.openai[0].endpoint + key_vault_id = azurerm_key_vault.openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] +} + +resource "azurerm_key_vault_secret" "openai_primary_key" { + name = "${var.openai_account_name}-openai-key" + value = var.create_openai_service == true ? module.create_openai_service[0].openai_primary_key : data.azurerm_cognitive_account.openai[0].primary_access_key + key_vault_id = azurerm_key_vault.openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] +} + +resource "azurerm_key_vault_secret" "openai_model_deployment_id" { + for_each = { for each in var.model_deployment : each.deployment_id => each } + name = "${var.openai_account_name}-model-${each.value.deployment_id}-id" + value = each.value.deployment_id + key_vault_id = azurerm_key_vault.openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] +} + +resource "azurerm_key_vault_secret" "openai_model" { + for_each = { for each in var.model_deployment : each.deployment_id => each } + name = "${var.openai_account_name}-model-${each.value.deployment_id}-name" + value = each.value.model_name + key_vault_id = azurerm_key_vault.openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] +} diff --git a/modules/openai/outputs.tf b/modules/openai/outputs.tf new file mode 100644 index 0000000..3874016 --- /dev/null +++ b/modules/openai/outputs.tf @@ -0,0 +1,46 @@ +################################################# +# OUTPUTS # +################################################# +### openai account outputs ### +output "openai_endpoint" { + description = "The endpoint used to connect to the Cognitive Service Account." + value = var.create_openai_service ? module.create_openai_service[0].openai_endpoint : data.azurerm_cognitive_account.openai[0].endpoint +} + +output "openai_primary_key" { + description = "The primary access key for the Cognitive Service Account." + sensitive = true + value = var.create_openai_service ? module.create_openai_service[0].openai_primary_key : data.azurerm_cognitive_account.openai[0].primary_access_key +} + +output "openai_secondary_key" { + description = "The secondary access key for the Cognitive Service Account." + sensitive = true + value = var.create_openai_service ? module.create_openai_service[0].openai_secondary_key : data.azurerm_cognitive_account.openai[0].secondary_access_key +} + +output "openai_subdomain" { + description = "The subdomain used to connect to the Cognitive Service Account." + value = var.create_openai_service ? module.create_openai_service[0].openai_subdomain : var.openai_custom_subdomain_name +} + +output "openai_account_name" { + description = "The name of the Cognitive Service Account." + value = var.create_openai_service ? module.create_openai_service[0].openai_account_name : var.openai_account_name +} + +output "openai_resource_group_name" { + description = "The name of the Resource Group hosting the Cognitive Service Account." + value = var.create_openai_service ? module.create_openai_service[0].openai_resource_group_name : var.openai_resource_group_name +} + +### key vault outputs ### +output "key_vault_id" { + description = "The ID of the Key Vault." + value = azurerm_key_vault.openai_kv.id +} + +output "key_vault_uri" { + description = "The URI of the Key Vault." + value = azurerm_key_vault.openai_kv.vault_uri +} diff --git a/modules/openai/variables.tf b/modules/openai/variables.tf new file mode 100644 index 0000000..e810391 --- /dev/null +++ b/modules/openai/variables.tf @@ -0,0 +1,236 @@ +################################################## +# VARIABLES # +################################################## +###Common### +variable "tags" { + type = map(string) + default = { + Terraform = "True" + Description = "Azure OpenAI service." + Author = "Marcel Lupo" + GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-service" + } + description = "A map of key value pairs that is used to tag resources created." +} + +variable "location" { + type = string + default = "uksouth" + description = "Azure region to deploy resources to." +} + +# solution resource group +variable "keyvault_resource_group_name" { + type = string + description = "Name of the resource group where the Key Vault will be hosted." + nullable = false +} + +###Key Vault### +variable "kv_config" { + type = object({ + name = string + sku = string + }) + default = { + name = "openaikv9000" + sku = "standard" + } + description = "Key Vault configuration object to create azure key vault to store openai account details." + nullable = false +} + +variable "keyvault_firewall_default_action" { + type = string + default = "Deny" + description = "Default action for key vault firewall rules." +} + +variable "keyvault_firewall_bypass" { + type = string + default = "AzureServices" + description = "List of key vault firewall rules to bypass." +} + +variable "keyvault_firewall_allowed_ips" { + type = list(string) + default = [] + description = "value of key vault firewall allowed ip rules." +} + +variable "keyvault_firewall_virtual_network_subnet_ids" { + type = list(string) + default = [] + description = "value of key vault firewall allowed virtual network subnet ids." +} + +########################################## +# OpenAI Service # +########################################## +variable "create_openai_service" { + type = bool + description = "Create the OpenAI service." + default = false +} + +variable "openai_resource_group_name" { + type = string + description = "Name of the resource group where the cognitive account OpenAI service is hosted (if different from solution resource group)." + nullable = false +} + +variable "openai_account_name" { + type = string + description = "Name of the OpenAI service." + default = "demo-account" +} + +variable "openai_sku_name" { + type = string + description = "SKU name of the OpenAI service." + default = "S0" +} + +variable "openai_custom_subdomain_name" { + type = string + description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created (normally the same as variable `openai_account_name`)" + default = "demo-account" +} + +variable "openai_dynamic_throttling_enabled" { + type = bool + description = "Determines whether or not dynamic throttling is enabled. If set to `true`, dynamic throttling will be enabled. If set to `false`, dynamic throttling will not be enabled." + default = null +} + +variable "openai_fqdns" { + type = list(string) + description = "List of FQDNs allowed for the Cognitive Account." + default = null +} + +variable "openai_local_auth_enabled" { + type = bool + description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." + default = true +} + +variable "openai_outbound_network_access_restricted" { + type = bool + description = "Whether or not outbound network access is restricted." + default = false +} + +variable "openai_public_network_access_enabled" { + type = bool + description = "Whether or not public network access is enabled for the Cognitive Account." + default = true +} +variable "openai_customer_managed_key" { + type = object({ + key_vault_key_id = string + identity_client_id = optional(string) + }) + default = null + description = <<-DESCRIPTION + type = object({ + key_vault_key_id = (Required) The ID of the Key Vault Key which should be used to Encrypt the data in this OpenAI Account. + identity_client_id = (Optional) The Client ID of the User Assigned Identity that has access to the key. This property only needs to be specified when there're multiple identities attached to the OpenAI Account. + }) + DESCRIPTION +} + +variable "openai_identity" { + type = object({ + type = string + identity_ids = optional(list(string)) + }) + default = null + description = <<-DESCRIPTION + type = object({ + type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. + identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. + }) + DESCRIPTION +} + +variable "openai_network_acls" { + type = set(object({ + default_action = string + ip_rules = optional(set(string)) + virtual_network_rules = optional(set(object({ + subnet_id = string + ignore_missing_vnet_service_endpoint = optional(bool, false) + }))) + })) + default = null + description = <<-DESCRIPTION + type = set(object({ + default_action = (Required) The Default Action to use when no rules match from ip_rules / virtual_network_rules. Possible values are `Allow` and `Deny`. + ip_rules = (Optional) One or more IP Addresses, or CIDR Blocks which should be able to access the Cognitive Account. + virtual_network_rules = optional(set(object({ + subnet_id = (Required) The ID of a Subnet which should be able to access the OpenAI Account. + ignore_missing_vnet_service_endpoint = (Optional) Whether ignore missing vnet service endpoint or not. Default to `false`. + }))) + })) + DESCRIPTION +} + +variable "openai_storage" { + type = list(object({ + storage_account_id = string + identity_client_id = optional(string) + })) + default = [] + description = <<-DESCRIPTION + type = list(object({ + storage_account_id = (Required) Full resource id of a Microsoft.Storage resource. + identity_client_id = (Optional) The client ID of the managed identity associated with the storage resource. + })) + DESCRIPTION + nullable = false +} + +########################################## +# Model Deployment # +########################################## +variable "create_model_deployment" { + type = bool + description = "Create the model deployment." + default = false +} + +variable "model_deployment" { + type = list(object({ + deployment_id = string + model_name = string + model_format = string + model_version = string + scale_type = string + scale_tier = optional(string) + scale_size = optional(number) + scale_family = optional(string) + scale_capacity = optional(number) + rai_policy_name = optional(string) + })) + default = [] + description = <<-DESCRIPTION + type = list(object({ + deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. + model_name = { + model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. + model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. + model_version = (Required) The version of Cognitive Services Account Deployment model. + } + scale = { + scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. + scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. + scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. + scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. + scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. + } + rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. + })) + DESCRIPTION + nullable = false +} \ No newline at end of file From f0d86a492f022882c405dd419a65dfd3290f0c76 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 01:11:00 +0000 Subject: [PATCH 032/163] test --- data.tf | 3 +- main.tf | 28 +++++++++++------- modules/cosmosdb/data.tf | 4 +++ modules/cosmosdb/main.tf | 15 ++++++++++ modules/cosmosdb/variables.tf | 8 +++++- modules/librechat_app/main.tf | 10 +++---- modules/librechat_app/variables.tf | 35 ++++++++++++++++++++-- modules/openai/outputs.tf | 2 +- tests/auto_test1/testing.auto.tfvars | 2 +- variables.tf | 43 ++++++++++++++++++++++++++++ 10 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 modules/cosmosdb/data.tf diff --git a/data.tf b/data.tf index 39da1e9..c0b96c0 100644 --- a/data.tf +++ b/data.tf @@ -1,8 +1,7 @@ ################################################## # DATA # ################################################## - -# Data sources to get Subnet ID/ss for CosmosDB and App Service +# Data sources to get Subnet ID/s for CosmosDB and App Service # Usage in Module example: subnet_id = data.azurerm_subnet.subnet["app-cosmos-sub"].id # data "azurerm_subnet" "subnet" { # for_each = { for each in var.subnet_config : each.subnet_name => each if var.create_openai_networking == false } diff --git a/main.tf b/main.tf index fdae24d..eedabf2 100644 --- a/main.tf +++ b/main.tf @@ -7,7 +7,7 @@ # 3.) Create an OpenAI language model deployments. (GPT-3, GPT-4, etc.) # 4.) Store the OpenAI account and model details in the key vault. module "openai" { -source = "./modules/openai" + source = "./modules/openai" #common location = var.location tags = var.tags @@ -69,20 +69,26 @@ module "openai_cosmosdb" { virtual_network_subnets = var.create_openai_networking == true ? toset(values(module.openai_networking[0].subnet_ids)) : var.cosmosdb_virtual_network_subnets is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled + openai_keyvault_id = var.create_openai_service == true ? module.openai.key_vault_id : var.openai_keyvault_id tags = var.tags } ### Create the Web App ### -# 7.) Create a Linux Web App running chatbot container. -module "openai_app" { - source = "./modules/librechat_app" - app_resource_group_name = var.cosmosdb_resource_group_name - location = var.location - tags = var.tags - app_service_name = "openai-asp90221" - app_service_sku_name = "B1" - app_name = "openai-app90221" -} +# # 7.) Create a Linux Web App running chatbot container. +# module "openai_app" { +# source = "./modules/librechat_app" +# app_resource_group_name = var.cosmosdb_resource_group_name +# location = var.location +# tags = var.tags + +# app_service_sku_name = var.app_service_sku_name +# app_service_name = var.app_service_name +# app_name = var.app_name +# app_title = var.app_title +# app_custom_footer = var.app_custom_footer + + +# } # 8.) grant the container app access a the key vault (optional). diff --git a/modules/cosmosdb/data.tf b/modules/cosmosdb/data.tf new file mode 100644 index 0000000..85cd0d0 --- /dev/null +++ b/modules/cosmosdb/data.tf @@ -0,0 +1,4 @@ +################################################## +# DATA # +################################################## +data "azurerm_client_config" "current" {} diff --git a/modules/cosmosdb/main.tf b/modules/cosmosdb/main.tf index b2c570c..0b2090a 100644 --- a/modules/cosmosdb/main.tf +++ b/modules/cosmosdb/main.tf @@ -38,4 +38,19 @@ resource "azurerm_cosmosdb_account" "mongo" { is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled public_network_access_enabled = var.public_network_access_enabled +} + +# Add "self" permission to key vault RBAC (to manange key vault secrets) +resource "azurerm_role_assignment" "kv_role_assigment" { + for_each = toset(["Key Vault Administrator"]) + role_definition_name = each.key + scope = var.openai_keyvault_id + principal_id = data.azurerm_client_config.current.object_id +} + +### Save CosmosDB details to Key Vault for consumption by other services (e.g. LibreChat App) +resource "azurerm_key_vault_secret" "openai_cosmos_uri" { + name = "${var.cosmosdb_name}-cosmos-uri" + value = azurerm_cosmosdb_account.mongo.primary_mongodb_connection_string + key_vault_id = var.openai_keyvault_id } \ No newline at end of file diff --git a/modules/cosmosdb/variables.tf b/modules/cosmosdb/variables.tf index 5a9e7c2..1ae38af 100644 --- a/modules/cosmosdb/variables.tf +++ b/modules/cosmosdb/variables.tf @@ -105,4 +105,10 @@ variable "tags" { GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" } description = "A map of key value pairs that is used to tag resources created." -} \ No newline at end of file +} + +### keyvault access### +variable "openai_keyvault_id" { + type = string + description = "The ID of the Key Vault to store the CosmosDB account details." +} diff --git a/modules/librechat_app/main.tf b/modules/librechat_app/main.tf index db92d2f..c321f6b 100644 --- a/modules/librechat_app/main.tf +++ b/modules/librechat_app/main.tf @@ -11,7 +11,7 @@ resource "azurerm_linux_web_app" "openai" { location = var.location resource_group_name = var.app_resource_group_name service_plan_id = azurerm_service_plan.openai.id - public_network_access_enabled = true + public_network_access_enabled = var.public_network_access_enabled https_only = true site_config { @@ -34,10 +34,10 @@ resource "azurerm_linux_web_app" "openai" { #==================================================# # Server Configuration # #==================================================# - APP_TITLE = var.app_title - # CUSTOM_FOOTER="My custom footer" - HOST = "0.0.0.0" - PORT = 80 + APP_TITLE = var.app_title + CUSTOM_FOOTER = var.app_custom_footer + HOST = var.app_host + PORT = var.app_port MONGO_URI = "" DOMAIN_CLIENT = "http://localhost:3080" DOMAIN_SERVER = "http://localhost:3080" diff --git a/modules/librechat_app/variables.tf b/modules/librechat_app/variables.tf index 8cdb998..5070317 100644 --- a/modules/librechat_app/variables.tf +++ b/modules/librechat_app/variables.tf @@ -39,17 +39,48 @@ variable "app_name" { default = "openai-app" } -##Pull from KV potentially +variable "public_network_access_enabled " { + type = bool + description = "Whether or not public network access is allowed for this App Service." + default = false +} + +### App Settings ### +## Server Configuration ## variable "app_title" { type = string description = "Title of the App." default = "PrivateGPT" } +variable "app_custom_footer" { + type = string + description = "Custom footer for the App." + default = "Privately hosted chat app powered by Azure OpenAI" +} + +variable "app_host" { + type = string + description = "The server will listen to localhost:3080 by default. You can change the target IP as you want. If you want to make this server available externally, for example to share the server with others or expose this from a Docker container, set host to 0.0.0.0 or your external IP interface. Setting host to 0.0.0.0 means listening on all interfaces. It's not a real IP." + default = "0.0.0.0" +} + +variable "app_port" { + type = number + description = "The port to listen on." + default = 80 +} + +variable "public_network_access_enabled" { + type = bool + description = "Whether or not public network access is allowed for this App Service." + default = false +} + +###31 variable "mongodb_connection_string" { type = string description = "Connection string to the MongoDB database." sensitive = true - default = "value" } diff --git a/modules/openai/outputs.tf b/modules/openai/outputs.tf index 3874016..7593b94 100644 --- a/modules/openai/outputs.tf +++ b/modules/openai/outputs.tf @@ -42,5 +42,5 @@ output "key_vault_id" { output "key_vault_uri" { description = "The URI of the Key Vault." - value = azurerm_key_vault.openai_kv.vault_uri + value = azurerm_key_vault.openai_kv.vault_uri } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 948a9a3..3e22dd9 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -3,7 +3,7 @@ resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" location = "uksouth" tags = { Terraform = "True" - Description = "Private ChatGPT hosted on Azure OpenAI" + Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" Author = "Marcel Lupo" GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" } diff --git a/variables.tf b/variables.tf index 4ee015e..1fef81e 100644 --- a/variables.tf +++ b/variables.tf @@ -327,6 +327,49 @@ variable "cosmosdb_public_network_access_enabled" { default = true } +variable "openai_keyvault_id" { + type = string + description = "The ID of the Key Vault to store the CosmosDB account details." + default = null +} + +################################### +### LibreChat App Module params ### +################################### +### App Service Plan ### +variable "app_service_name" { + type = string + description = "Name of the App Service." + default = "openai-asp9000" +} + +variable "app_service_sku_name" { + type = string + description = "The SKU name of the App Service Plan." + default = "B1" +} + +### App Service ### +variable "app_name" { + type = string + description = "Name of the App." + default = "openai-app-9000" +} + +variable "app_title" { + type = string + description = "Title of the App." + default = "PrivateGPT" +} + +variable "app_custom_footer" { + type = string + description = "Custom footer for the App." + default = "Privately hosted chat app powered by Azure OpenAI" +} + + + ################################### ### Container App Module params ### ################################### From 24e74b8862a7913e0fc9ddf2cd36171841238b2d Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 01:18:46 +0000 Subject: [PATCH 033/163] up --- modules/openai/main.tf | 4 +- modules/openai/model_deployment/README.md | 40 ++++++ modules/openai/model_deployment/data.tf | 4 + modules/openai/model_deployment/main.tf | 20 +++ modules/openai/model_deployment/outputs.tf | 4 + modules/openai/model_deployment/variables.tf | 46 +++++++ modules/openai/openai_service/README.md | 56 ++++++++ modules/openai/openai_service/main.tf | 55 ++++++++ modules/openai/openai_service/outputs.tf | 31 +++++ modules/openai/openai_service/variables.tf | 136 +++++++++++++++++++ 10 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 modules/openai/model_deployment/README.md create mode 100644 modules/openai/model_deployment/data.tf create mode 100644 modules/openai/model_deployment/main.tf create mode 100644 modules/openai/model_deployment/outputs.tf create mode 100644 modules/openai/model_deployment/variables.tf create mode 100644 modules/openai/openai_service/README.md create mode 100644 modules/openai/openai_service/main.tf create mode 100644 modules/openai/openai_service/outputs.tf create mode 100644 modules/openai/openai_service/variables.tf diff --git a/modules/openai/main.tf b/modules/openai/main.tf index fc00d5b..134de73 100644 --- a/modules/openai/main.tf +++ b/modules/openai/main.tf @@ -41,7 +41,7 @@ resource "azurerm_role_assignment" "kv_role_assigment" { ### OpenAI Service module "create_openai_service" { - source = "./modules/openai_service" + source = "./openai_service" # Only deploy a new openai service 'var.create_openai_service' is true count = var.create_openai_service == true ? 1 : 0 resource_group_name = var.openai_resource_group_name @@ -63,7 +63,7 @@ module "create_openai_service" { ### Model Deployments module "create_model_deployment" { - source = "./modules/model_deployment" + source = "./model_deployment" # Only deploy new model if 'var.create_model_deployment' is true (else use existing cognitive account) count = var.create_model_deployment == true ? 1 : 0 openai_resource_group_name = var.create_openai_service == true ? module.create_openai_service[0].openai_resource_group_name : var.openai_resource_group_name diff --git a/modules/openai/model_deployment/README.md b/modules/openai/model_deployment/README.md new file mode 100644 index 0000000..45b02dd --- /dev/null +++ b/modules/openai/model_deployment/README.md @@ -0,0 +1,40 @@ +# Create Model Deployments + +Sub module to create model deployments on an existing cognitive OpenAI service/account. + + +## Requirements + +No requirements. + +## Providers + +| Name | Version | +|------|---------| +| [azurerm](#provider\_azurerm) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [azurerm_cognitive_deployment.model](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cognitive_deployment) | resource | +| [azurerm_cognitive_account.openai](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/cognitive_account) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [model\_deployment](#input\_model\_deployment) | type = list(object({
deployment\_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created.
model\_name = {
model\_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI.
model\_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created.
model\_version = (Required) The version of Cognitive Services Account Deployment model.
}
scale = {
scale\_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created.
scale\_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created.
scale\_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created.
scale\_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created.
scale\_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created.
}
rai\_policy\_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created.
})) |
list(object({
deployment_id = string
model_name = string
model_format = string
model_version = string
scale_type = string
scale_tier = optional(string)
scale_size = optional(number)
scale_family = optional(string)
scale_capacity = optional(number)
rai_policy_name = optional(string)
}))
| `[]` | no | +| [openai\_account\_name](#input\_openai\_account\_name) | Name of the OpenAI service. | `string` | `"demo-account"` | no | +| [openai\_resource\_group\_name](#input\_openai\_resource\_group\_name) | Name of the resource group where the cognitive account OpenAI service is hosted. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [model\_deployment\_id](#output\_model\_deployment\_id) | The ID of the model deployment. | + \ No newline at end of file diff --git a/modules/openai/model_deployment/data.tf b/modules/openai/model_deployment/data.tf new file mode 100644 index 0000000..3f7c25e --- /dev/null +++ b/modules/openai/model_deployment/data.tf @@ -0,0 +1,4 @@ +data "azurerm_cognitive_account" "openai" { + name = var.openai_account_name + resource_group_name = var.openai_resource_group_name +} \ No newline at end of file diff --git a/modules/openai/model_deployment/main.tf b/modules/openai/model_deployment/main.tf new file mode 100644 index 0000000..a3e95d3 --- /dev/null +++ b/modules/openai/model_deployment/main.tf @@ -0,0 +1,20 @@ +resource "azurerm_cognitive_deployment" "model" { + for_each = { for each in var.model_deployment : each.deployment_id => each } + + cognitive_account_id = data.azurerm_cognitive_account.openai.id + name = each.value.deployment_id + rai_policy_name = each.value.rai_policy_name + + model { + format = each.value.model_format + name = each.value.model_name + version = each.value.model_version + } + scale { + type = each.value.scale_type + tier = each.value.scale_tier + size = each.value.scale_size + family = each.value.scale_family + capacity = each.value.scale_capacity + } +} \ No newline at end of file diff --git a/modules/openai/model_deployment/outputs.tf b/modules/openai/model_deployment/outputs.tf new file mode 100644 index 0000000..e1c58f0 --- /dev/null +++ b/modules/openai/model_deployment/outputs.tf @@ -0,0 +1,4 @@ +output "model_deployment_id" { + description = "The ID of the model deployment." + value = { for k, v in azurerm_cognitive_deployment.model : k => v.id } +} \ No newline at end of file diff --git a/modules/openai/model_deployment/variables.tf b/modules/openai/model_deployment/variables.tf new file mode 100644 index 0000000..10d04aa --- /dev/null +++ b/modules/openai/model_deployment/variables.tf @@ -0,0 +1,46 @@ +variable "openai_resource_group_name" { + type = string + description = "Name of the resource group where the cognitive account OpenAI service is hosted." + nullable = false +} + +variable "openai_account_name" { + type = string + description = "Name of the OpenAI service." + default = "demo-account" +} + +variable "model_deployment" { + type = list(object({ + deployment_id = string + model_name = string + model_format = string + model_version = string + scale_type = string + scale_tier = optional(string) + scale_size = optional(number) + scale_family = optional(string) + scale_capacity = optional(number) + rai_policy_name = optional(string) + })) + default = [] + description = <<-DESCRIPTION + type = list(object({ + deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. + model_name = { + model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. + model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. + model_version = (Required) The version of Cognitive Services Account Deployment model. + } + scale = { + scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. + scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. + scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. + scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. + scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. + } + rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. + })) + DESCRIPTION + nullable = false +} \ No newline at end of file diff --git a/modules/openai/openai_service/README.md b/modules/openai/openai_service/README.md new file mode 100644 index 0000000..006b849 --- /dev/null +++ b/modules/openai/openai_service/README.md @@ -0,0 +1,56 @@ +# Create OpenAI service + +This sub module will create the cognitive service and the resource group for the OpenAI service. + + +## Requirements + +No requirements. + +## Providers + +| Name | Version | +|------|---------| +| [azurerm](#provider\_azurerm) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [azurerm_cognitive_account.openai](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cognitive_account) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [account\_name](#input\_account\_name) | The name of the OpenAI service. | `string` | `"demo-account"` | no | +| [custom\_subdomain\_name](#input\_custom\_subdomain\_name) | The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name) | `string` | `"demo-account"` | no | +| [customer\_managed\_key](#input\_customer\_managed\_key) | type = object({
key\_vault\_key\_id = (Required) The ID of the Key Vault Key which should be used to Encrypt the data in this OpenAI Account.
identity\_client\_id = (Optional) The Client ID of the User Assigned Identity that has access to the key. This property only needs to be specified when there're multiple identities attached to the OpenAI Account.
}) |
object({
key_vault_key_id = string
identity_client_id = optional(string)
})
| `null` | no | +| [dynamic\_throttling\_enabled](#input\_dynamic\_throttling\_enabled) | Determines whether or not dynamic throttling is enabled. If set to `true`, dynamic throttling will be enabled. If set to `false`, dynamic throttling will not be enabled. | `bool` | `null` | no | +| [fqdns](#input\_fqdns) | List of FQDNs allowed for the Cognitive Account. | `list(string)` | `null` | no | +| [identity](#input\_identity) | type = object({
type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
}) |
object({
type = string
identity_ids = optional(list(string))
})
| `null` | no | +| [local\_auth\_enabled](#input\_local\_auth\_enabled) | Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`. | `bool` | `true` | no | +| [location](#input\_location) | Azure region where resources will be hosted. | `string` | `"uksouth"` | no | +| [network\_acls](#input\_network\_acls) | type = set(object({
default\_action = (Required) The Default Action to use when no rules match from ip\_rules / virtual\_network\_rules. Possible values are `Allow` and `Deny`.
ip\_rules = (Optional) One or more IP Addresses, or CIDR Blocks which should be able to access the Cognitive Account.
virtual\_network\_rules = optional(set(object({
subnet\_id = (Required) The ID of a Subnet which should be able to access the OpenAI Account.
ignore\_missing\_vnet\_service\_endpoint = (Optional) Whether ignore missing vnet service endpoint or not. Default to `false`.
})))
})) |
set(object({
default_action = string
ip_rules = optional(set(string))
virtual_network_rules = optional(set(object({
subnet_id = string
ignore_missing_vnet_service_endpoint = optional(bool, false)
})))
}))
| `null` | no | +| [outbound\_network\_access\_restricted](#input\_outbound\_network\_access\_restricted) | Whether outbound network access is restricted for the Cognitive Account. Defaults to `false`. | `bool` | `false` | no | +| [public\_network\_access\_enabled](#input\_public\_network\_access\_enabled) | Whether public network access is allowed for the Cognitive Account. Defaults to `true`. | `bool` | `true` | no | +| [resource\_group\_name](#input\_resource\_group\_name) | Name of the resource group where the OpenAI service will be hosted. | `string` | n/a | yes | +| [sku\_name](#input\_sku\_name) | The SKU name of the OpenAI service. | `string` | `"S0"` | no | +| [storage](#input\_storage) | type = list(object({
storage\_account\_id = (Required) Full resource id of a Microsoft.Storage resource.
identity\_client\_id = (Optional) The client ID of the managed identity associated with the storage resource.
})) |
list(object({
storage_account_id = string
identity_client_id = optional(string)
}))
| `[]` | no | +| [tags](#input\_tags) | A map of key value pairs that is used to tag resources created. | `map(string)` |
{
"Author": "Marcel Lupo",
"Description": "OpenAI Cognitive service",
"GitHub": "https://github.com/Pwd9000-ML/terraform-azurerm-openai-service",
"Terraform": "True"
}
| no | + +## Outputs + +| Name | Description | +|------|-------------| +| [openai\_account\_name](#output\_openai\_account\_name) | The name of the Cognitive Service Account. | +| [openai\_endpoint](#output\_openai\_endpoint) | The endpoint used to connect to the Cognitive Service Account. | +| [openai\_primary\_key](#output\_openai\_primary\_key) | The primary access key for the Cognitive Service Account. | +| [openai\_resource\_group\_name](#output\_openai\_resource\_group\_name) | The name of the Resource Group hosting the Cognitive Service Account. | +| [openai\_secondary\_key](#output\_openai\_secondary\_key) | The secondary access key for the Cognitive Service Account. | +| [openai\_subdomain](#output\_openai\_subdomain) | The subdomain used to connect to the Cognitive Service Account. | + \ No newline at end of file diff --git a/modules/openai/openai_service/main.tf b/modules/openai/openai_service/main.tf new file mode 100644 index 0000000..ca5125e --- /dev/null +++ b/modules/openai/openai_service/main.tf @@ -0,0 +1,55 @@ +resource "azurerm_cognitive_account" "openai" { + kind = "OpenAI" + location = var.location + name = var.account_name + resource_group_name = var.resource_group_name + sku_name = var.sku_name + custom_subdomain_name = var.custom_subdomain_name + dynamic_throttling_enabled = var.dynamic_throttling_enabled + fqdns = var.fqdns + local_auth_enabled = var.local_auth_enabled + outbound_network_access_restricted = var.outbound_network_access_restricted + public_network_access_enabled = var.public_network_access_enabled + tags = var.tags + + dynamic "customer_managed_key" { + for_each = var.customer_managed_key != null ? [var.customer_managed_key] : [] + content { + key_vault_key_id = customer_managed_key.value.key_vault_key_id + identity_client_id = customer_managed_key.value.identity_client_id + } + } + + dynamic "identity" { + for_each = var.identity != null ? [var.identity] : [] + content { + type = identity.value.type + identity_ids = identity.value.identity_ids + } + } + + dynamic "network_acls" { + for_each = var.network_acls != null ? [var.network_acls] : [] + content { + default_action = network_acls.value.default_action + ip_rules = network_acls.value.ip_rules + + dynamic "virtual_network_rules" { + for_each = network_acls.value.virtual_network_rules != null ? network_acls.value.virtual_network_rules : [] + content { + subnet_id = virtual_network_rules.value.subnet_id + ignore_missing_vnet_service_endpoint = virtual_network_rules.value.ignore_missing_vnet_service_endpoint + } + } + } + } + + dynamic "storage" { + for_each = var.storage + content { + storage_account_id = storage.value.storage_account_id + identity_client_id = storage.value.identity_client_id + } + } +} + diff --git a/modules/openai/openai_service/outputs.tf b/modules/openai/openai_service/outputs.tf new file mode 100644 index 0000000..0aaca6d --- /dev/null +++ b/modules/openai/openai_service/outputs.tf @@ -0,0 +1,31 @@ +output "openai_endpoint" { + description = "The endpoint used to connect to the Cognitive Service Account." + value = azurerm_cognitive_account.openai.endpoint +} + +output "openai_primary_key" { + description = "The primary access key for the Cognitive Service Account." + sensitive = true + value = azurerm_cognitive_account.openai.primary_access_key +} + +output "openai_secondary_key" { + description = "The secondary access key for the Cognitive Service Account." + sensitive = true + value = azurerm_cognitive_account.openai.secondary_access_key +} + +output "openai_subdomain" { + description = "The subdomain used to connect to the Cognitive Service Account." + value = azurerm_cognitive_account.openai.custom_subdomain_name +} + +output "openai_account_name" { + description = "The name of the Cognitive Service Account." + value = var.account_name +} + +output "openai_resource_group_name" { + description = "The name of the Resource Group hosting the Cognitive Service Account." + value = var.resource_group_name +} \ No newline at end of file diff --git a/modules/openai/openai_service/variables.tf b/modules/openai/openai_service/variables.tf new file mode 100644 index 0000000..fc6d1a0 --- /dev/null +++ b/modules/openai/openai_service/variables.tf @@ -0,0 +1,136 @@ +variable "resource_group_name" { + type = string + description = "Name of the resource group where the OpenAI service will be hosted." + nullable = false +} + +variable "location" { + type = string + default = "uksouth" + description = "Azure region where resources will be hosted." +} + +variable "tags" { + type = map(string) + default = { + Terraform = "True" + Description = "OpenAI Cognitive service" + Author = "Marcel Lupo" + GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-service" + } + description = "A map of key value pairs that is used to tag resources created." +} + +variable "account_name" { + type = string + default = "demo-account" + description = "The name of the OpenAI service." +} + +variable "sku_name" { + type = string + default = "S0" + description = "The SKU name of the OpenAI service." +} + +variable "custom_subdomain_name" { + type = string + default = "demo-account" + description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" +} + +variable "dynamic_throttling_enabled" { + type = bool + default = null + description = "Determines whether or not dynamic throttling is enabled. If set to `true`, dynamic throttling will be enabled. If set to `false`, dynamic throttling will not be enabled." +} + +variable "fqdns" { + type = list(string) + default = null + description = "List of FQDNs allowed for the Cognitive Account." +} + +variable "local_auth_enabled" { + type = bool + default = true + description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." +} + +variable "outbound_network_access_restricted" { + type = bool + default = false + description = "Whether outbound network access is restricted for the Cognitive Account. Defaults to `false`." +} + + +variable "public_network_access_enabled" { + type = bool + default = true + description = "Whether public network access is allowed for the Cognitive Account. Defaults to `true`." +} + +variable "customer_managed_key" { + type = object({ + key_vault_key_id = string + identity_client_id = optional(string) + }) + default = null + description = <<-DESCRIPTION + type = object({ + key_vault_key_id = (Required) The ID of the Key Vault Key which should be used to Encrypt the data in this OpenAI Account. + identity_client_id = (Optional) The Client ID of the User Assigned Identity that has access to the key. This property only needs to be specified when there're multiple identities attached to the OpenAI Account. + }) + DESCRIPTION +} + +variable "identity" { + type = object({ + type = string + identity_ids = optional(list(string)) + }) + default = null + description = <<-DESCRIPTION + type = object({ + type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. + identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. + }) + DESCRIPTION +} + +variable "network_acls" { + type = set(object({ + default_action = string + ip_rules = optional(set(string)) + virtual_network_rules = optional(set(object({ + subnet_id = string + ignore_missing_vnet_service_endpoint = optional(bool, false) + }))) + })) + default = null + description = <<-DESCRIPTION + type = set(object({ + default_action = (Required) The Default Action to use when no rules match from ip_rules / virtual_network_rules. Possible values are `Allow` and `Deny`. + ip_rules = (Optional) One or more IP Addresses, or CIDR Blocks which should be able to access the Cognitive Account. + virtual_network_rules = optional(set(object({ + subnet_id = (Required) The ID of a Subnet which should be able to access the OpenAI Account. + ignore_missing_vnet_service_endpoint = (Optional) Whether ignore missing vnet service endpoint or not. Default to `false`. + }))) + })) + DESCRIPTION +} + +variable "storage" { + type = list(object({ + storage_account_id = string + identity_client_id = optional(string) + })) + default = [] + description = <<-DESCRIPTION + type = list(object({ + storage_account_id = (Required) Full resource id of a Microsoft.Storage resource. + identity_client_id = (Optional) The client ID of the managed identity associated with the storage resource. + })) + DESCRIPTION + nullable = false +} \ No newline at end of file From fdbbc2e49b99f46bbff6018838cfac35e1401fc9 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 01:23:47 +0000 Subject: [PATCH 034/163] test --- modules/cosmosdb/main.tf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/cosmosdb/main.tf b/modules/cosmosdb/main.tf index 0b2090a..531a47f 100644 --- a/modules/cosmosdb/main.tf +++ b/modules/cosmosdb/main.tf @@ -41,12 +41,12 @@ resource "azurerm_cosmosdb_account" "mongo" { } # Add "self" permission to key vault RBAC (to manange key vault secrets) -resource "azurerm_role_assignment" "kv_role_assigment" { - for_each = toset(["Key Vault Administrator"]) - role_definition_name = each.key - scope = var.openai_keyvault_id - principal_id = data.azurerm_client_config.current.object_id -} +# resource "azurerm_role_assignment" "kv_role_assigment" { +# for_each = toset(["Key Vault Administrator"]) +# role_definition_name = each.key +# scope = var.openai_keyvault_id +# principal_id = data.azurerm_client_config.current.object_id +# } ### Save CosmosDB details to Key Vault for consumption by other services (e.g. LibreChat App) resource "azurerm_key_vault_secret" "openai_cosmos_uri" { From ca445fce34568f986dd9429ba529db316659fd59 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 01:41:54 +0000 Subject: [PATCH 035/163] refactor --- keyvault.tf | 0 main.tf | 304 ++--- networking.tf | 0 outputs.tf | 126 +- resource_group.tf | 5 + tests/auto_test1/data.tf | 10 +- tests/auto_test1/locals.tf | 66 +- tests/auto_test1/main.tf | 246 ++-- tests/auto_test1/testing.auto.tfvars | 480 +++---- tests/auto_test1/variables.tf | 1556 +++++++++++------------ variables.tf | 1728 +++++++++++++------------- 11 files changed, 2267 insertions(+), 2254 deletions(-) create mode 100644 keyvault.tf create mode 100644 networking.tf create mode 100644 resource_group.tf diff --git a/keyvault.tf b/keyvault.tf new file mode 100644 index 0000000..e69de29 diff --git a/main.tf b/main.tf index eedabf2..66dfd95 100644 --- a/main.tf +++ b/main.tf @@ -1,156 +1,156 @@ -############################################### -# OpenAI Service # -############################################### -### Create OpenAI Service ### -# 1.) Create an Azure Key Vault to store the OpenAI account details. -# 2.) Create an OpenAI service account. -# 3.) Create an OpenAI language model deployments. (GPT-3, GPT-4, etc.) -# 4.) Store the OpenAI account and model details in the key vault. -module "openai" { - source = "./modules/openai" - #common - location = var.location - tags = var.tags - - #key vault (To store OpenAI Account and model details) - keyvault_resource_group_name = var.keyvault_resource_group_name - kv_config = var.kv_config - keyvault_firewall_default_action = var.keyvault_firewall_default_action - keyvault_firewall_bypass = var.keyvault_firewall_bypass - keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips - keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids - - #Create OpenAI Service - create_openai_service = var.create_openai_service - openai_resource_group_name = var.openai_resource_group_name - openai_account_name = var.openai_account_name - openai_custom_subdomain_name = var.openai_custom_subdomain_name - openai_sku_name = var.openai_sku_name - openai_local_auth_enabled = var.openai_local_auth_enabled - openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted - openai_public_network_access_enabled = var.openai_public_network_access_enabled - openai_identity = var.openai_identity - - #Create Model Deployment - create_model_deployment = var.create_model_deployment - model_deployment = var.model_deployment -} - -### Create openai networking for CosmosDB and Web App (Optional) ### -# 5.) Create networking for CosmosDB and Web App (Optional) -module "openai_networking" { - count = var.create_openai_networking ? 1 : 0 - source = "./modules/networking" - network_resource_group_name = var.network_resource_group_name - location = var.location - virtual_network_name = var.virtual_network_name - vnet_address_space = var.vnet_address_space - subnet_config = var.subnet_config - tags = var.tags -} - -### Create a CosmosDB account running MongoDB to store chat data (Optional) ### -# 6.) Create a CosmosDB account running MongoDB to store chat data (Optional). -module "openai_cosmosdb" { - count = var.create_cosmosdb ? 1 : 0 - source = "./modules/cosmosdb" - cosmosdb_name = var.cosmosdb_name - cosmosdb_resource_group_name = var.cosmosdb_resource_group_name - location = var.location - cosmosdb_offer_type = var.cosmosdb_offer_type - cosmosdb_kind = var.cosmosdb_kind - cosmosdb_automatic_failover = var.cosmosdb_automatic_failover - use_cosmosdb_free_tier = var.use_cosmosdb_free_tier - cosmosdb_consistency_level = var.cosmosdb_consistency_level - cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds - cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix - geo_locations = var.cosmosdb_geo_locations - capabilities = var.cosmosdb_capabilities - virtual_network_subnets = var.create_openai_networking == true ? toset(values(module.openai_networking[0].subnet_ids)) : var.cosmosdb_virtual_network_subnets - is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled - public_network_access_enabled = var.cosmosdb_public_network_access_enabled - openai_keyvault_id = var.create_openai_service == true ? module.openai.key_vault_id : var.openai_keyvault_id - tags = var.tags -} - -### Create the Web App ### -# # 7.) Create a Linux Web App running chatbot container. -# module "openai_app" { -# source = "./modules/librechat_app" -# app_resource_group_name = var.cosmosdb_resource_group_name -# location = var.location -# tags = var.tags - -# app_service_sku_name = var.app_service_sku_name -# app_service_name = var.app_service_name -# app_name = var.app_name -# app_title = var.app_title -# app_custom_footer = var.app_custom_footer - +# ############################################### +# # OpenAI Service # +# ############################################### +# ### Create OpenAI Service ### +# # 1.) Create an Azure Key Vault to store the OpenAI account details. +# # 2.) Create an OpenAI service account. +# # 3.) Create an OpenAI language model deployments. (GPT-3, GPT-4, etc.) +# # 4.) Store the OpenAI account and model details in the key vault. +# module "openai" { +# # # source = "./modules/openai" +# #common +# location = var.location +# tags = var.tags + +# #key vault (To store OpenAI Account and model details) +# keyvault_resource_group_name = var.keyvault_resource_group_name +# kv_config = var.kv_config +# keyvault_firewall_default_action = var.keyvault_firewall_default_action +# keyvault_firewall_bypass = var.keyvault_firewall_bypass +# keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips +# keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids + +# #Create OpenAI Service +# create_openai_service = var.create_openai_service +# openai_resource_group_name = var.openai_resource_group_name +# openai_account_name = var.openai_account_name +# openai_custom_subdomain_name = var.openai_custom_subdomain_name +# openai_sku_name = var.openai_sku_name +# openai_local_auth_enabled = var.openai_local_auth_enabled +# openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted +# openai_public_network_access_enabled = var.openai_public_network_access_enabled +# openai_identity = var.openai_identity + +# #Create Model Deployment +# create_model_deployment = var.create_model_deployment +# model_deployment = var.model_deployment +# } +# ### Create openai networking for CosmosDB and Web App (Optional) ### +# # 5.) Create networking for CosmosDB and Web App (Optional) +# module "openai_networking" { +# count = var.create_openai_networking ? 1 : 0 +# source = "./modules/networking" +# network_resource_group_name = var.network_resource_group_name +# location = var.location +# virtual_network_name = var.virtual_network_name +# vnet_address_space = var.vnet_address_space +# subnet_config = var.subnet_config +# tags = var.tags # } +# ### Create a CosmosDB account running MongoDB to store chat data (Optional) ### +# # 6.) Create a CosmosDB account running MongoDB to store chat data (Optional). +# module "openai_cosmosdb" { +# count = var.create_cosmosdb ? 1 : 0 +# source = "./modules/cosmosdb" +# cosmosdb_name = var.cosmosdb_name +# cosmosdb_resource_group_name = var.cosmosdb_resource_group_name +# location = var.location +# cosmosdb_offer_type = var.cosmosdb_offer_type +# cosmosdb_kind = var.cosmosdb_kind +# cosmosdb_automatic_failover = var.cosmosdb_automatic_failover +# use_cosmosdb_free_tier = var.use_cosmosdb_free_tier +# cosmosdb_consistency_level = var.cosmosdb_consistency_level +# cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds +# cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix +# geo_locations = var.cosmosdb_geo_locations +# capabilities = var.cosmosdb_capabilities +# virtual_network_subnets = var.create_openai_networking == true ? toset(values(module.openai_networking[0].subnet_ids)) : var.cosmosdb_virtual_network_subnets +# is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled +# public_network_access_enabled = var.cosmosdb_public_network_access_enabled +# openai_keyvault_id = var.create_openai_service == true ? module.openai.key_vault_id : var.openai_keyvault_id +# tags = var.tags +# } -# 8.) grant the container app access a the key vault (optional). - -##module "privategpt_chatbot_container_apps" { -## source = "./modules/container_app" -## -## #common -## ca_resource_group_name = var.ca_resource_group_name -## location = var.location -## tags = var.tags -## -## #log analytics workspace -## laws_name = var.laws_name -## laws_sku = var.laws_sku -## laws_retention_in_days = var.laws_retention_in_days -## -## #container app environment -## cae_name = var.cae_name -## -## #container app -## ca_name = var.ca_name -## ca_revision_mode = var.ca_revision_mode -## ca_identity = var.ca_identity -## ca_ingress = var.ca_ingress -## ca_container_config = var.ca_container_config -## ca_secrets = var.ca_secrets -## -## #key vault access -## key_vault_access_permission = var.key_vault_access_permission #Set to `null` if no Key Vault access is needed on CA identity. -## key_vault_id = var.key_vault_id #Provide the key vault id if key_vault_access_permission is not null. -## -## depends_on = [module.openai] -##} - -### Front solution with an Azure front door (optional) ### -# 9.) Deploy Azure Front Door. -# 10.) Setup a custom domain with AFD managed certificate. -# 11.) Optionally create an Azure DNS Zone or use an existing one for the custom domain. (e.g PrivateGPT.mydomain.com) -# 12.) Create a CNAME and TXT record in the custom DNS zone. -# 13.) Setup and apply AFD WAF policy for the front door with allowed IPs custom rule. (Optional) -#module "azure_frontdoor_cdn" { -# count = var.create_front_door_cdn ? 1 : 0 -# source = "./modules/cdn_frontdoor" - -#create_dns_zone -# create_dns_zone = var.create_dns_zone -# dns_resource_group_name = var.dns_resource_group_name -# custom_domain_config = var.custom_domain_config - -#deploy front door -# cdn_resource_group_name = var.cdn_resource_group_name -# cdn_profile_name = var.cdn_profile_name -# cdn_sku_name = var.cdn_sku_name -## cdn_endpoint = var.cdn_endpoint -# cdn_origin_groups = var.cdn_origin_groups -# cdn_gpt_origin = local.cdn_gpt_origin -# cdn_route = var.cdn_route - -#deploy firewall policy -# cdn_firewall_policy = var.cdn_firewall_policy -# cdn_security_policy = var.cdn_security_policy -# tags = var.tags -# depends_on = [module.privategpt_chatbot_container_apps] -#} \ No newline at end of file +# ### Create the Web App ### +# # # 7.) Create a Linux Web App running chatbot container. +# # module "openai_app" { +# # source = "./modules/librechat_app" +# # app_resource_group_name = var.cosmosdb_resource_group_name +# # location = var.location +# # tags = var.tags + +# # app_service_sku_name = var.app_service_sku_name +# # app_service_name = var.app_service_name +# # app_name = var.app_name +# # app_title = var.app_title +# # app_custom_footer = var.app_custom_footer + + +# # } + + +# # 8.) grant the container app access a the key vault (optional). + +# ##module "privategpt_chatbot_container_apps" { +# ## source = "./modules/container_app" +# ## +# ## #common +# ## ca_resource_group_name = var.ca_resource_group_name +# ## location = var.location +# ## tags = var.tags +# ## +# ## #log analytics workspace +# ## laws_name = var.laws_name +# ## laws_sku = var.laws_sku +# ## laws_retention_in_days = var.laws_retention_in_days +# ## +# ## #container app environment +# ## cae_name = var.cae_name +# ## +# ## #container app +# ## ca_name = var.ca_name +# ## ca_revision_mode = var.ca_revision_mode +# ## ca_identity = var.ca_identity +# ## ca_ingress = var.ca_ingress +# ## ca_container_config = var.ca_container_config +# ## ca_secrets = var.ca_secrets +# ## +# ## #key vault access +# ## key_vault_access_permission = var.key_vault_access_permission #Set to `null` if no Key Vault access is needed on CA identity. +# ## key_vault_id = var.key_vault_id #Provide the key vault id if key_vault_access_permission is not null. +# ## +# ## depends_on = [module.openai] +# ##} + +# ### Front solution with an Azure front door (optional) ### +# # 9.) Deploy Azure Front Door. +# # 10.) Setup a custom domain with AFD managed certificate. +# # 11.) Optionally create an Azure DNS Zone or use an existing one for the custom domain. (e.g PrivateGPT.mydomain.com) +# # 12.) Create a CNAME and TXT record in the custom DNS zone. +# # 13.) Setup and apply AFD WAF policy for the front door with allowed IPs custom rule. (Optional) +# #module "azure_frontdoor_cdn" { +# # count = var.create_front_door_cdn ? 1 : 0 +# # source = "./modules/cdn_frontdoor" + +# #create_dns_zone +# # create_dns_zone = var.create_dns_zone +# # dns_resource_group_name = var.dns_resource_group_name +# # custom_domain_config = var.custom_domain_config + +# #deploy front door +# # cdn_resource_group_name = var.cdn_resource_group_name +# # cdn_profile_name = var.cdn_profile_name +# # cdn_sku_name = var.cdn_sku_name +# ## cdn_endpoint = var.cdn_endpoint +# # cdn_origin_groups = var.cdn_origin_groups +# # cdn_gpt_origin = local.cdn_gpt_origin +# # cdn_route = var.cdn_route + +# #deploy firewall policy +# # cdn_firewall_policy = var.cdn_firewall_policy +# # cdn_security_policy = var.cdn_security_policy +# # tags = var.tags +# # depends_on = [module.privategpt_chatbot_container_apps] +# #} \ No newline at end of file diff --git a/networking.tf b/networking.tf new file mode 100644 index 0000000..e69de29 diff --git a/outputs.tf b/outputs.tf index 55e84b4..3b530d4 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,63 +1,63 @@ -################################################# -# OUTPUTS # -################################################# -## OpenAI Service Account Details -output "openai_endpoint" { - description = "The endpoint used to connect to the Cognitive Service Account." - value = module.openai.openai_endpoint -} - -output "openai_primary_key" { - description = "The primary access key for the Cognitive Service Account." - sensitive = true - value = module.openai.openai_primary_key -} - -output "openai_secondary_key" { - description = "The secondary access key for the Cognitive Service Account." - sensitive = true - value = module.openai.openai_secondary_key -} - -output "openai_subdomain" { - description = "The subdomain used to connect to the Cognitive Service Account." - value = module.openai.openai_subdomain -} - -output "openai_account_name" { - description = "The name of the Cognitive Service Account." - value = module.openai.openai_account_name -} - -## key vault -output "key_vault_id" { - description = "The ID of the Key Vault used to store OpenAI account and model details." - value = module.openai.key_vault_id -} - -output "key_vault_uri" { - description = "The URI of the Key Vault used to store OpenAI account and model details.." - value = module.openai.key_vault_uri -} - -## Container App Enviornment -#output "container_app_enviornment_id" { -# description = "The ID of the container app enviornment." -# value = module.privategpt_chatbot_container_apps.container_app_environment_id -#} - -## Container App -#output "container_app_id" { -# description = "The ID of the container app." -# value = module.privategpt_chatbot_container_apps.container_app_id -#} - -#output "latest_revision_fqdn" { -# description = "The FQDN of the Latest Revision of the Container App." -# value = module.privategpt_chatbot_container_apps.latest_revision_fqdn -#} - -#output "latest_revision_name" { -# description = "The Name of the Latest Revision of the Container App." -# value = module.privategpt_chatbot_container_apps.latest_revision_name -#} +# ################################################# +# # OUTPUTS # +# ################################################# +# ## OpenAI Service Account Details +# output "openai_endpoint" { +# description = "The endpoint used to connect to the Cognitive Service Account." +# value = module.openai.openai_endpoint +# } + +# output "openai_primary_key" { +# description = "The primary access key for the Cognitive Service Account." +# sensitive = true +# value = module.openai.openai_primary_key +# } + +# output "openai_secondary_key" { +# description = "The secondary access key for the Cognitive Service Account." +# sensitive = true +# value = module.openai.openai_secondary_key +# } + +# output "openai_subdomain" { +# description = "The subdomain used to connect to the Cognitive Service Account." +# value = module.openai.openai_subdomain +# } + +# output "openai_account_name" { +# description = "The name of the Cognitive Service Account." +# value = module.openai.openai_account_name +# } + +# ## key vault +# output "key_vault_id" { +# description = "The ID of the Key Vault used to store OpenAI account and model details." +# value = module.openai.key_vault_id +# } + +# output "key_vault_uri" { +# description = "The URI of the Key Vault used to store OpenAI account and model details.." +# value = module.openai.key_vault_uri +# } + +# ## Container App Enviornment +# #output "container_app_enviornment_id" { +# # description = "The ID of the container app enviornment." +# # value = module.privategpt_chatbot_container_apps.container_app_environment_id +# #} + +# ## Container App +# #output "container_app_id" { +# # description = "The ID of the container app." +# # value = module.privategpt_chatbot_container_apps.container_app_id +# #} + +# #output "latest_revision_fqdn" { +# # description = "The FQDN of the Latest Revision of the Container App." +# # value = module.privategpt_chatbot_container_apps.latest_revision_fqdn +# #} + +# #output "latest_revision_name" { +# # description = "The Name of the Latest Revision of the Container App." +# # value = module.privategpt_chatbot_container_apps.latest_revision_name +# #} diff --git a/resource_group.tf b/resource_group.tf new file mode 100644 index 0000000..ac3fd44 --- /dev/null +++ b/resource_group.tf @@ -0,0 +1,5 @@ +resource "resource_group" "az_openai" { + name = var.resource_group_name + location = var.location + tags = var.tags +} \ No newline at end of file diff --git a/tests/auto_test1/data.tf b/tests/auto_test1/data.tf index 7f17408..662173b 100644 --- a/tests/auto_test1/data.tf +++ b/tests/auto_test1/data.tf @@ -1,5 +1,5 @@ -data "azurerm_key_vault" "gpt" { - name = local.kv_config.name - resource_group_name = azurerm_resource_group.rg.name - depends_on = [module.private-chatgpt-openai.key_vault_id] -} \ No newline at end of file +# data "azurerm_key_vault" "gpt" { +# name = local.kv_config.name +# resource_group_name = azurerm_resource_group.rg.name +# depends_on = [module.private-chatgpt-openai.key_vault_id] +# } \ No newline at end of file diff --git a/tests/auto_test1/locals.tf b/tests/auto_test1/locals.tf index d4fe422..25a5d26 100644 --- a/tests/auto_test1/locals.tf +++ b/tests/auto_test1/locals.tf @@ -1,37 +1,37 @@ -locals { - # Container App Secrets - ca_secrets = [ - { - name = "openai-api-key" - value = "${module.private-chatgpt-openai.openai_primary_key}" - }, - { - name = "openai-api-host" - value = "${module.private-chatgpt-openai.openai_endpoint}" - } - ] +# locals { +# # Container App Secrets +# ca_secrets = [ +# { +# name = "openai-api-key" +# value = "${module.private-chatgpt-openai.openai_primary_key}" +# }, +# { +# name = "openai-api-host" +# value = "${module.private-chatgpt-openai.openai_endpoint}" +# } +# ] - # Key Vault Config (with ranodm number suffix) - kv_config = { - name = "gptkv${random_integer.number.result}" - sku = "standard" - } +# # Key Vault Config (with ranodm number suffix) +# kv_config = { +# name = "gptkv${random_integer.number.result}" +# sku = "standard" +# } - # Custom Domain Config (with ranodm number suffix) - custom_domain_config = { - zone_name = "gpt${random_integer.number.result}.com" - host_name = "PrivateGPT" - ttl = 600 - tls = [{ - certificate_type = "ManagedCertificate" - minimum_tls_version = "TLS12" - }] - } +# # Custom Domain Config (with ranodm number suffix) +# custom_domain_config = { +# zone_name = "gpt${random_integer.number.result}.com" +# host_name = "PrivateGPT" +# ttl = 600 +# tls = [{ +# certificate_type = "ManagedCertificate" +# minimum_tls_version = "TLS12" +# }] +# } - #override the variable values for the WAF name to be unique (for automated tests) - cdn_firewall_policy = merge( - var.cdn_firewall_policy, - { name = "PrivateGPTWAF${random_integer.number.result}" } - ) +# #override the variable values for the WAF name to be unique (for automated tests) +# cdn_firewall_policy = merge( +# var.cdn_firewall_policy, +# { name = "PrivateGPTWAF${random_integer.number.result}" } +# ) -} \ No newline at end of file +# } \ No newline at end of file diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index b1a011b..38372d5 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -1,122 +1,124 @@ -terraform { - backend "azurerm" {} - #backend "local" { path = "terraform-test1.tfstate" } -} - -provider "azurerm" { - features { - key_vault { - purge_soft_delete_on_destroy = true - } - } -} - -################################################# -# PRE-REQS # -################################################# -### Random integer to generate unique names -resource "random_integer" "number" { - min = 0001 - max = 9999 -} - -### Resource group to deploy the container apps private ChatGPT instance and supporting resources into -resource "azurerm_resource_group" "rg" { - name = var.resource_group_name - location = var.location - tags = var.tags -} - -################################################## -# MODULE TO TEST # -################################################## -module "private-chatgpt-openai" { - source = "../.." - - #common - location = var.location - tags = var.tags - - #keyvault (OpenAI Service Account details) - kv_config = local.kv_config - keyvault_resource_group_name = azurerm_resource_group.rg.name - keyvault_firewall_default_action = var.keyvault_firewall_default_action - keyvault_firewall_bypass = var.keyvault_firewall_bypass - keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips - keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids - - #Create OpenAI Service? - create_openai_service = var.create_openai_service - openai_resource_group_name = azurerm_resource_group.rg.name - openai_account_name = "${var.openai_account_name}${random_integer.number.result}" - openai_custom_subdomain_name = "${var.openai_custom_subdomain_name}${random_integer.number.result}" - openai_sku_name = var.openai_sku_name - openai_local_auth_enabled = var.openai_local_auth_enabled - openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted - openai_public_network_access_enabled = var.openai_public_network_access_enabled - openai_identity = var.openai_identity - - #Create Model Deployment? - create_model_deployment = var.create_model_deployment - model_deployment = var.model_deployment - - #Create networking for CosmosDB and Web App (Optional) - create_openai_networking = var.create_openai_networking - network_resource_group_name = azurerm_resource_group.rg.name - virtual_network_name = "${var.virtual_network_name}${random_integer.number.result}" - vnet_address_space = var.vnet_address_space - subnet_config = var.subnet_config - - #Create a CosmosDB account running MongoDB to store chat data (Optional) - create_cosmosdb = var.create_cosmosdb - cosmosdb_name = "${var.cosmosdb_name}${random_integer.number.result}" - cosmosdb_resource_group_name = var.cosmosdb_resource_group_name - cosmosdb_offer_type = var.cosmosdb_offer_type - cosmosdb_kind = var.cosmosdb_kind - cosmosdb_automatic_failover = var.cosmosdb_automatic_failover - use_cosmosdb_free_tier = var.use_cosmosdb_free_tier - cosmosdb_consistency_level = var.cosmosdb_consistency_level - cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds - cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix - cosmosdb_geo_locations = var.cosmosdb_geo_locations - cosmosdb_capabilities = var.cosmosdb_capabilities - cosmosdb_is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled - cosmosdb_public_network_access_enabled = var.cosmosdb_public_network_access_enabled - - #Create a solution log analytics workspace to store logs from our container apps instance - #laws_name = "${var.laws_name}${random_integer.number.result}" - #laws_sku = var.laws_sku - #laws_retention_in_days = var.laws_retention_in_days - - #Create Container App Enviornment - #cae_name = "${var.cae_name}${random_integer.number.result}" - - #Create a container app instance - #ca_resource_group_name = azurerm_resource_group.rg.name - #ca_name = "${var.ca_name}${random_integer.number.result}" - #ca_revision_mode = var.ca_revision_mode - #ca_identity = var.ca_identity - #ca_container_config = var.ca_container_config - - #Create a container app secrets - #ca_secrets = local.ca_secrets - - #key vault access - #key_vault_access_permission = var.key_vault_access_permission - #key_vault_id = data.azurerm_key_vault.gpt.id - - #Create front door CDN - create_front_door_cdn = var.create_front_door_cdn - cdn_resource_group_name = azurerm_resource_group.rg.name - create_dns_zone = var.create_dns_zone - dns_resource_group_name = azurerm_resource_group.rg.name - custom_domain_config = local.custom_domain_config - cdn_profile_name = "${var.cdn_profile_name}${random_integer.number.result}" - cdn_sku_name = var.cdn_sku_name - cdn_endpoint = var.cdn_endpoint - cdn_origin_groups = var.cdn_origin_groups - cdn_gpt_origin = var.cdn_gpt_origin - cdn_route = var.cdn_route - cdn_firewall_policy = local.cdn_firewall_policy - cdn_security_policy = var.cdn_security_policy -} \ No newline at end of file + terraform { + backend "azurerm" {} + #backend "local" { path = "terraform-test1.tfstate" } + } + + provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = true + } + } + } + +# ################################################# +# # PRE-REQS # +# ################################################# + ### Random integer to generate unique names + resource "random_integer" "number" { + min = 0001 + max = 9999 + } + +# ### Resource group to deploy the container apps private ChatGPT instance and supporting resources into +# resource "azurerm_resource_group" "rg" { +# name = var.resource_group_name +# location = var.location +# tags = var.tags +# } + +# ################################################## +# # MODULE TO TEST # +# ################################################## + module "private-chatgpt-openai" { + source = "../.." + + #common + location = var.location + tags = var.tags + resource_group_name = var.resource_group_name + } + +# #keyvault (OpenAI Service Account details) +# kv_config = local.kv_config +# keyvault_resource_group_name = azurerm_resource_group.rg.name +# keyvault_firewall_default_action = var.keyvault_firewall_default_action +# keyvault_firewall_bypass = var.keyvault_firewall_bypass +# keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips +# keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids + +# #Create OpenAI Service? +# create_openai_service = var.create_openai_service +# openai_resource_group_name = azurerm_resource_group.rg.name +# openai_account_name = "${var.openai_account_name}${random_integer.number.result}" +# openai_custom_subdomain_name = "${var.openai_custom_subdomain_name}${random_integer.number.result}" +# openai_sku_name = var.openai_sku_name +# openai_local_auth_enabled = var.openai_local_auth_enabled +# openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted +# openai_public_network_access_enabled = var.openai_public_network_access_enabled +# openai_identity = var.openai_identity + +# #Create Model Deployment? +# create_model_deployment = var.create_model_deployment +# model_deployment = var.model_deployment + +# #Create networking for CosmosDB and Web App (Optional) +# create_openai_networking = var.create_openai_networking +# network_resource_group_name = azurerm_resource_group.rg.name +# virtual_network_name = "${var.virtual_network_name}${random_integer.number.result}" +# vnet_address_space = var.vnet_address_space +# subnet_config = var.subnet_config + +# #Create a CosmosDB account running MongoDB to store chat data (Optional) +# create_cosmosdb = var.create_cosmosdb +# cosmosdb_name = "${var.cosmosdb_name}${random_integer.number.result}" +# cosmosdb_resource_group_name = var.cosmosdb_resource_group_name +# cosmosdb_offer_type = var.cosmosdb_offer_type +# cosmosdb_kind = var.cosmosdb_kind +# cosmosdb_automatic_failover = var.cosmosdb_automatic_failover +# use_cosmosdb_free_tier = var.use_cosmosdb_free_tier +# cosmosdb_consistency_level = var.cosmosdb_consistency_level +# cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds +# cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix +# cosmosdb_geo_locations = var.cosmosdb_geo_locations +# cosmosdb_capabilities = var.cosmosdb_capabilities +# cosmosdb_is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled +# cosmosdb_public_network_access_enabled = var.cosmosdb_public_network_access_enabled + +# #Create a solution log analytics workspace to store logs from our container apps instance +# #laws_name = "${var.laws_name}${random_integer.number.result}" +# #laws_sku = var.laws_sku +# #laws_retention_in_days = var.laws_retention_in_days + +# #Create Container App Enviornment +# #cae_name = "${var.cae_name}${random_integer.number.result}" + +# #Create a container app instance +# #ca_resource_group_name = azurerm_resource_group.rg.name +# #ca_name = "${var.ca_name}${random_integer.number.result}" +# #ca_revision_mode = var.ca_revision_mode +# #ca_identity = var.ca_identity +# #ca_container_config = var.ca_container_config + +# #Create a container app secrets +# #ca_secrets = local.ca_secrets + +# #key vault access +# #key_vault_access_permission = var.key_vault_access_permission +# #key_vault_id = data.azurerm_key_vault.gpt.id + +# #Create front door CDN +# create_front_door_cdn = var.create_front_door_cdn +# cdn_resource_group_name = azurerm_resource_group.rg.name +# create_dns_zone = var.create_dns_zone +# dns_resource_group_name = azurerm_resource_group.rg.name +# custom_domain_config = local.custom_domain_config +# cdn_profile_name = "${var.cdn_profile_name}${random_integer.number.result}" +# cdn_sku_name = var.cdn_sku_name +# cdn_endpoint = var.cdn_endpoint +# cdn_origin_groups = var.cdn_origin_groups +# cdn_gpt_origin = var.cdn_gpt_origin +# cdn_route = var.cdn_route +# cdn_firewall_policy = local.cdn_firewall_policy +# cdn_security_policy = var.cdn_security_policy +# } \ No newline at end of file diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 3e22dd9..29a9720 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -2,245 +2,245 @@ resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" location = "uksouth" tags = { - Terraform = "True" - Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" + Terraform = "True" + Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" + Author = "Marcel Lupo" + GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" } -### OpenAI Service Module Inputs ### -keyvault_firewall_default_action = "Deny" -keyvault_firewall_bypass = "AzureServices" -keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs -keyvault_firewall_virtual_network_subnet_ids = [] - -### Create OpenAI Service ### -create_openai_service = true -openai_account_name = "gptopenai" -openai_custom_subdomain_name = "gptopenai" -openai_sku_name = "S0" -openai_local_auth_enabled = true -openai_outbound_network_access_restricted = false -openai_public_network_access_enabled = true -openai_identity = { - type = "SystemAssigned" -} - -### Create Model deployment ### -create_model_deployment = true -model_deployment = [ - { - deployment_id = "gpt-4" - model_name = "gpt-4" - model_format = "OpenAI" - model_version = "1106-Preview" - scale_type = "Standard" - scale_capacity = 10 # 34K == Roughly 204 RPM (Requests per minute) - } -] - -### networking ### -create_openai_networking = true -network_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -virtual_network_name = "openai-vnet" -vnet_address_space = ["10.4.0.0/16"] -subnet_config = [ - { - subnet_name = "app-cosmos-sub" - subnet_address_space = ["10.4.0.0/24"] - service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] - private_endpoint_network_policies_enabled = false - private_link_service_network_policies_enabled = false - subnets_delegation_settings = { - app-service-plan = [ - { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] - } - ] - } - } -] - -### cosmosdb ### -create_cosmosdb = true -cosmosdb_name = "gptcosmos" -cosmosdb_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -cosmosdb_offer_type = "Standard" -cosmosdb_kind = "MongoDB" -cosmosdb_automatic_failover = false -use_cosmosdb_free_tier = true -cosmosdb_consistency_level = "BoundedStaleness" -cosmosdb_max_interval_in_seconds = 10 -cosmosdb_max_staleness_prefix = 200 -cosmosdb_geo_locations = [ - { - location = "uksouth" - failover_priority = 0 - } -] -cosmosdb_capabilities = ["EnableMongo", "MongoDBv3.4"] -cosmosdb_is_virtual_network_filter_enabled = true -cosmosdb_public_network_access_enabled = true - -### log analytics workspace for container apps ### -#laws_name = "gptlaws" -#laws_sku = "PerGB2018" -#laws_retention_in_days = 30 - -### Container App Enviornment ### -#cae_name = "gptcae" - -### Container App ### -#ca_name = "gptca" -#ca_revision_mode = "Single" -#ca_identity = { -# type = "SystemAssigned" -#} -#ca_ingress = { -# allow_insecure_connections = false -# external_enabled = true -# target_port = 3000 -# transport = "auto" -# traffic_weight = { -# latest_revision = true -# percentage = 100 -# } -#} -#ca_container_config = { -# name = "gpt-chatbot-ui" -# image = "ghcr.io/pwd9000-ml/chatbot-ui:main" -# cpu = 2 -# memory = "4Gi" -# min_replicas = 0 -# max_replicas = 5 - -## Environment Variables (Required)## -# env = [ -# { -# name = "OPENAI_API_KEY" -# secret_name = "openai-api-key" #see locals.tf (Can also be added from key vault created by module, or existing key) -# }, -# { -# name = "OPENAI_API_HOST" -# secret_name = "openai-api-host" #see locals.tf (Can also be added from key vault created by module, or existing host/endpoint) -# }, -# { -# name = "OPENAI_API_TYPE" -# value = "azure" -# }, -# { -# name = "AZURE_DEPLOYMENT_ID" #see model_deployment variable (deployment_id) -# value = "gpt432k" -# }, -# { -# name = "DEFAULT_MODEL" #see model_deployment variable (model_name) -# value = "gpt-4-32k" -# } -# ] -#} - -### key vault access ### -#key_vault_access_permission = ["Key Vault Secrets User"] # set to `null` to ignore permission grant to a key vault -#key_vault_id = "kv-to-grant-permission-to" (See `data.tf`) Only required if `var.key_vault_access_permission` not `null`) - -### CDN - Front Door ### -create_front_door_cdn = true -create_dns_zone = true #Set to false if you already have a DNS zone, remember to add this DNS zone to your domain registrar - -# CDN PROFILE -cdn_profile_name = "cdnfd" -cdn_sku_name = "Standard_AzureFrontDoor" - -# CDN ENDPOINTS -cdn_endpoint = { - name = "PrivateGPT" - enabled = true -} - -# CDN ORIGIN GROUPS -cdn_origin_groups = [ - { - name = "PrivateGPTOriginGroup" - session_affinity_enabled = false - restore_traffic_time_to_healed_or_new_endpoint_in_minutes = 5 - health_probe = { - interval_in_seconds = 100 - path = "/" - protocol = "Https" - request_type = "HEAD" - } - load_balancing = { - additional_latency_in_milliseconds = 50 - sample_size = 4 - successful_samples_required = 3 - } - } -] - -# GPT CDN ORIGIN -cdn_gpt_origin = { - name = "PrivateGPTOrigin" - origin_group_name = "PrivateGPTOriginGroup" - enabled = true - certificate_name_check_enabled = true - http_port = 80 - https_port = 443 - priority = 1 - weight = 1000 -} - -# CDN ROUTE RULES -cdn_route = { - name = "PrivateGPTRoute" - enabled = true - forwarding_protocol = "HttpsOnly" - https_redirect_enabled = true - patterns_to_match = ["/*"] - supported_protocols = ["Http", "Https"] - cdn_frontdoor_origin_path = null - cdn_frontdoor_rule_set_ids = null - link_to_default_domain = false - cache = { - query_string_caching_behavior = "IgnoreQueryString" - query_strings = [] - compression_enabled = false - content_types_to_compress = [] - } -} - -# CDN WAF Config -cdn_firewall_policy = { - create_waf = true - name = "PrivateGPTWAF" - enabled = true - mode = "Prevention" - custom_block_response_body = "WW91ciByZXF1ZXN0IGhhcyBiZWVuIGJsb2NrZWQu" - custom_block_response_status_code = 403 - custom_rules = [ - { - name = "AllowedIPs" - action = "Block" - enabled = true - priority = 100 - type = "MatchRule" - rate_limit_duration_in_minutes = 1 - rate_limit_threshold = 10 - match_conditions = [ - { - negation_condition = true - match_values = ["86.106.76.66"] #Allowd IPs (Replace with your IP Allow list) - match_variable = "RemoteAddr" - operator = "IPMatch" - transforms = [] - } - ] - } - ] -} - -# CDN Security Policy Config -cdn_security_policy = { - name = "PrivateGPTSecurityPolicy" - patterns_to_match = ["/*"] -} \ No newline at end of file +# ### OpenAI Service Module Inputs ### +# keyvault_firewall_default_action = "Deny" +# keyvault_firewall_bypass = "AzureServices" +# keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs +# keyvault_firewall_virtual_network_subnet_ids = [] + +# ### Create OpenAI Service ### +# create_openai_service = true +# openai_account_name = "gptopenai" +# openai_custom_subdomain_name = "gptopenai" +# openai_sku_name = "S0" +# openai_local_auth_enabled = true +# openai_outbound_network_access_restricted = false +# openai_public_network_access_enabled = true +# openai_identity = { +# type = "SystemAssigned" +# } + +# ### Create Model deployment ### +# create_model_deployment = true +# model_deployment = [ +# { +# deployment_id = "gpt-4" +# model_name = "gpt-4" +# model_format = "OpenAI" +# model_version = "1106-Preview" +# scale_type = "Standard" +# scale_capacity = 10 # 34K == Roughly 204 RPM (Requests per minute) +# } +# ] + +# ### networking ### +# create_openai_networking = true +# network_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" +# virtual_network_name = "openai-vnet" +# vnet_address_space = ["10.4.0.0/16"] +# subnet_config = [ +# { +# subnet_name = "app-cosmos-sub" +# subnet_address_space = ["10.4.0.0/24"] +# service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] +# private_endpoint_network_policies_enabled = false +# private_link_service_network_policies_enabled = false +# subnets_delegation_settings = { +# app-service-plan = [ +# { +# name = "Microsoft.Web/serverFarms" +# actions = ["Microsoft.Network/virtualNetworks/subnets/action"] +# } +# ] +# } +# } +# ] + +# ### cosmosdb ### +# create_cosmosdb = true +# cosmosdb_name = "gptcosmos" +# cosmosdb_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" +# cosmosdb_offer_type = "Standard" +# cosmosdb_kind = "MongoDB" +# cosmosdb_automatic_failover = false +# use_cosmosdb_free_tier = true +# cosmosdb_consistency_level = "BoundedStaleness" +# cosmosdb_max_interval_in_seconds = 10 +# cosmosdb_max_staleness_prefix = 200 +# cosmosdb_geo_locations = [ +# { +# location = "uksouth" +# failover_priority = 0 +# } +# ] +# cosmosdb_capabilities = ["EnableMongo", "MongoDBv3.4"] +# cosmosdb_is_virtual_network_filter_enabled = true +# cosmosdb_public_network_access_enabled = true + +# ### log analytics workspace for container apps ### +# #laws_name = "gptlaws" +# #laws_sku = "PerGB2018" +# #laws_retention_in_days = 30 + +# ### Container App Enviornment ### +# #cae_name = "gptcae" + +# ### Container App ### +# #ca_name = "gptca" +# #ca_revision_mode = "Single" +# #ca_identity = { +# # type = "SystemAssigned" +# #} +# #ca_ingress = { +# # allow_insecure_connections = false +# # external_enabled = true +# # target_port = 3000 +# # transport = "auto" +# # traffic_weight = { +# # latest_revision = true +# # percentage = 100 +# # } +# #} +# #ca_container_config = { +# # name = "gpt-chatbot-ui" +# # image = "ghcr.io/pwd9000-ml/chatbot-ui:main" +# # cpu = 2 +# # memory = "4Gi" +# # min_replicas = 0 +# # max_replicas = 5 + +# ## Environment Variables (Required)## +# # env = [ +# # { +# # name = "OPENAI_API_KEY" +# # secret_name = "openai-api-key" #see locals.tf (Can also be added from key vault created by module, or existing key) +# # }, +# # { +# # name = "OPENAI_API_HOST" +# # secret_name = "openai-api-host" #see locals.tf (Can also be added from key vault created by module, or existing host/endpoint) +# # }, +# # { +# # name = "OPENAI_API_TYPE" +# # value = "azure" +# # }, +# # { +# # name = "AZURE_DEPLOYMENT_ID" #see model_deployment variable (deployment_id) +# # value = "gpt432k" +# # }, +# # { +# # name = "DEFAULT_MODEL" #see model_deployment variable (model_name) +# # value = "gpt-4-32k" +# # } +# # ] +# #} + +# ### key vault access ### +# #key_vault_access_permission = ["Key Vault Secrets User"] # set to `null` to ignore permission grant to a key vault +# #key_vault_id = "kv-to-grant-permission-to" (See `data.tf`) Only required if `var.key_vault_access_permission` not `null`) + +# ### CDN - Front Door ### +# create_front_door_cdn = true +# create_dns_zone = true #Set to false if you already have a DNS zone, remember to add this DNS zone to your domain registrar + +# # CDN PROFILE +# cdn_profile_name = "cdnfd" +# cdn_sku_name = "Standard_AzureFrontDoor" + +# # CDN ENDPOINTS +# cdn_endpoint = { +# name = "PrivateGPT" +# enabled = true +# } + +# # CDN ORIGIN GROUPS +# cdn_origin_groups = [ +# { +# name = "PrivateGPTOriginGroup" +# session_affinity_enabled = false +# restore_traffic_time_to_healed_or_new_endpoint_in_minutes = 5 +# health_probe = { +# interval_in_seconds = 100 +# path = "/" +# protocol = "Https" +# request_type = "HEAD" +# } +# load_balancing = { +# additional_latency_in_milliseconds = 50 +# sample_size = 4 +# successful_samples_required = 3 +# } +# } +# ] + +# # GPT CDN ORIGIN +# cdn_gpt_origin = { +# name = "PrivateGPTOrigin" +# origin_group_name = "PrivateGPTOriginGroup" +# enabled = true +# certificate_name_check_enabled = true +# http_port = 80 +# https_port = 443 +# priority = 1 +# weight = 1000 +# } + +# # CDN ROUTE RULES +# cdn_route = { +# name = "PrivateGPTRoute" +# enabled = true +# forwarding_protocol = "HttpsOnly" +# https_redirect_enabled = true +# patterns_to_match = ["/*"] +# supported_protocols = ["Http", "Https"] +# cdn_frontdoor_origin_path = null +# cdn_frontdoor_rule_set_ids = null +# link_to_default_domain = false +# cache = { +# query_string_caching_behavior = "IgnoreQueryString" +# query_strings = [] +# compression_enabled = false +# content_types_to_compress = [] +# } +# } + +# # CDN WAF Config +# cdn_firewall_policy = { +# create_waf = true +# name = "PrivateGPTWAF" +# enabled = true +# mode = "Prevention" +# custom_block_response_body = "WW91ciByZXF1ZXN0IGhhcyBiZWVuIGJsb2NrZWQu" +# custom_block_response_status_code = 403 +# custom_rules = [ +# { +# name = "AllowedIPs" +# action = "Block" +# enabled = true +# priority = 100 +# type = "MatchRule" +# rate_limit_duration_in_minutes = 1 +# rate_limit_threshold = 10 +# match_conditions = [ +# { +# negation_condition = true +# match_values = ["86.106.76.66"] #Allowd IPs (Replace with your IP Allow list) +# match_variable = "RemoteAddr" +# operator = "IPMatch" +# transforms = [] +# } +# ] +# } +# ] +# } + +# # CDN Security Policy Config +# cdn_security_policy = { +# name = "PrivateGPTSecurityPolicy" +# patterns_to_match = ["/*"] +# } \ No newline at end of file diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 2786bb6..e5f016d 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -1,780 +1,780 @@ ### common ### variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." -} - -variable "tags" { - type = map(string) - default = {} - description = "A map of key value pairs that is used to tag resources created." -} - -### solution resource group ### -variable "resource_group_name" { - type = string - description = "Name of the resource group to create where the cognitive account OpenAI service is hosted." - nullable = false -} - -### OpenAI service Module params ### -### key vault ### -variable "kv_config" { - type = object({ - name = string - sku = string - }) - default = { - name = "gptkv" - sku = "standard" - } - description = "Key Vault configuration object to create azure key vault to store openai account details." - nullable = false -} - -variable "keyvault_firewall_default_action" { - type = string - default = "Deny" - description = "Default action for keyvault firewall rules." -} - -variable "keyvault_firewall_bypass" { - type = string - default = "AzureServices" - description = "List of keyvault firewall rules to bypass." -} - -variable "keyvault_firewall_allowed_ips" { - type = list(string) - default = [] - description = "value of keyvault firewall allowed ip rules." -} - -variable "keyvault_firewall_virtual_network_subnet_ids" { - type = list(string) - default = [] - description = "value of keyvault firewall allowed virtual network subnet ids." -} - -### openai service ### -variable "create_openai_service" { - type = bool - description = "Create the OpenAI service." - default = false -} - -variable "openai_account_name" { - type = string - description = "Name of the OpenAI service." - default = "demo-account" -} - -variable "openai_custom_subdomain_name" { - type = string - description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" - default = "demo-account" -} - -variable "openai_sku_name" { - type = string - description = "SKU name of the OpenAI service." - default = "S0" -} - -variable "openai_local_auth_enabled" { - type = bool - default = true - description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." -} - -variable "openai_outbound_network_access_restricted" { - type = bool - default = false - description = "Whether or not outbound network access is restricted. Defaults to `false`." -} - -variable "openai_public_network_access_enabled" { - type = bool - default = true - description = "Whether or not public network access is enabled. Defaults to `false`." -} - -variable "openai_identity" { - type = object({ - type = string - }) - default = { - type = "SystemAssigned" - } - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -### model deployment ### -variable "create_model_deployment" { - type = bool - description = "Create the model deployment." - default = false -} - -variable "model_deployment" { - type = list(object({ - deployment_id = string - model_name = string - model_format = string - model_version = string - scale_type = string - scale_tier = optional(string) - scale_size = optional(number) - scale_family = optional(string) - scale_capacity = optional(number) - rai_policy_name = optional(string) - })) - default = [] - description = <<-DESCRIPTION - type = list(object({ - deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. - model_name = { - model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. - model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. - model_version = (Required) The version of Cognitive Services Account Deployment model. - } - scale = { - scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. - scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. - scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. - scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. - scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. - } - rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. - })) - DESCRIPTION - nullable = false -} - -### networking ### -variable "create_openai_networking" { - description = "Create a virtual network and subnet/s for networked services" - type = bool - default = false -} - -variable "network_resource_group_name" { - type = string - description = "Name of the resource group to where networking resources will be hosted." - nullable = false -} - -variable "virtual_network_name" { - type = string - default = null - description = "Name of the virtual network where resources are attached." -} - -variable "vnet_address_space" { - type = list(string) - default = null - description = "value of the address space for the virtual network." -} - -variable "subnet_config" { - type = list(object({ - subnet_name = string - subnet_address_space = list(string) - service_endpoints = list(string) - private_endpoint_network_policies_enabled = bool - private_link_service_network_policies_enabled = bool - subnets_delegation_settings = map(list(object({ - name = string - actions = list(string) - }))) - })) - default = [ - { - subnet_name = "app-cosmos-sub" - subnet_address_space = ["10.4.0.0/24"] - service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] - private_endpoint_network_policies_enabled = false - private_link_service_network_policies_enabled = false - subnets_delegation_settings = { - app-service-plan = [ - { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] - } - ] - } - } - ] - description = "A list of subnet configuration objects to create subnets in the virtual network." -} - -### cosmosdb ### -variable "create_cosmosdb" { - description = "Create a CosmosDB account running MongoDB to store chat data." - type = bool - default = false -} - -variable "cosmosdb_name" { - description = "The name of the Cosmos DB account" - type = string - default = "openaicosmosdb" -} - -variable "cosmosdb_resource_group_name" { - description = "The name of the resource group in which to create the Cosmos DB account" - type = string - nullable = false -} - -variable "cosmosdb_offer_type" { - description = "The offer type to use for the Cosmos DB account" - type = string - default = "Standard" -} - -variable "cosmosdb_kind" { - description = "The kind of Cosmos DB to create" - type = string - default = "MongoDB" -} - -variable "cosmosdb_automatic_failover" { - description = "Whether to enable automatic failover for the Cosmos DB account" - type = bool - default = false -} - -variable "use_cosmosdb_free_tier" { - description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." - type = bool - default = true -} - -variable "cosmosdb_consistency_level" { - description = "The consistency level of the Cosmos DB account" - type = string - default = "BoundedStaleness" -} - -variable "cosmosdb_max_interval_in_seconds" { - description = "The maximum staleness interval in seconds for the Cosmos DB account" - type = number - default = 10 -} - -variable "cosmosdb_max_staleness_prefix" { - description = "The maximum staleness prefix for the Cosmos DB account" - type = number - default = 200 -} - -variable "cosmosdb_geo_locations" { - description = "The geo-locations for the Cosmos DB account" - type = list(object({ - location = string - failover_priority = number - })) - default = [ - { - location = "uksouth" - failover_priority = 0 - } - ] -} - -variable "cosmosdb_capabilities" { - description = "The capabilities for the Cosmos DB account" - type = list(string) - default = ["EnableMongo", "MongoDBv3.4"] -} - -variable "cosmosdb_virtual_network_subnets" { - description = "The virtual network subnets to associate with the Cosmos DB account" - type = list(string) - default = null -} - -variable "cosmosdb_is_virtual_network_filter_enabled" { - description = "Whether to enable virtual network filtering for the Cosmos DB account" - type = bool - default = true -} - -variable "cosmosdb_public_network_access_enabled" { - description = "Whether to enable public network access for the Cosmos DB account" - type = bool - default = true -} - -### log analytics workspace ### -#variable "laws_name" { -# type = string -# description = "Name of the log analytics workspace to create." -# default = "gptlaws" -#} - -#variable "laws_sku" { -# type = string -# description = "SKU of the log analytics workspace to create." -# default = "PerGB2018" -#} - -#variable "laws_retention_in_days" { -# type = number -# description = "Retention in days of the log analytics workspace to create." -# default = 30 -#} - -### container app environment ### -#variable "cae_name" { -# type = string -# description = "Name of the container app environment to create." -# default = "gptcae" -#} - -### container app ### -#variable "ca_name" { -# type = string -# description = "Name of the container app to create." -# default = "gptca" -#} - -#variable "ca_revision_mode" { -# type = string -# description = "Revision mode of the container app to create." -# default = "Single" -#} - -#variable "ca_identity" { -# type = object({ -# type = string -# identity_ids = optional(list(string)) -# }) -# default = null -# description = <<-DESCRIPTION -# type = object({ -# type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. -# identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. -# }) -# DESCRIPTION -#} - -#variable "ca_ingress" { -# type = object({ -# allow_insecure_connections = optional(bool) -# external_enabled = optional(bool) -# target_port = number -# transport = optional(string) -# traffic_weight = optional(object({ -# percentage = number -# latest_revision = optional(bool) -# })) -# }) -# default = { -# allow_insecure_connections = false -# external_enabled = true -# target_port = 3000 -# transport = "auto" -# traffic_weight = { -# percentage = 100 -# latest_revision = true -# } -# } -# description = <<-DESCRIPTION -# type = object({ -# allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. -# external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. -# target_port = (Required) The port to use for the container app. Defaults to `3000`. -# transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. -# type = object({ -# percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. -# latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. -# }) -# DESCRIPTION -#} - -#variable "ca_container_config" { -# type = object({ -# name = string -# image = string -# cpu = number -# memory = string -# min_replicas = optional(number, 0) -# max_replicas = optional(number, 10) -# env = optional(list(object({ -# name = string -# secret_name = optional(string) -# value = optional(string) -# }))) -# }) -# default = { -# name = "gpt-chatbot-ui" -# image = "ghcr.io/pwd9000-ml/chatbot-ui:main" -# cpu = 1 -# memory = "2Gi" -# min_replicas = 0 -# max_replicas = 10 -# env = [] -# } -# description = <<-DESCRIPTION -# type = object({ -# name = (Required) The name of the container. -# image = (Required) The name of the container image. -# cpu = (Required) The number of CPU cores to allocate to the container. -# memory = (Required) The amount of memory to allocate to the container in GB. -# min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. -# max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. -# env = list(object({ -# name = (Required) The name of the environment variable. -# secret_name = (Optional) The name of the secret to use for the environment variable. -# value = (Optional) The value of the environment variable. -# })) -# }) -# DESCRIPTION -#} - -#variable "ca_secrets" { -# type = list(object({ -# name = string -# value = string -# })) -# default = [ -# { -# name = "secret1" -# value = "value1" -# }, -# { -# name = "secret2" -# value = "value2" -# } -# ] -# description = <<-DESCRIPTION -# type = list(object({ -# name = (Required) The name of the secret. -# value = (Required) The value of the secret. -# })) -# DESCRIPTION -#} - -# Key Vault Access # -### key vault access ### -#variable "key_vault_access_permission" { -# type = list(string) -# default = ["Key Vault Secrets User"] -# description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -#} - -#variable "key_vault_id" { -# type = string -# default = "" -# description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." -#} - -# DNS zone # -variable "create_dns_zone" { - description = "Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided." - type = bool - default = false -} - -variable "custom_domain_config" { - type = object({ - zone_name = string - host_name = string - ttl = optional(number, 3600) - tls = optional(list(object({ - certificate_type = optional(string, "ManagedCertificate") - minimum_tls_version = optional(string, "TLS12") - }))) - }) - default = { - zone_name = "mydomain7335.com" - host_name = "PrivateGPT" - ttl = 3600 - tls = [{ - certificate_type = "ManagedCertificate" - minimum_tls_version = "TLS12" - }] - } - description = <<-DESCRIPTION - type = object({ - zone_name = (Required) The name of the DNS zone to create the CNAME and TXT record in for the CDN Front Door Custom domain. - host_name = (Required) The host name of the DNS record to create. (e.g. Contoso) - ttl = (Optional) The TTL of the DNS record to create. (e.g. 3600) - tls = optional(list(object({ - certificate_type = (Optional) Defines the source of the SSL certificate. Possible values include 'CustomerCertificate' and 'ManagedCertificate'. Defaults to 'ManagedCertificate'. - NOTE: It may take up to 15 minutes for the Front Door Service to validate the state and Domain ownership of the Custom Domain. - minimum_tls_version = (Optional) TLS protocol version that will be used for Https. Possible values include TLS10 and TLS12. Defaults to TLS12. - })))) - }) - DESCRIPTION -} - -# Front Door # -variable "create_front_door_cdn" { - description = "Create a Front Door profile." - type = bool - default = false -} - -variable "cdn_profile_name" { - description = "The name of the CDN profile to create." - type = string - default = "example-cdn-profile" -} - -variable "cdn_sku_name" { - description = "Specifies the SKU for the CDN Front Door Profile. Possible values include 'Standard_AzureFrontDoor' and 'Premium_AzureFrontDoor'." - type = string - default = "Standard_AzureFrontDoor" -} - -variable "cdn_endpoint" { - type = object({ - name = string - enabled = optional(bool, true) - }) - default = { - name = "PrivateGPT" - enabled = true - } - description = < Date: Wed, 17 Jan 2024 01:42:58 +0000 Subject: [PATCH 036/163] lint --- tests/auto_test1/main.tf | 50 ++++++++++++++-------------- tests/auto_test1/testing.auto.tfvars | 8 ++--- tests/auto_test1/variables.tf | 30 ++++++++--------- variables.tf | 30 ++++++++--------- 4 files changed, 59 insertions(+), 59 deletions(-) diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 38372d5..a1c607e 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -1,24 +1,24 @@ - terraform { - backend "azurerm" {} - #backend "local" { path = "terraform-test1.tfstate" } - } - - provider "azurerm" { - features { - key_vault { - purge_soft_delete_on_destroy = true - } - } - } +terraform { + backend "azurerm" {} + #backend "local" { path = "terraform-test1.tfstate" } +} + +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = true + } + } +} # ################################################# # # PRE-REQS # # ################################################# - ### Random integer to generate unique names - resource "random_integer" "number" { - min = 0001 - max = 9999 - } +### Random integer to generate unique names +resource "random_integer" "number" { + min = 0001 + max = 9999 +} # ### Resource group to deploy the container apps private ChatGPT instance and supporting resources into # resource "azurerm_resource_group" "rg" { @@ -30,14 +30,14 @@ # ################################################## # # MODULE TO TEST # # ################################################## - module "private-chatgpt-openai" { - source = "../.." - - #common - location = var.location - tags = var.tags - resource_group_name = var.resource_group_name - } +module "private-chatgpt-openai" { + source = "../.." + + #common + location = var.location + tags = var.tags + resource_group_name = var.resource_group_name +} # #keyvault (OpenAI Service Account details) # kv_config = local.kv_config diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 29a9720..8c2803c 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -2,10 +2,10 @@ resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" location = "uksouth" tags = { - Terraform = "True" - Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" + Terraform = "True" + Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" + Author = "Marcel Lupo" + GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" } # ### OpenAI Service Module Inputs ### diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index e5f016d..696b372 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -1,22 +1,22 @@ ### common ### variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." + type = string + default = "uksouth" + description = "Azure region where resources will be hosted." } - variable "tags" { - type = map(string) - default = {} - description = "A map of key value pairs that is used to tag resources created." - } - - ### solution resource group ### - variable "resource_group_name" { - type = string - description = "Name of the resource group to create where the cognitive account OpenAI service is hosted." - nullable = false - } +variable "tags" { + type = map(string) + default = {} + description = "A map of key value pairs that is used to tag resources created." +} + +### solution resource group ### +variable "resource_group_name" { + type = string + description = "Name of the resource group to create where the cognitive account OpenAI service is hosted." + nullable = false +} # ### OpenAI service Module params ### # ### key vault ### diff --git a/variables.tf b/variables.tf index 606ea6d..7e57d61 100644 --- a/variables.tf +++ b/variables.tf @@ -2,23 +2,23 @@ # # VARIABLES # # ################################################## ### common ### - variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." - } +variable "location" { + type = string + default = "uksouth" + description = "Azure region where resources will be hosted." +} variable "tags" { - type = map(string) - default = {} - description = "A map of key value pairs that is used to tag resources created." - } - - variable "resource_group_name" { - type = string - description = "Name of the resource group to create the OpenAI service / or where an existing service is hosted." - nullable = false - } + type = map(string) + default = {} + description = "A map of key value pairs that is used to tag resources created." +} + +variable "resource_group_name" { + type = string + description = "Name of the resource group to create the OpenAI service / or where an existing service is hosted." + nullable = false +} # #################################### # ### OpenAI service Module params ### From 169084014059286360005169a5b6c35ae4dbdc73 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 01:47:02 +0000 Subject: [PATCH 037/163] test --- resource_group.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resource_group.tf b/resource_group.tf index ac3fd44..5411882 100644 --- a/resource_group.tf +++ b/resource_group.tf @@ -1,4 +1,4 @@ -resource "resource_group" "az_openai" { +resource "azurerm_resource_group" "az_openai" { name = var.resource_group_name location = var.location tags = var.tags From 382cbb2343a78a0b14a7a97023e17924b68bb9d6 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 02:17:08 +0000 Subject: [PATCH 038/163] refactor --- resource_group.tf => 01_resource_group.tf | 2 +- 02_networking.tf | 34 +++++++++ 03_keyvault.tf | 20 +++++ data.tf | 2 + keyvault.tf | 0 locals.tf | 12 +++ networking.tf | 0 tests/auto_test1/main.tf | 13 +++- tests/auto_test1/testing.auto.tfvars | 51 +++++++------ tests/auto_test1/variables.tf | 92 +++++++++++++++++++++-- variables.tf | 84 ++++++++++++++++++++- 11 files changed, 276 insertions(+), 34 deletions(-) rename resource_group.tf => 01_resource_group.tf (62%) create mode 100644 02_networking.tf create mode 100644 03_keyvault.tf delete mode 100644 keyvault.tf delete mode 100644 networking.tf diff --git a/resource_group.tf b/01_resource_group.tf similarity index 62% rename from resource_group.tf rename to 01_resource_group.tf index 5411882..b22c4e3 100644 --- a/resource_group.tf +++ b/01_resource_group.tf @@ -1,4 +1,4 @@ -resource "azurerm_resource_group" "az_openai" { +resource "azurerm_resource_group" "az_openai_rg" { name = var.resource_group_name location = var.location tags = var.tags diff --git a/02_networking.tf b/02_networking.tf new file mode 100644 index 0000000..a810626 --- /dev/null +++ b/02_networking.tf @@ -0,0 +1,34 @@ +resource "azurerm_virtual_network" "az_openai_vnet" { + name = var.virtual_network_name + location = var.location + resource_group_name = var.resource_group_name + address_space = var.vnet_address_space + tags = var.tags +} + +# Azure Virtual Network Subnets +resource "azurerm_subnet" "az_openai_subnet" { + for_each = { for each in var.subnet_config : each.subnet_name => each } + + resource_group_name = var.resource_group_name + virtual_network_name = azurerm_virtual_network.az_openai_vnet.name + name = each.value.subnet_name + address_prefixes = each.value.subnet_address_space + service_endpoints = each.value.service_endpoints + private_link_service_network_policies_enabled = each.value.private_link_service_network_policies_enabled + private_endpoint_network_policies_enabled = each.value.private_endpoint_network_policies_enabled + + dynamic "delegation" { + for_each = each.value.subnets_delegation_settings + content { + name = delegation.key + dynamic "service_delegation" { + for_each = toset(delegation.value) + content { + name = service_delegation.value.name + actions = service_delegation.value.actions + } + } + } + } +} diff --git a/03_keyvault.tf b/03_keyvault.tf new file mode 100644 index 0000000..49a3ed5 --- /dev/null +++ b/03_keyvault.tf @@ -0,0 +1,20 @@ +# # Key Vault - Create Key Vault to save cognitive account details +# resource "azurerm_key_vault" "az_openai_kv" { +# resource_group_name = var.resource_group_name +# location = var.location +# #values from variable kv_config object +# name = lower(var.kv_name) +# sku_name = var.kv_sku +# enable_rbac_authorization = true +# tenant_id = data.azurerm_client_config.current.tenant_id +# dynamic "network_acls" { +# for_each = local.kv_net_rules +# content { +# default_action = network_acls.value.default_action +# bypass = network_acls.value.bypass +# ip_rules = network_acls.value.ip_rules +# virtual_network_subnet_ids = network_acls.value.virtual_network_subnet_ids +# } +# } +# tags = var.tags +# } \ No newline at end of file diff --git a/data.tf b/data.tf index c0b96c0..95f33d6 100644 --- a/data.tf +++ b/data.tf @@ -1,6 +1,8 @@ ################################################## # DATA # ################################################## +data "azurerm_client_config" "current" {} + # Data sources to get Subnet ID/s for CosmosDB and App Service # Usage in Module example: subnet_id = data.azurerm_subnet.subnet["app-cosmos-sub"].id # data "azurerm_subnet" "subnet" { diff --git a/keyvault.tf b/keyvault.tf deleted file mode 100644 index e69de29..0000000 diff --git a/locals.tf b/locals.tf index 8324906..68cb30f 100644 --- a/locals.tf +++ b/locals.tf @@ -1,3 +1,15 @@ +locals { + ## locals config for key vault firewall rules ## + kv_net_rules = [ + { + default_action = var.keyvault_firewall_default_action + bypass = var.keyvault_firewall_bypass + ip_rules = var.keyvault_firewall_allowed_ips + virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids + } + ] +} + #locals { # cdn_gpt_origin = merge( # var.cdn_gpt_origin, diff --git a/networking.tf b/networking.tf deleted file mode 100644 index e69de29..0000000 diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index a1c607e..28ac539 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -33,10 +33,21 @@ resource "random_integer" "number" { module "private-chatgpt-openai" { source = "../.." - #common + #01 common + RG location = var.location tags = var.tags resource_group_name = var.resource_group_name + + #02 networking + virtual_network_name = var.virtual_network_name + vnet_address_space = var.vnet_address_space + subnet_config = var.subnet_config + + + #keyvault (Solution Secrets) + #kv_name = var.kv_name + #kv_sku = var.kv_sku_name + } # #keyvault (OpenAI Service Account details) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 8c2803c..754fb6a 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -1,4 +1,4 @@ -### Common Variables ### +### 01 Common Variables + RG ### resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" location = "uksouth" tags = { @@ -8,11 +8,32 @@ tags = { GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" } -# ### OpenAI Service Module Inputs ### -# keyvault_firewall_default_action = "Deny" -# keyvault_firewall_bypass = "AzureServices" -# keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs -# keyvault_firewall_virtual_network_subnet_ids = [] +### 02 networking ### +virtual_network_name = "openai-vnet-9000" +vnet_address_space = ["10.4.0.0/24"] +subnet_config = [ + { + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] + } + } +] + +### Solution KeyVault ### +keyvault_firewall_default_action = "Deny" +keyvault_firewall_bypass = "AzureServices" +keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs +keyvault_firewall_virtual_network_subnet_ids = [] # ### Create OpenAI Service ### # create_openai_service = true @@ -44,23 +65,7 @@ tags = { # network_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" # virtual_network_name = "openai-vnet" # vnet_address_space = ["10.4.0.0/16"] -# subnet_config = [ -# { -# subnet_name = "app-cosmos-sub" -# subnet_address_space = ["10.4.0.0/24"] -# service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] -# private_endpoint_network_policies_enabled = false -# private_link_service_network_policies_enabled = false -# subnets_delegation_settings = { -# app-service-plan = [ -# { -# name = "Microsoft.Web/serverFarms" -# actions = ["Microsoft.Network/virtualNetworks/subnets/action"] -# } -# ] -# } -# } -# ] + # ### cosmosdb ### # create_cosmosdb = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 696b372..4167b25 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -1,4 +1,4 @@ -### common ### +### 01 common + RG ### variable "location" { type = string default = "uksouth" @@ -11,13 +11,95 @@ variable "tags" { description = "A map of key value pairs that is used to tag resources created." } -### solution resource group ### variable "resource_group_name" { type = string description = "Name of the resource group to create where the cognitive account OpenAI service is hosted." nullable = false } +### 02 networking ### +variable "virtual_network_name" { + type = string + default = "openai-vnet-9000" + description = "Name of the virtual network where resources are attached." +} + +variable "vnet_address_space" { + type = list(string) + default = ["10.4.0.0/24"] + description = "value of the address space for the virtual network." +} + +variable "subnet_config" { + type = list(object({ + subnet_name = string + subnet_address_space = list(string) + service_endpoints = list(string) + private_endpoint_network_policies_enabled = bool + private_link_service_network_policies_enabled = bool + subnets_delegation_settings = map(list(object({ + name = string + actions = list(string) + }))) + })) + default = [ + { + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] + } + } + ] + description = "A list of subnet configuration objects to create subnets in the virtual network." +} + + +### key vault ### +variable "kv_name" { + type = string + description = "Name of the Key Vault to create (solution secrets)." + default = "openaikv9000" +} + +variable "kv_sku" { + type = string + description = "SKU of the Key Vault to create." + default = "standard" +} + +variable "keyvault_firewall_default_action" { + type = string + default = "Deny" + description = "Default action for key vault firewall rules." +} + +variable "keyvault_firewall_bypass" { + type = string + default = "AzureServices" + description = "List of key vault firewall rules to bypass." +} + +variable "keyvault_firewall_allowed_ips" { + type = list(string) + default = [] + description = "value of key vault firewall allowed ip rules." +} + +variable "keyvault_firewall_virtual_network_subnet_ids" { + type = list(string) + default = [] + description = "value of key vault firewall allowed virtual network subnet ids." +} + # ### OpenAI service Module params ### # ### key vault ### # variable "kv_config" { @@ -176,12 +258,6 @@ variable "resource_group_name" { # description = "Name of the virtual network where resources are attached." # } -# variable "vnet_address_space" { -# type = list(string) -# default = null -# description = "value of the address space for the virtual network." -# } - # variable "subnet_config" { # type = list(object({ # subnet_name = string diff --git a/variables.tf b/variables.tf index 7e57d61..6070d6f 100644 --- a/variables.tf +++ b/variables.tf @@ -1,7 +1,7 @@ # ################################################## # # VARIABLES # # ################################################## -### common ### +### 01 common + Resource Group ### variable "location" { type = string default = "uksouth" @@ -20,6 +20,88 @@ variable "resource_group_name" { nullable = false } +### 02 Networking ### +variable "virtual_network_name" { + type = string + default = "openai-vnet-9000" + description = "Name of the virtual network where resources are attached." +} + +variable "vnet_address_space" { + type = list(string) + default = ["10.4.0.0/24"] + description = "value of the address space for the virtual network." +} + +variable "subnet_config" { + type = list(object({ + subnet_name = string + subnet_address_space = list(string) + service_endpoints = list(string) + private_endpoint_network_policies_enabled = bool + private_link_service_network_policies_enabled = bool + subnets_delegation_settings = map(list(object({ + name = string + actions = list(string) + }))) + })) + default = [ + { + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] + } + } + ] + description = "A list of subnet configuration objects to create subnets in the virtual network." +} + +### key vault ### +variable "kv_name" { + type = string + description = "Name of the Key Vault to create (solution secrets)." + default = "openaikv9000" +} + +variable "kv_sku" { + type = string + description = "SKU of the Key Vault to create." + default = "standard" +} + +variable "keyvault_firewall_default_action" { + type = string + default = "Deny" + description = "Default action for key vault firewall rules." +} + +variable "keyvault_firewall_bypass" { + type = string + default = "AzureServices" + description = "List of key vault firewall rules to bypass." +} + +variable "keyvault_firewall_allowed_ips" { + type = list(string) + default = [] + description = "value of key vault firewall allowed ip rules." +} + +variable "keyvault_firewall_virtual_network_subnet_ids" { + type = list(string) + default = [] + description = "value of key vault firewall allowed virtual network subnet ids." +} + # #################################### # ### OpenAI service Module params ### # #################################### From ddd95433f727ff79af9067e9974a3cce4d95a07f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 02:28:31 +0000 Subject: [PATCH 039/163] up --- 02_networking.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/02_networking.tf b/02_networking.tf index a810626..db8a3e6 100644 --- a/02_networking.tf +++ b/02_networking.tf @@ -10,7 +10,7 @@ resource "azurerm_virtual_network" "az_openai_vnet" { resource "azurerm_subnet" "az_openai_subnet" { for_each = { for each in var.subnet_config : each.subnet_name => each } - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.az_openai_rg.name virtual_network_name = azurerm_virtual_network.az_openai_vnet.name name = each.value.subnet_name address_prefixes = each.value.subnet_address_space From d3be512eee07ab369178f4d516ca916be3ab3d03 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 02:35:43 +0000 Subject: [PATCH 040/163] update module --- 03_keyvault.tf | 48 ++++++++++++++++------------ tests/auto_test1/main.tf | 7 ++-- tests/auto_test1/testing.auto.tfvars | 4 ++- tests/auto_test1/variables.tf | 3 +- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/03_keyvault.tf b/03_keyvault.tf index 49a3ed5..1526062 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -1,20 +1,28 @@ -# # Key Vault - Create Key Vault to save cognitive account details -# resource "azurerm_key_vault" "az_openai_kv" { -# resource_group_name = var.resource_group_name -# location = var.location -# #values from variable kv_config object -# name = lower(var.kv_name) -# sku_name = var.kv_sku -# enable_rbac_authorization = true -# tenant_id = data.azurerm_client_config.current.tenant_id -# dynamic "network_acls" { -# for_each = local.kv_net_rules -# content { -# default_action = network_acls.value.default_action -# bypass = network_acls.value.bypass -# ip_rules = network_acls.value.ip_rules -# virtual_network_subnet_ids = network_acls.value.virtual_network_subnet_ids -# } -# } -# tags = var.tags -# } \ No newline at end of file +# Key Vault - Create Key Vault to save cognitive account details +resource "azurerm_key_vault" "az_openai_kv" { + resource_group_name = var.resource_group_name + location = var.location + #values from variable kv_config object + name = lower(var.kv_name) + sku_name = var.kv_sku + enable_rbac_authorization = true + tenant_id = data.azurerm_client_config.current.tenant_id + dynamic "network_acls" { + for_each = local.kv_net_rules + content { + default_action = network_acls.value.default_action + bypass = network_acls.value.bypass + ip_rules = network_acls.value.ip_rules + virtual_network_subnet_ids = network_acls.value.virtual_network_subnet_ids + } + } + tags = var.tags +} + +# Add "self" permission to key vault RBAC (to manange key vault secrets) +resource "azurerm_role_assignment" "kv_role_assigment" { + for_each = toset(["Key Vault Administrator"]) + role_definition_name = each.key + scope = azurerm_key_vault.az_openai_kv.id + principal_id = data.azurerm_client_config.current.object_id +} \ No newline at end of file diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 28ac539..0020e70 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -43,10 +43,9 @@ module "private-chatgpt-openai" { vnet_address_space = var.vnet_address_space subnet_config = var.subnet_config - - #keyvault (Solution Secrets) - #kv_name = var.kv_name - #kv_sku = var.kv_sku_name + #03 keyvault (Solution Secrets) + kv_name = var.kv_name + kv_sku = var.kv_sku } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 754fb6a..9d3e44f 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -29,7 +29,9 @@ subnet_config = [ } ] -### Solution KeyVault ### +### 03 KeyVault ### +kv_name = "openaikv9000" +kv_sku = "standard" keyvault_firewall_default_action = "Deny" keyvault_firewall_bypass = "AzureServices" keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 4167b25..b2bd3bd 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -62,8 +62,7 @@ variable "subnet_config" { description = "A list of subnet configuration objects to create subnets in the virtual network." } - -### key vault ### +### 03 key vault ### variable "kv_name" { type = string description = "Name of the Key Vault to create (solution secrets)." From 19a2a3272d8a45bb85d6ee60d4313596e334d36f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 02:59:59 +0000 Subject: [PATCH 041/163] fix --- 02_networking.tf | 3 +- 04_az_openai.tf | 75 +++++++++++++++++++++++++++++++++++ tests/auto_test1/data.tf | 7 ++++ tests/auto_test1/main.tf | 16 +++----- tests/auto_test1/variables.tf | 8 ++-- variables.tf | 16 +++++--- 6 files changed, 103 insertions(+), 22 deletions(-) create mode 100644 04_az_openai.tf diff --git a/02_networking.tf b/02_networking.tf index db8a3e6..58ab951 100644 --- a/02_networking.tf +++ b/02_networking.tf @@ -8,8 +8,7 @@ resource "azurerm_virtual_network" "az_openai_vnet" { # Azure Virtual Network Subnets resource "azurerm_subnet" "az_openai_subnet" { - for_each = { for each in var.subnet_config : each.subnet_name => each } - + for_each = var.subnet_config resource_group_name = azurerm_resource_group.az_openai_rg.name virtual_network_name = azurerm_virtual_network.az_openai_vnet.name name = each.value.subnet_name diff --git a/04_az_openai.tf b/04_az_openai.tf new file mode 100644 index 0000000..407b15e --- /dev/null +++ b/04_az_openai.tf @@ -0,0 +1,75 @@ +# resource "azurerm_cognitive_account" "az_openai" { +# kind = "OpenAI" +# location = var.location +# name = var.account_name +# resource_group_name = var.resource_group_name +# sku_name = var.sku_name +# custom_subdomain_name = var.custom_subdomain_name +# dynamic_throttling_enabled = var.dynamic_throttling_enabled +# fqdns = var.fqdns +# local_auth_enabled = var.local_auth_enabled +# outbound_network_access_restricted = var.outbound_network_access_restricted +# public_network_access_enabled = var.public_network_access_enabled +# tags = var.tags + +# dynamic "customer_managed_key" { +# for_each = var.customer_managed_key != null ? [var.customer_managed_key] : [] +# content { +# key_vault_key_id = customer_managed_key.value.key_vault_key_id +# identity_client_id = customer_managed_key.value.identity_client_id +# } +# } + +# dynamic "identity" { +# for_each = var.identity != null ? [var.identity] : [] +# content { +# type = identity.value.type +# identity_ids = identity.value.identity_ids +# } +# } + +# dynamic "network_acls" { +# for_each = var.network_acls != null ? [var.network_acls] : [] +# content { +# default_action = network_acls.value.default_action +# ip_rules = network_acls.value.ip_rules + +# dynamic "virtual_network_rules" { +# for_each = network_acls.value.virtual_network_rules != null ? network_acls.value.virtual_network_rules : [] +# content { +# subnet_id = virtual_network_rules.value.subnet_id +# ignore_missing_vnet_service_endpoint = virtual_network_rules.value.ignore_missing_vnet_service_endpoint +# } +# } +# } +# } + +# dynamic "storage" { +# for_each = var.storage +# content { +# storage_account_id = storage.value.storage_account_id +# identity_client_id = storage.value.identity_client_id +# } +# } +# } + +# resource "azurerm_cognitive_deployment" "az_openai_models" { +# for_each = { for each in var.model_deployment : each.deployment_id => each } + +# cognitive_account_id = data.azurerm_cognitive_account.openai.id +# name = each.value.deployment_id +# rai_policy_name = each.value.rai_policy_name + +# model { +# format = each.value.model_format +# name = each.value.model_name +# version = each.value.model_version +# } +# scale { +# type = each.value.scale_type +# tier = each.value.scale_tier +# size = each.value.scale_size +# family = each.value.scale_family +# capacity = each.value.scale_capacity +# } +# } \ No newline at end of file diff --git a/tests/auto_test1/data.tf b/tests/auto_test1/data.tf index 662173b..4f5423b 100644 --- a/tests/auto_test1/data.tf +++ b/tests/auto_test1/data.tf @@ -1,3 +1,10 @@ +data "azurerm_subnet" "subnet" { + for_each = var.subnet_config + name = each.value.subnet_name + virtual_network_name = var.virtual_network_name + resource_group_name = var.resource_group_name +} + # data "azurerm_key_vault" "gpt" { # name = local.kv_config.name # resource_group_name = azurerm_resource_group.rg.name diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 0020e70..08afc2f 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -44,18 +44,14 @@ module "private-chatgpt-openai" { subnet_config = var.subnet_config #03 keyvault (Solution Secrets) - kv_name = var.kv_name - kv_sku = var.kv_sku - + kv_name = var.kv_name + kv_sku = var.kv_sku + keyvault_firewall_default_action = var.keyvault_firewall_default_action + keyvault_firewall_bypass = var.keyvault_firewall_bypass + keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips + keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids } -# #keyvault (OpenAI Service Account details) -# kv_config = local.kv_config -# keyvault_resource_group_name = azurerm_resource_group.rg.name -# keyvault_firewall_default_action = var.keyvault_firewall_default_action -# keyvault_firewall_bypass = var.keyvault_firewall_bypass -# keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips -# keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids # #Create OpenAI Service? # create_openai_service = var.create_openai_service diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index b2bd3bd..634796b 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -31,7 +31,7 @@ variable "vnet_address_space" { } variable "subnet_config" { - type = list(object({ + type = object({ subnet_name = string subnet_address_space = list(string) service_endpoints = list(string) @@ -41,9 +41,8 @@ variable "subnet_config" { name = string actions = list(string) }))) - })) - default = [ - { + }) + default = { subnet_name = "app-cosmos-sub" subnet_address_space = ["10.4.0.0/24"] service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] @@ -58,7 +57,6 @@ variable "subnet_config" { ] } } - ] description = "A list of subnet configuration objects to create subnets in the virtual network." } diff --git a/variables.tf b/variables.tf index 6070d6f..02c0196 100644 --- a/variables.tf +++ b/variables.tf @@ -34,7 +34,7 @@ variable "vnet_address_space" { } variable "subnet_config" { - type = list(object({ + type = object({ subnet_name = string subnet_address_space = list(string) service_endpoints = list(string) @@ -44,8 +44,8 @@ variable "subnet_config" { name = string actions = list(string) }))) - })) - default = [ + }) + default = { subnet_name = "app-cosmos-sub" subnet_address_space = ["10.4.0.0/24"] @@ -61,11 +61,10 @@ variable "subnet_config" { ] } } - ] description = "A list of subnet configuration objects to create subnets in the virtual network." } -### key vault ### +### 03 key vault ### variable "kv_name" { type = string description = "Name of the Key Vault to create (solution secrets)." @@ -102,6 +101,13 @@ variable "keyvault_firewall_virtual_network_subnet_ids" { description = "value of key vault firewall allowed virtual network subnet ids." } +### 04 OpenAI service ### +variable "account_name" { + type = string + default = "az-openai-account" + description = "The name of the OpenAI service." +} + # #################################### # ### OpenAI service Module params ### # #################################### From 7091feb211efff1f5b76ed818bb02b942b9195cf Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:01:56 +0000 Subject: [PATCH 042/163] update --- tests/auto_test1/testing.auto.tfvars | 4 +--- variables.tf | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 9d3e44f..aff3e90 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -11,8 +11,7 @@ tags = { ### 02 networking ### virtual_network_name = "openai-vnet-9000" vnet_address_space = ["10.4.0.0/24"] -subnet_config = [ - { +subnet_config = { subnet_name = "app-cosmos-sub" subnet_address_space = ["10.4.0.0/24"] service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] @@ -27,7 +26,6 @@ subnet_config = [ ] } } -] ### 03 KeyVault ### kv_name = "openaikv9000" diff --git a/variables.tf b/variables.tf index 02c0196..4cf5414 100644 --- a/variables.tf +++ b/variables.tf @@ -45,8 +45,7 @@ variable "subnet_config" { actions = list(string) }))) }) - default = - { + default = { subnet_name = "app-cosmos-sub" subnet_address_space = ["10.4.0.0/24"] service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] From 6ff2a17c00ec2bd5ef8222bb381de045d84554a4 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:03:38 +0000 Subject: [PATCH 043/163] lint --- tests/auto_test1/testing.auto.tfvars | 26 +++++++++++++------------- tests/auto_test1/variables.tf | 28 ++++++++++++++-------------- variables.tf | 26 +++++++++++++------------- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index aff3e90..8c94b1f 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -12,20 +12,20 @@ tags = { virtual_network_name = "openai-vnet-9000" vnet_address_space = ["10.4.0.0/24"] subnet_config = { - subnet_name = "app-cosmos-sub" - subnet_address_space = ["10.4.0.0/24"] - service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] - private_endpoint_network_policies_enabled = false - private_link_service_network_policies_enabled = false - subnets_delegation_settings = { - app-service-plan = [ - { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] - } - ] - } + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] } +} ### 03 KeyVault ### kv_name = "openaikv9000" diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 634796b..8f18633 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -42,21 +42,21 @@ variable "subnet_config" { actions = list(string) }))) }) - default = { - subnet_name = "app-cosmos-sub" - subnet_address_space = ["10.4.0.0/24"] - service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] - private_endpoint_network_policies_enabled = false - private_link_service_network_policies_enabled = false - subnets_delegation_settings = { - app-service-plan = [ - { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] - } - ] - } + default = { + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] } + } description = "A list of subnet configuration objects to create subnets in the virtual network." } diff --git a/variables.tf b/variables.tf index 4cf5414..895540e 100644 --- a/variables.tf +++ b/variables.tf @@ -46,20 +46,20 @@ variable "subnet_config" { }))) }) default = { - subnet_name = "app-cosmos-sub" - subnet_address_space = ["10.4.0.0/24"] - service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] - private_endpoint_network_policies_enabled = false - private_link_service_network_policies_enabled = false - subnets_delegation_settings = { - app-service-plan = [ - { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] - } - ] - } + subnet_name = "app-cosmos-sub" + subnet_address_space = ["10.4.0.0/24"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + private_endpoint_network_policies_enabled = false + private_link_service_network_policies_enabled = false + subnets_delegation_settings = { + app-service-plan = [ + { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } + ] } + } description = "A list of subnet configuration objects to create subnets in the virtual network." } From 5cae418b12dd4364c646594ef1403c0873290052 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:08:31 +0000 Subject: [PATCH 044/163] up --- 02_networking.tf | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/02_networking.tf b/02_networking.tf index 58ab951..af76c06 100644 --- a/02_networking.tf +++ b/02_networking.tf @@ -8,17 +8,17 @@ resource "azurerm_virtual_network" "az_openai_vnet" { # Azure Virtual Network Subnets resource "azurerm_subnet" "az_openai_subnet" { - for_each = var.subnet_config + #for_each = var.subnet_config resource_group_name = azurerm_resource_group.az_openai_rg.name virtual_network_name = azurerm_virtual_network.az_openai_vnet.name - name = each.value.subnet_name - address_prefixes = each.value.subnet_address_space - service_endpoints = each.value.service_endpoints - private_link_service_network_policies_enabled = each.value.private_link_service_network_policies_enabled - private_endpoint_network_policies_enabled = each.value.private_endpoint_network_policies_enabled + name = var.subnet_config.subnet_name + address_prefixes = var.subnet_config.subnet_address_space + service_endpoints = var.subnet_config.service_endpoints + private_link_service_network_policies_enabled = var.subnet_config.private_link_service_network_policies_enabled + private_endpoint_network_policies_enabled = var.subnet_config.private_endpoint_network_policies_enabled dynamic "delegation" { - for_each = each.value.subnets_delegation_settings + for_each = var.subnet_config.subnets_delegation_settings content { name = delegation.key dynamic "service_delegation" { From ed01777219b147cf27650bbdd99f4388a5259894 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:11:46 +0000 Subject: [PATCH 045/163] fix --- tests/auto_test1/data.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/data.tf b/tests/auto_test1/data.tf index 4f5423b..4024dad 100644 --- a/tests/auto_test1/data.tf +++ b/tests/auto_test1/data.tf @@ -1,6 +1,6 @@ data "azurerm_subnet" "subnet" { for_each = var.subnet_config - name = each.value.subnet_name + name = var.subnet_config.subnet_name virtual_network_name = var.virtual_network_name resource_group_name = var.resource_group_name } From aed73ef9bbdb0c8a4af5038a3d2f0d0feb63feb8 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:16:44 +0000 Subject: [PATCH 046/163] test --- tests/auto_test1/data.tf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/auto_test1/data.tf b/tests/auto_test1/data.tf index 4024dad..84be4ad 100644 --- a/tests/auto_test1/data.tf +++ b/tests/auto_test1/data.tf @@ -1,9 +1,9 @@ -data "azurerm_subnet" "subnet" { - for_each = var.subnet_config - name = var.subnet_config.subnet_name - virtual_network_name = var.virtual_network_name - resource_group_name = var.resource_group_name -} +#data "azurerm_subnet" "subnet" { +# for_each = var.subnet_config +# name = var.subnet_config.subnet_name +# virtual_network_name = var.virtual_network_name +# resource_group_name = var.resource_group_name +#} # data "azurerm_key_vault" "gpt" { # name = local.kv_config.name From 54d7effb2dadc97e434c028841e87ddf0fcb5ef4 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:21:13 +0000 Subject: [PATCH 047/163] test --- 02_networking.tf | 1 - locals.tf | 2 +- variables.tf | 10 +++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/02_networking.tf b/02_networking.tf index af76c06..0fed516 100644 --- a/02_networking.tf +++ b/02_networking.tf @@ -8,7 +8,6 @@ resource "azurerm_virtual_network" "az_openai_vnet" { # Azure Virtual Network Subnets resource "azurerm_subnet" "az_openai_subnet" { - #for_each = var.subnet_config resource_group_name = azurerm_resource_group.az_openai_rg.name virtual_network_name = azurerm_virtual_network.az_openai_vnet.name name = var.subnet_config.subnet_name diff --git a/locals.tf b/locals.tf index 68cb30f..6ab6dea 100644 --- a/locals.tf +++ b/locals.tf @@ -5,7 +5,7 @@ locals { default_action = var.keyvault_firewall_default_action bypass = var.keyvault_firewall_bypass ip_rules = var.keyvault_firewall_allowed_ips - virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids + virtual_network_subnet_ids = azurerm_subnet.az_openai_subnet.*.id } ] } diff --git a/variables.tf b/variables.tf index 895540e..08f4198 100644 --- a/variables.tf +++ b/variables.tf @@ -94,11 +94,11 @@ variable "keyvault_firewall_allowed_ips" { description = "value of key vault firewall allowed ip rules." } -variable "keyvault_firewall_virtual_network_subnet_ids" { - type = list(string) - default = [] - description = "value of key vault firewall allowed virtual network subnet ids." -} +# variable "keyvault_firewall_virtual_network_subnet_ids" { +# type = list(string) +# default = [] +# description = "value of key vault firewall allowed virtual network subnet ids." +# } ### 04 OpenAI service ### variable "account_name" { From 6327155cf63c7caefde2073898fd3f5022969766 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:33:29 +0000 Subject: [PATCH 048/163] update --- 03_keyvault.tf | 4 +-- locals.tf | 22 ++++++++-------- tests/auto_test1/data.tf | 11 ++++---- tests/auto_test1/locals.tf | 13 ++++++++++ tests/auto_test1/main.tf | 9 +++---- tests/auto_test1/testing.auto.tfvars | 9 +++---- tests/auto_test1/variables.tf | 6 ----- variables.tf | 38 ++++++++++++---------------- 8 files changed, 53 insertions(+), 59 deletions(-) diff --git a/03_keyvault.tf b/03_keyvault.tf index 1526062..0afe5ad 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -8,7 +8,7 @@ resource "azurerm_key_vault" "az_openai_kv" { enable_rbac_authorization = true tenant_id = data.azurerm_client_config.current.tenant_id dynamic "network_acls" { - for_each = local.kv_net_rules + for_each = var.kv_net_rules content { default_action = network_acls.value.default_action bypass = network_acls.value.bypass @@ -23,6 +23,6 @@ resource "azurerm_key_vault" "az_openai_kv" { resource "azurerm_role_assignment" "kv_role_assigment" { for_each = toset(["Key Vault Administrator"]) role_definition_name = each.key - scope = azurerm_key_vault.az_openai_kv.id + scope = azurerm_key_vault.openai_kv.id principal_id = data.azurerm_client_config.current.object_id } \ No newline at end of file diff --git a/locals.tf b/locals.tf index 6ab6dea..3053536 100644 --- a/locals.tf +++ b/locals.tf @@ -1,14 +1,14 @@ -locals { - ## locals config for key vault firewall rules ## - kv_net_rules = [ - { - default_action = var.keyvault_firewall_default_action - bypass = var.keyvault_firewall_bypass - ip_rules = var.keyvault_firewall_allowed_ips - virtual_network_subnet_ids = azurerm_subnet.az_openai_subnet.*.id - } - ] -} +# locals { +# ## locals config for key vault firewall rules ## +# kv_net_rules = [ +# { +# default_action = var.keyvault_firewall_default_action +# bypass = var.keyvault_firewall_bypass +# ip_rules = var.keyvault_firewall_allowed_ips +# virtual_network_subnet_ids = azurerm_subnet.az_openai_subnet.*.id +# } +# ] +# } #locals { # cdn_gpt_origin = merge( diff --git a/tests/auto_test1/data.tf b/tests/auto_test1/data.tf index 84be4ad..7437546 100644 --- a/tests/auto_test1/data.tf +++ b/tests/auto_test1/data.tf @@ -1,9 +1,8 @@ -#data "azurerm_subnet" "subnet" { -# for_each = var.subnet_config -# name = var.subnet_config.subnet_name -# virtual_network_name = var.virtual_network_name -# resource_group_name = var.resource_group_name -#} +data "azurerm_subnet" "openai_subnet" { + name = var.subnet_config.subnet_name + virtual_network_name = var.virtual_network_name + resource_group_name = var.resource_group_name +} # data "azurerm_key_vault" "gpt" { # name = local.kv_config.name diff --git a/tests/auto_test1/locals.tf b/tests/auto_test1/locals.tf index 25a5d26..1bdcce9 100644 --- a/tests/auto_test1/locals.tf +++ b/tests/auto_test1/locals.tf @@ -1,3 +1,16 @@ +locals { + ## locals config for key vault firewall rules ## + kv_net_rules = [ + { + default_action = var.keyvault_firewall_default_action + bypass = var.keyvault_firewall_bypass + ip_rules = var.keyvault_firewall_allowed_ips + virtual_network_subnet_ids = data.azurerm_subnet.openai_subnet.id + } + ] +} + + # locals { # # Container App Secrets # ca_secrets = [ diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 08afc2f..afce2bc 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -44,12 +44,9 @@ module "private-chatgpt-openai" { subnet_config = var.subnet_config #03 keyvault (Solution Secrets) - kv_name = var.kv_name - kv_sku = var.kv_sku - keyvault_firewall_default_action = var.keyvault_firewall_default_action - keyvault_firewall_bypass = var.keyvault_firewall_bypass - keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips - keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids + kv_name = var.kv_name + kv_sku = var.kv_sku + kv_net_rules = local.kv_net_rules } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 8c94b1f..7b13f17 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -28,12 +28,9 @@ subnet_config = { } ### 03 KeyVault ### -kv_name = "openaikv9000" -kv_sku = "standard" -keyvault_firewall_default_action = "Deny" -keyvault_firewall_bypass = "AzureServices" -keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs -keyvault_firewall_virtual_network_subnet_ids = [] +kv_name = "openaikv9000" +kv_sku = "standard" + # ### Create OpenAI Service ### # create_openai_service = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 8f18633..0676921 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -91,12 +91,6 @@ variable "keyvault_firewall_allowed_ips" { description = "value of key vault firewall allowed ip rules." } -variable "keyvault_firewall_virtual_network_subnet_ids" { - type = list(string) - default = [] - description = "value of key vault firewall allowed virtual network subnet ids." -} - # ### OpenAI service Module params ### # ### key vault ### # variable "kv_config" { diff --git a/variables.tf b/variables.tf index 08f4198..b211739 100644 --- a/variables.tf +++ b/variables.tf @@ -76,30 +76,24 @@ variable "kv_sku" { default = "standard" } -variable "keyvault_firewall_default_action" { - type = string - default = "Deny" - description = "Default action for key vault firewall rules." -} - -variable "keyvault_firewall_bypass" { - type = string - default = "AzureServices" - description = "List of key vault firewall rules to bypass." -} - -variable "keyvault_firewall_allowed_ips" { - type = list(string) - default = [] - description = "value of key vault firewall allowed ip rules." +variable "kv_net_rules" { + type = list(object({ + default_action = string + bypass = string + ip_rules = list(string) + virtual_network_subnet_ids = list(string) + })) + default = [ + { + default_action = "Deny" + bypass = "AzureServices" + ip_rules = [] + virtual_network_subnet_ids = [] + } + ] + description = "A list of Key Vault network acl configuration objects to create Key Vault firewall rules." } -# variable "keyvault_firewall_virtual_network_subnet_ids" { -# type = list(string) -# default = [] -# description = "value of key vault firewall allowed virtual network subnet ids." -# } - ### 04 OpenAI service ### variable "account_name" { type = string From 88a65d6470c87ad503188adce2c014f1d07e2828 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:37:34 +0000 Subject: [PATCH 049/163] up --- 03_keyvault.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/03_keyvault.tf b/03_keyvault.tf index 0afe5ad..452fc10 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -19,6 +19,7 @@ resource "azurerm_key_vault" "az_openai_kv" { tags = var.tags } + # Add "self" permission to key vault RBAC (to manange key vault secrets) resource "azurerm_role_assignment" "kv_role_assigment" { for_each = toset(["Key Vault Administrator"]) From c9c52ce0f23a28a3f61f498a389883a63afd4759 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:39:11 +0000 Subject: [PATCH 050/163] fix --- 03_keyvault.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/03_keyvault.tf b/03_keyvault.tf index 452fc10..755b6ab 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -24,6 +24,6 @@ resource "azurerm_key_vault" "az_openai_kv" { resource "azurerm_role_assignment" "kv_role_assigment" { for_each = toset(["Key Vault Administrator"]) role_definition_name = each.key - scope = azurerm_key_vault.openai_kv.id + scope = azurerm_key_vault.az_openai_kv.id principal_id = data.azurerm_client_config.current.object_id } \ No newline at end of file From 87e4b09d33b3b651abbc7528d5e8b028355ff625 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:40:57 +0000 Subject: [PATCH 051/163] up --- tests/auto_test1/locals.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/locals.tf b/tests/auto_test1/locals.tf index 1bdcce9..bcf0719 100644 --- a/tests/auto_test1/locals.tf +++ b/tests/auto_test1/locals.tf @@ -5,7 +5,7 @@ locals { default_action = var.keyvault_firewall_default_action bypass = var.keyvault_firewall_bypass ip_rules = var.keyvault_firewall_allowed_ips - virtual_network_subnet_ids = data.azurerm_subnet.openai_subnet.id + virtual_network_subnet_ids = [data.azurerm_subnet.openai_subnet.id] } ] } From 1b3005449c60e00186ae277fd230b22b45120514 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:43:13 +0000 Subject: [PATCH 052/163] up --- 03_keyvault.tf | 1 + tests/auto_test1/testing.auto.tfvars | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/03_keyvault.tf b/03_keyvault.tf index 755b6ab..fcf0364 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -17,6 +17,7 @@ resource "azurerm_key_vault" "az_openai_kv" { } } tags = var.tags + depends_on = [ azurerm_subnet.az_openai_subnet] } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 7b13f17..8bd4827 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -14,7 +14,7 @@ vnet_address_space = ["10.4.0.0/24"] subnet_config = { subnet_name = "app-cosmos-sub" subnet_address_space = ["10.4.0.0/24"] - service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] + service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web", "Microsoft.KeyVault"] private_endpoint_network_policies_enabled = false private_link_service_network_policies_enabled = false subnets_delegation_settings = { From 6e2373b279208a777206353d6de6734ae29daf16 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 03:59:58 +0000 Subject: [PATCH 053/163] test --- 03_keyvault.tf | 4 ++-- data.tf | 7 +++++++ tests/auto_test1/data.tf | 10 +++++----- tests/auto_test1/locals.tf | 2 +- tests/auto_test1/testing.auto.tfvars | 9 ++++++--- tests/auto_test1/variables.tf | 6 ++++++ 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/03_keyvault.tf b/03_keyvault.tf index fcf0364..3b56fb4 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -16,8 +16,8 @@ resource "azurerm_key_vault" "az_openai_kv" { virtual_network_subnet_ids = network_acls.value.virtual_network_subnet_ids } } - tags = var.tags - depends_on = [ azurerm_subnet.az_openai_subnet] + tags = var.tags + depends_on = [azurerm_subnet.az_openai_subnet] } diff --git a/data.tf b/data.tf index 95f33d6..17223e7 100644 --- a/data.tf +++ b/data.tf @@ -3,6 +3,13 @@ ################################################## data "azurerm_client_config" "current" {} +data "azurerm_subnet" "openai_subnet" { + name = var.subnet_config.subnet_name + virtual_network_name = var.virtual_network_name + resource_group_name = var.resource_group_name + depends_on = [azurerm_subnet.az_openai_subnet] +} + # Data sources to get Subnet ID/s for CosmosDB and App Service # Usage in Module example: subnet_id = data.azurerm_subnet.subnet["app-cosmos-sub"].id # data "azurerm_subnet" "subnet" { diff --git a/tests/auto_test1/data.tf b/tests/auto_test1/data.tf index 7437546..3b9fde2 100644 --- a/tests/auto_test1/data.tf +++ b/tests/auto_test1/data.tf @@ -1,8 +1,8 @@ -data "azurerm_subnet" "openai_subnet" { - name = var.subnet_config.subnet_name - virtual_network_name = var.virtual_network_name - resource_group_name = var.resource_group_name -} +# data "azurerm_subnet" "openai_subnet" { +# name = var.subnet_config.subnet_name +# virtual_network_name = var.virtual_network_name +# resource_group_name = var.resource_group_name +# } # data "azurerm_key_vault" "gpt" { # name = local.kv_config.name diff --git a/tests/auto_test1/locals.tf b/tests/auto_test1/locals.tf index bcf0719..222e684 100644 --- a/tests/auto_test1/locals.tf +++ b/tests/auto_test1/locals.tf @@ -5,7 +5,7 @@ locals { default_action = var.keyvault_firewall_default_action bypass = var.keyvault_firewall_bypass ip_rules = var.keyvault_firewall_allowed_ips - virtual_network_subnet_ids = [data.azurerm_subnet.openai_subnet.id] + virtual_network_subnet_ids = var.virtual_network_subnet_name } ] } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 8bd4827..608302c 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -28,9 +28,12 @@ subnet_config = { } ### 03 KeyVault ### -kv_name = "openaikv9000" -kv_sku = "standard" - +kv_name = "openaikv9000" +kv_sku = "standard" +keyvault_firewall_default_action = "Deny" +keyvault_firewall_bypass = ["AzureServices"] +keyvault_firewall_allowed_ips = ["0.0.0.0/0"] +virtual_network_subnet_name = "app-cosmos-sub" # ### Create OpenAI Service ### # create_openai_service = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 0676921..62dc887 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -91,6 +91,12 @@ variable "keyvault_firewall_allowed_ips" { description = "value of key vault firewall allowed ip rules." } +variable "virtual_network_subnet_name" { + type = string + default = "" + description = "Name of the subnet to allow access to the key vault (service endpoint)." +} + # ### OpenAI service Module params ### # ### key vault ### # variable "kv_config" { From 30d4126f07d8b32b740ad4d6ef2e7eab46ba2ca5 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 04:01:54 +0000 Subject: [PATCH 054/163] up --- tests/auto_test1/locals.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/locals.tf b/tests/auto_test1/locals.tf index 222e684..44c9ef6 100644 --- a/tests/auto_test1/locals.tf +++ b/tests/auto_test1/locals.tf @@ -5,7 +5,7 @@ locals { default_action = var.keyvault_firewall_default_action bypass = var.keyvault_firewall_bypass ip_rules = var.keyvault_firewall_allowed_ips - virtual_network_subnet_ids = var.virtual_network_subnet_name + virtual_network_subnet_ids = [var.virtual_network_subnet_name] } ] } From 5f8791a083da5c2da3e03babaad7bdf9dce35f77 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 09:31:03 +0000 Subject: [PATCH 055/163] deploy kv --- 03_keyvault.tf | 13 +++++------ locals.tf | 12 +++++----- tests/auto_test1/locals.tf | 22 +++++++++---------- tests/auto_test1/main.tf | 8 ++++--- tests/auto_test1/testing.auto.tfvars | 11 +++++----- tests/auto_test1/variables.tf | 13 ++++------- variables.tf | 33 ++++++++++++++-------------- 7 files changed, 53 insertions(+), 59 deletions(-) diff --git a/03_keyvault.tf b/03_keyvault.tf index 3b56fb4..6a05a8b 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -7,14 +7,11 @@ resource "azurerm_key_vault" "az_openai_kv" { sku_name = var.kv_sku enable_rbac_authorization = true tenant_id = data.azurerm_client_config.current.tenant_id - dynamic "network_acls" { - for_each = var.kv_net_rules - content { - default_action = network_acls.value.default_action - bypass = network_acls.value.bypass - ip_rules = network_acls.value.ip_rules - virtual_network_subnet_ids = network_acls.value.virtual_network_subnet_ids - } + network_acls { + default_action = var.kv_fw_default_action + bypass = var.kv_fw_bypass + ip_rules = var.kv_fw_allowed_ips + virtual_network_subnet_ids = azurerm_subnet.az_openai_subnet.*.id } tags = var.tags depends_on = [azurerm_subnet.az_openai_subnet] diff --git a/locals.tf b/locals.tf index 3053536..ff88bc7 100644 --- a/locals.tf +++ b/locals.tf @@ -1,14 +1,14 @@ -# locals { -# ## locals config for key vault firewall rules ## +#locals { +## locals config for key vault firewall rules ## # kv_net_rules = [ # { # default_action = var.keyvault_firewall_default_action # bypass = var.keyvault_firewall_bypass # ip_rules = var.keyvault_firewall_allowed_ips -# virtual_network_subnet_ids = azurerm_subnet.az_openai_subnet.*.id -# } -# ] -# } +#virtual_network_subnet_ids = azurerm_subnet.az_openai_subnet.*.id +# } +# ] +#} #locals { # cdn_gpt_origin = merge( diff --git a/tests/auto_test1/locals.tf b/tests/auto_test1/locals.tf index 44c9ef6..ff86d91 100644 --- a/tests/auto_test1/locals.tf +++ b/tests/auto_test1/locals.tf @@ -1,14 +1,14 @@ -locals { - ## locals config for key vault firewall rules ## - kv_net_rules = [ - { - default_action = var.keyvault_firewall_default_action - bypass = var.keyvault_firewall_bypass - ip_rules = var.keyvault_firewall_allowed_ips - virtual_network_subnet_ids = [var.virtual_network_subnet_name] - } - ] -} +# locals { +# ## locals config for key vault firewall rules ## +# kv_net_rules = [ +# { +# default_action = var.keyvault_firewall_default_action +# bypass = var.keyvault_firewall_bypass +# ip_rules = var.keyvault_firewall_allowed_ips +# # virtual_network_subnet_name = var.virtual_network_subnet_name +# } +# ] +# } # locals { diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index afce2bc..f4465d1 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -44,9 +44,11 @@ module "private-chatgpt-openai" { subnet_config = var.subnet_config #03 keyvault (Solution Secrets) - kv_name = var.kv_name - kv_sku = var.kv_sku - kv_net_rules = local.kv_net_rules + kv_name = var.kv_name + kv_sku = var.kv_sku + kv_fw_default_action = var.kv_fw_default_action + kv_fw_bypass = var.kv_fw_bypass + kv_fw_allowed_ips = var.kv_fw_allowed_ips } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 608302c..2d2ef0e 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -28,12 +28,11 @@ subnet_config = { } ### 03 KeyVault ### -kv_name = "openaikv9000" -kv_sku = "standard" -keyvault_firewall_default_action = "Deny" -keyvault_firewall_bypass = ["AzureServices"] -keyvault_firewall_allowed_ips = ["0.0.0.0/0"] -virtual_network_subnet_name = "app-cosmos-sub" +kv_name = "openaikv9000" +kv_sku = "standard" +kv_fw_default_action = "Deny" +kv_fw_bypass = ["AzureServices"] +kv_fw_allowed_ips = ["0.0.0.0/0"] # ### Create OpenAI Service ### # create_openai_service = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 62dc887..5401c37 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -73,30 +73,25 @@ variable "kv_sku" { default = "standard" } -variable "keyvault_firewall_default_action" { +variable "kv_fw_default_action" { type = string default = "Deny" description = "Default action for key vault firewall rules." + } -variable "keyvault_firewall_bypass" { +variable "kv_fw_bypass" { type = string default = "AzureServices" description = "List of key vault firewall rules to bypass." } -variable "keyvault_firewall_allowed_ips" { +variable "kv_fw_allowed_ips" { type = list(string) default = [] description = "value of key vault firewall allowed ip rules." } -variable "virtual_network_subnet_name" { - type = string - default = "" - description = "Name of the subnet to allow access to the key vault (service endpoint)." -} - # ### OpenAI service Module params ### # ### key vault ### # variable "kv_config" { diff --git a/variables.tf b/variables.tf index b211739..69216aa 100644 --- a/variables.tf +++ b/variables.tf @@ -76,22 +76,23 @@ variable "kv_sku" { default = "standard" } -variable "kv_net_rules" { - type = list(object({ - default_action = string - bypass = string - ip_rules = list(string) - virtual_network_subnet_ids = list(string) - })) - default = [ - { - default_action = "Deny" - bypass = "AzureServices" - ip_rules = [] - virtual_network_subnet_ids = [] - } - ] - description = "A list of Key Vault network acl configuration objects to create Key Vault firewall rules." +variable "kv_fw_default_action" { + type = string + default = "Deny" + description = "Default action for key vault firewall rules." + +} + +variable "kv_fw_bypass" { + type = string + default = "AzureServices" + description = "List of key vault firewall rules to bypass." +} + +variable "kv_fw_allowed_ips" { + type = list(string) + default = [] + description = "value of key vault firewall allowed ip rules." } ### 04 OpenAI service ### From c718ba75aad0213ae508de15d83067cb54618965 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 09:35:16 +0000 Subject: [PATCH 056/163] up --- tests/auto_test1/variables.tf | 5 +++-- variables.tf | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 5401c37..f856f8c 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -81,11 +81,12 @@ variable "kv_fw_default_action" { } variable "kv_fw_bypass" { - type = string - default = "AzureServices" + type = list(string) + default = ["AzureServices"] description = "List of key vault firewall rules to bypass." } + variable "kv_fw_allowed_ips" { type = list(string) default = [] diff --git a/variables.tf b/variables.tf index 69216aa..fed4284 100644 --- a/variables.tf +++ b/variables.tf @@ -84,8 +84,8 @@ variable "kv_fw_default_action" { } variable "kv_fw_bypass" { - type = string - default = "AzureServices" + type = list(string) + default = ["AzureServices"] description = "List of key vault firewall rules to bypass." } From 728bf2034ae34e76acd5913daed45ff51d03fc7f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 09:37:51 +0000 Subject: [PATCH 057/163] test --- tests/auto_test1/testing.auto.tfvars | 2 +- tests/auto_test1/variables.tf | 4 ++-- variables.tf | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 2d2ef0e..6a243b6 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -31,7 +31,7 @@ subnet_config = { kv_name = "openaikv9000" kv_sku = "standard" kv_fw_default_action = "Deny" -kv_fw_bypass = ["AzureServices"] +kv_fw_bypass = "AzureServices" kv_fw_allowed_ips = ["0.0.0.0/0"] # ### Create OpenAI Service ### diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index f856f8c..0d3a1fc 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -81,8 +81,8 @@ variable "kv_fw_default_action" { } variable "kv_fw_bypass" { - type = list(string) - default = ["AzureServices"] + type = string + default = "AzureServices" description = "List of key vault firewall rules to bypass." } diff --git a/variables.tf b/variables.tf index fed4284..69216aa 100644 --- a/variables.tf +++ b/variables.tf @@ -84,8 +84,8 @@ variable "kv_fw_default_action" { } variable "kv_fw_bypass" { - type = list(string) - default = ["AzureServices"] + type = string + default = "AzureServices" description = "List of key vault firewall rules to bypass." } From 8033b3a339af0c6e082a29ff56528fed759b2cea Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 09:41:12 +0000 Subject: [PATCH 058/163] dependencies --- 02_networking.tf | 2 +- 03_keyvault.tf | 2 +- data.tf | 12 ++++++------ tests/auto_test1/variables.tf | 1 - 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/02_networking.tf b/02_networking.tf index 0fed516..2e9901b 100644 --- a/02_networking.tf +++ b/02_networking.tf @@ -1,7 +1,7 @@ resource "azurerm_virtual_network" "az_openai_vnet" { name = var.virtual_network_name location = var.location - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.az_openai_rg.name address_space = var.vnet_address_space tags = var.tags } diff --git a/03_keyvault.tf b/03_keyvault.tf index 6a05a8b..ba794c4 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -1,6 +1,6 @@ # Key Vault - Create Key Vault to save cognitive account details resource "azurerm_key_vault" "az_openai_kv" { - resource_group_name = var.resource_group_name + resource_group_name = azurerm_resource_group.az_openai_rg.name location = var.location #values from variable kv_config object name = lower(var.kv_name) diff --git a/data.tf b/data.tf index 17223e7..314251b 100644 --- a/data.tf +++ b/data.tf @@ -3,12 +3,12 @@ ################################################## data "azurerm_client_config" "current" {} -data "azurerm_subnet" "openai_subnet" { - name = var.subnet_config.subnet_name - virtual_network_name = var.virtual_network_name - resource_group_name = var.resource_group_name - depends_on = [azurerm_subnet.az_openai_subnet] -} +# data "azurerm_subnet" "openai_subnet" { +# name = var.subnet_config.subnet_name +# virtual_network_name = var.virtual_network_name +# resource_group_name = var.resource_group_name +# depends_on = [azurerm_subnet.az_openai_subnet] +# } # Data sources to get Subnet ID/s for CosmosDB and App Service # Usage in Module example: subnet_id = data.azurerm_subnet.subnet["app-cosmos-sub"].id diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 0d3a1fc..5401c37 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -86,7 +86,6 @@ variable "kv_fw_bypass" { description = "List of key vault firewall rules to bypass." } - variable "kv_fw_allowed_ips" { type = list(string) default = [] From 8b4e401b5004359f2185a1b0424b72b0823bf0d2 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 10:20:38 +0000 Subject: [PATCH 059/163] cognitive service --- 01_resource_group.tf | 1 + 02_networking.tf | 1 + 03_keyvault.tf | 2 +- 04_az_openai.tf | 167 ++++++++------ tests/auto_test1/main.tf | 22 +- tests/auto_test1/testing.auto.tfvars | 56 +++-- tests/auto_test1/variables.tf | 314 ++++++++++++--------------- variables.tf | 147 ++++++++++++- 8 files changed, 432 insertions(+), 278 deletions(-) diff --git a/01_resource_group.tf b/01_resource_group.tf index b22c4e3..40ba35c 100644 --- a/01_resource_group.tf +++ b/01_resource_group.tf @@ -1,3 +1,4 @@ +# Create Solution Resource Group resource "azurerm_resource_group" "az_openai_rg" { name = var.resource_group_name location = var.location diff --git a/02_networking.tf b/02_networking.tf index 2e9901b..a1a044e 100644 --- a/02_networking.tf +++ b/02_networking.tf @@ -1,3 +1,4 @@ +# Create Solution Virtual Network resource "azurerm_virtual_network" "az_openai_vnet" { name = var.virtual_network_name location = var.location diff --git a/03_keyvault.tf b/03_keyvault.tf index ba794c4..df943c0 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -1,4 +1,4 @@ -# Key Vault - Create Key Vault to save cognitive account details +# Key Vault - Create Key Vault to save cognitive account, cosmosDB, App details resource "azurerm_key_vault" "az_openai_kv" { resource_group_name = azurerm_resource_group.az_openai_rg.name location = var.location diff --git a/04_az_openai.tf b/04_az_openai.tf index 407b15e..66bef09 100644 --- a/04_az_openai.tf +++ b/04_az_openai.tf @@ -1,75 +1,108 @@ -# resource "azurerm_cognitive_account" "az_openai" { -# kind = "OpenAI" -# location = var.location -# name = var.account_name -# resource_group_name = var.resource_group_name -# sku_name = var.sku_name -# custom_subdomain_name = var.custom_subdomain_name -# dynamic_throttling_enabled = var.dynamic_throttling_enabled -# fqdns = var.fqdns -# local_auth_enabled = var.local_auth_enabled -# outbound_network_access_restricted = var.outbound_network_access_restricted -# public_network_access_enabled = var.public_network_access_enabled -# tags = var.tags +# Create OpenAI Cognitive Account +resource "azurerm_cognitive_account" "az_openai" { + kind = "OpenAI" + location = var.location + name = var.oai_account_name + resource_group_name = azurerm_resource_group.az_openai_rg.name + sku_name = var.oai_sku_name + custom_subdomain_name = var.oai_custom_subdomain_name + dynamic_throttling_enabled = var.oai_dynamic_throttling_enabled + fqdns = var.oai_fqdns + local_auth_enabled = var.oai_local_auth_enabled + outbound_network_access_restricted = var.oai_outbound_network_access_restricted + public_network_access_enabled = var.oai_public_network_access_enabled + tags = var.tags -# dynamic "customer_managed_key" { -# for_each = var.customer_managed_key != null ? [var.customer_managed_key] : [] -# content { -# key_vault_key_id = customer_managed_key.value.key_vault_key_id -# identity_client_id = customer_managed_key.value.identity_client_id -# } -# } + dynamic "customer_managed_key" { + for_each = var.oai_customer_managed_key != null ? [var.oai_customer_managed_key] : [] + content { + key_vault_key_id = customer_managed_key.value.key_vault_key_id + identity_client_id = customer_managed_key.value.identity_client_id + } + } -# dynamic "identity" { -# for_each = var.identity != null ? [var.identity] : [] -# content { -# type = identity.value.type -# identity_ids = identity.value.identity_ids -# } -# } + dynamic "identity" { + for_each = var.oai_identity != null ? [var.oai_identity] : [] + content { + type = identity.value.type + identity_ids = identity.value.identity_ids + } + } -# dynamic "network_acls" { -# for_each = var.network_acls != null ? [var.network_acls] : [] -# content { -# default_action = network_acls.value.default_action -# ip_rules = network_acls.value.ip_rules + dynamic "network_acls" { + for_each = var.oai_network_acls != null ? [var.oai_network_acls] : [] + content { + default_action = network_acls.value.default_action + ip_rules = network_acls.value.ip_rules -# dynamic "virtual_network_rules" { -# for_each = network_acls.value.virtual_network_rules != null ? network_acls.value.virtual_network_rules : [] -# content { -# subnet_id = virtual_network_rules.value.subnet_id -# ignore_missing_vnet_service_endpoint = virtual_network_rules.value.ignore_missing_vnet_service_endpoint -# } -# } -# } -# } + dynamic "virtual_network_rules" { + for_each = network_acls.value.virtual_network_rules != null ? network_acls.value.virtual_network_rules : [] + content { + subnet_id = virtual_network_rules.value.subnet_id + ignore_missing_vnet_service_endpoint = virtual_network_rules.value.ignore_missing_vnet_service_endpoint + } + } + } + } -# dynamic "storage" { -# for_each = var.storage -# content { -# storage_account_id = storage.value.storage_account_id -# identity_client_id = storage.value.identity_client_id -# } -# } -# } + dynamic "storage" { + for_each = var.oai_storage + content { + storage_account_id = storage.value.storage_account_id + identity_client_id = storage.value.identity_client_id + } + } +} -# resource "azurerm_cognitive_deployment" "az_openai_models" { -# for_each = { for each in var.model_deployment : each.deployment_id => each } +# Create OpenAI Cognitive Account Model Deployments +resource "azurerm_cognitive_deployment" "az_openai_models" { + for_each = { for each in var.oai_model_deployment : each.deployment_id => each } -# cognitive_account_id = data.azurerm_cognitive_account.openai.id -# name = each.value.deployment_id -# rai_policy_name = each.value.rai_policy_name + cognitive_account_id = data.azurerm_cognitive_account.openai.id + name = each.value.deployment_id + rai_policy_name = each.value.rai_policy_name -# model { -# format = each.value.model_format -# name = each.value.model_name -# version = each.value.model_version -# } -# scale { -# type = each.value.scale_type -# tier = each.value.scale_tier -# size = each.value.scale_size -# family = each.value.scale_family -# capacity = each.value.scale_capacity -# } -# } \ No newline at end of file + model { + format = each.value.model_format + name = each.value.model_name + version = each.value.model_version + } + scale { + type = each.value.scale_type + tier = each.value.scale_tier + size = each.value.scale_size + family = each.value.scale_family + capacity = each.value.scale_capacity + } +} + +# Save OpenAI Cognitive Account details to Key Vault for consumption by other services +resource "azurerm_key_vault_secret" "openai_endpoint" { + name = "${var.oai_account_name}-openai-endpoint" + value = azurerm_cognitive_account.az_openai.endpoint + key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] +} + +resource "azurerm_key_vault_secret" "openai_primary_key" { + name = "${var.oai_account_name}-openai-key" + value = azurerm_cognitive_account.az_openai.primary_access_key + key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] +} + +resource "azurerm_key_vault_secret" "openai_model_deployment_id" { + for_each = { for each in var.oai_model_deployment : each.deployment_id => each } + name = "${var.var.oai_account_name}-model-${each.value.deployment_id}-id" + value = each.value.deployment_id + key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] +} + +resource "azurerm_key_vault_secret" "openai_model" { + for_each = { for each in var.oai_model_deployment : each.deployment_id => each } + name = "${var.var.oai_account_name}-model-${each.value.deployment_id}-name" + value = each.value.model_name + key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] +} diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index f4465d1..87b48fe 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -39,19 +39,37 @@ module "private-chatgpt-openai" { resource_group_name = var.resource_group_name #02 networking - virtual_network_name = var.virtual_network_name + virtual_network_name = "${var.virtual_network_name}${random_integer.number.result}" vnet_address_space = var.vnet_address_space subnet_config = var.subnet_config #03 keyvault (Solution Secrets) - kv_name = var.kv_name + kv_name = "${var.kv_name}${random_integer.number.result}" kv_sku = var.kv_sku kv_fw_default_action = var.kv_fw_default_action kv_fw_bypass = var.kv_fw_bypass kv_fw_allowed_ips = var.kv_fw_allowed_ips + + #04 openai service + oai_account_name = "${var.oai_account_name}${random_integer.number.result}" + oai_sku_name = var.oai_sku_name + oai_custom_subdomain_name = var.oai_custom_subdomain_name + oai_dynamic_throttling_enabled = var.oai_dynamic_throttling_enabled + oai_fqdns = var.oai_fqdns + oai_local_auth_enabled = var.oai_local_auth_enabled + oai_outbound_network_access_restricted = var.oai_outbound_network_access_restricted + oai_public_network_access_enabled = var.oai_public_network_access_enabled + oai_customer_managed_key = var.oai_customer_managed_key + oai_identity = var.oai_identity + oai_network_acls = var.oai_network_acls + oai_storage = var.oai_storage + oai_model_deployment = var.oai_model_deployment } + + + # #Create OpenAI Service? # create_openai_service = var.create_openai_service # openai_resource_group_name = azurerm_resource_group.rg.name diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 6a243b6..9bb7715 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -34,37 +34,31 @@ kv_fw_default_action = "Deny" kv_fw_bypass = "AzureServices" kv_fw_allowed_ips = ["0.0.0.0/0"] -# ### Create OpenAI Service ### -# create_openai_service = true -# openai_account_name = "gptopenai" -# openai_custom_subdomain_name = "gptopenai" -# openai_sku_name = "S0" -# openai_local_auth_enabled = true -# openai_outbound_network_access_restricted = false -# openai_public_network_access_enabled = true -# openai_identity = { -# type = "SystemAssigned" -# } - -# ### Create Model deployment ### -# create_model_deployment = true -# model_deployment = [ -# { -# deployment_id = "gpt-4" -# model_name = "gpt-4" -# model_format = "OpenAI" -# model_version = "1106-Preview" -# scale_type = "Standard" -# scale_capacity = 10 # 34K == Roughly 204 RPM (Requests per minute) -# } -# ] - -# ### networking ### -# create_openai_networking = true -# network_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -# virtual_network_name = "openai-vnet" -# vnet_address_space = ["10.4.0.0/16"] - +### 04 Create OpenAI Service ### +oai_account_name = "gptopenai" +oai_sku_name = "S0" +oai_custom_subdomain_name = "gptopenai" +oai_dynamic_throttling_enabled = true +oai_fqdns = [] +oai_local_auth_enabled = true +oai_outbound_network_access_restricted = false +oai_public_network_access_enabled = true +oai_customer_managed_key = null +oai_identity = { + type = "SystemAssigned" +} +oai_network_acls = null +oai_storage = null +oai_model_deployment = [ + { + deployment_id = "gpt-4" + model_name = "gpt-4" + model_format = "OpenAI" + model_version = "1106-Preview" + scale_type = "Standard" + scale_capacity = 10 # 34K == Roughly 204 RPM (Requests per minute) + } +] # ### cosmosdb ### # create_cosmosdb = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 5401c37..0a94280 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -92,195 +92,157 @@ variable "kv_fw_allowed_ips" { description = "value of key vault firewall allowed ip rules." } -# ### OpenAI service Module params ### -# ### key vault ### -# variable "kv_config" { -# type = object({ -# name = string -# sku = string -# }) -# default = { -# name = "gptkv" -# sku = "standard" -# } -# description = "Key Vault configuration object to create azure key vault to store openai account details." -# nullable = false -# } - -# variable "keyvault_firewall_default_action" { -# type = string -# default = "Deny" -# description = "Default action for keyvault firewall rules." -# } - -# variable "keyvault_firewall_bypass" { -# type = string -# default = "AzureServices" -# description = "List of keyvault firewall rules to bypass." -# } - -# variable "keyvault_firewall_allowed_ips" { -# type = list(string) -# default = [] -# description = "value of keyvault firewall allowed ip rules." -# } - -# variable "keyvault_firewall_virtual_network_subnet_ids" { -# type = list(string) -# default = [] -# description = "value of keyvault firewall allowed virtual network subnet ids." -# } - -# ### openai service ### -# variable "create_openai_service" { -# type = bool -# description = "Create the OpenAI service." -# default = false -# } +### 04 openai service ### +variable "oai_account_name" { + type = string + default = "az-openai-account" + description = "The name of the OpenAI service." +} -# variable "openai_account_name" { -# type = string -# description = "Name of the OpenAI service." -# default = "demo-account" -# } +variable "oai_sku_name" { + type = string + description = "SKU name of the OpenAI service." + default = "S0" +} -# variable "openai_custom_subdomain_name" { -# type = string -# description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" -# default = "demo-account" -# } +variable "oai_custom_subdomain_name" { + type = string + description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" + default = "demo-account" +} -# variable "openai_sku_name" { -# type = string -# description = "SKU name of the OpenAI service." -# default = "S0" -# } +variable "oai_dynamic_throttling_enabled" { + type = bool + default = true + description = "Whether or not dynamic throttling is enabled. Defaults to `true`." +} -# variable "openai_local_auth_enabled" { -# type = bool -# default = true -# description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." -# } +variable "oai_fqdns" { + type = list(string) + default = [] + description = "A list of FQDNs to be used for token-based authentication. Changing this forces a new resource to be created." +} -# variable "openai_outbound_network_access_restricted" { -# type = bool -# default = false -# description = "Whether or not outbound network access is restricted. Defaults to `false`." -# } +variable "oai_local_auth_enabled" { + type = bool + default = true + description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." +} -# variable "openai_public_network_access_enabled" { -# type = bool -# default = true -# description = "Whether or not public network access is enabled. Defaults to `false`." -# } +variable "oai_outbound_network_access_restricted" { + type = bool + default = false + description = "Whether or not outbound network access is restricted. Defaults to `false`." +} -# variable "openai_identity" { -# type = object({ -# type = string -# }) -# default = { -# type = "SystemAssigned" -# } -# description = <<-DESCRIPTION -# type = object({ -# type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. -# identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. -# }) -# DESCRIPTION -# } +variable "oai_public_network_access_enabled" { + type = bool + default = true + description = "Whether or not public network access is enabled. Defaults to `false`." +} -# ### model deployment ### -# variable "create_model_deployment" { -# type = bool -# description = "Create the model deployment." -# default = false -# } +variable "oai_customer_managed_key" { + type = object({ + key_vault_key_id = string + identity_client_id = optional(string) + }) + default = null + description = <<-DESCRIPTION + type = object({ + key_vault_key_id = (Required) The ID of the Key Vault Key which should be used to Encrypt the data in this OpenAI Account. + identity_client_id = (Optional) The Client ID of the User Assigned Identity that has access to the key. This property only needs to be specified when there're multiple identities attached to the OpenAI Account. + }) + DESCRIPTION +} -# variable "model_deployment" { -# type = list(object({ -# deployment_id = string -# model_name = string -# model_format = string -# model_version = string -# scale_type = string -# scale_tier = optional(string) -# scale_size = optional(number) -# scale_family = optional(string) -# scale_capacity = optional(number) -# rai_policy_name = optional(string) -# })) -# default = [] -# description = <<-DESCRIPTION -# type = list(object({ -# deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. -# model_name = { -# model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. -# model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. -# model_version = (Required) The version of Cognitive Services Account Deployment model. -# } -# scale = { -# scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. -# scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. -# scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. -# scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. -# scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. -# } -# rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. -# })) -# DESCRIPTION -# nullable = false -# } +variable "oai_identity" { + type = object({ + type = string + identity_ids = optional(list(string)) + }) + default = { + type = "SystemAssigned" + } + description = <<-DESCRIPTION + type = object({ + type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. + identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. + }) + DESCRIPTION +} -# ### networking ### -# variable "create_openai_networking" { -# description = "Create a virtual network and subnet/s for networked services" -# type = bool -# default = false -# } +variable "oai_network_acls" { + type = set(object({ + default_action = string + ip_rules = optional(set(string)) + virtual_network_rules = optional(set(object({ + subnet_id = string + ignore_missing_vnet_service_endpoint = optional(bool, false) + }))) + })) + default = null + description = <<-DESCRIPTION + type = set(object({ + default_action = (Required) The Default Action to use when no rules match from ip_rules / virtual_network_rules. Possible values are `Allow` and `Deny`. + ip_rules = (Optional) One or more IP Addresses, or CIDR Blocks which should be able to access the Cognitive Account. + virtual_network_rules = optional(set(object({ + subnet_id = (Required) The ID of a Subnet which should be able to access the OpenAI Account. + ignore_missing_vnet_service_endpoint = (Optional) Whether ignore missing vnet service endpoint or not. Default to `false`. + }))) + })) + DESCRIPTION +} -# variable "network_resource_group_name" { -# type = string -# description = "Name of the resource group to where networking resources will be hosted." -# nullable = false -# } +variable "oai_storage" { + type = list(object({ + storage_account_id = string + identity_client_id = optional(string) + })) + default = [] + description = <<-DESCRIPTION + type = list(object({ + storage_account_id = (Required) Full resource id of a Microsoft.Storage resource. + identity_client_id = (Optional) The client ID of the managed identity associated with the storage resource. + })) + DESCRIPTION + nullable = false +} -# variable "virtual_network_name" { -# type = string -# default = null -# description = "Name of the virtual network where resources are attached." -# } +variable "oai_model_deployment" { + type = list(object({ + deployment_id = string + model_name = string + model_format = string + model_version = string + scale_type = string + scale_tier = optional(string) + scale_size = optional(number) + scale_family = optional(string) + scale_capacity = optional(number) + rai_policy_name = optional(string) + })) + default = [] + description = <<-DESCRIPTION + type = list(object({ + deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. + model_name = { + model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. + model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. + model_version = (Required) The version of Cognitive Services Account Deployment model. + } + scale = { + scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. + scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. + scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. + scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. + scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. + } + rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. + })) + DESCRIPTION + nullable = false +} -# variable "subnet_config" { -# type = list(object({ -# subnet_name = string -# subnet_address_space = list(string) -# service_endpoints = list(string) -# private_endpoint_network_policies_enabled = bool -# private_link_service_network_policies_enabled = bool -# subnets_delegation_settings = map(list(object({ -# name = string -# actions = list(string) -# }))) -# })) -# default = [ -# { -# subnet_name = "app-cosmos-sub" -# subnet_address_space = ["10.4.0.0/24"] -# service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] -# private_endpoint_network_policies_enabled = false -# private_link_service_network_policies_enabled = false -# subnets_delegation_settings = { -# app-service-plan = [ -# { -# name = "Microsoft.Web/serverFarms" -# actions = ["Microsoft.Network/virtualNetworks/subnets/action"] -# } -# ] -# } -# } -# ] -# description = "A list of subnet configuration objects to create subnets in the virtual network." -# } # ### cosmosdb ### # variable "create_cosmosdb" { diff --git a/variables.tf b/variables.tf index 69216aa..6702dd3 100644 --- a/variables.tf +++ b/variables.tf @@ -96,12 +96,157 @@ variable "kv_fw_allowed_ips" { } ### 04 OpenAI service ### -variable "account_name" { +variable "oai_account_name" { type = string default = "az-openai-account" description = "The name of the OpenAI service." } +variable "oai_sku_name" { + type = string + description = "SKU name of the OpenAI service." + default = "S0" +} + +variable "oai_custom_subdomain_name" { + type = string + description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" + default = "demo-account" +} + +variable "oai_dynamic_throttling_enabled" { + type = bool + default = true + description = "Whether or not dynamic throttling is enabled. Defaults to `true`." +} + +variable "oai_fqdns" { + type = list(string) + default = [] + description = "A list of FQDNs to be used for token-based authentication. Changing this forces a new resource to be created." + +} + +variable "oai_local_auth_enabled" { + type = bool + default = true + description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." +} + +variable "oai_outbound_network_access_restricted" { + type = bool + default = false + description = "Whether or not outbound network access is restricted. Defaults to `false`." +} + +variable "oai_public_network_access_enabled" { + type = bool + default = true + description = "Whether or not public network access is enabled. Defaults to `false`." +} + +variable "oai_customer_managed_key" { + type = object({ + key_vault_key_id = string + identity_client_id = optional(string) + }) + default = null + description = <<-DESCRIPTION + type = object({ + key_vault_key_id = (Required) The ID of the Key Vault Key which should be used to Encrypt the data in this OpenAI Account. + identity_client_id = (Optional) The Client ID of the User Assigned Identity that has access to the key. This property only needs to be specified when there're multiple identities attached to the OpenAI Account. + }) + DESCRIPTION +} + +variable "oai_identity" { + type = object({ + type = string + identity_ids = optional(list(string)) + }) + default = { + type = "SystemAssigned" + } + description = <<-DESCRIPTION + type = object({ + type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. + identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. + }) + DESCRIPTION +} + +variable "oai_network_acls" { + type = set(object({ + default_action = string + ip_rules = optional(set(string)) + virtual_network_rules = optional(set(object({ + subnet_id = string + ignore_missing_vnet_service_endpoint = optional(bool, false) + }))) + })) + default = null + description = <<-DESCRIPTION + type = set(object({ + default_action = (Required) The Default Action to use when no rules match from ip_rules / virtual_network_rules. Possible values are `Allow` and `Deny`. + ip_rules = (Optional) One or more IP Addresses, or CIDR Blocks which should be able to access the Cognitive Account. + virtual_network_rules = optional(set(object({ + subnet_id = (Required) The ID of a Subnet which should be able to access the OpenAI Account. + ignore_missing_vnet_service_endpoint = (Optional) Whether ignore missing vnet service endpoint or not. Default to `false`. + }))) + })) + DESCRIPTION +} + +variable "oai_storage" { + type = list(object({ + storage_account_id = string + identity_client_id = optional(string) + })) + default = [] + description = <<-DESCRIPTION + type = list(object({ + storage_account_id = (Required) Full resource id of a Microsoft.Storage resource. + identity_client_id = (Optional) The client ID of the managed identity associated with the storage resource. + })) + DESCRIPTION + nullable = false +} + +variable "oai_model_deployment" { + type = list(object({ + deployment_id = string + model_name = string + model_format = string + model_version = string + scale_type = string + scale_tier = optional(string) + scale_size = optional(number) + scale_family = optional(string) + scale_capacity = optional(number) + rai_policy_name = optional(string) + })) + default = [] + description = <<-DESCRIPTION + type = list(object({ + deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. + model_name = { + model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. + model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. + model_version = (Required) The version of Cognitive Services Account Deployment model. + } + scale = { + scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. + scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. + scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. + scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. + scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. + } + rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. + })) + DESCRIPTION + nullable = false +} + # #################################### # ### OpenAI service Module params ### # #################################### From 1d4b08321cf2d4e1973715b112bdee65a3c03412 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 10:23:44 +0000 Subject: [PATCH 060/163] fixes --- 04_az_openai.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/04_az_openai.tf b/04_az_openai.tf index 66bef09..90e6631 100644 --- a/04_az_openai.tf +++ b/04_az_openai.tf @@ -58,7 +58,7 @@ resource "azurerm_cognitive_account" "az_openai" { resource "azurerm_cognitive_deployment" "az_openai_models" { for_each = { for each in var.oai_model_deployment : each.deployment_id => each } - cognitive_account_id = data.azurerm_cognitive_account.openai.id + cognitive_account_id = azurerm_cognitive_account.az_openai.id name = each.value.deployment_id rai_policy_name = each.value.rai_policy_name @@ -93,7 +93,7 @@ resource "azurerm_key_vault_secret" "openai_primary_key" { resource "azurerm_key_vault_secret" "openai_model_deployment_id" { for_each = { for each in var.oai_model_deployment : each.deployment_id => each } - name = "${var.var.oai_account_name}-model-${each.value.deployment_id}-id" + name = "${var.oai_account_name}-model-${each.value.deployment_id}-id" value = each.value.deployment_id key_vault_id = azurerm_key_vault.az_openai_kv.id depends_on = [azurerm_role_assignment.kv_role_assigment] @@ -101,7 +101,7 @@ resource "azurerm_key_vault_secret" "openai_model_deployment_id" { resource "azurerm_key_vault_secret" "openai_model" { for_each = { for each in var.oai_model_deployment : each.deployment_id => each } - name = "${var.var.oai_account_name}-model-${each.value.deployment_id}-name" + name = "${var.oai_account_name}-model-${each.value.deployment_id}-name" value = each.value.model_name key_vault_id = azurerm_key_vault.az_openai_kv.id depends_on = [azurerm_role_assignment.kv_role_assigment] From 577ef3ef1ccc4da1b927d6b7b480b9b3e0765fea Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 10:29:21 +0000 Subject: [PATCH 061/163] cleanup --- modules/container_app/README.md | 64 ----- modules/container_app/main.tf | 91 ------- modules/container_app/outputs.tf | 29 --- modules/container_app/variables.tf | 182 -------------- modules/networking/README.md | 11 - modules/networking/main.tf | 34 --- modules/networking/outputs.tf | 9 - modules/networking/variables.tf | 66 ------ modules/openai/README.md | 11 - modules/openai/data.tf | 11 - modules/openai/locals.tf | 11 - modules/openai/main.tf | 104 -------- modules/openai/model_deployment/README.md | 40 ---- modules/openai/model_deployment/data.tf | 4 - modules/openai/model_deployment/main.tf | 20 -- modules/openai/model_deployment/outputs.tf | 4 - modules/openai/model_deployment/variables.tf | 46 ---- modules/openai/openai_service/README.md | 56 ----- modules/openai/openai_service/main.tf | 55 ----- modules/openai/openai_service/outputs.tf | 31 --- modules/openai/openai_service/variables.tf | 136 ----------- modules/openai/outputs.tf | 46 ---- modules/openai/variables.tf | 236 ------------------- tests/auto_test1/main.tf | 7 - tests/auto_test1/testing.auto.tfvars | 6 +- 25 files changed, 3 insertions(+), 1307 deletions(-) delete mode 100644 modules/container_app/README.md delete mode 100644 modules/container_app/main.tf delete mode 100644 modules/container_app/outputs.tf delete mode 100644 modules/container_app/variables.tf delete mode 100644 modules/networking/README.md delete mode 100644 modules/networking/main.tf delete mode 100644 modules/networking/outputs.tf delete mode 100644 modules/networking/variables.tf delete mode 100644 modules/openai/README.md delete mode 100644 modules/openai/data.tf delete mode 100644 modules/openai/locals.tf delete mode 100644 modules/openai/main.tf delete mode 100644 modules/openai/model_deployment/README.md delete mode 100644 modules/openai/model_deployment/data.tf delete mode 100644 modules/openai/model_deployment/main.tf delete mode 100644 modules/openai/model_deployment/outputs.tf delete mode 100644 modules/openai/model_deployment/variables.tf delete mode 100644 modules/openai/openai_service/README.md delete mode 100644 modules/openai/openai_service/main.tf delete mode 100644 modules/openai/openai_service/outputs.tf delete mode 100644 modules/openai/openai_service/variables.tf delete mode 100644 modules/openai/outputs.tf delete mode 100644 modules/openai/variables.tf diff --git a/modules/container_app/README.md b/modules/container_app/README.md deleted file mode 100644 index bcdd70b..0000000 --- a/modules/container_app/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Module: Azure Container App - -Create a container app running ChatBot UI linked with OpenAI service hosted in Azure - -- Create a container app log analytics workspace. -- Create a container app environment. -- Create a container app instance. -- Grant the container app access a the key vault for secret retrieval (optional). - - -## Requirements - -No requirements. - -## Providers - -| Name | Version | -|------|---------| -| [azurerm](#provider\_azurerm) | n/a | - -## Modules - -No modules. - -## Resources - -| Name | Type | -|------|------| -| [azurerm_container_app.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app) | resource | -| [azurerm_container_app_environment.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app_environment) | resource | -| [azurerm_log_analytics_workspace.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/log_analytics_workspace) | resource | -| [azurerm_role_assignment.kv_role_assigment](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [ca\_container\_config](#input\_ca\_container\_config) | type = object({
name = (Required) The name of the container.
image = (Required) The name of the container image.
cpu = (Required) The number of CPU cores to allocate to the container.
memory = (Required) The amount of memory to allocate to the container in GB.
min\_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`.
max\_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`.
env = list(object({
name = (Required) The name of the environment variable.
secret\_name = (Optional) The name of the secret to use for the environment variable.
value = (Optional) The value of the environment variable.
}))
}) |
object({
name = string
image = string
cpu = number
memory = string
min_replicas = optional(number, 0)
max_replicas = optional(number, 10)
env = optional(list(object({
name = string
secret_name = optional(string)
value = optional(string)
})))
})
|
{
"cpu": 1,
"env": [],
"image": "ghcr.io/pwd9000-ml/chatbot-ui:main",
"max_replicas": 10,
"memory": "2Gi",
"min_replicas": 0,
"name": "gpt-chatbot-ui"
}
| no | -| [ca\_identity](#input\_ca\_identity) | type = object({
type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
}) |
object({
type = string
identity_ids = optional(list(string))
})
| `null` | no | -| [ca\_ingress](#input\_ca\_ingress) | type = object({
allow\_insecure\_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`.
external\_enabled = (Optional) Enable external access to the container app. Defaults to `true`.
target\_port = (Required) The port to use for the container app. Defaults to `3000`.
transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`.
type = object({
percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`.
latest\_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`.
}) |
object({
allow_insecure_connections = optional(bool)
external_enabled = optional(bool)
target_port = number
transport = optional(string)
traffic_weight = optional(object({
percentage = number
latest_revision = optional(bool)
}))
})
|
{
"allow_insecure_connections": false,
"external_enabled": true,
"target_port": 3000,
"traffic_weight": {
"latest_revision": true,
"percentage": 100
},
"transport": "auto"
}
| no | -| [ca\_name](#input\_ca\_name) | Name of the container app to create. | `string` | `"gptca"` | no | -| [ca\_resource\_group\_name](#input\_ca\_resource\_group\_name) | Name of the resource group to create the Container App and supporting solution resources in. | `string` | n/a | yes | -| [ca\_revision\_mode](#input\_ca\_revision\_mode) | Revision mode of the container app to create. | `string` | `"Single"` | no | -| [ca\_secrets](#input\_ca\_secrets) | type = list(object({
name = (Required) The name of the secret.
value = (Required) The value of the secret.
})) |
list(object({
name = string
value = string
}))
|
[
{
"name": "secret1",
"value": "value1"
},
{
"name": "secret2",
"value": "value2"
}
]
| no | -| [cae\_name](#input\_cae\_name) | Name of the container app environment to create. | `string` | `"gptcae"` | no | -| [key\_vault\_access\_permission](#input\_key\_vault\_access\_permission) | The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`. | `list(string)` |
[
"Key Vault Secrets User"
]
| no | -| [key\_vault\_id](#input\_key\_vault\_id) | (Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set. | `string` | `""` | no | -| [laws\_name](#input\_laws\_name) | Name of the log analytics workspace to create. | `string` | `"gptlaws"` | no | -| [laws\_retention\_in\_days](#input\_laws\_retention\_in\_days) | Retention in days of the log analytics workspace to create. | `number` | `30` | no | -| [laws\_sku](#input\_laws\_sku) | SKU of the log analytics workspace to create. | `string` | `"PerGB2018"` | no | -| [location](#input\_location) | Azure region where resources will be hosted. | `string` | `"uksouth"` | no | -| [tags](#input\_tags) | A map of key value pairs that is used to tag resources created. | `map(string)` | `{}` | no | - -## Outputs - -| Name | Description | -|------|-------------| -| [container\_app\_environment\_id](#output\_container\_app\_environment\_id) | The id of the container app environment. | -| [container\_app\_id](#output\_container\_app\_id) | The id of the container app. | -| [latest\_revision\_fqdn](#output\_latest\_revision\_fqdn) | The FQDN of the latest revision of the container app. | -| [latest\_revision\_name](#output\_latest\_revision\_name) | The name of the latest Container Revision. | -| [log\_analytics\_workspace\_id](#output\_log\_analytics\_workspace\_id) | The id of the log analytics workspace. | -| [outbound\_ip\_addresses](#output\_outbound\_ip\_addresses) | A list of the Public IP Addresses which the Container App uses for outbound network access. | - \ No newline at end of file diff --git a/modules/container_app/main.tf b/modules/container_app/main.tf deleted file mode 100644 index 7ccaf70..0000000 --- a/modules/container_app/main.tf +++ /dev/null @@ -1,91 +0,0 @@ -### Create a solution log analytics workspace to store logs ### -resource "azurerm_log_analytics_workspace" "gpt" { - name = var.laws_name - location = var.location - resource_group_name = var.ca_resource_group_name - sku = var.laws_sku - retention_in_days = var.laws_retention_in_days - tags = var.tags -} - -### Create Container App Enviornment ### -resource "azurerm_container_app_environment" "gpt" { - name = var.cae_name - location = var.location - resource_group_name = var.ca_resource_group_name - log_analytics_workspace_id = azurerm_log_analytics_workspace.gpt.id - tags = var.tags -} - -### Create a container app instance ### -resource "azurerm_container_app" "gpt" { - name = var.ca_name - container_app_environment_id = azurerm_container_app_environment.gpt.id - resource_group_name = var.ca_resource_group_name - revision_mode = var.ca_revision_mode - - dynamic "identity" { - for_each = var.ca_identity != null ? [var.ca_identity] : [] - content { - type = identity.value.type - identity_ids = identity.value.identity_ids - } - } - - dynamic "ingress" { - for_each = var.ca_ingress != null ? [var.ca_ingress] : [] - content { - allow_insecure_connections = ingress.value.allow_insecure_connections - external_enabled = ingress.value.external_enabled - target_port = ingress.value.target_port - transport = ingress.value.transport - dynamic "traffic_weight" { - for_each = ingress.value.traffic_weight != null ? [ingress.value.traffic_weight] : [] - content { - percentage = traffic_weight.value.percentage - latest_revision = traffic_weight.value.latest_revision - } - } - } - } - - template { - min_replicas = var.ca_container_config != null ? var.ca_container_config.min_replicas : null - max_replicas = var.ca_container_config != null ? var.ca_container_config.max_replicas : null - dynamic "container" { - for_each = var.ca_container_config != null ? [var.ca_container_config] : [] - content { - name = container.value.name - image = container.value.image - cpu = container.value.cpu - memory = container.value.memory - dynamic "env" { - for_each = length(container.value.env) > 0 ? { for each in container.value.env : each.name => each } : {} - content { - name = env.value.name - secret_name = env.value.secret_name - value = env.value.value - } - } - } - } - } - - dynamic "secret" { - for_each = length(var.ca_secrets) > 0 ? { for each in var.ca_secrets : each.name => each } : {} - content { - name = secret.value.name - value = secret.value.value - } - } - - tags = var.tags -} - -# Add container app permission to key vault RBAC (to retrieve OpenAI Account and model details if stored in a key vault) -resource "azurerm_role_assignment" "kv_role_assigment" { - for_each = var.key_vault_access_permission != null ? toset(var.key_vault_access_permission) : [] - role_definition_name = each.key - scope = var.key_vault_id - principal_id = azurerm_container_app.gpt.identity.0.principal_id -} \ No newline at end of file diff --git a/modules/container_app/outputs.tf b/modules/container_app/outputs.tf deleted file mode 100644 index be0aa70..0000000 --- a/modules/container_app/outputs.tf +++ /dev/null @@ -1,29 +0,0 @@ -output "log_analytics_workspace_id" { - value = azurerm_log_analytics_workspace.gpt.id - description = "The id of the log analytics workspace." -} - -output "container_app_environment_id" { - value = azurerm_container_app_environment.gpt.id - description = "The id of the container app environment." -} - -output "container_app_id" { - value = azurerm_container_app.gpt.id - description = "The id of the container app." -} - -output "latest_revision_name" { - value = azurerm_container_app.gpt.latest_revision_name - description = "The name of the latest Container Revision." -} - -output "latest_revision_fqdn" { - value = azurerm_container_app.gpt.latest_revision_fqdn - description = "The FQDN of the latest revision of the container app." -} - -output "outbound_ip_addresses" { - value = azurerm_container_app.gpt.outbound_ip_addresses - description = "A list of the Public IP Addresses which the Container App uses for outbound network access." -} \ No newline at end of file diff --git a/modules/container_app/variables.tf b/modules/container_app/variables.tf deleted file mode 100644 index 23a8487..0000000 --- a/modules/container_app/variables.tf +++ /dev/null @@ -1,182 +0,0 @@ -### common vars ### -variable "ca_resource_group_name" { - type = string - description = "Name of the resource group to create the Container App and supporting solution resources in." - nullable = false -} - -variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." -} - -variable "tags" { - type = map(string) - default = {} - description = "A map of key value pairs that is used to tag resources created." -} - -### log analytics workspace ### -variable "laws_name" { - type = string - description = "Name of the log analytics workspace to create." - default = "gptlaws" -} - -variable "laws_sku" { - type = string - description = "SKU of the log analytics workspace to create." - default = "PerGB2018" -} - -variable "laws_retention_in_days" { - type = number - description = "Retention in days of the log analytics workspace to create." - default = 30 -} - -### container app environment ### -variable "cae_name" { - type = string - description = "Name of the container app environment to create." - default = "gptcae" -} - - -### container app ### -variable "ca_name" { - type = string - description = "Name of the container app to create." - default = "gptca" -} - -variable "ca_revision_mode" { - type = string - description = "Revision mode of the container app to create." - default = "Single" -} - -variable "ca_identity" { - type = object({ - type = string - identity_ids = optional(list(string)) - }) - default = null - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -variable "ca_ingress" { - type = object({ - allow_insecure_connections = optional(bool) - external_enabled = optional(bool) - target_port = number - transport = optional(string) - traffic_weight = optional(object({ - percentage = number - latest_revision = optional(bool) - })) - }) - default = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - percentage = 100 - latest_revision = true - } - } - description = <<-DESCRIPTION - type = object({ - allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. - external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. - target_port = (Required) The port to use for the container app. Defaults to `3000`. - transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. - type = object({ - percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. - latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. - }) - DESCRIPTION -} - -variable "ca_container_config" { - type = object({ - name = string - image = string - cpu = number - memory = string - min_replicas = optional(number, 0) - max_replicas = optional(number, 10) - env = optional(list(object({ - name = string - secret_name = optional(string) - value = optional(string) - }))) - }) - default = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 1 - memory = "2Gi" - min_replicas = 0 - max_replicas = 10 - env = [] - } - description = <<-DESCRIPTION - type = object({ - name = (Required) The name of the container. - image = (Required) The name of the container image. - cpu = (Required) The number of CPU cores to allocate to the container. - memory = (Required) The amount of memory to allocate to the container in GB. - min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. - max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. - env = list(object({ - name = (Required) The name of the environment variable. - secret_name = (Optional) The name of the secret to use for the environment variable. - value = (Optional) The value of the environment variable. - })) - }) - DESCRIPTION -} - -variable "ca_secrets" { - type = list(object({ - name = string - value = string - })) - default = [ - { - name = "secret1" - value = "value1" - }, - { - name = "secret2" - value = "value2" - } - ] - description = <<-DESCRIPTION - type = list(object({ - name = (Required) The name of the secret. - value = (Required) The value of the secret. - })) - DESCRIPTION -} - -### key vault access ### -variable "key_vault_access_permission" { - type = list(string) - default = ["Key Vault Secrets User"] - description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -} - -variable "key_vault_id" { - type = string - description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." - default = "" -} \ No newline at end of file diff --git a/modules/networking/README.md b/modules/networking/README.md deleted file mode 100644 index e1d9a2b..0000000 --- a/modules/networking/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Module: Azure Networking Resources (Optional) - -Create a new VNET and subnet/s for the CosmosDB and Web App resources to use. (Optional) -If existing networking resources are to be used, then the variables/names of the existing VNET and subnets must be provided as input variables to root the module (data sources): - -- Create a VNET. -- Create a Delegated Subnet for App Service + CosmosDB + Service Endpoint. - - - - \ No newline at end of file diff --git a/modules/networking/main.tf b/modules/networking/main.tf deleted file mode 100644 index 2333245..0000000 --- a/modules/networking/main.tf +++ /dev/null @@ -1,34 +0,0 @@ -resource "azurerm_virtual_network" "vnet" { - name = var.virtual_network_name - location = var.location - resource_group_name = var.network_resource_group_name - address_space = var.vnet_address_space - tags = var.tags -} - -# Azure Virtual Network Subnets -resource "azurerm_subnet" "subnet" { - for_each = { for each in var.subnet_config : each.subnet_name => each } - - resource_group_name = var.network_resource_group_name - virtual_network_name = azurerm_virtual_network.vnet.name - name = each.value.subnet_name - address_prefixes = each.value.subnet_address_space - service_endpoints = each.value.service_endpoints - private_link_service_network_policies_enabled = each.value.private_link_service_network_policies_enabled - private_endpoint_network_policies_enabled = each.value.private_endpoint_network_policies_enabled - - dynamic "delegation" { - for_each = each.value.subnets_delegation_settings - content { - name = delegation.key - dynamic "service_delegation" { - for_each = toset(delegation.value) - content { - name = service_delegation.value.name - actions = service_delegation.value.actions - } - } - } - } -} diff --git a/modules/networking/outputs.tf b/modules/networking/outputs.tf deleted file mode 100644 index 66ae07e..0000000 --- a/modules/networking/outputs.tf +++ /dev/null @@ -1,9 +0,0 @@ -output "virtual_network_id" { - description = "The ID of the Virtual Network" - value = azurerm_virtual_network.vnet.id -} - -output "subnet_ids" { - description = "The IDs of the Subnets" - value = { for each in azurerm_subnet.subnet : each.name => each.id } -} \ No newline at end of file diff --git a/modules/networking/variables.tf b/modules/networking/variables.tf deleted file mode 100644 index 584dc39..0000000 --- a/modules/networking/variables.tf +++ /dev/null @@ -1,66 +0,0 @@ -variable "network_resource_group_name" { - type = string - description = "Name of the resource group to where networking resources will be hosted." - nullable = false -} - -variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." -} - -variable "tags" { - type = map(string) - default = { - Terraform = "True" - Description = "OpenAI Private Networking Resource." - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" - } - description = "A map of key value pairs that is used to tag resources created." -} - -variable "virtual_network_name" { - type = string - default = "openai-vnet" - description = "Name of the virtual network to create." -} - -variable "vnet_address_space" { - type = list(string) - default = ["10.4.0.0/16"] - description = "value of the address space for the virtual network." -} - -variable "subnet_config" { - type = list(object({ - subnet_name = string - subnet_address_space = list(string) - service_endpoints = list(string) - private_endpoint_network_policies_enabled = bool - private_link_service_network_policies_enabled = bool - subnets_delegation_settings = map(list(object({ - name = string - actions = list(string) - }))) - })) - default = [ - { - subnet_name = "app-cosmos-sub" - subnet_address_space = ["10.4.0.0/24"] - service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] - private_endpoint_network_policies_enabled = false - private_link_service_network_policies_enabled = false - subnets_delegation_settings = { - app-service-plan = [ - { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] - } - ] - } - } - ] - description = "A list of subnet configuration objects to create subnets in the virtual network." -} \ No newline at end of file diff --git a/modules/openai/README.md b/modules/openai/README.md deleted file mode 100644 index e1d9a2b..0000000 --- a/modules/openai/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Module: Azure Networking Resources (Optional) - -Create a new VNET and subnet/s for the CosmosDB and Web App resources to use. (Optional) -If existing networking resources are to be used, then the variables/names of the existing VNET and subnets must be provided as input variables to root the module (data sources): - -- Create a VNET. -- Create a Delegated Subnet for App Service + CosmosDB + Service Endpoint. - - - - \ No newline at end of file diff --git a/modules/openai/data.tf b/modules/openai/data.tf deleted file mode 100644 index 6ba33a9..0000000 --- a/modules/openai/data.tf +++ /dev/null @@ -1,11 +0,0 @@ -################################################## -# DATA # -################################################## -data "azurerm_client_config" "current" {} - -# Get OpenAI Service details -data "azurerm_cognitive_account" "openai" { - count = var.create_openai_service ? 0 : 1 - name = var.openai_account_name - resource_group_name = var.openai_resource_group_name -} \ No newline at end of file diff --git a/modules/openai/locals.tf b/modules/openai/locals.tf deleted file mode 100644 index 437d142..0000000 --- a/modules/openai/locals.tf +++ /dev/null @@ -1,11 +0,0 @@ -locals { - ## locals config for key vault firewall rules ## - kv_net_rules = [ - { - default_action = var.keyvault_firewall_default_action - bypass = var.keyvault_firewall_bypass - ip_rules = var.keyvault_firewall_allowed_ips - virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids - } - ] -} \ No newline at end of file diff --git a/modules/openai/main.tf b/modules/openai/main.tf deleted file mode 100644 index 134de73..0000000 --- a/modules/openai/main.tf +++ /dev/null @@ -1,104 +0,0 @@ -########################## -### Solution resources ### -########################## -# Key Vault - Create Key Vault to save cognitive account details -resource "azurerm_key_vault" "openai_kv" { - resource_group_name = var.keyvault_resource_group_name - location = var.location - #values from variable kv_config object - name = lower(var.kv_config.name) - sku_name = var.kv_config.sku - enable_rbac_authorization = true - tenant_id = data.azurerm_client_config.current.tenant_id - dynamic "network_acls" { - for_each = local.kv_net_rules - content { - default_action = network_acls.value.default_action - bypass = network_acls.value.bypass - ip_rules = network_acls.value.ip_rules - virtual_network_subnet_ids = network_acls.value.virtual_network_subnet_ids - } - } - tags = var.tags -} - -# Add "self" permission to key vault RBAC (to manange key vault secrets) -resource "azurerm_role_assignment" "kv_role_assigment" { - for_each = toset(["Key Vault Administrator"]) - role_definition_name = each.key - scope = azurerm_key_vault.openai_kv.id - principal_id = data.azurerm_client_config.current.object_id -} - - -################################################## -# CREATE OPENAI Service and Model Deployment # -################################################## -# IMPORTANT: If existing service and model exist # -# set 'var.create_model_deployment' = false # -# set 'var.create_openai_service' = false # -################################################## - -### OpenAI Service -module "create_openai_service" { - source = "./openai_service" - # Only deploy a new openai service 'var.create_openai_service' is true - count = var.create_openai_service == true ? 1 : 0 - resource_group_name = var.openai_resource_group_name - location = var.location - account_name = var.openai_account_name - sku_name = var.openai_sku_name - custom_subdomain_name = var.openai_custom_subdomain_name - dynamic_throttling_enabled = var.openai_dynamic_throttling_enabled - fqdns = var.openai_fqdns - local_auth_enabled = var.openai_local_auth_enabled - outbound_network_access_restricted = var.openai_outbound_network_access_restricted - public_network_access_enabled = var.openai_public_network_access_enabled - customer_managed_key = var.openai_customer_managed_key - identity = var.openai_identity - network_acls = var.openai_network_acls - storage = var.openai_storage - tags = var.tags -} - -### Model Deployments -module "create_model_deployment" { - source = "./model_deployment" - # Only deploy new model if 'var.create_model_deployment' is true (else use existing cognitive account) - count = var.create_model_deployment == true ? 1 : 0 - openai_resource_group_name = var.create_openai_service == true ? module.create_openai_service[0].openai_resource_group_name : var.openai_resource_group_name - openai_account_name = var.create_openai_service == true ? module.create_openai_service[0].openai_account_name : var.openai_account_name - model_deployment = var.model_deployment - depends_on = [module.create_openai_service] -} - -### Save OpenAI Cognitive Account details to Key Vault for consumption by other services -resource "azurerm_key_vault_secret" "openai_endpoint" { - name = "${var.openai_account_name}-openai-endpoint" - value = var.create_openai_service == true ? module.create_openai_service[0].openai_endpoint : data.azurerm_cognitive_account.openai[0].endpoint - key_vault_id = azurerm_key_vault.openai_kv.id - depends_on = [azurerm_role_assignment.kv_role_assigment] -} - -resource "azurerm_key_vault_secret" "openai_primary_key" { - name = "${var.openai_account_name}-openai-key" - value = var.create_openai_service == true ? module.create_openai_service[0].openai_primary_key : data.azurerm_cognitive_account.openai[0].primary_access_key - key_vault_id = azurerm_key_vault.openai_kv.id - depends_on = [azurerm_role_assignment.kv_role_assigment] -} - -resource "azurerm_key_vault_secret" "openai_model_deployment_id" { - for_each = { for each in var.model_deployment : each.deployment_id => each } - name = "${var.openai_account_name}-model-${each.value.deployment_id}-id" - value = each.value.deployment_id - key_vault_id = azurerm_key_vault.openai_kv.id - depends_on = [azurerm_role_assignment.kv_role_assigment] -} - -resource "azurerm_key_vault_secret" "openai_model" { - for_each = { for each in var.model_deployment : each.deployment_id => each } - name = "${var.openai_account_name}-model-${each.value.deployment_id}-name" - value = each.value.model_name - key_vault_id = azurerm_key_vault.openai_kv.id - depends_on = [azurerm_role_assignment.kv_role_assigment] -} diff --git a/modules/openai/model_deployment/README.md b/modules/openai/model_deployment/README.md deleted file mode 100644 index 45b02dd..0000000 --- a/modules/openai/model_deployment/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Create Model Deployments - -Sub module to create model deployments on an existing cognitive OpenAI service/account. - - -## Requirements - -No requirements. - -## Providers - -| Name | Version | -|------|---------| -| [azurerm](#provider\_azurerm) | n/a | - -## Modules - -No modules. - -## Resources - -| Name | Type | -|------|------| -| [azurerm_cognitive_deployment.model](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cognitive_deployment) | resource | -| [azurerm_cognitive_account.openai](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/cognitive_account) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [model\_deployment](#input\_model\_deployment) | type = list(object({
deployment\_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created.
model\_name = {
model\_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI.
model\_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created.
model\_version = (Required) The version of Cognitive Services Account Deployment model.
}
scale = {
scale\_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created.
scale\_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created.
scale\_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created.
scale\_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created.
scale\_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created.
}
rai\_policy\_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created.
})) |
list(object({
deployment_id = string
model_name = string
model_format = string
model_version = string
scale_type = string
scale_tier = optional(string)
scale_size = optional(number)
scale_family = optional(string)
scale_capacity = optional(number)
rai_policy_name = optional(string)
}))
| `[]` | no | -| [openai\_account\_name](#input\_openai\_account\_name) | Name of the OpenAI service. | `string` | `"demo-account"` | no | -| [openai\_resource\_group\_name](#input\_openai\_resource\_group\_name) | Name of the resource group where the cognitive account OpenAI service is hosted. | `string` | n/a | yes | - -## Outputs - -| Name | Description | -|------|-------------| -| [model\_deployment\_id](#output\_model\_deployment\_id) | The ID of the model deployment. | - \ No newline at end of file diff --git a/modules/openai/model_deployment/data.tf b/modules/openai/model_deployment/data.tf deleted file mode 100644 index 3f7c25e..0000000 --- a/modules/openai/model_deployment/data.tf +++ /dev/null @@ -1,4 +0,0 @@ -data "azurerm_cognitive_account" "openai" { - name = var.openai_account_name - resource_group_name = var.openai_resource_group_name -} \ No newline at end of file diff --git a/modules/openai/model_deployment/main.tf b/modules/openai/model_deployment/main.tf deleted file mode 100644 index a3e95d3..0000000 --- a/modules/openai/model_deployment/main.tf +++ /dev/null @@ -1,20 +0,0 @@ -resource "azurerm_cognitive_deployment" "model" { - for_each = { for each in var.model_deployment : each.deployment_id => each } - - cognitive_account_id = data.azurerm_cognitive_account.openai.id - name = each.value.deployment_id - rai_policy_name = each.value.rai_policy_name - - model { - format = each.value.model_format - name = each.value.model_name - version = each.value.model_version - } - scale { - type = each.value.scale_type - tier = each.value.scale_tier - size = each.value.scale_size - family = each.value.scale_family - capacity = each.value.scale_capacity - } -} \ No newline at end of file diff --git a/modules/openai/model_deployment/outputs.tf b/modules/openai/model_deployment/outputs.tf deleted file mode 100644 index e1c58f0..0000000 --- a/modules/openai/model_deployment/outputs.tf +++ /dev/null @@ -1,4 +0,0 @@ -output "model_deployment_id" { - description = "The ID of the model deployment." - value = { for k, v in azurerm_cognitive_deployment.model : k => v.id } -} \ No newline at end of file diff --git a/modules/openai/model_deployment/variables.tf b/modules/openai/model_deployment/variables.tf deleted file mode 100644 index 10d04aa..0000000 --- a/modules/openai/model_deployment/variables.tf +++ /dev/null @@ -1,46 +0,0 @@ -variable "openai_resource_group_name" { - type = string - description = "Name of the resource group where the cognitive account OpenAI service is hosted." - nullable = false -} - -variable "openai_account_name" { - type = string - description = "Name of the OpenAI service." - default = "demo-account" -} - -variable "model_deployment" { - type = list(object({ - deployment_id = string - model_name = string - model_format = string - model_version = string - scale_type = string - scale_tier = optional(string) - scale_size = optional(number) - scale_family = optional(string) - scale_capacity = optional(number) - rai_policy_name = optional(string) - })) - default = [] - description = <<-DESCRIPTION - type = list(object({ - deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. - model_name = { - model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. - model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. - model_version = (Required) The version of Cognitive Services Account Deployment model. - } - scale = { - scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. - scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. - scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. - scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. - scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. - } - rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. - })) - DESCRIPTION - nullable = false -} \ No newline at end of file diff --git a/modules/openai/openai_service/README.md b/modules/openai/openai_service/README.md deleted file mode 100644 index 006b849..0000000 --- a/modules/openai/openai_service/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Create OpenAI service - -This sub module will create the cognitive service and the resource group for the OpenAI service. - - -## Requirements - -No requirements. - -## Providers - -| Name | Version | -|------|---------| -| [azurerm](#provider\_azurerm) | n/a | - -## Modules - -No modules. - -## Resources - -| Name | Type | -|------|------| -| [azurerm_cognitive_account.openai](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cognitive_account) | resource | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [account\_name](#input\_account\_name) | The name of the OpenAI service. | `string` | `"demo-account"` | no | -| [custom\_subdomain\_name](#input\_custom\_subdomain\_name) | The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name) | `string` | `"demo-account"` | no | -| [customer\_managed\_key](#input\_customer\_managed\_key) | type = object({
key\_vault\_key\_id = (Required) The ID of the Key Vault Key which should be used to Encrypt the data in this OpenAI Account.
identity\_client\_id = (Optional) The Client ID of the User Assigned Identity that has access to the key. This property only needs to be specified when there're multiple identities attached to the OpenAI Account.
}) |
object({
key_vault_key_id = string
identity_client_id = optional(string)
})
| `null` | no | -| [dynamic\_throttling\_enabled](#input\_dynamic\_throttling\_enabled) | Determines whether or not dynamic throttling is enabled. If set to `true`, dynamic throttling will be enabled. If set to `false`, dynamic throttling will not be enabled. | `bool` | `null` | no | -| [fqdns](#input\_fqdns) | List of FQDNs allowed for the Cognitive Account. | `list(string)` | `null` | no | -| [identity](#input\_identity) | type = object({
type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
}) |
object({
type = string
identity_ids = optional(list(string))
})
| `null` | no | -| [local\_auth\_enabled](#input\_local\_auth\_enabled) | Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`. | `bool` | `true` | no | -| [location](#input\_location) | Azure region where resources will be hosted. | `string` | `"uksouth"` | no | -| [network\_acls](#input\_network\_acls) | type = set(object({
default\_action = (Required) The Default Action to use when no rules match from ip\_rules / virtual\_network\_rules. Possible values are `Allow` and `Deny`.
ip\_rules = (Optional) One or more IP Addresses, or CIDR Blocks which should be able to access the Cognitive Account.
virtual\_network\_rules = optional(set(object({
subnet\_id = (Required) The ID of a Subnet which should be able to access the OpenAI Account.
ignore\_missing\_vnet\_service\_endpoint = (Optional) Whether ignore missing vnet service endpoint or not. Default to `false`.
})))
})) |
set(object({
default_action = string
ip_rules = optional(set(string))
virtual_network_rules = optional(set(object({
subnet_id = string
ignore_missing_vnet_service_endpoint = optional(bool, false)
})))
}))
| `null` | no | -| [outbound\_network\_access\_restricted](#input\_outbound\_network\_access\_restricted) | Whether outbound network access is restricted for the Cognitive Account. Defaults to `false`. | `bool` | `false` | no | -| [public\_network\_access\_enabled](#input\_public\_network\_access\_enabled) | Whether public network access is allowed for the Cognitive Account. Defaults to `true`. | `bool` | `true` | no | -| [resource\_group\_name](#input\_resource\_group\_name) | Name of the resource group where the OpenAI service will be hosted. | `string` | n/a | yes | -| [sku\_name](#input\_sku\_name) | The SKU name of the OpenAI service. | `string` | `"S0"` | no | -| [storage](#input\_storage) | type = list(object({
storage\_account\_id = (Required) Full resource id of a Microsoft.Storage resource.
identity\_client\_id = (Optional) The client ID of the managed identity associated with the storage resource.
})) |
list(object({
storage_account_id = string
identity_client_id = optional(string)
}))
| `[]` | no | -| [tags](#input\_tags) | A map of key value pairs that is used to tag resources created. | `map(string)` |
{
"Author": "Marcel Lupo",
"Description": "OpenAI Cognitive service",
"GitHub": "https://github.com/Pwd9000-ML/terraform-azurerm-openai-service",
"Terraform": "True"
}
| no | - -## Outputs - -| Name | Description | -|------|-------------| -| [openai\_account\_name](#output\_openai\_account\_name) | The name of the Cognitive Service Account. | -| [openai\_endpoint](#output\_openai\_endpoint) | The endpoint used to connect to the Cognitive Service Account. | -| [openai\_primary\_key](#output\_openai\_primary\_key) | The primary access key for the Cognitive Service Account. | -| [openai\_resource\_group\_name](#output\_openai\_resource\_group\_name) | The name of the Resource Group hosting the Cognitive Service Account. | -| [openai\_secondary\_key](#output\_openai\_secondary\_key) | The secondary access key for the Cognitive Service Account. | -| [openai\_subdomain](#output\_openai\_subdomain) | The subdomain used to connect to the Cognitive Service Account. | - \ No newline at end of file diff --git a/modules/openai/openai_service/main.tf b/modules/openai/openai_service/main.tf deleted file mode 100644 index ca5125e..0000000 --- a/modules/openai/openai_service/main.tf +++ /dev/null @@ -1,55 +0,0 @@ -resource "azurerm_cognitive_account" "openai" { - kind = "OpenAI" - location = var.location - name = var.account_name - resource_group_name = var.resource_group_name - sku_name = var.sku_name - custom_subdomain_name = var.custom_subdomain_name - dynamic_throttling_enabled = var.dynamic_throttling_enabled - fqdns = var.fqdns - local_auth_enabled = var.local_auth_enabled - outbound_network_access_restricted = var.outbound_network_access_restricted - public_network_access_enabled = var.public_network_access_enabled - tags = var.tags - - dynamic "customer_managed_key" { - for_each = var.customer_managed_key != null ? [var.customer_managed_key] : [] - content { - key_vault_key_id = customer_managed_key.value.key_vault_key_id - identity_client_id = customer_managed_key.value.identity_client_id - } - } - - dynamic "identity" { - for_each = var.identity != null ? [var.identity] : [] - content { - type = identity.value.type - identity_ids = identity.value.identity_ids - } - } - - dynamic "network_acls" { - for_each = var.network_acls != null ? [var.network_acls] : [] - content { - default_action = network_acls.value.default_action - ip_rules = network_acls.value.ip_rules - - dynamic "virtual_network_rules" { - for_each = network_acls.value.virtual_network_rules != null ? network_acls.value.virtual_network_rules : [] - content { - subnet_id = virtual_network_rules.value.subnet_id - ignore_missing_vnet_service_endpoint = virtual_network_rules.value.ignore_missing_vnet_service_endpoint - } - } - } - } - - dynamic "storage" { - for_each = var.storage - content { - storage_account_id = storage.value.storage_account_id - identity_client_id = storage.value.identity_client_id - } - } -} - diff --git a/modules/openai/openai_service/outputs.tf b/modules/openai/openai_service/outputs.tf deleted file mode 100644 index 0aaca6d..0000000 --- a/modules/openai/openai_service/outputs.tf +++ /dev/null @@ -1,31 +0,0 @@ -output "openai_endpoint" { - description = "The endpoint used to connect to the Cognitive Service Account." - value = azurerm_cognitive_account.openai.endpoint -} - -output "openai_primary_key" { - description = "The primary access key for the Cognitive Service Account." - sensitive = true - value = azurerm_cognitive_account.openai.primary_access_key -} - -output "openai_secondary_key" { - description = "The secondary access key for the Cognitive Service Account." - sensitive = true - value = azurerm_cognitive_account.openai.secondary_access_key -} - -output "openai_subdomain" { - description = "The subdomain used to connect to the Cognitive Service Account." - value = azurerm_cognitive_account.openai.custom_subdomain_name -} - -output "openai_account_name" { - description = "The name of the Cognitive Service Account." - value = var.account_name -} - -output "openai_resource_group_name" { - description = "The name of the Resource Group hosting the Cognitive Service Account." - value = var.resource_group_name -} \ No newline at end of file diff --git a/modules/openai/openai_service/variables.tf b/modules/openai/openai_service/variables.tf deleted file mode 100644 index fc6d1a0..0000000 --- a/modules/openai/openai_service/variables.tf +++ /dev/null @@ -1,136 +0,0 @@ -variable "resource_group_name" { - type = string - description = "Name of the resource group where the OpenAI service will be hosted." - nullable = false -} - -variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." -} - -variable "tags" { - type = map(string) - default = { - Terraform = "True" - Description = "OpenAI Cognitive service" - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-service" - } - description = "A map of key value pairs that is used to tag resources created." -} - -variable "account_name" { - type = string - default = "demo-account" - description = "The name of the OpenAI service." -} - -variable "sku_name" { - type = string - default = "S0" - description = "The SKU name of the OpenAI service." -} - -variable "custom_subdomain_name" { - type = string - default = "demo-account" - description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" -} - -variable "dynamic_throttling_enabled" { - type = bool - default = null - description = "Determines whether or not dynamic throttling is enabled. If set to `true`, dynamic throttling will be enabled. If set to `false`, dynamic throttling will not be enabled." -} - -variable "fqdns" { - type = list(string) - default = null - description = "List of FQDNs allowed for the Cognitive Account." -} - -variable "local_auth_enabled" { - type = bool - default = true - description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." -} - -variable "outbound_network_access_restricted" { - type = bool - default = false - description = "Whether outbound network access is restricted for the Cognitive Account. Defaults to `false`." -} - - -variable "public_network_access_enabled" { - type = bool - default = true - description = "Whether public network access is allowed for the Cognitive Account. Defaults to `true`." -} - -variable "customer_managed_key" { - type = object({ - key_vault_key_id = string - identity_client_id = optional(string) - }) - default = null - description = <<-DESCRIPTION - type = object({ - key_vault_key_id = (Required) The ID of the Key Vault Key which should be used to Encrypt the data in this OpenAI Account. - identity_client_id = (Optional) The Client ID of the User Assigned Identity that has access to the key. This property only needs to be specified when there're multiple identities attached to the OpenAI Account. - }) - DESCRIPTION -} - -variable "identity" { - type = object({ - type = string - identity_ids = optional(list(string)) - }) - default = null - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -variable "network_acls" { - type = set(object({ - default_action = string - ip_rules = optional(set(string)) - virtual_network_rules = optional(set(object({ - subnet_id = string - ignore_missing_vnet_service_endpoint = optional(bool, false) - }))) - })) - default = null - description = <<-DESCRIPTION - type = set(object({ - default_action = (Required) The Default Action to use when no rules match from ip_rules / virtual_network_rules. Possible values are `Allow` and `Deny`. - ip_rules = (Optional) One or more IP Addresses, or CIDR Blocks which should be able to access the Cognitive Account. - virtual_network_rules = optional(set(object({ - subnet_id = (Required) The ID of a Subnet which should be able to access the OpenAI Account. - ignore_missing_vnet_service_endpoint = (Optional) Whether ignore missing vnet service endpoint or not. Default to `false`. - }))) - })) - DESCRIPTION -} - -variable "storage" { - type = list(object({ - storage_account_id = string - identity_client_id = optional(string) - })) - default = [] - description = <<-DESCRIPTION - type = list(object({ - storage_account_id = (Required) Full resource id of a Microsoft.Storage resource. - identity_client_id = (Optional) The client ID of the managed identity associated with the storage resource. - })) - DESCRIPTION - nullable = false -} \ No newline at end of file diff --git a/modules/openai/outputs.tf b/modules/openai/outputs.tf deleted file mode 100644 index 7593b94..0000000 --- a/modules/openai/outputs.tf +++ /dev/null @@ -1,46 +0,0 @@ -################################################# -# OUTPUTS # -################################################# -### openai account outputs ### -output "openai_endpoint" { - description = "The endpoint used to connect to the Cognitive Service Account." - value = var.create_openai_service ? module.create_openai_service[0].openai_endpoint : data.azurerm_cognitive_account.openai[0].endpoint -} - -output "openai_primary_key" { - description = "The primary access key for the Cognitive Service Account." - sensitive = true - value = var.create_openai_service ? module.create_openai_service[0].openai_primary_key : data.azurerm_cognitive_account.openai[0].primary_access_key -} - -output "openai_secondary_key" { - description = "The secondary access key for the Cognitive Service Account." - sensitive = true - value = var.create_openai_service ? module.create_openai_service[0].openai_secondary_key : data.azurerm_cognitive_account.openai[0].secondary_access_key -} - -output "openai_subdomain" { - description = "The subdomain used to connect to the Cognitive Service Account." - value = var.create_openai_service ? module.create_openai_service[0].openai_subdomain : var.openai_custom_subdomain_name -} - -output "openai_account_name" { - description = "The name of the Cognitive Service Account." - value = var.create_openai_service ? module.create_openai_service[0].openai_account_name : var.openai_account_name -} - -output "openai_resource_group_name" { - description = "The name of the Resource Group hosting the Cognitive Service Account." - value = var.create_openai_service ? module.create_openai_service[0].openai_resource_group_name : var.openai_resource_group_name -} - -### key vault outputs ### -output "key_vault_id" { - description = "The ID of the Key Vault." - value = azurerm_key_vault.openai_kv.id -} - -output "key_vault_uri" { - description = "The URI of the Key Vault." - value = azurerm_key_vault.openai_kv.vault_uri -} diff --git a/modules/openai/variables.tf b/modules/openai/variables.tf deleted file mode 100644 index e810391..0000000 --- a/modules/openai/variables.tf +++ /dev/null @@ -1,236 +0,0 @@ -################################################## -# VARIABLES # -################################################## -###Common### -variable "tags" { - type = map(string) - default = { - Terraform = "True" - Description = "Azure OpenAI service." - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-service" - } - description = "A map of key value pairs that is used to tag resources created." -} - -variable "location" { - type = string - default = "uksouth" - description = "Azure region to deploy resources to." -} - -# solution resource group -variable "keyvault_resource_group_name" { - type = string - description = "Name of the resource group where the Key Vault will be hosted." - nullable = false -} - -###Key Vault### -variable "kv_config" { - type = object({ - name = string - sku = string - }) - default = { - name = "openaikv9000" - sku = "standard" - } - description = "Key Vault configuration object to create azure key vault to store openai account details." - nullable = false -} - -variable "keyvault_firewall_default_action" { - type = string - default = "Deny" - description = "Default action for key vault firewall rules." -} - -variable "keyvault_firewall_bypass" { - type = string - default = "AzureServices" - description = "List of key vault firewall rules to bypass." -} - -variable "keyvault_firewall_allowed_ips" { - type = list(string) - default = [] - description = "value of key vault firewall allowed ip rules." -} - -variable "keyvault_firewall_virtual_network_subnet_ids" { - type = list(string) - default = [] - description = "value of key vault firewall allowed virtual network subnet ids." -} - -########################################## -# OpenAI Service # -########################################## -variable "create_openai_service" { - type = bool - description = "Create the OpenAI service." - default = false -} - -variable "openai_resource_group_name" { - type = string - description = "Name of the resource group where the cognitive account OpenAI service is hosted (if different from solution resource group)." - nullable = false -} - -variable "openai_account_name" { - type = string - description = "Name of the OpenAI service." - default = "demo-account" -} - -variable "openai_sku_name" { - type = string - description = "SKU name of the OpenAI service." - default = "S0" -} - -variable "openai_custom_subdomain_name" { - type = string - description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created (normally the same as variable `openai_account_name`)" - default = "demo-account" -} - -variable "openai_dynamic_throttling_enabled" { - type = bool - description = "Determines whether or not dynamic throttling is enabled. If set to `true`, dynamic throttling will be enabled. If set to `false`, dynamic throttling will not be enabled." - default = null -} - -variable "openai_fqdns" { - type = list(string) - description = "List of FQDNs allowed for the Cognitive Account." - default = null -} - -variable "openai_local_auth_enabled" { - type = bool - description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." - default = true -} - -variable "openai_outbound_network_access_restricted" { - type = bool - description = "Whether or not outbound network access is restricted." - default = false -} - -variable "openai_public_network_access_enabled" { - type = bool - description = "Whether or not public network access is enabled for the Cognitive Account." - default = true -} -variable "openai_customer_managed_key" { - type = object({ - key_vault_key_id = string - identity_client_id = optional(string) - }) - default = null - description = <<-DESCRIPTION - type = object({ - key_vault_key_id = (Required) The ID of the Key Vault Key which should be used to Encrypt the data in this OpenAI Account. - identity_client_id = (Optional) The Client ID of the User Assigned Identity that has access to the key. This property only needs to be specified when there're multiple identities attached to the OpenAI Account. - }) - DESCRIPTION -} - -variable "openai_identity" { - type = object({ - type = string - identity_ids = optional(list(string)) - }) - default = null - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -variable "openai_network_acls" { - type = set(object({ - default_action = string - ip_rules = optional(set(string)) - virtual_network_rules = optional(set(object({ - subnet_id = string - ignore_missing_vnet_service_endpoint = optional(bool, false) - }))) - })) - default = null - description = <<-DESCRIPTION - type = set(object({ - default_action = (Required) The Default Action to use when no rules match from ip_rules / virtual_network_rules. Possible values are `Allow` and `Deny`. - ip_rules = (Optional) One or more IP Addresses, or CIDR Blocks which should be able to access the Cognitive Account. - virtual_network_rules = optional(set(object({ - subnet_id = (Required) The ID of a Subnet which should be able to access the OpenAI Account. - ignore_missing_vnet_service_endpoint = (Optional) Whether ignore missing vnet service endpoint or not. Default to `false`. - }))) - })) - DESCRIPTION -} - -variable "openai_storage" { - type = list(object({ - storage_account_id = string - identity_client_id = optional(string) - })) - default = [] - description = <<-DESCRIPTION - type = list(object({ - storage_account_id = (Required) Full resource id of a Microsoft.Storage resource. - identity_client_id = (Optional) The client ID of the managed identity associated with the storage resource. - })) - DESCRIPTION - nullable = false -} - -########################################## -# Model Deployment # -########################################## -variable "create_model_deployment" { - type = bool - description = "Create the model deployment." - default = false -} - -variable "model_deployment" { - type = list(object({ - deployment_id = string - model_name = string - model_format = string - model_version = string - scale_type = string - scale_tier = optional(string) - scale_size = optional(number) - scale_family = optional(string) - scale_capacity = optional(number) - rai_policy_name = optional(string) - })) - default = [] - description = <<-DESCRIPTION - type = list(object({ - deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. - model_name = { - model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. - model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. - model_version = (Required) The version of Cognitive Services Account Deployment model. - } - scale = { - scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. - scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. - scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. - scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. - scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. - } - rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. - })) - DESCRIPTION - nullable = false -} \ No newline at end of file diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 87b48fe..e86bcb2 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -20,13 +20,6 @@ resource "random_integer" "number" { max = 9999 } -# ### Resource group to deploy the container apps private ChatGPT instance and supporting resources into -# resource "azurerm_resource_group" "rg" { -# name = var.resource_group_name -# location = var.location -# tags = var.tags -# } - # ################################################## # # MODULE TO TEST # # ################################################## diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 9bb7715..bc2fb9c 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -9,7 +9,7 @@ tags = { } ### 02 networking ### -virtual_network_name = "openai-vnet-9000" +virtual_network_name = "openaivnet" vnet_address_space = ["10.4.0.0/24"] subnet_config = { subnet_name = "app-cosmos-sub" @@ -28,14 +28,14 @@ subnet_config = { } ### 03 KeyVault ### -kv_name = "openaikv9000" +kv_name = "openaikv" kv_sku = "standard" kv_fw_default_action = "Deny" kv_fw_bypass = "AzureServices" kv_fw_allowed_ips = ["0.0.0.0/0"] ### 04 Create OpenAI Service ### -oai_account_name = "gptopenai" +oai_account_name = "gptopenaiaccount" oai_sku_name = "S0" oai_custom_subdomain_name = "gptopenai" oai_dynamic_throttling_enabled = true From 28b509ff9650e9500c0bc3bc7de6605e0076fcf0 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 10:31:59 +0000 Subject: [PATCH 062/163] fix sub domain name --- tests/auto_test1/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index e86bcb2..8e149fe 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -46,7 +46,7 @@ module "private-chatgpt-openai" { #04 openai service oai_account_name = "${var.oai_account_name}${random_integer.number.result}" oai_sku_name = var.oai_sku_name - oai_custom_subdomain_name = var.oai_custom_subdomain_name + oai_custom_subdomain_name = "${var.oai_custom_subdomain_name}${random_integer.number.result}" oai_dynamic_throttling_enabled = var.oai_dynamic_throttling_enabled oai_fqdns = var.oai_fqdns oai_local_auth_enabled = var.oai_local_auth_enabled From 30a02eb67850c29881ce0bfd03f1d560997b9d19 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 10:40:38 +0000 Subject: [PATCH 063/163] test --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index bc2fb9c..6ac519f 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -38,7 +38,7 @@ kv_fw_allowed_ips = ["0.0.0.0/0"] oai_account_name = "gptopenaiaccount" oai_sku_name = "S0" oai_custom_subdomain_name = "gptopenai" -oai_dynamic_throttling_enabled = true +oai_dynamic_throttling_enabled = false oai_fqdns = [] oai_local_auth_enabled = true oai_outbound_network_access_restricted = false From 39f848375c2f21f354953bf956e03e1224000fc9 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 11:42:06 +0000 Subject: [PATCH 064/163] deploy cosmosdb --- 04_az_openai.tf | 16 -- 05_cosmosdb.tf | 48 ++++ modules/cosmosdb/main.tf | 8 - tests/auto_test1/main.tf | 16 ++ tests/auto_test1/testing.auto.tfvars | 40 +-- tests/auto_test1/variables.tf | 161 ++++++------ variables.tf | 380 ++++++--------------------- 7 files changed, 232 insertions(+), 437 deletions(-) create mode 100644 05_cosmosdb.tf diff --git a/04_az_openai.tf b/04_az_openai.tf index 90e6631..9713afa 100644 --- a/04_az_openai.tf +++ b/04_az_openai.tf @@ -90,19 +90,3 @@ resource "azurerm_key_vault_secret" "openai_primary_key" { key_vault_id = azurerm_key_vault.az_openai_kv.id depends_on = [azurerm_role_assignment.kv_role_assigment] } - -resource "azurerm_key_vault_secret" "openai_model_deployment_id" { - for_each = { for each in var.oai_model_deployment : each.deployment_id => each } - name = "${var.oai_account_name}-model-${each.value.deployment_id}-id" - value = each.value.deployment_id - key_vault_id = azurerm_key_vault.az_openai_kv.id - depends_on = [azurerm_role_assignment.kv_role_assigment] -} - -resource "azurerm_key_vault_secret" "openai_model" { - for_each = { for each in var.oai_model_deployment : each.deployment_id => each } - name = "${var.oai_account_name}-model-${each.value.deployment_id}-name" - value = each.value.model_name - key_vault_id = azurerm_key_vault.az_openai_kv.id - depends_on = [azurerm_role_assignment.kv_role_assigment] -} diff --git a/05_cosmosdb.tf b/05_cosmosdb.tf new file mode 100644 index 0000000..331f627 --- /dev/null +++ b/05_cosmosdb.tf @@ -0,0 +1,48 @@ +resource "azurerm_cosmosdb_account" "mongo" { + name = var.cosmosdb_name + resource_group_name = azurerm_resource_group.az_openai_rg.name + location = var.location + offer_type = var.cosmosdb_offer_type + kind = var.cosmosdb_kind + enable_automatic_failover = var.cosmosdb_automatic_failover + enable_free_tier = var.use_cosmosdb_free_tier + tags = var.tags + + consistency_policy { + consistency_level = var.cosmosdb_consistency_level + max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds + max_staleness_prefix = var.cosmosdb_max_staleness_prefix + } + + dynamic "geo_location" { + for_each = var.cosmosdb_geo_locations + content { + location = geo_location.value.location + failover_priority = geo_location.value.failover_priority + } + } + + dynamic "capabilities" { + for_each = var.cosmosdb_capabilities + content { + name = capabilities.value + } + } + + dynamic "virtual_network_rule" { + for_each = var.cosmosdb_virtual_network_subnets != null ? var.cosmosdb_virtual_network_subnets : azurerm_subnet.az_openai_subnet.*.id + content { + id = virtual_network_rule.value + } + } + + is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled + public_network_access_enabled = var.cosmosdb_public_network_access_enabled +} + +### Save CosmosDB details to Key Vault for consumption by other services (e.g. LibreChat App) +resource "azurerm_key_vault_secret" "openai_cosmos_uri" { + name = "${var.cosmosdb_name}-cosmos-uri" + value = azurerm_cosmosdb_account.mongo.primary_mongodb_connection_string + key_vault_id = azurerm_key_vault.az_openai_kv.id +} \ No newline at end of file diff --git a/modules/cosmosdb/main.tf b/modules/cosmosdb/main.tf index 531a47f..825d07f 100644 --- a/modules/cosmosdb/main.tf +++ b/modules/cosmosdb/main.tf @@ -40,14 +40,6 @@ resource "azurerm_cosmosdb_account" "mongo" { public_network_access_enabled = var.public_network_access_enabled } -# Add "self" permission to key vault RBAC (to manange key vault secrets) -# resource "azurerm_role_assignment" "kv_role_assigment" { -# for_each = toset(["Key Vault Administrator"]) -# role_definition_name = each.key -# scope = var.openai_keyvault_id -# principal_id = data.azurerm_client_config.current.object_id -# } - ### Save CosmosDB details to Key Vault for consumption by other services (e.g. LibreChat App) resource "azurerm_key_vault_secret" "openai_cosmos_uri" { name = "${var.cosmosdb_name}-cosmos-uri" diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 8e149fe..1a300fa 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -57,6 +57,22 @@ module "private-chatgpt-openai" { oai_network_acls = var.oai_network_acls oai_storage = var.oai_storage oai_model_deployment = var.oai_model_deployment + + #05 cosmosdb + cosmosdb_name = "${var.cosmosdb_name}${random_integer.number.result}" + cosmosdb_offer_type = var.cosmosdb_offer_type + cosmosdb_kind = var.cosmosdb_kind + cosmosdb_automatic_failover = var.cosmosdb_automatic_failover + use_cosmosdb_free_tier = var.use_cosmosdb_free_tier + cosmosdb_consistency_level = var.cosmosdb_consistency_level + cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds + cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix + cosmosdb_geo_locations = var.cosmosdb_geo_locations + cosmosdb_capabilities = var.cosmosdb_capabilities + cosmosdb_virtual_network_subnets = var.cosmosdb_virtual_network_subnets + cosmosdb_is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled + cosmosdb_public_network_access_enabled = var.cosmosdb_public_network_access_enabled + } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 6ac519f..c272dec 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -60,26 +60,26 @@ oai_model_deployment = [ } ] -# ### cosmosdb ### -# create_cosmosdb = true -# cosmosdb_name = "gptcosmos" -# cosmosdb_resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -# cosmosdb_offer_type = "Standard" -# cosmosdb_kind = "MongoDB" -# cosmosdb_automatic_failover = false -# use_cosmosdb_free_tier = true -# cosmosdb_consistency_level = "BoundedStaleness" -# cosmosdb_max_interval_in_seconds = 10 -# cosmosdb_max_staleness_prefix = 200 -# cosmosdb_geo_locations = [ -# { -# location = "uksouth" -# failover_priority = 0 -# } -# ] -# cosmosdb_capabilities = ["EnableMongo", "MongoDBv3.4"] -# cosmosdb_is_virtual_network_filter_enabled = true -# cosmosdb_public_network_access_enabled = true +### 05 cosmosdb ### + +cosmosdb_name = "gptcosmosdb" +cosmosdb_offer_type = "Standard" +cosmosdb_kind = "MongoDB" +cosmosdb_automatic_failover = false +use_cosmosdb_free_tier = true +cosmosdb_consistency_level = "BoundedStaleness" +cosmosdb_max_interval_in_seconds = 10 +cosmosdb_max_staleness_prefix = 200 +cosmosdb_geo_locations = [ + { + location = "uksouth" + failover_priority = 0 + } +] +cosmosdb_capabilities = ["EnableMongo", "MongoDBv3.4"] +cosmosdb_virtual_network_subnets = null +cosmosdb_is_virtual_network_filter_enabled = true +cosmosdb_public_network_access_enabled = true # ### log analytics workspace for container apps ### # #laws_name = "gptlaws" diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 0a94280..779eabf 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -243,105 +243,92 @@ variable "oai_model_deployment" { nullable = false } +### 05 OpenAI CosmosDB ### +variable "cosmosdb_name" { + description = "The name of the Cosmos DB account" + type = string + default = "openaicosmosdb" +} -# ### cosmosdb ### -# variable "create_cosmosdb" { -# description = "Create a CosmosDB account running MongoDB to store chat data." -# type = bool -# default = false -# } - -# variable "cosmosdb_name" { -# description = "The name of the Cosmos DB account" -# type = string -# default = "openaicosmosdb" -# } - -# variable "cosmosdb_resource_group_name" { -# description = "The name of the resource group in which to create the Cosmos DB account" -# type = string -# nullable = false -# } - -# variable "cosmosdb_offer_type" { -# description = "The offer type to use for the Cosmos DB account" -# type = string -# default = "Standard" -# } +variable "cosmosdb_offer_type" { + description = "The offer type to use for the Cosmos DB account" + type = string + default = "Standard" +} -# variable "cosmosdb_kind" { -# description = "The kind of Cosmos DB to create" -# type = string -# default = "MongoDB" -# } +variable "cosmosdb_kind" { + description = "The kind of Cosmos DB to create" + type = string + default = "MongoDB" +} -# variable "cosmosdb_automatic_failover" { -# description = "Whether to enable automatic failover for the Cosmos DB account" -# type = bool -# default = false -# } +variable "cosmosdb_automatic_failover" { + description = "Whether to enable automatic failover for the Cosmos DB account" + type = bool + default = false +} -# variable "use_cosmosdb_free_tier" { -# description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." -# type = bool -# default = true -# } +variable "use_cosmosdb_free_tier" { + description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." + type = bool + default = true +} -# variable "cosmosdb_consistency_level" { -# description = "The consistency level of the Cosmos DB account" -# type = string -# default = "BoundedStaleness" -# } +variable "cosmosdb_consistency_level" { + description = "The consistency level of the Cosmos DB account" + type = string + default = "BoundedStaleness" +} -# variable "cosmosdb_max_interval_in_seconds" { -# description = "The maximum staleness interval in seconds for the Cosmos DB account" -# type = number -# default = 10 -# } +variable "cosmosdb_max_interval_in_seconds" { + description = "The maximum staleness interval in seconds for the Cosmos DB account" + type = number + default = 10 +} -# variable "cosmosdb_max_staleness_prefix" { -# description = "The maximum staleness prefix for the Cosmos DB account" -# type = number -# default = 200 -# } +variable "cosmosdb_max_staleness_prefix" { + description = "The maximum staleness prefix for the Cosmos DB account" + type = number + default = 200 +} -# variable "cosmosdb_geo_locations" { -# description = "The geo-locations for the Cosmos DB account" -# type = list(object({ -# location = string -# failover_priority = number -# })) -# default = [ -# { -# location = "uksouth" -# failover_priority = 0 -# } -# ] -# } +variable "cosmosdb_geo_locations" { + description = "The geo-locations for the Cosmos DB account" + type = list(object({ + location = string + failover_priority = number + })) + default = [ + { + location = "uksouth" + failover_priority = 0 + } + ] +} -# variable "cosmosdb_capabilities" { -# description = "The capabilities for the Cosmos DB account" -# type = list(string) -# default = ["EnableMongo", "MongoDBv3.4"] -# } +variable "cosmosdb_capabilities" { + description = "The capabilities for the Cosmos DB account" + type = list(string) + default = ["EnableMongo", "MongoDBv3.4"] +} -# variable "cosmosdb_virtual_network_subnets" { -# description = "The virtual network subnets to associate with the Cosmos DB account" -# type = list(string) -# default = null -# } +variable "cosmosdb_virtual_network_subnets" { + description = "The virtual network subnets to associate with the Cosmos DB account (Service Endpoint). If networking is created as part of the module, this will be automatically populated." + type = list(string) + default = null +} -# variable "cosmosdb_is_virtual_network_filter_enabled" { -# description = "Whether to enable virtual network filtering for the Cosmos DB account" -# type = bool -# default = true -# } +variable "cosmosdb_is_virtual_network_filter_enabled" { + description = "Whether to enable virtual network filtering for the Cosmos DB account" + type = bool + default = true +} -# variable "cosmosdb_public_network_access_enabled" { -# description = "Whether to enable public network access for the Cosmos DB account" -# type = bool -# default = true -# } +variable "cosmosdb_public_network_access_enabled" { + description = "Whether to enable public network access for the Cosmos DB account" + type = bool + default = true +} # ### log analytics workspace ### # #variable "laws_name" { diff --git a/variables.tf b/variables.tf index 6702dd3..1017154 100644 --- a/variables.tf +++ b/variables.tf @@ -247,324 +247,92 @@ variable "oai_model_deployment" { nullable = false } -# #################################### -# ### OpenAI service Module params ### -# #################################### -# ### key vault ### -# variable "keyvault_resource_group_name" { -# type = string -# description = "Name of the resource group to create the Key Vault that will store OpenAI service account details." -# nullable = false -# } - -# variable "kv_config" { -# type = object({ -# name = string -# sku = string -# }) -# default = { -# name = "kvname" -# sku = "standard" -# } -# description = "Key Vault configuration object to create azure key vault to store openai account details." -# nullable = false -# } - -# variable "keyvault_firewall_default_action" { -# type = string -# default = "Deny" -# description = "Default action for key vault firewall rules." -# } - -# variable "keyvault_firewall_bypass" { -# type = string -# default = "AzureServices" -# description = "List of key vault firewall rules to bypass." -# } - -# variable "keyvault_firewall_allowed_ips" { -# type = list(string) -# default = [] -# description = "value of key vault firewall allowed ip rules." -# } - -# variable "keyvault_firewall_virtual_network_subnet_ids" { -# type = list(string) -# default = [] -# description = "value of key vault firewall allowed virtual network subnet ids." -# } - -# ### openai service ### -# variable "openai_resource_group_name" { -# type = string -# description = "Name of the resource group to create the OpenAI service / or where an existing service is hosted." -# nullable = false -# } - -# variable "create_openai_service" { -# type = bool -# description = "Create the OpenAI service." -# default = false -# } - -# variable "openai_account_name" { -# type = string -# description = "Name of the OpenAI service." -# default = "demo-account" -# } - -# variable "openai_custom_subdomain_name" { -# type = string -# description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" -# default = "demo-account" -# } - -# variable "openai_sku_name" { -# type = string -# description = "SKU name of the OpenAI service." -# default = "S0" -# } - -# variable "openai_local_auth_enabled" { -# type = bool -# default = true -# description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." -# } - -# variable "openai_outbound_network_access_restricted" { -# type = bool -# default = false -# description = "Whether or not outbound network access is restricted. Defaults to `false`." -# } - -# variable "openai_public_network_access_enabled" { -# type = bool -# default = true -# description = "Whether or not public network access is enabled. Defaults to `false`." -# } - -# variable "openai_identity" { -# type = object({ -# type = string -# }) -# default = { -# type = "SystemAssigned" -# } -# description = <<-DESCRIPTION -# type = object({ -# type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. -# identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. -# }) -# DESCRIPTION -# } - -# ### model deployment ### -# variable "create_model_deployment" { -# type = bool -# description = "Create the model deployment." -# default = false -# } - -# variable "model_deployment" { -# type = list(object({ -# deployment_id = string -# model_name = string -# model_format = string -# model_version = string -# scale_type = string -# scale_tier = optional(string) -# scale_size = optional(number) -# scale_family = optional(string) -# scale_capacity = optional(number) -# rai_policy_name = optional(string) -# })) -# default = [] -# description = <<-DESCRIPTION -# type = list(object({ -# deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. -# model_name = { -# model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. -# model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. -# model_version = (Required) The version of Cognitive Services Account Deployment model. -# } -# scale = { -# scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. -# scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. -# scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. -# scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. -# scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. -# } -# rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. -# })) -# DESCRIPTION -# nullable = false -# } - -# ##################################### -# ### Network service Module params ### -# ##################################### -# variable "create_openai_networking" { -# description = "Create a virtual network and subnet/s for networked services" -# type = bool -# default = false -# } - -# variable "network_resource_group_name" { -# type = string -# description = "Name of the resource group to where networking resources will be hosted." -# nullable = false -# } - -# variable "virtual_network_name" { -# type = string -# default = null -# description = "Name of the virtual network where resources are attached." -# } - -# variable "vnet_address_space" { -# type = list(string) -# default = null -# description = "value of the address space for the virtual network." -# } - -# variable "subnet_config" { -# type = list(object({ -# subnet_name = string -# subnet_address_space = list(string) -# service_endpoints = list(string) -# private_endpoint_network_policies_enabled = bool -# private_link_service_network_policies_enabled = bool -# subnets_delegation_settings = map(list(object({ -# name = string -# actions = list(string) -# }))) -# })) -# default = [ -# { -# subnet_name = "app-cosmos-sub" -# subnet_address_space = ["10.4.0.0/24"] -# service_endpoints = ["Microsoft.AzureCosmosDB", "Microsoft.Web"] -# private_endpoint_network_policies_enabled = false -# private_link_service_network_policies_enabled = false -# subnets_delegation_settings = { -# app-service-plan = [ -# { -# name = "Microsoft.Web/serverFarms" -# actions = ["Microsoft.Network/virtualNetworks/subnets/action"] -# } -# ] -# } -# } -# ] -# description = "A list of subnet configuration objects to create subnets in the virtual network." -# } - -# ###################################### -# ### CosmosDB service Module params ### -# ###################################### -# variable "create_cosmosdb" { -# description = "Create a CosmosDB account running MongoDB to store chat data." -# type = bool -# default = false -# } - -# variable "cosmosdb_name" { -# description = "The name of the Cosmos DB account" -# type = string -# default = "openaicosmosdb" -# } - -# variable "cosmosdb_resource_group_name" { -# description = "The name of the resource group in which to create the Cosmos DB account" -# type = string -# nullable = false -# } - -# variable "cosmosdb_offer_type" { -# description = "The offer type to use for the Cosmos DB account" -# type = string -# default = "Standard" -# } +### 05 OpenAI CosmosDB ### +variable "cosmosdb_name" { + description = "The name of the Cosmos DB account" + type = string + default = "openaicosmosdb" +} -# variable "cosmosdb_kind" { -# description = "The kind of Cosmos DB to create" -# type = string -# default = "MongoDB" -# } +variable "cosmosdb_offer_type" { + description = "The offer type to use for the Cosmos DB account" + type = string + default = "Standard" +} -# variable "cosmosdb_automatic_failover" { -# description = "Whether to enable automatic failover for the Cosmos DB account" -# type = bool -# default = false -# } +variable "cosmosdb_kind" { + description = "The kind of Cosmos DB to create" + type = string + default = "MongoDB" +} -# variable "use_cosmosdb_free_tier" { -# description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." -# type = bool -# default = true -# } +variable "cosmosdb_automatic_failover" { + description = "Whether to enable automatic failover for the Cosmos DB account" + type = bool + default = false +} -# variable "cosmosdb_consistency_level" { -# description = "The consistency level of the Cosmos DB account" -# type = string -# default = "BoundedStaleness" -# } +variable "use_cosmosdb_free_tier" { + description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." + type = bool + default = true +} -# variable "cosmosdb_max_interval_in_seconds" { -# description = "The maximum staleness interval in seconds for the Cosmos DB account" -# type = number -# default = 10 -# } +variable "cosmosdb_consistency_level" { + description = "The consistency level of the Cosmos DB account" + type = string + default = "BoundedStaleness" +} -# variable "cosmosdb_max_staleness_prefix" { -# description = "The maximum staleness prefix for the Cosmos DB account" -# type = number -# default = 200 -# } +variable "cosmosdb_max_interval_in_seconds" { + description = "The maximum staleness interval in seconds for the Cosmos DB account" + type = number + default = 10 +} -# variable "cosmosdb_geo_locations" { -# description = "The geo-locations for the Cosmos DB account" -# type = list(object({ -# location = string -# failover_priority = number -# })) -# default = [ -# { -# location = "uksouth" -# failover_priority = 0 -# } -# ] -# } +variable "cosmosdb_max_staleness_prefix" { + description = "The maximum staleness prefix for the Cosmos DB account" + type = number + default = 200 +} -# variable "cosmosdb_capabilities" { -# description = "The capabilities for the Cosmos DB account" -# type = list(string) -# default = ["EnableMongo", "MongoDBv3.4"] -# } +variable "cosmosdb_geo_locations" { + description = "The geo-locations for the Cosmos DB account" + type = list(object({ + location = string + failover_priority = number + })) + default = [ + { + location = "uksouth" + failover_priority = 0 + } + ] +} -# variable "cosmosdb_virtual_network_subnets" { -# description = "The virtual network subnets to associate with the Cosmos DB account (Service Endpoint). If networking is created as part of the module, this will be automatically populated." -# type = list(string) -# default = null -# } +variable "cosmosdb_capabilities" { + description = "The capabilities for the Cosmos DB account" + type = list(string) + default = ["EnableMongo", "MongoDBv3.4"] +} -# variable "cosmosdb_is_virtual_network_filter_enabled" { -# description = "Whether to enable virtual network filtering for the Cosmos DB account" -# type = bool -# default = true -# } +variable "cosmosdb_virtual_network_subnets" { + description = "The virtual network subnets to associate with the Cosmos DB account (Service Endpoint). If networking is created as part of the module, this will be automatically populated." + type = list(string) + default = null +} -# variable "cosmosdb_public_network_access_enabled" { -# description = "Whether to enable public network access for the Cosmos DB account" -# type = bool -# default = true -# } +variable "cosmosdb_is_virtual_network_filter_enabled" { + description = "Whether to enable virtual network filtering for the Cosmos DB account" + type = bool + default = true +} -# variable "openai_keyvault_id" { -# type = string -# description = "The ID of the Key Vault to store the CosmosDB account details." -# default = null -# } +variable "cosmosdb_public_network_access_enabled" { + description = "Whether to enable public network access for the Cosmos DB account" + type = bool + default = true +} # ################################### # ### LibreChat App Module params ### From d39465cbdeaa65c454673fb627f6ef3f44da99ad Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 11:49:22 +0000 Subject: [PATCH 065/163] add var for subnet ids in kv --- 03_keyvault.tf | 2 +- tests/auto_test1/main.tf | 11 ++++++----- tests/auto_test1/testing.auto.tfvars | 11 ++++++----- tests/auto_test1/variables.tf | 6 ++++++ variables.tf | 6 ++++++ 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/03_keyvault.tf b/03_keyvault.tf index df943c0..c7e7ca1 100644 --- a/03_keyvault.tf +++ b/03_keyvault.tf @@ -11,7 +11,7 @@ resource "azurerm_key_vault" "az_openai_kv" { default_action = var.kv_fw_default_action bypass = var.kv_fw_bypass ip_rules = var.kv_fw_allowed_ips - virtual_network_subnet_ids = azurerm_subnet.az_openai_subnet.*.id + virtual_network_subnet_ids = var.kv_fw_network_subnet_ids != null ? var.kv_fw_network_subnet_ids : azurerm_subnet.az_openai_subnet.*.id } tags = var.tags depends_on = [azurerm_subnet.az_openai_subnet] diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 1a300fa..b380b15 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -37,11 +37,12 @@ module "private-chatgpt-openai" { subnet_config = var.subnet_config #03 keyvault (Solution Secrets) - kv_name = "${var.kv_name}${random_integer.number.result}" - kv_sku = var.kv_sku - kv_fw_default_action = var.kv_fw_default_action - kv_fw_bypass = var.kv_fw_bypass - kv_fw_allowed_ips = var.kv_fw_allowed_ips + kv_name = "${var.kv_name}${random_integer.number.result}" + kv_sku = var.kv_sku + kv_fw_default_action = var.kv_fw_default_action + kv_fw_bypass = var.kv_fw_bypass + kv_fw_allowed_ips = var.kv_fw_allowed_ips + kv_fw_network_subnet_ids = var.kv_fw_network_subnet_ids #04 openai service oai_account_name = "${var.oai_account_name}${random_integer.number.result}" diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index c272dec..2320869 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -28,11 +28,12 @@ subnet_config = { } ### 03 KeyVault ### -kv_name = "openaikv" -kv_sku = "standard" -kv_fw_default_action = "Deny" -kv_fw_bypass = "AzureServices" -kv_fw_allowed_ips = ["0.0.0.0/0"] +kv_name = "openaikv" +kv_sku = "standard" +kv_fw_default_action = "Deny" +kv_fw_bypass = "AzureServices" +kv_fw_allowed_ips = ["0.0.0.0/0"] +kv_fw_network_subnet_ids = null ### 04 Create OpenAI Service ### oai_account_name = "gptopenaiaccount" diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 779eabf..254908b 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -92,6 +92,12 @@ variable "kv_fw_allowed_ips" { description = "value of key vault firewall allowed ip rules." } +variable "kv_fw_network_subnet_ids" { + description = "The virtual network subnets to associate with the Cosmos DB account (Service Endpoint). If networking is created as part of the module, this will be automatically populated." + type = list(string) + default = null +} + ### 04 openai service ### variable "oai_account_name" { type = string diff --git a/variables.tf b/variables.tf index 1017154..9df6ed1 100644 --- a/variables.tf +++ b/variables.tf @@ -95,6 +95,12 @@ variable "kv_fw_allowed_ips" { description = "value of key vault firewall allowed ip rules." } +variable "kv_fw_network_subnet_ids" { + description = "The virtual network subnets to associate with the Cosmos DB account (Service Endpoint). If networking is created as part of the module, this will be automatically populated." + type = list(string) + default = null +} + ### 04 OpenAI service ### variable "oai_account_name" { type = string From 5fde2a82e949f7955bfc251bbaa1660776ae2321 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 17:20:40 +0000 Subject: [PATCH 066/163] test asp and meiliserach and kv --- 05_cosmosdb.tf | 7 +- 06_librechat_app.tf | 395 +++++++++++++++++++++++++++ main.tf | 66 +---- modules/cosmosdb/README.md | 10 - modules/cosmosdb/data.tf | 4 - modules/cosmosdb/main.tf | 48 ---- modules/cosmosdb/outputs.tf | 25 -- modules/cosmosdb/variables.tf | 114 -------- tests/auto_test1/main.tf | 5 + tests/auto_test1/testing.auto.tfvars | 66 +---- tests/auto_test1/variables.tf | 188 ++----------- variables.tf | 38 ++- 12 files changed, 459 insertions(+), 507 deletions(-) create mode 100644 06_librechat_app.tf delete mode 100644 modules/cosmosdb/README.md delete mode 100644 modules/cosmosdb/data.tf delete mode 100644 modules/cosmosdb/main.tf delete mode 100644 modules/cosmosdb/outputs.tf delete mode 100644 modules/cosmosdb/variables.tf diff --git a/05_cosmosdb.tf b/05_cosmosdb.tf index 331f627..96de347 100644 --- a/05_cosmosdb.tf +++ b/05_cosmosdb.tf @@ -1,4 +1,5 @@ -resource "azurerm_cosmosdb_account" "mongo" { +# Create CosmosDB Account running MongoDB +resource "azurerm_cosmosdb_account" "az_openai_mongodb" { name = var.cosmosdb_name resource_group_name = azurerm_resource_group.az_openai_rg.name location = var.location @@ -40,9 +41,9 @@ resource "azurerm_cosmosdb_account" "mongo" { public_network_access_enabled = var.cosmosdb_public_network_access_enabled } -### Save CosmosDB details to Key Vault for consumption by other services (e.g. LibreChat App) +### Save MongoDB URI details to Key Vault for consumption by other services (e.g. LibreChat App) resource "azurerm_key_vault_secret" "openai_cosmos_uri" { name = "${var.cosmosdb_name}-cosmos-uri" - value = azurerm_cosmosdb_account.mongo.primary_mongodb_connection_string + value = azurerm_cosmosdb_account.az_openai_mongodb.primary_mongodb_connection_string key_vault_id = azurerm_key_vault.az_openai_kv.id } \ No newline at end of file diff --git a/06_librechat_app.tf b/06_librechat_app.tf new file mode 100644 index 0000000..1bc8e92 --- /dev/null +++ b/06_librechat_app.tf @@ -0,0 +1,395 @@ +# Generate random strings as keys for meilisearch and librechat (Stored securely in Azure Key Vault) +resource "random_string" "meilisearch_master_key" { + length = 20 + special = false +} + +resource "azurerm_key_vault_secret" "meilisearch_master_key" { + name = "${var.meilisearch_app_name}-master-key" + value = random_string.meilisearch_master_key.result + key_vault_id = azurerm_key_vault.az_openai_kv.id +} + +# Create app service plan for librechat app and meilisearch app +resource "azurerm_service_plan" "az_openai_asp" { + name = var.app_service_name + location = var.location + resource_group_name = azurerm_resource_group.az_openai_rg.name + os_type = "Linux" + sku_name = var.app_service_sku_name +} + +resource "azurerm_linux_web_app" "meilisearch" { + name = var.meilisearch_app_name + location = var.location + resource_group_name = azurerm_resource_group.az_openai_rg.name + service_plan_id = azurerm_service_plan.az_openai_asp.id + + app_settings = { + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + + MEILI_MASTER_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" + MEILI_NO_ANALYTICS = true + + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + DOCKER_ENABLE_CI = false + WEBSITES_PORT = 7700 + PORT = 7700 + DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" + } + + site_config { + always_on = "true" + ip_restriction { + virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : element(values(azurerm_subnet.az_openai_subnet)[0].id, 0) + priority = 100 + name = "Allow from LibreChat app subnet" + action = "Allow" + } + } + + logs { + http_logs { + file_system { + retention_in_days = 7 + retention_in_mb = 35 + } + } + application_logs { + file_system_level = "Information" + } + } + + identity { + type = "SystemAssigned" + } +} + +resource "azurerm_role_assignment" "meilisearch_app_kv_access" { + scope = azurerm_key_vault.az_openai_kv.id + principal_id = azurerm_linux_web_app.meilisearch.identity[0].principal_id + role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. +} + +# resource "azurerm_linux_web_app" "az_openai_librechat" { +# name = var.app_name +# location = var.location +# resource_group_name = azurerm_resource_group.az_openai_rg.name +# service_plan_id = azurerm_service_plan.az_openai_asp.id +# public_network_access_enabled = var.public_network_access_enabled +# https_only = true + +# site_config { +# minimum_tls_version = "1.2" +# } + +# logs { +# http_logs { +# file_system { +# retention_in_days = 7 +# retention_in_mb = 35 +# } +# } +# application_logs { +# file_system_level = "Information" +# } +# } + +# app_settings = { +# #==================================================# +# # Server Configuration # +# #==================================================# +# APP_TITLE = var.app_title +# CUSTOM_FOOTER = var.app_custom_footer +# HOST = var.app_host +# PORT = var.app_port +# MONGO_URI = "" +# DOMAIN_CLIENT = "http://localhost:3080" +# DOMAIN_SERVER = "http://localhost:3080" + +# #===============# +# # Debug Logging # +# #===============# +# DEBUG_LOGGING = true +# DEBUG_CONSOLE = false + +# #=============# +# # Permissions # +# #=============# +# # UID=1000 +# # GID=1000 + +# #===================================================# +# # Endpoints # +# #===================================================# +# ENDPOINTS = "azureOpenAI" #openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic +# # PROXY= + +# #============# +# # Anthropic # +# #============# +# # ANTHROPIC_API_KEY = "user_provided" +# # ANTHROPIC_MODELS = "claude-1,claude-instant-1,claude-2" +# # ANTHROPIC_REVERSE_PROXY= + +# #============# +# # Azure # +# #============# +# AZURE_API_KEY = "" +# AZURE_OPENAI_MODELS = "gpt-4-1106-preview,gpt-4,gpt-3.5-turbo,gpt-3.5-turbo-1106,gpt-4-vision-preview" +# # AZURE_OPENAI_DEFAULT_MODEL = "gpt-3.5-turbo" +# # PLUGINS_USE_AZURE = true + +# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true +# AZURE_OPENAI_API_INSTANCE_NAME = "gpt9000" +# # AZURE_OPENAI_API_DEPLOYMENT_NAME = +# AZURE_OPENAI_API_VERSION = "2023-07-01-preview" +# # AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = +# # AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = + +# #============# +# # BingAI # +# #============# +# #BINGAI_TOKEN = var.bingai_token +# # BINGAI_HOST = "https://cn.bing.com" + +# #============# +# # ChatGPT # +# #============# +# #CHATGPT_TOKEN = var.chatgpt_token +# #CHATGPT_MODELS = "text-davinci-002-render-sha" +# # CHATGPT_REVERSE_PROXY = "" + +# #============# +# # Google # +# #============# +# #GOOGLE_KEY = "user_provided" +# # GOOGLE_MODELS="gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k" +# # GOOGLE_REVERSE_PROXY= "" + +# #============# +# # OpenAI # +# #============# +# # OPENAI_API_KEY = var.openai_key +# # OPENAI_MODELS = "gpt-3.5-turbo-1106,gpt-4-1106-preview,gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,text-davinci-003,gpt-4,gpt-4-0314,gpt-4-0613" +# #DEBUG_OPENAI = false +# # TITLE_CONVO = false +# # OPENAI_TITLE_MODEL = "gpt-3.5-turbo" +# # OPENAI_SUMMARIZE = true +# # OPENAI_SUMMARY_MODEL = "gpt-3.5-turbo" +# # OPENAI_FORCE_PROMPT = true +# # OPENAI_REVERSE_PROXY = "" + +# #============# +# # OpenRouter # +# #============# +# # OPENROUTER_API_KEY = + +# #============# +# # Plugins # +# #============# +# # PLUGIN_MODELS = "gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,gpt-4,gpt-4-0314,gpt-4-0613" +# DEBUG_PLUGINS = true +# CREDS_KEY = "dfsdgdsffgdsfgds" +# CREDS_IV = "dfsdgdsffgdsfgds" + +# # Azure AI Search +# #----------------- +# # AZURE_AI_SEARCH_SERVICE_ENDPOINT= +# # AZURE_AI_SEARCH_INDEX_NAME= +# # AZURE_AI_SEARCH_API_KEY= +# # AZURE_AI_SEARCH_API_VERSION= +# # AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE= +# # AZURE_AI_SEARCH_SEARCH_OPTION_TOP= +# # AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= + +# # DALL·E 3 +# #---------------- +# # DALLE_API_KEY= +# # DALLE3_SYSTEM_PROMPT="Your System Prompt here" +# # DALLE_REVERSE_PROXY= + +# # Google +# #----------------- +# # GOOGLE_API_KEY= +# # GOOGLE_CSE_ID= + +# # SerpAPI +# #----------------- +# # SERPAPI_API_KEY= + +# # Stable Diffusion +# #----------------- +# # SD_WEBUI_URL=http://host.docker.internal:7860 + +# # WolframAlpha +# #----------------- +# # WOLFRAM_APP_ID= + +# # Zapier +# #----------------- +# # ZAPIER_NLA_API_KEY= + +# #==================================================# +# # Search # +# #==================================================# +# SEARCH = true +# MEILI_NO_ANALYTICS = true +# MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" +# # MEILI_HTTP_ADDR=0.0.0.0:7700 +# MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" + +# #===================================================# +# # User System # +# #===================================================# + +# #========================# +# # Moderation # +# #========================# +# BAN_VIOLATIONS = true +# BAN_DURATION = 1000 * 60 * 60 * 2 +# BAN_INTERVAL = 20 + +# LOGIN_VIOLATION_SCORE = 1 +# REGISTRATION_VIOLATION_SCORE = 1 +# CONCURRENT_VIOLATION_SCORE = 1 +# MESSAGE_VIOLATION_SCORE = 1 +# NON_BROWSER_VIOLATION_SCORE = 20 + +# LOGIN_MAX = 7 +# LOGIN_WINDOW = 5 +# REGISTER_MAX = 5 +# REGISTER_WINDOW = 60 + +# LIMIT_CONCURRENT_MESSAGES = true +# CONCURRENT_MESSAGE_MAX = 2 + +# LIMIT_MESSAGE_IP = true +# MESSAGE_IP_MAX = 40 +# MESSAGE_IP_WINDOW = 1 + +# LIMIT_MESSAGE_USER = false +# MESSAGE_USER_MAX = 40 +# MESSAGE_USER_WINDOW = 1 + +# #========================# +# # Balance # +# #========================# +# CHECK_BALANCE = false + +# #========================# +# # Registration and Login # +# #========================# +# ALLOW_EMAIL_LOGIN = true +# ALLOW_REGISTRATION = true +# ALLOW_SOCIAL_LOGIN = false +# ALLOW_SOCIAL_REGISTRATION = false + +# SESSION_EXPIRY = 1000 * 60 * 15 +# REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 + +# JWT_SECRET = "dfsdgdsffgdsfgds" +# JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" + +# # Discord +# # DISCORD_CLIENT_ID= +# # DISCORD_CLIENT_SECRET= +# # DISCORD_CALLBACK_URL=/oauth/discord/callback + +# # Facebook +# # FACEBOOK_CLIENT_ID= +# # FACEBOOK_CLIENT_SECRET= +# # FACEBOOK_CALLBACK_URL=/oauth/facebook/callback + +# # GitHub +# # GITHUB_CLIENT_ID= +# # GITHUB_CLIENT_SECRET= +# # GITHUB_CALLBACK_URL=/oauth/github/callback + +# # Google +# # GOOGLE_CLIENT_ID= +# # GOOGLE_CLIENT_SECRET= +# # GOOGLE_CALLBACK_URL=/oauth/google/callback + +# # OpenID +# # OPENID_CLIENT_ID= +# # OPENID_CLIENT_SECRET= +# # OPENID_ISSUER= +# # OPENID_SESSION_SECRET= +# # OPENID_SCOPE="openid profile email" +# # OPENID_CALLBACK_URL=/oauth/openid/callback + +# # OPENID_BUTTON_LABEL= +# # OPENID_IMAGE_URL= + +# #========================# +# # Email Password Reset # +# #========================# + +# # EMAIL_SERVICE= +# # EMAIL_HOST= +# # EMAIL_PORT=25 +# # EMAIL_ENCRYPTION= +# # EMAIL_ENCRYPTION_HOSTNAME= +# # EMAIL_ALLOW_SELFSIGNED= +# # EMAIL_USERNAME= +# # EMAIL_PASSWORD= +# # EMAIL_FROM_NAME= +# # EMAIL_FROM=noreply@librechat.ai + +# #==================================================# +# # Others # +# #==================================================# +# # You should leave the following commented out # + +# # NODE_ENV= + +# # REDIS_URI= +# # USE_REDIS= + +# # E2E_USER_EMAIL= +# # E2E_USER_PASSWORD= + +# #=============================================================# +# # Azure App Service Configuration # +# #=============================================================# + +# WEBSITE_RUN_FROM_PACKAGE = "1" +# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false +# DOCKER_ENABLE_CI = false +# WEBSITES_PORT = 80 +# PORT = 80 +# DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" +# NODE_ENV = "production" +# } +# virtual_network_subnet_id = "/subscriptions/829efd7e-aa80-4c0d-9c1c-7aa2557f8e07/resourceGroups/TF-Module-Automated-Tests-Cognitive-GPT/providers/Microsoft.Network/virtualNetworks/openai-vnet2698/subnets/app-cosmos-sub" + +# depends_on = [azurerm_linux_web_app.meilisearch] +# } + + +# # Deploy code from a public GitHub repo +# # resource "azurerm_app_service_source_control" "sourcecontrol" { +# # app_id = azurerm_linux_web_app.librechat.id +# # repo_url = "https://github.com/danny-avila/LibreChat" +# # branch = "main" +# # type = "Github" + +# # # use_manual_integration = true +# # # use_mercurial = false +# # depends_on = [ +# # azurerm_linux_web_app.librechat, +# # ] +# # } + +# # resource "azurerm_app_service_virtual_network_swift_connection" "librechat" { +# # app_service_id = azurerm_linux_web_app.librechat.id +# # subnet_id = module.vnet.vnet_subnets_name_id["subnet0"] + +# # depends_on = [ +# # azurerm_linux_web_app.librechat, +# # module.vnet +# # ] +# # } \ No newline at end of file diff --git a/main.tf b/main.tf index 66dfd95..6b51afc 100644 --- a/main.tf +++ b/main.tf @@ -2,76 +2,12 @@ # # OpenAI Service # # ############################################### # ### Create OpenAI Service ### -# # 1.) Create an Azure Key Vault to store the OpenAI account details. -# # 2.) Create an OpenAI service account. -# # 3.) Create an OpenAI language model deployments. (GPT-3, GPT-4, etc.) -# # 4.) Store the OpenAI account and model details in the key vault. -# module "openai" { -# # # source = "./modules/openai" -# #common -# location = var.location -# tags = var.tags -# #key vault (To store OpenAI Account and model details) -# keyvault_resource_group_name = var.keyvault_resource_group_name -# kv_config = var.kv_config -# keyvault_firewall_default_action = var.keyvault_firewall_default_action -# keyvault_firewall_bypass = var.keyvault_firewall_bypass -# keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips -# keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids - -# #Create OpenAI Service -# create_openai_service = var.create_openai_service -# openai_resource_group_name = var.openai_resource_group_name -# openai_account_name = var.openai_account_name -# openai_custom_subdomain_name = var.openai_custom_subdomain_name -# openai_sku_name = var.openai_sku_name -# openai_local_auth_enabled = var.openai_local_auth_enabled -# openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted -# openai_public_network_access_enabled = var.openai_public_network_access_enabled -# openai_identity = var.openai_identity - -# #Create Model Deployment -# create_model_deployment = var.create_model_deployment -# model_deployment = var.model_deployment -# } # ### Create openai networking for CosmosDB and Web App (Optional) ### -# # 5.) Create networking for CosmosDB and Web App (Optional) -# module "openai_networking" { -# count = var.create_openai_networking ? 1 : 0 -# source = "./modules/networking" -# network_resource_group_name = var.network_resource_group_name -# location = var.location -# virtual_network_name = var.virtual_network_name -# vnet_address_space = var.vnet_address_space -# subnet_config = var.subnet_config -# tags = var.tags -# } + # ### Create a CosmosDB account running MongoDB to store chat data (Optional) ### -# # 6.) Create a CosmosDB account running MongoDB to store chat data (Optional). -# module "openai_cosmosdb" { -# count = var.create_cosmosdb ? 1 : 0 -# source = "./modules/cosmosdb" -# cosmosdb_name = var.cosmosdb_name -# cosmosdb_resource_group_name = var.cosmosdb_resource_group_name -# location = var.location -# cosmosdb_offer_type = var.cosmosdb_offer_type -# cosmosdb_kind = var.cosmosdb_kind -# cosmosdb_automatic_failover = var.cosmosdb_automatic_failover -# use_cosmosdb_free_tier = var.use_cosmosdb_free_tier -# cosmosdb_consistency_level = var.cosmosdb_consistency_level -# cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds -# cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix -# geo_locations = var.cosmosdb_geo_locations -# capabilities = var.cosmosdb_capabilities -# virtual_network_subnets = var.create_openai_networking == true ? toset(values(module.openai_networking[0].subnet_ids)) : var.cosmosdb_virtual_network_subnets -# is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled -# public_network_access_enabled = var.cosmosdb_public_network_access_enabled -# openai_keyvault_id = var.create_openai_service == true ? module.openai.key_vault_id : var.openai_keyvault_id -# tags = var.tags -# } # ### Create the Web App ### # # # 7.) Create a Linux Web App running chatbot container. diff --git a/modules/cosmosdb/README.md b/modules/cosmosdb/README.md deleted file mode 100644 index 671b2c2..0000000 --- a/modules/cosmosdb/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Module: Azure CosmosDB (Optional) - -Create a new CosmosDB. (Optional) -If existing an existing CosmosDB instance is to be used, then the variables/names of the existing DB must be provided as input variables to root the module (data sources): - -- Create an Azure CosmosDB instance running MongoDB API. - - - - \ No newline at end of file diff --git a/modules/cosmosdb/data.tf b/modules/cosmosdb/data.tf deleted file mode 100644 index 85cd0d0..0000000 --- a/modules/cosmosdb/data.tf +++ /dev/null @@ -1,4 +0,0 @@ -################################################## -# DATA # -################################################## -data "azurerm_client_config" "current" {} diff --git a/modules/cosmosdb/main.tf b/modules/cosmosdb/main.tf deleted file mode 100644 index 825d07f..0000000 --- a/modules/cosmosdb/main.tf +++ /dev/null @@ -1,48 +0,0 @@ -resource "azurerm_cosmosdb_account" "mongo" { - name = var.cosmosdb_name - resource_group_name = var.cosmosdb_resource_group_name - location = var.location - offer_type = var.cosmosdb_offer_type - kind = var.cosmosdb_kind - enable_automatic_failover = var.cosmosdb_automatic_failover - enable_free_tier = var.use_cosmosdb_free_tier - tags = var.tags - - consistency_policy { - consistency_level = var.cosmosdb_consistency_level - max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds - max_staleness_prefix = var.cosmosdb_max_staleness_prefix - } - - dynamic "geo_location" { - for_each = var.geo_locations - content { - location = geo_location.value.location - failover_priority = geo_location.value.failover_priority - } - } - - dynamic "capabilities" { - for_each = var.capabilities - content { - name = capabilities.value - } - } - - dynamic "virtual_network_rule" { - for_each = var.virtual_network_subnets - content { - id = virtual_network_rule.value - } - } - - is_virtual_network_filter_enabled = var.is_virtual_network_filter_enabled - public_network_access_enabled = var.public_network_access_enabled -} - -### Save CosmosDB details to Key Vault for consumption by other services (e.g. LibreChat App) -resource "azurerm_key_vault_secret" "openai_cosmos_uri" { - name = "${var.cosmosdb_name}-cosmos-uri" - value = azurerm_cosmosdb_account.mongo.primary_mongodb_connection_string - key_vault_id = var.openai_keyvault_id -} \ No newline at end of file diff --git a/modules/cosmosdb/outputs.tf b/modules/cosmosdb/outputs.tf deleted file mode 100644 index 2b3c4be..0000000 --- a/modules/cosmosdb/outputs.tf +++ /dev/null @@ -1,25 +0,0 @@ -output "cosmosdb_account_id" { - description = "The ID of the Cosmos DB account" - value = azurerm_cosmosdb_account.mongo.id -} - -output "cosmosdb_account_endpoint" { - description = "The endpoint of the Cosmos DB account" - value = azurerm_cosmosdb_account.mongo.endpoint -} - -output "cosmosdb_account_primary_master_key" { - description = "The primary master key of the Cosmos DB account" - value = azurerm_cosmosdb_account.mongo.primary_key - sensitive = true -} - -output "cosmosdb_account_read_endpoints" { - description = "The read endpoints of the Cosmos DB account" - value = azurerm_cosmosdb_account.mongo.read_endpoints -} - -output "cosmosdb_account_write_endpoints" { - description = "The write endpoints of the Cosmos DB account" - value = azurerm_cosmosdb_account.mongo.write_endpoints -} \ No newline at end of file diff --git a/modules/cosmosdb/variables.tf b/modules/cosmosdb/variables.tf deleted file mode 100644 index 1ae38af..0000000 --- a/modules/cosmosdb/variables.tf +++ /dev/null @@ -1,114 +0,0 @@ -variable "cosmosdb_name" { - description = "The name of the Cosmos DB account" - type = string - default = "openaicosmosdb" -} - -variable "cosmosdb_resource_group_name" { - description = "The name of the resource group in which to create the Cosmos DB account" - type = string - nullable = false -} - -variable "location" { - description = "The location/region in which to create the Cosmos DB account" - type = string - default = "uksouth" -} - -variable "cosmosdb_offer_type" { - description = "The offer type to use for the Cosmos DB account" - type = string - default = "Standard" -} - -variable "cosmosdb_kind" { - description = "The kind of Cosmos DB to create" - type = string - default = "MongoDB" -} - -variable "cosmosdb_automatic_failover" { - description = "Whether to enable automatic failover for the Cosmos DB account" - type = bool - default = false -} - -variable "use_cosmosdb_free_tier" { - description = "Whether to enable the free tier for the Cosmos DB account. This needs to be false if another instance already uses free tier." - type = bool - default = true -} - -variable "cosmosdb_consistency_level" { - description = "The consistency level of the Cosmos DB account" - type = string - default = "BoundedStaleness" -} - -variable "cosmosdb_max_interval_in_seconds" { - description = "The maximum staleness interval in seconds for the Cosmos DB account" - type = number - default = 10 -} - -variable "cosmosdb_max_staleness_prefix" { - description = "The maximum staleness prefix for the Cosmos DB account" - type = number - default = 200 -} - -variable "geo_locations" { - description = "The geo-locations for the Cosmos DB account" - type = list(object({ - location = string - failover_priority = number - })) - default = [ - { - location = "uksouth" - failover_priority = 0 - } - ] -} - -variable "capabilities" { - description = "The capabilities for the Cosmos DB account" - type = list(string) - default = ["EnableMongo", "MongoDBv3.4"] -} - -variable "virtual_network_subnets" { - description = "The virtual network subnet ID for the Cosmos DB account (Service Endpoint)" - type = list(string) - default = [] -} - -variable "is_virtual_network_filter_enabled" { - description = "Whether to enable virtual network filtering for the Cosmos DB account" - type = bool - default = true -} - -variable "public_network_access_enabled" { - description = "Whether to enable public network access for the Cosmos DB account" - type = bool - default = true -} - -variable "tags" { - type = map(string) - default = { - Terraform = "True" - Description = "OpenAI CosmosDB Resource." - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" - } - description = "A map of key value pairs that is used to tag resources created." -} - -### keyvault access### -variable "openai_keyvault_id" { - type = string - description = "The ID of the Key Vault to store the CosmosDB account details." -} diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index b380b15..68bef48 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -74,6 +74,11 @@ module "private-chatgpt-openai" { cosmosdb_is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled cosmosdb_public_network_access_enabled = var.cosmosdb_public_network_access_enabled + #06 app services (librechat app + meilisearch) + app_service_name = "${var.app_service_name}${random_integer.number.result}" + app_service_sku_name = var.app_service_sku_name + meilisearch_app_name = "${var.meilisearch_app_name}${random_integer.number.result}" + meilisearch_app_virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 2320869..1543b5b 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -62,7 +62,6 @@ oai_model_deployment = [ ] ### 05 cosmosdb ### - cosmosdb_name = "gptcosmosdb" cosmosdb_offer_type = "Standard" cosmosdb_kind = "MongoDB" @@ -82,66 +81,11 @@ cosmosdb_virtual_network_subnets = null cosmosdb_is_virtual_network_filter_enabled = true cosmosdb_public_network_access_enabled = true -# ### log analytics workspace for container apps ### -# #laws_name = "gptlaws" -# #laws_sku = "PerGB2018" -# #laws_retention_in_days = 30 - -# ### Container App Enviornment ### -# #cae_name = "gptcae" - -# ### Container App ### -# #ca_name = "gptca" -# #ca_revision_mode = "Single" -# #ca_identity = { -# # type = "SystemAssigned" -# #} -# #ca_ingress = { -# # allow_insecure_connections = false -# # external_enabled = true -# # target_port = 3000 -# # transport = "auto" -# # traffic_weight = { -# # latest_revision = true -# # percentage = 100 -# # } -# #} -# #ca_container_config = { -# # name = "gpt-chatbot-ui" -# # image = "ghcr.io/pwd9000-ml/chatbot-ui:main" -# # cpu = 2 -# # memory = "4Gi" -# # min_replicas = 0 -# # max_replicas = 5 - -# ## Environment Variables (Required)## -# # env = [ -# # { -# # name = "OPENAI_API_KEY" -# # secret_name = "openai-api-key" #see locals.tf (Can also be added from key vault created by module, or existing key) -# # }, -# # { -# # name = "OPENAI_API_HOST" -# # secret_name = "openai-api-host" #see locals.tf (Can also be added from key vault created by module, or existing host/endpoint) -# # }, -# # { -# # name = "OPENAI_API_TYPE" -# # value = "azure" -# # }, -# # { -# # name = "AZURE_DEPLOYMENT_ID" #see model_deployment variable (deployment_id) -# # value = "gpt432k" -# # }, -# # { -# # name = "DEFAULT_MODEL" #see model_deployment variable (model_name) -# # value = "gpt-4-32k" -# # } -# # ] -# #} - -# ### key vault access ### -# #key_vault_access_permission = ["Key Vault Secrets User"] # set to `null` to ignore permission grant to a key vault -# #key_vault_id = "kv-to-grant-permission-to" (See `data.tf`) Only required if `var.key_vault_access_permission` not `null`) +### 06 app services (librechat app + meilisearch) ### +app_service_name = "openaiasp" +app_service_sku_name = "B1" +meilisearch_app_name = "meilisearchapp" +meilisearch_app_virtual_network_subnet_id = null # ### CDN - Front Door ### # create_front_door_cdn = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 254908b..289c95c 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -336,169 +336,31 @@ variable "cosmosdb_public_network_access_enabled" { default = true } -# ### log analytics workspace ### -# #variable "laws_name" { -# # type = string -# # description = "Name of the log analytics workspace to create." -# # default = "gptlaws" -# #} - -# #variable "laws_sku" { -# # type = string -# # description = "SKU of the log analytics workspace to create." -# # default = "PerGB2018" -# #} - -# #variable "laws_retention_in_days" { -# # type = number -# # description = "Retention in days of the log analytics workspace to create." -# # default = 30 -# #} - -# ### container app environment ### -# #variable "cae_name" { -# # type = string -# # description = "Name of the container app environment to create." -# # default = "gptcae" -# #} - -# ### container app ### -# #variable "ca_name" { -# # type = string -# # description = "Name of the container app to create." -# # default = "gptca" -# #} - -# #variable "ca_revision_mode" { -# # type = string -# # description = "Revision mode of the container app to create." -# # default = "Single" -# #} - -# #variable "ca_identity" { -# # type = object({ -# # type = string -# # identity_ids = optional(list(string)) -# # }) -# # default = null -# # description = <<-DESCRIPTION -# # type = object({ -# # type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. -# # identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. -# # }) -# # DESCRIPTION -# #} - -# #variable "ca_ingress" { -# # type = object({ -# # allow_insecure_connections = optional(bool) -# # external_enabled = optional(bool) -# # target_port = number -# # transport = optional(string) -# # traffic_weight = optional(object({ -# # percentage = number -# # latest_revision = optional(bool) -# # })) -# # }) -# # default = { -# # allow_insecure_connections = false -# # external_enabled = true -# # target_port = 3000 -# # transport = "auto" -# # traffic_weight = { -# # percentage = 100 -# # latest_revision = true -# # } -# # } -# # description = <<-DESCRIPTION -# # type = object({ -# # allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. -# # external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. -# # target_port = (Required) The port to use for the container app. Defaults to `3000`. -# # transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. -# # type = object({ -# # percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. -# # latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. -# # }) -# # DESCRIPTION -# #} - -# #variable "ca_container_config" { -# # type = object({ -# # name = string -# # image = string -# # cpu = number -# # memory = string -# # min_replicas = optional(number, 0) -# # max_replicas = optional(number, 10) -# # env = optional(list(object({ -# # name = string -# # secret_name = optional(string) -# # value = optional(string) -# # }))) -# # }) -# # default = { -# # name = "gpt-chatbot-ui" -# # image = "ghcr.io/pwd9000-ml/chatbot-ui:main" -# # cpu = 1 -# # memory = "2Gi" -# # min_replicas = 0 -# # max_replicas = 10 -# # env = [] -# # } -# # description = <<-DESCRIPTION -# # type = object({ -# # name = (Required) The name of the container. -# # image = (Required) The name of the container image. -# # cpu = (Required) The number of CPU cores to allocate to the container. -# # memory = (Required) The amount of memory to allocate to the container in GB. -# # min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. -# # max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. -# # env = list(object({ -# # name = (Required) The name of the environment variable. -# # secret_name = (Optional) The name of the secret to use for the environment variable. -# # value = (Optional) The value of the environment variable. -# # })) -# # }) -# # DESCRIPTION -# #} - -# #variable "ca_secrets" { -# # type = list(object({ -# # name = string -# # value = string -# # })) -# # default = [ -# # { -# # name = "secret1" -# # value = "value1" -# # }, -# # { -# # name = "secret2" -# # value = "value2" -# # } -# # ] -# # description = <<-DESCRIPTION -# # type = list(object({ -# # name = (Required) The name of the secret. -# # value = (Required) The value of the secret. -# # })) -# # DESCRIPTION -# #} - -# # Key Vault Access # -# ### key vault access ### -# #variable "key_vault_access_permission" { -# # type = list(string) -# # default = ["Key Vault Secrets User"] -# # description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -# #} - -# #variable "key_vault_id" { -# # type = string -# # default = "" -# # description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." -# #} +### 06 app services (librechat app + meilisearch) ### +variable "app_service_name" { + type = string + description = "Name of the Linux App Service Plan." + default = "openai-asp9000" +} + +variable "app_service_sku_name" { + type = string + description = "The SKU name of the App Service Plan." + default = "B1" +} + +variable "meilisearch_app_name" { + type = string + description = "Name of the meilisearch App Service." + default = "meilisearchapp9000" + +} + +variable "meilisearch_app_virtual_network_subnet_id" { + type = string + description = "The ID of the subnet to deploy the meilisearch App Service in." + default = null +} # # DNS zone # # variable "create_dns_zone" { diff --git a/variables.tf b/variables.tf index 9df6ed1..b7fb6eb 100644 --- a/variables.tf +++ b/variables.tf @@ -340,21 +340,31 @@ variable "cosmosdb_public_network_access_enabled" { default = true } -# ################################### -# ### LibreChat App Module params ### -# ################################### -# ### App Service Plan ### -# variable "app_service_name" { -# type = string -# description = "Name of the App Service." -# default = "openai-asp9000" -# } +### 06 LibreChat App Services ### +variable "app_service_name" { + type = string + description = "Name of the Linux App Service Plan." + default = "openaiasp9000" +} -# variable "app_service_sku_name" { -# type = string -# description = "The SKU name of the App Service Plan." -# default = "B1" -# } +variable "app_service_sku_name" { + type = string + description = "The SKU name of the App Service Plan." + default = "B1" +} + +variable "meilisearch_app_name" { + type = string + description = "Name of the meilisearch App Service." + default = "meilisearchapp9000" + +} + +variable "meilisearch_app_virtual_network_subnet_id" { + type = string + description = "The ID of the subnet to deploy the meilisearch App Service in." + default = null +} # ### App Service ### # variable "app_name" { From b005b861941fef8bae3e8ee2e7e588abd7a94267 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 17:26:08 +0000 Subject: [PATCH 067/163] test --- 06_librechat_app.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 1bc8e92..f739d9e 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -42,7 +42,7 @@ resource "azurerm_linux_web_app" "meilisearch" { site_config { always_on = "true" ip_restriction { - virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : element(values(azurerm_subnet.az_openai_subnet)[0].id, 0) + virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet[var.subnet_config.subnet_name].id priority = 100 name = "Allow from LibreChat app subnet" action = "Allow" From f3537dd7901af2e7df0bd92209fe6b62c605d531 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 17:31:41 +0000 Subject: [PATCH 068/163] up --- 06_librechat_app.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index f739d9e..ff9eee6 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -42,7 +42,7 @@ resource "azurerm_linux_web_app" "meilisearch" { site_config { always_on = "true" ip_restriction { - virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet[var.subnet_config.subnet_name].id + virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id priority = 100 name = "Allow from LibreChat app subnet" action = "Allow" From 7557dc32f16a5651a0c34f31fb37613e4ec7c96d Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Wed, 17 Jan 2024 17:47:04 +0000 Subject: [PATCH 069/163] documentation --- 06_librechat_app.tf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index ff9eee6..92b9015 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -19,6 +19,8 @@ resource "azurerm_service_plan" "az_openai_asp" { sku_name = var.app_service_sku_name } +# Create meilisearch app +# TODO: Add support for private endpoints instead of subnet access resource "azurerm_linux_web_app" "meilisearch" { name = var.meilisearch_app_name location = var.location @@ -66,6 +68,7 @@ resource "azurerm_linux_web_app" "meilisearch" { } } +# Grant kv access to meilisearch app to reference the master key secret resource "azurerm_role_assignment" "meilisearch_app_kv_access" { scope = azurerm_key_vault.az_openai_kv.id principal_id = azurerm_linux_web_app.meilisearch.identity[0].principal_id From 9bde7486c79c82328e0e9cbeb3cefc45d5865a9b Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Fri, 19 Jan 2024 16:43:13 +0000 Subject: [PATCH 070/163] add app config --- 06_librechat_app.tf | 386 +++++++++---------------------------- 06_librechat_app_config.tf | 350 +++++++++++++++++++++++++++++++++ locals.tf | 21 -- variables.tf | 18 ++ 4 files changed, 454 insertions(+), 321 deletions(-) create mode 100644 06_librechat_app_config.tf delete mode 100644 locals.tf diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 92b9015..6c11cb0 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -10,6 +10,52 @@ resource "azurerm_key_vault_secret" "meilisearch_master_key" { key_vault_id = azurerm_key_vault.az_openai_kv.id } +# LibreChat CREDS key (64 characters in hex) and 16-byte IV (32 characters in hex) +resource "random_string" "libre_app_creds_key" { + length = 64 + special = false +} + +resource "random_string" "libre_app_creds_iv" { + length = 32 + special = false +} + +resource "azurerm_key_vault_secret" "libre_app_creds_key" { + name = "${libre_app_name}-key" + value = random_string.libre_app_creds_key.result + key_vault_id = azurerm_key_vault.az_openai_kv.id +} + +resource "azurerm_key_vault_secret" "libre_app_creds_iv" { + name = "${libre_app_name}-iv" + value = random_string.libre_app_creds_iv.result + key_vault_id = azurerm_key_vault.az_openai_kv.id +} + +# LibreChat JWT Secret (64 characters in hex) and JWT Refresh Secret (64 characters in hex) +resource "random_string" "libre_app_jwt_secret" { + length = 64 + special = false +} + +resource "random_string" "libre_app_jwt_refresh_secret" { + length = 64 + special = false +} + +resource "azurerm_key_vault_secret" "libre_app_jwt_secret" { + name = "${libre_app_name}-jwt-secret" + value = random_string.libre_app_jwt_secret.result + key_vault_id = azurerm_key_vault.az_openai_kv.id +} + +resource "azurerm_key_vault_secret" "libre_app_jwt_refresh_secret" { + name = "${libre_app_name}-jwt-refresh-secret" + value = random_string.libre_app_jwt_refresh_secret.result + key_vault_id = azurerm_key_vault.az_openai_kv.id +} + # Create app service plan for librechat app and meilisearch app resource "azurerm_service_plan" "az_openai_asp" { name = var.app_service_name @@ -26,6 +72,7 @@ resource "azurerm_linux_web_app" "meilisearch" { location = var.location resource_group_name = azurerm_resource_group.az_openai_rg.name service_plan_id = azurerm_service_plan.az_openai_asp.id + https_only = true app_settings = { WEBSITES_ENABLE_APP_SERVICE_STORAGE = false @@ -75,303 +122,42 @@ resource "azurerm_role_assignment" "meilisearch_app_kv_access" { role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. } -# resource "azurerm_linux_web_app" "az_openai_librechat" { -# name = var.app_name -# location = var.location -# resource_group_name = azurerm_resource_group.az_openai_rg.name -# service_plan_id = azurerm_service_plan.az_openai_asp.id -# public_network_access_enabled = var.public_network_access_enabled -# https_only = true - -# site_config { -# minimum_tls_version = "1.2" -# } - -# logs { -# http_logs { -# file_system { -# retention_in_days = 7 -# retention_in_mb = 35 -# } -# } -# application_logs { -# file_system_level = "Information" -# } -# } - -# app_settings = { -# #==================================================# -# # Server Configuration # -# #==================================================# -# APP_TITLE = var.app_title -# CUSTOM_FOOTER = var.app_custom_footer -# HOST = var.app_host -# PORT = var.app_port -# MONGO_URI = "" -# DOMAIN_CLIENT = "http://localhost:3080" -# DOMAIN_SERVER = "http://localhost:3080" - -# #===============# -# # Debug Logging # -# #===============# -# DEBUG_LOGGING = true -# DEBUG_CONSOLE = false - -# #=============# -# # Permissions # -# #=============# -# # UID=1000 -# # GID=1000 - -# #===================================================# -# # Endpoints # -# #===================================================# -# ENDPOINTS = "azureOpenAI" #openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic -# # PROXY= - -# #============# -# # Anthropic # -# #============# -# # ANTHROPIC_API_KEY = "user_provided" -# # ANTHROPIC_MODELS = "claude-1,claude-instant-1,claude-2" -# # ANTHROPIC_REVERSE_PROXY= - -# #============# -# # Azure # -# #============# -# AZURE_API_KEY = "" -# AZURE_OPENAI_MODELS = "gpt-4-1106-preview,gpt-4,gpt-3.5-turbo,gpt-3.5-turbo-1106,gpt-4-vision-preview" -# # AZURE_OPENAI_DEFAULT_MODEL = "gpt-3.5-turbo" -# # PLUGINS_USE_AZURE = true - -# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true -# AZURE_OPENAI_API_INSTANCE_NAME = "gpt9000" -# # AZURE_OPENAI_API_DEPLOYMENT_NAME = -# AZURE_OPENAI_API_VERSION = "2023-07-01-preview" -# # AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = -# # AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = - -# #============# -# # BingAI # -# #============# -# #BINGAI_TOKEN = var.bingai_token -# # BINGAI_HOST = "https://cn.bing.com" - -# #============# -# # ChatGPT # -# #============# -# #CHATGPT_TOKEN = var.chatgpt_token -# #CHATGPT_MODELS = "text-davinci-002-render-sha" -# # CHATGPT_REVERSE_PROXY = "" - -# #============# -# # Google # -# #============# -# #GOOGLE_KEY = "user_provided" -# # GOOGLE_MODELS="gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k" -# # GOOGLE_REVERSE_PROXY= "" - -# #============# -# # OpenAI # -# #============# -# # OPENAI_API_KEY = var.openai_key -# # OPENAI_MODELS = "gpt-3.5-turbo-1106,gpt-4-1106-preview,gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,text-davinci-003,gpt-4,gpt-4-0314,gpt-4-0613" -# #DEBUG_OPENAI = false -# # TITLE_CONVO = false -# # OPENAI_TITLE_MODEL = "gpt-3.5-turbo" -# # OPENAI_SUMMARIZE = true -# # OPENAI_SUMMARY_MODEL = "gpt-3.5-turbo" -# # OPENAI_FORCE_PROMPT = true -# # OPENAI_REVERSE_PROXY = "" - -# #============# -# # OpenRouter # -# #============# -# # OPENROUTER_API_KEY = - -# #============# -# # Plugins # -# #============# -# # PLUGIN_MODELS = "gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,gpt-4,gpt-4-0314,gpt-4-0613" -# DEBUG_PLUGINS = true -# CREDS_KEY = "dfsdgdsffgdsfgds" -# CREDS_IV = "dfsdgdsffgdsfgds" - -# # Azure AI Search -# #----------------- -# # AZURE_AI_SEARCH_SERVICE_ENDPOINT= -# # AZURE_AI_SEARCH_INDEX_NAME= -# # AZURE_AI_SEARCH_API_KEY= -# # AZURE_AI_SEARCH_API_VERSION= -# # AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE= -# # AZURE_AI_SEARCH_SEARCH_OPTION_TOP= -# # AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= - -# # DALL·E 3 -# #---------------- -# # DALLE_API_KEY= -# # DALLE3_SYSTEM_PROMPT="Your System Prompt here" -# # DALLE_REVERSE_PROXY= - -# # Google -# #----------------- -# # GOOGLE_API_KEY= -# # GOOGLE_CSE_ID= - -# # SerpAPI -# #----------------- -# # SERPAPI_API_KEY= - -# # Stable Diffusion -# #----------------- -# # SD_WEBUI_URL=http://host.docker.internal:7860 - -# # WolframAlpha -# #----------------- -# # WOLFRAM_APP_ID= - -# # Zapier -# #----------------- -# # ZAPIER_NLA_API_KEY= +resource "azurerm_linux_web_app" "az_openai_librechat" { + name = var.libre_app_name + location = var.location + resource_group_name = azurerm_resource_group.az_openai_rg.name + service_plan_id = azurerm_service_plan.az_openai_asp.id + public_network_access_enabled = var.libre_app_public_network_access_enabled + https_only = true -# #==================================================# -# # Search # -# #==================================================# -# SEARCH = true -# MEILI_NO_ANALYTICS = true -# MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" -# # MEILI_HTTP_ADDR=0.0.0.0:7700 -# MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" - -# #===================================================# -# # User System # -# #===================================================# - -# #========================# -# # Moderation # -# #========================# -# BAN_VIOLATIONS = true -# BAN_DURATION = 1000 * 60 * 60 * 2 -# BAN_INTERVAL = 20 - -# LOGIN_VIOLATION_SCORE = 1 -# REGISTRATION_VIOLATION_SCORE = 1 -# CONCURRENT_VIOLATION_SCORE = 1 -# MESSAGE_VIOLATION_SCORE = 1 -# NON_BROWSER_VIOLATION_SCORE = 20 - -# LOGIN_MAX = 7 -# LOGIN_WINDOW = 5 -# REGISTER_MAX = 5 -# REGISTER_WINDOW = 60 - -# LIMIT_CONCURRENT_MESSAGES = true -# CONCURRENT_MESSAGE_MAX = 2 - -# LIMIT_MESSAGE_IP = true -# MESSAGE_IP_MAX = 40 -# MESSAGE_IP_WINDOW = 1 - -# LIMIT_MESSAGE_USER = false -# MESSAGE_USER_MAX = 40 -# MESSAGE_USER_WINDOW = 1 - -# #========================# -# # Balance # -# #========================# -# CHECK_BALANCE = false - -# #========================# -# # Registration and Login # -# #========================# -# ALLOW_EMAIL_LOGIN = true -# ALLOW_REGISTRATION = true -# ALLOW_SOCIAL_LOGIN = false -# ALLOW_SOCIAL_REGISTRATION = false - -# SESSION_EXPIRY = 1000 * 60 * 15 -# REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 - -# JWT_SECRET = "dfsdgdsffgdsfgds" -# JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" - -# # Discord -# # DISCORD_CLIENT_ID= -# # DISCORD_CLIENT_SECRET= -# # DISCORD_CALLBACK_URL=/oauth/discord/callback - -# # Facebook -# # FACEBOOK_CLIENT_ID= -# # FACEBOOK_CLIENT_SECRET= -# # FACEBOOK_CALLBACK_URL=/oauth/facebook/callback - -# # GitHub -# # GITHUB_CLIENT_ID= -# # GITHUB_CLIENT_SECRET= -# # GITHUB_CALLBACK_URL=/oauth/github/callback - -# # Google -# # GOOGLE_CLIENT_ID= -# # GOOGLE_CLIENT_SECRET= -# # GOOGLE_CALLBACK_URL=/oauth/google/callback - -# # OpenID -# # OPENID_CLIENT_ID= -# # OPENID_CLIENT_SECRET= -# # OPENID_ISSUER= -# # OPENID_SESSION_SECRET= -# # OPENID_SCOPE="openid profile email" -# # OPENID_CALLBACK_URL=/oauth/openid/callback - -# # OPENID_BUTTON_LABEL= -# # OPENID_IMAGE_URL= - -# #========================# -# # Email Password Reset # -# #========================# - -# # EMAIL_SERVICE= -# # EMAIL_HOST= -# # EMAIL_PORT=25 -# # EMAIL_ENCRYPTION= -# # EMAIL_ENCRYPTION_HOSTNAME= -# # EMAIL_ALLOW_SELFSIGNED= -# # EMAIL_USERNAME= -# # EMAIL_PASSWORD= -# # EMAIL_FROM_NAME= -# # EMAIL_FROM=noreply@librechat.ai - -# #==================================================# -# # Others # -# #==================================================# -# # You should leave the following commented out # - -# # NODE_ENV= - -# # REDIS_URI= -# # USE_REDIS= - -# # E2E_USER_EMAIL= -# # E2E_USER_PASSWORD= + site_config { + minimum_tls_version = "1.2" + } -# #=============================================================# -# # Azure App Service Configuration # -# #=============================================================# + logs { + http_logs { + file_system { + retention_in_days = 7 + retention_in_mb = 35 + } + } + application_logs { + file_system_level = "Information" + } + } -# WEBSITE_RUN_FROM_PACKAGE = "1" -# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" -# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false -# DOCKER_ENABLE_CI = false -# WEBSITES_PORT = 80 -# PORT = 80 -# DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" -# NODE_ENV = "production" -# } -# virtual_network_subnet_id = "/subscriptions/829efd7e-aa80-4c0d-9c1c-7aa2557f8e07/resourceGroups/TF-Module-Automated-Tests-Cognitive-GPT/providers/Microsoft.Network/virtualNetworks/openai-vnet2698/subnets/app-cosmos-sub" + app_settings = local.libre_app_settings + virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id -# depends_on = [azurerm_linux_web_app.meilisearch] -# } + depends_on = [azurerm_linux_web_app.meilisearch] +} +# Grant kv access to librechat app to reference environment variables (stored as secrets in key vault) +resource "azurerm_role_assignment" "libre_app_kv_access" { + scope = azurerm_key_vault.az_openai_kv.id + principal_id = azurerm_linux_web_app.az_openai_librechat.identity[0].principal_id + role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. +} # # Deploy code from a public GitHub repo # # resource "azurerm_app_service_source_control" "sourcecontrol" { @@ -387,12 +173,12 @@ resource "azurerm_role_assignment" "meilisearch_app_kv_access" { # # ] # # } -# # resource "azurerm_app_service_virtual_network_swift_connection" "librechat" { -# # app_service_id = azurerm_linux_web_app.librechat.id -# # subnet_id = module.vnet.vnet_subnets_name_id["subnet0"] +# resource "azurerm_app_service_virtual_network_swift_connection" "librechat" { +# app_service_id = azurerm_linux_web_app.librechat.id +# subnet_id = module.vnet.vnet_subnets_name_id["subnet0"] +# depends_on = [ +# azurerm_linux_web_app.librechat, +# module.vnet +# ] +# } -# # depends_on = [ -# # azurerm_linux_web_app.librechat, -# # module.vnet -# # ] -# # } \ No newline at end of file diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf new file mode 100644 index 0000000..30fcd92 --- /dev/null +++ b/06_librechat_app_config.tf @@ -0,0 +1,350 @@ +locals { + libre_app_settings = { + ### Server Configuration ### + APP_TITLE = var.libre_app_title + CUSTOM_FOOTER = var.libre_app_custom_footer + HOST = var.libre_app_host + PORT = var.libre_app_port + MONGO_URI = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" + DOMAIN_CLIENT = var.libre_app_domain_client #"http://localhost:3080" + DOMAIN_SERVER = var.libre_app_domain_server #"http://localhost:3080" + + ### Debug Logging ### + DEBUG_LOGGING = var.libre_app_debug_logging #true + DEBUG_CONSOLE = var.libre_app_debug_console #false + + ### Endpoints ### + ENDPOINTS = var.libre_app_endpoints #"azureOpenAI" + + ### Azure OpenAI ### + AZURE_API_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" + AZURE_OPENAI_MODELS = var.libre_app_azure_openai_models # "gpt-4-1106-preview" + AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_azure_use_model_as_deployment_name #true + AZURE_OPENAI_API_INSTANCE_NAME = split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] # "gpt9000" + AZURE_OPENAI_API_VERSION = var.libre_app_azure_openai_api_version #"2023-07-01-preview" + + ### Plugins ### + # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) + # Warning: If you don't set them, the app will crash on startup. + DEBUG_PLUGINS = false + CREDS_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" + CREDS_IV = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" + + ### Search ### + SEARCH = var.enable_meilisearch #true + MEILI_NO_ANALYTICS = var.disable_meilisearch_analytics #true + MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" + MEILI_MASTER_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" + + ### User - Moderation ### + BAN_VIOLATIONS = true + BAN_DURATION = 1000 * 60 * 60 * 2 + BAN_INTERVAL = 20 + LOGIN_VIOLATION_SCORE = 1 + REGISTRATION_VIOLATION_SCORE = 1 + CONCURRENT_VIOLATION_SCORE = 1 + MESSAGE_VIOLATION_SCORE = 1 + NON_BROWSER_VIOLATION_SCORE = 20 + LOGIN_MAX = 7 + LOGIN_WINDOW = 5 + REGISTER_MAX = 5 + REGISTER_WINDOW = 60 + LIMIT_CONCURRENT_MESSAGES = true + CONCURRENT_MESSAGE_MAX = 2 + LIMIT_MESSAGE_IP = true + MESSAGE_IP_MAX = 40 + MESSAGE_IP_WINDOW = 1 + LIMIT_MESSAGE_USER = false + MESSAGE_USER_MAX = 40 + MESSAGE_USER_WINDOW = 1 + + ### User - Balance ### + CHECK_BALANCE = false + + ### User - Registration and Login ### + ALLOW_EMAIL_LOGIN = true + ALLOW_REGISTRATION = true + ALLOW_SOCIAL_LOGIN = false + ALLOW_SOCIAL_REGISTRATION = false + SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days + JWT_SECRET = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" + JWT_REFRESH_SECRET = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" + + ### App Service Configuration ### + WEBSITE_RUN_FROM_PACKAGE = "1" + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + DOCKER_ENABLE_CI = false + WEBSITES_PORT = 80 + PORT = 80 + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + NODE_ENV = "production" + } +} + +#MOVE TO CDN CSETTINGS + +# cdn_gpt_origin = merge( +# var.cdn_gpt_origin, +# { +# host_name = module.privategpt_chatbot_container_apps.latest_revision_fqdn +# origin_host_header = module.privategpt_chatbot_container_apps.latest_revision_fqdn +# } +# ) + +################################################################################################################## +### For reference, here is a list of additional/optional app settings that can be configured for librechat app ### +################################################################################################################## + +### Also see the official project environment variables documentation here: https://docs.librechat.ai/install/configuration/dotenv.html +### Azure specific variables: https://docs.librechat.ai/install/configuration/dotenv.html#azure +### Offical Docs and Homepage: https://docs.librechat.ai/index.html + +# app_settings = { +# #==================================================# +# # Server Configuration # +# #==================================================# +# +# #=============# +# # Permissions # +# #=============# +# UID=1000 +# GID=1000 +# +# #===================================================# +# # Endpoints # +# #===================================================# +# ENDPOINTS = "openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic" +# PROXY = "" +# +# #============# +# # Anthropic # +# #============# +# ANTHROPIC_API_KEY = "user_provided" +# ANTHROPIC_MODELS = "claude-1,claude-instant-1,claude-2" +# ANTHROPIC_REVERSE_PROXY = "" +# +# #============# +# # Azure # +# #============# +# AZURE_API_KEY = "" +# AZURE_OPENAI_MODELS = "gpt-3.5-turbo,gpt-4" +# AZURE_OPENAI_DEFAULT_MODEL = "gpt-3.5-turbo" +# PLUGINS_USE_AZURE = true +# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true +# AZURE_OPENAI_API_INSTANCE_NAME = split("//", split(".", module.openai.openai_endpoint)[0])[1] +# AZURE_OPENAI_API_DEPLOYMENT_NAME = "" +# AZURE_OPENAI_API_VERSION = "" +# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = "" +# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = "" +# +# #============# +# # BingAI # +# #============# +# BINGAI_TOKEN = "" +# BINGAI_HOST = "https://cn.bing.com" +# +# #============# +# # ChatGPT # +# #============# +# CHATGPT_TOKEN = "" +# CHATGPT_MODELS = "text-davinci-002-render-sha" +# CHATGPT_REVERSE_PROXY = "" +# +# #============# +# # Google # +# #============# +# GOOGLE_KEY = "user_provided" +# GOOGLE_MODELS = "gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k" +# GOOGLE_REVERSE_PROXY= "" +# +# #============# +# # OpenAI # +# #============# +# OPENAI_API_KEY = "" +# OPENAI_MODELS = "gpt-3.5-turbo-1106,gpt-4-1106-preview,gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,text-davinci-003,gpt-4,gpt-4-0314,gpt-4-0613" +# DEBUG_OPENAI = false +# TITLE_CONVO = false +# OPENAI_TITLE_MODEL = "gpt-3.5-turbo" +# OPENAI_SUMMARIZE = true +# OPENAI_SUMMARY_MODEL = "gpt-3.5-turbo" +# OPENAI_FORCE_PROMPT = true +# OPENAI_REVERSE_PROXY = "" +# +# #============# +# # OpenRouter # +# #============# +# OPENROUTER_API_KEY = "" +# +# #============# +# # Plugins # +# #============# +# # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) +# # Warning: If you don't set them, the app will crash on startup. +# +# PLUGIN_MODELS = "gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,gpt-4,gpt-4-0314,gpt-4-0613" +# DEBUG_PLUGINS = false +# CREDS_KEY = "" +# CREDS_IV = "" +# +# ### Azure AI Search ### +# AZURE_AI_SEARCH_SERVICE_ENDPOINT = +# AZURE_AI_SEARCH_INDEX_NAME = +# AZURE_AI_SEARCH_API_KEY = +# AZURE_AI_SEARCH_API_VERSION = +# AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE = +# AZURE_AI_SEARCH_SEARCH_OPTION_TOP = +# AZURE_AI_SEARCH_SEARCH_OPTION_SELECT = +# +# ### DALL·E 3 ### +# DALLE_API_KEY = +# DALLE3_SYSTEM_PROMPT = "Your System Prompt here" +# DALLE_REVERSE_PROXY = +# +# ### Google ### +# GOOGLE_API_KEY = +# GOOGLE_CSE_ID = +# +# ### SerpAPI ### +# SERPAPI_API_KEY = +# +# ### Stable Diffusion ### +# SD_WEBUI_URL = "http://host.docker.internal:7860" +# +# ### WolframAlpha ### +# WOLFRAM_APP_ID = +# +# ### Zapier ### +# ZAPIER_NLA_API_KEY = +# +# #============# +# # Sarch # +# #============# +# SEARCH = true +# MEILI_NO_ANALYTICS = true +# MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" +# MEILI_HTTP_ADDR = 0.0.0.0:7700 +# MEILI_MASTER_KEY = "" +# +# #=============# +# # User System # +# #=============# +# +# #=============# +# # Moderation # +# #=============# +# BAN_VIOLATIONS = true +# BAN_DURATION = 1000 * 60 * 60 * 2 +# BAN_INTERVAL = 20 +# +# LOGIN_VIOLATION_SCORE = 1 +# REGISTRATION_VIOLATION_SCORE = 1 +# CONCURRENT_VIOLATION_SCORE = 1 +# MESSAGE_VIOLATION_SCORE = 1 +# NON_BROWSER_VIOLATION_SCORE = 20 +# +# LOGIN_MAX = 7 +# LOGIN_WINDOW = 5 +# REGISTER_MAX = 5 +# REGISTER_WINDOW = 60 +# +# LIMIT_CONCURRENT_MESSAGES = true +# CONCURRENT_MESSAGE_MAX = 2 +# +# LIMIT_MESSAGE_IP = true +# MESSAGE_IP_MAX = 40 +# MESSAGE_IP_WINDOW = 1 +# +# LIMIT_MESSAGE_USER = false +# MESSAGE_USER_MAX = 40 +# MESSAGE_USER_WINDOW = 1 +# +#========================# +# Balance # +#========================# +# CHECK_BALANCE = false +# +#========================# +# Registration and Login # +#========================# +# ALLOW_EMAIL_LOGIN = true +# ALLOW_REGISTRATION = true +# ALLOW_SOCIAL_LOGIN = false +# ALLOW_SOCIAL_REGISTRATION = false +# +# SESSION_EXPIRY = 1000 * 60 * 15 +# REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 +# +# JWT_SECRET = "dfsdgdsffgdsfgds" +# JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" +# +### Discord +# DISCORD_CLIENT_ID = +# DISCORD_CLIENT_SECRET = +# DISCORD_CALLBACK_URL = /discord/callback +# +### Facebook +# FACEBOOK_CLIENT_ID = +# FACEBOOK_CLIENT_SECRET = +# FACEBOOK_CALLBACK_URL = /oauth/facebook/callback +# +### GitHub +# GITHUB_CLIENT_ID = +# GITHUB_CLIENT_SECRET = +# GITHUB_CALLBACK_URL = /oauth/github/callback +# +### Google +# GOOGLE_CLIENT_ID = +# GOOGLE_CLIENT_SECRET = +# GOOGLE_CALLBACK_URL = /oauth/google/callback +# +### OpenID +# OPENID_CLIENT_ID = +# OPENID_CLIENT_SECRET = +# OPENID_ISSUER = +# OPENID_SESSION_SECRET = +# OPENID_SCOPE = "openid profile email" +# OPENID_CALLBACK_URL = /oauth/openid/callback +# +# OPENID_BUTTON_LABEL = +# OPENID_IMAGE_URL = +# +#========================# +# Email Password Reset # +#========================# +# EMAIL_SERVICE = +# EMAIL_HOST = +# EMAIL_PORT = 25 +# EMAIL_ENCRYPTION = +# EMAIL_ENCRYPTION_HOSTNAME = +# EMAIL_ALLOW_SELFSIGNED = +# EMAIL_USERNAME = +# EMAIL_PASSWORD = +# EMAIL_FROM_NAME = +# EMAIL_FROM = noreply@librechat.ai +# +#==========# +# Others # +#==========# +### You should leave the following commented out ### +# +# #NODE_ENV = +# #REDIS_URI = +# #USE_REDIS = +# #E2E_USER_EMAIL = +# #E2E_USER_PASSWORD = +# +#=================================# +# Azure App Service Configuration # +#=================================# +# +# WEBSITE_RUN_FROM_PACKAGE = "1" +# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false +# DOCKER_ENABLE_CI = false +# WEBSITES_PORT = 80 +# PORT = 80 +# DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" +# NODE_ENV = "production" +# } \ No newline at end of file diff --git a/locals.tf b/locals.tf deleted file mode 100644 index ff88bc7..0000000 --- a/locals.tf +++ /dev/null @@ -1,21 +0,0 @@ -#locals { -## locals config for key vault firewall rules ## -# kv_net_rules = [ -# { -# default_action = var.keyvault_firewall_default_action -# bypass = var.keyvault_firewall_bypass -# ip_rules = var.keyvault_firewall_allowed_ips -#virtual_network_subnet_ids = azurerm_subnet.az_openai_subnet.*.id -# } -# ] -#} - -#locals { -# cdn_gpt_origin = merge( -# var.cdn_gpt_origin, -# { -# host_name = module.privategpt_chatbot_container_apps.latest_revision_fqdn -# origin_host_header = module.privategpt_chatbot_container_apps.latest_revision_fqdn -# } -# ) -#} \ No newline at end of file diff --git a/variables.tf b/variables.tf index b7fb6eb..168f77c 100644 --- a/variables.tf +++ b/variables.tf @@ -366,6 +366,24 @@ variable "meilisearch_app_virtual_network_subnet_id" { default = null } +variable "libre_app_name" { + type = string + description = "Name of the LibreChat App Service." + default = "librechatapp9000" +} + +variable "libre_app_public_network_access_enabled" { + type = bool + description = "Whether or not public network access is enabled. Defaults to `false`." + default = true +} + +variable "libre_app_virtual_network_subnet_id" { + type = string + description = "The ID of the subnet to deploy the LibreChat App Service in." + default = null +} + # ### App Service ### # variable "app_name" { # type = string From 30b9999b16a36e1578b45bbd6d2b3d3d05a1ba07 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sat, 20 Jan 2024 11:18:22 +0000 Subject: [PATCH 071/163] UPDATE VARS --- 06_librechat_app_config.tf | 26 ++--- tests/auto_test1/main.tf | 64 ++++++++++-- tests/auto_test1/testing.auto.tfvars | 41 +++++++- tests/auto_test1/variables.tf | 141 +++++++++++++++++++++++++++ variables.tf | 132 ++++++++++++++++++++++--- 5 files changed, 365 insertions(+), 39 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 30fcd92..b9ed1b5 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -5,30 +5,30 @@ locals { CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host PORT = var.libre_app_port - MONGO_URI = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" - DOMAIN_CLIENT = var.libre_app_domain_client #"http://localhost:3080" - DOMAIN_SERVER = var.libre_app_domain_server #"http://localhost:3080" + MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" + DOMAIN_CLIENT = var.libre_app_domain_client + DOMAIN_SERVER = var.libre_app_domain_server ### Debug Logging ### - DEBUG_LOGGING = var.libre_app_debug_logging #true - DEBUG_CONSOLE = var.libre_app_debug_console #false + DEBUG_LOGGING = var.libre_app_debug_logging + DEBUG_CONSOLE = var.libre_app_debug_console ### Endpoints ### ENDPOINTS = var.libre_app_endpoints #"azureOpenAI" ### Azure OpenAI ### - AZURE_API_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" - AZURE_OPENAI_MODELS = var.libre_app_azure_openai_models # "gpt-4-1106-preview" - AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_azure_use_model_as_deployment_name #true - AZURE_OPENAI_API_INSTANCE_NAME = split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] # "gpt9000" - AZURE_OPENAI_API_VERSION = var.libre_app_azure_openai_api_version #"2023-07-01-preview" + AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" + AZURE_OPENAI_MODELS = var.libre_app_az_oai_models + AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_az_oai_use_model_as_deployment_name + AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] + AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version ### Plugins ### # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) # Warning: If you don't set them, the app will crash on startup. - DEBUG_PLUGINS = false - CREDS_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" - CREDS_IV = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" + DEBUG_PLUGINS = var.libre_app_debug_plugins + CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" + CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" ### Search ### SEARCH = var.enable_meilisearch #true diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 68bef48..e49d5a8 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -26,17 +26,20 @@ resource "random_integer" "number" { module "private-chatgpt-openai" { source = "../.." - #01 common + RG + # 01 common + RG # + #================# location = var.location tags = var.tags resource_group_name = var.resource_group_name - #02 networking + # 02 networking # + #===============# virtual_network_name = "${var.virtual_network_name}${random_integer.number.result}" vnet_address_space = var.vnet_address_space subnet_config = var.subnet_config - #03 keyvault (Solution Secrets) + # 03 keyvault (Solution Secrets) + #==============================# kv_name = "${var.kv_name}${random_integer.number.result}" kv_sku = var.kv_sku kv_fw_default_action = var.kv_fw_default_action @@ -44,7 +47,8 @@ module "private-chatgpt-openai" { kv_fw_allowed_ips = var.kv_fw_allowed_ips kv_fw_network_subnet_ids = var.kv_fw_network_subnet_ids - #04 openai service + # 04 openai service + #==================# oai_account_name = "${var.oai_account_name}${random_integer.number.result}" oai_sku_name = var.oai_sku_name oai_custom_subdomain_name = "${var.oai_custom_subdomain_name}${random_integer.number.result}" @@ -59,7 +63,8 @@ module "private-chatgpt-openai" { oai_storage = var.oai_storage oai_model_deployment = var.oai_model_deployment - #05 cosmosdb + # 05 cosmosdb + #============# cosmosdb_name = "${var.cosmosdb_name}${random_integer.number.result}" cosmosdb_offer_type = var.cosmosdb_offer_type cosmosdb_kind = var.cosmosdb_kind @@ -74,11 +79,54 @@ module "private-chatgpt-openai" { cosmosdb_is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled cosmosdb_public_network_access_enabled = var.cosmosdb_public_network_access_enabled - #06 app services (librechat app + meilisearch) - app_service_name = "${var.app_service_name}${random_integer.number.result}" - app_service_sku_name = var.app_service_sku_name + # 06 app services (librechat app + meilisearch) + #=============================================# + # App Service Plan + app_service_name = "${var.app_service_name}${random_integer.number.result}" + app_service_sku_name = var.app_service_sku_name + + # MeiSearch App meilisearch_app_name = "${var.meilisearch_app_name}${random_integer.number.result}" meilisearch_app_virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id + + # LibreChat App + libre_app_name = "${var.libre_app_name}${random_integer.number.result}" + libre_app_virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id + libre_app_public_network_access_enabled = var.libre_app_public_network_access_enabled + + ### LibreChat App Settings ### + # Server Config + libre_app_title = var.libre_app_title + libre_app_custom_footer = var.libre_app_custom_footer + libre_app_host = var.libre_app_host + libre_app_port = var.libre_app_port + libre_app_mongo_uri = var.libre_app_mongo_uri + libre_app_domain_client = var.libre_app_domain_client + libre_app_domain_server = var.libre_app_domain_server + + # Debug Config + libre_app_debug_logging = var.libre_app_debug_logging + libre_app_debug_console = var.libre_app_debug_console + + # Endpoints + libre_app_endpoints = var.libre_app_endpoints + + # Azure OpenAI Config + libre_app_az_oai_api_key = var.libre_app_az_oai_api_key + libre_app_az_oai_models = var.libre_app_az_oai_models + libre_app_az_oai_use_model_as_deployment_name = var.libre_app_az_oai_use_model_as_deployment_name + libre_app_az_oai_instance_name = var.libre_app_az_oai_use_model_as_deployment_name + libre_app_az_oai_api_version = var.libre_app_az_oai_api_version + + # Plugins + libre_app_debug_plugins = var.libre_app_debug_plugins + libre_app_plugins_creds_key = var.libre_app_plugins_creds_key + libre_app_plugins_creds_iv = var.libre_app_plugins_creds_iv + + + + + } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 1543b5b..281cdf3 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -82,11 +82,48 @@ cosmosdb_is_virtual_network_filter_enabled = true cosmosdb_public_network_access_enabled = true ### 06 app services (librechat app + meilisearch) ### -app_service_name = "openaiasp" -app_service_sku_name = "B1" +# App Service Plan +app_service_name = "openaiasp" +app_service_sku_name = "B1" + +# Meilisearch App meilisearch_app_name = "meilisearchapp" meilisearch_app_virtual_network_subnet_id = null +# LibreChat App Service +libre_app_name = "librechatapp" +libre_app_public_network_access_enabled = true +libre_app_virtual_network_subnet_id = null + +### LibreChat App Settings ### +# Server Config +libre_app_title = "Azure OpenAI LibreChat" +libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" +libre_app_host = "0.0.0.0" +libre_app_port = 3080 +libre_app_mongo_uri = null +libre_app_domain_client = "https://localhost:3080" +libre_app_domain_server = "https://localhost:3080" + +# debug logging +libre_app_debug_logging = false +libre_app_debug_console = false + +# Endpoints +libre_app_endpoints = "AzureOpenAI" + +# Azure OpenAI +libre_app_az_oai_api_key = null +libre_app_az_oai_models = "gpt-4-1106-Preview" +libre_app_az_oai_use_model_as_deployment_name = true +libre_app_az_oai_instance_name = null +libre_app_az_oai_api_version = "2023-07-01-preview" + +# Plugins +libre_app_debug_plugins = false +libre_app_plugins_creds_key = null +libre_app_plugins_creds_iv = null + # ### CDN - Front Door ### # create_front_door_cdn = true # create_dns_zone = true #Set to false if you already have a DNS zone, remember to add this DNS zone to your domain registrar diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 289c95c..03fd557 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -337,6 +337,7 @@ variable "cosmosdb_public_network_access_enabled" { } ### 06 app services (librechat app + meilisearch) ### +# App service Plan variable "app_service_name" { type = string description = "Name of the Linux App Service Plan." @@ -349,6 +350,7 @@ variable "app_service_sku_name" { default = "B1" } +# Meilisearch App Service variable "meilisearch_app_name" { type = string description = "Name of the meilisearch App Service." @@ -362,6 +364,145 @@ variable "meilisearch_app_virtual_network_subnet_id" { default = null } +# LibreChat App Service +variable "libre_app_name" { + type = string + description = "Name of the LibreChat App Service." + default = "librechatapp9000" +} + +variable "libre_app_public_network_access_enabled" { + type = bool + description = "Whether or not public network access is enabled. Defaults to `false`." + default = true +} + +variable "libre_app_virtual_network_subnet_id" { + type = string + description = "The ID of the subnet to deploy the LibreChat App Service in." + default = null +} + +# LibreChat App Service App Settings +# Server Config +variable "libre_app_title" { + type = string + description = "Add a custom title for the App." + default = "PrivateGPT" +} + +variable "libre_app_custom_footer" { + type = string + description = "Add a custom footer for the App." + default = "Privately hosted chat app powered by Azure OpenAI and LibreChat." +} + +variable "libre_app_host" { + type = string + description = "he server will listen to localhost:3080 by default. You can change the target IP as you want. If you want to make this server available externally, for example to share the server with others or expose this from a Docker container, set host to 0.0.0.0 or your external IP interface." + default = "0.0.0.0" +} + +variable "libre_app_port" { + type = number + description = "The host port to listen on." + default = 3080 +} + +variable "libre_app_mongo_uri" { + type = string + description = "The MongoDB Connection String to connect to." + default = null + sensitive = true +} + +variable "libre_app_domain_client" { + type = string + description = "To use locally, set DOMAIN_CLIENT and DOMAIN_SERVER to http://localhost:3080 (3080 being the port previously configured).When deploying to a custom domain, set DOMAIN_CLIENT and DOMAIN_SERVER to your deployed URL, e.g. https://mydomain.example.com" + default = "http://localhost:3080" +} + +variable "libre_app_domain_server" { + type = string + description = "To use locally, set DOMAIN_CLIENT and DOMAIN_SERVER to http://localhost:3080 (3080 being the port previously configured).When deploying to a custom domain, set DOMAIN_CLIENT and DOMAIN_SERVER to your deployed URL, e.g. https://mydomain.example.com" + default = "http://localhost:3080" +} + +# Debug logging +variable "libre_app_debug_logging" { + type = bool + description = "LibreChat has central logging built into its backend (api). Log files are saved in /api/logs. Error logs are saved by default. Debug logs are enabled by default but can be turned off if not desired." + default = false +} + +variable "libre_app_debug_console" { + type = bool + description = "Enable verbose server output in the console, though it's not recommended due to high verbosity." + default = false +} + +# Endpoints +variable "libre_app_endpoints" { + type = string + description = "endpoints and models selection. E.g. 'openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic'" + default = "azureOpenAI" +} + +# Azure OpenAI +variable "libre_app_az_oai_api_key" { + type = string + description = "Azure OpenAI API Key" + default = null + sensitive = true +} + +variable "libre_app_az_oai_models" { + type = string + description = "Azure OpenAI Models. E.g. 'gpt-4-1106-preview,gpt-4,gpt-3.5-turbo,gpt-3.5-turbo-1106,gpt-4-vision-preview'" + default = "gpt-4-1106-preview" +} + +variable "libre_app_az_oai_use_model_as_deployment_name" { + type = bool + description = "Azure OpenAI Use Model as Deployment Name" + default = true +} + +variable "libre_app_az_oai_instance_name" { + type = string + description = "Azure OpenAI Instance Name" + default = null +} + +variable "libre_app_az_oai_api_version" { + type = string + description = "Azure OpenAI API Version" + default = "2023-07-01-preview" +} + +# Plugins +variable "libre_app_debug_plugins" { + type = bool + description = "Enable debug mode for Libre App plugins." + default = false +} + +variable "libre_app_plugins_creds_key" { + type = string + description = "Libre App Plugins Creds Key" + default = null + sensitive = true +} + +variable "libre_app_plugins_creds_iv" { + type = string + description = "Libre App Plugins Creds IV" + default = null + sensitive = true +} + +# LibreChat App Service App Settings + # # DNS zone # # variable "create_dns_zone" { # description = "Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided." diff --git a/variables.tf b/variables.tf index 168f77c..a79b577 100644 --- a/variables.tf +++ b/variables.tf @@ -341,6 +341,7 @@ variable "cosmosdb_public_network_access_enabled" { } ### 06 LibreChat App Services ### +# App Service Plan variable "app_service_name" { type = string description = "Name of the Linux App Service Plan." @@ -353,6 +354,7 @@ variable "app_service_sku_name" { default = "B1" } +# Meilisearch App Service variable "meilisearch_app_name" { type = string description = "Name of the meilisearch App Service." @@ -366,6 +368,7 @@ variable "meilisearch_app_virtual_network_subnet_id" { default = null } +# LibreChat App Service variable "libre_app_name" { type = string description = "Name of the LibreChat App Service." @@ -384,26 +387,123 @@ variable "libre_app_virtual_network_subnet_id" { default = null } -# ### App Service ### -# variable "app_name" { -# type = string -# description = "Name of the App." -# default = "openai-app-9000" -# } +# LibreChat App Service App Settings +# Server Config +variable "libre_app_title" { + type = string + description = "Add a custom title for the App." + default = "PrivateGPT" +} -# variable "app_title" { -# type = string -# description = "Title of the App." -# default = "PrivateGPT" -# } +variable "libre_app_custom_footer" { + type = string + description = "Add a custom footer for the App." + default = "Privately hosted chat app powered by Azure OpenAI and LibreChat." +} -# variable "app_custom_footer" { -# type = string -# description = "Custom footer for the App." -# default = "Privately hosted chat app powered by Azure OpenAI" -# } +variable "libre_app_host" { + type = string + description = "he server will listen to localhost:3080 by default. You can change the target IP as you want. If you want to make this server available externally, for example to share the server with others or expose this from a Docker container, set host to 0.0.0.0 or your external IP interface." + default = "0.0.0.0" +} + +variable "libre_app_port" { + type = number + description = "The host port to listen on." + default = 3080 +} + +variable "libre_app_mongo_uri" { + type = string + description = "The MongoDB Connection String to connect to." + default = null + sensitive = true +} + +variable "libre_app_domain_client" { + type = string + description = "To use locally, set DOMAIN_CLIENT and DOMAIN_SERVER to http://localhost:3080 (3080 being the port previously configured).When deploying to a custom domain, set DOMAIN_CLIENT and DOMAIN_SERVER to your deployed URL, e.g. https://mydomain.example.com" + default = "http://localhost:3080" +} + +variable "libre_app_domain_server" { + type = string + description = "To use locally, set DOMAIN_CLIENT and DOMAIN_SERVER to http://localhost:3080 (3080 being the port previously configured).When deploying to a custom domain, set DOMAIN_CLIENT and DOMAIN_SERVER to your deployed URL, e.g. https://mydomain.example.com" + default = "http://localhost:3080" +} + +# Debug logging +variable "libre_app_debug_logging" { + type = bool + description = "LibreChat has central logging built into its backend (api). Log files are saved in /api/logs. Error logs are saved by default. Debug logs are enabled by default but can be turned off if not desired." + default = false +} + +variable "libre_app_debug_console" { + type = bool + description = "Enable verbose server output in the console, though it's not recommended due to high verbosity." + default = false +} + +# Endpoints +variable "libre_app_endpoints" { + type = string + description = "endpoints and models selection. E.g. 'openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic'" + default = "azureOpenAI" +} + +# Azure OpenAI +variable "libre_app_az_oai_api_key" { + type = string + description = "Azure OpenAI API Key" + default = null + sensitive = true +} + +variable "libre_app_az_oai_models" { + type = string + description = "Azure OpenAI Models. E.g. 'gpt-4-1106-preview,gpt-4,gpt-3.5-turbo,gpt-3.5-turbo-1106,gpt-4-vision-preview'" + default = "gpt-4-1106-preview" +} + +variable "libre_app_az_oai_use_model_as_deployment_name" { + type = bool + description = "Azure OpenAI Use Model as Deployment Name" + default = true +} + +variable "libre_app_az_oai_instance_name" { + type = string + description = "Azure OpenAI Instance Name" + default = null +} + +variable "libre_app_az_oai_api_version" { + type = string + description = "Azure OpenAI API Version" + default = "2023-07-01-preview" +} + +# Plugins +variable "libre_app_debug_plugins" { + type = bool + description = "Enable debug mode for Libre App plugins." + default = false +} +variable "libre_app_plugins_creds_key" { + type = string + description = "Libre App Plugins Creds Key" + default = null + sensitive = true +} +variable "libre_app_plugins_creds_iv" { + type = string + description = "Libre App Plugins Creds IV" + default = null + sensitive = true +} # ################################### # ### Container App Module params ### From 01f1688f5ccd7562c92bd26b229741672d1a192f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sat, 20 Jan 2024 11:35:23 +0000 Subject: [PATCH 072/163] update vars --- 06_librechat_app_config.tf | 34 +++++++--------------------- tests/auto_test1/main.tf | 7 ++++++ tests/auto_test1/testing.auto.tfvars | 8 ++++++- tests/auto_test1/variables.tf | 26 +++++++++++++++++++++ variables.tf | 26 +++++++++++++++++++++ 5 files changed, 74 insertions(+), 27 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index b9ed1b5..3979b7f 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -31,32 +31,10 @@ locals { CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" ### Search ### - SEARCH = var.enable_meilisearch #true - MEILI_NO_ANALYTICS = var.disable_meilisearch_analytics #true - MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" - MEILI_MASTER_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" - - ### User - Moderation ### - BAN_VIOLATIONS = true - BAN_DURATION = 1000 * 60 * 60 * 2 - BAN_INTERVAL = 20 - LOGIN_VIOLATION_SCORE = 1 - REGISTRATION_VIOLATION_SCORE = 1 - CONCURRENT_VIOLATION_SCORE = 1 - MESSAGE_VIOLATION_SCORE = 1 - NON_BROWSER_VIOLATION_SCORE = 20 - LOGIN_MAX = 7 - LOGIN_WINDOW = 5 - REGISTER_MAX = 5 - REGISTER_WINDOW = 60 - LIMIT_CONCURRENT_MESSAGES = true - CONCURRENT_MESSAGE_MAX = 2 - LIMIT_MESSAGE_IP = true - MESSAGE_IP_MAX = 40 - MESSAGE_IP_WINDOW = 1 - LIMIT_MESSAGE_USER = false - MESSAGE_USER_MAX = 40 - MESSAGE_USER_WINDOW = 1 + SEARCH = var.libre_app_enable_meilisearch + MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics + MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" + MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" ### User - Balance ### CHECK_BALANCE = false @@ -234,6 +212,10 @@ locals { # #=============# # # Moderation # # #=============# +# OPENAI_MODERATION=true +# OPENAI_MODERATION_API_KEY=sk-1234 +# OPENAI_MODERATION_REVERSE_PROXY=false +# # BAN_VIOLATIONS = true # BAN_DURATION = 1000 * 60 * 60 * 2 # BAN_INTERVAL = 20 diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index e49d5a8..b16762d 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -123,6 +123,13 @@ module "private-chatgpt-openai" { libre_app_plugins_creds_key = var.libre_app_plugins_creds_key libre_app_plugins_creds_iv = var.libre_app_plugins_creds_iv + # Search + libre_app_enable_meilisearch = var.libre_app_enable_meilisearch + libre_app_disable_meilisearch_analytics = var.libre_app_disable_meilisearch_analytics + libre_app_meili_host = var.libre_app_meili_host + libre_app_meili_key = var.libre_app_meili_key + + diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 281cdf3..3c0feb3 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -1,6 +1,6 @@ ### 01 Common Variables + RG ### resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -location = "uksouth" +location = "eastus" tags = { Terraform = "True" Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" @@ -124,6 +124,12 @@ libre_app_debug_plugins = false libre_app_plugins_creds_key = null libre_app_plugins_creds_iv = null +# Search +libre_app_enable_meilisearch = true +libre_app_disable_meilisearch_analytics = true +libre_app_meili_host = null +libre_app_meili_key = null + # ### CDN - Front Door ### # create_front_door_cdn = true # create_dns_zone = true #Set to false if you already have a DNS zone, remember to add this DNS zone to your domain registrar diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 03fd557..5c63a49 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -501,6 +501,32 @@ variable "libre_app_plugins_creds_iv" { sensitive = true } +# Search +variable "libre_app_enable_meilisearch" { + type = bool + description = "Enable Meilisearch" + default = true +} + +variable "libre_app_disable_meilisearch_analytics" { + type = bool + description = "Disable Meilisearch Analytics" + default = true +} + +variable "libre_app_meili_host" { + type = string + description = "For the API server to connect to the search server. E.g. https://meilisearch.example.com" + default = null +} + +variable "libre_app_meili_key" { + type = string + description = "Meilisearch API Key" + default = null + sensitive = true +} + # LibreChat App Service App Settings # # DNS zone # diff --git a/variables.tf b/variables.tf index a79b577..115382e 100644 --- a/variables.tf +++ b/variables.tf @@ -505,6 +505,32 @@ variable "libre_app_plugins_creds_iv" { sensitive = true } +# Search +variable "libre_app_enable_meilisearch" { + type = bool + description = "Enable Meilisearch" + default = true +} + +variable "libre_app_disable_meilisearch_analytics" { + type = bool + description = "Disable Meilisearch Analytics" + default = true +} + +variable "libre_app_meili_host" { + type = string + description = "For the API server to connect to the search server. E.g. https://meilisearch.example.com" + default = null +} + +variable "libre_app_meili_key" { + type = string + description = "Meilisearch API Key" + default = null + sensitive = true +} + # ################################### # ### Container App Module params ### # ################################### From f469a8db2abacd8f167fff1a589e8a70f89dbfd0 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sat, 20 Jan 2024 14:02:23 +0000 Subject: [PATCH 073/163] alpha test run --- 06_librechat_app.tf | 2 +- 06_librechat_app_config.tf | 38 +++++++++++++------------- tests/auto_test1/main.tf | 11 +++++--- tests/auto_test1/testing.auto.tfvars | 8 ++++++ tests/auto_test1/variables.tf | 40 ++++++++++++++++++++++++++++ variables.tf | 39 +++++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 24 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 6c11cb0..bf479ec 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -80,7 +80,7 @@ resource "azurerm_linux_web_app" "meilisearch" { MEILI_MASTER_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" MEILI_NO_ANALYTICS = true - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = 7700 diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 3979b7f..aae300c 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -1,5 +1,15 @@ locals { libre_app_settings = { + ### App Service Configuration ### + WEBSITE_RUN_FROM_PACKAGE = "1" + # DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + DOCKER_ENABLE_CI = false + WEBSITES_PORT = var.libre_app_port + PORT = var.libre_app_port + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + #NODE_ENV = "production" + ### Server Configuration ### APP_TITLE = var.libre_app_title CUSTOM_FOOTER = var.libre_app_custom_footer @@ -37,27 +47,17 @@ locals { MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" ### User - Balance ### - CHECK_BALANCE = false + #CHECK_BALANCE = false ### User - Registration and Login ### - ALLOW_EMAIL_LOGIN = true - ALLOW_REGISTRATION = true - ALLOW_SOCIAL_LOGIN = false - ALLOW_SOCIAL_REGISTRATION = false - SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes - REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days - JWT_SECRET = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" - JWT_REFRESH_SECRET = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" - - ### App Service Configuration ### - WEBSITE_RUN_FROM_PACKAGE = "1" - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" - WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - DOCKER_ENABLE_CI = false - WEBSITES_PORT = 80 - PORT = 80 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - NODE_ENV = "production" + ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true + ALLOW_REGISTRATION = var.libre_app_allow_registration #true + ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false + ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false + SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days + JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" + JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" } } diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index b16762d..6483880 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -129,10 +129,13 @@ module "private-chatgpt-openai" { libre_app_meili_host = var.libre_app_meili_host libre_app_meili_key = var.libre_app_meili_key - - - - + # User Registration + libre_app_allow_email_login = var.libre_app_allow_email_login + libre_app_allow_registration = var.libre_app_allow_registration + libre_app_allow_social_login = var.libre_app_allow_social_login + libre_app_allow_social_registration = var.libre_app_allow_social_registration + libre_app_jwt_secret = var.libre_app_jwt_secret + libre_app_jwt_refresh_secret = var.libre_app_jwt_refresh_secret } diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 3c0feb3..6b8876c 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -130,6 +130,14 @@ libre_app_disable_meilisearch_analytics = true libre_app_meili_host = null libre_app_meili_key = null +# User Registration +libre_app_allow_email_login = true +libre_app_allow_registration = true +libre_app_allow_social_login = false +libre_app_allow_social_registration = false +libre_app_jwt_secret = null +libre_app_jwt_refresh_secret = null + # ### CDN - Front Door ### # create_front_door_cdn = true # create_dns_zone = true #Set to false if you already have a DNS zone, remember to add this DNS zone to your domain registrar diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 5c63a49..cb71b84 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -527,6 +527,46 @@ variable "libre_app_meili_key" { sensitive = true } +# User Registration +variable "libre_app_allow_email_login" { + type = bool + description = "Allow Email Login" + default = true +} + +variable "libre_app_allow_registration" { + type = bool + description = "Allow Registration" + default = true +} + +variable "libre_app_allow_social_login" { + type = bool + description = "Allow Social Login" + default = false +} + +variable "libre_app_allow_social_registration" { + type = bool + description = "Allow Social Registration" + default = false +} + +variable "libre_app_jwt_secret" { + type = string + description = "JWT Secret" + default = null + sensitive = true +} + +variable "libre_app_jwt_refresh_secret" { + type = string + description = "JWT Refresh Secret" + default = null + sensitive = true +} + + # LibreChat App Service App Settings # # DNS zone # diff --git a/variables.tf b/variables.tf index 115382e..939f23b 100644 --- a/variables.tf +++ b/variables.tf @@ -531,6 +531,45 @@ variable "libre_app_meili_key" { sensitive = true } +# User Registration +variable "libre_app_allow_email_login" { + type = bool + description = "Allow Email Login" + default = true +} + +variable "libre_app_allow_registration" { + type = bool + description = "Allow Registration" + default = true +} + +variable "libre_app_allow_social_login" { + type = bool + description = "Allow Social Login" + default = false +} + +variable "libre_app_allow_social_registration" { + type = bool + description = "Allow Social Registration" + default = false +} + +variable "libre_app_jwt_secret" { + type = string + description = "JWT Secret" + default = null + sensitive = true +} + +variable "libre_app_jwt_refresh_secret" { + type = string + description = "JWT Refresh Secret" + default = null + sensitive = true +} + # ################################### # ### Container App Module params ### # ################################### From a7fec654a9ec82bb108b93c071ae71041a4c8b45 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sat, 20 Jan 2024 14:04:32 +0000 Subject: [PATCH 074/163] fixes on vars --- 06_librechat_app.tf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index bf479ec..9f4f0fa 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -22,13 +22,13 @@ resource "random_string" "libre_app_creds_iv" { } resource "azurerm_key_vault_secret" "libre_app_creds_key" { - name = "${libre_app_name}-key" + name = "${var.libre_app_name}-key" value = random_string.libre_app_creds_key.result key_vault_id = azurerm_key_vault.az_openai_kv.id } resource "azurerm_key_vault_secret" "libre_app_creds_iv" { - name = "${libre_app_name}-iv" + name = "${var.libre_app_name}-iv" value = random_string.libre_app_creds_iv.result key_vault_id = azurerm_key_vault.az_openai_kv.id } @@ -45,13 +45,13 @@ resource "random_string" "libre_app_jwt_refresh_secret" { } resource "azurerm_key_vault_secret" "libre_app_jwt_secret" { - name = "${libre_app_name}-jwt-secret" + name = "${var.libre_app_name}-jwt-secret" value = random_string.libre_app_jwt_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id } resource "azurerm_key_vault_secret" "libre_app_jwt_refresh_secret" { - name = "${libre_app_name}-jwt-refresh-secret" + name = "${var.libre_app_name}-jwt-refresh-secret" value = random_string.libre_app_jwt_refresh_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id } From 7d47479d7e598b389cbabd8e4d8ecc8f82b1dd15 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sat, 20 Jan 2024 14:13:18 +0000 Subject: [PATCH 075/163] fixes - add identity to libre app --- 06_librechat_app.tf | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 9f4f0fa..e993e81 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -122,7 +122,7 @@ resource "azurerm_role_assignment" "meilisearch_app_kv_access" { role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. } -resource "azurerm_linux_web_app" "az_openai_librechat" { +resource "azurerm_linux_web_app" "librechat" { name = var.libre_app_name location = var.location resource_group_name = azurerm_resource_group.az_openai_rg.name @@ -146,6 +146,10 @@ resource "azurerm_linux_web_app" "az_openai_librechat" { } } + identity { + type = "SystemAssigned" + } + app_settings = local.libre_app_settings virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id @@ -153,9 +157,15 @@ resource "azurerm_linux_web_app" "az_openai_librechat" { } # Grant kv access to librechat app to reference environment variables (stored as secrets in key vault) -resource "azurerm_role_assignment" "libre_app_kv_access" { +#resource "azurerm_role_assignment" "libre_app_kv_access" { +# scope = azurerm_key_vault.az_openai_kv.id +# principal_id = azurerm_linux_web_app.az_openai_librechat.identity[0].principal_id +# role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. +#} + +resource "azurerm_role_assignment" "librechat_app_kv_access" { scope = azurerm_key_vault.az_openai_kv.id - principal_id = azurerm_linux_web_app.az_openai_librechat.identity[0].principal_id + principal_id = azurerm_linux_web_app.librechat.identity[0].principal_id role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. } From 0225c5ccbb3c1b6205d0a3cd440864f4400e05b5 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sat, 20 Jan 2024 14:18:49 +0000 Subject: [PATCH 076/163] fix random output being shown in screen --- 06_librechat_app.tf | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index e993e81..fa8ef88 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -1,58 +1,58 @@ # Generate random strings as keys for meilisearch and librechat (Stored securely in Azure Key Vault) -resource "random_string" "meilisearch_master_key" { +resource "random_password" "meilisearch_master_key" { length = 20 special = false } resource "azurerm_key_vault_secret" "meilisearch_master_key" { name = "${var.meilisearch_app_name}-master-key" - value = random_string.meilisearch_master_key.result + value = random_password.meilisearch_master_key.result key_vault_id = azurerm_key_vault.az_openai_kv.id } # LibreChat CREDS key (64 characters in hex) and 16-byte IV (32 characters in hex) -resource "random_string" "libre_app_creds_key" { +resource "random_password" "libre_app_creds_key" { length = 64 special = false } -resource "random_string" "libre_app_creds_iv" { +resource "random_password" "libre_app_creds_iv" { length = 32 special = false } resource "azurerm_key_vault_secret" "libre_app_creds_key" { name = "${var.libre_app_name}-key" - value = random_string.libre_app_creds_key.result + value = random_password.libre_app_creds_key.result key_vault_id = azurerm_key_vault.az_openai_kv.id } resource "azurerm_key_vault_secret" "libre_app_creds_iv" { name = "${var.libre_app_name}-iv" - value = random_string.libre_app_creds_iv.result + value = random_password.libre_app_creds_iv.result key_vault_id = azurerm_key_vault.az_openai_kv.id } # LibreChat JWT Secret (64 characters in hex) and JWT Refresh Secret (64 characters in hex) -resource "random_string" "libre_app_jwt_secret" { +resource "random_password" "libre_app_jwt_secret" { length = 64 special = false } -resource "random_string" "libre_app_jwt_refresh_secret" { +resource "random_password" "libre_app_jwt_refresh_secret" { length = 64 special = false } resource "azurerm_key_vault_secret" "libre_app_jwt_secret" { name = "${var.libre_app_name}-jwt-secret" - value = random_string.libre_app_jwt_secret.result + value = random_password.libre_app_jwt_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id } resource "azurerm_key_vault_secret" "libre_app_jwt_refresh_secret" { name = "${var.libre_app_name}-jwt-refresh-secret" - value = random_string.libre_app_jwt_refresh_secret.result + value = random_password.libre_app_jwt_refresh_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id } From c09d76b528f51b894b432b5fc4b6968d3e1ec1c8 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sat, 20 Jan 2024 14:19:48 +0000 Subject: [PATCH 077/163] sdfsdf --- .github/dependabot.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5034c6e..94924df 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,9 @@ updates: - package-ecosystem: "terraform" directory: "/" schedule: - interval: "daily" + interval: "weekly" + day: "monday" + time: "00:00" timezone: "Europe/London" - package-ecosystem: "github-actions" From 5167de090d2e8bb7270cc1857c84288a4a2bd2c1 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sat, 20 Jan 2024 14:40:46 +0000 Subject: [PATCH 078/163] change region --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 6b8876c..3030dbd 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -1,6 +1,6 @@ ### 01 Common Variables + RG ### resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -location = "eastus" +location = "westus" tags = { Terraform = "True" Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" From b6159b5c139fc526d39110652ea5f016e0da0a78 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 10:47:03 +0000 Subject: [PATCH 079/163] add vision --- 06_librechat_app_config.tf | 2 +- tests/auto_test1/testing.auto.tfvars | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index aae300c..bf9586d 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -107,7 +107,7 @@ locals { # # Azure # # #============# # AZURE_API_KEY = "" -# AZURE_OPENAI_MODELS = "gpt-3.5-turbo,gpt-4" +# AZURE_OPENAI_MODELS = "gpt-4-1106-preview,gpt-4,gpt-3.5-turbo,gpt-3.5-turbo-1106,gpt-4-vision-preview" # AZURE_OPENAI_DEFAULT_MODEL = "gpt-3.5-turbo" # PLUGINS_USE_AZURE = true # AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 3030dbd..098c805 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -52,12 +52,20 @@ oai_network_acls = null oai_storage = null oai_model_deployment = [ { - deployment_id = "gpt-4" - model_name = "gpt-4" + deployment_id = "gpt-4-1106-Preview" + model_name = "gpt-4-1106-Preview" model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 10 # 34K == Roughly 204 RPM (Requests per minute) + scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + }, + { + deployment_id = "gpt-4-vision-preview" + model_name = "gpt-4-vision-preview" + model_format = "OpenAI" + model_version = "vision-preview" + scale_type = "Standard" + scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) } ] From c77e42dc91bc0489567736fdcb97e28a6c925a92 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 10:55:48 +0000 Subject: [PATCH 080/163] test --- tests/auto_test1/testing.auto.tfvars | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 098c805..c46d190 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -57,16 +57,16 @@ oai_model_deployment = [ model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) - }, - { - deployment_id = "gpt-4-vision-preview" - model_name = "gpt-4-vision-preview" - model_format = "OpenAI" - model_version = "vision-preview" - scale_type = "Standard" - scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) - } + scale_capacity = 10 # 34K == Roughly 204 RPM (Requests per minute) + } #, + # { + # deployment_id = "gpt-4-vision-preview" + # model_name = "gpt-4-vision-preview" + # model_format = "OpenAI" + # model_version = "vision-preview" + # scale_type = "Standard" + # scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + #} ] ### 05 cosmosdb ### From d789902a5f3cd3001d67cd7c582b2340bcd81dc8 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 11:11:08 +0000 Subject: [PATCH 081/163] up --- tests/auto_test1/testing.auto.tfvars | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index c46d190..a6e9cfe 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -53,20 +53,20 @@ oai_storage = null oai_model_deployment = [ { deployment_id = "gpt-4-1106-Preview" - model_name = "gpt-4-1106-Preview" + model_name = "gpt-4" model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 10 # 34K == Roughly 204 RPM (Requests per minute) - } #, - # { - # deployment_id = "gpt-4-vision-preview" - # model_name = "gpt-4-vision-preview" - # model_format = "OpenAI" - # model_version = "vision-preview" - # scale_type = "Standard" - # scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) - #} + scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + }, + { + deployment_id = "gpt-4-vision-preview" + model_name = "gpt-4" + model_format = "OpenAI" + model_version = "vision-preview" + scale_type = "Standard" + scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + } ] ### 05 cosmosdb ### @@ -122,7 +122,7 @@ libre_app_endpoints = "AzureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null -libre_app_az_oai_models = "gpt-4-1106-Preview" +libre_app_az_oai_models = "gpt-4-1106-Preview,gpt-4-vision-preview" libre_app_az_oai_use_model_as_deployment_name = true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" From 4bd879167c460d7b1052176bdd45c83707fabe41 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 11:15:21 +0000 Subject: [PATCH 082/163] test --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index a6e9cfe..7bec3b1 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -65,7 +65,7 @@ oai_model_deployment = [ model_format = "OpenAI" model_version = "vision-preview" scale_type = "Standard" - scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + scale_capacity = 5 } ] From 4e01366dbf89281c1ee7f28bf214a6cca74c7c1f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 11:19:37 +0000 Subject: [PATCH 083/163] test --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 7bec3b1..168219e 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -108,7 +108,7 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 3080 +libre_app_port = 80 libre_app_mongo_uri = null libre_app_domain_client = "https://localhost:3080" libre_app_domain_server = "https://localhost:3080" From 81c80ccc8f4a538ae80d1d1541c89b0026aa93c4 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 11:31:02 +0000 Subject: [PATCH 084/163] test --- tests/auto_test1/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 6483880..9324a2f 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -115,7 +115,7 @@ module "private-chatgpt-openai" { libre_app_az_oai_api_key = var.libre_app_az_oai_api_key libre_app_az_oai_models = var.libre_app_az_oai_models libre_app_az_oai_use_model_as_deployment_name = var.libre_app_az_oai_use_model_as_deployment_name - libre_app_az_oai_instance_name = var.libre_app_az_oai_use_model_as_deployment_name + libre_app_az_oai_instance_name = var.libre_app_az_oai_instance_name libre_app_az_oai_api_version = var.libre_app_az_oai_api_version # Plugins From 7ddc07ee607ce2650b38d16b9082894414016e6e Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 11:39:41 +0000 Subject: [PATCH 085/163] test --- 06_librechat_app_config.tf | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index bf9586d..1e16f45 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -49,6 +49,32 @@ locals { ### User - Balance ### #CHECK_BALANCE = false + BAN_VIOLATIONS = true + BAN_DURATION = 1000 * 60 * 60 * 2 + BAN_INTERVAL = 20 + + LOGIN_VIOLATION_SCORE = 1 + REGISTRATION_VIOLATION_SCORE = 1 + CONCURRENT_VIOLATION_SCORE = 1 + MESSAGE_VIOLATION_SCORE = 1 + NON_BROWSER_VIOLATION_SCORE = 20 + + LOGIN_MAX = 7 + LOGIN_WINDOW = 5 + REGISTER_MAX = 5 + REGISTER_WINDOW = 60 + + LIMIT_CONCURRENT_MESSAGES = true + CONCURRENT_MESSAGE_MAX = 2 + + LIMIT_MESSAGE_IP = true + MESSAGE_IP_MAX = 40 + MESSAGE_IP_WINDOW = 1 + + LIMIT_MESSAGE_USER = false + MESSAGE_USER_MAX = 40 + MESSAGE_USER_WINDOW = 1 + ### User - Registration and Login ### ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true ALLOW_REGISTRATION = var.libre_app_allow_registration #true From 3ecb8226021ca4b19d6bb108fa1df70921ed62c3 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 11:42:34 +0000 Subject: [PATCH 086/163] test --- 06_librechat_app_config.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 1e16f45..d207635 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -2,13 +2,13 @@ locals { libre_app_settings = { ### App Service Configuration ### WEBSITE_RUN_FROM_PACKAGE = "1" - # DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io"####### WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - #NODE_ENV = "production" + NODE_ENV = "production" ####### ### Server Configuration ### APP_TITLE = var.libre_app_title From ad46429ffbc08cba9868ee7cf16073e5189d4e44 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 11:45:43 +0000 Subject: [PATCH 087/163] test --- 06_librechat_app_config.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index d207635..40c5874 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -47,7 +47,7 @@ locals { MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" ### User - Balance ### - #CHECK_BALANCE = false + CHECK_BALANCE = false ####### BAN_VIOLATIONS = true BAN_DURATION = 1000 * 60 * 60 * 2 From a859d71da3ba6de1dea3bd0b1d1fb44b8488edff Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 12:12:33 +0000 Subject: [PATCH 088/163] test --- 06_librechat_app_config.tf | 148 +++++++++++++-------------- 07_test.tf | 88 ++++++++++++++++ tests/auto_test1/testing.auto.tfvars | 4 +- 3 files changed, 164 insertions(+), 76 deletions(-) create mode 100644 07_test.tf diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 40c5874..1e742e7 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -1,91 +1,91 @@ -locals { - libre_app_settings = { - ### App Service Configuration ### - WEBSITE_RUN_FROM_PACKAGE = "1" - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io"####### - WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - DOCKER_ENABLE_CI = false - WEBSITES_PORT = var.libre_app_port - PORT = var.libre_app_port - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - NODE_ENV = "production" ####### +# locals { +# libre_app_settings = { +# ### App Service Configuration ### +# WEBSITE_RUN_FROM_PACKAGE = "1" +# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io"####### +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false +# DOCKER_ENABLE_CI = false +# WEBSITES_PORT = var.libre_app_port +# PORT = var.libre_app_port +# DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" +# NODE_ENV = "production" ####### - ### Server Configuration ### - APP_TITLE = var.libre_app_title - CUSTOM_FOOTER = var.libre_app_custom_footer - HOST = var.libre_app_host - PORT = var.libre_app_port - MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" - DOMAIN_CLIENT = var.libre_app_domain_client - DOMAIN_SERVER = var.libre_app_domain_server +# ### Server Configuration ### +# APP_TITLE = var.libre_app_title +# CUSTOM_FOOTER = var.libre_app_custom_footer +# HOST = var.libre_app_host +# PORT = var.libre_app_port +# MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" +# DOMAIN_CLIENT = var.libre_app_domain_client +# DOMAIN_SERVER = var.libre_app_domain_server - ### Debug Logging ### - DEBUG_LOGGING = var.libre_app_debug_logging - DEBUG_CONSOLE = var.libre_app_debug_console +# ### Debug Logging ### +# DEBUG_LOGGING = var.libre_app_debug_logging +# DEBUG_CONSOLE = var.libre_app_debug_console - ### Endpoints ### - ENDPOINTS = var.libre_app_endpoints #"azureOpenAI" +# ### Endpoints ### +# ENDPOINTS = var.libre_app_endpoints #"azureOpenAI" - ### Azure OpenAI ### - AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" - AZURE_OPENAI_MODELS = var.libre_app_az_oai_models - AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_az_oai_use_model_as_deployment_name - AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] - AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version +# ### Azure OpenAI ### +# AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" +# AZURE_OPENAI_MODELS = var.libre_app_az_oai_models +# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_az_oai_use_model_as_deployment_name +# AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] +# AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version - ### Plugins ### - # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) - # Warning: If you don't set them, the app will crash on startup. - DEBUG_PLUGINS = var.libre_app_debug_plugins - CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" - CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" +# ### Plugins ### +# # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) +# # Warning: If you don't set them, the app will crash on startup. +# DEBUG_PLUGINS = var.libre_app_debug_plugins +# CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" +# CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" - ### Search ### - SEARCH = var.libre_app_enable_meilisearch - MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics - MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" - MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" +# ### Search ### +# SEARCH = var.libre_app_enable_meilisearch +# MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics +# MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" +# MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" - ### User - Balance ### - CHECK_BALANCE = false ####### +# ### User - Balance ### +# CHECK_BALANCE = false ####### - BAN_VIOLATIONS = true - BAN_DURATION = 1000 * 60 * 60 * 2 - BAN_INTERVAL = 20 +# BAN_VIOLATIONS = true +# BAN_DURATION = 1000 * 60 * 60 * 2 +# BAN_INTERVAL = 20 - LOGIN_VIOLATION_SCORE = 1 - REGISTRATION_VIOLATION_SCORE = 1 - CONCURRENT_VIOLATION_SCORE = 1 - MESSAGE_VIOLATION_SCORE = 1 - NON_BROWSER_VIOLATION_SCORE = 20 +# LOGIN_VIOLATION_SCORE = 1 +# REGISTRATION_VIOLATION_SCORE = 1 +# CONCURRENT_VIOLATION_SCORE = 1 +# MESSAGE_VIOLATION_SCORE = 1 +# NON_BROWSER_VIOLATION_SCORE = 20 - LOGIN_MAX = 7 - LOGIN_WINDOW = 5 - REGISTER_MAX = 5 - REGISTER_WINDOW = 60 +# LOGIN_MAX = 7 +# LOGIN_WINDOW = 5 +# REGISTER_MAX = 5 +# REGISTER_WINDOW = 60 - LIMIT_CONCURRENT_MESSAGES = true - CONCURRENT_MESSAGE_MAX = 2 +# LIMIT_CONCURRENT_MESSAGES = true +# CONCURRENT_MESSAGE_MAX = 2 - LIMIT_MESSAGE_IP = true - MESSAGE_IP_MAX = 40 - MESSAGE_IP_WINDOW = 1 +# LIMIT_MESSAGE_IP = true +# MESSAGE_IP_MAX = 40 +# MESSAGE_IP_WINDOW = 1 - LIMIT_MESSAGE_USER = false - MESSAGE_USER_MAX = 40 - MESSAGE_USER_WINDOW = 1 +# LIMIT_MESSAGE_USER = false +# MESSAGE_USER_MAX = 40 +# MESSAGE_USER_WINDOW = 1 - ### User - Registration and Login ### - ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true - ALLOW_REGISTRATION = var.libre_app_allow_registration #true - ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false - ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false - SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes - REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days - JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" - JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" - } -} +# ### User - Registration and Login ### +# ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true +# ALLOW_REGISTRATION = var.libre_app_allow_registration #true +# ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false +# ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false +# SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes +# REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days +# JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" +# JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" +# } +# } #MOVE TO CDN CSETTINGS diff --git a/07_test.tf b/07_test.tf new file mode 100644 index 0000000..5476394 --- /dev/null +++ b/07_test.tf @@ -0,0 +1,88 @@ +locals { + libre_app_settings = { + + + #==================================================# + # Server Configuration # + #==================================================# + APP_TITLE = "test" + CUSTOM_FOOTER = "test" + HOST = "0.0.0.0" + PORT = 80 + MONGO_URI = "" + DOMAIN_CLIENT = "http://localhost:3080" + DOMAIN_SERVER = "http://localhost:3080" + + DEBUG_LOGGING = true + DEBUG_CONSOLE = false + + ENDPOINTS = "azureOpenAI" #openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic + + AZURE_API_KEY = "" + AZURE_OPENAI_MODELS = "gpt-4-1106-Preview,gpt-4-vision-preview" + + AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true + AZURE_OPENAI_API_INSTANCE_NAME = "gptopenai2698" + + AZURE_OPENAI_API_VERSION = "2023-07-01-preview" + + DEBUG_PLUGINS = true + CREDS_KEY = "dfsdgdsffgdsfgds" + CREDS_IV = "dfsdgdsffgdsfgds" + + SEARCH = true + MEILI_NO_ANALYTICS = true + MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" + MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" + + BAN_VIOLATIONS = true + BAN_DURATION = 1000 * 60 * 60 * 2 + BAN_INTERVAL = 20 + + LOGIN_VIOLATION_SCORE = 1 + REGISTRATION_VIOLATION_SCORE = 1 + CONCURRENT_VIOLATION_SCORE = 1 + MESSAGE_VIOLATION_SCORE = 1 + NON_BROWSER_VIOLATION_SCORE = 20 + + LOGIN_MAX = 7 + LOGIN_WINDOW = 5 + REGISTER_MAX = 5 + REGISTER_WINDOW = 60 + + LIMIT_CONCURRENT_MESSAGES = true + CONCURRENT_MESSAGE_MAX = 2 + + LIMIT_MESSAGE_IP = true + MESSAGE_IP_MAX = 40 + MESSAGE_IP_WINDOW = 1 + + LIMIT_MESSAGE_USER = false + MESSAGE_USER_MAX = 40 + MESSAGE_USER_WINDOW = 1 + + + CHECK_BALANCE = false + + + ALLOW_EMAIL_LOGIN = true + ALLOW_REGISTRATION = true + ALLOW_SOCIAL_LOGIN = false + ALLOW_SOCIAL_REGISTRATION = false + + SESSION_EXPIRY = 1000 * 60 * 15 + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 + + JWT_SECRET = "dfsdgdsffgdsfgds" + JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" + + WEBSITE_RUN_FROM_PACKAGE = "1" + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + DOCKER_ENABLE_CI = false + WEBSITES_PORT = 80 + # PORT = 80 + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + NODE_ENV = "production" + } +} \ No newline at end of file diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 168219e..fa2d608 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -114,7 +114,7 @@ libre_app_domain_client = "https://localhost:3080" libre_app_domain_server = "https://localhost:3080" # debug logging -libre_app_debug_logging = false +libre_app_debug_logging = true libre_app_debug_console = false # Endpoints @@ -128,7 +128,7 @@ libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" # Plugins -libre_app_debug_plugins = false +libre_app_debug_plugins = true libre_app_plugins_creds_key = null libre_app_plugins_creds_iv = null From f908d4e469c4a3d069ad4528ee7f75e1ba248780 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 12:34:32 +0000 Subject: [PATCH 089/163] test new --- 06_librechat_app_config.tf | 148 +++++++++++++-------------- 07_test.tf | 132 ++++++++++++------------ tests/auto_test1/testing.auto.tfvars | 8 +- 3 files changed, 144 insertions(+), 144 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 1e742e7..40c5874 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -1,91 +1,91 @@ -# locals { -# libre_app_settings = { -# ### App Service Configuration ### -# WEBSITE_RUN_FROM_PACKAGE = "1" -# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io"####### -# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false -# DOCKER_ENABLE_CI = false -# WEBSITES_PORT = var.libre_app_port -# PORT = var.libre_app_port -# DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" -# NODE_ENV = "production" ####### +locals { + libre_app_settings = { + ### App Service Configuration ### + WEBSITE_RUN_FROM_PACKAGE = "1" + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io"####### + WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + DOCKER_ENABLE_CI = false + WEBSITES_PORT = var.libre_app_port + PORT = var.libre_app_port + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + NODE_ENV = "production" ####### -# ### Server Configuration ### -# APP_TITLE = var.libre_app_title -# CUSTOM_FOOTER = var.libre_app_custom_footer -# HOST = var.libre_app_host -# PORT = var.libre_app_port -# MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" -# DOMAIN_CLIENT = var.libre_app_domain_client -# DOMAIN_SERVER = var.libre_app_domain_server + ### Server Configuration ### + APP_TITLE = var.libre_app_title + CUSTOM_FOOTER = var.libre_app_custom_footer + HOST = var.libre_app_host + PORT = var.libre_app_port + MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" + DOMAIN_CLIENT = var.libre_app_domain_client + DOMAIN_SERVER = var.libre_app_domain_server -# ### Debug Logging ### -# DEBUG_LOGGING = var.libre_app_debug_logging -# DEBUG_CONSOLE = var.libre_app_debug_console + ### Debug Logging ### + DEBUG_LOGGING = var.libre_app_debug_logging + DEBUG_CONSOLE = var.libre_app_debug_console -# ### Endpoints ### -# ENDPOINTS = var.libre_app_endpoints #"azureOpenAI" + ### Endpoints ### + ENDPOINTS = var.libre_app_endpoints #"azureOpenAI" -# ### Azure OpenAI ### -# AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" -# AZURE_OPENAI_MODELS = var.libre_app_az_oai_models -# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_az_oai_use_model_as_deployment_name -# AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] -# AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version + ### Azure OpenAI ### + AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" + AZURE_OPENAI_MODELS = var.libre_app_az_oai_models + AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_az_oai_use_model_as_deployment_name + AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] + AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version -# ### Plugins ### -# # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) -# # Warning: If you don't set them, the app will crash on startup. -# DEBUG_PLUGINS = var.libre_app_debug_plugins -# CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" -# CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" + ### Plugins ### + # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) + # Warning: If you don't set them, the app will crash on startup. + DEBUG_PLUGINS = var.libre_app_debug_plugins + CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" + CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" -# ### Search ### -# SEARCH = var.libre_app_enable_meilisearch -# MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics -# MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" -# MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" + ### Search ### + SEARCH = var.libre_app_enable_meilisearch + MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics + MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" + MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" -# ### User - Balance ### -# CHECK_BALANCE = false ####### + ### User - Balance ### + CHECK_BALANCE = false ####### -# BAN_VIOLATIONS = true -# BAN_DURATION = 1000 * 60 * 60 * 2 -# BAN_INTERVAL = 20 + BAN_VIOLATIONS = true + BAN_DURATION = 1000 * 60 * 60 * 2 + BAN_INTERVAL = 20 -# LOGIN_VIOLATION_SCORE = 1 -# REGISTRATION_VIOLATION_SCORE = 1 -# CONCURRENT_VIOLATION_SCORE = 1 -# MESSAGE_VIOLATION_SCORE = 1 -# NON_BROWSER_VIOLATION_SCORE = 20 + LOGIN_VIOLATION_SCORE = 1 + REGISTRATION_VIOLATION_SCORE = 1 + CONCURRENT_VIOLATION_SCORE = 1 + MESSAGE_VIOLATION_SCORE = 1 + NON_BROWSER_VIOLATION_SCORE = 20 -# LOGIN_MAX = 7 -# LOGIN_WINDOW = 5 -# REGISTER_MAX = 5 -# REGISTER_WINDOW = 60 + LOGIN_MAX = 7 + LOGIN_WINDOW = 5 + REGISTER_MAX = 5 + REGISTER_WINDOW = 60 -# LIMIT_CONCURRENT_MESSAGES = true -# CONCURRENT_MESSAGE_MAX = 2 + LIMIT_CONCURRENT_MESSAGES = true + CONCURRENT_MESSAGE_MAX = 2 -# LIMIT_MESSAGE_IP = true -# MESSAGE_IP_MAX = 40 -# MESSAGE_IP_WINDOW = 1 + LIMIT_MESSAGE_IP = true + MESSAGE_IP_MAX = 40 + MESSAGE_IP_WINDOW = 1 -# LIMIT_MESSAGE_USER = false -# MESSAGE_USER_MAX = 40 -# MESSAGE_USER_WINDOW = 1 + LIMIT_MESSAGE_USER = false + MESSAGE_USER_MAX = 40 + MESSAGE_USER_WINDOW = 1 -# ### User - Registration and Login ### -# ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true -# ALLOW_REGISTRATION = var.libre_app_allow_registration #true -# ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false -# ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false -# SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes -# REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days -# JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" -# JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" -# } -# } + ### User - Registration and Login ### + ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true + ALLOW_REGISTRATION = var.libre_app_allow_registration #true + ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false + ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false + SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days + JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" + JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" + } +} #MOVE TO CDN CSETTINGS diff --git a/07_test.tf b/07_test.tf index 5476394..9e90ae1 100644 --- a/07_test.tf +++ b/07_test.tf @@ -1,88 +1,88 @@ -locals { - libre_app_settings = { +# locals { +# libre_app_settings = { - #==================================================# - # Server Configuration # - #==================================================# - APP_TITLE = "test" - CUSTOM_FOOTER = "test" - HOST = "0.0.0.0" - PORT = 80 - MONGO_URI = "" - DOMAIN_CLIENT = "http://localhost:3080" - DOMAIN_SERVER = "http://localhost:3080" +# #==================================================# +# # Server Configuration # +# #==================================================# +# APP_TITLE = "test" +# CUSTOM_FOOTER = "test" +# HOST = "0.0.0.0" +# PORT = 80 +# MONGO_URI = "" +# DOMAIN_CLIENT = "http://localhost:3080" +# DOMAIN_SERVER = "http://localhost:3080" - DEBUG_LOGGING = true - DEBUG_CONSOLE = false +# DEBUG_LOGGING = true +# DEBUG_CONSOLE = false - ENDPOINTS = "azureOpenAI" #openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic +# ENDPOINTS = "azureOpenAI" #openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic - AZURE_API_KEY = "" - AZURE_OPENAI_MODELS = "gpt-4-1106-Preview,gpt-4-vision-preview" +# AZURE_API_KEY = "" +# AZURE_OPENAI_MODELS = "gpt-4-1106-Preview,gpt-4-vision-preview" - AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true - AZURE_OPENAI_API_INSTANCE_NAME = "gptopenai2698" +# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true +# AZURE_OPENAI_API_INSTANCE_NAME = "gptopenai2698" - AZURE_OPENAI_API_VERSION = "2023-07-01-preview" +# AZURE_OPENAI_API_VERSION = "2023-07-01-preview" - DEBUG_PLUGINS = true - CREDS_KEY = "dfsdgdsffgdsfgds" - CREDS_IV = "dfsdgdsffgdsfgds" +# DEBUG_PLUGINS = true +# CREDS_KEY = "dfsdgdsffgdsfgds" +# CREDS_IV = "dfsdgdsffgdsfgds" - SEARCH = true - MEILI_NO_ANALYTICS = true - MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" - MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" +# SEARCH = true +# MEILI_NO_ANALYTICS = true +# MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" +# MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" - BAN_VIOLATIONS = true - BAN_DURATION = 1000 * 60 * 60 * 2 - BAN_INTERVAL = 20 +# BAN_VIOLATIONS = true +# BAN_DURATION = 1000 * 60 * 60 * 2 +# BAN_INTERVAL = 20 - LOGIN_VIOLATION_SCORE = 1 - REGISTRATION_VIOLATION_SCORE = 1 - CONCURRENT_VIOLATION_SCORE = 1 - MESSAGE_VIOLATION_SCORE = 1 - NON_BROWSER_VIOLATION_SCORE = 20 +# LOGIN_VIOLATION_SCORE = 1 +# REGISTRATION_VIOLATION_SCORE = 1 +# CONCURRENT_VIOLATION_SCORE = 1 +# MESSAGE_VIOLATION_SCORE = 1 +# NON_BROWSER_VIOLATION_SCORE = 20 - LOGIN_MAX = 7 - LOGIN_WINDOW = 5 - REGISTER_MAX = 5 - REGISTER_WINDOW = 60 +# LOGIN_MAX = 7 +# LOGIN_WINDOW = 5 +# REGISTER_MAX = 5 +# REGISTER_WINDOW = 60 - LIMIT_CONCURRENT_MESSAGES = true - CONCURRENT_MESSAGE_MAX = 2 +# LIMIT_CONCURRENT_MESSAGES = true +# CONCURRENT_MESSAGE_MAX = 2 - LIMIT_MESSAGE_IP = true - MESSAGE_IP_MAX = 40 - MESSAGE_IP_WINDOW = 1 +# LIMIT_MESSAGE_IP = true +# MESSAGE_IP_MAX = 40 +# MESSAGE_IP_WINDOW = 1 - LIMIT_MESSAGE_USER = false - MESSAGE_USER_MAX = 40 - MESSAGE_USER_WINDOW = 1 +# LIMIT_MESSAGE_USER = false +# MESSAGE_USER_MAX = 40 +# MESSAGE_USER_WINDOW = 1 - CHECK_BALANCE = false +# CHECK_BALANCE = false - ALLOW_EMAIL_LOGIN = true - ALLOW_REGISTRATION = true - ALLOW_SOCIAL_LOGIN = false - ALLOW_SOCIAL_REGISTRATION = false +# ALLOW_EMAIL_LOGIN = true +# ALLOW_REGISTRATION = true +# ALLOW_SOCIAL_LOGIN = false +# ALLOW_SOCIAL_REGISTRATION = false - SESSION_EXPIRY = 1000 * 60 * 15 - REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 +# SESSION_EXPIRY = 1000 * 60 * 15 +# REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 - JWT_SECRET = "dfsdgdsffgdsfgds" - JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" +# JWT_SECRET = "dfsdgdsffgdsfgds" +# JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" - WEBSITE_RUN_FROM_PACKAGE = "1" - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" - WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - DOCKER_ENABLE_CI = false - WEBSITES_PORT = 80 - # PORT = 80 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - NODE_ENV = "production" - } -} \ No newline at end of file +# WEBSITE_RUN_FROM_PACKAGE = "1" +# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false +# DOCKER_ENABLE_CI = false +# WEBSITES_PORT = 80 +# # PORT = 80 +# DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" +# NODE_ENV = "production" +# } +# } \ No newline at end of file diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index fa2d608..8d6f7cd 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -52,7 +52,7 @@ oai_network_acls = null oai_storage = null oai_model_deployment = [ { - deployment_id = "gpt-4-1106-Preview" + deployment_id = "gpt-4-1106-preview" model_name = "gpt-4" model_format = "OpenAI" model_version = "1106-Preview" @@ -110,8 +110,8 @@ libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and libre_app_host = "0.0.0.0" libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "https://localhost:3080" -libre_app_domain_server = "https://localhost:3080" +libre_app_domain_client = "http://localhost:3080" +libre_app_domain_server = "http://localhost:3080" # debug logging libre_app_debug_logging = true @@ -122,7 +122,7 @@ libre_app_endpoints = "AzureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null -libre_app_az_oai_models = "gpt-4-1106-Preview,gpt-4-vision-preview" +libre_app_az_oai_models = "gpt-4-1106-preview,gpt-4-vision-preview" libre_app_az_oai_use_model_as_deployment_name = true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" From 4d25b67c24db0a5721f1a7a328d4bb8381efcee3 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 12:37:19 +0000 Subject: [PATCH 090/163] fix casing --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 8d6f7cd..e85bff2 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -118,7 +118,7 @@ libre_app_debug_logging = true libre_app_debug_console = false # Endpoints -libre_app_endpoints = "AzureOpenAI" +libre_app_endpoints = "azureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null From 1b9aa64ca5043c8762c13ea2eb834dbe7722878b Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 13:42:58 +0000 Subject: [PATCH 091/163] test --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index e85bff2..d11dbe5 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -38,7 +38,7 @@ kv_fw_network_subnet_ids = null ### 04 Create OpenAI Service ### oai_account_name = "gptopenaiaccount" oai_sku_name = "S0" -oai_custom_subdomain_name = "gptopenai" +oai_custom_subdomain_name = "gptopenaiaccount" oai_dynamic_throttling_enabled = false oai_fqdns = [] oai_local_auth_enabled = true From a6b93ee56f2f7d4d0bd613a95ccd01ae84163c25 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 13:44:02 +0000 Subject: [PATCH 092/163] tst --- 06_librechat_app_config.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 40c5874..a5d8e86 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -24,7 +24,7 @@ locals { DEBUG_CONSOLE = var.libre_app_debug_console ### Endpoints ### - ENDPOINTS = var.libre_app_endpoints #"azureOpenAI" + ENDPOINTS = var.libre_app_endpoints ### Azure OpenAI ### AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" From 330a175afade4e0945f5416bb34b652f3f820ea7 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 13:55:45 +0000 Subject: [PATCH 093/163] change keys --- 06_librechat_app.tf | 5 +++++ 06_librechat_app_config.tf | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index fa8ef88..5e1955b 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -2,6 +2,7 @@ resource "random_password" "meilisearch_master_key" { length = 20 special = false + upper = false } resource "azurerm_key_vault_secret" "meilisearch_master_key" { @@ -14,11 +15,13 @@ resource "azurerm_key_vault_secret" "meilisearch_master_key" { resource "random_password" "libre_app_creds_key" { length = 64 special = false + upper = false } resource "random_password" "libre_app_creds_iv" { length = 32 special = false + upper = false } resource "azurerm_key_vault_secret" "libre_app_creds_key" { @@ -37,11 +40,13 @@ resource "azurerm_key_vault_secret" "libre_app_creds_iv" { resource "random_password" "libre_app_jwt_secret" { length = 64 special = false + upper = false } resource "random_password" "libre_app_jwt_refresh_secret" { length = 64 special = false + upper = false } resource "azurerm_key_vault_secret" "libre_app_jwt_secret" { diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index a5d8e86..5bea717 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -1,8 +1,8 @@ locals { libre_app_settings = { ### App Service Configuration ### - WEBSITE_RUN_FROM_PACKAGE = "1" - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io"####### + WEBSITE_RUN_FROM_PACKAGE = "1" + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port From cec5792f6b5bf9a5e9ebad40964a5d1d87a5258a Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 14:15:19 +0000 Subject: [PATCH 094/163] test --- tests/auto_test1/testing.auto.tfvars | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index d11dbe5..c0f8db5 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -110,8 +110,8 @@ libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and libre_app_host = "0.0.0.0" libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "http://localhost:3080" -libre_app_domain_server = "http://localhost:3080" +libre_app_domain_client = "http://localhost" #:3080" +libre_app_domain_server = "http://localhost" #:3080" # debug logging libre_app_debug_logging = true @@ -122,7 +122,7 @@ libre_app_endpoints = "azureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null -libre_app_az_oai_models = "gpt-4-1106-preview,gpt-4-vision-preview" +libre_app_az_oai_models = "gpt-4,gpt-4-1106-preview,gpt-4-vision-preview" libre_app_az_oai_use_model_as_deployment_name = true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" From 107f62c85519589ba70ce1ece1facec4da720109 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 15:22:08 +0000 Subject: [PATCH 095/163] test --- 06_librechat_app_config.tf | 6 +++--- tests/auto_test1/testing.auto.tfvars | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 5bea717..343b114 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -5,8 +5,8 @@ locals { DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false - WEBSITES_PORT = var.libre_app_port - PORT = var.libre_app_port + WEBSITES_PORT = 3080 + PORT = 3080 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" NODE_ENV = "production" ####### @@ -14,7 +14,7 @@ locals { APP_TITLE = var.libre_app_title CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host - PORT = var.libre_app_port + # PORT = var.libre_app_port MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" DOMAIN_CLIENT = var.libre_app_domain_client DOMAIN_SERVER = var.libre_app_domain_server diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index c0f8db5..0744ae8 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -108,10 +108,10 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 80 +libre_app_port = 3080 libre_app_mongo_uri = null -libre_app_domain_client = "http://localhost" #:3080" -libre_app_domain_server = "http://localhost" #:3080" +libre_app_domain_client = "http://localhost:3080" +libre_app_domain_server = "http://localhost:3080" # debug logging libre_app_debug_logging = true From 7ce10c60a8c56bf3ae7a75913682440eeda1c6f5 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 15:31:03 +0000 Subject: [PATCH 096/163] test --- 06_librechat_app_config.tf | 6 +++--- tests/auto_test1/testing.auto.tfvars | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 343b114..20378fb 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -5,8 +5,8 @@ locals { DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false - WEBSITES_PORT = 3080 - PORT = 3080 + WEBSITES_PORT = var.libre_app_port + #PORT = 80 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" NODE_ENV = "production" ####### @@ -14,7 +14,7 @@ locals { APP_TITLE = var.libre_app_title CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host - # PORT = var.libre_app_port + PORT = var.libre_app_port MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" DOMAIN_CLIENT = var.libre_app_domain_client DOMAIN_SERVER = var.libre_app_domain_server diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 0744ae8..dbd0565 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -108,7 +108,7 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 3080 +libre_app_port = 80 libre_app_mongo_uri = null libre_app_domain_client = "http://localhost:3080" libre_app_domain_server = "http://localhost:3080" From 72ecdaf18c07deb8d321d357c2772149ad2aaf12 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 15:31:33 +0000 Subject: [PATCH 097/163] test --- 06_librechat_app_config.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 20378fb..3ff712a 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -12,7 +12,7 @@ locals { ### Server Configuration ### APP_TITLE = var.libre_app_title - CUSTOM_FOOTER = var.libre_app_custom_footer + #CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host PORT = var.libre_app_port MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" From f387700cb752d6e91f0f1b3cb0d595d3b5548305 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 17:48:06 +0000 Subject: [PATCH 098/163] test --- tests/auto_test1/testing.auto.tfvars | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index dbd0565..0ed9e95 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -108,10 +108,10 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 80 +libre_app_port = 8080 libre_app_mongo_uri = null -libre_app_domain_client = "http://localhost:3080" -libre_app_domain_server = "http://localhost:3080" +libre_app_domain_client = "http://localhost:8080" +libre_app_domain_server = "http://localhost:8080" # debug logging libre_app_debug_logging = true From 0e971902b4a34004a22dcfea77a9d7addb99ecbb Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 17:53:24 +0000 Subject: [PATCH 099/163] test with random string --- 06_librechat_app.tf | 25 ++++++++++--------------- 06_librechat_app_config.tf | 28 +--------------------------- tests/auto_test1/testing.auto.tfvars | 6 +++--- 3 files changed, 14 insertions(+), 45 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 5e1955b..e993e81 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -1,63 +1,58 @@ # Generate random strings as keys for meilisearch and librechat (Stored securely in Azure Key Vault) -resource "random_password" "meilisearch_master_key" { +resource "random_string" "meilisearch_master_key" { length = 20 special = false - upper = false } resource "azurerm_key_vault_secret" "meilisearch_master_key" { name = "${var.meilisearch_app_name}-master-key" - value = random_password.meilisearch_master_key.result + value = random_string.meilisearch_master_key.result key_vault_id = azurerm_key_vault.az_openai_kv.id } # LibreChat CREDS key (64 characters in hex) and 16-byte IV (32 characters in hex) -resource "random_password" "libre_app_creds_key" { +resource "random_string" "libre_app_creds_key" { length = 64 special = false - upper = false } -resource "random_password" "libre_app_creds_iv" { +resource "random_string" "libre_app_creds_iv" { length = 32 special = false - upper = false } resource "azurerm_key_vault_secret" "libre_app_creds_key" { name = "${var.libre_app_name}-key" - value = random_password.libre_app_creds_key.result + value = random_string.libre_app_creds_key.result key_vault_id = azurerm_key_vault.az_openai_kv.id } resource "azurerm_key_vault_secret" "libre_app_creds_iv" { name = "${var.libre_app_name}-iv" - value = random_password.libre_app_creds_iv.result + value = random_string.libre_app_creds_iv.result key_vault_id = azurerm_key_vault.az_openai_kv.id } # LibreChat JWT Secret (64 characters in hex) and JWT Refresh Secret (64 characters in hex) -resource "random_password" "libre_app_jwt_secret" { +resource "random_string" "libre_app_jwt_secret" { length = 64 special = false - upper = false } -resource "random_password" "libre_app_jwt_refresh_secret" { +resource "random_string" "libre_app_jwt_refresh_secret" { length = 64 special = false - upper = false } resource "azurerm_key_vault_secret" "libre_app_jwt_secret" { name = "${var.libre_app_name}-jwt-secret" - value = random_password.libre_app_jwt_secret.result + value = random_string.libre_app_jwt_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id } resource "azurerm_key_vault_secret" "libre_app_jwt_refresh_secret" { name = "${var.libre_app_name}-jwt-refresh-secret" - value = random_password.libre_app_jwt_refresh_secret.result + value = random_string.libre_app_jwt_refresh_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id } diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 3ff712a..7fa5bff 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -47,33 +47,7 @@ locals { MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" ### User - Balance ### - CHECK_BALANCE = false ####### - - BAN_VIOLATIONS = true - BAN_DURATION = 1000 * 60 * 60 * 2 - BAN_INTERVAL = 20 - - LOGIN_VIOLATION_SCORE = 1 - REGISTRATION_VIOLATION_SCORE = 1 - CONCURRENT_VIOLATION_SCORE = 1 - MESSAGE_VIOLATION_SCORE = 1 - NON_BROWSER_VIOLATION_SCORE = 20 - - LOGIN_MAX = 7 - LOGIN_WINDOW = 5 - REGISTER_MAX = 5 - REGISTER_WINDOW = 60 - - LIMIT_CONCURRENT_MESSAGES = true - CONCURRENT_MESSAGE_MAX = 2 - - LIMIT_MESSAGE_IP = true - MESSAGE_IP_MAX = 40 - MESSAGE_IP_WINDOW = 1 - - LIMIT_MESSAGE_USER = false - MESSAGE_USER_MAX = 40 - MESSAGE_USER_WINDOW = 1 + #CHECK_BALANCE = false ### User - Registration and Login ### ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 0ed9e95..dbd0565 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -108,10 +108,10 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 8080 +libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "http://localhost:8080" -libre_app_domain_server = "http://localhost:8080" +libre_app_domain_client = "http://localhost:3080" +libre_app_domain_server = "http://localhost:3080" # debug logging libre_app_debug_logging = true From df59f093216789f4c858fcf4c10ce58878b83fe5 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 18:11:44 +0000 Subject: [PATCH 100/163] update --- 06_librechat_app_config.tf | 4 ++-- tests/auto_test1/variables.tf | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 7fa5bff..cab8175 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -2,13 +2,13 @@ locals { libre_app_settings = { ### App Service Configuration ### WEBSITE_RUN_FROM_PACKAGE = "1" - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### + #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port #PORT = 80 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - NODE_ENV = "production" ####### + # NODE_ENV = "production" ####### ### Server Configuration ### APP_TITLE = var.libre_app_title diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index cb71b84..2ab419c 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -143,7 +143,7 @@ variable "oai_outbound_network_access_restricted" { variable "oai_public_network_access_enabled" { type = bool - default = true + default = false description = "Whether or not public network access is enabled. Defaults to `false`." } From 90cf9f9d5dc4f8482e7daae95a81afae09833b2d Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 18:21:53 +0000 Subject: [PATCH 101/163] up --- 06_librechat_app_config.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index cab8175..3e64ffd 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -14,7 +14,7 @@ locals { APP_TITLE = var.libre_app_title #CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host - PORT = var.libre_app_port + # PORT = var.libre_app_port MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" DOMAIN_CLIENT = var.libre_app_domain_client DOMAIN_SERVER = var.libre_app_domain_server From 616e9bdec9fcf412aa27dd98ad5707704cb7fdb0 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 19:02:42 +0000 Subject: [PATCH 102/163] up --- tests/auto_test1/testing.auto.tfvars | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index dbd0565..9ed2e5c 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -108,10 +108,10 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 80 +libre_app_port = 3080 libre_app_mongo_uri = null -libre_app_domain_client = "http://localhost:3080" -libre_app_domain_server = "http://localhost:3080" +#libre_app_domain_client = "http://localhost:3080" +#libre_app_domain_server = "http://localhost:3080" # debug logging libre_app_debug_logging = true From 1151489db28b6a7b30eede9d4ba94a560447fdb0 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 19:16:24 +0000 Subject: [PATCH 103/163] up --- 06_librechat_app_config.tf | 11 ++++++----- tests/auto_test1/testing.auto.tfvars | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 3e64ffd..e410d16 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -1,20 +1,21 @@ locals { libre_app_settings = { ### App Service Configuration ### - WEBSITE_RUN_FROM_PACKAGE = "1" + WEBSITE_RUN_FROM_PACKAGE = "1" #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port #PORT = 80 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - # NODE_ENV = "production" ####### + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + NODE_ENV = "production" ####### + USE_REDIS = false ### Server Configuration ### APP_TITLE = var.libre_app_title - #CUSTOM_FOOTER = var.libre_app_custom_footer + CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host - # PORT = var.libre_app_port + # PORT = var.libre_app_port MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" DOMAIN_CLIENT = var.libre_app_domain_client DOMAIN_SERVER = var.libre_app_domain_server diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 9ed2e5c..dbd0565 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -108,10 +108,10 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 3080 +libre_app_port = 80 libre_app_mongo_uri = null -#libre_app_domain_client = "http://localhost:3080" -#libre_app_domain_server = "http://localhost:3080" +libre_app_domain_client = "http://localhost:3080" +libre_app_domain_server = "http://localhost:3080" # debug logging libre_app_debug_logging = true From 8ad13d9724d5a374321719aed278440cea2bb93a Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 19:30:59 +0000 Subject: [PATCH 104/163] up --- 06_librechat_app_config.tf | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index e410d16..17a6a9d 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -6,10 +6,11 @@ locals { WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port - #PORT = 80 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - NODE_ENV = "production" ####### - USE_REDIS = false + PORT = var.libre_app_port + WEBSITES_CONTAINER_START_TIME_LIMIT = 2200 + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + NODE_ENV = "production" ####### + USE_REDIS = false ### Server Configuration ### APP_TITLE = var.libre_app_title From c92172c3e563eb4bb5c6a903702ab3d3e54df3e2 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 19:40:23 +0000 Subject: [PATCH 105/163] always on --- 06_librechat_app.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index e993e81..6a6e2ce 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -132,6 +132,7 @@ resource "azurerm_linux_web_app" "librechat" { site_config { minimum_tls_version = "1.2" + always_on = true } logs { From c4b1e8688a9704e8343f7a41196760e27a344c7a Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 21:30:36 +0000 Subject: [PATCH 106/163] test --- 06_librechat_app_config.tf | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 17a6a9d..d8cd58b 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -7,16 +7,14 @@ locals { DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port - WEBSITES_CONTAINER_START_TIME_LIMIT = 2200 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + WEBSITES_CONTAINER_START_TIME_LIMIT = 1500 + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:d7b4ed3079e357e316e2398093acc456f128dc6e" NODE_ENV = "production" ####### - USE_REDIS = false ### Server Configuration ### APP_TITLE = var.libre_app_title CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host - # PORT = var.libre_app_port MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" DOMAIN_CLIENT = var.libre_app_domain_client DOMAIN_SERVER = var.libre_app_domain_server From 76d347c7245236dca5690742da8d125a6af1665c Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 21:37:14 +0000 Subject: [PATCH 107/163] test --- 06_librechat_app_config.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index d8cd58b..2d56546 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -8,7 +8,8 @@ locals { WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 1500 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:d7b4ed3079e357e316e2398093acc456f128dc6e" + #DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:d7b4ed3079e357e316e2398093acc456f128dc6e" + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" NODE_ENV = "production" ####### ### Server Configuration ### From fbd047eb72e3db17c08bd3fc253b97a96c322cc1 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 21:42:33 +0000 Subject: [PATCH 108/163] test --- 06_librechat_app_config.tf | 6 +++--- tests/auto_test1/testing.auto.tfvars | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 2d56546..b3bfed5 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -7,9 +7,9 @@ locals { DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port - WEBSITES_CONTAINER_START_TIME_LIMIT = 1500 - #DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:d7b4ed3079e357e316e2398093acc456f128dc6e" - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" + WEBSITES_CONTAINER_START_TIME_LIMIT = 2000 + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + #DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-:latest" NODE_ENV = "production" ####### ### Server Configuration ### diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index dbd0565..387c153 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -92,7 +92,7 @@ cosmosdb_public_network_access_enabled = true ### 06 app services (librechat app + meilisearch) ### # App Service Plan app_service_name = "openaiasp" -app_service_sku_name = "B1" +app_service_sku_name = "B3" # Meilisearch App meilisearch_app_name = "meilisearchapp" From effbd8ce56ccc9f79387e25e292403608c46566b Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 21:51:53 +0000 Subject: [PATCH 109/163] test --- tests/auto_test1/testing.auto.tfvars | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 387c153..ee0a0f2 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -110,8 +110,8 @@ libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and libre_app_host = "0.0.0.0" libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "http://localhost:3080" -libre_app_domain_server = "http://localhost:3080" +libre_app_domain_client = "http://0.0.0.0:80" +libre_app_domain_server = "http://0.0.0.0:80" # debug logging libre_app_debug_logging = true From 0ed39d013c8ca8879ae5f07a59a6d808558687c6 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 22:03:18 +0000 Subject: [PATCH 110/163] test --- tests/auto_test1/testing.auto.tfvars | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index ee0a0f2..412200e 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -51,6 +51,14 @@ oai_identity = { oai_network_acls = null oai_storage = null oai_model_deployment = [ + { + deployment_id = "gpt-4" + model_name = "gpt-4" + model_format = "OpenAI" + model_version = "1106-Preview" + scale_type = "Standard" + scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + }, { deployment_id = "gpt-4-1106-preview" model_name = "gpt-4" @@ -92,7 +100,7 @@ cosmosdb_public_network_access_enabled = true ### 06 app services (librechat app + meilisearch) ### # App Service Plan app_service_name = "openaiasp" -app_service_sku_name = "B3" +app_service_sku_name = "B2" # Meilisearch App meilisearch_app_name = "meilisearchapp" @@ -110,8 +118,8 @@ libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and libre_app_host = "0.0.0.0" libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "http://0.0.0.0:80" -libre_app_domain_server = "http://0.0.0.0:80" +libre_app_domain_client = "http://localhost:3080" +libre_app_domain_server = "http://localhost:3080" # debug logging libre_app_debug_logging = true From ea1dac17028af1040c216c29903623d09898a28c Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 22:14:35 +0000 Subject: [PATCH 111/163] test --- 06_librechat_app_config.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index b3bfed5..f6f7dbb 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -7,9 +7,9 @@ locals { DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port - WEBSITES_CONTAINER_START_TIME_LIMIT = 2000 + WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - #DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-:latest" + # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" NODE_ENV = "production" ####### ### Server Configuration ### From ec375e0fd9f62484536a4c2bf9c6ba4b8b73d046 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Sun, 21 Jan 2024 22:25:19 +0000 Subject: [PATCH 112/163] test --- 06_librechat_app_config.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index f6f7dbb..3e02158 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -4,6 +4,7 @@ locals { WEBSITE_RUN_FROM_PACKAGE = "1" #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + WEBSITE_AUTH_ENABLED = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port From e041460a70368feb05fcd7b5fdc475476107c62a Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 00:30:09 +0000 Subject: [PATCH 113/163] test uksouth --- 06_librechat_app_config.tf | 1 - tests/auto_test1/testing.auto.tfvars | 40 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 3e02158..f6f7dbb 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -4,7 +4,6 @@ locals { WEBSITE_RUN_FROM_PACKAGE = "1" #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - WEBSITE_AUTH_ENABLED = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 412200e..24e2347 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -1,6 +1,6 @@ ### 01 Common Variables + RG ### resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -location = "westus" +location = "uksouth" #"westus" tags = { Terraform = "True" Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" @@ -36,9 +36,9 @@ kv_fw_allowed_ips = ["0.0.0.0/0"] kv_fw_network_subnet_ids = null ### 04 Create OpenAI Service ### -oai_account_name = "gptopenaiaccount" +oai_account_name = "gptopenai" oai_sku_name = "S0" -oai_custom_subdomain_name = "gptopenaiaccount" +oai_custom_subdomain_name = "gptopenai" oai_dynamic_throttling_enabled = false oai_fqdns = [] oai_local_auth_enabled = true @@ -59,22 +59,22 @@ oai_model_deployment = [ scale_type = "Standard" scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) }, - { - deployment_id = "gpt-4-1106-preview" - model_name = "gpt-4" - model_format = "OpenAI" - model_version = "1106-Preview" - scale_type = "Standard" - scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) - }, - { - deployment_id = "gpt-4-vision-preview" - model_name = "gpt-4" - model_format = "OpenAI" - model_version = "vision-preview" - scale_type = "Standard" - scale_capacity = 5 - } + # { + # deployment_id = "gpt-4-1106-preview" + # model_name = "gpt-4" + # model_format = "OpenAI" + # model_version = "1106-Preview" + # scale_type = "Standard" + # scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + # }, + #{ + # deployment_id = "gpt-4-vision-preview" + # model_name = "gpt-4" + # model_format = "OpenAI" + # model_version = "vision-preview" + # scale_type = "Standard" + # scale_capacity = 5 + #} ] ### 05 cosmosdb ### @@ -100,7 +100,7 @@ cosmosdb_public_network_access_enabled = true ### 06 app services (librechat app + meilisearch) ### # App Service Plan app_service_name = "openaiasp" -app_service_sku_name = "B2" +app_service_sku_name = "B1" # Meilisearch App meilisearch_app_name = "meilisearchapp" From e98966047e90661fa80fd58e150df8b49057a359 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 00:42:33 +0000 Subject: [PATCH 114/163] tws --- 06_librechat_app.tf | 1 - 06_librechat_app_config.tf | 4 ++-- tests/auto_test1/testing.auto.tfvars | 16 ++++++++-------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 6a6e2ce..e993e81 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -132,7 +132,6 @@ resource "azurerm_linux_web_app" "librechat" { site_config { minimum_tls_version = "1.2" - always_on = true } logs { diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index f6f7dbb..c460bf1 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -9,8 +9,8 @@ locals { PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" - NODE_ENV = "production" ####### + # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" + NODE_ENV = "production" ####### ### Server Configuration ### APP_TITLE = var.libre_app_title diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 24e2347..9e924e9 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -59,14 +59,14 @@ oai_model_deployment = [ scale_type = "Standard" scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) }, - # { - # deployment_id = "gpt-4-1106-preview" - # model_name = "gpt-4" - # model_format = "OpenAI" - # model_version = "1106-Preview" - # scale_type = "Standard" - # scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) - # }, + # { + # deployment_id = "gpt-4-1106-preview" + # model_name = "gpt-4" + # model_format = "OpenAI" + # model_version = "1106-Preview" + # scale_type = "Standard" + # scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + # }, #{ # deployment_id = "gpt-4-vision-preview" # model_name = "gpt-4" From b3cc079ea624192d787aa6363417eecebf323b29 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 01:10:03 +0000 Subject: [PATCH 115/163] add meilisearch key as --- 06_librechat_app.tf | 2 +- tests/auto_test1/main.tf | 1 + tests/auto_test1/testing.auto.tfvars | 1 + tests/auto_test1/variables.tf | 7 +++++++ variables.tf | 7 +++++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index e993e81..801c67b 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -77,7 +77,7 @@ resource "azurerm_linux_web_app" "meilisearch" { app_settings = { WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - MEILI_MASTER_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" + MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" MEILI_NO_ANALYTICS = true #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 9324a2f..1911f88 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -88,6 +88,7 @@ module "private-chatgpt-openai" { # MeiSearch App meilisearch_app_name = "${var.meilisearch_app_name}${random_integer.number.result}" meilisearch_app_virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id + meilisearch_app_key = var.meilisearch_app_key # LibreChat App libre_app_name = "${var.libre_app_name}${random_integer.number.result}" diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 9e924e9..7a432c8 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -105,6 +105,7 @@ app_service_sku_name = "B1" # Meilisearch App meilisearch_app_name = "meilisearchapp" meilisearch_app_virtual_network_subnet_id = null +meilisearch_app_key = null # LibreChat App Service libre_app_name = "librechatapp" diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 2ab419c..0c63277 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -364,6 +364,13 @@ variable "meilisearch_app_virtual_network_subnet_id" { default = null } +variable "meilisearch_app_key" { + type = string + description = "The Meilisearch API Key to use for authentication." + default = null + sensitive = true +} + # LibreChat App Service variable "libre_app_name" { type = string diff --git a/variables.tf b/variables.tf index 939f23b..4806b51 100644 --- a/variables.tf +++ b/variables.tf @@ -368,6 +368,13 @@ variable "meilisearch_app_virtual_network_subnet_id" { default = null } +variable "meilisearch_app_key" { + type = string + description = "The Meilisearch API Key to use for authentication." + default = null + sensitive = true +} + # LibreChat App Service variable "libre_app_name" { type = string From 03c22a9a7c001bf276a19db0dc690a613ccd36bf Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 01:16:22 +0000 Subject: [PATCH 116/163] update --- 06_librechat_app.tf | 2 +- 06_librechat_app_config.tf | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 801c67b..19a3f92 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -77,7 +77,7 @@ resource "azurerm_linux_web_app" "meilisearch" { app_settings = { WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" + MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : random_string.meilisearch_master_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" MEILI_NO_ANALYTICS = true #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index c460bf1..b052be7 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -16,7 +16,7 @@ locals { APP_TITLE = var.libre_app_title CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host - MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" + MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : azurerm_cosmosdb_account.az_openai_mongodb.primary_mongodb_connection_string #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" DOMAIN_CLIENT = var.libre_app_domain_client DOMAIN_SERVER = var.libre_app_domain_server @@ -28,7 +28,7 @@ locals { ENDPOINTS = var.libre_app_endpoints ### Azure OpenAI ### - AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" + AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : azurerm_cognitive_account.az_openai.primary_access_key #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" AZURE_OPENAI_MODELS = var.libre_app_az_oai_models AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_az_oai_use_model_as_deployment_name AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] @@ -38,27 +38,27 @@ locals { # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) # Warning: If you don't set them, the app will crash on startup. DEBUG_PLUGINS = var.libre_app_debug_plugins - CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" - CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" + CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : random_string.libre_app_creds_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" + CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : random_string.libre_app_creds_iv.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" ### Search ### SEARCH = var.libre_app_enable_meilisearch MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" - MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" + MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : random_string.meilisearch_master_key.result # "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" ### User - Balance ### #CHECK_BALANCE = false ### User - Registration and Login ### - ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true - ALLOW_REGISTRATION = var.libre_app_allow_registration #true - ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false - ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false - SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes - REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days - JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" - JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" + ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true + ALLOW_REGISTRATION = var.libre_app_allow_registration #true + ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false + ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false + SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days + JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : random_string.libre_app_jwt_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" + JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : random_string.libre_app_jwt_refresh_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" } } From a6f49bb6e35ed8288fb025e7dc9e0ac3afe01190 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 01:27:48 +0000 Subject: [PATCH 117/163] add dependencies --- 05_cosmosdb.tf | 1 + 06_librechat_app.tf | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/05_cosmosdb.tf b/05_cosmosdb.tf index 96de347..e4d9e8b 100644 --- a/05_cosmosdb.tf +++ b/05_cosmosdb.tf @@ -46,4 +46,5 @@ resource "azurerm_key_vault_secret" "openai_cosmos_uri" { name = "${var.cosmosdb_name}-cosmos-uri" value = azurerm_cosmosdb_account.az_openai_mongodb.primary_mongodb_connection_string key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] } \ No newline at end of file diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 19a3f92..548b5c8 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -8,6 +8,7 @@ resource "azurerm_key_vault_secret" "meilisearch_master_key" { name = "${var.meilisearch_app_name}-master-key" value = random_string.meilisearch_master_key.result key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] } # LibreChat CREDS key (64 characters in hex) and 16-byte IV (32 characters in hex) @@ -25,12 +26,14 @@ resource "azurerm_key_vault_secret" "libre_app_creds_key" { name = "${var.libre_app_name}-key" value = random_string.libre_app_creds_key.result key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] } resource "azurerm_key_vault_secret" "libre_app_creds_iv" { name = "${var.libre_app_name}-iv" value = random_string.libre_app_creds_iv.result key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] } # LibreChat JWT Secret (64 characters in hex) and JWT Refresh Secret (64 characters in hex) @@ -48,12 +51,14 @@ resource "azurerm_key_vault_secret" "libre_app_jwt_secret" { name = "${var.libre_app_name}-jwt-secret" value = random_string.libre_app_jwt_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] } resource "azurerm_key_vault_secret" "libre_app_jwt_refresh_secret" { name = "${var.libre_app_name}-jwt-refresh-secret" value = random_string.libre_app_jwt_refresh_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id + depends_on = [azurerm_role_assignment.kv_role_assigment] } # Create app service plan for librechat app and meilisearch app From 8c971ccd5f0426d50597384a0b13e8cba7eb4668 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 01:33:59 +0000 Subject: [PATCH 118/163] add subnet dependencies --- 05_cosmosdb.tf | 1 + 06_librechat_app.tf | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/05_cosmosdb.tf b/05_cosmosdb.tf index e4d9e8b..6159dc5 100644 --- a/05_cosmosdb.tf +++ b/05_cosmosdb.tf @@ -39,6 +39,7 @@ resource "azurerm_cosmosdb_account" "az_openai_mongodb" { is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled public_network_access_enabled = var.cosmosdb_public_network_access_enabled + depends_on = [azurerm_subnet.az_openai_subnet] } ### Save MongoDB URI details to Key Vault for consumption by other services (e.g. LibreChat App) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 548b5c8..baed626 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -118,6 +118,8 @@ resource "azurerm_linux_web_app" "meilisearch" { identity { type = "SystemAssigned" } + + depends_on = [azurerm_subnet.az_openai_subnet] } # Grant kv access to meilisearch app to reference the master key secret @@ -158,7 +160,7 @@ resource "azurerm_linux_web_app" "librechat" { app_settings = local.libre_app_settings virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id - depends_on = [azurerm_linux_web_app.meilisearch] + depends_on = [azurerm_linux_web_app.meilisearch, azurerm_subnet.az_openai_subnet] } # Grant kv access to librechat app to reference environment variables (stored as secrets in key vault) From 5077ff10229c7488d50b7dd0431e7f8db4daea38 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 08:29:46 +0000 Subject: [PATCH 119/163] tet --- 06_librechat_app_config.tf | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index b052be7..9a566a8 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -30,7 +30,7 @@ locals { ### Azure OpenAI ### AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : azurerm_cognitive_account.az_openai.primary_access_key #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" AZURE_OPENAI_MODELS = var.libre_app_az_oai_models - AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_az_oai_use_model_as_deployment_name + AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = false #var.libre_app_az_oai_use_model_as_deployment_name AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version @@ -59,6 +59,40 @@ locals { REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : random_string.libre_app_jwt_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : random_string.libre_app_jwt_refresh_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" + + ###To remove + DEBUG_OPENAI = false + + BAN_VIOLATIONS = true + BAN_DURATION = 1000 * 60 * 60 * 2 + BAN_INTERVAL = 20 + + LOGIN_VIOLATION_SCORE = 1 + REGISTRATION_VIOLATION_SCORE = 1 + CONCURRENT_VIOLATION_SCORE = 1 + MESSAGE_VIOLATION_SCORE = 1 + NON_BROWSER_VIOLATION_SCORE = 20 + + LOGIN_MAX = 7 + LOGIN_WINDOW = 5 + REGISTER_MAX = 5 + REGISTER_WINDOW = 60 + + LIMIT_CONCURRENT_MESSAGES = true + CONCURRENT_MESSAGE_MAX = 2 + + LIMIT_MESSAGE_IP = true + MESSAGE_IP_MAX = 40 + MESSAGE_IP_WINDOW = 1 + + LIMIT_MESSAGE_USER = false + MESSAGE_USER_MAX = 40 + MESSAGE_USER_WINDOW = 1 + + AZURE_OPENAI_API_DEPLOYMENT_NAME = "gpt-4" + # AZURE_OPENAI_API_VERSION = var.azure_openai_api_version + # AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = + # AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = } } From 68cec22be47d4bddf5e2f6ba70c301a9ef2cb560 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 08:51:19 +0000 Subject: [PATCH 120/163] t --- 06_librechat_app_config.tf | 2 +- tests/auto_test1/testing.auto.tfvars | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 9a566a8..71ca77b 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -10,7 +10,7 @@ locals { WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" - NODE_ENV = "production" ####### + NODE_ENV = "Production" ####### ### Server Configuration ### APP_TITLE = var.libre_app_title diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 7a432c8..c123001 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -119,8 +119,8 @@ libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and libre_app_host = "0.0.0.0" libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "http://localhost:3080" -libre_app_domain_server = "http://localhost:3080" +libre_app_domain_client = "http://0.0.0.0:80" +libre_app_domain_server = "http://0.0.0.0:80" # debug logging libre_app_debug_logging = true From 36014f5792438df35b27f26a9a3469357d936e49 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 08:52:05 +0000 Subject: [PATCH 121/163] verbos elog --- 06_librechat_app.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index baed626..425b5e1 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -149,7 +149,7 @@ resource "azurerm_linux_web_app" "librechat" { } } application_logs { - file_system_level = "Information" + file_system_level = "Verbose" #"Information" } } From b662db052a54bc2e0b0ad6312ee2dbc15d68c43e Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 08:57:06 +0000 Subject: [PATCH 122/163] test --- 06_librechat_app_config.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 71ca77b..de36210 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -6,7 +6,7 @@ locals { WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port - PORT = var.libre_app_port + #PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" From 3647888b1bf6db805bdcf7a34e2d82341b3d5839 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 09:20:23 +0000 Subject: [PATCH 123/163] up --- 06_librechat_app_config.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index de36210..3d06ea4 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -8,7 +8,7 @@ locals { WEBSITES_PORT = var.libre_app_port #PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:5b283622826ca12599ed7690ec1b53223d5cec70" # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" NODE_ENV = "Production" ####### From 9391c558303d4c193222998ba4792c2a3848ec71 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 09:30:42 +0000 Subject: [PATCH 124/163] dd --- 06_librechat_app_config.tf | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 3d06ea4..6b47957 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -90,6 +90,16 @@ locals { MESSAGE_USER_WINDOW = 1 AZURE_OPENAI_API_DEPLOYMENT_NAME = "gpt-4" + + BINGAI_TOKEN = random_string.meilisearch_app_key.result + + CHATGPT_TOKEN = random_string.meilisearch_app_key.result + CHATGPT_MODELS = "text-davinci-002-render-sha" + + GOOGLE_KEY = "user_provided" + + + DEBUG_OPENAI = false # AZURE_OPENAI_API_VERSION = var.azure_openai_api_version # AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = # AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = From a6c1cf9b2ab2b52de8217b0f07a7d52bae821bd7 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 09:34:50 +0000 Subject: [PATCH 125/163] test --- tests/auto_test1/testing.auto.tfvars | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index c123001..5d4c638 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -117,10 +117,10 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 80 +libre_app_port = 8081 libre_app_mongo_uri = null -libre_app_domain_client = "http://0.0.0.0:80" -libre_app_domain_server = "http://0.0.0.0:80" +libre_app_domain_client = "http://0.0.0.0:8081" +libre_app_domain_server = "http://0.0.0.0:8081" # debug logging libre_app_debug_logging = true From 4c32af5701c6380f8252438beeb267e2a3c80ed2 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 09:36:10 +0000 Subject: [PATCH 126/163] test --- 06_librechat_app_config.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 6b47957..42c24eb 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -91,9 +91,9 @@ locals { AZURE_OPENAI_API_DEPLOYMENT_NAME = "gpt-4" - BINGAI_TOKEN = random_string.meilisearch_app_key.result + BINGAI_TOKEN = "sdfdgf23rf23rcopjfoi3h9hf3efeef23" - CHATGPT_TOKEN = random_string.meilisearch_app_key.result + CHATGPT_TOKEN = "sdfdgf23rf23rcopjfoi3h9hf3efeef23" CHATGPT_MODELS = "text-davinci-002-render-sha" GOOGLE_KEY = "user_provided" From b4d3d02b902f44cee021898bb01bf0a4b92a752a Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 10:15:38 +0000 Subject: [PATCH 127/163] test --- 06_librechat_app_config.tf | 4 ++-- tests/auto_test1/testing.auto.tfvars | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 42c24eb..cdc3a3b 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -6,9 +6,9 @@ locals { WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port - #PORT = var.libre_app_port + PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:5b283622826ca12599ed7690ec1b53223d5cec70" + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" NODE_ENV = "Production" ####### diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 5d4c638..c123001 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -117,10 +117,10 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 8081 +libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "http://0.0.0.0:8081" -libre_app_domain_server = "http://0.0.0.0:8081" +libre_app_domain_client = "http://0.0.0.0:80" +libre_app_domain_server = "http://0.0.0.0:80" # debug logging libre_app_debug_logging = true From 1fd0e6db23e58b62c40c21e5202064fb13d00eac Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 10:27:32 +0000 Subject: [PATCH 128/163] test --- tests/auto_test1/testing.auto.tfvars | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index c123001..ea75a51 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -117,10 +117,10 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 80 +libre_app_port = 443 libre_app_mongo_uri = null -libre_app_domain_client = "http://0.0.0.0:80" -libre_app_domain_server = "http://0.0.0.0:80" +libre_app_domain_client = "https://0.0.0.0:443" +libre_app_domain_server = "https://0.0.0.0:443" # debug logging libre_app_debug_logging = true From fbd5c85fc6292c69fcbdf0544285ec32702cc8bd Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 10:38:29 +0000 Subject: [PATCH 129/163] test --- 06_librechat_app_config.tf | 4 ++-- tests/auto_test1/testing.auto.tfvars | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index cdc3a3b..bcd34c6 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -55,8 +55,8 @@ locals { ALLOW_REGISTRATION = var.libre_app_allow_registration #true ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false - SESSION_EXPIRY = 1000 * 60 * 15 #15 minutes - REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 #7 days + SESSION_EXPIRY = 1000 * 60 * 7 #15 minutes + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 5 #7 days JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : random_string.libre_app_jwt_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : random_string.libre_app_jwt_refresh_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index ea75a51..c730417 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -117,22 +117,22 @@ libre_app_virtual_network_subnet_id = null libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" -libre_app_port = 443 +libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "https://0.0.0.0:443" -libre_app_domain_server = "https://0.0.0.0:443" +libre_app_domain_client = "https://0.0.0.0:80" +libre_app_domain_server = "https://0.0.0.0:80" # debug logging libre_app_debug_logging = true libre_app_debug_console = false # Endpoints -libre_app_endpoints = "azureOpenAI" +libre_app_endpoints = "azureOpenAI,OpenAI" # Azure OpenAI libre_app_az_oai_api_key = null -libre_app_az_oai_models = "gpt-4,gpt-4-1106-preview,gpt-4-vision-preview" -libre_app_az_oai_use_model_as_deployment_name = true +libre_app_az_oai_models = "gpt-4" +libre_app_az_oai_use_model_as_deployment_name = false#true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" From 4ae2e4de9a6fab30826b0e7d4c3feba0f208af8b Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 10:39:06 +0000 Subject: [PATCH 130/163] test --- 06_librechat_app_config.tf | 2 +- tests/auto_test1/testing.auto.tfvars | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index bcd34c6..d5d8d78 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -55,7 +55,7 @@ locals { ALLOW_REGISTRATION = var.libre_app_allow_registration #true ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false - SESSION_EXPIRY = 1000 * 60 * 7 #15 minutes + SESSION_EXPIRY = 1000 * 60 * 7 #15 minutes REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 5 #7 days JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : random_string.libre_app_jwt_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : random_string.libre_app_jwt_refresh_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index c730417..fef6f98 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -132,7 +132,7 @@ libre_app_endpoints = "azureOpenAI,OpenAI" # Azure OpenAI libre_app_az_oai_api_key = null libre_app_az_oai_models = "gpt-4" -libre_app_az_oai_use_model_as_deployment_name = false#true +libre_app_az_oai_use_model_as_deployment_name = false #true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" From 92ed4e6756a7fa9008e5904319c4efd35545d686 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 11:13:31 +0000 Subject: [PATCH 131/163] test --- 06_librechat_app.tf | 2 +- 06_librechat_app_config.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 425b5e1..c3a1231 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -90,7 +90,7 @@ resource "azurerm_linux_web_app" "meilisearch" { DOCKER_ENABLE_CI = false WEBSITES_PORT = 7700 PORT = 7700 - DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" + DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:v1.5.1" } site_config { diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index d5d8d78..9a0de74 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -8,7 +8,7 @@ locals { WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:v0.5.9" # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" NODE_ENV = "Production" ####### From 6351b69fdf23ee5ffc9049d44e4842dc1cd329f2 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 11:22:26 +0000 Subject: [PATCH 132/163] test --- 06_librechat_app.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index c3a1231..979d319 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -85,7 +85,7 @@ resource "azurerm_linux_web_app" "meilisearch" { MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : random_string.meilisearch_master_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" MEILI_NO_ANALYTICS = true - #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" + DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = 7700 From 15d3038d3337c93d56588ce85e7abc9b8e7bf168 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 11:31:49 +0000 Subject: [PATCH 133/163] test --- 06_librechat_app_config.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 9a0de74..c5bf31f 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -8,7 +8,7 @@ locals { WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:v0.5.9" + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:v0.6.2" # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" NODE_ENV = "Production" ####### From 105fbbcff52713f3f8315b22245f48bad5ba10eb Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 12:33:13 +0000 Subject: [PATCH 134/163] test --- 06_librechat_app_config.tf | 2 +- tests/auto_test1/testing.auto.tfvars | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index c5bf31f..d5d8d78 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -8,7 +8,7 @@ locals { WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:v0.6.2" + DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" NODE_ENV = "Production" ####### diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index fef6f98..ca027dc 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -105,7 +105,7 @@ app_service_sku_name = "B1" # Meilisearch App meilisearch_app_name = "meilisearchapp" meilisearch_app_virtual_network_subnet_id = null -meilisearch_app_key = null +meilisearch_app_key = "dfsdgdsffgdsfgds"#null # LibreChat App Service libre_app_name = "librechatapp" @@ -127,7 +127,7 @@ libre_app_debug_logging = true libre_app_debug_console = false # Endpoints -libre_app_endpoints = "azureOpenAI,OpenAI" +libre_app_endpoints = "azureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null @@ -137,23 +137,23 @@ libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" # Plugins -libre_app_debug_plugins = true -libre_app_plugins_creds_key = null -libre_app_plugins_creds_iv = null +libre_app_debug_plugins = false +libre_app_plugins_creds_key = "dfsdgdsffgdsfgds"#null +libre_app_plugins_creds_iv = "dfsdgdsffgdsfgds"#null # Search -libre_app_enable_meilisearch = true +libre_app_enable_meilisearch = false libre_app_disable_meilisearch_analytics = true libre_app_meili_host = null -libre_app_meili_key = null +libre_app_meili_key = "dfsdgdsffgdsfgds"#null # User Registration libre_app_allow_email_login = true libre_app_allow_registration = true libre_app_allow_social_login = false libre_app_allow_social_registration = false -libre_app_jwt_secret = null -libre_app_jwt_refresh_secret = null +libre_app_jwt_secret = "dfsdgdsffgdsfgds"#null +libre_app_jwt_refresh_secret = "dfsdgdsffgdsfgds"#null # ### CDN - Front Door ### # create_front_door_cdn = true From 6d856be7b870aaffae7cb169154cdbac5be5b778 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 12:34:38 +0000 Subject: [PATCH 135/163] test --- 06_librechat_app.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 979d319..a59c858 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -83,7 +83,7 @@ resource "azurerm_linux_web_app" "meilisearch" { WEBSITES_ENABLE_APP_SERVICE_STORAGE = false MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : random_string.meilisearch_master_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" - MEILI_NO_ANALYTICS = true + MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" WEBSITES_ENABLE_APP_SERVICE_STORAGE = false From d62a81605dd0d2991e40f43bcceb3f3d99cc430f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 12:35:51 +0000 Subject: [PATCH 136/163] lint --- tests/auto_test1/testing.auto.tfvars | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index ca027dc..3d1e5b3 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -105,7 +105,7 @@ app_service_sku_name = "B1" # Meilisearch App meilisearch_app_name = "meilisearchapp" meilisearch_app_virtual_network_subnet_id = null -meilisearch_app_key = "dfsdgdsffgdsfgds"#null +meilisearch_app_key = "dfsdgdsffgdsfgds" #null # LibreChat App Service libre_app_name = "librechatapp" @@ -138,22 +138,22 @@ libre_app_az_oai_api_version = "2023-07-01-preview" # Plugins libre_app_debug_plugins = false -libre_app_plugins_creds_key = "dfsdgdsffgdsfgds"#null -libre_app_plugins_creds_iv = "dfsdgdsffgdsfgds"#null +libre_app_plugins_creds_key = "dfsdgdsffgdsfgds" #null +libre_app_plugins_creds_iv = "dfsdgdsffgdsfgds" #null # Search libre_app_enable_meilisearch = false libre_app_disable_meilisearch_analytics = true libre_app_meili_host = null -libre_app_meili_key = "dfsdgdsffgdsfgds"#null +libre_app_meili_key = "dfsdgdsffgdsfgds" #null # User Registration libre_app_allow_email_login = true libre_app_allow_registration = true libre_app_allow_social_login = false libre_app_allow_social_registration = false -libre_app_jwt_secret = "dfsdgdsffgdsfgds"#null -libre_app_jwt_refresh_secret = "dfsdgdsffgdsfgds"#null +libre_app_jwt_secret = "dfsdgdsffgdsfgds" #null +libre_app_jwt_refresh_secret = "dfsdgdsffgdsfgds" #null # ### CDN - Front Door ### # create_front_door_cdn = true From f5a22142d30f55b072cf5c609c90c288a1d227de Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 12:41:49 +0000 Subject: [PATCH 137/163] test --- 06_librechat_app.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index a59c858..abd9ec1 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -90,7 +90,7 @@ resource "azurerm_linux_web_app" "meilisearch" { DOCKER_ENABLE_CI = false WEBSITES_PORT = 7700 PORT = 7700 - DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:v1.5.1" + DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" } site_config { From 292c0b72f6fa3abe40383410edb15a7b473c6f77 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 12:52:47 +0000 Subject: [PATCH 138/163] disable meili --- 06_librechat_app.tf | 101 +++++++++++++++++++------------------ 06_librechat_app_config.tf | 8 +-- 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index abd9ec1..c81326d 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -72,55 +72,55 @@ resource "azurerm_service_plan" "az_openai_asp" { # Create meilisearch app # TODO: Add support for private endpoints instead of subnet access -resource "azurerm_linux_web_app" "meilisearch" { - name = var.meilisearch_app_name - location = var.location - resource_group_name = azurerm_resource_group.az_openai_rg.name - service_plan_id = azurerm_service_plan.az_openai_asp.id - https_only = true - - app_settings = { - WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - - MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : random_string.meilisearch_master_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" - MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics - - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" - WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - DOCKER_ENABLE_CI = false - WEBSITES_PORT = 7700 - PORT = 7700 - DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" - } - - site_config { - always_on = "true" - ip_restriction { - virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id - priority = 100 - name = "Allow from LibreChat app subnet" - action = "Allow" - } - } - - logs { - http_logs { - file_system { - retention_in_days = 7 - retention_in_mb = 35 - } - } - application_logs { - file_system_level = "Information" - } - } - - identity { - type = "SystemAssigned" - } - - depends_on = [azurerm_subnet.az_openai_subnet] -} +# resource "azurerm_linux_web_app" "meilisearch" { +# name = var.meilisearch_app_name +# location = var.location +# resource_group_name = azurerm_resource_group.az_openai_rg.name +# service_plan_id = azurerm_service_plan.az_openai_asp.id +# https_only = true + +# app_settings = { +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + +# MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : random_string.meilisearch_master_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" +# MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics + +# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false +# DOCKER_ENABLE_CI = false +# WEBSITES_PORT = 7700 +# PORT = 7700 +# DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" +# } + +# site_config { +# always_on = "true" +# ip_restriction { +# virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id +# priority = 100 +# name = "Allow from LibreChat app subnet" +# action = "Allow" +# } +# } + +# logs { +# http_logs { +# file_system { +# retention_in_days = 7 +# retention_in_mb = 35 +# } +# } +# application_logs { +# file_system_level = "Information" +# } +# } + +# identity { +# type = "SystemAssigned" +# } + +# depends_on = [azurerm_subnet.az_openai_subnet] +# } # Grant kv access to meilisearch app to reference the master key secret resource "azurerm_role_assignment" "meilisearch_app_kv_access" { @@ -160,7 +160,8 @@ resource "azurerm_linux_web_app" "librechat" { app_settings = local.libre_app_settings virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id - depends_on = [azurerm_linux_web_app.meilisearch, azurerm_subnet.az_openai_subnet] + #depends_on = [azurerm_linux_web_app.meilisearch, azurerm_subnet.az_openai_subnet] + depends_on = [azurerm_subnet.az_openai_subnet] } # Grant kv access to librechat app to reference environment variables (stored as secrets in key vault) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index d5d8d78..f0f68f8 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -42,10 +42,10 @@ locals { CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : random_string.libre_app_creds_iv.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" ### Search ### - SEARCH = var.libre_app_enable_meilisearch - MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics - MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" - MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : random_string.meilisearch_master_key.result # "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" + SEARCH = false #var.libre_app_enable_meilisearch + # MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics + # MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" + # MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : random_string.meilisearch_master_key.result # "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" ### User - Balance ### #CHECK_BALANCE = false From fac6c9671a37b7db28be867e64e4e91ffb922eb0 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 12:54:17 +0000 Subject: [PATCH 139/163] fix --- 06_librechat_app.tf | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index c81326d..f1a0f67 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -123,11 +123,11 @@ resource "azurerm_service_plan" "az_openai_asp" { # } # Grant kv access to meilisearch app to reference the master key secret -resource "azurerm_role_assignment" "meilisearch_app_kv_access" { - scope = azurerm_key_vault.az_openai_kv.id - principal_id = azurerm_linux_web_app.meilisearch.identity[0].principal_id - role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. -} +# resource "azurerm_role_assignment" "meilisearch_app_kv_access" { +# scope = azurerm_key_vault.az_openai_kv.id +# principal_id = azurerm_linux_web_app.meilisearch.identity[0].principal_id +# role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. +# } resource "azurerm_linux_web_app" "librechat" { name = var.libre_app_name From 851bfde4a919c9886af02e1297fd059537b9b2fb Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 15:34:03 +0000 Subject: [PATCH 140/163] remove meili --- 06_librechat_app.tf | 173 +++++++++++++-------------- 06_librechat_app_config.tf | 83 +++---------- tests/auto_test1/main.tf | 14 +-- tests/auto_test1/testing.auto.tfvars | 60 ++++------ tests/auto_test1/variables.tf | 56 ++++----- variables.tf | 72 +++++------ 6 files changed, 195 insertions(+), 263 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index f1a0f67..030c135 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -1,62 +1,49 @@ -# Generate random strings as keys for meilisearch and librechat (Stored securely in Azure Key Vault) -resource "random_string" "meilisearch_master_key" { - length = 20 - special = false -} - -resource "azurerm_key_vault_secret" "meilisearch_master_key" { - name = "${var.meilisearch_app_name}-master-key" - value = random_string.meilisearch_master_key.result - key_vault_id = azurerm_key_vault.az_openai_kv.id - depends_on = [azurerm_role_assignment.kv_role_assigment] -} - # LibreChat CREDS key (64 characters in hex) and 16-byte IV (32 characters in hex) -resource "random_string" "libre_app_creds_key" { +resource "random_password" "libre_app_creds_key" { length = 64 special = false } -resource "random_string" "libre_app_creds_iv" { +resource "random_password" "libre_app_creds_iv" { length = 32 special = false } resource "azurerm_key_vault_secret" "libre_app_creds_key" { name = "${var.libre_app_name}-key" - value = random_string.libre_app_creds_key.result + value = random_password.libre_app_creds_key.result key_vault_id = azurerm_key_vault.az_openai_kv.id depends_on = [azurerm_role_assignment.kv_role_assigment] } resource "azurerm_key_vault_secret" "libre_app_creds_iv" { name = "${var.libre_app_name}-iv" - value = random_string.libre_app_creds_iv.result + value = random_password.libre_app_creds_iv.result key_vault_id = azurerm_key_vault.az_openai_kv.id depends_on = [azurerm_role_assignment.kv_role_assigment] } # LibreChat JWT Secret (64 characters in hex) and JWT Refresh Secret (64 characters in hex) -resource "random_string" "libre_app_jwt_secret" { +resource "random_password" "libre_app_jwt_secret" { length = 64 special = false } -resource "random_string" "libre_app_jwt_refresh_secret" { +resource "random_password" "libre_app_jwt_refresh_secret" { length = 64 special = false } resource "azurerm_key_vault_secret" "libre_app_jwt_secret" { name = "${var.libre_app_name}-jwt-secret" - value = random_string.libre_app_jwt_secret.result + value = random_password.libre_app_jwt_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id depends_on = [azurerm_role_assignment.kv_role_assigment] } resource "azurerm_key_vault_secret" "libre_app_jwt_refresh_secret" { name = "${var.libre_app_name}-jwt-refresh-secret" - value = random_string.libre_app_jwt_refresh_secret.result + value = random_password.libre_app_jwt_refresh_secret.result key_vault_id = azurerm_key_vault.az_openai_kv.id depends_on = [azurerm_role_assignment.kv_role_assigment] } @@ -70,64 +57,7 @@ resource "azurerm_service_plan" "az_openai_asp" { sku_name = var.app_service_sku_name } -# Create meilisearch app -# TODO: Add support for private endpoints instead of subnet access -# resource "azurerm_linux_web_app" "meilisearch" { -# name = var.meilisearch_app_name -# location = var.location -# resource_group_name = azurerm_resource_group.az_openai_rg.name -# service_plan_id = azurerm_service_plan.az_openai_asp.id -# https_only = true - -# app_settings = { -# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - -# MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : random_string.meilisearch_master_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" -# MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics - -# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" -# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false -# DOCKER_ENABLE_CI = false -# WEBSITES_PORT = 7700 -# PORT = 7700 -# DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" -# } - -# site_config { -# always_on = "true" -# ip_restriction { -# virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id -# priority = 100 -# name = "Allow from LibreChat app subnet" -# action = "Allow" -# } -# } - -# logs { -# http_logs { -# file_system { -# retention_in_days = 7 -# retention_in_mb = 35 -# } -# } -# application_logs { -# file_system_level = "Information" -# } -# } - -# identity { -# type = "SystemAssigned" -# } -# depends_on = [azurerm_subnet.az_openai_subnet] -# } - -# Grant kv access to meilisearch app to reference the master key secret -# resource "azurerm_role_assignment" "meilisearch_app_kv_access" { -# scope = azurerm_key_vault.az_openai_kv.id -# principal_id = azurerm_linux_web_app.meilisearch.identity[0].principal_id -# role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. -# } resource "azurerm_linux_web_app" "librechat" { name = var.libre_app_name @@ -149,7 +79,7 @@ resource "azurerm_linux_web_app" "librechat" { } } application_logs { - file_system_level = "Verbose" #"Information" + file_system_level = "Information" } } @@ -160,23 +90,17 @@ resource "azurerm_linux_web_app" "librechat" { app_settings = local.libre_app_settings virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id - #depends_on = [azurerm_linux_web_app.meilisearch, azurerm_subnet.az_openai_subnet] depends_on = [azurerm_subnet.az_openai_subnet] } # Grant kv access to librechat app to reference environment variables (stored as secrets in key vault) -#resource "azurerm_role_assignment" "libre_app_kv_access" { -# scope = azurerm_key_vault.az_openai_kv.id -# principal_id = azurerm_linux_web_app.az_openai_librechat.identity[0].principal_id -# role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. -#} - resource "azurerm_role_assignment" "librechat_app_kv_access" { scope = azurerm_key_vault.az_openai_kv.id principal_id = azurerm_linux_web_app.librechat.identity[0].principal_id role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. } +#TODO: # # Deploy code from a public GitHub repo # # resource "azurerm_app_service_source_control" "sourcecontrol" { # # app_id = azurerm_linux_web_app.librechat.id @@ -191,12 +115,75 @@ resource "azurerm_role_assignment" "librechat_app_kv_access" { # # ] # # } -# resource "azurerm_app_service_virtual_network_swift_connection" "librechat" { -# app_service_id = azurerm_linux_web_app.librechat.id -# subnet_id = module.vnet.vnet_subnets_name_id["subnet0"] -# depends_on = [ -# azurerm_linux_web_app.librechat, -# module.vnet -# ] +# Implement a Search (either Meili or Azure AI Search) +# # Generate random strings as keys for meilisearch and librechat (Stored securely in Azure Key Vault) +# resource "random_string" "meilisearch_master_key" { +# length = 20 +# special = false +# } + +# resource "azurerm_key_vault_secret" "meilisearch_master_key" { +# name = "${var.meilisearch_app_name}-master-key" +# value = random_string.meilisearch_master_key.result +# key_vault_id = azurerm_key_vault.az_openai_kv.id +# depends_on = [azurerm_role_assignment.kv_role_assigment] +# } + +# Create meilisearch app +# TODO: Add support for private endpoints instead of subnet access +# resource "azurerm_linux_web_app" "meilisearch" { +# name = var.meilisearch_app_name +# location = var.location +# resource_group_name = azurerm_resource_group.az_openai_rg.name +# service_plan_id = azurerm_service_plan.az_openai_asp.id +# https_only = true + +# app_settings = { +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false + +# MEILI_MASTER_KEY = var.meilisearch_app_key != null ? var.meilisearch_app_key : random_string.meilisearch_master_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" +# MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics + +# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" +# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false +# DOCKER_ENABLE_CI = false +# WEBSITES_PORT = 7700 +# PORT = 7700 +# DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" +# } + +# site_config { +# always_on = "true" +# ip_restriction { +# virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id != null ? var.meilisearch_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id +# priority = 100 +# name = "Allow from LibreChat app subnet" +# action = "Allow" +# } +# } + +# logs { +# http_logs { +# file_system { +# retention_in_days = 7 +# retention_in_mb = 35 +# } +# } +# application_logs { +# file_system_level = "Information" +# } +# } + +# identity { +# type = "SystemAssigned" +# } + +# depends_on = [azurerm_subnet.az_openai_subnet] # } +# Grant kv access to meilisearch app to reference the master key secret +# resource "azurerm_role_assignment" "meilisearch_app_kv_access" { +# scope = azurerm_key_vault.az_openai_kv.id +# principal_id = azurerm_linux_web_app.meilisearch.identity[0].principal_id +# role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. +# } \ No newline at end of file diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index f0f68f8..98ab9c0 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -1,22 +1,20 @@ locals { libre_app_settings = { ### App Service Configuration ### - WEBSITE_RUN_FROM_PACKAGE = "1" - #DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" ####### + WEBSITE_RUN_FROM_PACKAGE = "1" WEBSITES_ENABLE_APP_SERVICE_STORAGE = false DOCKER_ENABLE_CI = false WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port - WEBSITES_CONTAINER_START_TIME_LIMIT = 2500 + WEBSITES_CONTAINER_START_TIME_LIMIT = 1500 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - # DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev:latest" - NODE_ENV = "Production" ####### + #NODE_ENV = "Production" ####### ### Server Configuration ### APP_TITLE = var.libre_app_title CUSTOM_FOOTER = var.libre_app_custom_footer HOST = var.libre_app_host - MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : azurerm_cosmosdb_account.az_openai_mongodb.primary_mongodb_connection_string #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" + MONGO_URI = var.libre_app_mongo_uri != null ? var.libre_app_mongo_uri : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_cosmos_uri.id})" DOMAIN_CLIENT = var.libre_app_domain_client DOMAIN_SERVER = var.libre_app_domain_server @@ -28,9 +26,9 @@ locals { ENDPOINTS = var.libre_app_endpoints ### Azure OpenAI ### - AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : azurerm_cognitive_account.az_openai.primary_access_key #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" + AZURE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" AZURE_OPENAI_MODELS = var.libre_app_az_oai_models - AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = false #var.libre_app_az_oai_use_model_as_deployment_name + AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = var.libre_app_az_oai_use_model_as_deployment_name AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version @@ -38,71 +36,24 @@ locals { # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) # Warning: If you don't set them, the app will crash on startup. DEBUG_PLUGINS = var.libre_app_debug_plugins - CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : random_string.libre_app_creds_key.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" - CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : random_string.libre_app_creds_iv.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" + CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" + CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" ### Search ### - SEARCH = false #var.libre_app_enable_meilisearch + SEARCH = var.libre_app_enable_meilisearch # MEILI_NO_ANALYTICS = var.libre_app_disable_meilisearch_analytics # MEILI_HOST = var.libre_app_meili_host != null ? var.libre_app_meili_host : "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" # MEILI_MASTER_KEY = var.libre_app_meili_key != null ? var.libre_app_meili_key : random_string.meilisearch_master_key.result # "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.meilisearch_master_key.id})" - ### User - Balance ### - #CHECK_BALANCE = false - ### User - Registration and Login ### - ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login #true - ALLOW_REGISTRATION = var.libre_app_allow_registration #true - ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login #false - ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration #false - SESSION_EXPIRY = 1000 * 60 * 7 #15 minutes - REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 5 #7 days - JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : random_string.libre_app_jwt_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" - JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : random_string.libre_app_jwt_refresh_secret.result #"@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" - - ###To remove - DEBUG_OPENAI = false - - BAN_VIOLATIONS = true - BAN_DURATION = 1000 * 60 * 60 * 2 - BAN_INTERVAL = 20 - - LOGIN_VIOLATION_SCORE = 1 - REGISTRATION_VIOLATION_SCORE = 1 - CONCURRENT_VIOLATION_SCORE = 1 - MESSAGE_VIOLATION_SCORE = 1 - NON_BROWSER_VIOLATION_SCORE = 20 - - LOGIN_MAX = 7 - LOGIN_WINDOW = 5 - REGISTER_MAX = 5 - REGISTER_WINDOW = 60 - - LIMIT_CONCURRENT_MESSAGES = true - CONCURRENT_MESSAGE_MAX = 2 - - LIMIT_MESSAGE_IP = true - MESSAGE_IP_MAX = 40 - MESSAGE_IP_WINDOW = 1 - - LIMIT_MESSAGE_USER = false - MESSAGE_USER_MAX = 40 - MESSAGE_USER_WINDOW = 1 - - AZURE_OPENAI_API_DEPLOYMENT_NAME = "gpt-4" - - BINGAI_TOKEN = "sdfdgf23rf23rcopjfoi3h9hf3efeef23" - - CHATGPT_TOKEN = "sdfdgf23rf23rcopjfoi3h9hf3efeef23" - CHATGPT_MODELS = "text-davinci-002-render-sha" - - GOOGLE_KEY = "user_provided" - - - DEBUG_OPENAI = false - # AZURE_OPENAI_API_VERSION = var.azure_openai_api_version - # AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = - # AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = + ALLOW_EMAIL_LOGIN = var.libre_app_allow_email_login + ALLOW_REGISTRATION = var.libre_app_allow_registration + ALLOW_SOCIAL_LOGIN = var.libre_app_allow_social_login + ALLOW_SOCIAL_REGISTRATION = var.libre_app_allow_social_registration + SESSION_EXPIRY = 1000 * 60 * 7 #15 minutes + REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 5 #7 days + JWT_SECRET = var.libre_app_jwt_secret != null ? var.libre_app_jwt_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_secret.id})" + JWT_REFRESH_SECRET = var.libre_app_jwt_refresh_secret != null ? var.libre_app_jwt_refresh_secret : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_jwt_refresh_secret.id})" } } diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 1911f88..5eba2f3 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -86,9 +86,9 @@ module "private-chatgpt-openai" { app_service_sku_name = var.app_service_sku_name # MeiSearch App - meilisearch_app_name = "${var.meilisearch_app_name}${random_integer.number.result}" - meilisearch_app_virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id - meilisearch_app_key = var.meilisearch_app_key + #meilisearch_app_name = "${var.meilisearch_app_name}${random_integer.number.result}" + #meilisearch_app_virtual_network_subnet_id = var.meilisearch_app_virtual_network_subnet_id + #meilisearch_app_key = var.meilisearch_app_key # LibreChat App libre_app_name = "${var.libre_app_name}${random_integer.number.result}" @@ -125,10 +125,10 @@ module "private-chatgpt-openai" { libre_app_plugins_creds_iv = var.libre_app_plugins_creds_iv # Search - libre_app_enable_meilisearch = var.libre_app_enable_meilisearch - libre_app_disable_meilisearch_analytics = var.libre_app_disable_meilisearch_analytics - libre_app_meili_host = var.libre_app_meili_host - libre_app_meili_key = var.libre_app_meili_key + libre_app_enable_meilisearch = var.libre_app_enable_meilisearch + #libre_app_disable_meilisearch_analytics = var.libre_app_disable_meilisearch_analytics + #libre_app_meili_host = var.libre_app_meili_host + #libre_app_meili_key = var.libre_app_meili_key # User Registration libre_app_allow_email_login = var.libre_app_allow_email_login diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 3d1e5b3..7db98a4 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -1,6 +1,6 @@ ### 01 Common Variables + RG ### resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -location = "uksouth" #"westus" +location = "westus" tags = { Terraform = "True" Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" @@ -52,29 +52,21 @@ oai_network_acls = null oai_storage = null oai_model_deployment = [ { - deployment_id = "gpt-4" + deployment_id = "gpt-4-1106-preview" model_name = "gpt-4" model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) }, - # { - # deployment_id = "gpt-4-1106-preview" - # model_name = "gpt-4" - # model_format = "OpenAI" - # model_version = "1106-Preview" - # scale_type = "Standard" - # scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) - # }, - #{ - # deployment_id = "gpt-4-vision-preview" - # model_name = "gpt-4" - # model_format = "OpenAI" - # model_version = "vision-preview" - # scale_type = "Standard" - # scale_capacity = 5 - #} + { + deployment_id = "gpt-4-vision-preview" + model_name = "gpt-4" + model_format = "OpenAI" + model_version = "vision-preview" + scale_type = "Standard" + scale_capacity = 5 + } ] ### 05 cosmosdb ### @@ -88,7 +80,7 @@ cosmosdb_max_interval_in_seconds = 10 cosmosdb_max_staleness_prefix = 200 cosmosdb_geo_locations = [ { - location = "uksouth" + location = "westus" failover_priority = 0 } ] @@ -103,9 +95,9 @@ app_service_name = "openaiasp" app_service_sku_name = "B1" # Meilisearch App -meilisearch_app_name = "meilisearchapp" -meilisearch_app_virtual_network_subnet_id = null -meilisearch_app_key = "dfsdgdsffgdsfgds" #null +#meilisearch_app_name = "meilisearchapp" +#meilisearch_app_virtual_network_subnet_id = null +#meilisearch_app_key = null # LibreChat App Service libre_app_name = "librechatapp" @@ -119,8 +111,8 @@ libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and libre_app_host = "0.0.0.0" libre_app_port = 80 libre_app_mongo_uri = null -libre_app_domain_client = "https://0.0.0.0:80" -libre_app_domain_server = "https://0.0.0.0:80" +libre_app_domain_client = "http://localhost:80" +libre_app_domain_server = "http://localhost:80" # debug logging libre_app_debug_logging = true @@ -131,29 +123,29 @@ libre_app_endpoints = "azureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null -libre_app_az_oai_models = "gpt-4" -libre_app_az_oai_use_model_as_deployment_name = false #true +libre_app_az_oai_models = "gpt-4-1106-preview,gpt-4-vision-preview" +libre_app_az_oai_use_model_as_deployment_name = true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" # Plugins libre_app_debug_plugins = false -libre_app_plugins_creds_key = "dfsdgdsffgdsfgds" #null -libre_app_plugins_creds_iv = "dfsdgdsffgdsfgds" #null +libre_app_plugins_creds_key = null +libre_app_plugins_creds_iv = null # Search -libre_app_enable_meilisearch = false -libre_app_disable_meilisearch_analytics = true -libre_app_meili_host = null -libre_app_meili_key = "dfsdgdsffgdsfgds" #null +libre_app_enable_meilisearch = false +#libre_app_disable_meilisearch_analytics = true +#libre_app_meili_host = null +#libre_app_meili_key = null # User Registration libre_app_allow_email_login = true libre_app_allow_registration = true libre_app_allow_social_login = false libre_app_allow_social_registration = false -libre_app_jwt_secret = "dfsdgdsffgdsfgds" #null -libre_app_jwt_refresh_secret = "dfsdgdsffgdsfgds" #null +libre_app_jwt_secret = null +libre_app_jwt_refresh_secret = null # ### CDN - Front Door ### # create_front_door_cdn = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 0c63277..441750e 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -350,26 +350,26 @@ variable "app_service_sku_name" { default = "B1" } -# Meilisearch App Service -variable "meilisearch_app_name" { - type = string - description = "Name of the meilisearch App Service." - default = "meilisearchapp9000" +# # Meilisearch App Service +# variable "meilisearch_app_name" { +# type = string +# description = "Name of the meilisearch App Service." +# default = "meilisearchapp9000" -} +# } -variable "meilisearch_app_virtual_network_subnet_id" { - type = string - description = "The ID of the subnet to deploy the meilisearch App Service in." - default = null -} +# variable "meilisearch_app_virtual_network_subnet_id" { +# type = string +# description = "The ID of the subnet to deploy the meilisearch App Service in." +# default = null +# } -variable "meilisearch_app_key" { - type = string - description = "The Meilisearch API Key to use for authentication." - default = null - sensitive = true -} +# variable "meilisearch_app_key" { +# type = string +# description = "The Meilisearch API Key to use for authentication." +# default = null +# sensitive = true +# } # LibreChat App Service variable "libre_app_name" { @@ -512,20 +512,20 @@ variable "libre_app_plugins_creds_iv" { variable "libre_app_enable_meilisearch" { type = bool description = "Enable Meilisearch" - default = true + default = false } -variable "libre_app_disable_meilisearch_analytics" { - type = bool - description = "Disable Meilisearch Analytics" - default = true -} +# variable "libre_app_disable_meilisearch_analytics" { +# type = bool +# description = "Disable Meilisearch Analytics" +# default = true +# } -variable "libre_app_meili_host" { - type = string - description = "For the API server to connect to the search server. E.g. https://meilisearch.example.com" - default = null -} +# variable "libre_app_meili_host" { +# type = string +# description = "For the API server to connect to the search server. E.g. https://meilisearch.example.com" +# default = null +# } variable "libre_app_meili_key" { type = string diff --git a/variables.tf b/variables.tf index 4806b51..61d3bf9 100644 --- a/variables.tf +++ b/variables.tf @@ -354,26 +354,27 @@ variable "app_service_sku_name" { default = "B1" } +#TODO # Meilisearch App Service -variable "meilisearch_app_name" { - type = string - description = "Name of the meilisearch App Service." - default = "meilisearchapp9000" +# variable "meilisearch_app_name" { +# type = string +# description = "Name of the meilisearch App Service." +# default = "meilisearchapp9000" -} +# } -variable "meilisearch_app_virtual_network_subnet_id" { - type = string - description = "The ID of the subnet to deploy the meilisearch App Service in." - default = null -} +# variable "meilisearch_app_virtual_network_subnet_id" { +# type = string +# description = "The ID of the subnet to deploy the meilisearch App Service in." +# default = null +# } -variable "meilisearch_app_key" { - type = string - description = "The Meilisearch API Key to use for authentication." - default = null - sensitive = true -} +# variable "meilisearch_app_key" { +# type = string +# description = "The Meilisearch API Key to use for authentication." +# default = null +# sensitive = true +# } # LibreChat App Service variable "libre_app_name" { @@ -512,31 +513,32 @@ variable "libre_app_plugins_creds_iv" { sensitive = true } +#TODO # Search variable "libre_app_enable_meilisearch" { type = bool description = "Enable Meilisearch" - default = true -} - -variable "libre_app_disable_meilisearch_analytics" { - type = bool - description = "Disable Meilisearch Analytics" - default = true -} - -variable "libre_app_meili_host" { - type = string - description = "For the API server to connect to the search server. E.g. https://meilisearch.example.com" - default = null + default = false } -variable "libre_app_meili_key" { - type = string - description = "Meilisearch API Key" - default = null - sensitive = true -} +# # variable "libre_app_disable_meilisearch_analytics" { +# # type = bool +# # description = "Disable Meilisearch Analytics" +# # default = true +# # } + +# # variable "libre_app_meili_host" { +# # type = string +# # description = "For the API server to connect to the search server. E.g. https://meilisearch.example.com" +# # default = null +# # } + +# # variable "libre_app_meili_key" { +# # type = string +# # description = "Meilisearch API Key" +# # default = null +# # sensitive = true +# # } # User Registration variable "libre_app_allow_email_login" { From 230d46d5079abfd5ed524a06810843003f6f838e Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 15:58:05 +0000 Subject: [PATCH 141/163] add ip_restrictions --- 06_librechat_app.tf | 51 ++++++++++++++++++++++++++-- tests/auto_test1/main.tf | 1 + tests/auto_test1/testing.auto.tfvars | 1 + tests/auto_test1/variables.tf | 6 ++++ variables.tf | 6 ++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 030c135..39d0a66 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -57,8 +57,7 @@ resource "azurerm_service_plan" "az_openai_asp" { sku_name = var.app_service_sku_name } - - +#Create LibeChat App Service resource "azurerm_linux_web_app" "librechat" { name = var.libre_app_name location = var.location @@ -69,6 +68,19 @@ resource "azurerm_linux_web_app" "librechat" { site_config { minimum_tls_version = "1.2" + ip_restriction { + virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id + priority = 100 + name = "Allow from LibreChat app subnet" + action = "Allow" + } + + ip_restriction { + virtual_network_subnet_id = var.libre_app_allowed_ip_address + priority = 200 + name = "The CIDR notation of the IP or IP Range to match to allow. For example: 10.0.0.0/24 or 192.168.10.1/32" + action = "Allow" + } } logs { @@ -100,6 +112,41 @@ resource "azurerm_role_assignment" "librechat_app_kv_access" { role_definition_name = "Key Vault Secrets User" # Read secret contents. Only works for key vaults that use the 'Azure role-based access control' permission model. } +#Custom Domain / Certificates / Allowed IPs +# resource "azurerm_dns_zone" "dns-zone" { +# name = var.azure_dns_zone +# resource_group_name = var.azure_resource_group_name +# } + +# resource "azurerm_linux_web_app" "app-service" { +# name = "some-service" +# resource_group_name = var.azure_resource_group_name +# location = var.azure_region +# service_plan_id = "some-plan" +# site_config {} +# } + +# resource "azurerm_dns_txt_record" "domain-verification" { +# name = "asuid.api.domain.com" +# zone_name = var.azure_dns_zone +# resource_group_name = var.azure_resource_group_name +# ttl = 300 + +# record { +# value = azurerm_linux_web_app.app-service.custom_domain_verification_id +# } +# } + +# resource "azurerm_dns_cname_record" "cname-record" { +# name = "domain.com" +# zone_name = azurerm_dns_zone.dns-zone.name +# resource_group_name = var.azure_resource_group_name +# ttl = 300 +# record = azurerm_linux_web_app.app-service.default_hostname + +# depends_on = [azurerm_dns_txt_record.domain-verification] +# } + #TODO: # # Deploy code from a public GitHub repo # # resource "azurerm_app_service_source_control" "sourcecontrol" { diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 5eba2f3..92576f5 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -94,6 +94,7 @@ module "private-chatgpt-openai" { libre_app_name = "${var.libre_app_name}${random_integer.number.result}" libre_app_virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id libre_app_public_network_access_enabled = var.libre_app_public_network_access_enabled + libre_app_allowed_ip_address = var.libre_app_allowed_ip_address ### LibreChat App Settings ### # Server Config diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 7db98a4..f7e8ecc 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -103,6 +103,7 @@ app_service_sku_name = "B1" libre_app_name = "librechatapp" libre_app_public_network_access_enabled = true libre_app_virtual_network_subnet_id = null +libre_app_allowed_ip_address = "0.0.0.0/0" ### LibreChat App Settings ### # Server Config diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 441750e..8df8d59 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -390,6 +390,12 @@ variable "libre_app_virtual_network_subnet_id" { default = null } +variable "libre_app_allowed_ip_address" { + type = string + description = "The IP Address to allow access to the LibreChat App Service from." + default = "0.0.0.0/0" +} + # LibreChat App Service App Settings # Server Config variable "libre_app_title" { diff --git a/variables.tf b/variables.tf index 61d3bf9..535b120 100644 --- a/variables.tf +++ b/variables.tf @@ -395,6 +395,12 @@ variable "libre_app_virtual_network_subnet_id" { default = null } +variable "libre_app_allowed_ip_address" { + type = string + description = "The IP Address to allow access to the LibreChat App Service from." + default = "0.0.0.0/0" +} + # LibreChat App Service App Settings # Server Config variable "libre_app_title" { From c2d0fc9df3ed00b4c90ab6ea1ae0e775b5f5b848 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 16:03:19 +0000 Subject: [PATCH 142/163] upgrade Terraform version --- versions.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.tf b/versions.tf index cb7a872..581f4a4 100644 --- a/versions.tf +++ b/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.6.5" + required_version = ">= 1.7.0" required_providers { azurerm = { source = "hashicorp/azurerm" From 01ec99abb0b52f7b2f4b07cf43e4057a685eeb04 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 16:36:48 +0000 Subject: [PATCH 143/163] fix --- 06_librechat_app.tf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 39d0a66..379b4b0 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -76,10 +76,10 @@ resource "azurerm_linux_web_app" "librechat" { } ip_restriction { - virtual_network_subnet_id = var.libre_app_allowed_ip_address - priority = 200 - name = "The CIDR notation of the IP or IP Range to match to allow. For example: 10.0.0.0/24 or 192.168.10.1/32" - action = "Allow" + ipip_address = var.libre_app_allowed_ip_address + priority = 200 + name = "The CIDR notation of the IP or IP Range to match to allow. For example: 10.0.0.0/24 or 192.168.10.1/32" + action = "Allow" } } From ae093b0d9782e57ca67de127d06d2b729351ec39 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 16:38:48 +0000 Subject: [PATCH 144/163] fix --- 06_librechat_app.tf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 379b4b0..16c2378 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -76,10 +76,10 @@ resource "azurerm_linux_web_app" "librechat" { } ip_restriction { - ipip_address = var.libre_app_allowed_ip_address - priority = 200 - name = "The CIDR notation of the IP or IP Range to match to allow. For example: 10.0.0.0/24 or 192.168.10.1/32" - action = "Allow" + ip_address = var.libre_app_allowed_ip_address + priority = 200 + name = "The CIDR notation of the IP or IP Range to match to allow. For example: 10.0.0.0/24 or 192.168.10.1/32" + action = "Allow" } } From de4690c872f8c459dc6f203027f3366b85847b0f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 16:40:31 +0000 Subject: [PATCH 145/163] add todo --- 06_librechat_app.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 16c2378..21f8c4a 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -68,10 +68,11 @@ resource "azurerm_linux_web_app" "librechat" { site_config { minimum_tls_version = "1.2" + #TODO - Make dynamic ip_restriction { virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id priority = 100 - name = "Allow from LibreChat app subnet" + name = "Allow access from app subnet, should also host other services e.g. CosmosDB, MeiliSearch, etc." action = "Allow" } From c715184a1ec131702a9e2c0949553abd94cebb86 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 16:49:21 +0000 Subject: [PATCH 146/163] up --- 06_librechat_app.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 21f8c4a..3446f7e 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -72,14 +72,14 @@ resource "azurerm_linux_web_app" "librechat" { ip_restriction { virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id priority = 100 - name = "Allow access from app subnet, should also host other services e.g. CosmosDB, MeiliSearch, etc." + name = "${azurerm_subnet.az_openai_subnet.id}-access" # "Allow from LibreChat app subnet and hosted services e.g. cosmosdb, meilisearch etc." action = "Allow" } ip_restriction { ip_address = var.libre_app_allowed_ip_address priority = 200 - name = "The CIDR notation of the IP or IP Range to match to allow. For example: 10.0.0.0/24 or 192.168.10.1/32" + name = "ip-access" # "The CIDR notation of the IP or IP Range to match to allow. For example: 10.0.0.0/24 or 192.168.10.1/32" action = "Allow" } } From 4b7e6508da669c5e220b0ff40474f5c4317bdbc6 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 16:53:16 +0000 Subject: [PATCH 147/163] up --- 06_librechat_app.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 3446f7e..e6714bb 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -72,7 +72,7 @@ resource "azurerm_linux_web_app" "librechat" { ip_restriction { virtual_network_subnet_id = var.libre_app_virtual_network_subnet_id != null ? var.libre_app_virtual_network_subnet_id : azurerm_subnet.az_openai_subnet.id priority = 100 - name = "${azurerm_subnet.az_openai_subnet.id}-access" # "Allow from LibreChat app subnet and hosted services e.g. cosmosdb, meilisearch etc." + name = "${azurerm_subnet.az_openai_subnet.name}-access" # "Allow from LibreChat app subnet and hosted services e.g. cosmosdb, meilisearch etc." action = "Allow" } From 2a4f5c43e1bdf4a623e53c60bd745a1b9d8a8655 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 17:19:35 +0000 Subject: [PATCH 148/163] change region to sweden central for Dall E --- 06_librechat_app.tf | 1 + tests/auto_test1/testing.auto.tfvars | 4 ++-- tests/auto_test1/variables.tf | 2 +- variables.tf | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/06_librechat_app.tf b/06_librechat_app.tf index e6714bb..31c114e 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -148,6 +148,7 @@ resource "azurerm_role_assignment" "librechat_app_kv_access" { # depends_on = [azurerm_dns_txt_record.domain-verification] # } +#TODO: Implement DALL-E #TODO: # # Deploy code from a public GitHub repo # # resource "azurerm_app_service_source_control" "sourcecontrol" { diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index f7e8ecc..0d0cf76 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -1,6 +1,6 @@ ### 01 Common Variables + RG ### resource_group_name = "TF-Module-Automated-Tests-Cognitive-GPT" -location = "westus" +location = "SwedenCentral" tags = { Terraform = "True" Description = "Private ChatGPT hosted on Azure OpenAI (Librechat)" @@ -80,7 +80,7 @@ cosmosdb_max_interval_in_seconds = 10 cosmosdb_max_staleness_prefix = 200 cosmosdb_geo_locations = [ { - location = "westus" + location = "SwedenCentral" failover_priority = 0 } ] diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 8df8d59..8a909b0 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -392,7 +392,7 @@ variable "libre_app_virtual_network_subnet_id" { variable "libre_app_allowed_ip_address" { type = string - description = "The IP Address to allow access to the LibreChat App Service from." + description = "The IP Address to allow access to the LibreChat App Service from. (Change to your IP Address). default is allow all" default = "0.0.0.0/0" } diff --git a/variables.tf b/variables.tf index 535b120..9389b31 100644 --- a/variables.tf +++ b/variables.tf @@ -397,7 +397,7 @@ variable "libre_app_virtual_network_subnet_id" { variable "libre_app_allowed_ip_address" { type = string - description = "The IP Address to allow access to the LibreChat App Service from." + description = "The IP Address to allow access to the LibreChat App Service from. (Change to your IP Address). default is allow all" default = "0.0.0.0/0" } From 598d190b4adecaab65e9d4bba91e0bb802901ddd Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 17:43:12 +0000 Subject: [PATCH 149/163] up --- tests/auto_test1/testing.auto.tfvars | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 0d0cf76..8d248a2 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -66,6 +66,14 @@ oai_model_deployment = [ model_version = "vision-preview" scale_type = "Standard" scale_capacity = 5 + }, + { + deployment_id = "dall-e-3" + model_name = "dall-e-3" + model_format = "OpenAI" + model_version = "3.0" + scale_type = "Standard" + scale_capacity = 2 } ] From c68c756b601179a6db1fa98e12175e33e69865e1 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 17:43:37 +0000 Subject: [PATCH 150/163] lint --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 8d248a2..dafb2d6 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -67,7 +67,7 @@ oai_model_deployment = [ scale_type = "Standard" scale_capacity = 5 }, - { + { deployment_id = "dall-e-3" model_name = "dall-e-3" model_format = "OpenAI" From 4d0517734f67258c6ae82112df2b0bd57b6b6443 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 18:33:59 +0000 Subject: [PATCH 151/163] update app --- 06_librechat_app_config.tf | 5 +++++ tests/auto_test1/main.tf | 2 ++ tests/auto_test1/testing.auto.tfvars | 3 +++ tests/auto_test1/variables.tf | 12 ++++++++++++ variables.tf | 12 ++++++++++++ 5 files changed, 34 insertions(+) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 98ab9c0..775d2b6 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -32,6 +32,11 @@ locals { AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version + ### Azure OpenAI DALL-E-3 (Only in 'SwedenCentral' and 'EastUS') ### + DALLE3_AZURE_API_VERSION = var.libre_app_az_oai_dall3_api_version + DALLE3_BASEURL = "https://${var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1]}.openai.azure.com/openai/deployments/${var.libre_app_az_oai_dall3_deployment_name}/" + DALLE3_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" + ### Plugins ### # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) # Warning: If you don't set them, the app will crash on startup. diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 92576f5..7ca520f 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -119,6 +119,8 @@ module "private-chatgpt-openai" { libre_app_az_oai_use_model_as_deployment_name = var.libre_app_az_oai_use_model_as_deployment_name libre_app_az_oai_instance_name = var.libre_app_az_oai_instance_name libre_app_az_oai_api_version = var.libre_app_az_oai_api_version + libre_app_az_oai_dall3_api_version = var.libre_app_az_oai_dall3_api_version + libre_app_az_oai_dall3_deployment_name = var.libre_app_az_oai_dall3_deployment_name # Plugins libre_app_debug_plugins = var.libre_app_debug_plugins diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index dafb2d6..c4bfa95 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -136,6 +136,9 @@ libre_app_az_oai_models = "gpt-4-1106-preview,gpt-4-vision libre_app_az_oai_use_model_as_deployment_name = true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" +libre_app_az_oai_dall3_api_version = "2023-12-01-preview" +libre_app_az_oai_dall3_deployment_name = "dall-e-3" + # Plugins libre_app_debug_plugins = false diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 8a909b0..11f2355 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -493,6 +493,18 @@ variable "libre_app_az_oai_api_version" { default = "2023-07-01-preview" } +variable "libre_app_az_oai_dall3_api_version" { + type = string + description = "Azure OpenAI DALL-E API Version" + default = "2023-12-01-preview" +} + +variable "libre_app_az_oai_dall3_deployment_name" { + type = string + description = "Azure OpenAI DALL-E Deployment Name" + default = "dall-e-3" +} + # Plugins variable "libre_app_debug_plugins" { type = bool diff --git a/variables.tf b/variables.tf index 9389b31..ec87ec3 100644 --- a/variables.tf +++ b/variables.tf @@ -498,6 +498,18 @@ variable "libre_app_az_oai_api_version" { default = "2023-07-01-preview" } +variable "libre_app_az_oai_dall3_api_version" { + type = string + description = "Azure OpenAI DALL-E API Version" + default = "2023-12-01-preview" +} + +variable "libre_app_az_oai_dall3_deployment_name" { + type = string + description = "Azure OpenAI DALL-E Deployment Name" + default = "dall-e-3" +} + # Plugins variable "libre_app_debug_plugins" { type = bool From 633d5892f9e5dbfbfb8e7f949cb801a606d2a0ae Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 20:40:56 +0000 Subject: [PATCH 152/163] update app --- 06_librechat_app_config.tf | 12 ++++++------ tests/auto_test1/main.tf | 1 + tests/auto_test1/testing.auto.tfvars | 4 ++-- tests/auto_test1/variables.tf | 6 ++++++ variables.tf | 7 +++++++ 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 775d2b6..2eb5981 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -8,7 +8,6 @@ locals { PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 1500 DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - #NODE_ENV = "Production" ####### ### Server Configuration ### APP_TITLE = var.libre_app_title @@ -32,17 +31,18 @@ locals { AZURE_OPENAI_API_INSTANCE_NAME = var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1] AZURE_OPENAI_API_VERSION = var.libre_app_az_oai_api_version - ### Azure OpenAI DALL-E-3 (Only in 'SwedenCentral' and 'EastUS') ### - DALLE3_AZURE_API_VERSION = var.libre_app_az_oai_dall3_api_version - DALLE3_BASEURL = "https://${var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1]}.openai.azure.com/openai/deployments/${var.libre_app_az_oai_dall3_deployment_name}/" - DALLE3_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" - ### Plugins ### # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) # Warning: If you don't set them, the app will crash on startup. DEBUG_PLUGINS = var.libre_app_debug_plugins CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" + PLUGIN_MODELS = var.libre_app_plugin_models + + ### Azure OpenAI DALL-E-3 Plugin (Only in 'SwedenCentral' and 'EastUS') ### + DALLE3_AZURE_API_VERSION = var.libre_app_az_oai_dall3_api_version + DALLE3_BASEURL = "https://${var.libre_app_az_oai_instance_name != null ? var.libre_app_az_oai_instance_name : split("//", split(".", azurerm_cognitive_account.az_openai.endpoint)[0])[1]}.openai.azure.com/openai/deployments/${var.libre_app_az_oai_dall3_deployment_name}/" + DALLE_API_KEY = var.libre_app_az_oai_api_key != null ? var.libre_app_az_oai_api_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.openai_primary_key.id})" ### Search ### SEARCH = var.libre_app_enable_meilisearch diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 7ca520f..1dd7c28 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -126,6 +126,7 @@ module "private-chatgpt-openai" { libre_app_debug_plugins = var.libre_app_debug_plugins libre_app_plugins_creds_key = var.libre_app_plugins_creds_key libre_app_plugins_creds_iv = var.libre_app_plugins_creds_iv + libre_app_plugin_models = var.libre_app_plugin_models # Search libre_app_enable_meilisearch = var.libre_app_enable_meilisearch diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index c4bfa95..4fef11e 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -139,11 +139,11 @@ libre_app_az_oai_api_version = "2023-07-01-preview" libre_app_az_oai_dall3_api_version = "2023-12-01-preview" libre_app_az_oai_dall3_deployment_name = "dall-e-3" - # Plugins -libre_app_debug_plugins = false +libre_app_debug_plugins = true libre_app_plugins_creds_key = null libre_app_plugins_creds_iv = null +libre_app_plugin_models = "gpt-4,dall-e-3" # Search libre_app_enable_meilisearch = false diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 11f2355..06d3945 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -526,6 +526,12 @@ variable "libre_app_plugins_creds_iv" { sensitive = true } +variable "libre_app_plugin_models" { + type = string + description = "Libre App Plugin Models e.g. 'gpt-4,dall-e-3'" + default = "gpt-4,dall-e-3" +} + # Search variable "libre_app_enable_meilisearch" { type = bool diff --git a/variables.tf b/variables.tf index ec87ec3..c64e4b9 100644 --- a/variables.tf +++ b/variables.tf @@ -531,6 +531,13 @@ variable "libre_app_plugins_creds_iv" { sensitive = true } +variable "libre_app_plugin_models" { + type = string + description = "Libre App Plugin Models e.g. 'gpt-4,dall-e-3'" + default = "gpt-4,dall-e-3" +} + + #TODO # Search variable "libre_app_enable_meilisearch" { From f26bc20194ecce03e60f1c67c0a43e615ba39eb9 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 20:53:13 +0000 Subject: [PATCH 153/163] u[date --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 4fef11e..17da82b 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -143,7 +143,7 @@ libre_app_az_oai_dall3_deployment_name = "dall-e-3" libre_app_debug_plugins = true libre_app_plugins_creds_key = null libre_app_plugins_creds_iv = null -libre_app_plugin_models = "gpt-4,dall-e-3" +libre_app_plugin_models = "gpt-4,dall-e-3,gpt-4-1106-preview,gpt-4-vision-preview" # Search libre_app_enable_meilisearch = false From 4cf68017a34182342d79391e13da734b1f2b140f Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 21:53:18 +0000 Subject: [PATCH 154/163] test --- 06_librechat_app_config.tf | 9 +++++---- tests/auto_test1/testing.auto.tfvars | 5 +++-- tests/auto_test1/variables.tf | 6 ++++++ variables.tf | 5 +++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 2eb5981..0abf26b 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -34,10 +34,11 @@ locals { ### Plugins ### # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) # Warning: If you don't set them, the app will crash on startup. - DEBUG_PLUGINS = var.libre_app_debug_plugins - CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" - CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" - PLUGIN_MODELS = var.libre_app_plugin_models + DEBUG_PLUGINS = var.libre_app_debug_plugins + CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" + CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" + PLUGIN_MODELS = var.libre_app_plugin_models + PLUGINS_USE_AZURE = var.libre_app_plugins_use_azure ### Azure OpenAI DALL-E-3 Plugin (Only in 'SwedenCentral' and 'EastUS') ### DALLE3_AZURE_API_VERSION = var.libre_app_az_oai_dall3_api_version diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 17da82b..2d2a1a3 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -132,7 +132,7 @@ libre_app_endpoints = "azureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null -libre_app_az_oai_models = "gpt-4-1106-preview,gpt-4-vision-preview" +libre_app_az_oai_models = "gpt-4-1106-preview,gpt-4-vision-preview,dall-e-3" libre_app_az_oai_use_model_as_deployment_name = true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" @@ -143,7 +143,8 @@ libre_app_az_oai_dall3_deployment_name = "dall-e-3" libre_app_debug_plugins = true libre_app_plugins_creds_key = null libre_app_plugins_creds_iv = null -libre_app_plugin_models = "gpt-4,dall-e-3,gpt-4-1106-preview,gpt-4-vision-preview" +libre_app_plugin_models = "gpt-4-1106-preview,gpt-4-vision-preview,dall-e-3" +libre_app_plugins_use_azure = true # Search libre_app_enable_meilisearch = false diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index 06d3945..f8744b0 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -532,6 +532,12 @@ variable "libre_app_plugin_models" { default = "gpt-4,dall-e-3" } +variable "libre_app_plugins_use_azure" { + type = bool + description = "Libre App Plugins Use Azure, required for Azure OpenAI Plugins e.g. 'dall-e-3'" + default = true +} + # Search variable "libre_app_enable_meilisearch" { type = bool diff --git a/variables.tf b/variables.tf index c64e4b9..2d32914 100644 --- a/variables.tf +++ b/variables.tf @@ -537,6 +537,11 @@ variable "libre_app_plugin_models" { default = "gpt-4,dall-e-3" } +variable "libre_app_plugins_use_azure" { + type = bool + description = "Libre App Plugins Use Azure, required for Azure OpenAI Plugins e.g. 'dall-e-3'" + default = true +} #TODO # Search From 599d57b651eb8fb3dadd8dd693425f0ab2ae5cc0 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 22:03:28 +0000 Subject: [PATCH 155/163] up --- tests/auto_test1/testing.auto.tfvars | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 2d2a1a3..9571c16 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -51,6 +51,14 @@ oai_identity = { oai_network_acls = null oai_storage = null oai_model_deployment = [ + { + deployment_id = "gpt-4" + model_name = "gpt-4" + model_format = "OpenAI" + model_version = "0613" + scale_type = "Standard" + scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + }, { deployment_id = "gpt-4-1106-preview" model_name = "gpt-4" @@ -100,7 +108,7 @@ cosmosdb_public_network_access_enabled = true ### 06 app services (librechat app + meilisearch) ### # App Service Plan app_service_name = "openaiasp" -app_service_sku_name = "B1" +app_service_sku_name = "B2" # Meilisearch App #meilisearch_app_name = "meilisearchapp" @@ -132,7 +140,7 @@ libre_app_endpoints = "azureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null -libre_app_az_oai_models = "gpt-4-1106-preview,gpt-4-vision-preview,dall-e-3" +libre_app_az_oai_models = "gpt-4,gpt-4-1106-preview,gpt-4-vision-preview" libre_app_az_oai_use_model_as_deployment_name = true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" @@ -143,7 +151,7 @@ libre_app_az_oai_dall3_deployment_name = "dall-e-3" libre_app_debug_plugins = true libre_app_plugins_creds_key = null libre_app_plugins_creds_iv = null -libre_app_plugin_models = "gpt-4-1106-preview,gpt-4-vision-preview,dall-e-3" +libre_app_plugin_models = "dall-e-3" libre_app_plugins_use_azure = true # Search From 1aec2c7c63b3f4093c25e17f14c852efcb1cb5d4 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 22:03:40 +0000 Subject: [PATCH 156/163] liny --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 9571c16..ad69a34 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -51,7 +51,7 @@ oai_identity = { oai_network_acls = null oai_storage = null oai_model_deployment = [ - { + { deployment_id = "gpt-4" model_name = "gpt-4" model_format = "OpenAI" From c658542817ff063121fe00db716d1806a6627750 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 22:24:45 +0000 Subject: [PATCH 157/163] deploy --- tests/auto_test1/testing.auto.tfvars | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index ad69a34..6b06145 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -51,13 +51,21 @@ oai_identity = { oai_network_acls = null oai_storage = null oai_model_deployment = [ + { + deployment_id = "gpt-35-turbo" + model_name = "gpt-35-turbo" + model_format = "OpenAI" + model_version = "1106" + scale_type = "Standard" + scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + }, { deployment_id = "gpt-4" - model_name = "gpt-4" + model_name = "1106-Preview" model_format = "OpenAI" - model_version = "0613" + model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + scale_capacity = 15 }, { deployment_id = "gpt-4-1106-preview" @@ -65,7 +73,7 @@ oai_model_deployment = [ model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 20 # 34K == Roughly 204 RPM (Requests per minute) + scale_capacity = 15 }, { deployment_id = "gpt-4-vision-preview" @@ -140,7 +148,7 @@ libre_app_endpoints = "azureOpenAI" # Azure OpenAI libre_app_az_oai_api_key = null -libre_app_az_oai_models = "gpt-4,gpt-4-1106-preview,gpt-4-vision-preview" +libre_app_az_oai_models = "gpt-35-turbo,gpt-4,gpt-4-vision-preview" libre_app_az_oai_use_model_as_deployment_name = true libre_app_az_oai_instance_name = null libre_app_az_oai_api_version = "2023-07-01-preview" @@ -151,7 +159,7 @@ libre_app_az_oai_dall3_deployment_name = "dall-e-3" libre_app_debug_plugins = true libre_app_plugins_creds_key = null libre_app_plugins_creds_iv = null -libre_app_plugin_models = "dall-e-3" +libre_app_plugin_models = "gpt-35-turbo,gpt-4,gpt-4-vision-preview" libre_app_plugins_use_azure = true # Search From 3d2fef6f257993d4fa791459ce5710c6564878cf Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 22:25:53 +0000 Subject: [PATCH 158/163] lint --- tests/auto_test1/testing.auto.tfvars | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 6b06145..37bb241 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -51,7 +51,7 @@ oai_identity = { oai_network_acls = null oai_storage = null oai_model_deployment = [ - { + { deployment_id = "gpt-35-turbo" model_name = "gpt-35-turbo" model_format = "OpenAI" @@ -65,7 +65,7 @@ oai_model_deployment = [ model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 15 + scale_capacity = 15 }, { deployment_id = "gpt-4-1106-preview" @@ -73,7 +73,7 @@ oai_model_deployment = [ model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 15 + scale_capacity = 15 }, { deployment_id = "gpt-4-vision-preview" From 2e77d04d0aafc7b6b4d9c5fc541e0c221cbfc52d Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 22:31:04 +0000 Subject: [PATCH 159/163] up --- tests/auto_test1/testing.auto.tfvars | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 37bb241..7bb56c1 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -65,15 +65,7 @@ oai_model_deployment = [ model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" - scale_capacity = 15 - }, - { - deployment_id = "gpt-4-1106-preview" - model_name = "gpt-4" - model_format = "OpenAI" - model_version = "1106-Preview" - scale_type = "Standard" - scale_capacity = 15 + scale_capacity = 20 }, { deployment_id = "gpt-4-vision-preview" From b35abe6b9e94e4b8fb43b9b53a5f824662844328 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 22:37:30 +0000 Subject: [PATCH 160/163] fui --- tests/auto_test1/testing.auto.tfvars | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 7bb56c1..c1edf70 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -61,7 +61,7 @@ oai_model_deployment = [ }, { deployment_id = "gpt-4" - model_name = "1106-Preview" + model_name = "gpt-4" model_format = "OpenAI" model_version = "1106-Preview" scale_type = "Standard" From 0c1cd90e4308d7bc3ef49caa57d07c5678ad6cdc Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 23:33:25 +0000 Subject: [PATCH 161/163] up --- 06_librechat_app_config.tf | 2 +- modules/cdn_frontdoor/README.md | 64 ----- modules/cdn_frontdoor/data.tf | 9 - modules/cdn_frontdoor/main.tf | 187 ------------- modules/cdn_frontdoor/outputs.tf | 0 modules/cdn_frontdoor/variables.tf | 323 ----------------------- modules/librechat_app/README.md | 15 -- modules/librechat_app/main.tf | 378 --------------------------- modules/librechat_app/outputs.tf | 0 modules/librechat_app/variables.tf | 86 ------ tests/auto_test1/main.tf | 1 + tests/auto_test1/testing.auto.tfvars | 3 +- tests/auto_test1/variables.tf | 6 + variables.tf | 6 + 14 files changed, 16 insertions(+), 1064 deletions(-) delete mode 100644 modules/cdn_frontdoor/README.md delete mode 100644 modules/cdn_frontdoor/data.tf delete mode 100644 modules/cdn_frontdoor/main.tf delete mode 100644 modules/cdn_frontdoor/outputs.tf delete mode 100644 modules/cdn_frontdoor/variables.tf delete mode 100644 modules/librechat_app/README.md delete mode 100644 modules/librechat_app/main.tf delete mode 100644 modules/librechat_app/outputs.tf delete mode 100644 modules/librechat_app/variables.tf diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 0abf26b..6369d78 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -7,7 +7,7 @@ locals { WEBSITES_PORT = var.libre_app_port PORT = var.libre_app_port WEBSITES_CONTAINER_START_TIME_LIMIT = 1500 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" + DOCKER_CUSTOM_IMAGE_NAME = var.libre_app_docker_image ### Server Configuration ### APP_TITLE = var.libre_app_title diff --git a/modules/cdn_frontdoor/README.md b/modules/cdn_frontdoor/README.md deleted file mode 100644 index d507613..0000000 --- a/modules/cdn_frontdoor/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Module: Azure CDN Front Door - -Front solution with an Azure front door (optional) - -- Deploy Azure Front Door CDN. -- Setup a custom domain with AFD managed certificate. -- Optionally create an Azure DNS Zone or use an existing one for the custom domain. (e.g PrivateGPT.mydomain.com) -- Create CNAME and TXT record in the custom DNS zone. -- Setup and apply AFD WAF policy for the front door with allowed IPs custom rule. (Optional) - - -## Requirements - -No requirements. - -## Providers - -| Name | Version | -|------|---------| -| [azurerm](#provider\_azurerm) | n/a | - -## Modules - -No modules. - -## Resources - -| Name | Type | -|------|------| -| [azurerm_cdn_frontdoor_custom_domain.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_custom_domain) | resource | -| [azurerm_cdn_frontdoor_endpoint.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_endpoint) | resource | -| [azurerm_cdn_frontdoor_firewall_policy.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_firewall_policy) | resource | -| [azurerm_cdn_frontdoor_origin.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_origin) | resource | -| [azurerm_cdn_frontdoor_origin_group.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_origin_group) | resource | -| [azurerm_cdn_frontdoor_profile.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_profile) | resource | -| [azurerm_cdn_frontdoor_route.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_route) | resource | -| [azurerm_cdn_frontdoor_security_policy.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cdn_frontdoor_security_policy) | resource | -| [azurerm_dns_cname_record.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/dns_cname_record) | resource | -| [azurerm_dns_txt_record.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/dns_txt_record) | resource | -| [azurerm_dns_zone.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/dns_zone) | resource | -| [azurerm_dns_zone.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/dns_zone) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [cdn\_endpoint](#input\_cdn\_endpoint) | typp = object({
name = (Required) The name of the CDN endpoint to create.
enabled = (Optional) Is the CDN endpoint enabled? Defaults to `true`.
}) |
object({
name = string
enabled = optional(bool, true)
})
|
{
"enabled": true,
"name": "PrivateGPT"
}
| no | -| [cdn\_firewall\_policy](#input\_cdn\_firewall\_policy) | The CDN firewall policies to create. |
object({
create_waf = bool
name = string
enabled = optional(bool, true)
mode = optional(string, "Prevention")
redirect_url = optional(string)
custom_block_response_status_code = optional(number, 403)
custom_block_response_body = optional(string)
custom_rules = optional(list(object({
name = string
action = string
enabled = optional(bool, true)
priority = number
type = string
rate_limit_duration_in_minutes = optional(number, 1)
rate_limit_threshold = optional(number, 10)
match_conditions = list(object({
match_variable = string
match_values = list(string)
operator = string
selector = optional(string)
negation_condition = optional(bool)
transforms = optional(list(string))
}))
})))
})
|
{
"create_waf": true,
"custom_block_response_body": "WW91ciByZXF1ZXN0IGhhcyBiZWVuIGJsb2NrZWQu",
"custom_block_response_status_code": 403,
"custom_rules": [
{
"action": "Block",
"enabled": true,
"match_conditions": [
{
"match_values": [
"10.0.1.0/24",
"10.0.2.0/24"
],
"match_variable": "RemoteAddr",
"negation_condition": null,
"operator": "IPMatch",
"selector": null,
"transforms": []
}
],
"name": "PrivateGPTFirewallPolicyCustomRule",
"priority": 100,
"rate_limit_duration_in_minutes": 1,
"rate_limit_threshold": 10,
"type": "MatchRule"
}
],
"enabled": true,
"mode": "Prevention",
"name": "PrivateGPTFirewallPolicy",
"redirect_url": null
}
| no | -| [cdn\_gpt\_origin](#input\_cdn\_gpt\_origin) | type = object({
name = (Required) The name which should be used for this Front Door Origin. Changing this forces a new Front Door Origin to be created.
origin\_group\_name = (Required) The name of the CDN origin group to associate this origin with.
enabled = (Optional) Is the CDN origin enabled? Defaults to `true`.
certificate\_name\_check\_enabled = (Required) Specifies whether certificate name checks are enabled for this origin. Defaults to `true`.
host\_name = (Required) The IPv4 address, IPv6 address or Domain name of the Origin. (e.g. mysite.example.com)
http\_port = (Optional) The HTTP port of the origin. (e.g. 80)
https\_port = (Optional) The HTTPS port of the origin. (e.g. 443)
origin\_host\_header = (Optional) The origin host header. (e.g. www.mysite.example.com)
priority = (Optional) The priority of the origin. (e.g. 1)
weight = (Optional) The weight of the origin. (e.g. 1000)
}) |
object({
name = string
origin_group_name = string
enabled = optional(bool, true)
certificate_name_check_enabled = optional(bool, true)
host_name = string
http_port = optional(number, 80)
https_port = optional(number, 443)
origin_host_header = optional(string, "www.mysite.example.com")
priority = optional(number, 1)
weight = optional(number, 1000)
})
|
{
"certificate_name_check_enabled": true,
"enabled": true,
"host_name": "mysite.example.com",
"http_port": 80,
"https_port": 443,
"name": "PrivateGPTOrigin",
"origin_group_name": "PrivateGPTOriginGroup",
"origin_host_header": "www.mysite.example.com",
"priority": 1,
"weight": 1000
}
| no | -| [cdn\_origin\_groups](#input\_cdn\_origin\_groups) | type = list(object({
name = (Required) The name of the CDN origin group to create.
session\_affinity\_enabled = (Optional) Is session affinity enabled? Defaults to `false`.
restore\_traffic\_time\_to\_healed\_or\_new\_endpoint\_in\_minutes = (Optional) The time in minutes to restore traffic to a healed or new endpoint. Defaults to `5`.
health\_probe = (Optional) The health probe settings.
type = object({
interval\_in\_seconds = (Optional) The interval in seconds between health probes. Defaults to `100`.
path = (Optional) The path to use for health probes. Defaults to `/`.
protocol = (Optional) The protocol to use for health probes. Possible values include 'Http' and 'Https'. Defaults to `Http`.
request\_type = (Optional) The request type to use for health probes. Possible values include 'GET', 'HEAD', and 'OPTIONS'. Defaults to `HEAD`.
}))
load\_balancing = (Optional) The load balancing settings.
type = object({
additional\_latency\_in\_milliseconds = (Optional) The additional latency in milliseconds for probes to fall into the lowest latency bucket. Defaults to `50`.
sample\_size = (Optional) The number of samples to take for load balancing decisions. Defaults to `4`.
successful\_samples\_required = (Optional) The number of samples within the sample period that must succeed. Defaults to `3`.
}))
})) |
list(object({
name = string
session_affinity_enabled = optional(bool, false)
restore_traffic_time_to_healed_or_new_endpoint_in_minutes = optional(number, 5)
health_probe = optional(object({
interval_in_seconds = optional(number, 100)
path = optional(string, "/")
protocol = optional(string, "Http")
request_type = optional(string, "HEAD")
}))
load_balancing = optional(object({
additional_latency_in_milliseconds = optional(number, 50)
sample_size = optional(number, 4)
successful_samples_required = optional(number, 3)
}))
}))
|
[
{
"health_probe": {
"interval_in_seconds": 100,
"path": "/",
"protocol": "Http",
"request_type": "HEAD"
},
"load_balancing": {
"additional_latency_in_milliseconds": 50,
"sample_size": 4,
"successful_samples_required": 3
},
"name": "PrivateGPTOriginGroup",
"restore_traffic_time_to_healed_or_new_endpoint_in_minutes": 5,
"session_affinity_enabled": false
}
]
| no | -| [cdn\_profile\_name](#input\_cdn\_profile\_name) | The name of the CDN profile to create. | `string` | `"example-cdn-profile"` | no | -| [cdn\_resource\_group\_name](#input\_cdn\_resource\_group\_name) | Name of the resource group to create the CDN Front Door solution resources in. | `string` | `"dns-rg-01"` | no | -| [cdn\_route](#input\_cdn\_route) | type = object({
name = (Required) The name of the CDN route to create.
enabled = (Optional) Is the CDN route enabled? Defaults to `true`.
forwarding\_protocol = (Optional) The protocol this rule will use when forwarding traffic to backends. Possible values include `MatchRequest`, `HttpOnly` and `HttpsOnly`. Defaults to `HttpsOnly`.
https\_redirect\_enabled = (Optional) Is HTTPS redirect enabled? Defaults to `false`.
patterns\_to\_match = (Optional) The list of patterns to match for this rule. Defaults to `["/*"]`.
supported\_protocols = (Optional) The list of supported protocols for this rule. Defaults to `["Http", "Https"]`.
cdn\_frontdoor\_origin\_path = (Optional) The path to use when forwarding traffic to backends. Defaults to `null`.
cdn\_frontdoor\_rule\_set\_ids = (Optional) The list of rule set IDs to associate with this rule. Defaults to `null`.
link\_to\_default\_domain = (Optional) Is the CDN route linked to the default domain? Defaults to `false`.
cache = (Optional) The CDN route cache settings.
type = object({
query\_string\_caching\_behavior = (Required) The query string caching behavior. Possible values include 'IgnoreQueryString', 'BypassCaching', 'UseQueryString', and 'NotSet'. Defaults to 'IgnoreQueryString'.
query\_strings = (Optional) The list of query strings to include or exclude from caching. Defaults to `[]`.
compression\_enabled = (Required) Is compression enabled? Defaults to `false`.
content\_types\_to\_compress = (Optional) The list of content types to compress. Defaults to `[]`.
})
}) |
object({
name = string
enabled = optional(bool, true)
forwarding_protocol = optional(string, "HttpsOnly")
https_redirect_enabled = optional(bool, false)
patterns_to_match = optional(list(string), ["/*"])
supported_protocols = optional(list(string), ["Http", "Https"])
cdn_frontdoor_origin_path = optional(string, null)
cdn_frontdoor_rule_set_ids = optional(list(string), null)
link_to_default_domain = optional(bool, false)
cache = optional(object({
query_string_caching_behavior = string
query_strings = optional(list(string), [])
compression_enabled = bool
content_types_to_compress = optional(list(string), [])
}))
})
|
{
"cache": {
"compression_enabled": false,
"content_types_to_compress": [],
"query_string_caching_behavior": "IgnoreQueryString",
"query_strings": []
},
"cdn_frontdoor_origin_path": null,
"cdn_frontdoor_rule_set_ids": null,
"enabled": true,
"forwarding_protocol": "HttpsOnly",
"https_redirect_enabled": false,
"link_to_default_domain": false,
"name": "PrivateGPTRoute",
"patterns_to_match": [
"/*"
],
"supported_protocols": [
"Http",
"Https"
]
}
| no | -| [cdn\_security\_policy](#input\_cdn\_security\_policy) | type = object({
name = (Required) The name of the CDN security policy to create.
patterns\_to\_match = (Required) The list of patterns to match for this policy. Defaults to `["/*"]`.
}) |
object({
name = string
patterns_to_match = list(string)
})
|
{
"name": "PrivateGPTSecurityPolicy",
"patterns_to_match": [
"/*"
]
}
| no | -| [cdn\_sku\_name](#input\_cdn\_sku\_name) | Specifies the SKU for the CDN Front Door Profile. Possible values include 'Standard\_AzureFrontDoor' and 'Premium\_AzureFrontDoor'. | `string` | `"Standard_AzureFrontDoor"` | no | -| [create\_dns\_zone](#input\_create\_dns\_zone) | Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided. | `bool` | `false` | no | -| [custom\_domain\_config](#input\_custom\_domain\_config) | type = object({
zone\_name = (Required) The name of the DNS zone to create the CNAME and TXT record in for the CDN Front Door Custom domain.
host\_name = (Required) The host name of the DNS record to create. (e.g. Contoso)
ttl = (Optional) The TTL of the DNS record to create. (e.g. 3600)
tls = optional(list(object({
certificate\_type = (Optional) Defines the source of the SSL certificate. Possible values include 'CustomerCertificate' and 'ManagedCertificate'. Defaults to 'ManagedCertificate'.
NOTE: It may take up to 15 minutes for the Front Door Service to validate the state and Domain ownership of the Custom Domain.
minimum\_tls\_version = (Optional) TLS protocol version that will be used for Https. Possible values include TLS10 and TLS12. Defaults to TLS12.
}))))
}) |
object({
zone_name = string
host_name = string
ttl = optional(number, 3600)
tls = optional(list(object({
certificate_type = optional(string, "ManagedCertificate")
minimum_tls_version = optional(string, "TLS12")
})))
})
|
{
"host_name": "PrivateGPT",
"tls": [
{
"certificate_type": "ManagedCertificate",
"minimum_tls_version": "TLS12"
}
],
"ttl": 3600,
"zone_name": "mydomain7335.com"
}
| no | -| [dns\_resource\_group\_name](#input\_dns\_resource\_group\_name) | The name of the resource group to create the DNS zone in / or where the existing zone is hosted. | `string` | `"cdn-rg-01"` | no | -| [tags](#input\_tags) | A map of key value pairs that is used to tag resources created. | `map(string)` | `{}` | no | - -## Outputs - -No outputs. - \ No newline at end of file diff --git a/modules/cdn_frontdoor/data.tf b/modules/cdn_frontdoor/data.tf deleted file mode 100644 index e17ebdc..0000000 --- a/modules/cdn_frontdoor/data.tf +++ /dev/null @@ -1,9 +0,0 @@ -################################################## -# DATA # -################################################## -# Get DNS Zone ID if custom DNS zone already exists -data "azurerm_dns_zone" "gpt" { - count = var.create_dns_zone ? 0 : 1 - name = var.custom_domain_config.zone_name - resource_group_name = var.dns_resource_group_name -} \ No newline at end of file diff --git a/modules/cdn_frontdoor/main.tf b/modules/cdn_frontdoor/main.tf deleted file mode 100644 index 712b65d..0000000 --- a/modules/cdn_frontdoor/main.tf +++ /dev/null @@ -1,187 +0,0 @@ -#Create Custom DNS zone or use existing -resource "azurerm_dns_zone" "gpt" { - count = var.create_dns_zone ? 1 : 0 - name = var.custom_domain_config.zone_name - resource_group_name = var.dns_resource_group_name - tags = var.tags -} - -#Create CDN profile -resource "azurerm_cdn_frontdoor_profile" "gpt" { - name = var.cdn_profile_name - resource_group_name = var.cdn_resource_group_name - sku_name = var.cdn_sku_name #"Standard_AzureFrontDoor" - tags = var.tags -} - -#Create CDN endpoint -resource "azurerm_cdn_frontdoor_endpoint" "gpt" { - name = var.cdn_endpoint.name - cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.gpt.id - enabled = var.cdn_endpoint.enabled - tags = var.tags -} - -#Create CDN origin group -resource "azurerm_cdn_frontdoor_origin_group" "gpt" { - for_each = { for each in var.cdn_origin_groups : each.name => each } - name = each.value.name - cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.gpt.id - session_affinity_enabled = each.value.session_affinity_enabled - restore_traffic_time_to_healed_or_new_endpoint_in_minutes = each.value.restore_traffic_time_to_healed_or_new_endpoint_in_minutes - - dynamic "health_probe" { - for_each = each.value.health_probe != null ? [each.value.health_probe] : [] - content { - interval_in_seconds = each.value.health_probe.interval_in_seconds - path = each.value.health_probe.path - protocol = each.value.health_probe.protocol - request_type = each.value.health_probe.request_type - } - } - - load_balancing { - additional_latency_in_milliseconds = each.value.load_balancing.additional_latency_in_milliseconds - sample_size = each.value.load_balancing.sample_size - successful_samples_required = each.value.load_balancing.successful_samples_required - } -} - -#Create CDN origin -resource "azurerm_cdn_frontdoor_origin" "gpt" { - name = var.cdn_gpt_origin.name - cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.gpt[var.cdn_gpt_origin.origin_group_name].id - enabled = var.cdn_gpt_origin.enabled - certificate_name_check_enabled = var.cdn_gpt_origin.certificate_name_check_enabled - host_name = var.cdn_gpt_origin.host_name - http_port = var.cdn_gpt_origin.http_port - https_port = var.cdn_gpt_origin.https_port - origin_host_header = var.cdn_gpt_origin.origin_host_header - priority = var.cdn_gpt_origin.priority - weight = var.cdn_gpt_origin.weight -} - -#Create CDN custom domain -resource "azurerm_cdn_frontdoor_custom_domain" "gpt" { - name = var.custom_domain_config.host_name - cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.gpt.id - dns_zone_id = var.create_dns_zone ? azurerm_dns_zone.gpt[0].id : data.azurerm_dns_zone.gpt[0].id - host_name = "${var.custom_domain_config.host_name}.${var.custom_domain_config.zone_name}" - - dynamic "tls" { - for_each = length(var.custom_domain_config.tls) > 0 ? { for each in var.custom_domain_config.tls : each.certificate_type => each } : {} - content { - certificate_type = tls.value.certificate_type - minimum_tls_version = tls.value.minimum_tls_version - } - } -} - -#create CDN route -resource "azurerm_cdn_frontdoor_route" "gpt" { - name = var.cdn_route.name - enabled = var.cdn_route.enabled - cdn_frontdoor_endpoint_id = azurerm_cdn_frontdoor_endpoint.gpt.id - cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.gpt[var.cdn_gpt_origin.origin_group_name].id - cdn_frontdoor_origin_ids = [azurerm_cdn_frontdoor_origin.gpt.id] - forwarding_protocol = var.cdn_route.forwarding_protocol - https_redirect_enabled = var.cdn_route.https_redirect_enabled - patterns_to_match = var.cdn_route.patterns_to_match - supported_protocols = var.cdn_route.supported_protocols - cdn_frontdoor_custom_domain_ids = [azurerm_cdn_frontdoor_custom_domain.gpt.id] - cdn_frontdoor_origin_path = var.cdn_route.cdn_frontdoor_origin_path - cdn_frontdoor_rule_set_ids = var.cdn_route.cdn_frontdoor_rule_set_ids - link_to_default_domain = var.cdn_route.link_to_default_domain - - dynamic "cache" { - for_each = var.cdn_route.cache != null ? [var.cdn_route.cache] : [] - content { - query_string_caching_behavior = cache.value.query_string_caching_behavior - query_strings = cache.value.query_strings - compression_enabled = cache.value.compression_enabled - content_types_to_compress = cache.value.content_types_to_compress - } - } -} - -# Create a CNAME record in the custom DNS zone. -resource "azurerm_dns_cname_record" "gpt" { - name = var.custom_domain_config.host_name - zone_name = var.create_dns_zone ? azurerm_dns_zone.gpt[0].name : data.azurerm_dns_zone.gpt[0].name - resource_group_name = var.dns_resource_group_name - ttl = var.custom_domain_config.ttl - record = azurerm_cdn_frontdoor_endpoint.gpt.host_name - depends_on = [azurerm_cdn_frontdoor_route.gpt] -} - -# Create a TXT record in the custom DNS zone. -resource "azurerm_dns_txt_record" "gpt" { - name = join(".", ["_dnsauth", "${var.custom_domain_config.host_name}"]) - zone_name = var.create_dns_zone ? azurerm_dns_zone.gpt[0].name : data.azurerm_dns_zone.gpt[0].name - resource_group_name = var.dns_resource_group_name - ttl = var.custom_domain_config.ttl - - record { - value = azurerm_cdn_frontdoor_custom_domain.gpt.validation_token - } - depends_on = [azurerm_cdn_frontdoor_route.gpt] -} - -# Create WAF Firewall Policy -resource "azurerm_cdn_frontdoor_firewall_policy" "gpt" { - count = var.cdn_firewall_policy.create_waf == true ? 1 : 0 - name = var.cdn_firewall_policy.name - resource_group_name = var.cdn_resource_group_name - sku_name = var.cdn_sku_name - enabled = var.cdn_firewall_policy.enabled - mode = var.cdn_firewall_policy.mode - redirect_url = var.cdn_firewall_policy.redirect_url - custom_block_response_status_code = var.cdn_firewall_policy.custom_block_response_status_code - custom_block_response_body = var.cdn_firewall_policy.custom_block_response_body - - dynamic "custom_rule" { - for_each = var.cdn_firewall_policy.custom_rules - content { - name = custom_rule.value.name - enabled = custom_rule.value.enabled - priority = custom_rule.value.priority - rate_limit_duration_in_minutes = custom_rule.value.rate_limit_duration_in_minutes - rate_limit_threshold = custom_rule.value.rate_limit_threshold - type = custom_rule.value.type - action = custom_rule.value.action - - dynamic "match_condition" { - for_each = custom_rule.value.match_conditions - content { - match_variable = match_condition.value.match_variable - match_values = match_condition.value.match_values - operator = match_condition.value.operator - selector = match_condition.value.selector - negation_condition = match_condition.value.negation_condition - transforms = match_condition.value.transforms - } - } - } - } - - tags = var.tags -} - -resource "azurerm_cdn_frontdoor_security_policy" "gpt" { - count = var.cdn_firewall_policy.create_waf == true ? 1 : 0 - name = var.cdn_security_policy.name - cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.gpt.id - - security_policies { - firewall { - cdn_frontdoor_firewall_policy_id = azurerm_cdn_frontdoor_firewall_policy.gpt[0].id - - association { - domain { - cdn_frontdoor_domain_id = azurerm_cdn_frontdoor_custom_domain.gpt.id - } - patterns_to_match = var.cdn_security_policy.patterns_to_match - } - } - } -} \ No newline at end of file diff --git a/modules/cdn_frontdoor/outputs.tf b/modules/cdn_frontdoor/outputs.tf deleted file mode 100644 index e69de29..0000000 diff --git a/modules/cdn_frontdoor/variables.tf b/modules/cdn_frontdoor/variables.tf deleted file mode 100644 index c27ab50..0000000 --- a/modules/cdn_frontdoor/variables.tf +++ /dev/null @@ -1,323 +0,0 @@ -# common vars # -variable "cdn_resource_group_name" { - type = string - description = "Name of the resource group to create the CDN Front Door solution resources in." - nullable = false - default = "dns-rg-01" -} - -variable "tags" { - type = map(string) - default = {} - description = "A map of key value pairs that is used to tag resources created." -} - -# DNS zone # -variable "create_dns_zone" { - description = "Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided." - type = bool - default = false -} - -variable "dns_resource_group_name" { - description = "The name of the resource group to create the DNS zone in / or where the existing zone is hosted." - type = string - nullable = false - default = "cdn-rg-01" -} - -variable "custom_domain_config" { - type = object({ - zone_name = string - host_name = string - ttl = optional(number, 3600) - tls = optional(list(object({ - certificate_type = optional(string, "ManagedCertificate") - minimum_tls_version = optional(string, "TLS12") - }))) - }) - default = { - zone_name = "mydomain7335.com" - host_name = "PrivateGPT" - ttl = 3600 - tls = [{ - certificate_type = "ManagedCertificate" - minimum_tls_version = "TLS12" - }] - } - description = <<-DESCRIPTION - type = object({ - zone_name = (Required) The name of the DNS zone to create the CNAME and TXT record in for the CDN Front Door Custom domain. - host_name = (Required) The host name of the DNS record to create. (e.g. Contoso) - ttl = (Optional) The TTL of the DNS record to create. (e.g. 3600) - tls = optional(list(object({ - certificate_type = (Optional) Defines the source of the SSL certificate. Possible values include 'CustomerCertificate' and 'ManagedCertificate'. Defaults to 'ManagedCertificate'. - NOTE: It may take up to 15 minutes for the Front Door Service to validate the state and Domain ownership of the Custom Domain. - minimum_tls_version = (Optional) TLS protocol version that will be used for Https. Possible values include TLS10 and TLS12. Defaults to TLS12. - })))) - }) - DESCRIPTION -} - - -# Front Door # -variable "cdn_profile_name" { - description = "The name of the CDN profile to create." - type = string - default = "example-cdn-profile" -} - -variable "cdn_sku_name" { - description = "Specifies the SKU for the CDN Front Door Profile. Possible values include 'Standard_AzureFrontDoor' and 'Premium_AzureFrontDoor'." - type = string - default = "Standard_AzureFrontDoor" -} - -variable "cdn_endpoint" { - type = object({ - name = string - enabled = optional(bool, true) - }) - default = { - name = "PrivateGPT" - enabled = true - } - description = < - - \ No newline at end of file diff --git a/modules/librechat_app/main.tf b/modules/librechat_app/main.tf deleted file mode 100644 index c321f6b..0000000 --- a/modules/librechat_app/main.tf +++ /dev/null @@ -1,378 +0,0 @@ -resource "azurerm_service_plan" "openai" { - name = var.app_service_name - location = var.location - resource_group_name = var.app_resource_group_name - os_type = "Linux" - sku_name = var.app_service_sku_name -} - -resource "azurerm_linux_web_app" "openai" { - name = var.app_name - location = var.location - resource_group_name = var.app_resource_group_name - service_plan_id = azurerm_service_plan.openai.id - public_network_access_enabled = var.public_network_access_enabled - https_only = true - - site_config { - minimum_tls_version = "1.2" - } - - logs { - http_logs { - file_system { - retention_in_days = 7 - retention_in_mb = 35 - } - } - application_logs { - file_system_level = "Information" - } - } - - app_settings = { - #==================================================# - # Server Configuration # - #==================================================# - APP_TITLE = var.app_title - CUSTOM_FOOTER = var.app_custom_footer - HOST = var.app_host - PORT = var.app_port - MONGO_URI = "" - DOMAIN_CLIENT = "http://localhost:3080" - DOMAIN_SERVER = "http://localhost:3080" - - #===============# - # Debug Logging # - #===============# - DEBUG_LOGGING = true - DEBUG_CONSOLE = false - - #=============# - # Permissions # - #=============# - # UID=1000 - # GID=1000 - - #===================================================# - # Endpoints # - #===================================================# - ENDPOINTS = "azureOpenAI" #openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic - # PROXY= - - #============# - # Anthropic # - #============# - # ANTHROPIC_API_KEY = "user_provided" - # ANTHROPIC_MODELS = "claude-1,claude-instant-1,claude-2" - # ANTHROPIC_REVERSE_PROXY= - - #============# - # Azure # - #============# - AZURE_API_KEY = "" - AZURE_OPENAI_MODELS = "gpt-4-1106-preview,gpt-4,gpt-3.5-turbo,gpt-3.5-turbo-1106,gpt-4-vision-preview" - # AZURE_OPENAI_DEFAULT_MODEL = "gpt-3.5-turbo" - # PLUGINS_USE_AZURE = true - - AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true - AZURE_OPENAI_API_INSTANCE_NAME = "gpt9000" - # AZURE_OPENAI_API_DEPLOYMENT_NAME = - AZURE_OPENAI_API_VERSION = "2023-07-01-preview" - # AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = - # AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = - - #============# - # BingAI # - #============# - #BINGAI_TOKEN = var.bingai_token - # BINGAI_HOST = "https://cn.bing.com" - - #============# - # ChatGPT # - #============# - #CHATGPT_TOKEN = var.chatgpt_token - #CHATGPT_MODELS = "text-davinci-002-render-sha" - # CHATGPT_REVERSE_PROXY = "" - - #============# - # Google # - #============# - #GOOGLE_KEY = "user_provided" - # GOOGLE_MODELS="gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k" - # GOOGLE_REVERSE_PROXY= "" - - #============# - # OpenAI # - #============# - # OPENAI_API_KEY = var.openai_key - # OPENAI_MODELS = "gpt-3.5-turbo-1106,gpt-4-1106-preview,gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,text-davinci-003,gpt-4,gpt-4-0314,gpt-4-0613" - #DEBUG_OPENAI = false - # TITLE_CONVO = false - # OPENAI_TITLE_MODEL = "gpt-3.5-turbo" - # OPENAI_SUMMARIZE = true - # OPENAI_SUMMARY_MODEL = "gpt-3.5-turbo" - # OPENAI_FORCE_PROMPT = true - # OPENAI_REVERSE_PROXY = "" - - #============# - # OpenRouter # - #============# - # OPENROUTER_API_KEY = - - #============# - # Plugins # - #============# - # PLUGIN_MODELS = "gpt-3.5-turbo,gpt-3.5-turbo-16k,gpt-3.5-turbo-0301,gpt-4,gpt-4-0314,gpt-4-0613" - DEBUG_PLUGINS = true - CREDS_KEY = "dfsdgdsffgdsfgds" - CREDS_IV = "dfsdgdsffgdsfgds" - - # Azure AI Search - #----------------- - # AZURE_AI_SEARCH_SERVICE_ENDPOINT= - # AZURE_AI_SEARCH_INDEX_NAME= - # AZURE_AI_SEARCH_API_KEY= - # AZURE_AI_SEARCH_API_VERSION= - # AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE= - # AZURE_AI_SEARCH_SEARCH_OPTION_TOP= - # AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= - - # DALL·E 3 - #---------------- - # DALLE_API_KEY= - # DALLE3_SYSTEM_PROMPT="Your System Prompt here" - # DALLE_REVERSE_PROXY= - - # Google - #----------------- - # GOOGLE_API_KEY= - # GOOGLE_CSE_ID= - - # SerpAPI - #----------------- - # SERPAPI_API_KEY= - - # Stable Diffusion - #----------------- - # SD_WEBUI_URL=http://host.docker.internal:7860 - - # WolframAlpha - #----------------- - # WOLFRAM_APP_ID= - - # Zapier - #----------------- - # ZAPIER_NLA_API_KEY= - - #==================================================# - # Search # - #==================================================# - SEARCH = true - MEILI_NO_ANALYTICS = true - MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" - # MEILI_HTTP_ADDR=0.0.0.0:7700 - MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" - - #===================================================# - # User System # - #===================================================# - - #========================# - # Moderation # - #========================# - BAN_VIOLATIONS = true - BAN_DURATION = 1000 * 60 * 60 * 2 - BAN_INTERVAL = 20 - - LOGIN_VIOLATION_SCORE = 1 - REGISTRATION_VIOLATION_SCORE = 1 - CONCURRENT_VIOLATION_SCORE = 1 - MESSAGE_VIOLATION_SCORE = 1 - NON_BROWSER_VIOLATION_SCORE = 20 - - LOGIN_MAX = 7 - LOGIN_WINDOW = 5 - REGISTER_MAX = 5 - REGISTER_WINDOW = 60 - - LIMIT_CONCURRENT_MESSAGES = true - CONCURRENT_MESSAGE_MAX = 2 - - LIMIT_MESSAGE_IP = true - MESSAGE_IP_MAX = 40 - MESSAGE_IP_WINDOW = 1 - - LIMIT_MESSAGE_USER = false - MESSAGE_USER_MAX = 40 - MESSAGE_USER_WINDOW = 1 - - #========================# - # Balance # - #========================# - CHECK_BALANCE = false - - #========================# - # Registration and Login # - #========================# - ALLOW_EMAIL_LOGIN = true - ALLOW_REGISTRATION = true - ALLOW_SOCIAL_LOGIN = false - ALLOW_SOCIAL_REGISTRATION = false - - SESSION_EXPIRY = 1000 * 60 * 15 - REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 - - JWT_SECRET = "dfsdgdsffgdsfgds" - JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" - - # Discord - # DISCORD_CLIENT_ID= - # DISCORD_CLIENT_SECRET= - # DISCORD_CALLBACK_URL=/oauth/discord/callback - - # Facebook - # FACEBOOK_CLIENT_ID= - # FACEBOOK_CLIENT_SECRET= - # FACEBOOK_CALLBACK_URL=/oauth/facebook/callback - - # GitHub - # GITHUB_CLIENT_ID= - # GITHUB_CLIENT_SECRET= - # GITHUB_CALLBACK_URL=/oauth/github/callback - - # Google - # GOOGLE_CLIENT_ID= - # GOOGLE_CLIENT_SECRET= - # GOOGLE_CALLBACK_URL=/oauth/google/callback - - # OpenID - # OPENID_CLIENT_ID= - # OPENID_CLIENT_SECRET= - # OPENID_ISSUER= - # OPENID_SESSION_SECRET= - # OPENID_SCOPE="openid profile email" - # OPENID_CALLBACK_URL=/oauth/openid/callback - - # OPENID_BUTTON_LABEL= - # OPENID_IMAGE_URL= - - #========================# - # Email Password Reset # - #========================# - - # EMAIL_SERVICE= - # EMAIL_HOST= - # EMAIL_PORT=25 - # EMAIL_ENCRYPTION= - # EMAIL_ENCRYPTION_HOSTNAME= - # EMAIL_ALLOW_SELFSIGNED= - # EMAIL_USERNAME= - # EMAIL_PASSWORD= - # EMAIL_FROM_NAME= - # EMAIL_FROM=noreply@librechat.ai - - #==================================================# - # Others # - #==================================================# - # You should leave the following commented out # - - # NODE_ENV= - - # REDIS_URI= - # USE_REDIS= - - # E2E_USER_EMAIL= - # E2E_USER_PASSWORD= - - #=============================================================# - # Azure App Service Configuration # - #=============================================================# - - WEBSITE_RUN_FROM_PACKAGE = "1" - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" - WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - DOCKER_ENABLE_CI = false - WEBSITES_PORT = 80 - PORT = 80 - DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" - NODE_ENV = "production" - } - virtual_network_subnet_id = "/subscriptions/829efd7e-aa80-4c0d-9c1c-7aa2557f8e07/resourceGroups/TF-Module-Automated-Tests-Cognitive-GPT/providers/Microsoft.Network/virtualNetworks/openai-vnet2698/subnets/app-cosmos-sub" - - depends_on = [azurerm_linux_web_app.meilisearch] - # depends_on = [azurerm_linux_web_app.meilisearch] -} - -# Deploy code from a public GitHub repo -# resource "azurerm_app_service_source_control" "sourcecontrol" { -# app_id = azurerm_linux_web_app.librechat.id -# repo_url = "https://github.com/danny-avila/LibreChat" -# branch = "main" -# type = "Github" - -# # use_manual_integration = true -# # use_mercurial = false -# depends_on = [ -# azurerm_linux_web_app.librechat, -# ] -# } - -# resource "azurerm_app_service_virtual_network_swift_connection" "librechat" { -# app_service_id = azurerm_linux_web_app.librechat.id -# subnet_id = module.vnet.vnet_subnets_name_id["subnet0"] - -# depends_on = [ -# azurerm_linux_web_app.librechat, -# module.vnet -# ] -# } - -#TODO: privately communicate between librechat and meilisearch, right now it is via public internet -resource "azurerm_linux_web_app" "meilisearch" { - name = "meilisearchapp453454345" - location = var.location - resource_group_name = var.app_resource_group_name - service_plan_id = azurerm_service_plan.openai.id - - app_settings = { - WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - - MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" - MEILI_NO_ANALYTICS = true - - DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" - WEBSITES_ENABLE_APP_SERVICE_STORAGE = false - DOCKER_ENABLE_CI = false - WEBSITES_PORT = 7700 - PORT = 7700 - DOCKER_CUSTOM_IMAGE_NAME = "getmeili/meilisearch:latest" - } - - site_config { - always_on = "true" - ip_restriction { - virtual_network_subnet_id = "/subscriptions/829efd7e-aa80-4c0d-9c1c-7aa2557f8e07/resourceGroups/TF-Module-Automated-Tests-Cognitive-GPT/providers/Microsoft.Network/virtualNetworks/openai-vnet2698/subnets/app-cosmos-sub" - priority = 100 - name = "Allow from LibreChat subnet" - action = "Allow" - } - } - - logs { - http_logs { - file_system { - retention_in_days = 7 - retention_in_mb = 35 - } - } - application_logs { - file_system_level = "Information" - } - } - - # identity { - # type = "SystemAssigned" - # } - -} \ No newline at end of file diff --git a/modules/librechat_app/outputs.tf b/modules/librechat_app/outputs.tf deleted file mode 100644 index e69de29..0000000 diff --git a/modules/librechat_app/variables.tf b/modules/librechat_app/variables.tf deleted file mode 100644 index 5070317..0000000 --- a/modules/librechat_app/variables.tf +++ /dev/null @@ -1,86 +0,0 @@ -variable "app_resource_group_name" { - type = string - description = "Name of the resource group to where networking resources will be hosted." - nullable = false -} - -variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." -} - -variable "tags" { - type = map(string) - default = { - Terraform = "True" - Description = "OpenAI App Resource." - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" - } - description = "A map of key value pairs that is used to tag resources created." -} - -variable "app_service_name" { - type = string - description = "Name of the App Service." - default = "openai-asp" -} - -variable "app_service_sku_name" { - type = string - description = "The SKU name of the App Service Plan." - default = "B1" -} - -variable "app_name" { - type = string - description = "Name of the App." - default = "openai-app" -} - -variable "public_network_access_enabled " { - type = bool - description = "Whether or not public network access is allowed for this App Service." - default = false -} - -### App Settings ### -## Server Configuration ## -variable "app_title" { - type = string - description = "Title of the App." - default = "PrivateGPT" -} - -variable "app_custom_footer" { - type = string - description = "Custom footer for the App." - default = "Privately hosted chat app powered by Azure OpenAI" -} - -variable "app_host" { - type = string - description = "The server will listen to localhost:3080 by default. You can change the target IP as you want. If you want to make this server available externally, for example to share the server with others or expose this from a Docker container, set host to 0.0.0.0 or your external IP interface. Setting host to 0.0.0.0 means listening on all interfaces. It's not a real IP." - default = "0.0.0.0" -} - -variable "app_port" { - type = number - description = "The port to listen on." - default = 80 -} - -variable "public_network_access_enabled" { - type = bool - description = "Whether or not public network access is allowed for this App Service." - default = false -} - -###31 -variable "mongodb_connection_string" { - type = string - description = "Connection string to the MongoDB database." - sensitive = true -} - diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 1dd7c28..6cb68ee 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -105,6 +105,7 @@ module "private-chatgpt-openai" { libre_app_mongo_uri = var.libre_app_mongo_uri libre_app_domain_client = var.libre_app_domain_client libre_app_domain_server = var.libre_app_domain_server + libre_app_docker_image = var.libre_app_docker_image # Debug Config libre_app_debug_logging = var.libre_app_debug_logging diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index c1edf70..8ba580b 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -108,7 +108,7 @@ cosmosdb_public_network_access_enabled = true ### 06 app services (librechat app + meilisearch) ### # App Service Plan app_service_name = "openaiasp" -app_service_sku_name = "B2" +app_service_sku_name = "B1" # Meilisearch App #meilisearch_app_name = "meilisearchapp" @@ -130,6 +130,7 @@ libre_app_port = 80 libre_app_mongo_uri = null libre_app_domain_client = "http://localhost:80" libre_app_domain_server = "http://localhost:80" +libre_app_docker_image = "ghcr.io/danny-avila/librechat-dev-api:latest" # debug logging libre_app_debug_logging = true diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index f8744b0..cac8430 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -441,6 +441,12 @@ variable "libre_app_domain_server" { default = "http://localhost:3080" } +variable "libre_app_docker_image" { + type = string + description = "The Docker Image to use for the App Service." + default = "ghcr.io/danny-avila/librechat-dev-api:latest" +} + # Debug logging variable "libre_app_debug_logging" { type = bool diff --git a/variables.tf b/variables.tf index 2d32914..7eb99ac 100644 --- a/variables.tf +++ b/variables.tf @@ -446,6 +446,12 @@ variable "libre_app_domain_server" { default = "http://localhost:3080" } +variable "libre_app_docker_image" { + type = string + description = "The Docker Image to use for the App Service." + default = "ghcr.io/danny-avila/librechat-dev-api:latest" +} + # Debug logging variable "libre_app_debug_logging" { type = bool From 0764931c5ea85f6286b8dc297a3425050c621602 Mon Sep 17 00:00:00 2001 From: Pwd9000-ML Date: Mon, 22 Jan 2024 23:54:18 +0000 Subject: [PATCH 162/163] up --- 06_librechat_app.tf | 13 - 06_librechat_app_config.tf | 4 +- 07_test.tf | 88 --- README.md | 58 +- assets/chatbotui1.png | Bin 65455 -> 0 bytes assets/chatbotui2.png | Bin 85309 -> 0 bytes assets/coming_soon.md | 0 assets/mainflow1.png | Bin 307579 -> 0 bytes data.tf | 22 - examples/Coming_soon.md | 0 .../README.md | 112 ---- .../common.auto.tfvars | 239 ------- .../data.tf | 5 - .../locals.tf | 12 - .../main.tf | 94 --- .../variables.tf | 630 ------------------ .../README.md | 112 ---- .../common.auto.tfvars | 239 ------- .../PrivateGPT_w_AFD_WAF_new_DNS_zone/data.tf | 5 - .../locals.tf | 12 - .../PrivateGPT_w_AFD_WAF_new_DNS_zone/main.tf | 94 --- .../variables.tf | 630 ------------------ examples/PrivateGPT_without_AFD_WAF/README.md | 94 --- .../common.auto.tfvars | 132 ---- examples/PrivateGPT_without_AFD_WAF/data.tf | 5 - examples/PrivateGPT_without_AFD_WAF/locals.tf | 12 - examples/PrivateGPT_without_AFD_WAF/main.tf | 82 --- .../PrivateGPT_without_AFD_WAF/variables.tf | 330 --------- main.tf | 92 --- tests/auto_test1/data.tf | 11 - tests/auto_test1/locals.tf | 50 -- tests/auto_test1/main.tf | 88 +-- tests/auto_test1/testing.auto.tfvars | 6 +- tests/auto_test1/variables.tf | 32 +- variables.tf | 528 +-------------- 35 files changed, 52 insertions(+), 3779 deletions(-) delete mode 100644 07_test.tf delete mode 100644 assets/chatbotui1.png delete mode 100644 assets/chatbotui2.png create mode 100644 assets/coming_soon.md delete mode 100644 assets/mainflow1.png create mode 100644 examples/Coming_soon.md delete mode 100644 examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/README.md delete mode 100644 examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/common.auto.tfvars delete mode 100644 examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/data.tf delete mode 100644 examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/locals.tf delete mode 100644 examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/main.tf delete mode 100644 examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/variables.tf delete mode 100644 examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/README.md delete mode 100644 examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/common.auto.tfvars delete mode 100644 examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/data.tf delete mode 100644 examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/locals.tf delete mode 100644 examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/main.tf delete mode 100644 examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/variables.tf delete mode 100644 examples/PrivateGPT_without_AFD_WAF/README.md delete mode 100644 examples/PrivateGPT_without_AFD_WAF/common.auto.tfvars delete mode 100644 examples/PrivateGPT_without_AFD_WAF/data.tf delete mode 100644 examples/PrivateGPT_without_AFD_WAF/locals.tf delete mode 100644 examples/PrivateGPT_without_AFD_WAF/main.tf delete mode 100644 examples/PrivateGPT_without_AFD_WAF/variables.tf delete mode 100644 main.tf delete mode 100644 tests/auto_test1/data.tf delete mode 100644 tests/auto_test1/locals.tf diff --git a/06_librechat_app.tf b/06_librechat_app.tf index 31c114e..8b6505f 100644 --- a/06_librechat_app.tf +++ b/06_librechat_app.tf @@ -150,19 +150,6 @@ resource "azurerm_role_assignment" "librechat_app_kv_access" { #TODO: Implement DALL-E #TODO: -# # Deploy code from a public GitHub repo -# # resource "azurerm_app_service_source_control" "sourcecontrol" { -# # app_id = azurerm_linux_web_app.librechat.id -# # repo_url = "https://github.com/danny-avila/LibreChat" -# # branch = "main" -# # type = "Github" - -# # # use_manual_integration = true -# # # use_mercurial = false -# # depends_on = [ -# # azurerm_linux_web_app.librechat, -# # ] -# # } # Implement a Search (either Meili or Azure AI Search) # # Generate random strings as keys for meilisearch and librechat (Stored securely in Azure Key Vault) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index 6369d78..b212ae1 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -37,8 +37,8 @@ locals { DEBUG_PLUGINS = var.libre_app_debug_plugins CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" - PLUGIN_MODELS = var.libre_app_plugin_models - PLUGINS_USE_AZURE = var.libre_app_plugins_use_azure + #PLUGIN_MODELS = var.libre_app_plugin_models + #PLUGINS_USE_AZURE = var.libre_app_plugins_use_azure ### Azure OpenAI DALL-E-3 Plugin (Only in 'SwedenCentral' and 'EastUS') ### DALLE3_AZURE_API_VERSION = var.libre_app_az_oai_dall3_api_version diff --git a/07_test.tf b/07_test.tf deleted file mode 100644 index 9e90ae1..0000000 --- a/07_test.tf +++ /dev/null @@ -1,88 +0,0 @@ -# locals { -# libre_app_settings = { - - -# #==================================================# -# # Server Configuration # -# #==================================================# -# APP_TITLE = "test" -# CUSTOM_FOOTER = "test" -# HOST = "0.0.0.0" -# PORT = 80 -# MONGO_URI = "" -# DOMAIN_CLIENT = "http://localhost:3080" -# DOMAIN_SERVER = "http://localhost:3080" - -# DEBUG_LOGGING = true -# DEBUG_CONSOLE = false - -# ENDPOINTS = "azureOpenAI" #openAI,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic - -# AZURE_API_KEY = "" -# AZURE_OPENAI_MODELS = "gpt-4-1106-Preview,gpt-4-vision-preview" - -# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = true -# AZURE_OPENAI_API_INSTANCE_NAME = "gptopenai2698" - -# AZURE_OPENAI_API_VERSION = "2023-07-01-preview" - -# DEBUG_PLUGINS = true -# CREDS_KEY = "dfsdgdsffgdsfgds" -# CREDS_IV = "dfsdgdsffgdsfgds" - -# SEARCH = true -# MEILI_NO_ANALYTICS = true -# MEILI_HOST = "${azurerm_linux_web_app.meilisearch.name}.azurewebsites.net" -# MEILI_MASTER_KEY = "dfsdgdsffgdsfgds" - -# BAN_VIOLATIONS = true -# BAN_DURATION = 1000 * 60 * 60 * 2 -# BAN_INTERVAL = 20 - -# LOGIN_VIOLATION_SCORE = 1 -# REGISTRATION_VIOLATION_SCORE = 1 -# CONCURRENT_VIOLATION_SCORE = 1 -# MESSAGE_VIOLATION_SCORE = 1 -# NON_BROWSER_VIOLATION_SCORE = 20 - -# LOGIN_MAX = 7 -# LOGIN_WINDOW = 5 -# REGISTER_MAX = 5 -# REGISTER_WINDOW = 60 - -# LIMIT_CONCURRENT_MESSAGES = true -# CONCURRENT_MESSAGE_MAX = 2 - -# LIMIT_MESSAGE_IP = true -# MESSAGE_IP_MAX = 40 -# MESSAGE_IP_WINDOW = 1 - -# LIMIT_MESSAGE_USER = false -# MESSAGE_USER_MAX = 40 -# MESSAGE_USER_WINDOW = 1 - - -# CHECK_BALANCE = false - - -# ALLOW_EMAIL_LOGIN = true -# ALLOW_REGISTRATION = true -# ALLOW_SOCIAL_LOGIN = false -# ALLOW_SOCIAL_REGISTRATION = false - -# SESSION_EXPIRY = 1000 * 60 * 15 -# REFRESH_TOKEN_EXPIRY = (1000 * 60 * 60 * 24) * 7 - -# JWT_SECRET = "dfsdgdsffgdsfgds" -# JWT_REFRESH_SECRET = "dfsdgdsffgdsfgds" - -# WEBSITE_RUN_FROM_PACKAGE = "1" -# DOCKER_REGISTRY_SERVER_URL = "https://index.docker.io" -# WEBSITES_ENABLE_APP_SERVICE_STORAGE = false -# DOCKER_ENABLE_CI = false -# WEBSITES_PORT = 80 -# # PORT = 80 -# DOCKER_CUSTOM_IMAGE_NAME = "ghcr.io/danny-avila/librechat-dev-api:latest" -# NODE_ENV = "production" -# } -# } \ No newline at end of file diff --git a/README.md b/README.md index a651059..a39cd62 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ # Module: Azure OpenAI Private ChatGPT -**NOTE:** Your Azure subscription will need to be whitelisted for **Azure Open AI**. At the release time of this module (August 2023) you will need to request access via this **[form](https://aka.ms/oai/access)** and a further form for **[GPT 4](https://customervoice.microsoft.com/Pages/ResponsePage.aspx?id=v4j5cvGGr0GRqy180BHbR7en2Ais5pxKtso_Pz4b1_xURjE4QlhVUERGQ1NXOTlNT0w1NldTWjJCMSQlQCN0PWcu)**. Once you have access deploy either **GPT-35-Turbo**, **GPT-35-Turbo-16k** or if you have access to **GPT-4-32k**, go forward with that model. +## Legacy Version 1.x -Easily construct a ChatGPT-style interface using Azure OpenAI and a suite of Azure services. Simplifying the complex. +**NOTE:** This module is now in version **2.x**. The legacy version **1.x** can be found in the legacy branch **[here](https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt/tree/legacy-v1) +This version is a complete rewrite of the module and is not backwards compatible with version 1.x. +New integrations and features have been added to the module to use the latest **Azure OpenAI** services and features such as `GPT-4-1106`, `GPT-4-Vision` and `DALL-E-3`. A new ChatBot UI / [LibreChat](https://docs.librechat.ai/index.html) has been added to the module to provide a complete solution. -![image.png](https://raw.githubusercontent.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt/master/assets/chatbotui1.png) + ## Introduction @@ -21,63 +23,21 @@ For a deeper dive, refer to this [Microsoft Learn article](https://learn.microso While Azure OpenAI does come with a cost, it's highly affordable—often, a conversation costs under 10 cents. You can review Azure [OpenAI's pricing](https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/) details here. -This terraform module helps establishing a secure **ChatGPT-like** interface. This uses Azure OpenAI, combined with an array of Azure's other services, such as **Azure Container App**, **Azure Front Door / CDN**, **Web Application Firewall**, **Key Vault** and **Azure DNS**, ensuring a confidential and dedicated ChatGPT experience for you. - ## Diagram -![image.png](https://raw.githubusercontent.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt/master/assets/mainflow1.png) +coming soon... ## Description -This flexible terraform module is an **OpenAI accelerator** that can be used to deploy a privately hosted instance of a **ChatBot** similar to **ChatGPT** hosted on Azure using **Azure Container Apps**, **Azure OpenAI** and optionally fronted by **Azure CDN/Front Door** with a **WAF / Firewall** and custom allowed IP list. - -## This module can be used to create the following - -### Create OpenAI Service - -1. Create an Azure Key Vault to store the OpenAI account details. -2. Create an OpenAI service account. - Other options include: - - Specify an already existing OpenAI service account to use. - -3. Create OpenAI language model deployments on the OpenAI service. (e.g. GPT-3, GPT-4, etc.) -4. Store the OpenAI account and model details in the key vault for consumption. - -### Create a container app ChatBot UI linked with OpenAI service hosted in Azure - -1. Create a container app log analytics workspace (to link with container app). -2. Create a container app environment. -3. Create a container app instance hosting chatbot-ui from image/container. -4. Link chatbot-ui with corresponding OpenAI account and language model deployment. -5. Grant the container app access to the key vault to retrieve secrets (optional). - -### Front solution with an Azure front door (optional) - -1. Deploy Azure Front Door to front solution with CDN + WAF. -2. Setup a custom domain in Azure Front Door with AFD managed certificate. - Other options include: - - This example specifies an already existing DNZ zone to use. (e.g. `existingzone.com` - see `common.auto.tfvars`) - - **Note:** Remember to add the zone to your DNS registrar as the module creates a TXT auth. (Certificates fully managed by AFD) - -3. Create a CNAME and TXT record in the custom DNS zone. (e.g. `privategpt.existingzone.com`) -4. Setup and apply an AFD WAF policy with `IPAllow list` for allowed IPs to connect using a custom rule. +coming soon... ## ChatBot Demo -![image.png](https://raw.githubusercontent.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt/master/assets/chatbotui2.png) +coming soon... ## Examples -See **[Private ChatGPT with Azure Front Door + Firewall on existing DNS zone](https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt/tree/master/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone):** -For an example of how to create a Privately hosted instance of ChatBot/ChatGPT on Azure OpenAI with AFD + WAF using an existing DNS zone for the custom domain configuration. - -See **[Private ChatGPT with Azure Front Door + Firewall on new DNS zone](https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt/tree/master/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone):** -For an example of how to create a Privately hosted instance of ChatBot/ChatGPT on Azure OpenAI with AFD + WAF using a new DNS zone for the custom domain configuration. - -See **[Private ChatGPT instance only](https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt/tree/master/examples/PrivateGPT_without_AFD_WAF):** -For an example of how to create a Privately hosted instance of ChatBot/ChatGPT on Azure OpenAI only. (No AFD + WAF + DNS zone) - -This module is published on the **[Public Terraform Registry - openai-private-chatgpt](https://registry.terraform.io/modules/Pwd9000-ML/openai-private-chatgpt/azurerm/latest)** +coming soon... Enjoy! diff --git a/assets/chatbotui1.png b/assets/chatbotui1.png deleted file mode 100644 index ebce676f678f4ef6608add4475e89648eb3e2b20..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65455 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 diff --git a/assets/chatbotui2.png b/assets/chatbotui2.png deleted file mode 100644 index 61a3ef4a8dc3a3aa557987f591e947137b4bc98a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85309 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ⅇ@bal7hj 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ν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 diff --git a/assets/coming_soon.md b/assets/coming_soon.md new file mode 100644 index 0000000..e69de29 diff --git a/assets/mainflow1.png b/assets/mainflow1.png deleted file mode 100644 index 9e15e28f57eb365f530556be0385faa5d0aaeb06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 307579 zcmYhjN6zfZx+Qk1fGU(8P!kZUdx28pUC^5*z4y$d=@jU_x5*i39%^e48i1xDsILJi zxX*A(Kl|NL)%{q@&>Rb<}%>#zT;{q@&>{Ga~Ie+GO0 z)Bp2t{}=fB51T8ofBos(7v9w{qMHPJpVIql4?zc?Vn?@|9w2E zit%3!jOl6mKKTbC;{SxessBA_#(cUquxa|>1BSqElm%Ph0D=9zc@x_lz?W2H-SnQq)$25XJt7Q)>%ITLPA@5wwfX;b z@s6R`lj`p=tchzIH%7AmW9rK+p<#HS{vO9!tXngU|C$!~pTs}^bzuL^#ov4L;%qW- zK`=)Gn9_gE1?a!|*K7s~?L%^m%>Kr8$1|ql^Y4H6J|^H!|ID}zChXtgzdIpyINk|U z=^B(uP;Dn4{G%Uf{c$Qss4B-GpX~N(=20ys1Y`OK!r~c(3+~~o(z=kxvWIA+tV-hv zmn=L8nV?4^6D*i*F(iB!~vwq-ffUSpNI4Pt+}gQ_$+@ zHaJ%vkySzl86yeA0NTi|sC`>LtXthPgP)sqJmi4S5|aCyA@S!((drS~Cki2yN0kpl zPy_tV#ALI-fG&BSqRDckVV#N@^1*f^D-kMoehZX=R-j8dir{R;a9a4B?=(|xb=K4a)SfNz zSGJ1uofHbnt(wt`fCqCTl6TW(b8QJ0$+YXH-nylp2NeL7XV)!|_8o6b3pB){Q7W@xUg_R-faKxVDsLS0NB+ zmVokiu1rMz*y`B+>eoJwitbvUZ(a(6k<7Qs;XR3sSz`KB`I?ogoca`IqRX~)GS_tvQWeiL>3+{q7 zK!2f|igy2JQu@>=l^zB{#*R2Jw=A%j;_TDlv#P)xijz)p6$yqRTJHzk0jtQ$xjG;* z7&804P}Ka{4NF-$)>gSoRlj;oq$}3O6I_uE60^C|i>Ufi%vtU!il}Ve-IAk{<)ZaF zRhvyOV3h1nE^JyR8fpO7p{{+}IVXEf3HW(^O-KHfqq^<&t8QS|(3F!hFUq9&n-kRt z|4}+F+5$9-wpE-yO^TcOU6druGwN$BQjzvO13Xk+0Wu?=ZuwMn$5XPMk=6Oru~*$) zX4ApoS?~8wlY?z>#mePgV_@wDpBBnjRH$oL8LY(eP;3_`6ZMImH)VyKbLgH;B3Zs zGMz9u^WM(uoL=dE{~H(WhVNsdd2x8Jbe@QLTtKEovUm;E?`??4h}T7yCXdkN?{-Ox7Pibak{?&#Dm+KQaBera{xx`$sVD31JHU0Yj}1H;uppvs3o_-6FsrgYT|uijH+dHyNxL}n+6q*4F zDTbyrDY68%@N$gpL}!Z%{8*hZLh10G+&+((_rWjkoV=G@o$uA!*!K#tAJdV|Nx#aJ z)JGK}Hv=}Mes?6joHGU$Xx@00?(GPv{7AfQih1vl?(phun?l5e^(9eKxFYG8GNKuQ z%FiYU)z-4fdrjjEA-j5ecu%b9=u@_3dv#+~=b-DaPzXTd#gLrYL#|7(NfAHd9cn~j zlXnts@@YFBYdsN9Y5%b;Am%KOEpGEl8xvASuH9?IHkZm`@=~;r+CcfYf-nO}nE2`o z32d&&hSc)PgD7<5yx}|OWlam)f3@0tB_53Qyh}f(3 z38jF~YYtOkq7W|LIa#lsTIrT&fA2WXkBSW^5J1tO(@9alFR&d~zxTP*pxJ+;fGKR$H<3EgWA8#170mB!5aBfH_ zVC`1?JCqdUzfwV#=m-YDM&)K8QB9+=3hV|UZZU>51wHr>U^>}kJI2M0 zEfI(tu&4a*E>rpSGGa-eq*HF65p#iTveo?Badc1{7J@OozAb|$lu{lLmE`7mhIINg zA*}YX??hVa4SJw{a1Li!M*2fxALLY&0;FRcz1!b6B4IzUfZ5@riavKelH%@%FN*YT zN^9X;e4ZZpYvkMNd#{0`y@CyXhSoENW(8}HXxVW1`DmeruK{imclEUiL0uuw_0s|j zfo+7k&1YfhgZ4>D5xduQx-c&ZHyc6ml3R|){h;<2Ep;fNA=)v{nq;%fT|D!e>^dp9 zO3&Pn6I08#sGd0!_e`EK5tXs`UVcez0mI3fV6^O6A|&`i|GzKL0Q{jR@rq!0lJ8ei zBASO21xSIS7Q?_EgnkU##zjxvdP97gC8Bd42p?OX2yk3v+$f5(OGes{0Y9~oQIS8h zqUb)wHP3+VEEA;d)j?_lG5YB>YjGPU0u%9>J8@Q{-^5Q1>StvJKe0E3Lhcs({Un9M zvNWp7n3|0>h8{w*^YQF_$$ytWp0Gvla%rdF4vQa{((UXTFXZ5BDO-V}YVA{dQUM{( z!tP%3>eU!{pInL#?)P!Za>mQLD0xi8h~a9+8V7!%0%1H$B(654Vz;5Kd{tOxK3f=P zgNN9AU0_%dW#{YSMsX%XPrE#khDi{M9#TZ*Ur1DUhN_p!X%g*jKkQ6>p6-J>`tED& zjb~Q{>t3SFCP+BU1#zF>su5#((LU^7uZUx6K=ZjF-Hnr;!h%@j`ax#7eUfPNZ36qa zF1wGIfeWY1R(Mtv$f6l?q~MowU-+^0uWlrnZAOGz=W!@^baP_`mkAS4l-BFn+|LNy zO(#<%zv=K*J7+6lsYQ4*!=J6*#^G?oR=lb4b%lA45R~ki9Bw2bNBc|NvW8LokYg^+ z0DGHwN%2VfQEKqytK}Ok52Go}$^9LXjHv&(4ry3EpKy;sb`Q=7q{xMViUE%z9y{## z9fVAUuh~i!@#m&og=RZ=X@DG{1+&7AWJr3zf_xD$tD+UksEXA!HlS_q9P77^@QEhF z9X)>@$r)V4{RNK3|u4ShYl!90QJtb$=LqYGxnLp>BHX zAdinzL;3o?(m3S3?b8$`p&(5Z_Og=M3r7%)vCWwjqr?y z42gLp*0X+R!{>!>YJY#<^kW0pgb^yzA10L$09k>a_ra~XHN20xJ4sj~N| zI}Jy(aS$yHMY$p4Foi8yM-mfUG%424n7s1>Qir&MrDAnb$Zxb~HR0kXR-_5SxYw{v z4~}C2BmD)W~BJ&ELHYRdk)2Bh;g=4L08!)S7A0yFimmW|zq7p!A`LbGwu!~; z3G~P+oD*Q0tGF7iTp;LTN%APm4Q7EWLBg~&uFV!p?l&fwRjFEtWVq(IN}S(1!GOe( zHPmlD|B~Xe9r?8;rkWG*RrK|2+0N%@Mly&Jmd`N#T=)`Y!qd}=JfdU#y3KK5v$F`_ zwT9m)KiCk~zilyOW%>vp4a;V)}}g zci#5+9N=_4jDWjh6$+J;9gV8&!Y3_;cDsw`!RQQtdiVs$L^N&Ezonhw3iHA4H=f?* z>2qwcvs!tWr4^4ivFgXp|81R_<4VVFNP=eUR{%RxUt#~=zw+J$MsL=bAK9!4m;hJ%qjT1oyka( zX=r1Re6$-@^gTtjbc=|Dj861@a>*>Yf@##VufzCtc1?n3z)sQ)k8Kqf{O2HL$M+c` z0AVtF0t}AXZM>9Q{#80ELRvPcCwRmJ_*lBFFQtgRS=~OPK4y;xEO0oRKrL>Xwo>ZE{~vEW7FYN!e4m zIc`gEA3D}e7u3Qxtjqnn0Fxo#IqEFjJn*W2x&7?AqIQ|CQ+T@RJ0C8&l-IdpzxT0gsZ`c|B#H*HK0Mb31 z1_YQ56FOA#aAmeJ$$_5`<#S8Q@Hn!0$B*34ox>}!w`K72Dy`|ql=$@|F9$_NS(0Wx z0049k2!S`LVi!w?n61TcKOg3+%06LQdm_~Evpv%cs%(e8nUvNfc~C? z9mfQUnm1dU#o}oTUD^EP{xfbPq6O4fLBya$1J@aigGh3&vp*1=4>$H@c>61|`t|i| z68o_SFVHH5Ua(jGEP3nDiQT;0)LBUK0u-$iUlZYW_(_Lg7#K;C&PT6xfRx^GTRs{> z;id|Y@JKl`<<^G!q6@y_a)th5GywYWnM)i^7Kv)I-U_gZtO3$}*G*~~gb>>T1axHex7<^E*!%)0NS}5d(`M6Oi)@!^H--9D=MvD zYzoSQ!x%xn`BV1+2Le7;w&H7j&UJ=YZ6(~DOhJIbq;r@%m>qez>YzC=f@|Rp>w~(7QZcDt1Y2Cblh#iOg*B?s<&#hSvgElZSPW9mvOz5a_ zo9qSN2Bl2w=F%=J!^YiLXtc&%7Ht_!rW0364r~AiUg!uwhN(e{FwE#nL=2qA@(;OR zys(`^K?*%%Nu@g>aRId=J|M=Ae$EMp^xtnm#-f1&;Wo0NBc6t1G;Fc7>q^|uJdCxv z`3Ma(qh%FH#fe2&oW;P)H&(1AmxRmY_d!Dt!>$&nQh|g3hx_ngM4A>v4fb4Kjk1hU z<|zONvl?U?Vr5Gs{}bJ)O$qg*$E@%N$N+*ST5-O%;q6-{m@*s|cUQmz z!|N3+-4hji$*UZD{5_a>_=f9WKVR^}^kmHf6h|08oKYk={ih0&1o@SmZ;rewhG)V7 z5<-sD-VrYYm0Nk~OwOtNup_z!&G&IfE_>@ZXS3a|Ok#pD0BB6!yW)BLTO7 z+@9rNp663@yY)mnLLPH|t~yk#15RbX^1R5056}`}v>fL=?m48^Z~6<-@eY+9-K6~N z*#{znTO}~!vMV?beYXrkpMtOO3-en{V!n`>F znLQt(9VO-&NZaRjK&*XY@SK}U8Dvhb>h)ISI1_(ZrSqFk^E`96KbCNWpo$Q+8F%mE)8wsqoEd@hv}M)h529FO9TD&_T(v;r4%@caTjzvB;@;wiiX z7h?BK8j$kz9ccsQ6!$g7qPc>9d@({vX9iEfCxYV0n5an8ISu$OD6*xTS^?7*w|1PL^qap+7+~hip0H35UPJ_>D-3d3aL!b-^o9fx;_) z+|~GCDkis-G-q3=8y-|`(Bv2icmc@7aQSKLy&KpfI%dk(3Kn0gZk8%0g~-oVy~hxis@ z#Tx%2{`x(_kla-ch1G6|SYkEwqem!MXhT@rHS)`bj#USOS|$AUh75S3=1%XJVX%@> zOmV=#u|V*lJg)oeGkptgX#$R$xPoa<@kYDTm10X@Ltqv(d+mM?Z)mViF0Zpnn;w7+ z6DAUk+jt;k5EF!PdOFa`+6M-uWZhv)r!3FkU)kY1rsum!2-fZ51hyZy(UfkKJY{II!k>dg zjM+F|YBfE3%U$H^Tb}!gP0u0ByWwQ$A0PZPKOBxn71H&Mh?~mCD6UsW2ZjCBXPz%v zjn5qS0|9}EUJ)$$DVLlTxj*YVR@gMz)RieRDI~e=`({W^8K@EdzA9tw;i`)=i1`a6 z)$|_=s+D$yVto!WdUFzFB6D;rSDGW{KsjmV^{zUyk71$ok}N5JnZEN$JLV+YS-u)dm4nRvNCV|@ z&p(WVjOKM>+Li7tHL@i;OW01NCI+?4PvRmIR08^X8Imat6F9)*x>Zq^Ga3emJ}ui6 z6!RK^Uj1(Tph{RSGW&?qmfIWl64aVQI*MyxuOKbJPOzWS5(eH=!DitYO2c2P2g=<7 zu+0ZZC8mPVGi!w4l(kAxG7W>;E`l+menv30YII;RMW2)~o(pf`vmm(%LXuDYoDag0 z#qmuY7_gNf>si#Px&?9A-9%KoYQcX$1t%xWQ3g>f>hqj6V(E{B$cfhNQ6_vw@ZiP~on zN|c4Qw6|L$I|?Bf{MRrU@Piz_U^{no@ORAFC zM}FVl$K7tsM-b7mvVf6p7f#_Zzcm3qXt@uvZ(qH1px6G}aooS?%&)eZasy>@&Y z>1y3wg!F`;U3Fin$Bd(j(tUcurW3u57^-6P*#XyNf94oUNEe0HASt1-6MJ+w738|q zS7$3f&|Fxv&(y$?_RhaB+!97bTy#$K9k0r#Pksw`#oqea#Rc ziOgSN3FoQMM(M>9hVL)yl5q`%4NVz;`%1?C;VXAQ=1=|<=PD4U9acRR?#|p;G6p%L z`g6XYQ51fVU@pJxohPp&PoaGi`1lZG_78@F>_fx+sj`2yncyJL^L1kZp+rR(yuW4W z<4G}t4|^!WidjCEZ()~D`_|TBFYQC*eB@evBGz=!K15S=$ROBV?=CSz-&&oAbb#z_ zbq{eqXOUm`xieDSFcLd|=sVm^Ur9YFioBGFMkZo48kQZHz=%4`?{qR&u26f|E6eU z`ehULJk=(Nsxfq8CM*RF#pebL7a22Cwg2*Cl}AF?aBuE02Nf1{JI!3^-%xYdj8yR! zlAn8R(*R-~(LCxZNlXY^!|W<*dE6i@U)2HD8o1r<24U=^`XIoU@x7aTfK!8~0sPs~ zq`!f=VP3ym2fc}7vc6`V63aaWbqdB8q+02LrsEfGaE9msTEsC07odQR1aK6o?nr%` zCe5#8AkJb8IA(5ZD(=;DQzURt7UZ$f2q?3gDw|@3Uj{9!8g@Bl2l8bWo3UWSFt6j= zNXmo8veT{F?3XApMJzj5R0D8YPiz?#;$1jeXRCKXDk?HZnyEe=nTNleLQ! z*-;!g(2(KLECV)vMKrgA;Hu0E*wBmGL1LIPvLZ-RkV{EVz92^c^%P zSY?dop|cNC&N%a(HF1y=456rWc^`;i)?BTU?Z|@=36+)NW86r&cr6- z&d&+$oj=)`AH{ijOAZU=Zx*#=*M$+#@lwJ>$Ozn1z;(hV)(v3@1j}8pn`Mx`=zDR7 z$;}+z!MGlPsz9EKKnb;fIMC+%ghJyIl#3XvG$#?fIbje08XW<=nFxQC5Azwr#Tjmw zIj%PfJ4RVLKqL|>acHp-HJTt2=@zs+6V?I8HPY6-5!bhXfin_r20hL|VzQ5bn^x5Y zD7Tl)jx9cX48<04p}bVL1^`UX18lbzR6>Kg(pp&`LcMK%UIIMXdsVl~yE!Hx{0f7K z&Oadx&+{kaTZ+Qd08z&36Z@S-=tX+Sam<6zKPJ5Goh&xLV_2#|+L_01Oome6!{o-b zqHH1fN_r)&nsP3nNEsZ`;#ipX`K7m7piBmLzO zUaTxAPbAKyGTKd68=9rzsnpq3+(&Rf+lR}cW9-?XQBk$pj@|?sWUcUI)C9+ z(!N_M?v2}GZ)&`Yw?|{3Zf-U9CDGuYbHa%c?|2aQ+kd&*XPcdLH z0LGEhh#muSnR$&8K^w=~pH~tJ6g#pDFuu~Uk2>^Lh$(7U$!U)#R4|De1Y|nm82jUOL ztN}*z+;YBk=XM$MREd6tGJ@qP^U4lgu3Vbcr4;VkK{2ZWh&RM3kBk|eezJZ5cGx8> z_=#!Wy=k5gW`43p3@JT>m;FyNfiT@@J#WdPsLoAZHKE6rKfcQ>$mJ@#P*w($;0&+} zo!qG$g_zU!e3%_TY$7yfh+8O`C)BzqhQhC#t>;6Bqo{Snc1cbH=Pb%FK1dh%Cfm!E zxj}X~;kbqJdoT>p8ZVuXXQZh^k#(vZf-}RB8&nu5*G%Lcim!mb^;YjVN#d931oZ=a zro#Y+>r0|(9@q}n|EhPb-Vi7%fM1`65PJ-4iFu4x)pZ|BLF5idUZWOZAZp{Ii;(CX z(&SPO^3@MleL%qxK#0Fb3{@(x_ZT3dOeha`S4msN)7=BIXadXbwNBSNXGZijl&b7e zgIJsuT^aOOUwjN0aFIo4{`C@j6k?#hEkK~&;K;O{e-Of{KR+g)j<5?fKb5+LQbN^C4JZ6mCQ)0HqgE z_3K?d>If+hBLTeN*7@=KtNpCRTAH6^6m0LNY$jb;(rx1c=i*9x#qFP&J3h!qmnsV3 zXf#V;1gNPH!JW^59E|0HV4n5-^Myw^fm;kFcO}1=#sZuldiBX%0W}XFEEmvW zRe7u=P`UY)zS*{;+!TyBx%?!VoX5x(;%=a_j2A_tZCgs8l%FyjC=-#9l*{-{{Rmn@ ztHTm>AqBakW50HHod~-fn}JN7fhtj0tMRyGmkGhX275gZ&TwrB=KNL%sIRX4>N-NY z0Nr|wHhAzn5dE;FabQ<|5|NvXPdbT&{+%mBnM9jqw`ZLjgaNAv{$NygM-z3!lu#Ey zlmt>mtN1)>%iKaSRA;X{XT>}ng?&l+#05ZB@IR92Fpv3I39Id{k$VB8Ffk?wk(}a{ ziUo17K99j_pDP5MS4zja7Ftb!R5e8<{(;*_(|276s{2n_Ezgbzfz_G)Sw`UA`ANh( zKg`&;Y?E?9AU5^j?g=}?`1(nlbjDrYv^pFEr0;O-=9}M&v0juZY_xa6(`h@45B<6?O z`T;BhzM^0T=;T$u^om zhq=sT4WYt}JHG^9UgZoTiay1fUL9C*+mg8W&?B+(Id%J9VdNeeIu35+(dc%kY@m= zMGDY4fjjC3@xtgq-$2=k6sF!@d9<;lBTAi9cTj{uf0hbUfpVCW3#^B08K$>9=e0O$S1VQ{DYLnIG{R#xHq&XrkCsP&a(N zTjl(Mpk_)fozeKt9i_=kKs5$e#W_<_?claL=Z_owsqjSx1mD=Q11hBOK!dDXA&>!6 z*eE5*{euH^V6K$Wm*! zw>EqMZ+34$&=Ir`BBRUzyPXA8gGTuV*VO^{%Gv>Tjy%c`Io31!BND$GlVO?x1_p&aDE{G_F#NToQy+sYm7jg^i%OSX4-E=Lq97q z9R5J?3yTwTXH9p`Z@={=H&E@SIY?ogzV@}RI6uPg%MXrV0j@+1qZ|B#H$n$WwJWTn zQxdBHBLiv5)ONGmoi;X;9qpSWOu>Z!Rw9Me_GZ)p>pM0&4^gfw?4ajP@;Yr0U;rkR zH(6kN{R8J`0d$9HzOSHYw1_vZQGW`(pq*meffM{ri(xIhTtG%~>HbLKIK_sN1Ubz3 ztnc6s|JeeOC%Dh9iy_(qGUuJi-$}Mi$lQREP$`;f$_J@l7iJr$!05`c?+qnC--ADP zEZlVcBEBopTEMsdD593F)y5P_D^VuJ=T*&(@35xhA%L`o0jqC7M+K3n0z`OHX$v%U zV%M{hfEN7V?>!MWvBCMo{NNK81<916dR83!rX4$kB+x`GX6Fy8o+}&z4lDK={g$6gps~B%UQ+y`d`5sU^E4{y3h) zE+I*b14pDv(%cI+iaDQd1}_{Q41g^a#lKw`V4fHZ$`tq) zdjjZ*VFin&--vE;j*H5Wy$dMWJsLmMkMDEQ2%|Q-r!E(WPhv{D@ury1Z&RBclzY-! zc(`ieVq>YXpqk&hmqP%e0Hd^EQvyJ)w3Qs28$<70(o-XZ?xJihfc#u+!|x^Md*q2& zeN7(wr2yli1;mDl7 z@X#O|`c=+{11Yu*r^y#hF9!P2^%)9nVJ5lE?!XbG6`sJd4O*V1j$}5_UlDK(%iVV! zYFrTzf)9YQrsA4Ck(u3ceDyX~%Mr*OI}#T#vEUSq=u<}Hy=B0uYxdS&aUUWOTmEO7 z-)n)sn9Jb8#ZFm$l|=YM1X+?>N_}?68Wp3hBi7WSSOP?mAO?t`xV8)w?0I~?>ox(t zmRsH7X6SFIhZ*;n$KoyAyRbz{7#-eT{djRk@TZvHxpYB|)9arAFml`)WQeQQH!eN3 z0&{X7fOBqM^>Zx%DZW=~u3y_^T>wHC-O5nHT2UE1w1!xBSbwE0)VMuqG2LYZ?7L;G z^el@UEwBE_1lrN@@3UtbL7?%%rSm+F9Ly{v0PA3YhcAJieqc_6lJ<7;OlZJ*v^&P2 z5(k+tdKV)A<}U#&1m%Yc6=1aiiho$-wK=J`(;D6ftR`R==q9!VTyNlE3tHEO6~LV< zcFnH=Q<&T673u}ZI8M9RD!sIdNnJAY1Knj;te-rP){B^ z%g#+Woid&Q%a61$&33Fds6Xf@*RL%*F=aDxAqrt2jE>leSUenrc6v^P-=4iSKcc?t z#1WE+oa0h zQ}c!DctcIRURVvGIKj?N9zN_If>|pF7V&|Q@IOk)Q2 zy&#)@+#X8xAE=SRQDepTS&Z%4X5#04irzKpIi-2wqzp z-iQD}wAed88*cfyW0x{3aa6rR>#K%*bCsb4(Bw>tX?KChCdLV$?GEw-Cv8#q9z>6h zNj6;r^D~TM>_q1{@7;U+(0qyX{~0GM*(1D2Q4zO<7W0N7OaP1q z))Hap(d2mrW$dy&T#R!LpR@r>xsv{bgxJtneR0jyuTv@#+(6EefVYj;)=|Ok(@*l% z4!AoyKPUk8G|-@G0HD@sY75~};9ScIiv4!yVQ6mLVCL3@$| z+x$?~zt9KXNZ{15K~MmfrfDjJa8;5Vx~H)Kt&faE?f4Y6=q<{5sPPaDYfXS|HvP;c zw0VPQU}lloi|ip zzG|f>$P<$*(!+j}#SN#)0v1xfEMUcyP}hKhl=2CPirAB1k1quJ^P4G)!)$g&P_zOT zWm`(T1S`4va*%>p_I~6}hRA4`2_aI7%Usm2z35=m5F9ftnQy;OIp4AE?-*h%-2o&N zrkTiF!ID*^{{W&4w`@XQ?HbS>umW)nza0ue8z?f~JIE?bF?})IE*e1IPgsD&CtD(v zT%%xjdx=Qthf{;d3HWDIy^V=io#U8=QU$;XlqPp^6nzzXse~e8Je}s!7)L&=8_gt` zVD1Q2377(Wu7OuRTw$bm^X&2?Q=aS);l20uBkMtb=Za3u zujR~$iUqM^)4`fr>Vhu;fDG|OUMl*BV>*3iqa(mNA;8>iOn}lT%diQw$CZi%n=0Tt zUxEj-aM)}p^tJvh7*`GNT2iz?%wL=H=b*q9!eOlCc}yvxj|G^MaU3z!og%jLi7b8bq?IuxinSYF{5%bhgN>DnP+RD}XyhBYv_3 zSdK9OT;+>YMyAk0!2*o=#ckdgOya=HNWLJ<7{h%h7c#PAF6 zT&@B7ft50^fhrCTtmm9+a)+KW1M7r>98lqeA^?LZZ2TgHbeI!r>9EtWV&8d}DvQ~8 z`%JEX7I}@+o=Uz}77nxQpdF|$gHB)NXBPF6BN5Sb&*B)o)Cdwhf3Ze?Wv))uCvBOm zXRj%BFI@T)-y*X1%_ME6X1JPcmz^_v+ThU5{8*{npu9HnG#t&V1?2`#Wggg|;pQ5atHg%CiAn5(Ddv3F#XH z=Nwsw#S)ec7HpR17kLiiD)jgpY4I13M^Xf|28&Zb`HZn@Q+RU)0qkS-Y@fpTN zo%vv_yrEGeMAZ(_&w=zhXiLRo!~+iBpB2I*Rn%{!k%z$HU1fI9gbqniT>!w5=Hw}$ zHn5HT#DIQx@eOIGUC2WS`$pywYQqBF>%-{?w-)b+H1K|ty#ua;Jr4xD zP(h}^dsl>-)xVPPeROBdF^Cc4!A*7u1m0q(%T-^k#n2zP^tD;!uoFW=Ug*ZzY!$>M z{+{E$nhWrHuj8M0#I)8xq3!(H0Ij4g_5F@6kX0RC)4ic9aClMn-GE&N1msi^s)Ja# z1(g0b=lSOBZ-a0Y4z4WdF$Sz4?k9HR;AQ9CC%t zU{$u9L;FMPfNj5kW+G^S7HM0{B`C1s8mAjyt+Zq0vnN zT9AnHKmZypxui;ismV^(?h$C!$FMa3EE58SbAWMK0+RWk2*%)@w;YwY@J(SS(gRX< zpn*&Zf+TX0CTap#fh9x9lk2eTT5h;KN&q&S3AD{WUz4a0wl5I!1G$`s1*Ei)lQhWz?-TW4R_+ItO^#5+90Jy0_?tdG8E z2gHE-a+G<1t~PlUKwr-J#4>J0=<#$l4d@pv%yT#eHO1?xQ$d32ATExE%|Y4BvFzW6 z1r^Q4u=<15qkqv~v6#BA%z0 zyCPwSlhX;}c+8bTcq(USxk819VCAvji;JR4$Y)wKP2MW>C96;Zu=FF8q| zA<-)Aln%*lz?c(3FpRh<1K^tdl3tV8RSi(St692+$`N(B(};RSvPY@=1nyT&c%MBU zSSXrS+L4+rkW0CGelLi$rZA$Ajx{yW&gV6dOBZ|^&jXUKL3G09(N{fNgJ1gxE90T7bJ(z;}a#jkEjKpckG*Kibz^kgkVq40F>=FcYLga+g|DuU?wXVAEEp0 zuh{23$r-U}Wr_www5R{V7#=A61PZIs3P43RZ$b#$kNbeai6{`0Qm?;lRoSb;O4>+x?ZHWARZoj9c zVIH5bYT{?}dSTKV@*(PJ^#89;O)73Sejf-s^o`d1 z_FG~@0hWR@5rlX;zbZFvOUY`&?|iN3L7^k46Krn#hMI%Rrr7rsbS7rEH#pMkqdWcX ztGc8WD^@>(=4H1RHW+ai(7|1Qli@7gb0`DdW`9DDmp{-g=YYqY$<{oX^gcY*&MKsV zl+`_kR^FFhD8;NW>cVpg5?wFshXM%MZlrqWCHP*%VlPnVO2v~C!%sbk<*X2cxDyOTQGqJQb z5Z*|C%C}Y=NMkX>hi300ARColAHuo#x+`Th!)awpbt{EYwVupMY{d!u&=^_`IA`Jm z_@G1eZEfKrnc!YmfhepWlE;RqfM|@C=S3^pO{~^qXsKv8*ZfXX{g4>)`^Q!SE1`Mg zk7eS})%G@D#AR9Ty-gv@z#5vR0{yuWNJ=y<*>|`ins4ej)Dko7duIt;oinX?z)gtd$gtUT z6gUHihD3@iI1BLcazzN2+jJAMy>NQ0^e`sBj>ku~m61@S(eoDvu-s&3{dqpE>#f0F zqgdYXMK3o;&7MM8rs-FcnIiMX%?ZYJL@w0MxhSaGC z_x7RPQrs_SIb9Cu{l}gJMAt2Oi}&aK5}0gnTeZ&rzE_pcu3%<2@7ZrvUL46of6W`f z_K)*i7E?fm8=-^cv;M?-#N^x8QT@y&LSu-CMFq~S__E8U_?cf={&YWu+*wiNAKyg) z{%!Mq9}SYSljC2jpwY~ue`=b|-*2A1O7S<%?e_CBx-0n(-|>7X)nB?Nv}n&MULQpL zat+q>{IJ9F>5ZzCxp-yVvqKt3x~7$ydZvFeQ2^T*P=|v6s(tWr&hnJKw6`4?9u)xW zBz=J>?>a}Bn0Y7%<7_ z5ZI#Z>C8KHT=5vpnphwq82}L_AJBaiXANuTRidB4|VJQG=t+{7?9d<=yg zt5YLf#L4+Y<1hb}vei0W{JXB*65X$*Pw#`W@y-i%qt??jb4fmXF@H`&vn*bYbN6tH ze-!bY>zqz+)eDGi{9IPL`y(C+sP~4P1;YJZy*~zNCpLAAm&>sSM5)HH#m{9xc6nHu zJqXbb2Uo0}B|6#cu$QU3r!RENs~oJm>015*B6|SUO72}qcfDCJ1qTV2k5zH6{a(p& zR6zyBu`Q)J_|@kEow`h^H9P(tQt{4pRRS=*o}C*tWKRG3;88NY z3_jlYNG!tn)yLwIZKdX1%3fUavKY~zlfrAVMeiRzt6ramMZ;SYF%)OIqz?CK{yI~k zJ(0YddYY@{%Jq>B%uNty=t-Sj=9*t#hOH%^>m4Nr=%>C>`K6P7IG<9MmPtE&)d3J) zhpPg@<&?^9+y69qs1YJjD}hM-EzpXL$FkvRwZ=U(j{z%}04tvgA?0-Ksc?){} zy7k>~vize>4kdt+5=1JN)*4Xl%jALf#F-$Z=JUAsM)mof$@?ci-dSPq7~vrJpFT4} zPA^eBGM1p+F7f4cV|%E4HejZi@oPGro&so9p+!IP5F%m)>B)XLuZMO&mzmr6C#wzR zRof$*3X7S7Jwnh=wcqaaDg}w<(qaZPT)!ngn~BO`b89-0U-xkZUjBFw(~LnaMQntl z#D0fT$82b`9sX9By>Kp}0+D~Pj)2%#R4H*;-TY3N(R z@`UwCHf()FalB3YFo-Q+&fV*G6`JUl8VckkiSBowOY$RI)+;g#2(+TW0k=R44Ninm+F}F=(N*teZvNJ^e`;w_{cPNjkO0+kbI2$17zXE2Luo|Ldr+`;ynV?D0{Bk^ z(@H81%q}!1rb=m9^NToCr`4BQp4<; zaE%;)Uvt5^Jn?9sF@O|vVyNID5j2r(85>oLz8}(UbLmk@cs5@*vv-WJ6N!iJg%;$# zlis&hOs;wnUMryCZuK*HG#7`N_jlmET9WyGg;-v3bUy_U9-RN-E<{7vKm`epnx3`s z!ntJb7{ISW*|zinx_nR_+nHlXvexP7%_@C6-IEVcL3=dSUB>Rzu*rF-Z*P|bMO3wh z4d;|g7myA^O@Qd&)o`?`TFH9;iT3B%Hh=L&u5KNZ=7)4~=Dd z%V*!im%)0YNHvDC;z-pqW~Pfq6K7>HXg=;5K;uDs|I;tN>ZiL=UB&u#iqwMP1QLRO z*=-P*edmQnY+r8G*jc>y#-bv|x9Phzmm{kpJ`@LCbs3}mx>snzfiw8=7lRn(!}QeY z*H%A&DiC7qQHRja#a{M=LAe#*cvd%y?=BqO_STi3joRzytoAD%(@-sl>)A(xnZ;ZLDks+G!8HAZp*?sZ(X5#rqXk@Wz$8XIXTC9|y>)$tv4Zynk z7}LGHeVggKgxu#S*lv_Lx)-PeEIg|0i-srgO|@)yhM--H91t0gyG zzpNB~-v^%QjzU7c9)q^jjX{b0<#*|W=_EN@G0&rx22Wym6x<++uEE(~`UIBXdx5~@ zLr~t6_ZRn2`|hO@A?Md94YKU94@d=I;REvOhqf(9R>h3yv?BmkrxMsOx)lRupvDv9~Tp#HGQY`pz?bqV{6& zw~8wF@vRI+)%x6?t64q*?+c5cOy5dc?L!z)g%qbl4X`d zew9#)1leqw8FJvx)ZzQkVpC6uL+ZQ}Pkp9-C>;gt`_oX9=It3NBonvMT z`mJ#7`8b@Z`cb*L;ADU0_kFddw)lXdV$<%p>Wyb&K#Igctq}=}VzR%1CtZKCs9ZO? zXhCD9%A>6ZexNh;v7Qm*h3EMeg5&on`+7j`7`SM4703SGb;f!jqI@gg#E+x=zyLWA zhpnFZ!F}ksRm&JFs(YUKm)CkhCJfhOxvRE7d=t4glx3kqlYGeEU80dxm7oR)*Y4XF zl;Ftm6->N7>LX$c>T|BqSYvoUKXh`)qT?j+SNjKkPn12wmczCsEs@iY`tPIz$Bw10J%{n{U*Y`lU7LYR*rd zBZx(O|H^bb9nKv4gKFXVbuBJhvXNw(-5}SXWojR3SOd3(Df(e35Vo?$v$o(yP~J}4 zJPjLEixVgd8E% za$8SdZJFRSU~I+cAUM(&LzGQmFyOsnRZt z5d?^rp@2VY3*KpJ8r3om`-&-FP5bG@WWedWbQ*G#%OdQFu`q087oRBR)DV};A1Mf@ zt+-lv*N&BI?`Pa_a+L89glV0pksYrD#ZKVsGjJY|H->Fj`R!MNDKbjH$e46fVw|RyF$KXpbYe0t;{F zw}cJ{CO{Nkk-}?OY(;)INx>Ts+QpH?-|e=1&ii(Hjl$baS5^LS@o#x_e_v-W)eft< zTvxXmbvL{E$E%}+J5KO#&D;VO%IELX!*76vPdR_iMllI%sQaf8k~c-XVSr{-^<47C z$2Ap`=zYpNipoZI7aAzEDliwDk6q$9<>Z?v||pEWRIxAq6c(7N&&D9IY_d;UI4#HhCe$c4+9MGnhY ze{A9=oIZaX0PamY%B}H&skHaouSiI#0`|B10-66vZpCrZ*+GA3U+Zxs8mxL%DP6`_?-btS#SMSui6t(CtX$rsK+|QnR zh@dr^iR^|{K}o>Qjl{_AGmv|~U|Hm~ZB@VSQJ!v5NS5gRJ$h9S>Mu<^nLAz)jO%1z zAu6h!9M#*ix*E9e=^(TM1uo~LsZ>SopAwl~nt485Hqy;u#=7`4eTUX66Hv}8V!f=GN8 zg0EfGqd{LQ(I?7O-ZT84d^wdFpG`kKS}}lPKclt`;feu4s-m2WV~w8P?RNvl^4)$P z!C>Q6_-xy^c!ab{c*+x1zv6B~HE?{t_Z7bjqU;C(c4LHPg67tJ^v+JrmCdiZFHPZ+ z|Kh>vK4s&5s5zJ;5-vqIcT;RuN2Teic{m2;pGAi(vSY7zmD8$TMTeh#TNkHY7fWGV z#i|0YdH;zAh;eY$ql)f^rtieM;tEsBUWZon46TbF>efM6Kz_%hO>|+%ZHz6xgg=$;ZNwVA*MBUSDV2t&uK8PlRNUdjkR~KFr z5*wy_5_AzD{n-7Ayr|bi+vmVO!6&rmYBGKj(hHsz2})o2-uVdKpD8h3a{TcTpSO}? zbrv+N;I-_nD%9YH0cgEPZ+m7<9k?Wq zissdEj#tJ3NWHr!rk_8}(C+024(%KbQ1#=&{%%KhT#GC$&)e`(t>3=oJNpMq_YY03 z0G9GJu{|{2f5netGPbNd8R5Xj-n_L3X-H%qU+A=@Bhg$S3#LnxTbPTjekUb3p~4k@A@-4E4y zgnIr#kME_irS-7u`EH6tk6pez5OLp%fTr#U~{Tbdp zWpkUCJp=p{cVri*?K6GP!b9X4lYw*-Q|RyxayUnS?)hZZ%SR$D-P_NexL>%ZLa%o) zyVFa8;W8xpJg%iU5wqI+ zxFSf^9D}Y1^RaILPv}dLuUHbyPxhR2S*hNFCCUM^b4AiL=35o!?X)BesDk$93OA|! z^1%+2Zs2A0z1>~3A+S&?R|`c4rM0o9C^K^?wR@L;JxBxVUC(X;T@o1{VIRMf`HA85 zmfb=0rK!l?1{qhDqG~>GCde_IuiXHT906n(Xp} zF#Y=uFhI=P360p3;2qxcwXrNb>#(o!1M|hF7dIba-t-U`rg`cYDmc95C2L=X(w3xs z2t@f9o*^`hA|LOGlhScNB?HfGy3f?Q63x+3-Zk}w9;1!^_sxRc5Nn`cU!*!uZ z%WrY(-vc&zR+3zgj5834CWHjppFI?%QOkLR2&P4#y-L)`gAXTFil7j9HUj(URsdU8 zQ?{@7Ed<9+5<^7cwnGc$l4Bm_jxMbNQG*>jkHkw+f(jQ?Rr+IqX0bcAf z%gtRZk?J`)%X}OUqeqWvSCH_e3Jrf7e25>}mbOYsAN1aTZTt zJ7b|%vXy#g8PYAT^%K8Bt<}1_t*XG zb40N;s_P&2O0UlK0lXE zl5b%;F|Qcpy0u`R59Nr5Af-v|t4zQCHn-qq+r5Dw)|6b8Q*&Rk`^-5}R%Ji)XWUc8 z64Dp4I@8C8n&G0gWlB-Ba1@2Jc_|Wig^f^e6X=UU>Wrj&tI8(S<`d0>VZnSvuSLejv;wt_A_Bn~K*VpT`N~cWI?;>E1l#hri zc8QH!*?e7U(Pp(YpZsa|2aamP#d78To9Gwzs#Icw4=SEsZaByuyg!sbC7fFxv?Tjv zzlTR=1M-gRw7-!zpTuO}Vyd!Gs~BS>_*+X(-zVjA7#d2?2j~#qJ`Tlrk)V?lq%gF_ z__;U7Umpy+ZSNlA0RBBb$DPPtT(Hjz=5acV*^EZ|QhJwcqmLAiGF4AYpUE?9;k*fW z_a9WWvfyAP-&^p`o4O*G9U^|1glWC9H2Gl8VDioaCBP|jR+*!4yVlprdA43&xF<6( zKKyv*JX=r!B+(4%i&kP=dgtToJOJ+5Tq0}YDOAyt$iLI1<{^Mp^Pl6cYmx3AVdB0a zh?)%cksqHfnn3?}Vx5g_xh1Qt2^YHo`OztFj5`Q!`Vu65g!6%we+%*oHN~0r2BF#3 zwK<%3)kd*oDjhV=7uSTz4keuGs{XvUB*y#Pbcl#uo<8JZoBP}Qz8~f;f-D|@oqDRr z_@GwQ4KRa?KIT=iSm$!y*3LrbeNHbE`>WA(q=pl>Z{^$40~Z8&(Fc?Srtuvui!}k_ z`mG(dalc9_nDhwoW8%To`vwDMlZuuO$k5jb^L!4+tpXEAj5(#awl}(7%ET zCVX7qS*pUq({!%Wb5Jz-vM5`2Nq>E+4=E0@dsT|5y>W7Tp21@gf9w<&%0hZ^MVIpP z4=S!RJERNs6;YRJcrk$gTD=1=Vjpp*XreQ>>shrv6e-NCbJ%CIb1$Lknmq#fH6i(U zzFJ}M%OAHsabe)Z^pfsHsVz2sNxm78$8q(2kBt$uDKCbabH?n1Jnlu&M$ltRES(dX zHqfRq5|djl+)HHXkTYkc2bD$O>doDUoxhg*Uf}|S(aXP)3hxi>sn@_&$`Vwiq>u|G zazh3}Y{#Oz>;=tJ@0@Aydx!RpRZ=`3tUV+$ijh4 zN;u0IB5R3Hx}j`Aca#VV(t`Xp9v;iYxbAmd?@#bYJz$Bb->>ui27t%Cpzr`{gFw)n z>FKxEs`k(7@Bc;P+!p9l^xdAHfg0sEvize$4qjed%W?7c?Bu;ebP*%AgNvlI1h27R;$8)iHy{@Z0mh&eb z5+jqB@ttTa$i(g!0>5-8EM@|QH55(s$B@I@>vub0Zel9_5}`QvJya))+E+NvDf<4a zo$VDOf;JRyEj|bhYIv{Prq#F1rz4=CXl`E?uKV)&Km`tOk@ehB^p=dhUn-`<1ro!( zd=V6&u)1&YIENUn)HYKG8Rl{O#HkzIBVUhwVPW^J=EnHJz?+#H z!hkHVv1XK~%V2@@F^cX14UF!HUj70-+R}zv!tamGGoa~zG&~2OGP1f}I)C50=IvSB z0ZKdDP%ZoDsL;mzI0fUUAezqk5NRY$6hAJ(9dFCn!u%52xd(#rn;dYb-Zy~FmtWvB zXzIY+;MqC-Q!?z(Lft^P8sb*KYp3An3?@ z=Tc7X!J5Vs1|ShJ({4GXfVqEz>Ewgh4}N|M+vm>|)OCLQg$I5bMm6?n z?1yVj8+C2?%LFLd1@0FLD+nX^^Ucd`7N!0O>kh;C47%jcGv`@1h8BX4cmj98K10#3UD&$q~>U?6?hs5zM%s0Gt-$~z`ZSyIIr$^v=Y}MR-7&30b z4&5)l_vH!Y!)zwxQSB=!Er;;ebr%Q=W7kw3le|yliCZ?0H(^8?+zkFmUpr|1hft{( z&!nE7UQd#`%XHI6kKmV1R6Nu?US(N#khg@kY7zl57Puv<^V%S$=zIB`^mA-mJz&reip!Q z1pjW@cjhM=@ghWTfjTY$x-7qE|4?MJ324IO&;Da@Km*YLaq>pFOsEU?>KC!ulrYy4 zb{`mXZU;NN?@J~Q_q*%|Rkhf?j54WX`YN`6wNM|4mJNnx>xcO@1i!DTuZ6mN|3o;S z!L@=~m!bk9TLOGfu4gTx$q&3k(%saP^z5X~S*f3oB&+tBrEe4I$USh$D z;OIty_jmK`_eo^!PS}d|(e8mGD~x&a6U64>+gxt1lb;di$=+94xhqGx*CB+nZ9bfm z%m@)H{*Et6JmNT#h?Gmq`C`0}KyuQx0nOM{s#XCvt2y#-f_4QxrE^R)G4jf?M(g%r=v~MhjyIKY zaCj)wO2)jK7G{UZ(D?T;vaZMTkm|@@ARHA?3hXQsxlrjdO7{UnE=%t$M3{fdq8P+Pry++rR`kKUd1mSVX$KpVOpM_ApY4;l58GVGwVP`)=_ z^4Kt=+eYWVl94f16_-vwv`X6m_xxLfT^*(6SjcCXorBu_=uS=8oIi)Rbh|@^(5yoE zef6XOmUksFfW|)WLGX${V3fOATAKbbwCN2a7^p_#O;wBc=6mNT|88lWeXk^>0QO4N zZz+$GqxZ^bUjj>0n?GvQjz_{j5aHGEr}brq7IE`>{&i>Qj{<4mmo4uj3twW48n#GQ ztXf19nK>M+OiB)yLNuOzIQ}7#K%j2ZI^P%C3ol-KeE)0^ql)s^!O2X;6&}SIjSIzy zj>m(a#jU*uV>_Z+WL#Z5UZ5NrFbTEq1syj|HWm0#OiP?YT{7fso)7JS1}VozUheVF zeO*d{k>RY^((d0WYkU7S5j0tSJsH%b$s@|g2qpo`^Lg3zd6o^Y^lH3XP*&UOb^+KtW?iq0H4WL{lKJZ-dF0u5x%va`;s?a-$Ax$B;#JlX0l3gP#)-Hsi<53UUA zn)s&uq8CMNf4t-%_eJ&uBU+5_ZI-Fu_W)~^jASpubH=s}=?70#z?r|z*Et-BRmCPg+X>|xxfTjrQtqJU~BY`U+4CC$Tu$W zJk%F+J*%u!F|TO{8{g*OEu6mmmizOG_B}IxsayJEFr&n7S{An?a%cWV&cL%NexC`xojsSgb{#8-fuEi5;ow!WE?vt=6ps^PVt{oiaN_aY6qcbf^s5y=a-Q@P z6Wf8b%B+2$t(X88d7XdrnJkd|kS>k_4)l`d=YyJl_73KcpQUDN2%}}V0RTC{4e(bS z_IOFy)@8B#(OYUg_6tR;5Yfajlz@81j_=-)EJ=Xe-p`-0dQ5`BsgX*9)_3_)yE>8x zF}~(hJ{G+U(_WYbR63n6y*<{?vxfdI0PDzj6QF}zpH35qwegxa`YD0sM2YCpb0YC( zg|TtUYWYb&=9}L?VO}Xf9q5jy;2qwNHt&{xO7?sizXOHn)r4#uPpcE!54OnVSzqH@ zH=c%SVPMS?bd;biC?Q1jRYfaksonGM$D{kn!Vyy!3onFC`CARu!lK*x1D7Ztthr-G z?|06UWVjZ2`$a<6LBmoelE*%7%Lhi7c*6Y<2Qg((RRNYn=x(9)W%kvtO{r(|S?WiC zwd*0<=uLI=$G2a3km{T5<$L>7@V67H%!?Km0ezO|Z?&}Yu)XNRo-!;dl&Q~W@tCZ& zS`W0rO|PWm2JF~ZCUQlgoWA#9xStY>G`2?472`phDPEXRqN$Tm4rMq1~Al$1HntF zZtftg*X9E4b^VmTd%7?1`pw+9uT4I=c^B&x=?|<$GZSSHwcbM5j=U_SO#48iDQUES zCP1g}?_bqpp#YaWoWC`+1FoSyOyGdWu$<7R~+|3&&@Lls4ZJPW6 z9TUimJU0huyd_?1{(U|jp<6rGpsQTKykHSQUM|smrL{_Iqq1V;J{$b%ymxa%0ai!* zU9MLiUgPx_KPwASpQPdUHJ`@}9!XB|cq<=kJ|;hI`Y6%=pJ8o&yVstW#g&ASyjVLf zPkF`!Txa|Y05sqU(1aL{qbqb;`K38VBQ=B`P z^NXfqnhH^1JLxlhVzDHYR>i{uQ@nM=$c2HuH;5zn@~p>)&g27>A1{H?!j6O`$jA5o zax(Z$-I$=Doo#dG?V`SNbEfRvxqmJYK*|SZ`J!YgZf#gChyFQNq(nd}O?(R%Wl=w@ zdnKe|qKW#JD{L~$p!avOQDu*Z0_)x1v{==N_(K035@2yG}J z;&Cm4u#xsm8mBQWQ1Fd_EJS>8^5yWDf7U#}Xn~#ieUD%p;iJ=WluSuctdMLiA{#R`i2(`A^QT3-|JkGe|l; ze4J7+ztULzz%t}Di31lI94D{~0nu1#xcD#Id#VZAe2?tMc67QAAvvH79_5E^Ub+NT zA-wTZtVrJ|cC)J6&DY;1+4+s|i)TMEA5DVrF%}5IyN@(_@l{n>gJlYs7oyb#7T3vg z5?%+paou3!K1=hg*!MmcU~qW}Klx3Uw0%}huWT{a)OtfH`*0&}WqS|mdwy(&Y*hvV z*02CXhj=2)e8!cha)Z=~C{$pQ7pA&DOTBijji&>Z>92XfLKOa{T=m|xvcMi59ut3d zwhqCIpLZ`$Bg|GdEPZ+t zelSJFk+4_^y(ty)4FH+F@h6A!wrn>(5|3zdz$ef;7nV zzU+f@4+XYmcKNd83_AzNlfCWJzr1I))Dkhw4SmydGAd%S@WWi{O-)bZYj}KeZHyVm z5%*m|rL0}_Nb#%QJMROfAPM$8y|=fQ96lXGudWJ41Si51542Uff^jKK!nG$9Ys1PA zZr_>5tH9LQH|DEV!-_}?mCpn8W*RfSWh zFX$tue@`cs^&6FkL6Aq8r2=!HYjL83>9}8>YD&1S%I>Kg6t4_U=Ds#9c}}}v|6R5{ z#Rlyx{i+c{sV7n0gX9$6>jAP=-Rh&dWP_k-N7Kf2sVr1H?rOyO^jpO9%(`C^CMJCdzc7%ecQ&%ozL8iy4&)lnK2% zg*tq!I(wu^To1fmh5~$;7RHHUon_(pPK;j`l<*%MMJ(WwLqc}n_wh&WgFzw>G577A zusC{)E^5%!o-XI}CTXq&FA2G94=_9aMO5Jofcq(fG1Vqx7$^p#o0 z>EP=XmQGY6S0M2VW58-0l~6}3pyJg9kJD#;s>Wh1x5nHvzMx^7Z$de7HcdqXBJ^mT z4zE}0!0-KX-8BCiVbS~y-!`e-Mu@0!AD!m`0yo@2rzptn?DnqzTpaeJYjDX=D_*(5ei&TUR%7Ea`TTpDI3xIISviJP*PvYCoQL@iS3O!>#EN^r3U_{4|iqzL0a3k@7A6}sfUZI7aYKeO!6=kskTRWc(I+TnnTp0cIex*tspVHb>YU*bm^o3v9 z2?D?bBv8IMO$)DGwXgN5{1dqiR!QH#-OakKR_FZQv$WtjInnEiZOwBFN% z!G}`7yGWOOd!`j7a9Y`v;bBQ$nl5qIqf#x;J?#+ln`J(JSww37LaP!5*55r|nlD;q zshFMIz&8wa=+rm=Yi{n}4sv;EGT#c`5nEbG&lv@s$Ki9@gnvVP5X$s(oZ9xPw*1X{ zI=5;$?ODPA^tnR(_Pa>qrpfyhm_9E)qVdD<%Gi#(nI6Sylaxz*{$pnLPvN3J#Uue7 zZ5sP0IE8;l>da(}lxve&>VU{6YH-q73yKBt5U7_$46D4V{t~xbepPx{N$AM_>)>Wbk z;;aETZns+B_nBADuboi@MRl^rf%heu+4eLr#XoAc?{_x2NClUh%Xz6i;{wg_R}fXi zHmUk?2Dfg%%Rd+1>?KzKFq#~#(S=zr?sF_;I$d4joj6I6!Ibkyput0D2xD@ERuw+6_x>x+*2F6c=7EBf!k%Bf*zO~= zD50j7I_|lnl49V zOp>PKX`p8KCyoX_hlOy;pfexDp-z2=Mv6kLQ|-I{p@d+rR%h55mBgpgR~DX5pI3R* zk0+Bg)HH`wOEp>*h&6J8!akmd!s?C6p9BlRhWtsrEddNgoaMf;T&_F?Llf%Cqezgb zU#&F`w|LbM(nH+;g}xmb+{@qx%gjf=zhD}d{^Sc*x__)Zz1DE_7!&^;f8cKXl<%;Q zvAO!y?3d@|w3cui2FkrF9kvo>eV^0P(+-&6Iww5rIdi_^UwL{v{DS_WNm2TcZo9m( z_4DCk$%$NH-#?_)xkpxPy(nk-^Tbzq!2b>^`9tyM_obXkFyWhI(m_u={$^Wz9-oF_ z!;VXsf*?8bqs$}&bAM&su@2-sgIHX3m#3eWF-VpaYlYtn&QQ$Z7;%4m?Z|V!#Gb-k zq7uH>l(X*DfTpF6YKoq{G29KWhc)CyP}<7|!XJ5tAYUT@8>rEjWMAQIpuU1VbEnn> zLqXgp^LXby|GvmnbRh1*;;mNce?RMG+3|i)<}70$tzp!Gum;H6iIc8J4sIrU{Mu^| zXInS*)}ZF0O0>IalJz=%AI6i3HQZ;&T=PUmtLTcKiI{un?LM_cw&{({Hn5hWrfz3v zwK8d1U?<+-GRY+@Y(O*5%p4ex+@iAYU+8plaYFL<&)U0FYh4u=Y_pD zQR8gCCUa*uQx(hj0}Ef?v-%#Q32aLsRxX5Z?&}lin-S?JsEk7K|I9yLSql}&zd*W9 z?T5;k&l^8!gURG9DU70@a6Gx}u}Go+6=Z?1;==nO;{RgR`P3bkSn+bon2FLqs1kOR zqI?WW?a0$HqNueKLh{6M^G!M+-gt#0N0Wq4JsMM+AP%}u&^>=b zGsXcNXdjJ7Uj@u3Nh$T~*Jo^nb)y3P?En-pE~5{ic;X7#7{*6adMQ4k8DZ(WG2k5r z@o8;eLz)oLewm`*l?A#Nl%P^P=rd(194@?jlP{Z;{*dKmH8g!4{S7!eYh2y6L7ko6 z^UddjZ(`#r2W~?C4MPHArMd=4w3?TT_+X*<=xW=(8;O5=)H$MPm|>L`tgPX@RNk#X z%s3^#5UI9@%ui$gWZxlseM^G3-&Tn`RrM&G>g2kv94X^E+*S7eoDazxg&BvU|vcN1F0MXVrd19r3|aJA z^nAu>fu*05>DSZaJoeleneeD{e|~njE>b=~nfNtGOhoNz0YMu1`a3yyjGG=s6!zLHX0-bd%mx&Ja}|m)EN=8a?HPpabII637!r3BCP#GGTI+9CigB5zH#PmQ);c63t6~dH+nvD?RalS_@Y)G;xYI-@vGf?J(xIh`(ljGrwXe_E_#RzlY8pHcM5kVrL&Y*dwJhdv=w2Dn$`gWF7D4= zp`hnCxPmFvuNn|I!>=@899- zivd^|!35RA_{Uy0gPM>qFW(5>W5Xa-%UFP#%)}t6S?>}lx>&ufeUqnmL&L?JzZb2o zSOHp*t(>=ZUZZZ?j`M0^}t1`i9fjzVZzh81C`*YA5 zBJ=R5B-8BC>i6B68u!*-B~;+^3v80h&|%~0v@;D`P@#sUb-{Fb&j1z?0!iG9QNQSs zZ{X{YQm`w4EvZLvy-Ot@S?_>zMsMEMy4FL%dCtxh^=*T0bjm=X+#@-(RyPnYFcw$j z0^T08nN*p>$tf=YhO!h0KCX`hWEG%m$jDCtQ~*&YtX=9?eKBM=T+fZp?#6pCv%INL z6+pRuZInF~|C5@ikI_4S5nHJ~FK<~mt8}vJy9}Rd?Vq$afo#7^5DjI6Dd$!FQrHvZ zO-^fc^{kb5+TG@2|H48DLB5ja?T7st=9RMat2L5`9k5}LkXyp3XOlN9;>W$pmux@u ztd$M{R>~K4VSAQ$f2aE0Gj2iza*X5O%K^`lPJ5(E&hA#qY4X8-pn|iw%@3O3caI2A z=c%s`O3_)H;?m;(0#jFdp4yFvt;0>anA7wpJfaz6{(Et14U(&cXe zy#phH3NM-^&k*wbRT?Q!DNu4H3QvqY^01IYo5dz|Wp`GOn(kEZ`y$V+nVlZ8yJl$0w@o$2)I8S*}2){10wy*?AmF;E9Dc(ecS0V=W&@m6*o9*^U7V7 zoPnnV7LED!fjOKFu)Fk05Qrw?N`qbQ_>k#zA|krMCsZQ$i%8vwOFE{9jbq!T@h{2-OHj3!WqaQz zNF048eqd+;N(XcOgl#!ud$#IOkpY0|7s~K3Lq4+a^4rvk>11)+MU6#)Q{r(}OVE#x8~N^q{T8E%Qa4nY*cQWM5?8@2_d-p|_j98kpUy-1$wuCHYc)$HCtKOlaVl_!ZA}oi&LupBkow))8X~@cjLZ&S$>^6*HEHm1I>t6ut}%Ca?ku}z*)*UX-)U+a?y_M2NtHr zJ9WtCo4OxvhrrsdUJJ~8M*|YJABp>x#OUC*w0bUrLlm9dIf3+S{OFOHzP1ox3v6i9 z>>cKr-1lAv8SUx!M*kHOUD6*M(RdA|j7^e*V-v+)tOMrQ{KitLr0xYBNe)Eg$P||X z$r9BR9$Fn1PGpt5eeaJwWX1tV&k^W_bsC&b!h4Tac=&r;p=Xpr4~MVt-Kf!~yt!7n zkLVE0#DfSwYfT|l0bf4-hTn@5L$?NoXGgHOjnjVDw)|>Rcdv0S^%%##=(C2xyj7)} zWUxcKk4fm)$NQGLs|!to&*2UYKCm2&itn%DUo&YdkADB+N`Bv_=QT7I53n9lLo-v& zF)KBA8kVK$4;Dn*Y-f?5Pu0F(s%F;gAr%@`bMm$G!Lc+pawdo{&QbEo=+J@)&cf$_TR_(;f3O#p^l*-Amq-ng_zqmd z1lanCtMv1(iRlXDt*%_lW82=t1>PRtArT9YhUnSvZCSNfij) z4P=b~qq;}wGUC=b&MC%JRLDnXR0>f06HS*)@ovoZiRY^345|vmFn`O*p~{U4vByGP zbT%GtdUIxXeOcdy#`vB18|gqL1}3Z@?{%}*r+bYWf%!dCh}Gn1=6EY!yH3UogAZe~ zpg8?{bKj{+^l+lUiz8FuOz=62W2a4#vaC9sqJ-~NpZD+U`6OElD$NTO9rdN52#Hb& zdgaU?vnOTO_uHW)MyGkE4ruz4+L!zt^w}am4r$s=E3aHqBCOK`zmL`>4Bj-hVwprD z&(Wt=3@KjZHiP!HCBY;+-P;yCI<0%2P?6N1qf@bW7w+oH@31Dg8r-Z}9U5yVJ4j#J zU*5d;KIXB_@yGS>bF$QhdVo~nimUWP7JKE=L_Gxfuf^S9fJYf#Pn!$K$C;3zy>SUa zH2y7R5O902#W9=FulE?LHr>XUI?HaJkac2O$N~4ai@qMbh365<6heFsjgu$FQKRj= zK2U>tM|}UiZ~;%>ui`CMouAL1(_5*!r+s^bh@48~x0VACG?81~-m@nadzv~t!0LFC z?os4RJEh5% zr2Pj6*t-AzR13#Oujl#F?HBWH3V$P5L{*7j3YPLXNNxMYFV>l`-^Tnk#h()Wro?-` zGpd6moyc6|<41wQkaPZC$uLu|@p&ck*Poh3s7MLQ)N{%o_Uw7J5dKC2XRA^!u`U-s z7EhES!pim(*vW*)O`E8$@8d9j`fkT>NJk%k$ahcPr_xd>6hE(Ur~x=3QX^$&p(vr$ zQNJ+D^U|)F^kbtmG+sw5hRyk6j<{(13$^J9C*4?V23XJ{uqTkI z!2czU#6temgjRu0b)Z1P`Qn@puGHO#eKsih&q7X|Cq#$u4sca;h$dff{=JA}1~pQ} z43^vS`2{mV^LnL4{(>Y3FFW`=rpKHw94%cI??+DD%V00~s5hS|-!d~%#pC&WUE7%* z7q>+VD@54=Ngwd-pSm+C>a_Q>o!m4*PbE zx&IN{6!=`-e7%&YcboMkt7glp00&n8P0LAoYoa?#s7N)`zj0dwrRq_2OzdcO86rXv&Wh2Mr=Bg)T zvKdKFnG3qg`SczA>BpvCH%-BrK( zOY2$9vxD%g>eIq((y&I>v$~J_9tiWa8+SLZ;0(?Dt-%@mFhB&t7<%32sw+Rkc?k*A zyu-tw@EJ>Qhoi(c>Fp(~hcDwEobWrt>*S5mHh(}D{)2V}v5#TX!)puLFT=~3JD^}RDbe?gm^dHz zw#fNjEYo<+20a|)YB(9s9V>Z@=Tqd&4|zH}6&zul=Mdb_UpuRV30Q zwY!Y0Yp<~pQ0W|R$w+1I52vT}V!+z?g=2Gb#wQ1OVIDCa{T!`Jw2RHCd(<=JMq906 zIo`a?-oIL}G;dq7-0J;>*#J>HSj}U%ktnzHn#O{2*OuVAB!-75~pXb>Z zqtg@If&eHMm&fiPRVAYL*%INt#j?n8)TtSVc!2V$gS?b^_&T%P-smD9pqkyc{yZPu zqTgdjA`QL8zP=3XK%8mYR`?|!g|DA(a7OM@SG;~KQpb&<27n!I$&(bevz8b-3gA{5 zvR5RV(Lu~Zxjs&!Cwa~t*c6JSe*6Z>Y?o;Fl`g;88esI%ZIi{Ukc_Hm;2q*z?L1z2 zBfzNYHyV>q^oDh!YA%%Jc*2^~pTtem)y=}ob_D?yH=>$964wZd7@`7{g*mv}X8h-! z7vrc$m%yVHaHIC9L0&)Vxi5|%|9Fc;Uw)taQIX$DQ|`I&XJq$E@kLor!==TisO8G= zkwK|PyCo1?GSUtX+^_E?a9bJm9X=4eaj#0Jo@sDBre|!!Cg9HBkV>f4b!tgUE^Y+m zMnMGpv4?JXj3%ezht_r?CrJCRHME(&j0CbYMr^_cPcfI~yAHk%dpgs~?bD_rNMw@! zo_FJwt=X+nur*|BQ#7m`@TiJmOSH()*;<<5L+@G7X!S)(!h{LfUwekN*2?;5wVz|j zNJWiEhJzxskCDL2p$l*Iul-n_l6oxJ_&oP8IK*LnJqX{aMw-hL4aQ5zd^n5lwPu#c zLb6#Dd(VkHPaBX*f$jxs65D&4)js8xzIo|0k=|zqKq2;902Lcsyxir3MAXB6A$nfh zPEP_(R1HsSnoVAB9p~mZr8vFSOASTr^XG*iB-x9a`YcRpE_!ufuFUG-)&9$Vl_&8L z`gFY6#UJOq-PeA!-&Y;SJLMN-0#y})v#Kt5?E6{@@$z1Y%lc`qcCL=V1oJ`wI`sK= z-!1D(Rv*%V(;V459+n=3)ZqSE)GJeC<`e_~0P!a2LANlfi}cW{GSc{QtI=nL!Z^x# z^E1W1c39L9kpa^pYwSn7(LHGLS-6Gd^hSn&ODMe(g<(&|6rc8usD}zsAB@YCSFaI!geL@n`Thw2e(q76t6N;9TFap z8YkOMQG5-kM!z3fa;@A;_}sGvQP&HA8l5Mt`d>TVsz(%dse(6_w%}c$iJQqVzL)%I zKbaeL5Q)(H#a1t}t)G~sRXIr^o_Lshaysl5mdYJ;YS8YlJof!{ZHD8sTHMo>JDyGo zR;1jiT@^ezrz&OcBdI%q)^)B2VXV|%K%AT=E zEZcP`tJd^%u+uJ!qmRtkm+$N8IFrv^>x`zP&m%=)(RFCxKSSvJVPkno;TPac9Kr6M z?P1vM-BoA`y4+3nRHCC#ceffCz^g16yYZf6E`tV~Ugp8g^XlB9c7xOS$Ado`|OVXezh&Pagao=>US%$9`WUib^ID{|Xr?RHGz#0iJ5%7Ud;6MYt~S|r?&qTFHr0d2PMXccy7!^%j8}BOFfwU59$1R*$zt!C37iY zAF)rsV4_}wi=6wvZUzB1cxo@~&m;2K=T882xZH@k2hvj=-?v)_ILdYjNJgQI!518r;9{X1TPm{$2;6*Zybp3P}=@7lXb;=P~%v1$a z=|s6_%gMfefQle89wtM_m!eyxwn8*Wf%;D`Q^dnuN9=RE$+l|Uukfx1qCt7Qu(@n zNF5N%X5TE*iD-y(s94x3F6Ju|Ic=D+Jxbqs6IH00&u0G%HBs>BCmE?(iNs!NWTF>3 z#$h|ig52I!*Fha^9ZkKXLVsVsc8eKJdMRK9fW+H3+Pj@962~5|RJK&d<}%Wq6U$)t zlw+MWg!&sTlZbC|5r=2<56#@!xAUvl0@!7=wM2sD#KI?Oma!JFqt}vOKS(S!$b&X8 zch!C(GOk1WUmd_fN4M;cu}fB+^7*wp5Qq3b$Il7?^}uClDWQ^gFEg*o5{Ts_vDa< z!mIgfeVrJ!ko8`MfcM>+D`DUJEAMvXMhmIRI$IX3ydig8j*&DL3NhgQFh0jtYV%x{1O>4$9XSI%M>6ymhg(YBf^TvApg(=EB*x4kS^klJbOzPl=~j}bVf3nB zFX~4-c<~cM#r|9(2N!?b>q3#`!;iSKa*@K;ar`VZFKSQ&4e%V zIX~S#PG-Q&E_Pq)m4Aozx4hn@xAin9D^Q#S_#CddM@%@|kMrj=tj5dlvx>j4i#?`O zqaaEr&wEInFI)2Y%IiPmAxlcd$2TgeUl$uNL{*h^ zMKHl5B?RogjU`uouFb)#Qw}&tMUPI!O~iQk}fLB zGer|-Q24K>nUBBkx)S%fgLe0>KZ1a%a-_0XO!ybjHsP>r`zT#rgsl>QxlSA})dSO& zGVLY!ABZT1Iiycb%a3i-u|v|930uJHC2E{Pb^jNGa+B4e2aZFE!V&T-&R&HG78}bC z6@8#yFa3jZ969FL?^_;z!R|2;i8PyDoHLEZp4vZjP7~2^nw>ki!+WqrhiX6@zq|B5 z>0(7_>XTu_ZXt0qYLxv)q{rm&1B4$2oWF{Fjo);YOO%EXeyr!anR>&;V0ieE&<-`wWrIF6wG_)U1#>jpz#8p2b-%^#ukGa=VcpS9X@|O5Vew7vAlA9*U zsmqBwk&VCjCIuTZ9BFu9F0j9miGM@+N4%HA2r@N&XP=qBpl~?p?&fLIEYXPIXjtd$0(}%P zL`dvTDKeLHhuSJ#eXA*6c5|l)s3(+0jJ_PC?&ND{z7F8K`Z_E^Z?k@Bd5RAscVW{h zjfmWs_=lt}^KqXQNVeG2_gCUG<}q!E_MK~e+{YKS)plcLsTGUoX>mk7G=d9rV^I^_ zV=qmL?dIGLd-0TIc>qD#oiY!N*xdNDb~tNh$v@?r#lptb;dXK2W2O-4EJue|5F9lB zHz|8f?hCBL_>a`I#T(_q7Y2_-+#Sh-_@M6>uRa(1{+45=HVkOYiCkhBe0wa8>{S-g z(q`lw(17k1Q0ACl=uQhMx_>+AQ?xMwqLlVjGx6bl_NUZ36i0qvOUE9jiZ%chrKN#l zj6Q!}pRXP-eo6e)u0O@^Yn()PTyg_i*n6HbbUb9j?zr7;NU9K?sWe?{%GCk1p((8TSaUbO#5%Q#W@3vdVPcYr%(WKq^ zyh4ij`5Ef&^mNGIgL|K!hUQ1>gL1e^LoY(Ypl~lCB_JsyZQtnOP+$*s8HVvDyZ*N> z8m@zhvdiOV4VI7U@ykK)YspPxS%tRCJSWc=G|-Qgd)I>nTEhEb4*A_kUv}h(kZ@l1 zD}VNZ!+q^3JK*xryrV8>fG)%CV{3ia^9L*_bgtn|d_139F&P{E-UA>|FQFXaM`pwr zvRWRqXlmZ5;_0q+m6)VV%pY&+B^h{mzx^h}4k@r-_B4Z~kWo*@!&fB5+*g?KH`>EX z{~G@ct36KxZ}FsQ8QgvO@`>1?t{q4q!_b!d@}4;ALxiQ=WE`lqOTB$Om9o=T|DtTx zrR-y|Izzudahp;yFWFC>E)GIQyO^lrc7 zv}igk{A^VYAAHr5vniSwvMRDc@OK{MnfWQY^V9rT5kVyawkj36p9c~*jTDwAEAwBQ zn+?-FH_X7icIgYCSZSMI{&AC)hZQYM)7|%AfLG#I004^4D6FT)jPM9p>(GVOnGrfd zT2~o5AMh0w7I_R{bOo)?>0}Bz3~P>#`N+up6c`zO>wa4cJ+2kLG7lCLzQ3ym&4n7~ zhA*Xe_gK7_o%?oC-oeB-EWi&Bm=XFbvyVfpbM|8*)v=jg}J?VWSZnyRQqSW#O04? z-al=7CK>)uqnrp(#lEHZ2E#%;6)7J8H~z*{jd0Dc0kj?B%p@J=5KsfGN;j>6%gHfC z6+j9@isliV3G13EjL>-zCf#$P@NRKes^NXLK>1Cf!{#Kjk4JRx`?-f8^sL9BF$iMp z{BKj2?-`siy3_!GKh3ngW}he%f1naCs!m5!22bEWw8B6+&F#BXUf6*dcEYaz~Z-3O499SK4frA_eM7@^`plm)O}Po&u{r!W|B*YyZQO-=Lx! z+2L0F%`$fh4~cHF`nP-EunfVff5Y3L9#hEXckg<6_1lGCpOsF-Y+K+jcU}L@{8>O9 zzAqA(f6{OaRoaGnVdE8#O$HeE`XRK3aySI;qw;xaJXGa>PtKAk43{h$2@HIqhjogw zSf9ucXdB!>@`=Te1wzSuw9yP!LQrVW^iy)^Ng8+RAG**h)AGU`_rPao&23|jlR~1#clKozef$TnRr>@Q{vJtC zC3$R#?Rv;!C{iN5mh3prtGxQA#}busIx+%j=wcX03N`_io|2?h4!SU^nA$Iep*Yi7 zzf>6L0R1FS2O;+CY%ge`kbcIM5%-kKoK^Vy>~3f2J0N~E67xeuNo_Kcl=&jcMo@E*+w?XUgdc-mRDm^m%J4}^=+dX} z-@cV%=A;vMjxk@x;iOwd+!8;+zv+aOC&#CLeO*-U$0i<%edRu}I&44PS!&t$iJna@ z>$;Og9h|k_ccEIw06H?p>s7AbuUB>AT6Wvl^|Db;BwzMDbxT=ltgXZ07Q$hKn~_Zw zpX2fptA52-sFvJNsK@eM^5S7kUfwKR54rzVb3lX_wG~;`Mpor zpD36{Xpr^v;t@HQE%8U@t4Cnu4NU8lFdlrp6g#4i5ep+B=|?D}W6M=NL#L&NK>Tqp z>2t_wGuETA-mlT`OX;m$9cjF+CgdIW_)4W?JUU1?)Jx-waMGdhqSu4)?xT3EU`8pT ziOX2URR>kvf6QirW}@K3!=UBX&1ZhcgDn+84)2^^o&1|DK;L0#HUSXJ`nGW@QT<}m zIA!DcO3n|=TK9lQ$}Y^APO<&CCh(x(!b?J&i$heFHOC!{CBKj#ZaW>l0oB<0P0-F+ zAOQEkG~wwhQW{^;1dc@UQN4BhPlC6XE@Hadi8a&2Cd30gmcAt+lqpQ_rw`gj)?73+ z2KZH!2H}+ElJxkQg%7?6FWliLNAF1&eOnR;g?Nm+*!tzh^Q~1(cP71gL4G!bCqy(i zmT8J02txz(Di_-R4|p*({6p4iH(jsKvA8}^Ks~+3GK^K{9YX*N3#k<2S99LEKross zV7+ReA3@N0Ssysv+Y2)Gs5Uot@7iPyO_@k_% zI@|aC22jIucYSvbari0Y0S}oIqTl@z;!>*XbApr-JQ>8b3~RnKR|t*sL|?H~-ojIg zx~7B&oqwyiZ6FhKB2>YlVSrawS@XUQtD?hyHsWT4f_=}gTL0U-*SpHLKR@%vy#TG z-P(}_Idt*l`btAG^Fva#HswMJ-2wNf-&Kozg{?~JpV)^>Hwm*^DS2GxS8TEjpCrQF zHubMilx_c;ye7Nb5Q=A8CZ`(&d^KF=%V}U&Q`~ds7cvk?mM=7an#AarfZ1wMhLH(F z2iag@&*W;z-Ce^78l}JI&_x;Zo{B~c+ead)(-SWz-Hk+$@jKUIvvp#LmFX;LQt7so zUvlWBbTyb* zq~!#$Ll;nR1@=E+)K zr<*-TXFHcuSd;%E)@6RsW)SQnfL+BSPP`U4bNcF6y6P?XVBodQ3RCxDdJ+yN%K9en z>hja@lC?zdJ{ZvQW{zyMFMveQ_rI_%C9;lzhDOY9%Nb^MAD)JbxU##S z!*_EOjN8Ov;8`Ws@&VCa?#D|39O9Z)m&2<}+S|RpTn)#BE?Rn?z@0)$_}x=um`r-f z)o1PaL2=DxE1PSL^gbj5UcW-|jCdtLSk8C8n{!!vM-ZmP*?>KJBJ-Umq_iBY(jEZY z=Hrl6Wn4cs33^S=jX%czd3#y+^{B1;;yw25gA#JAE{Xp_UA1>>QdIsltbj5lb8Y{k zxJ|WgpQqBWa?44@p}$AxJ^-zGe~MD}qUNfwxfX^w<#-0zq#DApWgZ@TMIbN!zt&^nZY?{04ekEchO^uxnx zKi;+jS-N-<3(Ltb`#nX3FRo4*6@~vTVD=kCu)cb;I5nIvk;v|+RHoL4`Mp-YtuyQT z+Pv=BxvpN$`b!nSp3S-h+E!^yo3kUbqB2vz{-7CA@#s-T`3{wv;X4!M)TF-;;*}mZ z7}PyamP$6DL!27*0B(WZaaUNh^vT*RMU8h&8O9lsz0g|O-y^}_!#62E=KbZIfrAj7 zSKQGADV!;GyeO`xoo;NxWiGfX9+`i>HZSiPxG;??WFqV(!hg_|Tc>09{Vj?hgaR$W zSgwcx`mf!yv=LURRV)YV~ed)`ZfqtLDe#RU|uRB?MuAJfEl^9fjx5<$kz->Gko_)@rdF^3-vr#CK23jLc5r z#7?uX!To2#GZ9VUR361eIuV-dSrc$iCJ^3$c|dtJ3cW;rCa@O1^c!Z2e;x=xPQwTu z<$-iO@j7#CAW6FSkIKqW?&9Mrz0lVIg|8b7E@daOeSCZQz3>Oem9&Dk~y2o_uW6J$%z(YwNmCF3TH@?K19vN1NNl(FFv=JB2u{(CT%$Aq(gW->?vHcnKLJk3a_}B(v80y z+Li1k?3h{RoAm*#9N|)HO4|!DZ$6~5Z(fZ)mv#DTyAA1qv?Nt-?dMPIVz$yDo$crb z87A%NF%Y1{4?ap;LOtI9z#XK%Wlr)C1@VORQ>~75O8$;p1JA!d^G!EVI=_SFXCM`N zE|kk97aGI71L+F!6578#hBE&V1{qeCqIneDx9H2kxtlNTOj(bE!S-26+k_6yWcf$w zZ~i=4F(h!og0=bVy!TWkgu>LVcw-z^Xx?Me?^SiZ^X{cT)gb)+o3yNWQ`koLbPq`8 z<|3H)r-AN9OzCzw-O#c!kCxOpToPkeERf?R+`TNM-x>^6VY`yRYhot>i~|5jb2*78 z3r{*VO9f%SROPG}dNK>~dBxF1vM63~*kU_7UU!l`7&%g!h-n->|04C-n;4bshZfGk z`bBTEQmJzxUM~`)0Q$r@_r!X2PMT4YDUJ z^+}SLOCfZk9nSS&Ypm2Lt!xtfwWzO;kn7La{XVW{TA1_?xpk-F0prCHgGDWQstw|t z6k!-8u_tYS>`Y!4%wE`VUw8i8`a6*2$7@p0mmFG1exQi1po41A_>U8}K$4!C#iHKx z-5qea??}o;TUjOV<0(I}6O=pVJ*TFgCf2ckmoGzsw!9DB8~BW&mBy}`!!Zo{mk}!o z)Og12C*_^s?XQ|K!`&`c+M01^49Dzz8AJIyc^yGHWf$Kq;$F8}I6OGL?4dFrtaIXV zyjpC*rm_nUC)G=pg0{_VP>ZeIlRN@hP@9(1wpI{nH53Y$PrtoWJI`*deRsDjTPa`h zrOwtKG_9iYmgoFo#4fbiqP3o?3n@VS#KLq{! zJbr)JsI6?dufHF)Z%g184Fe%30Dgs7vz57rJcVSEkMh^b_pfZ2 z#6K~*x$lhj2>~9=SKO~C!PB?I9gcIrlUzu{8<)57`HTU?tudW1_IG=Hpm%zoAs15` zArt`><3Q~q0ekBD3v9AK?*9tyF{7rMuthjPu7AkRy#%_poN1ebW_PY?AI)b*s^s8^M_ z`n`o}hT(8;a!iYCT<7sKD5r}Yy=nE$*dKgL(i6p37T=;rT}k73GD^K~hQ|RpOu%w@ zvw(gncO!gC1oPaO=NDOjeP~@HOpkP3yz}$hUxJ#+_JZ$i7DRq&OB=$m=+>(Y7hJU> zewh%6zRo^mmu99cVSuP@7rnU>=G!|4Jx*f({Vfe_G*$XwK9E=hTo7yVJlve-JY69> zp1<$RF`}yacKJ5rerJP9^TjfR>%h&2FoY^VA{-Axpy>e)0a^&s9=$$q=X%vQVieAt zRt$6TSqR$w+}sI$Dh$t~lNe4(dUaId3B|43MY$RDIHWJWKQL>aUwHU0U-M2%dzD}6 z_j;)Bu4pBQE^^>kE{JY3pEHfar;{zj55g{so9GJ$5-M| zUWpaH1tEQp&l7Tj({GN!#QgP2lUM+|8u)^a1dRmumo@tx|Jd%3j=eqAfIz0+*z$V{j3PyIBL8qk57iw6r6fwxR{o_UJoNQkk#qq9tzIAD+ z`tuA?Pel0z55G?*pgSD`|NKEt7k_E65Lnp@)oMyshPir{OLzLH8NwLLgMtQ~Z6SS~ zwS@)Ih=Sa%y^gJA0+&)JL=n=lby6sb*FY4;)dU~=Na9Y2})hI+zHzB7VNU2Ty!a*a`diJ-2==LH|5{f-gR zO%ROv@@ReOd%5kY?vB|2^|WknW7`Q?RUx>koAWAUS}o!zzrDGJ@B)yNPp@Kj7HI)X z2!fuk$7NubT6W8$3Yr0=nNnHe*`GFO`uXC1?o<6ac8@wReRdDJz$qnv@)!P2f0Ddw zfL+Oy`$t65j=<&Rdh6f6w69BB^?cR} zL$m&JtUon>Mu#i}-oQC`(Yxr^8@ptFcOVGgUCL=|$q&7Zwt#{6z6ZeZsJw~Zz=5`46iYsF=owgDf548tWhFeS_T!pAZik;Hat?Et zwVMl`#c1WZm*0y4g&+Y53?vbYaG=1vqnt+@cK0Cih9lXo&FA$?`S8%kn9dWKKi$UX zCVRY>oY0eEe(<*5hT=#*+tGiu1iI}$=y6Bmnn6R(xErs|z1)w`{U$uN?o7Byyt$5{ zRDGoN%3CJvMbC2}%q)})uL?GPy?|=?3{GC=%Y=P$%VxE`XjOryA z!)<$X+j-v#%S?V$cX`!ryU({OKp6d79pVENPDVs4LRjxl>lVBf6o-L&ekM_oC6%kg z50!B!gGAnYLz#RFAxoFU-n<^Rr-jbUd;^%Tq*WYKYx;d#Q}(Y70;pya@Z$D6`7`=a zpRuu12Ro4FJ!E~^W0t09Yg)#Z@wQKfaiC~FX&u=D6D!KR5! z&^r4Nzd$3)C}Wm|et~#>s9fhyfk)J4=BY2*3hZ6-i-3!Oz2-4fg+KXr?A&Zt1 zlO9O)bj}M!>l5ejxlbKEl)bSNMkw(5@D`Zf!uojtV$Zxr+TQ;HX=TC1^Es9*99?2}qL5r=(2asstkPq;)dF&Sh zz+_(Qf5p-h4>0(iC_tI59GJBuVeWf=KN)kc+iyJ&ENOMlA{mg-M z10*Wm=sh5UN~);O*5|NX13#V%2aQC1xxFHz{b2qZZPq&3N%e!()Kz`zF~Ect`bZD~ zfZM$%Qdfl+PZ|`rir?JtEe`26y;13i0LUxv5o+Uy`Mz-$i{ad5-!RPT8jor87u`My z|75G{TFt_I;#Ff4zDe(&hl@aCIjQqqr}j-XcuGoYE(N$R^#`Z_lYsjFnixaEEG>Mri#O& z|4Jmo?^$fG!Ql4i8xG9Td!~BW2Jb=_s=7UH<=$Dm+3p%I zm?cQV=}#>m8!Ft>w0$j9oDc>Fwli#xX9)QSa^9nYgZS6@OoxU}c|{LdKZ}xKiHH~q zo0@nT8*(t`5t0uxNlm2)Wb z2ppUyntYMo)@xmU(_iyAAGT%~cH(37!U0^N4ri%8C?Hmf-~QV8>&>F-ME8^Z_zTn> z(qA&eT#&m`hEG#0brvJt&rPn*Aw(6wL6v2oCM8>Ro~Hy~J;!lh664t}`V(bv*96@R z=~aAv{MDdS=)dluxn3k@4*UXF0exT-PbT}{pwQ@BFvper95pjk(oLzq=XRjwZN`?4ns*ZmI# zhL_1{YV|hBU;=$NEsY8=%$0|DPk;G*V!s37KXT~35xF+#fIZHw6V{^&wwa9SFp^5`Hx%jKRB6qFOAa5aG+x4X_%?w0i3*X)&9C{sI{ zjeJNq&fdja@YoxS-phHFIy*cS5dM4yUvwg##|98*LEzG&IEniy5=MATzwoq)Z(pf& z#*&z6hl3rO9j0Rsw|G2fUn5O=Z9Z$d(T_Gmh{(6ubqnEx@zrr@O+G6p6)5vK(B@>C zrBM>dGu`R(z6l)J20@;*14j59;;qXn7D=*nemsFl zWQae*{y=Xa#-1CUfBE#bEIiF&DOv5Isbuj|1FHHH zgzEV&-IJTsk1F%mBXm~m3B&Oah(5IB*AMU=%;i$v1a$LIpv-9xW<Kzg3(d@ zJfTzFQ0Aq(K!TiHFMRELP&+BHCrPUZ2lxXMbY#&t%oH3FS@!Plt|8yK!pq1jOM_dy zqdTwp%Qrs&6YKB%;1Q%EEql-B7_uT*tIM?nS#uv-S^>xj8dIz0S%L)dOY@E{y_WpOVy9@D~$x^bAUDk z?r1@iDN(cJT|?SSmiKeyulGx1Lt2)WW9Ly{`?Dv~;sLD~BfaA5JjmY;8Wg84%hB1C zLy=AG$3t_55$)Ckp$BgRnPEipHfA1|iq;i#v!F)8R@QkS6W>u52DX|{bPtu9eW?0E z%we*Au6agr-_!|J9+3{X&w{QfXL{KN*nbg)28>Ey2Xb3_m+6JVh##B^klkVx?_I32 zE3UYNQVmXVcs%-F;W-3U*SMD|3Z^MefO*Se?O9`ta3y>Ru*RT01lQ%6)5G*49BYcV zHj!7FxjTiEBt(Ft^{{bRyEsm`*e2ogLGx#srU*X1GlTgi&+}ryj}V^F7c{**HhT@p zPpzo!j6TNG1a;*ggy%0#^?rK2jk9Y_-fMev`GO!u>Xp?zfYqeJ+o>h-@%E4SPyn9q zifMf|(H;%z!RLPyUn8aoVms*pHIZ}gdn@ot4P-mx`$2xBB3c&sAQt5Xw*q^<6a$UH zCe^cNvUR?|AmL+1%kZmW_%>m;RI@$>FM51Fvg7r}_Ae{LYQrnki|aut59Hwk8{s|cij5ImCHxJvuJ7dL`CMv#0uM0MRsF0v?9$boHpY~`I9_tcl<0MK}k6ED3X z{nX2CBoVo*iCV}IJMiEQ&dLK8FllkwB%@;=thp={;bl%=T;iOe*e=*K{%q!NS9N6V z!+*}(!@sl5GAh6Q^kIM~#1LJSJEkm7(F5*Gl%RDi(bo-+HiPS0UzN7o2DUtSdUzd9 za&7;o>ysArx7Wh>>0ar99kI;b?H^6owWX-GMSqDL9zjG@l7N7`DN#_69KQZes?Qyx zy310@JFLCd48JAi7`+1TI0r-jYCflJUgpDtR}#pGIN(f2WKLtu*+nB%>wJrc_76gX z2ND9si8s-bWOH5>vKUACGTz@OYd!)8195^=>=PZAY!sM`!<=gHH`Mt4w|yA!&#V%X z91{=Duw#5iMd3cZVR!|@VA_|tpnj3JtHQ;50ipx~_&C*lJJ;)KKUW-e%Ql&(=)k>1 zsr3;K@7t?{sJbz4Ny~g`sqHkm{YuIbz60|Y8duoCz|N|4DIOY*@zKfxWJi2hZ=bWk6oW6Kc_sZJ8&K`sZKlkoU_o30mW*Hc4 zAO$4z1yB+0i>XE>@DVQ|e(=%?ov}a|fRK0W3BvIHoG_qDS4dI;c97g}-Jf33;Jr?6 zoM`}?^N-SI`ZL>}3cGuVbc`A=-t=M9 zW9VU!EcS8R*9uM+q)G(Rp$GHuSh1)3Qbcxi!$&53M+Sw%EcG!mf!zOI$u*{se!7n{z4@)R?iXHiLW_J(w zqZu8eAFXrG2ix!69;f91{Pimj?JnbwZ>>pN_lwkRq0lD{YJojm=0f)#7!rR7mA0T!;a(Sa_{{EuQ57v5yv(1D2R@@oepBeK~?PdF@^TV~me(8ddNL#F#gL;k;;wUze{R%cI+1 z-J|}-quMycH+i3*gL)I8jSqqETLWERdo^8;Gly3NyP4?4U}905)h;&}t@EeH?f!Yi zvf}=@J2y>X9Ny5H;T)>nju)@r=FtsZj(q`GzX_j9w>?h0ua&p<6n_@}M09Y_IGnr= z@Qf(5`v(3PN>UOp=Up{27TS}9X+)-Kfg9&6OZ54S<=7;>h-E!IS<*>{V;cIpZL(9? zvG2>m)kg18UCQTJ%w6r!V8!5ll{1Oh?Njn6+e%6V3w!E;g3&ca8&u=gX~#MU)9oVWhy!ItBG{)0CU(o3AIeGy5~ z(7{z=gB-SxPTb<6)h6h`lP+cSd2f3@r`A$YLEN-kjv5o2&1K1 za8c!Snx)4Rk)O=Ob6Z~_1y<$X>;2G`!M;H2vR^hAd2mzJ8}OgM2lspF;s>-oXsI7P zYmt^iBk9rYybto!=tHZM%>+5JL&h3>(2;+9~-ZTiZATfvL5LZxoO(yH`{7a#^bxV zTp#pd3oshW0oSgX%#tc%{*@lT@F&aPdDDH^J!C2Wt`$3#UC7fm14`GR108AYxotKy z5g_sm6)rwOU*w-ZpqMn}3-RH1MAnDAd*%87*vkEQpWkqC4iC|FHy%~LP&hd?dmQQd z5CYoBF9e;D$e1|jFUm29?(xxg+)=16NAR!jcML=-YwTT0hJD|W-g@Sn1Thdt$nSz1-@dZRgx`qW8S^$tSE5ZO~e3m+3Hs2j}JvNoPR4 z35aMzE>4)O$GQNZ4rygb78L-4y!Tx9pnCAt{a~Bd;U3(O@0 z9~iu$<&k22k!z8Ukyl7|j$&Tbx&ihVW7Je*yXZ8DeHxB*V4zOSSg+gi+*7(g14-eh zXdDf&)6C1tUI;8rHJ3fvR5U`7Q-@=nGFh6NR}UWsR0F5MfHbhP<(tf@Fai^Qw#gww z3Rw;NJrIWH*A=oEetw?^82fPIhkS*=`vgzecZKj9hy5MSG{L$!GjkX^Vwi9p`6dgf zw?(Ve^JFFyr+U9}pWJ-EjzRd^lO*b0sU7(U z90oPv&uFn7SW$SZ>ZIH}%J5NTUhuXn!(e+s`~h`2wlKkI-(UN| z#*fORtn>E!)$x84g^WsXF$g1h?Q7^e$udM;TC>kU3p2hbSPO>=aXIZJ2LVk??)NTjd+=4Pl`!;yC5ujXIF(hK z`3|a+!g>~n*5)eg(uXo9249p)w|qb-BWFtLjeFm4hC;CR5XU&TaNg<+uKk6`{L0%t z&;L>Dc-4X|Z(l{4wcq~z4uv;}_%NIp4pZ-`jtP5k32H zePf~nEez)M!P)Xl7WTt2WG|f@O<17(2)a177p>p>lV*e}s)xfER$xL7hq$gG`a-!+ zACM3a+`IyL&?|2?p=B0L#&iA~usCeZU8Qgx_H%O?6-a;>w@HRiTEXP2RF3BGxvd31 zfA~+LdbCHKC*TCe?%=)H^m>2z0vyG^`&sCYC++hjUN_eDGv66mYBc{B9ygd8@=hWD zLD!PD+r0gW<9mlgFCI^^ne*RHji@PDKI$|-eqhO#49_m{(9fdqQ~Rq=eb1(_+QQf0 zAGcAQJBCl7p3UanxvN9!vi9Hl6h^R?=brbYd2te6nYkkVM#}H_)XvasU6Q8Y2J+1v z1(jOl>-$TV>DRB}0h+}r9F+AlBSTxuLBuG_FZ~|BecJ#87j_%t&G(oD;%JilJb!3! zIZaH6TJpoXt>;<2O@biY6X;p;`n%WNmjE?YfN`s95?lu7c@>9WhP}3aG$D{s#Q}Hva#(x z8M-XD!W8DG@sz)6aE1VWvHd(NtfWpRCCML94@cArFQB=epg1>L5H@;AB@It?ibv-V zfX??0cCEsmX4G{x1Mp}nAM-)d?DI{AupzcyQ8GK!F~qF!2UlmMjkx{Jm#nT+WK6_H zJF)GJoQ~IC8mn_tyf2eD!?tJ{Vnp4KuRGA(19lB8{jK>|>i6&4#eg{Pg=A^<<*5mM z&3>?m#qK{&KF52MlZWPc4_u-aC0W{sGlTvdtM~Vg&uFBkeydpT<@C5~0#Z9fvj4V- z3Zht*_D4Lr%KND|>H`lEtnaKnm-_2jX;XOqcr-rJo-Y$Q=YTLTOfPgI>d7a{{zIDg z(%>W=bB2BO7F!1)3;nLUKbYP-bBC*>TkkiiKosihY_%IWdq;xA{gXM3s>UBr)e?wB z^*)|c(&@5@h%017>fADisgL^XH@cD8WUcy^Bolr^2XDCPGk3de6MeMB@BIopld-ssN;U^G2KYpAb$G+j%UbLa;QEsPev#aDC>gSRy^;hn2hEsAJ~KXTr+ z1k&#EKFEk`814H8Md6b_EPbF7V|vzpvd~};TANe}oZ`HS{Mb`*@`AeU8;K=SQKUB% zIFjM|Ai|J(#MaR(sAiI|s}+n!{Mye=fPEq&5mo*Q_}{f2Cuf6#OFY^Zdr80*b5`+` zX#LZcvag{1(z>XF?FI9T=#&{dh*Z`4q4){wAzn`EDFcpNHR3Q4JTPaE&R?6C|xT{22Led-IlK@spS5KD7W$4D#) zqd5}1vSV*$Y$N_Xp*{MMVbDY4iK^x01YXu1@wJ>%z15fLk^>=@V5XFZ^D5DYgU+tM zeH=Tw^3^ad3tizx$XrMFdhTzd$rJDg)}Yh2{A2bXvy8hcPwaevk#go4mjZbC^A1mj zR3bw`<<q%rRvi*Yr9>>1_Wlm*(8<35*q=kt{7$Qwvk z-o6x~vRYp)8;R=gF5XA($iRCLUrbh%xLMxrrQ~fie~Hq1kuwA<@^m@etj7+Bz~Nv? z=)3ty&cFowH|i9hd-i(Z($?Y41HHo|&?RIDV|CO`*ZZxTMH^Y7k}aV_5;QuOurM!F z3ew(u#c%dTpBu18RK#sxWsIR5=yw0Ig>htI;LRDy-0}5Q1nH$oy76&>Ml!#7F&AD0 zE`{(JHd4VjreMmn1%7nr&&u8&Zk2dyG>qx5ejkh$C`UkY1@DExZb>w1zCkH18S<;HLHz&j|YSDZKUSFb&wX`}BGv z=8Jm`py}iCxf-WSz6Gm2>IwF>iNuRaFRxQue%WRZR3$+hKZ2t0KH&q( z6P1Z}Adm6VB7{yuda|wf&j>~)fKFm5LH)L%H1YUSHrK8Zecc**7_kPEyCM;tn?WA40Xu^0*wbUB|UoX~)dZ^CfTOc5418L;}T1il#U0=inwX@W?>wMx{NQkygw{dESzJxW$Uh**1(LzNh}FovJH0XVAx zTZcV8(l7;?+hYD?QIWfw+a48t{c-|fg-S=7K=`7D}Pz$87WOK@+_k*zQ z9TN0a4}C=srKLl%eUC7Wi6C_63iXwHu0S**oa*S0Ya==F=dG-^RtC`RyPTl&g{fZ6 z2v9HIigQB!LmFfnG;VqvZmqaCjc0KdwfL=G#vAf}R&;bW`roF`r_s)?WPofVr zm?LyO>%|~57d~$ozFZeTzS$}_EqeTl zNrHG&Iqeew>STn^7W^He0ItDlQhQtQwh`Mu?81CQ`)sZY@|cXA+F^3nT*Lb%yJouj znvtmw4e1v1;|VJD_C_~Yo;GQc?%t0G!;LoE@6`BnGD1+K13z@I$){&frR7o z>xpdqxzojBrui$ZKn8vQR6T&f4SdpZ9f2sQ#7L^yC(VV!$eAP~-q}ZgfAi<}YxWHk zV-d+X*Vl*t9x|{>Fng)%_uzE}PqiUzf==<%6%OcPs;JPm$iNETnN5gJhu7a%k(<`_ zhm0gf^+vz5pH{Ti#+AguW4F}Mt;XGKI3;1;SF`XqpUyCK?r@QEzU2##5NT%($jXCW zu*ib*`tT*=hiQCJG1ZNIG_7pcF&pshzS_8zaz`}MNEmlLOMn3FQ*p{FggZ8Td1m5L zr;q``%C3_98l@hxcdgZU1;+2Q5_)0g5Y3%01EL$XyQRs+InX6(L6d~!-`&~ME3uKv zV<{Yu#Klbd;&q=Zn}5Jf!VkmbcB!zg#I~&QTZ~#lq7n`gzo0k6R&bav+AIoKcfEUt z^PU26_S{4`_j>m_SC{1W)?yI-{L!Ze{etilS%Sm;_iN6wp_i=!J<|xGq{iQf7gl~o zd+cATC|7R?1#r(12~nxTZSOc^{Xz!%Kt89$W)Z+NiDKsuko_x-%8&&G5BLhc#X`dB*4a>L(ESC=;TGB$J+P)>}_zjx2)X9rMF*yfU?Qx^K!I zaMLqV(v4KDx4rBJ5U5%ykE7>{Ab(QX0h$Mb3keM@e&iMGX;M;eSM5)Sv28r+>-7#C zs^fru4c9$z4|3_b3@3trF^7HSIzb@oN9zxr%5)kH(@bA;?wVFjE#iasv!H{Dg+OVK z^$}esuRR(phIjei#h!DvFFMLMZKQEwE&u8Zq<;^Inmli0U3seW32-DKMIsgXoIF`+`;6^N)$JheP1;of{E zmOVb;4h-Y>V{m|Z$CY+xn!MdWT|d+>T~FVzu+UW3xeAfdBdpL)P@^Vd?0hpiyW_bt zJv&c$g`^OSl+Mls%f4cCPr{G*^4HO`dwpv6yt@8FGr=K~ivG#KoUdsf)92!|(mFh( ztMZwuKbHx*@bg8G9J0qF%0_?|2dZbY)e^?)QRET)s&4UB-=6p#1%$0@&v@G<1y9F<*yQZ7| zr%5LShkCh|MX5rU24!F!Ce^Ia3$2G5mxME7? zOOOT&Romh&T^y@LJeOhUq|b2&q(fm;oh8UYJeE%3J=J7J(|wuEr*$m#&-bwjqV2(S zM;Nw|!Q3bSDZGW%Rjds&+S=Cary%wQ1#ODm9!=#I!Y1zomL2NWiQ*;bvrlx?6#fw$ zlFB#;x(e-p5_6*(y!W{cDZAs#dXOx)ux6QYClTV}M{7E@hu;9KNr7?D z@($sfx)svVXaEY2tp37_+fmvVOZxML|38$Gd&8-I^mV{(gLOKfO+awBQihS(lzI%< z%RVf-HJ3aeqfxDcVPJBSrT5+A=a*i@zs;1tb$85vm|b#bzZeP{mbVz*c(nA~GgR66 z{C!`~{#8Uj)?x@uhDRY-JvE@qDWEg)@MYXB+oLfKb#jhTlYPoN$WQa=U~D}X7JO2@ z&i~YPWnU1fJb$0orj{aEOOEe8&P%vey%zSJyA_{pl|$oqL;g~gp2c!g%XN1o^A z3#d$`l1!cMz3Op||MIkptF9M!UyYtIXY3QPJRqw*%q|C>+f}}D*8{8M>BXq}aJl1z zCEIA-HJ;NsNMEi(1W%@G8Jg9;cvnBw-@X6W3eumFmHpz}QVSlUe=+yUC}R`1)ALyf ze2rNR$2+kwO}IeDDG=nT2Oo0CWft!jwSJ`KdDEi)i!d-ts z_UHPx?XkIu!RhVGzq2m3FiU^zLtiopLsanZ6YD*ol&vrx{{=FVi%uW!BoV3b9cS4} z3teP{Zi7U_)Ia5N@Tf6=b+*MtS&o|e9)Pp`y>r?g>}Do89@#x?=k_KByy~np z5qmg7WV~js$0)NdS_>V6-*KDO!`pJ3GcTS&G3K4e%Kdy!BfWi0LuN=8Jg_ok3hegP zBAHLV_m}n&^e=AcJ;B+2f#Sc(vAOfb(}0o^0D&~AXpAb~1#1?iD@Gz~<~&HBUE$Rs zoF9?lvusUfi6m`RE-rsb{$0~b-qk7O0p?-U#!y+N6k}7n@pD-qgd^A#(6?{yZNS-V z{ULt34#7|vED>N4%I!#865o4PQpiXe-jX2;Cmq3 zq}JCX{klHk%Mvrh{gT=zuZ_B1Kfb`Tg^uuPzCuKLDp>wCZW1G}I#Q{>nH{3HachU~ zt%3LxaF?Q4L~;vDF0-6u_cHzaHn-_;1(4tu0iPSP=#-mZiEY04Wcnfo5@D$f5pnwyWzHpMMPr<9`ZtR16D%;-PDd|8PewTQzn`!Od zAfpQdHNUsbbvUo|aT`0eW7VJD9=h8mkJA|Kii>h-ANsremngpi2bxSd$^b-qO~dc- z+9Xj0tqA-x$bW=qrkf#BgErQFxL0y13gyY>*N*}$7SSatLO;cJ&E#@B*Nl-H{9mrN z`q6DJc+lDY!!w{tuwiG|BN#Kn0Z+{y~rYYxP$$n&N8aElrdYxz)Y zo)RxmLHpoos1)y!uTS7VDmA^E+RUfadg^4<9j+n^-Ku0?61%;=-oj^21@^wu+Tyc& z``uPf>ZtkT?J?|j>g~3LIbxj_?+}xrYC;v-ehW`1X?p_Qn;X~K1*N@|A_Ci?5boS_Y$wBwMaCs0v~@-LGtF0LN+ zMLDiaeCVWaJxN)KJdFJ0tmcIgx$b{~yQh>d_5+=Kgmmv=0JvVEndg%A?_cJ@c+F%7 zWR>LWN2h5ke?yiqhYW&D-gI20d<79;XtgwsS6s^FYip*k{eTMqh0Z^sfDjh_U>&X% zVLk7SpYS^DHw9KxN0|Xlj^H3 zTt4FdTmu^ohq6o`*y18*R?jKi_;7U)$35n@4{7u})il{_(>qA|pOwl>6NGj$N_?3; zy*p~yztKLv*-bG*E@E=0W?SuC@Wu{lHZZB}>pM7$ae9UBr&c@7lL%Z#vfAW+B$h z*EE{dg2p}_myVPC_F)RZ;w6H5aMcJe^>7>(Gr87RrTSFGymzntgs4X%t+V+uAn}^-#WNnmLJ~|vJ@=_ye1e+>!en)VjfX*e*Fk{;TzG}t{c+L zcgumj!9L;=z6Mc!3fCgbUUkh+4+GH9CP!X5I$ReMpsEB$9us)Z_{uXrjPwNy_dRzR z`1M05FhA?(gC86?qgw#)Vr3x~om0i8A`-l|-R`5nZXD@yTfg3W=6pDEb>Qq{;kzV$ z43{`{7;^%qX?_pKS%#J}Vr@3tqH07V>TRE)lT$eFH-jN=*dMHljjR_di#sRCa$KJ( zt_7O$LlfD_+XR7+h&?mfFH5cI`tl1O^<_`>;pf+t;^(`seElCnKv&x$`F#%XH}rgE zQYmQ^Wz(ok^=^dO$BCe`IzgPK1UxM-K9`RcvHVL+{D< z_4o})8cdF2d`T^=10TCK6 zJofw4agra+XEkT$9F1&O-}cn3AX|(<1lRaALyF@y4iKaK!ck_GK?xab6>yxYNSm2& zw+-M!Y}NIKyj?)~55vH5kVvn{4jxO1Xm}L^a>VaVT7CE2JNDGfy$9bWLxtL+au3hy zGJ>+XE#^;0!rgwbmuOqf=*_jnenEc>ct~B`LZ0ol*zSyyZm&eI{*24s01}c*>8G-g zN`bZM7slPd>ibQBYRcYjD_3pb9QAbMnnS-YkqkL(aV>a&S*RO zb35(BXyTW&l4L+V8bo?jMDL~8udN1ET81L z0a5YZ;G~ZjaKfRVe#L|10i8NzOHNy7`OyI&kc{QGgIh)HQ=E(O>mmK( zuHBo>8+7mEZ;(n}{M9!x$v-EEJ?_b^6`Ew9d%9cKmA5F8sy z@R~Z?Yv}N$wdv%7@^SNkfdgPR*1J0md@)gUe1Z~tp$pnEe?MTxp)n$B{Woxy?Y`3& zN3)iN>lXPxw*1e+%R_(>DA5M7vTc5C7rkz0boLBr>SX zzX|8_{m-3g75B50(`>(C_?E^{PPXow)D>{ee46g}f{dd-1y*t1I|h=OFHnc^KL4#b z3dY!>ke(#SaJq&ce0am-60#1v@M=xj-P-6ey>M%|5BB>#M(H(7)LF|yp;6WMZpJ3# zfbwxr(;%J6klQ4h0e5hxwyIG=#eBF$8aTQD6VX^Mex?}`)4^Xhy0GeOcwORpI+P4) zqUF7&Xg+IY0s~WVIQb;~ZX&6xYA_%Va{xsS#hvUU0D=07M{TdTYKn#%#>;d=LP zAtr`9lyji~pb3@UFY#$Nar_?P&=zTAKPu$?$vD{W=7Lhb#{Z^l?3!0bFh&e!&pliB z#*f|b8)hM7$J*~cacy3d?TsHlkel4+oL;;4_$HPms-@mvFrVMMiAuwB*xI*zVNjQ| z{k-^kVDL-GHt|HP*}v+w2<-<5bMxM)zRJtKfHxtiTlbCms%meel`vPSj>%~;3}Bsl z?t4A`lW9ue50#+e*kMXhr*f>xJXy1Z-7~}5i|FfdAO9G0vj;}bU?K`F|JKgP>LvWV zRP(*lIPQ&|ADLJGu^FSQfGxBl0(f?@*4dX64vNdnjECU-quS>{9hvWfEZrL)q7Fk!8ulfu$0WRekro6F@0olqKT-0Uda4o;&VvM9S(Zm} zKkEH^!W$Y@3(U*Bm6E87y!K{iK4#cBh;N9c76WxPsaG_Rpy2N30tWpvA+ON*J^Dur`BJQ@Uaa<0)w`R(ye6;IFY&w50;Ut3MS46JZA-w@GW z%o7j3StRArll**A;jI-~L@hHaDE1YUtW+Q*n6CMo$7Sxo0Ljf6jMrb)OVmHLq_hzi zsnJhr!tQt|vTGYdRE!~4y3t72=%#zc4Kl_GHN7(>t`3|}VhKj-L&3huACivP+bEse zas+s1Zz}BciOUTVa@XwE+)rn|S55Z>WAsO;t@f6BQBP8jXY!7e#p&k<0aZaAD1CF` z6$N>H6}RVnTC0lNkNR0X4rPmFm<2Y9zl~=+6S}`g84p7rZ*HL=*0*#{@h9lM{XVu? zw9AOLB10o{z(bc+LRA#O)-}kegCyfMWN`0qO^|Q%sqJ5(z-!(>%ZhL@tWz}qYQP_F z;6D59oiqAhw%((-TB_~?SHxj#pWAItSMbbY0p@>@FpE}b;GS-)ZBca>t_dh0imFL!zIZyDL_wWSr9A=8c#Jq8k)ujeY)9Tlrj|~?4wD(V0Xu99kyv42$ zWoXv$kWUL1@wi+V#!3i*h`yIHu($dLT8cl3RNtqs;kl<70X*M6e_{aLwJ3^r!!2ee z&4(P=`MvATpC~k;)f6PZ_fL_aP7kKRKClj!ly0uo0)wr1k2~-@;>#m)sP)L=L@M`P z>H$)9*_FBgLfA?-8ba3U@y^RgdGS2sM11iIgH`_|o=@FtiO_p%kKOY3oR6X{fs(aP zA2CZf@mE-ba&u1!kLnmP5b*5n?x*?#foo1=8^zWkpk$$7VqmCdnDAjf^!UJ=PV%ys zG=F_)f}|ilW#ljSbu*QHJ=SF)`E$ILlq>sgVKp4^SjnG=eTwRMIp0^9WFEWoU{kSS zU5|kcO&-g()XT#D4(i=1ET}rQi9$Pi^!%BD*)%_FG*RqJVPWw&lL_^0uUBS(yZHHc zm+j``)?44(aaSn%hw)B-K{&Db)iZbZtCeyp`9M|6&O`7DpD@1k%k8O+aN9lE%fP%( z6<{ShoJ(DA7|;(z?*z00>#M9JZY>oS-*085kBXQ0%71Q<1OvnTj#m0+Z;KOb@%A~N zPE5d@TykUm%Jx!)Lm^=P(`itwiLeXMHhX2N_CONrzzyM2cYeRwNA)zk!|efTw*fLG zSJpH1=%2ChLX2q#qhT^#rt>`2`SP)P#%lw)HKY9wCO`6Id58$lhSlU;!CUuXr5UU^ zdi{Rz)@a5W>y5@$2(C%(&xVKba+cK_z!UL{B-chU=Qi!=;k);&WO+e**ZL?XveRn^ zq3+St_l%V)q%^QCs1uaVWiSZ5Raoie!SFCx{%W@p8lMl9+%JrSxislGkB6pz`DA!Y z06x9gT$lHd3z9(P6oMSy<6O!KIG%_5y&gFwbRZ%5sQW@-UPfV)pHSI;QkxM;JSyi! zSgT)^_(PEkTXAlyt?8AMwRhV`Lw_JNkA0`}q=^sj_h%NW)`695-YxjL_s2t3c4J_9 zmu{5%V#UqSvn$_Uu>&=l;P8jvpxfT#*3Sbt+rjSasLG|nU(9`6U-J4@9FA(4aHn_p zbf`UohYUpcqyO@QNz@1@>v!N#YSZ4PpHy#HZ`At1x4JWq>-*N3PCdMuEM>fdVqRkn zd`M_4usyx^_BVeu;lZ8^<8%UK{A_3Sysg=jetph@Q=HAz_8GtR?cXJT ziCh*XZ|kb4lW$DQ9Bo+33lFg}Th@7QrIHGCkw66$+o?WZzZI%* zlxkl))H!B+=y7GpU^uDp)9(@2I)+7EvggTGZxOnvyuOiK);`Aad2wr3m6ihVc_3MB z@IXA+`gMJO^*S`MuaT?obFN*e}lgI-fzukt}Y+Mw{e#rAGeOy z!T6TLYknBh>v13ye*7FXI{fT=K7Q~(M)Cj~6!UkxWsn%Ghm9Vn{PVefm*TDI_xC8d z+00xu1b}jIg0<;>Z{?`Dv_VuWF_tze+z+P}yyA=s0P|UW{RQQdo`;Gb*T3&3s|zA{ zi}DE_L3Vn^k9sT&8KtyuHyF*>x0RZ6o6%oMb{4F?jf0m5U4HJU{4rWz4E*Qa2l)Ln zevMCIw(Fx`)T~;Lo?*S*fzrwm?s>&1FCF?S!OQ ze3iW*tQrFf3DCw|k0-V@?vM6J-+u4(7Q7=m;s&~wFinD@^$`3R{?P@;!=lU{Krsq= zv!k`LenMGfSK?Qp`V5tu;^`lG_Sy+^Pr`xMa!#I}wN#0cwkIsjEC16dk#6(-;)LN0 zKrqf|jm`Jg>fv}Cx2dKkeOO%Rycvbll+VjFQdN$j7K=I9yOD~O&S6+7=e32;g{rJf zb!JFu%ngmFalc67w;l*LdqbXO>@3oWGx1g2y}}hTi0!{=&#zTZekPf0r@06fX6%4|z@WnAFyr}ldY z8`E|V5g>sIGX{Tpchh}U!)e?9fat#+j-W*z=5X$$-}4eFjJWi-?0R{yx0?-jkmi7@ z9IgxMtk(~7DCS@~moa(A=0DPZxY0rA+S})Ce4kWh3H!jq{NU7UYYHcW792`jFb(wC z{&6xC-LZmscIoZ*oxnWXoA6POMnLo5TLJ?ggXvKY{AIV(TrK;O)u77PVZTk7i)j41 zrOqlu!+{Z^Ys6iVArm*u-^sg4GXu>na~Al)H~16|t`ew7hkDm_83$*Dhff7dhCn_My9c#n#;`r@%~1vP6;zr$yl8 z=l3HE{xN7VO(gFVP=A3&S^x=N7kJ{MRH=k0JB-(TE*5RWDXpjz-L`7SR^0StitpD) z{axsNK69pGP|;cgBpYPge>NE@bFbyrZP3Ao{GQ`}kJ}@JKO3Qu)0E!Rj%b4%a%rYT zw;zXgVtCXkM-sTaVC5?5w9j%Ul7{g@7CU|LJ0s$)1*OyX+c2CjDypELmyo$x+${*K zWhnWKg?TCC8&t-eR>MLG?^cSJ`CEr@`2IsR0#pwFt`INqM zzEr;ClTAZvUbGHzctJ(v&i^R06ZWi%%Aa?SZ0-Ur587A#P!LVU69YRVmaN zh*XOOQrzw*mfX$YZ_?Lz`lpj=-68z90~?@T(=*&soso|}#K?PMI>4TBeh$yP?2{Pa zuN$evdYUUF1K}o%^+=X6-Sz?y$K<;&2Q-Qyf92Iz->2{@9d8a9`3|=}@aMz1oxe!j zd)}Gh-kTebuX=~fRAkgAHmsq~)R}r-)oY&f!m^!Jy?owqOm388B7}tABi8_o+viiH zoegQff3>oY8nCZuX9iMyCumw<(x*~gAJ7d|4iyM%&+ne@ zZ2S}KowJayg^=^vdyf(3CLFBok3hsT>4BZS`6e#ag*<&M|5sJ#GVhqM_Funi7#Jx} zY#AtC&$6nF2e)4A_&!fFU|1I5ZsIF^1Ljp>PL&zK6AB{9HmO$;48RmYeYQd3+^WaW zce#UUiaEnB@B-mHwtL^8R&Ls}qaU8s+EC*qlE~S&q-X9J&gZP&J%tYY3S}Sg&%J3j zngiE3u?d^NZK$Ag0FMNn{)AuZ4j^P^vK8@V^V!7h0~)`}mN6t*^xI0ncD*UPXTg(e0aCzL@UMB- z{piF0?)ZeFGtK@jvfJjQZNYGaZ)K2ro@0{Guzn^z60qd=kcg_3ptXRl)P8%X!WuTY z(WC(-FL@vNYz8@r>(3dyebKjc#0fyv-012{{Kg}{ZHj z=Id2HccxUZcljixK979^55}3+^t_25`uBj=+|p)!`BTAvGTH}fuHSa^3bMW(e)mh5 z1HgeZfE;gTA!2*xaGBrtVTa)Q3e2;}RR_u%`Eteht^N+p`3IwDaJDZ&@YS#s0z**q z@|DGr)xeAd^A3-_tjl1OIc~^dJl)or;AwFUl4-buYvg5K%#ps<<|B>!NgQXm=bcBOI#|y;YO%l#TqkBK|8bv{_<2|L|FMc41gk#`m)K5Y4W$pZ?1`z8-;#Z9CZ7l!fP>u$;LhczUnSh?ZZ;JQ)k5~c+}P#jYlOR2i=EG z1FGhw@YY(udT79b#r%5>GoEF3nPjxFdSvOl_Mcrv+vV$`rI!% zWc97dZn+G)luoooH9t%DaxX;LYJM;1|EbV}b;C?y7g1^c>?^$tQ@-ki!q*K@S)BLW z0c4WBlEf*lVesxaYZO5*B=cS&+wBxF%H%ys#Az-dVC!jhkNq-WQIh@V%R3k-vNTmAzMQMHMt< z9P^;?C;7dHqe#{)U*9LMza9c;)b$1!czn}E;$#vra z`F~dX2vRNWCprGfiRTB$B(hJxu3g|PubcG}#=7UP;pJDd%--AlH!UGGu%N(raA1m2 zau2Nb1}6A~I1^-xT%-^@B~hT{!G~?tbPIcEm&<^S9@XlXz%^_x)$bTd1~E z=Qzb zH#y87izt4zZF+=RZHiCf9md;(MKqHX~Gn zr?wS3RATOff_9~>%gHI-R!=2=bSu#3scnyi<98Z9jY}S4G8NwIeNVu?8n>$J;<+@XG~=*@`a1@Gp%WL(#-dp^uv$-lr>R_P!q~PFY@!w z@T+~^LS$1ktQK)txFEy#qeEH9feH8ZH+_%v8wFUf?&?Ey&gjzE0xXm7FrMX$5KLu2 zo=5l6KSV>n%uv>&@_9)5*>gMbe9TYg`1yEZ46xwylr;8jFYNADo9nj?L!56V>-!J^ z_}cKko3c3+g6v0nzqlaR<7@Xsbbmz9>X`B5hXQiRkszMb-gfOZ*`(7q2;mWDTwlhe zg5xRP?;r>jOeIj{K$a8ouvU@r4%zpYrp@2W)9W|x{2mK^-*A5)Dg`q{tpm6&lkNtw z1p%m!<2>HRZw+mHPa?qC5d}hk0O;wVB@*TYb-5nV+LUS9>*q3m8#K5z-S5L&`e^-fOS5<~JESbq`8>X~;bsvbtMq?)m)@tT@8ey?2r67cZ^CS)%tdfs#`X zRuf+8!rd1`I^Q+qivp1-jx*T4ls99qE5sb05$~Ed+;7 z2;=TtvhgI_!IHeDld2}waGU4TQKdrQ7&R;5rE|Pt@$+=oVl8Op{BDa-KErnoR%}-V z7Q)}hy~hV68JS+neJDHt6qhgObf2=t;0pV-%0w2TdOgIrYxdhp-Y#g0>l=wVh)Tle zF7{A5Kjf&W_Bn z@_p2^Ix>aRuu!(cCJKOEy&O9H#v z2iY(F=4UJ{$Ad4nz_n(dF^gGwg!H z5G|DmU+EX_bG!rr3#K{elH_?yHcM|=s7^eK;`!kl`T%Gw$=$~LlF|#o-nH-yy|t;1 ztcSNoaz6dmJ`{L^D8=G*kyDnXYyDZjYJ<2P&{?Y)FP}es;j%)S21i;PU4_9=Umv-X zmaD=n*v#y=PwE$;qm>%+HM|~v@kw!PbNa!u1P1CKlDDq{*P`$Sd@U)&7?50_w7_I7 z`-^{+DlUC0==V_v_vb>fNLYKr(k^3B*TO%Zg`1johpyN>MQDV#I~sTY@Gj2u<8xtL zLq2AAgXLa(V7i|op^5dlQOaGnXUYpL;MOPV6Q^r_a=@0Qu&@9DzO1@(&!ad~oyp*YlK>&_r?T<* z!<|BGZ(40I=B7|_w9-`YfcqjMRYAu7-sTC8OK0gOPVBE5hx%;&-siPi_F)hTOBcl^ ze;?nUtcDwCj}q+H4+3*QmbOzX>xaZF_62eTvBmkIXn9-IGw9zgL(Z8IMaw&|X0n)I zIywOFASkt399Lc#P{X%@rONX-OrBambfA?8cgC9Z;i@V?7Fnn1_??+QRTRo}Z;?4Fmu+&6=?SXa<-rvb{o^}6VAs2N^6l9$OutP*0j!_?6hwl_W30_^VxSpU>QQpkgq=XONj(ZjO{ zOg0zla^#lIy^<7r9MRHiCabVFf=U+CFu@-Hu|rKPxki`y8y5Pgj=w5_P3re&f&EWD zo`aok-0>F7{DT+uS2vOQB2xJ$^R%~# zG~s?V{Nc`@O>DzH01=3!u^~AKz1CCKgiBiL&XE<)1AH(<#igzo zZ*uFhxfPC_hpvFi^btRKo6fgC+2(p(uu4vvs15<;kst4_0f$Gv1@WT z!gvBeGb0*)_c-3CN0~?*7#KD8Pk3Q*f}r3;ZR~Wo zaQ@?HrsDhYf`Oh%U+(+zSan|Vt;@Ta6>Q1{PwOsH)Ypfv>mfa^dil~JS*o)fdSmm` z7H8r(&y8|gvN{ua5_jtb^*1r?codBA8L^c#U{DoiNm>R27IOJWoRW3`WCP=jv$CO=0q;$G0~IMLRHr+7x{v z&LkRmzZ(EnB?qf%8i_0>ub=7jc(9Jj1Fx5!t`Ph=hTgo#F+4V1=3GA?nvB0TysMbo z@btLbAE{Sj_UWn-vzxiW1&HNL`7Su84eM%S05Gm>esxzhS~eZj;aoLjZ=pqQn2HOl zbwBlarI&60#M@$&2fnlr7=mv>3~-5#9E0hOe5OC4gx%-A1e!{I#;QDOfBny@+~a^p z?{p~WTs)hJpcnfA6!jbd>&d}Z@v}#oG-GPVsYC1F_p%S?4X+ia}82s@LIs@-Y3C-3kiNB zYuoIhc0U135ADAvpqQWzAk=XO+^kvXp%*<%}=>qkSYCZV@22DVqUG#zd(9h-!N}r0 zcb&R)`;CgSk~n-)D8E~*h|gpnJE<0x23kt*G!PUfJhTWj?jO$t5YyUJg#N*a1mv5e zICZGg{CBvyEb->j0AY8#U0K#TJV^b17OtmOd02lm>Ug|C1!+jrm!$x+LCmiZsOv8h1jxZh4Z#+T# zDk;584?)e%3G`T=Gt3DY*hjA1&~xO;V}&f*98|}@-gG z@eAH2q;bXr+2@5ePJ=juW?sFjOl%(?lJE|%rq2BQ?9gm}MGH!{>6k#~6BC|2?+OKE zD>O1AJx8R2ATIf%)L?7dVN3QE#aKMN&w$dVY!TCCA6j&o+^|*%&!(!cEl^!Dt7|Q# zhamKvQtv8C@pxYJRH&RfU!C=A_$o}YF%kyGOoK|co%U~mZU1WK?aVnxY2=JQGhv2d z$A>=x&{rg%cJolmErlQdUEAl#=Xx=qT?j9HPQDcnp46gG__-7!IVCrVkV1;Kz~YWt zs3V1eDaleeB#4Q(9=g-1P&Tn-?_OgP}maiTEK;|p682nYx_qCh^}U<8HYB>!%7g(HPSdon0TV!4frmGq!1OOc!%ZP3eMmij z%`O&L0Ed>IZ-dF_o+wLVF~P3v-UcwPo9d78)iH!)!W%PWIM<);Rm*Eg3Z#zE4*V&O6&l>*OL-*2fofIC-B|WR{Uj zMv9I%4li_kdU89Cy}T5RY$`%8a}4ue}K7uU-N);GVhTG{LyXV;&1 zySz;k<|^7IB|;0oZbdv}>5iWK?5%kR^6megUj_lTt4|;Mz>zu?pVVwZgE9`AlwY5B z{d^Ey?X6w|BChH8BWm+~!pVQD89?~jtRJ+1OfZHJ`r5{X51~(%xI8LW{vT>4Ii@%<*=pcPmW>6rP?!Y z@%5b4poNk28(e@B3?#$}KvvtIU?kTke8(d(*$(I1xZm>&oK@+Di82G|u}_9Y@O?2( z4?SqOZ2Bw|7q{xl+s}CK67*G=&~b?(QZ#A@4Zpyd`QpsU=ERSCy-<2Jm7z^(hkLvZ zF}Yg+N%KIQXPRzwsK=wr@iN2Z#&R#Yt)wTlE1yC4J)K3V_3GyvISivt#)Gy3pco@1 zFU8ClF8|6dvXjh@PB19Fq!*I?<23D`f7xd?{6vl@{yyj$)Df$WqkUPG%fP^~j}(?; z)G?b-Z=h1`sg5LQF?Hh?7D4~V<_ku>KM2655vAA4JFLCqHU9ug?OKP@nC)5mAh5ds zDtpc461I`n`|2)B^DXZ{)i{ufz#qc`fNe_zZ{L?TSfek&oYvthCR#0^|2<&CJ=Vm! zPUY;c8KbFW^NZRWTUWS{7&cUZO`Dt}%y;u3ejW^4wXGK6Zg<#^;Fqb1N9=r+^kLm6 z>1l*!<8gaoA~p8tk^^Gv7GFQYws0^>C>~E(v}^)476Tzb)chR5BZve|wfG>1)8SWJVC*nqXL-n7@wo^52f?Gz zM&(7u(o5C*Vn63ugQ4v*94Y7d?&0JH*~;k-j&){6KN!@tC0Lmzwj=#H*A&jxF2BRF zzUTFJM~|8PIF$`4@sYtA+0i|CzZKi=&79DG<`;^W&9`!R!*g!G<^iDv@7vUaO@;v1-Glq?ZREvp zymDD?Qu}102OQtV05iQabtqC-0%w(UrTUAs-c+yY$7Ot$6)%ETI*|9a{L0p=a8>Q= z%im<=F<_!fo4yAg?4n0Ca;NSY$>WP&h;jjg;om_ddxii0k&p*8WvV2=0LEQ-mLjK` z<0d>fs>-F}6vtb4Wd1NN0O)HO_wFJ)=$PUL-%^$Pg$5C`F_q$?gu@sgs`GY-C;->> z)jVER1};0hVY|B23(&7L+RugvSUumBZ{krmTa&cB#zrFh%D#KF;k@TfI^UjttcCjb z27q7N`%IRiUR_WIWsP_05o*Ti*G8@WYtZuVA#_dzQc%|x!sY%lU&&n9_0j6ltn<%z zAThA0S9>vJ@mW4_;-&pEY*We1je{&i9|gv0xBY5C2|YLhEQv&9FL-q?5vsYbHXiQ# z#f$8MxAAJlK=60`1DJ~a;lbVdlkFWXCKe6uKsxzx_N*c~n6KxNZAo}3I@;qtQR6H9 z<2{QAQ+6e3;UFIFj-rp8^&C9r^f^LO}WBhcbgy)2$l_?$a(WyDi*L@se zi;vS}6briRjA};nBa*vFFzh^^Nq+4K8zzuRG|FIaZ~VF+dU~R_%uZwzHsN6qiPI{a zJ!_(0<(YrR!gaACUGQw<72GpsbnZW`dUD`$T*<0w5JEv!>me4{2PCApGAKTXee!Q_ zH5$b#DO2l4o8&vAog;SUPin7uLfLdL?>Dx4+tNBaf9}dxE~1aH24H4sRA&ie{@rsofoonKK|K~;tbL?ZTEr#W6AX-8=eEt@fR%F!}Y$h)s_ z4gH6XrZ=az@4@?tE-JgdD3Y=k(m8C2Uk6Xo`8pLUGXI|Pz39F04P;TaNz@yOK29wJ zNjBSTou-j|nd}h(IhJY#Utn>2QOj4p;i2d&0v&foblUCVRkyD!5#P=&2~WLrCB(Zl zrqQsUCX#(WVNliw+X6ooMRftR26h*^n?a7~TYCjG8!}b{BgBFcxy4%y;{9&Ah2F0- zQ7DQG;g&=_won;;2PNP)Ia3frypYej8|oX0q9sJ0n2)-rK&3%r0l$BgBl*veSYMxK zA%oQ>HhkLh`)73aIf}iF-g0VmLP)>8YyOJRJ#SHSEuThXcD5@=Y>FZ$8S?Tc{)w;A z4(v`(-jD6K`g_vjFbRD(?I|#cw0RbXL1M%6L<##@;VQ#u0e#qAKo4UsWn(lis7rf& zezT8J4fFGDA9)vINo?W$2TYaNZ$Auo$2+t}?D=Y3vwNf@APuHI-p4nPDaxTfBLJF? zZI(P)q%_QwWFEvFy$q)lD)eu+ettwi;f+kMy6FZ8T1kbZaBUYa?uHZYZAow?bSEEd z#0`=avVR>bW_o?cR8z>B4yj@SJ*_-nB$HsMphOnEJ<(4VinG`4Y(o0Qff?&Q9n&9& zZ^w*#j7`;&;ESIzI!(j;R#9`XXk8A;vir^TcS4Bj5>)=gD;Ku*VZQ9-CrZA)a%H>> zuQ=08+pJYkhK8r7nU~j~EvR(IA_`*8+PbNB` zgJxMWGSY}E;C4^Fd|sATVNv-9Q=em$4(3xLZw1eEoy|z!FDTRD#FO!BY+Ir4lFQh4 zeP1Sw%EL)$EE%^%3q%Q8aSnX+B75C_)SFD+ZG`wfy|A58twH)SiTKQDpKVPd z=dQlJ25h>vzP&Qf8ic_l=2$Zj`T8ayeFh-@)bZ%8UX0cs;z@bFZM#X zQK$QUyVrl62pZ59q%SN*Cje;{>1PO(qONVw@mK7L|rumfO^@Y(#n1D}D; z=<;gsuiw(2C-~doy>ju%Y%CX7D0AgR2~uQm4dKmfbcLh)VJXEe&l6j4Jp}M4;<*8o zNOJxl@pNh)68!DMVzCa#DJ;s+1%nYSalo~;T*M3avaS>0h)9^h_PIs{K}Cyo=rzUm zH674eku^_9$<9wMnyGA6yD+vSoTNCQKX+F`7f<85Uj`CU*Vu=fwLbxROy;F!g>isJ zZdUj0=O9r?1VBmp?)EH#{t>9dk6c{o*W8>u?Dx6g?qNBhHv6=^2SoUxi_7Qq3F`6# zyw{vDm^j~4_w>MOkP?c&X3dC z>##g)Z*YdMmswX~EWW+1;ZmJbdBRKwmdKcOVeZ*C4H-b=*Y7KbwK)X(iGmKm+!znG z)XUt?M%2YNr=dkIV10oIv|n*T)^UV#*1uHycfmU!o&i&M{2r*d8vT7+{6RPi6lo** z1xwjymgQ&g!Xwu)I{xro9NcJ<1v4jbXSTPrFRc$U5HrTxr`x>SCm@NlU@uZV!S5yb zd&=XUljgWRjE5_&s3HO(TcTVS{VMM-{f1SLYzZSMB}L03v=EthO5q(_N(Os%1{Far z-^6W=mz+%!e_LOaKD%vzG$B4bSXWJ!xu_KRB{$&PdirI_Yw$I_i)bpzjdxCdnn23i z*K0f0N?(?*qXmMLeU2Fnf$E;|;7w!TcxaCuC{*j?&+CKt9@F!=iIQIi(esi8-n>Cu zPEqahE0~|Z4XJ=j7h@c9$9fLi@!TUFdF2UEAvrRY?z9hE z;#=K-L>2Ydk$litn}5vPq5X0N2iqPBP8EYq@E2kK647U$T{gwrJL+HXhaCathmG1x zy4#K(`UKIv(h-C?8=+E5uU=lZ>M1St{RW3Q%}BQ}WZD;J;CRb^1Fe{$-C~eP z{L>!QqHjc6j7Z4TSz5%JVce7wvYU~wfsB>9da*=%%F>{`O3^dLXhci&EeDp9vEi&f z3EofgAaTTpu=}-X;8rygbRY^Vd);RWKEP_`*Ps`CBaglC#$YA+pT$J1{<bT zfo4lZvdBF?zf=nds(*)+txI}8LO*nWEBY^jZYfv34NP^j^n2;h!D<01#-`HGdi6N{ zUBqsA>GM0D-2>S;;=hdgAX@{;Lbw6J%Ai)}AaNZ)j^ip<6XqU32$NM#n44E{;T~w{ zDQ7d|E6U#|0Q2cDb~wBvKM!8d?a^PN4W0e6i*}qT z*NSN7?s{w<@OXGK2=tS(z`F|2jn6AsZTA@Hw)>+hvV9IKYc_o_gyp)!;}XeFlTFSH z%(=CzN24+?B-zJQQQ|ncf%QDv`-z+73YvUP9fiZ)j^jclrlYh4qY7LnQ#@+<;rNIP z%W01|_5%cy`uMdcq`wsUUnE)yaJK^n@|MK2297{!efH~V3rU!0;+OB8cl){(TBy*D z^+OI2iik$zbr$ovG7j8~Bj4e|>{&*qOPDGKM=zRRXrcX`R*L)yDfYTga)^g9n0_B% z^a}N_QqWi`i>&KwE$m3q=UPV}*#Hgw084iU#fh{U$(vKsbTt&Wt110HKadxFZ|zrL z$66=G2Geul0f>T5FoFK-ver;s3coA*wrHrm+B84-3oVJL~hte zOY45!dKrGMUlVFpH&jULQORZk948NhB3aX3%7u73;2O);v2357`>{@f1<3oyvg_jd z3IIf(r|#9Ci*(#x)ejVISYTH?>V@+)Xb} z&8j>Z(CFVMXX2EMo|ohuM6iYEP`(No=A7;;X7!vQRtd*qU}ACw*rF^0!hL!$QmO7s}i;DLnte&Lpmk;l!oWrXpKeXrO{WYhfa3EiI z<^*6EqD2UI`|EZ&MMgg*$D1rC{yF?g_32z71JfYWql3*Plcn@A%b%}GC+C*m^y@hH zGaYu;Zz-KSrvMfGTaN`(BylJko%sWP>a>{VJx7P>w|tLG*umW0i7^ESN>=z8wAS~g zFBRnP^69xmmhv6gqYpAnq*sTqCkW@1# zYWNFXTp|mOW=Vg9_$%4eiHZ7nWaDLGvBTZZ{bioCT-eBZyZ zy!Sh)>@RLiV;v8n5KhtUIG@oWfXB_>0KEM^O;txGeaovb82OPp9`c_#1D+Uu%I)l2 zDwfMY3@64`|!p`M0U`6 z8fu#7?)@RT(2mXZ-VPywaZonfq1L>51BAPJ)0?ui#pOYF=Q-&v+k8y+35d@s2vxz% z#@CU$`4N;`DgO@Ye9^-1*&FU;9MSN+RMHqN1PaVV@j4FAAg8p$+0uPI-Ao0HIJM|H zF!hx_T>xU8&bn}7!htuk;m{gyNK-`tjrvVPU6gR_)kb$W{FCfKMDXr@R(T*G z(w*_=`=i%#V2$|5AfHf)B=I0B=1mfk+5?3>(3_Q4ZHXYaoi3}ecrAML`cE;_2)9UMOrywrY!apYefUL&Z&-Pwl`M`Da?G0YdmNgTwwRSKhl zd$&&<*@bpURJptBce-puyTtN7cSd+l@*96#ml{^c)z>iLY>$L9S?pU@d^SDPWVubI zI(&&fihTGGJeia=So>8@AF#Es0S)dqlv2^$Cl#c>xVYPj{rIO#SMN7+TODnmyIeiv zv|mt1a>!seEgGgIuL*?zksX_jN-g>HFrs!LA0+yGjRd#@jWLp2FMS@n}v&M5GJY- zJ;m+lmwgDU+v6L$>0jB^_xl$czM{)8hj*5uEAPlv$w__QCw{+VP5Qgos_dcWWu3Pl zO1=tg-<9ir3x7{KtFmKpK-UodJol&)4*WNw@rS)%O6RVWVRqFu%UP8G!8*ZA`@3!X zHQCXJvv~+=`=~gr8l4q}ErsGBI!}TT|A6sqDW(49+L@GcEa3yEf2NJcx9-26<8G9w zLQkPSKHZVsVtE6N7={?$;rcy{bJNx1b$iZ4&Lq!@xegg`&NiLG`4p+Y8s|B?&1G*o zV(2_ct{~MQ`5r>$!gp9{JYbi#pEWl1`ty&xM^GkS==$qf`)uW%2|1#;ufS7G$+CDP z4|I;UjGGtO3V+yFevLyq#*#6uD>s@MhWmO6hqL1T`~4LNB(5tCBm@T7Ijv5W0Z)s`q{wEWepR1ijBVdeR4z0jD4e zi!W@BlPQNsx=9az?Z5BL`ckoO%)P6C z!Bxe@A{|BHjpe0pm1j5p^9&Jg?};}1v#0vSv2(8IMfyCL)gZrgAemP}vm%6LO)njV z_yCRrHUud=-&~rIs@VY8DoZ1yzFmLQr5AiEt|F(JyV(h2W>n)1JzUWj7Lwe6$W2PW zuL$j>y2Esd{CF2#B{+-|AFU7Yu=GP-@S5ahfHC7cw7L%Z(Fv;}2w?cwts z6Dn6oURrO7;@k9K_4C`WK}U#5wVbcg(b~i4_7%@JpMDB@oCm}4o?tGMHknl7$T}t{ zTy39h(CF3rk&V!ex|nlbipVG{{ugmwp7zb8hQL#hMGrHegz}R6K#9P8h|4|T`P7&X zGgGY3*7X2azq-P?hMV^Zos&yyNH~_Q*<w%Gjr8gA zwpsXAIuf$-c?*}-wDbOu=NU>?>MHco~9m`>>6`xut8KbcwCiwUC}fRI^Hy@>W< zcNQQPd(W8y>-pA=gZMl7Ur!d|dcTfJ^xjX(&rL6lqp0yq_F?N9K?e>@`in2VSqaj$ zK%KYme4j5b0dl1*St)>zg5eNEauPnxHmLHyPb^JxfYkEp!ECZ*58|I5yVL&V2Qdl{2eOy8HUopSzSyT=HKVap>jaR#fy1w+H6^P1Sc@ z-X)f(dl@ng8~|*_24s3F?~V_tk}e4)`$o5hZQmit%3EQx?g?-2bI=$2BQ25Fk@Upo z`|)mlASeNPd{vRbLs7Ax!?@=sQDwJJb4jrhuH-e45IS@^m|bkdto$%lpTJZ@!{37% zh;{p9#@(Q%GGalsAmn=di5r?bkLRrI>+f)A;QfRe_xt+if`m6&fGY~gU8hgkMBn55 zerv;bJf9oh0sUntJ|P3U80X?D+u#bvi*ZAI2qmh@!(Gq@4Meu8P_%Vo{neKqs}K*< zN>N4kJ00|TXtvw43487sElk>E=hFVz_BVMzVUxEJQv0_U{PK7n;7?<=B*8{+@3d<@ zRazkR=>3GSb?GnrT`C`M>=~n##WAA2@juBNrQ>l1<)-;-kor-7p>pS*cx&zJ{?2{$ zNC>IN9zIp9%$tYATd!S0XIQ96(;s08sI!~SlXto`@BW-`?BntU{^eFX(t zIro`%zk5oCzX2-;d5k_!jtaW9>8;(8bviv*L=6ABZeFRW?Z5nGKF(uc;*%X+-a#Mg z5C5fke^y2w`SL+H^X`3kdC`Nx!M>=qtfG0F?= z7@bwWW(a#-xQ&ZW7obr%Djo#bh1L@^DCIF!`Er4781KaoySkmvybBvwnMM@DL4rz` z?>7{U-AlO!4;^mIHZ-g~b}DjjSaC=n>nUv|QEgK5IN_^Hy?E`^HMz8gkCUm$-=bCa z4o7J&y2p=&ck)~CItCsmc7}GCNWmvds05t(v>}5*!&E6*8DO8`D%};|0&>F9+kGZ} za5Z%O;U-*Z2MxTZ{~4`6R^X_eH1m7MTh|9UiA~3QOU4r?8yAPVah}R)0*bs5x3S~H z8*Jg>_2i@GrEJ^RQv1li)d{oq-Dg;R;(-nM@}nC8EC7)h-3CzEbsuyoIfk~pbOjd* zI{qcrx|nLm|MBo){`#UhR|O#YTW0=SLxOQQ1~;zV!@-N;am@N68`~rTeQ@R6TSOok z#{E_`+?U0`VGD3J;)v{ux(2n@CfmSozk03-JsqCUIJeJ-GjM0Yyys0o7&_ms`$)oH zyMmOa;JXeKUu*fzc)2F5gUr{aoSqB}Nbz#~m<)yFmiluqBP&)e^El*qx8o$3u_b5u z66qHjekf@1=Ll~YrenZ)X~3f0~JRCx*&K5L?@ zSvg&p_z+^tJvJ{12e9k$FaB0awc&de>!K;HgT{zBvHNqEziMb^eAXdBu~+2D*_FWg zkc5SQpbg`B0zAYq4ttJ)R=S*IwWIr?DZ_C?M?dam9zg2#7PftqK5{qXhx)skQK{JSQ{tMh8vRu?sAzcjw^4yc4fYrX0AGFxRP z7jQ6>u7^Ytu%D`9zrARmgdcU)1KsKUwgK~l4JC2hz62(uZ}p*;GU$c^U$p*6OK^RE z)b+x1{2!=qI7gG_^Y;_LTk#Uj*Q{W3P5hxr^rOz+>&w7u{FSl5dB~|wd+&`fqw8Xr zNV#5@?Rb)-|1+u<*NoHp20-cM0^}7}KucU=ur6UwyJ;Of!A* zfNzD55L<<96%+;UH*P&Olj^|ZqvKaR051Y}@scxpf&^~iw6BlvWpD|e{fPMwZH;@7 zE)4-?Z<9r{Z=7|Bk@Z|#t7-GujNZr0TQ{E4YhM#X6l+)x+^5X_Dr*W#_Xbo`=$UU8 z{z;`mF}wdpurLo15hjTs?Vx4a>gzcM^rf)F&NenCxOy11?Iq+yNHRcx3;wNTNJWv-(b6Xwr&E zD!9Th@(s6@UvAwCRza;(`}&0bTiai%-|_S~ zm;wY9(3?j9W3kb1SBMm>yi=1bo&j`@5|) zyo7aUm~IbMw%)Pmh*oD0s2C(Gi#c-h^X#}A)2sB)-SRY72&ay(DJ?4GJj18WAf&wX zWM-E|&FG4|y~xWEX&sFt$_0F!MM!?v0w6w=9bX)$ileR%XES&{ zz8=g;_uyO`+|d_)36W?#WWoCtK=mBiCvoPY9cgJqLbRBS!*$w=_8`F9?j&*XlY73agE8{&^kwJ0Jq40M-S_ATZjJeA z+Cx_SNJ{Hi^PV#?wGv!ZcnKE7lkz>KU{XS859b@xq@y`ph-=C}e!R!)=j6rga}hTW_8zcp8nsIz7t8?C z&LdPc@Yn}$%H@7~hw=Ps)yA6Twyka0?v*%@m4sF%x{QPK>(jBB*IZ)fepN$Dy+wF` zen`?_t`yR~ThqW#xmcHUA~h!&R_)O*n#d5{u`eIB1&xd;CfqdT4=N?eGJ ze)V*d=)))wU47;by!b6YRFukU_00AWk^8>u7Z)aVZiUcs1 z!xx`^#mb@kDb-BH!d)Q4GP&+pR5CspCiezb=z7rRPq8q{enpboM#mk1XFab^*BhaR z;ht}mRCn(M#G*Pb``V23c(xhQ}7L&c6tF3*3F^hhllmh2eb)CU!d9Avq? z%}VU)F9D67@bUcImvkBNP`_(}<5Qb&U}^;ShY%M}EbxVHl2${eTbyA3+HDE?>68gGttK{vbq$~WT{GwQ{|?O<-I*xhZe=!tm^Egc3IRU=m9dF&Mcd%}>bf#N9{dv|r~|pEkYC zfiAMbCxX?zO%|26;wtY^brc;jTUH2~sjoV9l=#DVnrE<`+j25PSld54mwIfHUpEc>b7F>uo7Y4Dq?-q(U_ z?BvWPA9w+0p_>DvS$_%|K*BAC54J98!oFwVVgF93-6I9P4ILCm)G|1Lp$UaibSgb& zzluh`>~@WFDai`}(rrwkJOcyR39KaDyr$h~|BCCqF>~^H_!hBo8sJm~Q*9P@6zlNW zXNBa7gj8+7#RwG92fVW}2TG6dd9a=@36{0PK^R9oG&^R<;9rRRDPx=HYZ#k7)9tZg zIuI8F5{+#77G?~T4?r{xc)_?7GTy;w;73pTdS3^Yxsth)rA_0-8+vkCzDGHhNZ$NY z;Xcl4YES7--Fkc>b#xa#$xqhj4;R^ZUuYkcF?`G8VBUKkLbDV4E?x}tyV#FRb4vr@ zlQBvtVbp_GZt%5r_V}FMxTu3tJU-UgIf^#=-)6uL2b;f6S%2k|W!Wxzi)$UR1=8~n zO0(EyaJt+}a1~@>l{@zh!rN^=X7H1ODO`S@9`_L`3gEcF9WN^LxjHw{s<`%%-UU0l zOwU3tAw^YIEu;!WzF~a>ifmIr1oL+&lT^4!XT>J7f7WP8HQZCj=f3WW&mSmcEZ026 z#o>3xY%N76a6%YYG~K_<@gucIH04Ey=SA*12-$;xAV-LH$lqV8MgugMN~Tu{-cg`^ z+n+v4FlM>jmWi+i=+s2^&yj9Yqq{Q4_1(pMr?!q73h(n-_SdU1&r!(h@T65wMND9!}OWmF?w6zuq@eO>d0dKRL84hX`R* zna2WBJ-p-V_&65^^6i=BE41_T7_+=zr0KYI3U%@!Qs)6ZN&v z_3{t4Z8z4{bk^t3*sN+C?s}8?aGJ%tOdBiS(9?a3Pns+9tj?Dj_BJKOuH-CnVB%Bd zIpCELy%>QwJ*SlCNY6qcC7>2ldubH;e5JAKokrWWqT3#hGXlhZ5W+|NXBEC)q!NlI zP`&y)A71ESg-!4LGmP+K`(TVdPq7(*f}C!=Xn_=NW^qEJ-vzW(jzESDU2N7(Oip_= z94+x7BBc?^Jiv^4EGGyBQE z1svFhId%+0=M_022rtW>w z-y)2Wm_1c2bv0v)L!(%>I)`ub^yv=50d2R}J(9>0biV!=_Xi7{GKt=W@5ms{w}}ID zQam3&ERi`jHGZ{C<$XfZw10X2KA4^NZ&WnDD%8%C8tiw>NaKGW+z{t^seeY0NYTDF zQltF%8?><`(%yJOO~B|zvw40^FP4m)o4Q7a`G?t9LLVvk5QhU7_&+1G;3|^v7K{9V zf3wo@ogYqRRKk99zpN=(x+@aDkHKW-lrqwugH2zh8H z+aGG=%4>hH?oZ=xy&mN0V9vZXtpw+~a1eUg@8~m8U)6*snAU&#N%Wn3MqD?HceB4k z)LhO5y}nvkbd(ph@0Dg!xS7g zR2FTFQ~a$7AMelNjK49zw*dIyfqb`ZGKMKKA?esvdl{h;_6+mTDrm0OZ7afS2LNzW zG)X#6;{8-2Y*lje^3tHtqWJg&)b=`=&yV%oMI~?TpD6<<#rq(z=kssJG>=NPr&jS_ zBQY~V4ateB9xn^o*;e@T#Whx-aerg&Ap?`=(IX@DX=oX8;=PpH*WgA$O*zTJJKVzL zQ;dNj@_XuE&4H7w<0>~KruIR!xZerxsiycGByO#h-(E}Fne(dTHtnoEi{p1oo9`|e zQf)c9J|>R)Sv2zdA8)zu)jeFw>r8&H4!*~;hmRhcw1k#kzoFruy#({Xa=9Y6@pypD z9P2HF)X-qITr?d_K#ee`^Gf^uWXFbzM{Cck1pfqo$gGBgu3Zi_@8id}RNqALCNoIa zRUpRjFFdH$&o_Sfkg1j49%=y`d`&DKVK!#Wa2JV>8@+Iq$gvnII{~fv?3u>||GX*o z!>3?hK1msG+@m=7-|zlCSQUf-OA&2*;N+fK7=O`$I$yW)9!i%Xi>GU;twi&l$n-rW z!V-x}`#Ox*k`7)zg$KPV?`jl&N=?7h=b2H_?|C>9QGCf`+$=o;Ij{0YJGNH#GBsHU z{&Z7wj0*Tdo&v1{1EH@F57EZ$hE)F_DqFmpd0PG|fje-#Lgjtsjk!d~u! zm{L}+FU3!y z9%}=+0Ljw_Af9XVrS}yts#CZMU8>>e$3eE-)I@pWtB|i|6$t2C0h<-?7bBI$zY8cW z_;vm*tQ+`M13)J~yr|VwPZA}+Cr~FhrhUbMZOGdvdMCht@ zmv-gNAf}7uU7g`(PuaeilAR*J!CEb+-;ss3M|l}0HxIal`^lpTlP+stlK+0x>7M4l z1Oy!Wd<9|nbfDU1lzCW^+VA7LzFCin%#50*I{(N@qMcoC;0DeVb%@~OjtMTj4`=wB zq-dY=2@oG;Pmu-?+DfLzHh;|AVMP3#NCC=luIK3OrI)a^EU0uDB1S54vX7~wC_ zg=ntg>l(ruWRhnK1Sf{_8U>oo^vI1=$LzJM>~gey_cQZ-6FvHQz&QQU5&#yzh#q=` z->KTyzZUnbQHy^o#QS;d4lhv{iW5db{lp`ep8==cCaM3hx)-t5vJ5J@1%r>FTwC`A z55|1*!hf!LfhMcc9nb><_B?9q%T6DY)FL_^_idMZoGyIU9OC-7+~Ax(-w|(-PT{=T z4AVv@9EY?{9g$C1vnAMwQpqt628{0lV9Pp_c{0O<=D*&t*}o9i>M$hYDWSSfCYWod z3;m{!2q2{6f#`;+ZPfjp5j`mx%(R@Juc!T(DaI9p!?_c08xO5qv$Qe<@0}6f9OXLA zE58<1VQQni4pVrwv4%>#CeaIQCzwEl%~X9jpO2XM#SPx$)v4Fz*c{%eN@JSQr9CoH z;cGl?>%Jjd9)Sx?Iw5=#1e_MI^NgP#8}^iC8O8XZ=CC}J`snwTr(Vef~?&8lF;5<{0SX!ES_em0$Ajr{#v*0x$!7S|akRzxm;y z3(@1DQ_S`_JX*mUA(DEMwdG*gj%Ol|T9>Dl45t_l&ghkn3XV94v~QF%C$fREQ!e4| zPCkS0gS_#+d^|qC-}wlOz{2uwpLB5S1hWX-$A-jLKk>jFMVw5>SCkAI^oknMr zZj>lDqYQGQM78-joIY>CBZzHgf!OCy?B}aMz9p&7$N#W){&@p&xt}NUAM0rrVEc2y zP>%fNIMHo*DpJWalp;Px9F3ErvLD*A&dy<+_mvojazFFJB=1|HV;iE80>Xb<7pF zeU)~9_WU%5yMvkUBS7T(@4A04`JYg6f-j*$e*=?>^)Km}pD%9uYP0?;lg0?bm%v5G zQ*`!b?hB>IZ4F(Ty=+9iCe$aMccCkH`5l9_KN8_y?36?R0X#v~pYMx&6o1>(`0KU8y?3GN zeVJZvT%~p9Ff$cVUq8M-r?)dDVAoN58j${G14z9$Lb_V1%G4LOG z0L83+2*U0aua+1*)uW}jiX5lkUNtwqP!B=ZFxoH4ungfN)7{>_8r;7}#6{d~wqV)1 zZ>_)c&;Onxu;Mwj6XN8L$tlU(*xKfl!NoKO`MTPIlDtd5pBPwP3J%!k#9$4W$y*pp z)(nNa@TGZfBQ-x1H0!h~D_$2rv_ClA*Tw!(JnhwEeY|1?Yd(O6TGsk_4MA!yU`W($ zi3rXS;VFe-lA7t)xc^Ku_b|W0dkKcNo4!Wt?VF}a8*ThLX>WRHzZ^x9amMlHZ(G$c z>(8f;k!pQ`+5JpM0B_td)|=7m%)InRF7d`n=1K#JEdP5WTE-ymAro)=X?ym0weuNT zN@O8(;WyuZKVr#D$H~b=Iwi$gSIo;56`m@bBN)q?6T`%;b4TY6y80@3Z)ite>&Vbv zr%J!@iyIx_pVy>)dxz8e4`BHkma(fY&=Y7Pf-Zq_U+p`l%>FF@!0|)$+oF%zDcOO3 zC~DH4W%3HZj-dJsue{h9W-mKYQNlU#Th-lXF<3(r;NvY6Tdo1~I+sq~|=28;sTBkR|Z zpAXAqz-GscqFre#fs!S{7L+*T{G}v^4D)DPZA5>e2*-uBP4wkR!16B;URB0SKhaP0 z6FzShwa6hLte}034IBGuz+u{PzrY3dh1h-nH0&kFT{$#knA*)W#CZSzEW`v%p>fZR z@NhN6ZAd2&pvmLSmHLB>0!!<0kn6nT%R@-(>z1ts zVfkZbCFn48?wktMt=|tYXTl=^qQm?iU_$y7-@MDuRYZhs!COjS`s%WW?D-y`-qY|! z^kH884a$1d{)OG=zv1Wdx36O4{!bP)<8#o(ov$kmBu30Gh5TW@i{9PN<7icp2(ag+Vi2+xGZ!q0e-u1_{Vnkx#{)C!ibj1Z+RjKj9bJ^Jjna@4$v+Ss(+D zw>?1K$WoM4w?;k<)?$wU5=Mf?Eb;1AQgWcCW3mBa z!g81}h~9RxX$u%T;uq|LQp|OEdR~qu)Hhn7ev|j%NC}|S-}wd>+RaD)`oQ3_!gi3- zbj4>ZS-D4-o%kbHjPDBprOQeCb?rHwZ`IvjM^OPwIQa3$+d#PNquuy@XLd_ah!*;D z0T8JO?R<4@NZmW=(jAm=_7ipQOPp^5K0OJs*0w}*3@+6}_7BvJ(9s|Abx^tX9Fd-G zk4t{RP+j9@Zy6KrcSrP<5p}+e={l9!LuYqQ9en-jZ{1Qjgc5%vpyT?ZLk&BMdW^>_ z$Ye>`{kf^xN+yXDns`yJc*GZQILT=pGX`| z@3v52SzPq>>-Te2n(^-rbIAG!l3+tU5!${tI*tx*e8Jnpc&e_+ebhO@P+(R6nQ^Fx ze3rSHRpmWv{rF+|aS}HA{rpoOS2eZg#j5jt5_`~oiR>g;x|z8Q3O%EP>j;QOROlNE zN#LWSat5=(&q)E7s05u@{)^twZ62%bkUX10l;z_u^=GPA)_XY2n%`en!;kuJ@}QRj zbhpv>4yZ0y_IHe>BKVdzd9ZUtyKT)kqkR=e=kuq!V`6o4K~jznj;Foa>izx^H=6EB zf|k;lQ(WcRkthA>26dhu5-t^BI?F+iXxkV4a~W=hW~r}rhz?G9VUCj8covQsDNEh` zT`U(B=}(4Gu;SBIKScFnM%vsesU)aowz%^92900>Lh!LZ-Y}KE zO*eR1v9V%LXRuUh_=+;ciRXDtvDf+N-r$IOBP~UY_Z9(cK3%tCWQ%(xw^H)uHEX2e z;H+m+4~Ho}%9S%Iq=zN}`3Bra%ZcpyF|Zrlk~B1YDI*`XRd0)BZTsn-NLPeV0)JUA z9gULiGq@%!v7e9dtZO34gLCz{dXTXJMcR+es#9ct(a*m{9}fhEL*RaOYM{2HZl|98 zGzTMj!mxm%<;UUacIWW)86R|apA8)R9O&ZZakg|P1`4H#(3tLmSub?Pq#wPtP|xeW zd&RI}ojDm~dd5YvnE!0|>bIOxoBn;}I-f}HeK~N!UQ+$Yhx+Vij}BDuqI?Xg;v-RX{UO&_uRr_G7IlLnHj~Y?@RZ2;v%$$bqLep9x7@?c|jaR71 zJ#g>KC;k31x1e6c#q_U#yFH#{d{NyH-o#C{78V1S=%L}S@!mKB>X-AM)xj0Cj&eM( zzTY(koRyqXt_*5|<NaSG8(Eyb^ zd>`X!=4Lm7B7{CUZ@APD@T^USM<*XKlT>0}cQ*3(MxeoWQtSh~3GvX!Uyj$2o+q|~ zYPefRmexvlWM=C=?KUZ<)+=|>o+Mfb>BucEx zB1?(jq&8Q>;qP+dsAJJBtG5$cRq9wrx9fR(Iehviv>BBcd6EN7X(^PJUj|POfcfnu zK{)&_)y#}r?cS0O1=>1vjldBqP71d`e5FS+HOW+&v!_`_3S@T@?yD{lO~WWO6ksIZNI^tDX)P0+(&{0HZn6z(5d7^KeI4IeD@ zV+d5I!=d=u6d&GXOr<2rewq!xy}Qi9=FWY?!l{>1U=(x5(ZKD$J^52jI0Q!PK+}$! z72h@{XIWEV0#C2`L;!+p%IYc$OA(xMn6sxkkXq8sW;r0Ni5Gf3YHP*)U92JDCUa@h z2m1AXp7cryt7nAVSA>-Zc|2sOI||)}ziF%Xws}ygsaBKKw7K3q^RA?{Ez-{{9GI^F z{mGs$@Ba<2rFx;C_yohDbNJ-Cgm*F$I;9vOfREEBDy3}k;LlR>Sg7PTntjUd-@5#g z_w)A~Mv(Vz(l($lr#w7N^3b7uD;cz+@3>oWY3^$=9(*l=J385H$=ySCxgy_%e{5mg z{HNb1prxTHU$DQ|YXDz=6wsAM1BNv|@7qUq9U+o*(L6lhFmP!vE|Bne_?aEbwHn!d z;i``>Mu9=c0nyyKhLC$-a^VTK-_5=OV;cTkx)4l_d@{B8lD`lwP`=;OnMYyd5zcPD zB^cY|hM0gk3|;fgd#%l^Au61b8tD@1sk-0#_)ShaaR=XO4;@x5SgaB>>kc->#t~4E z)8Gr?Bbeg7ZjmBJ?KQSbU?|xYXW~}ik0vN=FmD4IdEHKXDdRn|dsvlTi*{6Wj2EWm zqr3{Xr(o=Q)OfDYZjk6_{@KF^Dmg!R7K_Krwg+RX8d}OL1A7*|I-g;CA}+97O?ekbYxSM^BF3-^qLfGylne`sT ztM`eHCA-%#ey}&ZY7c|^Ax8m{@>e~vpJ~?`+@|Au^)6%Ud&8c;n3beHuY`&os_qR= z0dz6bt04cS8um-XqOivqL&NdBPOvx4`Ud-j+Ll`df-Ise^;JU~ZD=RT--IJvLcYGw z#uD%%X0RU#pt6~F{U(cU5s#uurX*Hz1~sqx3x%T_zV;~#1as>RYMh5_J#>t{=Atk6++#P+m{N4aWh`b(O2-J9UCt zxsfbQ?|<$mEAkE&+s)zBA|^OvwnX)mT?U9raVE(%I9&MZ<6T3bVJtcVZ$UiD^atGwnN1saC8Zj?!L(xJ1of9z}Bv7pf$R`>|p_ zt4i0dzmk0>*Ne$6hXeE0yy{`1woiZC_I}iIv|ysu=yBEl2sOKIxlHD*GMyvg%-&!e zeYq}1>qLItKzS-91^4iO7lvxiy@%IZpzRU6h^lpT>KqxvA=~Yq7`Z9@&d^)EbH2Ul z>j-nQPe=<_hCTxaRj=mvUN#(qiCrKh;6>Fb7U)V$)&*?qX_pJM!D1D*HB)+8tpZka>qY-h~4ZcqDWrqInJhqA&ueR*r& zbC)xCk*5}RWw-x=%(`A5ud;ofAFtz^uh=%oXR9>10)9)k2hk*JX4a5Ytm2<MLqWGA1|UuU$fqhl&Q#Qe13TabzwA5IwCX6VXmKn*}$mXob}B3bgRzDx|q#FHzDMG;q#*XRJ?4Ey=>2j{5V_K<)t{CKVTWa>bm}kj4;Av>-S1a=Tn{e-p^md zQF?Viih(1T&M~!7uPc4YqdUQ?l+#?Z#n!gn-W+dkfj1)D8IivlDe{t_y6MIbey4&V z-$RGoYl&ljwloRUZybpuw_3--4hR{$S{}3WN?kv~U+j&0_Gzp46XEXB-M)K-#3^>^ zy9ZqY?p{zo4&y$*5Wizvz6MtdXx7URhdttLoJcCSi-x5nGp~<_^OZ+HeBSww$Ls`ht#iVjN+X^!qW?C*ur^R`@*@6CpJPEkMa08*AS+5CZ@16;c2x6g{c8 zs%VVt>o^?ty%u(V)iAbah|B$)@gP%P_A3__xA3087B7KgK7m}j4nIs|ZtuVFwb%yY zlBN^)^7n3`^vC=mXjit<^B*Pi`6Q|6t8N!YzkQ({Z3BY>;IRbI(%!DKp*i*lw6f1IG6Bq}rdQO9aX zvq{PzIO?M}r~`^hFIHkRd&Z%12*uU}zJKke*!z{>NQV!LG?JK&h;V}&u6cyc%8VQsK?oJs3~x+eHZDR+*>VZ6jP&75*y6a2~VA1J*@M}|rf zH>e*}C^=QtLgU;uA#>fV`PXs;4z5~rna=5> zh0h-(MJK}G+%dn)&oQIt^NO$dt)7?Z<2ue;iB6s|y5kDA$Pi4pnLf4BPm)6)E& zGsbR{UAiOrZLwU-!JqTYn~EtF2awe}I7cxMRC1 zkMKoGgYVmo&r77v?rB@|mqTpvs?ueFawy?Cm>DZ>D!I?=s@a#owm}<(t{#$tp&ZQ; zXb9Zs<8oKvN0J?^czAFpQn?EOf523ecoV*8pxIFfXVN&Oi99O%26P|HOj1fVnouX> zi6vB{_gD%)H_BE;HJmEAS}$*ySf2Iv+Go+z&gA~XczYzp5aA8&X&gPwD<<#joGT{s z!wIFR^s$g%yPUjLF9R2r1Q;*5Qs_xDs;qws(Tmm(!FWI~Fx*>9jUlrrM(sEC}+n0dYL`;{s4V=Y1_r#
  • rKy^D?|xwTU-G_yVmTV9phFTY|}N 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:C7I~ch&i4}%g|EuryXM?F2%HAVYIUL6kMJ=CsmCld^5s|w?i~^+; zD&iF2pO4e=s8`b!VCv>>IrjHRr`0_pH*%BSdE|AKApvig6*;3*fFrv78eODZms!E9s&v(mI>?m(@?|) z3BM*UFG^?o7DIp5kqfhiT%x2QlqFXou=mcZZM(msTv#fii>w_u*@;yT`0M!j5k=q7 zp13!LF7131&$Sa^h5*XPv;C}7lAARNgL<=yo})1oL~&z0-<`AxgPYC#WDZ*s-T|Lr zlYgwmx8|vt>K)c>gDU|mYx!dw48^DipPU@N&nOB|4_>wAgA(rfSgqWKdms0^ZYt4) zqe&|HqGrzgB+lQGVc1%&9U9G$&7LWjYVR;{^QlV8ueJ*|Z{S1P3^xS2efEh+dfBkP!ov?M9JUvS z%a|XE!<*>$cegiu7%w~)*H%KF<-~@BPExjwV%_et8d}^1!j`ok%f6I} z&Q@}6o+11VNG>RzJ+_+OfBCii?pf<~i{LYD~dV8+$sw zG@l%LY;_*#YClw16th+n@gr310De!cHxYwI|IkhM5V{w3vJU~Il2=PSgz#FJjH@6b zNGZvuawx{wV!qzP?s$p%AgaAak8W2w=k9ATV!x3Z7V(NdBpT-E8bTlALC#8b8Bh*D zZ>nv|D*MOLice2q39--w!~TwG^i|Jh$-J;S=_GnJPt}Gpy4WhJI2tNeu`y9xl=+8E zdTm(L&yceXSZ{V*T2cM49wu<@Pb%48w~<+3W<*P zuh7?v&^|(*DSriG;n8|mrU|5~OT0f9YBNS60~lxRUvgsXw|V-p2<<3Q`$S4Bxa# zw%je#7um8$6X%Y<216nvyXeRo5Wk7IG~s3slRIXn8kuu;biK_X6S~A%IT<#p?Mdw|MkxAzPx*cBp8L$>n&T{8sZTikPd#7v`$no&LD#D&}FMUotn4a`iZ9G$Rg> zXx*R9CvhKY`EBD|*OafY`&W3(UZUz#7Zv6U5weqKJROa;37to#${cp*k{*Y!m*L+d zN1=u{t18U=1QG8BjpCm)Y?+|v&&Bb`n+@e%?)Cz4usc2vl~+s=5foo_X?H0k$fW5& zwQJUe@5TCl?M9HjsR9KCdZ|{`Q)%qot^jjy<3B}$`1hCOgPs?B&s+}YdpbH@U%J=a zeGcC6n|Jx*)+L@^PvU3&_PG{356ud@G1#Ox982iVjOb0)3x9!jQ=T1%1Hp=%*IkrF zn%OM{r?VmdCQaZAvMU2z)m3WN58asWp=B)e*rpBlkz3ef2n2c@TMoxs_HrBfmoh^P{|$wbLLc=|skQ4i@Yo4)Ae>A-L?m^gpgHQ76-)9Qxn zdW1p|T_MsRcl><-#2;qa<{XWlm5sUAb^9)COHRWKq;;Yf-G?-2)C+T|MSD@%& z|9e21XrShssKOD%ppEhK;rS7lD1Y~K?{5Tv30+{TlgCO-$q}M!KqJ>S;QOtTnWMQl`g;X6H@^z^q5WVm z(!7boqI#SBe`+TKp?ksGpx(YhvM8tTb^^4jibFh3hu@pDqi0YwpV3z9v=21@@Wkq2&OXGVOX>HO;%PKQ>WKvG}FVwD&KO%1gc@WDqZ8M0}w~-W@0XL@;g9 zmSoXScobUSztc22`}Vw)TpYPcwu*%k4Q5Cs4W=%q2}>Ltm}U&vVE045glr=o0)@)d z2R)j9Z;t(88YNIZefSziXQ7N-Edl>|Y4RLE8-9P53v#teKJofq<#zMfosQXayk*`ixSH2J6?LvRpA~=1aF_*9ZXnHj)od#XXo_XCiYhy-oLkh5B0wT-n^G^TaX@)U6F#xq}Tt- z8TOkcLn#<{xiHsm7{Xg6B=D`gr%g|vlAHULYl{Z^@qYakFNb&L{kJ!#t6V%Y2dXJv z)acCA)3gh0T7&l>Z(znuX#7I#TMCB)hLTCm_Jo`p^+oNK+7UmiuzlMsb=3Z+M-5{S z4&BUK5}&O-MH0hp+q--4o

    object({
    name = string
    image = string
    cpu = number
    memory = string
    min_replicas = optional(number, 0)
    max_replicas = optional(number, 10)
    env = optional(list(object({
    name = string
    secret_name = optional(string)
    value = optional(string)
    })))
    })
    |
    {
    "cpu": 1,
    "env": [],
    "image": "ghcr.io/pwd9000-ml/chatbot-ui:main",
    "max_replicas": 10,
    "memory": "2Gi",
    "min_replicas": 0,
    "name": "gpt-chatbot-ui"
    }
    | no | -| [ca\_identity](#input\_ca\_identity) | type = object({
    type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
    identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
    }) |
    object({
    type = string
    identity_ids = optional(list(string))
    })
    | `null` | no | -| [ca\_ingress](#input\_ca\_ingress) | type = object({
    allow\_insecure\_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`.
    external\_enabled = (Optional) Enable external access to the container app. Defaults to `true`.
    target\_port = (Required) The port to use for the container app. Defaults to `3000`.
    transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`.
    type = object({
    percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`.
    latest\_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`.
    }) |
    object({
    allow_insecure_connections = optional(bool)
    external_enabled = optional(bool)
    target_port = number
    transport = optional(string)
    traffic_weight = optional(object({
    percentage = number
    latest_revision = optional(bool)
    }))
    })
    |
    {
    "allow_insecure_connections": false,
    "external_enabled": true,
    "target_port": 3000,
    "traffic_weight": {
    "latest_revision": true,
    "percentage": 100
    },
    "transport": "auto"
    }
    | no | -| [ca\_name](#input\_ca\_name) | Name of the container app to create. | `string` | `"gptca"` | no | -| [ca\_revision\_mode](#input\_ca\_revision\_mode) | Revision mode of the container app to create. | `string` | `"Single"` | no | -| [ca\_secrets](#input\_ca\_secrets) | type = list(object({
    name = (Required) The name of the secret.
    value = (Required) The value of the secret.
    })) |
    list(object({
    name = string
    value = string
    }))
    |
    [
    {
    "name": "secret1",
    "value": "value1"
    },
    {
    "name": "secret2",
    "value": "value2"
    }
    ]
    | no | -| [cae\_name](#input\_cae\_name) | Name of the container app environment to create. | `string` | `"gptcae"` | no | -| [cdn\_endpoint](#input\_cdn\_endpoint) | typp = object({
    name = (Required) The name of the CDN endpoint to create.
    enabled = (Optional) Is the CDN endpoint enabled? Defaults to `true`.
    }) |
    object({
    name = string
    enabled = optional(bool, true)
    })
    |
    {
    "enabled": true,
    "name": "PrivateGPT"
    }
    | no | -| [cdn\_firewall\_policy](#input\_cdn\_firewall\_policy) | The CDN firewall policies to create. |
    object({
    create_waf = bool
    name = string
    enabled = optional(bool, true)
    mode = optional(string, "Prevention")
    redirect_url = optional(string)
    custom_block_response_status_code = optional(number, 403)
    custom_block_response_body = optional(string)
    custom_rules = optional(list(object({
    name = string
    action = string
    enabled = optional(bool, true)
    priority = number
    type = string
    rate_limit_duration_in_minutes = optional(number, 1)
    rate_limit_threshold = optional(number, 10)
    match_conditions = list(object({
    match_variable = string
    match_values = list(string)
    operator = string
    selector = optional(string)
    negation_condition = optional(bool)
    transforms = optional(list(string))
    }))
    })))
    })
    |
    {
    "create_waf": true,
    "custom_block_response_body": "WW91ciByZXF1ZXN0IGhhcyBiZWVuIGJsb2NrZWQu",
    "custom_block_response_status_code": 403,
    "custom_rules": [
    {
    "action": "Block",
    "enabled": true,
    "match_conditions": [
    {
    "match_values": [
    "10.0.1.0/24",
    "10.0.2.0/24"
    ],
    "match_variable": "RemoteAddr",
    "negation_condition": null,
    "operator": "IPMatch",
    "selector": null,
    "transforms": []
    }
    ],
    "name": "PrivateGPTFirewallPolicyCustomRule",
    "priority": 100,
    "rate_limit_duration_in_minutes": 1,
    "rate_limit_threshold": 10,
    "type": "MatchRule"
    }
    ],
    "enabled": true,
    "mode": "Prevention",
    "name": "PrivateGPTFirewallPolicy",
    "redirect_url": null
    }
    | no | -| [cdn\_gpt\_origin](#input\_cdn\_gpt\_origin) | type = object({
    name = (Required) The name which should be used for this Front Door Origin. Changing this forces a new Front Door Origin to be created.
    origin\_group\_name = (Required) The name of the CDN origin group to associate this origin with.
    enabled = (Optional) Is the CDN origin enabled? Defaults to `true`.
    certificate\_name\_check\_enabled = (Required) Specifies whether certificate name checks are enabled for this origin. Defaults to `true`.
    http\_port = (Optional) The HTTP port of the origin. (e.g. 80)
    https\_port = (Optional) The HTTPS port of the origin. (e.g. 443)
    priority = (Optional) The priority of the origin. (e.g. 1)
    weight = (Optional) The weight of the origin. (e.g. 1000)
    }) |
    object({
    name = string
    origin_group_name = string
    enabled = optional(bool, true)
    certificate_name_check_enabled = optional(bool, true)
    http_port = optional(number, 80)
    https_port = optional(number, 443)
    priority = optional(number, 1)
    weight = optional(number, 1000)
    })
    |
    {
    "certificate_name_check_enabled": true,
    "enabled": true,
    "http_port": 80,
    "https_port": 443,
    "name": "PrivateGPTOrigin",
    "origin_group_name": "PrivateGPTOriginGroup",
    "priority": 1,
    "weight": 1000
    }
    | no | -| [cdn\_origin\_groups](#input\_cdn\_origin\_groups) | type = list(object({
    name = (Required) The name of the CDN origin group to create.
    session\_affinity\_enabled = (Optional) Is session affinity enabled? Defaults to `false`.
    restore\_traffic\_time\_to\_healed\_or\_new\_endpoint\_in\_minutes = (Optional) The time in minutes to restore traffic to a healed or new endpoint. Defaults to `5`.
    health\_probe = (Optional) The health probe settings.
    type = object({
    interval\_in\_seconds = (Optional) The interval in seconds between health probes. Defaults to `100`.
    path = (Optional) The path to use for health probes. Defaults to `/`.
    protocol = (Optional) The protocol to use for health probes. Possible values include 'Http' and 'Https'. Defaults to `Http`.
    request\_type = (Optional) The request type to use for health probes. Possible values include 'GET', 'HEAD', and 'OPTIONS'. Defaults to `HEAD`.
    }))
    load\_balancing = (Optional) The load balancing settings.
    type = object({
    additional\_latency\_in\_milliseconds = (Optional) The additional latency in milliseconds for probes to fall into the lowest latency bucket. Defaults to `50`.
    sample\_size = (Optional) The number of samples to take for load balancing decisions. Defaults to `4`.
    successful\_samples\_required = (Optional) The number of samples within the sample period that must succeed. Defaults to `3`.
    }))
    })) |
    list(object({
    name = string
    session_affinity_enabled = optional(bool, false)
    restore_traffic_time_to_healed_or_new_endpoint_in_minutes = optional(number, 5)
    health_probe = optional(object({
    interval_in_seconds = optional(number, 100)
    path = optional(string, "/")
    protocol = optional(string, "Http")
    request_type = optional(string, "HEAD")
    }))
    load_balancing = optional(object({
    additional_latency_in_milliseconds = optional(number, 50)
    sample_size = optional(number, 4)
    successful_samples_required = optional(number, 3)
    }))
    }))
    |
    [
    {
    "health_probe": {
    "interval_in_seconds": 100,
    "path": "/",
    "protocol": "Http",
    "request_type": "HEAD"
    },
    "load_balancing": {
    "additional_latency_in_milliseconds": 50,
    "sample_size": 4,
    "successful_samples_required": 3
    },
    "name": "PrivateGPTOriginGroup",
    "restore_traffic_time_to_healed_or_new_endpoint_in_minutes": 5,
    "session_affinity_enabled": false
    }
    ]
    | no | -| [cdn\_profile\_name](#input\_cdn\_profile\_name) | The name of the CDN profile to create. | `string` | `"example-cdn-profile"` | no | -| [cdn\_route](#input\_cdn\_route) | type = object({
    name = (Required) The name of the CDN route to create.
    enabled = (Optional) Is the CDN route enabled? Defaults to `true`.
    forwarding\_protocol = (Optional) The protocol this rule will use when forwarding traffic to backends. Possible values include `MatchRequest`, `HttpOnly` and `HttpsOnly`. Defaults to `HttpsOnly`.
    https\_redirect\_enabled = (Optional) Is HTTPS redirect enabled? Defaults to `false`.
    patterns\_to\_match = (Optional) The list of patterns to match for this rule. Defaults to `["/*"]`.
    supported\_protocols = (Optional) The list of supported protocols for this rule. Defaults to `["Http", "Https"]`.
    cdn\_frontdoor\_origin\_path = (Optional) The path to use when forwarding traffic to backends. Defaults to `null`.
    cdn\_frontdoor\_rule\_set\_ids = (Optional) The list of rule set IDs to associate with this rule. Defaults to `null`.
    link\_to\_default\_domain = (Optional) Is the CDN route linked to the default domain? Defaults to `false`.
    cache = (Optional) The CDN route cache settings.
    type = object({
    query\_string\_caching\_behavior = (Required) The query string caching behavior. Possible values include 'IgnoreQueryString', 'BypassCaching', 'UseQueryString', and 'NotSet'. Defaults to 'IgnoreQueryString'.
    query\_strings = (Optional) The list of query strings to include or exclude from caching. Defaults to `[]`.
    compression\_enabled = (Required) Is compression enabled? Defaults to `false`.
    content\_types\_to\_compress = (Optional) The list of content types to compress. Defaults to `[]`.
    })
    }) |
    object({
    name = string
    enabled = optional(bool, true)
    forwarding_protocol = optional(string, "HttpsOnly")
    https_redirect_enabled = optional(bool, false)
    patterns_to_match = optional(list(string), ["/*"])
    supported_protocols = optional(list(string), ["Http", "Https"])
    cdn_frontdoor_origin_path = optional(string, null)
    cdn_frontdoor_rule_set_ids = optional(list(string), null)
    link_to_default_domain = optional(bool, false)
    cache = optional(object({
    query_string_caching_behavior = string
    query_strings = optional(list(string), [])
    compression_enabled = bool
    content_types_to_compress = optional(list(string), [])
    }))
    })
    |
    {
    "cache": {
    "compression_enabled": false,
    "content_types_to_compress": [],
    "query_string_caching_behavior": "IgnoreQueryString",
    "query_strings": []
    },
    "cdn_frontdoor_origin_path": null,
    "cdn_frontdoor_rule_set_ids": null,
    "enabled": true,
    "forwarding_protocol": "HttpsOnly",
    "https_redirect_enabled": false,
    "link_to_default_domain": false,
    "name": "PrivateGPTRoute",
    "patterns_to_match": [
    "/*"
    ],
    "supported_protocols": [
    "Http",
    "Https"
    ]
    }
    | no | -| [cdn\_security\_policy](#input\_cdn\_security\_policy) | type = object({
    name = (Required) The name of the CDN security policy to create.
    patterns\_to\_match = (Required) The list of patterns to match for this policy. Defaults to `["/*"]`.
    }) |
    object({
    name = string
    patterns_to_match = list(string)
    })
    |
    {
    "name": "PrivateGPTSecurityPolicy",
    "patterns_to_match": [
    "/*"
    ]
    }
    | no | -| [cdn\_sku\_name](#input\_cdn\_sku\_name) | Specifies the SKU for the CDN Front Door Profile. Possible values include 'Standard\_AzureFrontDoor' and 'Premium\_AzureFrontDoor'. | `string` | `"Standard_AzureFrontDoor"` | no | -| [create\_dns\_zone](#input\_create\_dns\_zone) | Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided. | `bool` | `false` | no | -| [create\_front\_door\_cdn](#input\_create\_front\_door\_cdn) | Create a Front Door profile. | `bool` | `false` | no | -| [create\_model\_deployment](#input\_create\_model\_deployment) | Create the model deployment. | `bool` | `false` | no | -| [create\_openai\_service](#input\_create\_openai\_service) | Create the OpenAI service. | `bool` | `false` | no | -| [custom\_domain\_config](#input\_custom\_domain\_config) | type = object({
    zone\_name = (Required) The name of the DNS zone to create the CNAME and TXT record in for the CDN Front Door Custom domain.
    host\_name = (Required) The host name of the DNS record to create. (e.g. Contoso)
    ttl = (Optional) The TTL of the DNS record to create. (e.g. 3600)
    tls = optional(list(object({
    certificate\_type = (Optional) Defines the source of the SSL certificate. Possible values include 'CustomerCertificate' and 'ManagedCertificate'. Defaults to 'ManagedCertificate'.
    NOTE: It may take up to 15 minutes for the Front Door Service to validate the state and Domain ownership of the Custom Domain.
    minimum\_tls\_version = (Optional) TLS protocol version that will be used for Https. Possible values include TLS10 and TLS12. Defaults to TLS12.
    }))))
    }) |
    object({
    zone_name = string
    host_name = string
    ttl = optional(number, 3600)
    tls = optional(list(object({
    certificate_type = optional(string, "ManagedCertificate")
    minimum_tls_version = optional(string, "TLS12")
    })))
    })
    |
    {
    "host_name": "PrivateGPT",
    "tls": [
    {
    "certificate_type": "ManagedCertificate",
    "minimum_tls_version": "TLS12"
    }
    ],
    "ttl": 3600,
    "zone_name": "mydomain7335.com"
    }
    | no | -| [dns\_resource\_group\_name](#input\_dns\_resource\_group\_name) | The name of the resource group to create the DNS zone in / or where the existing zone is hosted. | `string` | n/a | yes | -| [key\_vault\_access\_permission](#input\_key\_vault\_access\_permission) | The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`. | `list(string)` |
    [
    "Key Vault Secrets User"
    ]
    | no | -| [key\_vault\_id](#input\_key\_vault\_id) | (Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set. | `string` | `""` | no | -| [keyvault\_firewall\_allowed\_ips](#input\_keyvault\_firewall\_allowed\_ips) | value of keyvault firewall allowed ip rules. | `list(string)` | `[]` | no | -| [keyvault\_firewall\_bypass](#input\_keyvault\_firewall\_bypass) | List of keyvault firewall rules to bypass. | `string` | `"AzureServices"` | no | -| [keyvault\_firewall\_default\_action](#input\_keyvault\_firewall\_default\_action) | Default action for keyvault firewall rules. | `string` | `"Deny"` | no | -| [keyvault\_firewall\_virtual\_network\_subnet\_ids](#input\_keyvault\_firewall\_virtual\_network\_subnet\_ids) | value of keyvault firewall allowed virtual network subnet ids. | `list(string)` | `[]` | no | -| [kv\_config](#input\_kv\_config) | Key Vault configuration object to create azure key vault to store openai account details. |
    object({
    name = string
    sku = string
    })
    |
    {
    "name": "gptkv",
    "sku": "standard"
    }
    | no | -| [laws\_name](#input\_laws\_name) | Name of the log analytics workspace to create. | `string` | `"gptlaws"` | no | -| [laws\_retention\_in\_days](#input\_laws\_retention\_in\_days) | Retention in days of the log analytics workspace to create. | `number` | `30` | no | -| [laws\_sku](#input\_laws\_sku) | SKU of the log analytics workspace to create. | `string` | `"PerGB2018"` | no | -| [location](#input\_location) | Azure region where resources will be hosted. | `string` | `"uksouth"` | no | -| [model\_deployment](#input\_model\_deployment) | type = list(object({
    deployment\_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created.
    model\_name = {
    model\_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI.
    model\_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created.
    model\_version = (Required) The version of Cognitive Services Account Deployment model.
    }
    scale = {
    scale\_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created.
    scale\_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created.
    scale\_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created.
    scale\_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created.
    scale\_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created.
    }
    rai\_policy\_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created.
    })) |
    list(object({
    deployment_id = string
    model_name = string
    model_format = string
    model_version = string
    scale_type = string
    scale_tier = optional(string)
    scale_size = optional(number)
    scale_family = optional(string)
    scale_capacity = optional(number)
    rai_policy_name = optional(string)
    }))
    | `[]` | no | -| [openai\_account\_name](#input\_openai\_account\_name) | Name of the OpenAI service. | `string` | `"demo-account"` | no | -| [openai\_custom\_subdomain\_name](#input\_openai\_custom\_subdomain\_name) | The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name) | `string` | `"demo-account"` | no | -| [openai\_identity](#input\_openai\_identity) | type = object({
    type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
    identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
    }) |
    object({
    type = string
    })
    |
    {
    "type": "SystemAssigned"
    }
    | no | -| [openai\_local\_auth\_enabled](#input\_openai\_local\_auth\_enabled) | Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`. | `bool` | `true` | no | -| [openai\_outbound\_network\_access\_restricted](#input\_openai\_outbound\_network\_access\_restricted) | Whether or not outbound network access is restricted. Defaults to `false`. | `bool` | `false` | no | -| [openai\_public\_network\_access\_enabled](#input\_openai\_public\_network\_access\_enabled) | Whether or not public network access is enabled. Defaults to `false`. | `bool` | `true` | no | -| [openai\_sku\_name](#input\_openai\_sku\_name) | SKU name of the OpenAI service. | `string` | `"S0"` | no | -| [resource\_group\_name](#input\_resource\_group\_name) | Name of the resource group to create where the cognitive account OpenAI service is hosted. | `string` | n/a | yes | -| [tags](#input\_tags) | A map of key value pairs that is used to tag resources created. | `map(string)` | `{}` | no | - -## Outputs - -No outputs. - diff --git a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/common.auto.tfvars b/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/common.auto.tfvars deleted file mode 100644 index 0b2956c..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/common.auto.tfvars +++ /dev/null @@ -1,239 +0,0 @@ -### Common Variables ### -resource_group_name = "TF-Module-Example1-Cognitive-GPT" -location = "uksouth" -tags = { - Terraform = "True" - Description = "Private ChatGPT hosted on Azure OpenAI" - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" -} - -### OpenAI Service Module Inputs ### -kv_config = { - name = "openaikv2157" - sku = "standard" -} -keyvault_firewall_default_action = "Deny" -keyvault_firewall_bypass = "AzureServices" -keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs -keyvault_firewall_virtual_network_subnet_ids = [] - -### Create OpenAI Service ### -create_openai_service = true -openai_account_name = "openaiacc2157" -openai_custom_subdomain_name = "openaiacc2157" #translates to "https://openaiacc2157.openai.azure.com/" -openai_sku_name = "S0" -openai_local_auth_enabled = true -openai_outbound_network_access_restricted = false -openai_public_network_access_enabled = true -openai_identity = { - type = "SystemAssigned" -} - -### Create Model deployment ### -create_model_deployment = true -model_deployment = [ - # { - # deployment_id = "gpt35turbo" ## Example of "gpt-35-turbo" - # model_name = "gpt-35-turbo" - # model_format = "OpenAI" - # model_version = "0613" - # scale_type = "Standard" - # scale_capacity = 16 - # }, - # { - # deployment_id = "gpt4" ## Example of "gpt-4" - # model_name = "gpt-4" - # model_format = "OpenAI" - # model_version = "0613" - # scale_type = "Standard" - # scale_capacity = 16 - # }, - { - deployment_id = "gpt35turbo16k" - model_name = "gpt-35-turbo-16k" - model_format = "OpenAI" - model_version = "0613" - scale_type = "Standard" - scale_capacity = 34 # 34K == Roughly 204 RPM (Requests per minute) - }, - { - deployment_id = "gpt432k" ## latest model - model_name = "gpt-4-32k" - model_format = "OpenAI" - model_version = "0613" - scale_type = "Standard" - scale_capacity = 34 # 34K == Roughly 204 RPM (Requests per minute) - } -] - -### log analytics workspace for container apps ### -laws_name = "openailaws2157" -laws_sku = "PerGB2018" -laws_retention_in_days = 30 - -### Container App Enviornment ### -cae_name = "openaicae2157" - -### Container App ### -ca_name = "openaica2157" -ca_revision_mode = "Single" -ca_identity = { - type = "SystemAssigned" -} -ca_ingress = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - latest_revision = true - percentage = 100 - } -} -ca_container_config = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 2 - memory = "4Gi" - min_replicas = 0 - max_replicas = 5 - - ## Environment Variables (Required)## - env = [ - { - name = "OPENAI_API_KEY" - secret_name = "openai-api-key" #see locals.tf (Can also be added from key vault created by module, or existing key) - }, - { - name = "OPENAI_API_HOST" - secret_name = "openai-api-host" #see locals.tf (Can also be added from key vault created by module, or existing host/endpoint) - }, - { - name = "OPENAI_API_TYPE" - value = "azure" - }, - { - name = "AZURE_DEPLOYMENT_ID" #see model_deployment variable (deployment_id) - value = "gpt432k" - }, - { - name = "DEFAULT_MODEL" #see model_deployment variable (model_name) - value = "gpt-4-32k" - } - ] -} - -### key vault access ### -key_vault_access_permission = ["Key Vault Secrets User"] - -### CDN - Front Door ### -create_front_door_cdn = true -create_dns_zone = false # Set to false if you already have a DNS zone, set to true if you want to create a new one -dns_resource_group_name = "rg-of-existing-dns-zone" -custom_domain_config = { - zone_name = "existingzone.com" - host_name = "privategpt" - ttl = 600 - tls = [{ - certificate_type = "ManagedCertificate" - minimum_tls_version = "TLS12" - }] -} - -# CDN PROFILE -cdn_profile_name = "openaifd2157" -cdn_sku_name = "Standard_AzureFrontDoor" - -# CDN ENDPOINTS -cdn_endpoint = { - name = "PrivateGPT" - enabled = true -} - -# CDN ORIGIN GROUPS -cdn_origin_groups = [ - { - name = "PrivateGPTOriginGroup" - session_affinity_enabled = false - restore_traffic_time_to_healed_or_new_endpoint_in_minutes = 5 - health_probe = { - interval_in_seconds = 100 - path = "/" - protocol = "Https" - request_type = "HEAD" - } - load_balancing = { - additional_latency_in_milliseconds = 50 - sample_size = 4 - successful_samples_required = 3 - } - } -] - -# GPT CDN ORIGIN -cdn_gpt_origin = { - name = "PrivateGPTOrigin" - origin_group_name = "PrivateGPTOriginGroup" - enabled = true - certificate_name_check_enabled = true - http_port = 80 - https_port = 443 - priority = 1 - weight = 1000 -} - -# CDN ROUTE RULES -cdn_route = { - name = "PrivateGPTRoute" - enabled = true - forwarding_protocol = "HttpsOnly" - https_redirect_enabled = true - patterns_to_match = ["/*"] - supported_protocols = ["Http", "Https"] - cdn_frontdoor_origin_path = null - cdn_frontdoor_rule_set_ids = null - link_to_default_domain = false - cache = { - query_string_caching_behavior = "IgnoreQueryString" - query_strings = [] - compression_enabled = false - content_types_to_compress = [] - } -} - -# CDN FIREWALL POLICIES -cdn_firewall_policy = { - create_waf = true - name = "PrivateGPTWAF2158" - enabled = true - mode = "Prevention" - custom_block_response_body = "WW91ciByZXF1ZXN0IGhhcyBiZWVuIGJsb2NrZWQu" - custom_block_response_status_code = 403 - custom_rules = [ - { - name = "AllowedIPs" - action = "Block" - enabled = true - priority = 100 - type = "MatchRule" - rate_limit_duration_in_minutes = 1 - rate_limit_threshold = 10 - match_conditions = [ - { - negation_condition = true - match_values = ["86.106.76.66"] #Add your Allowed IPs here (e.g. ["x.x.x.x", "x.x.x.x"]) - match_variable = "RemoteAddr" - operator = "IPMatch" - transforms = [] - } - ] - } - ] -} - -# CDN SECURITY POLICY (WAF) -cdn_security_policy = { - name = "PrivateGPTSecurityPolicy" - patterns_to_match = ["/*"] -} \ No newline at end of file diff --git a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/data.tf b/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/data.tf deleted file mode 100644 index 6bc0ced..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/data.tf +++ /dev/null @@ -1,5 +0,0 @@ -data "azurerm_key_vault" "gpt" { - name = var.kv_config.name - resource_group_name = azurerm_resource_group.rg.name - depends_on = [module.private-chatgpt-openai.key_vault_id] -} \ No newline at end of file diff --git a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/locals.tf b/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/locals.tf deleted file mode 100644 index efefc1f..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/locals.tf +++ /dev/null @@ -1,12 +0,0 @@ -locals { - ca_secrets = [ - { - name = "openai-api-key" - value = "${module.private-chatgpt-openai.openai_primary_key}" - }, - { - name = "openai-api-host" - value = "${module.private-chatgpt-openai.openai_endpoint}" - } - ] -} \ No newline at end of file diff --git a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/main.tf b/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/main.tf deleted file mode 100644 index 25c5909..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/main.tf +++ /dev/null @@ -1,94 +0,0 @@ -terraform { - #backend "azurerm" {} - backend "local" { path = "terraform-example1.tfstate" } -} - -provider "azurerm" { - features { - key_vault { - purge_soft_delete_on_destroy = true - } - } -} - -################################################# -# PRE-REQS # -################################################# -### Resource group to deploy the container apps private ChatGPT instance and supporting resources into -resource "azurerm_resource_group" "rg" { - name = var.resource_group_name - location = var.location - tags = var.tags -} - -################################################## -# MODULE TO TEST # -################################################## -module "private-chatgpt-openai" { - source = "Pwd9000-ML/openai-private-chatgpt/azurerm" - version = ">= 1.1.0" - - #common - location = var.location - tags = var.tags - - #keyvault (OpenAI Service Account details) - kv_config = var.kv_config - keyvault_resource_group_name = azurerm_resource_group.rg.name - keyvault_firewall_default_action = var.keyvault_firewall_default_action - keyvault_firewall_bypass = var.keyvault_firewall_bypass - keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips - keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids - - #Create OpenAI Service? - create_openai_service = var.create_openai_service - openai_resource_group_name = azurerm_resource_group.rg.name - openai_account_name = var.openai_account_name - openai_custom_subdomain_name = var.openai_custom_subdomain_name - openai_sku_name = var.openai_sku_name - openai_local_auth_enabled = var.openai_local_auth_enabled - openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted - openai_public_network_access_enabled = var.openai_public_network_access_enabled - openai_identity = var.openai_identity - - #Create Model Deployment? - create_model_deployment = var.create_model_deployment - model_deployment = var.model_deployment - - #Create a solution log analytics workspace to store logs from our container apps instance - laws_name = var.laws_name - laws_sku = var.laws_sku - laws_retention_in_days = var.laws_retention_in_days - - #Create Container App Enviornment - cae_name = var.cae_name - - #Create a container app instance - ca_resource_group_name = azurerm_resource_group.rg.name - ca_name = var.ca_name - ca_revision_mode = var.ca_revision_mode - ca_identity = var.ca_identity - ca_container_config = var.ca_container_config - - #Create a container app secrets - ca_secrets = local.ca_secrets - - #key vault access - key_vault_access_permission = var.key_vault_access_permission - key_vault_id = data.azurerm_key_vault.gpt.id - - #Create front door CDN - create_front_door_cdn = var.create_front_door_cdn - cdn_resource_group_name = azurerm_resource_group.rg.name - create_dns_zone = var.create_dns_zone - dns_resource_group_name = var.dns_resource_group_name - custom_domain_config = var.custom_domain_config - cdn_profile_name = var.cdn_profile_name - cdn_sku_name = var.cdn_sku_name - cdn_endpoint = var.cdn_endpoint - cdn_origin_groups = var.cdn_origin_groups - cdn_gpt_origin = var.cdn_gpt_origin - cdn_route = var.cdn_route - cdn_firewall_policy = var.cdn_firewall_policy - cdn_security_policy = var.cdn_security_policy -} \ No newline at end of file diff --git a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/variables.tf b/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/variables.tf deleted file mode 100644 index 4522b4e..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/variables.tf +++ /dev/null @@ -1,630 +0,0 @@ -### common ### -variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." -} - -variable "tags" { - type = map(string) - default = {} - description = "A map of key value pairs that is used to tag resources created." -} - -### solution resource group ### -variable "resource_group_name" { - type = string - description = "Name of the resource group to create where the cognitive account OpenAI service is hosted." - nullable = false -} - -### OpenAI service Module params ### -### key vault ### -variable "kv_config" { - type = object({ - name = string - sku = string - }) - default = { - name = "gptkv" - sku = "standard" - } - description = "Key Vault configuration object to create azure key vault to store openai account details." - nullable = false -} - -variable "keyvault_firewall_default_action" { - type = string - default = "Deny" - description = "Default action for keyvault firewall rules." -} - -variable "keyvault_firewall_bypass" { - type = string - default = "AzureServices" - description = "List of keyvault firewall rules to bypass." -} - -variable "keyvault_firewall_allowed_ips" { - type = list(string) - default = [] - description = "value of keyvault firewall allowed ip rules." -} - -variable "keyvault_firewall_virtual_network_subnet_ids" { - type = list(string) - default = [] - description = "value of keyvault firewall allowed virtual network subnet ids." -} - -### openai service ### -variable "create_openai_service" { - type = bool - description = "Create the OpenAI service." - default = false -} - -variable "openai_account_name" { - type = string - description = "Name of the OpenAI service." - default = "demo-account" -} - -variable "openai_custom_subdomain_name" { - type = string - description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" - default = "demo-account" -} - -variable "openai_sku_name" { - type = string - description = "SKU name of the OpenAI service." - default = "S0" -} - -variable "openai_local_auth_enabled" { - type = bool - default = true - description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." -} - -variable "openai_outbound_network_access_restricted" { - type = bool - default = false - description = "Whether or not outbound network access is restricted. Defaults to `false`." -} - -variable "openai_public_network_access_enabled" { - type = bool - default = true - description = "Whether or not public network access is enabled. Defaults to `false`." -} - -variable "openai_identity" { - type = object({ - type = string - }) - default = { - type = "SystemAssigned" - } - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -### model deployment ### -variable "create_model_deployment" { - type = bool - description = "Create the model deployment." - default = false -} - -variable "model_deployment" { - type = list(object({ - deployment_id = string - model_name = string - model_format = string - model_version = string - scale_type = string - scale_tier = optional(string) - scale_size = optional(number) - scale_family = optional(string) - scale_capacity = optional(number) - rai_policy_name = optional(string) - })) - default = [] - description = <<-DESCRIPTION - type = list(object({ - deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. - model_name = { - model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. - model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. - model_version = (Required) The version of Cognitive Services Account Deployment model. - } - scale = { - scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. - scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. - scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. - scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. - scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. - } - rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. - })) - DESCRIPTION - nullable = false -} - -### log analytics workspace ### -variable "laws_name" { - type = string - description = "Name of the log analytics workspace to create." - default = "gptlaws" -} - -variable "laws_sku" { - type = string - description = "SKU of the log analytics workspace to create." - default = "PerGB2018" -} - -variable "laws_retention_in_days" { - type = number - description = "Retention in days of the log analytics workspace to create." - default = 30 -} - -### container app environment ### -variable "cae_name" { - type = string - description = "Name of the container app environment to create." - default = "gptcae" -} - -### container app ### -variable "ca_name" { - type = string - description = "Name of the container app to create." - default = "gptca" -} - -variable "ca_revision_mode" { - type = string - description = "Revision mode of the container app to create." - default = "Single" -} - -variable "ca_identity" { - type = object({ - type = string - identity_ids = optional(list(string)) - }) - default = null - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -variable "ca_ingress" { - type = object({ - allow_insecure_connections = optional(bool) - external_enabled = optional(bool) - target_port = number - transport = optional(string) - traffic_weight = optional(object({ - percentage = number - latest_revision = optional(bool) - })) - }) - default = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - percentage = 100 - latest_revision = true - } - } - description = <<-DESCRIPTION - type = object({ - allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. - external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. - target_port = (Required) The port to use for the container app. Defaults to `3000`. - transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. - type = object({ - percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. - latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. - }) - DESCRIPTION -} - -variable "ca_container_config" { - type = object({ - name = string - image = string - cpu = number - memory = string - min_replicas = optional(number, 0) - max_replicas = optional(number, 10) - env = optional(list(object({ - name = string - secret_name = optional(string) - value = optional(string) - }))) - }) - default = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 1 - memory = "2Gi" - min_replicas = 0 - max_replicas = 10 - env = [] - } - description = <<-DESCRIPTION - type = object({ - name = (Required) The name of the container. - image = (Required) The name of the container image. - cpu = (Required) The number of CPU cores to allocate to the container. - memory = (Required) The amount of memory to allocate to the container in GB. - min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. - max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. - env = list(object({ - name = (Required) The name of the environment variable. - secret_name = (Optional) The name of the secret to use for the environment variable. - value = (Optional) The value of the environment variable. - })) - }) - DESCRIPTION -} - -variable "ca_secrets" { - type = list(object({ - name = string - value = string - })) - default = [ - { - name = "secret1" - value = "value1" - }, - { - name = "secret2" - value = "value2" - } - ] - description = <<-DESCRIPTION - type = list(object({ - name = (Required) The name of the secret. - value = (Required) The value of the secret. - })) - DESCRIPTION -} - -# Key Vault Access # -### key vault access ### -variable "key_vault_access_permission" { - type = list(string) - default = ["Key Vault Secrets User"] - description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -} - -variable "key_vault_id" { - type = string - default = "" - description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." -} - -# DNS zone # -variable "create_dns_zone" { - description = "Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided." - type = bool - default = false -} - -variable "dns_resource_group_name" { - description = "The name of the resource group to create the DNS zone in / or where the existing zone is hosted." - type = string - nullable = false -} - -variable "custom_domain_config" { - type = object({ - zone_name = string - host_name = string - ttl = optional(number, 3600) - tls = optional(list(object({ - certificate_type = optional(string, "ManagedCertificate") - minimum_tls_version = optional(string, "TLS12") - }))) - }) - default = { - zone_name = "mydomain7335.com" - host_name = "PrivateGPT" - ttl = 3600 - tls = [{ - certificate_type = "ManagedCertificate" - minimum_tls_version = "TLS12" - }] - } - description = <<-DESCRIPTION - type = object({ - zone_name = (Required) The name of the DNS zone to create the CNAME and TXT record in for the CDN Front Door Custom domain. - host_name = (Required) The host name of the DNS record to create. (e.g. Contoso) - ttl = (Optional) The TTL of the DNS record to create. (e.g. 3600) - tls = optional(list(object({ - certificate_type = (Optional) Defines the source of the SSL certificate. Possible values include 'CustomerCertificate' and 'ManagedCertificate'. Defaults to 'ManagedCertificate'. - NOTE: It may take up to 15 minutes for the Front Door Service to validate the state and Domain ownership of the Custom Domain. - minimum_tls_version = (Optional) TLS protocol version that will be used for Https. Possible values include TLS10 and TLS12. Defaults to TLS12. - })))) - }) - DESCRIPTION -} - -# Front Door # -variable "create_front_door_cdn" { - description = "Create a Front Door profile." - type = bool - default = false -} - -variable "cdn_profile_name" { - description = "The name of the CDN profile to create." - type = string - default = "example-cdn-profile" -} - -variable "cdn_sku_name" { - description = "Specifies the SKU for the CDN Front Door Profile. Possible values include 'Standard_AzureFrontDoor' and 'Premium_AzureFrontDoor'." - type = string - default = "Standard_AzureFrontDoor" -} - -variable "cdn_endpoint" { - type = object({ - name = string - enabled = optional(bool, true) - }) - default = { - name = "PrivateGPT" - enabled = true - } - description = < -## Requirements - -No requirements. - -## Providers - -| Name | Version | -|------|---------| -| [azurerm](#provider\_azurerm) | n/a | - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [private-chatgpt-openai](#module\_private-chatgpt-openai) | Pwd9000-ML/openai-private-chatgpt/azurerm | >= 1.1.0 | - -## Resources - -| Name | Type | -|------|------| -| [azurerm_resource_group.rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | -| [azurerm_key_vault.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/key_vault) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [ca\_container\_config](#input\_ca\_container\_config) | type = object({
    name = (Required) The name of the container.
    image = (Required) The name of the container image.
    cpu = (Required) The number of CPU cores to allocate to the container.
    memory = (Required) The amount of memory to allocate to the container in GB.
    min\_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`.
    max\_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`.
    env = list(object({
    name = (Required) The name of the environment variable.
    secret\_name = (Optional) The name of the secret to use for the environment variable.
    value = (Optional) The value of the environment variable.
    }))
    }) |
    object({
    name = string
    image = string
    cpu = number
    memory = string
    min_replicas = optional(number, 0)
    max_replicas = optional(number, 10)
    env = optional(list(object({
    name = string
    secret_name = optional(string)
    value = optional(string)
    })))
    })
    |
    {
    "cpu": 1,
    "env": [],
    "image": "ghcr.io/pwd9000-ml/chatbot-ui:main",
    "max_replicas": 10,
    "memory": "2Gi",
    "min_replicas": 0,
    "name": "gpt-chatbot-ui"
    }
    | no | -| [ca\_identity](#input\_ca\_identity) | type = object({
    type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
    identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
    }) |
    object({
    type = string
    identity_ids = optional(list(string))
    })
    | `null` | no | -| [ca\_ingress](#input\_ca\_ingress) | type = object({
    allow\_insecure\_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`.
    external\_enabled = (Optional) Enable external access to the container app. Defaults to `true`.
    target\_port = (Required) The port to use for the container app. Defaults to `3000`.
    transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`.
    type = object({
    percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`.
    latest\_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`.
    }) |
    object({
    allow_insecure_connections = optional(bool)
    external_enabled = optional(bool)
    target_port = number
    transport = optional(string)
    traffic_weight = optional(object({
    percentage = number
    latest_revision = optional(bool)
    }))
    })
    |
    {
    "allow_insecure_connections": false,
    "external_enabled": true,
    "target_port": 3000,
    "traffic_weight": {
    "latest_revision": true,
    "percentage": 100
    },
    "transport": "auto"
    }
    | no | -| [ca\_name](#input\_ca\_name) | Name of the container app to create. | `string` | `"gptca"` | no | -| [ca\_revision\_mode](#input\_ca\_revision\_mode) | Revision mode of the container app to create. | `string` | `"Single"` | no | -| [ca\_secrets](#input\_ca\_secrets) | type = list(object({
    name = (Required) The name of the secret.
    value = (Required) The value of the secret.
    })) |
    list(object({
    name = string
    value = string
    }))
    |
    [
    {
    "name": "secret1",
    "value": "value1"
    },
    {
    "name": "secret2",
    "value": "value2"
    }
    ]
    | no | -| [cae\_name](#input\_cae\_name) | Name of the container app environment to create. | `string` | `"gptcae"` | no | -| [cdn\_endpoint](#input\_cdn\_endpoint) | typp = object({
    name = (Required) The name of the CDN endpoint to create.
    enabled = (Optional) Is the CDN endpoint enabled? Defaults to `true`.
    }) |
    object({
    name = string
    enabled = optional(bool, true)
    })
    |
    {
    "enabled": true,
    "name": "PrivateGPT"
    }
    | no | -| [cdn\_firewall\_policy](#input\_cdn\_firewall\_policy) | The CDN firewall policies to create. |
    object({
    create_waf = bool
    name = string
    enabled = optional(bool, true)
    mode = optional(string, "Prevention")
    redirect_url = optional(string)
    custom_block_response_status_code = optional(number, 403)
    custom_block_response_body = optional(string)
    custom_rules = optional(list(object({
    name = string
    action = string
    enabled = optional(bool, true)
    priority = number
    type = string
    rate_limit_duration_in_minutes = optional(number, 1)
    rate_limit_threshold = optional(number, 10)
    match_conditions = list(object({
    match_variable = string
    match_values = list(string)
    operator = string
    selector = optional(string)
    negation_condition = optional(bool)
    transforms = optional(list(string))
    }))
    })))
    })
    |
    {
    "create_waf": true,
    "custom_block_response_body": "WW91ciByZXF1ZXN0IGhhcyBiZWVuIGJsb2NrZWQu",
    "custom_block_response_status_code": 403,
    "custom_rules": [
    {
    "action": "Block",
    "enabled": true,
    "match_conditions": [
    {
    "match_values": [
    "10.0.1.0/24",
    "10.0.2.0/24"
    ],
    "match_variable": "RemoteAddr",
    "negation_condition": null,
    "operator": "IPMatch",
    "selector": null,
    "transforms": []
    }
    ],
    "name": "PrivateGPTFirewallPolicyCustomRule",
    "priority": 100,
    "rate_limit_duration_in_minutes": 1,
    "rate_limit_threshold": 10,
    "type": "MatchRule"
    }
    ],
    "enabled": true,
    "mode": "Prevention",
    "name": "PrivateGPTFirewallPolicy",
    "redirect_url": null
    }
    | no | -| [cdn\_gpt\_origin](#input\_cdn\_gpt\_origin) | type = object({
    name = (Required) The name which should be used for this Front Door Origin. Changing this forces a new Front Door Origin to be created.
    origin\_group\_name = (Required) The name of the CDN origin group to associate this origin with.
    enabled = (Optional) Is the CDN origin enabled? Defaults to `true`.
    certificate\_name\_check\_enabled = (Required) Specifies whether certificate name checks are enabled for this origin. Defaults to `true`.
    http\_port = (Optional) The HTTP port of the origin. (e.g. 80)
    https\_port = (Optional) The HTTPS port of the origin. (e.g. 443)
    priority = (Optional) The priority of the origin. (e.g. 1)
    weight = (Optional) The weight of the origin. (e.g. 1000)
    }) |
    object({
    name = string
    origin_group_name = string
    enabled = optional(bool, true)
    certificate_name_check_enabled = optional(bool, true)
    http_port = optional(number, 80)
    https_port = optional(number, 443)
    priority = optional(number, 1)
    weight = optional(number, 1000)
    })
    |
    {
    "certificate_name_check_enabled": true,
    "enabled": true,
    "http_port": 80,
    "https_port": 443,
    "name": "PrivateGPTOrigin",
    "origin_group_name": "PrivateGPTOriginGroup",
    "priority": 1,
    "weight": 1000
    }
    | no | -| [cdn\_origin\_groups](#input\_cdn\_origin\_groups) | type = list(object({
    name = (Required) The name of the CDN origin group to create.
    session\_affinity\_enabled = (Optional) Is session affinity enabled? Defaults to `false`.
    restore\_traffic\_time\_to\_healed\_or\_new\_endpoint\_in\_minutes = (Optional) The time in minutes to restore traffic to a healed or new endpoint. Defaults to `5`.
    health\_probe = (Optional) The health probe settings.
    type = object({
    interval\_in\_seconds = (Optional) The interval in seconds between health probes. Defaults to `100`.
    path = (Optional) The path to use for health probes. Defaults to `/`.
    protocol = (Optional) The protocol to use for health probes. Possible values include 'Http' and 'Https'. Defaults to `Http`.
    request\_type = (Optional) The request type to use for health probes. Possible values include 'GET', 'HEAD', and 'OPTIONS'. Defaults to `HEAD`.
    }))
    load\_balancing = (Optional) The load balancing settings.
    type = object({
    additional\_latency\_in\_milliseconds = (Optional) The additional latency in milliseconds for probes to fall into the lowest latency bucket. Defaults to `50`.
    sample\_size = (Optional) The number of samples to take for load balancing decisions. Defaults to `4`.
    successful\_samples\_required = (Optional) The number of samples within the sample period that must succeed. Defaults to `3`.
    }))
    })) |
    list(object({
    name = string
    session_affinity_enabled = optional(bool, false)
    restore_traffic_time_to_healed_or_new_endpoint_in_minutes = optional(number, 5)
    health_probe = optional(object({
    interval_in_seconds = optional(number, 100)
    path = optional(string, "/")
    protocol = optional(string, "Http")
    request_type = optional(string, "HEAD")
    }))
    load_balancing = optional(object({
    additional_latency_in_milliseconds = optional(number, 50)
    sample_size = optional(number, 4)
    successful_samples_required = optional(number, 3)
    }))
    }))
    |
    [
    {
    "health_probe": {
    "interval_in_seconds": 100,
    "path": "/",
    "protocol": "Http",
    "request_type": "HEAD"
    },
    "load_balancing": {
    "additional_latency_in_milliseconds": 50,
    "sample_size": 4,
    "successful_samples_required": 3
    },
    "name": "PrivateGPTOriginGroup",
    "restore_traffic_time_to_healed_or_new_endpoint_in_minutes": 5,
    "session_affinity_enabled": false
    }
    ]
    | no | -| [cdn\_profile\_name](#input\_cdn\_profile\_name) | The name of the CDN profile to create. | `string` | `"example-cdn-profile"` | no | -| [cdn\_route](#input\_cdn\_route) | type = object({
    name = (Required) The name of the CDN route to create.
    enabled = (Optional) Is the CDN route enabled? Defaults to `true`.
    forwarding\_protocol = (Optional) The protocol this rule will use when forwarding traffic to backends. Possible values include `MatchRequest`, `HttpOnly` and `HttpsOnly`. Defaults to `HttpsOnly`.
    https\_redirect\_enabled = (Optional) Is HTTPS redirect enabled? Defaults to `false`.
    patterns\_to\_match = (Optional) The list of patterns to match for this rule. Defaults to `["/*"]`.
    supported\_protocols = (Optional) The list of supported protocols for this rule. Defaults to `["Http", "Https"]`.
    cdn\_frontdoor\_origin\_path = (Optional) The path to use when forwarding traffic to backends. Defaults to `null`.
    cdn\_frontdoor\_rule\_set\_ids = (Optional) The list of rule set IDs to associate with this rule. Defaults to `null`.
    link\_to\_default\_domain = (Optional) Is the CDN route linked to the default domain? Defaults to `false`.
    cache = (Optional) The CDN route cache settings.
    type = object({
    query\_string\_caching\_behavior = (Required) The query string caching behavior. Possible values include 'IgnoreQueryString', 'BypassCaching', 'UseQueryString', and 'NotSet'. Defaults to 'IgnoreQueryString'.
    query\_strings = (Optional) The list of query strings to include or exclude from caching. Defaults to `[]`.
    compression\_enabled = (Required) Is compression enabled? Defaults to `false`.
    content\_types\_to\_compress = (Optional) The list of content types to compress. Defaults to `[]`.
    })
    }) |
    object({
    name = string
    enabled = optional(bool, true)
    forwarding_protocol = optional(string, "HttpsOnly")
    https_redirect_enabled = optional(bool, false)
    patterns_to_match = optional(list(string), ["/*"])
    supported_protocols = optional(list(string), ["Http", "Https"])
    cdn_frontdoor_origin_path = optional(string, null)
    cdn_frontdoor_rule_set_ids = optional(list(string), null)
    link_to_default_domain = optional(bool, false)
    cache = optional(object({
    query_string_caching_behavior = string
    query_strings = optional(list(string), [])
    compression_enabled = bool
    content_types_to_compress = optional(list(string), [])
    }))
    })
    |
    {
    "cache": {
    "compression_enabled": false,
    "content_types_to_compress": [],
    "query_string_caching_behavior": "IgnoreQueryString",
    "query_strings": []
    },
    "cdn_frontdoor_origin_path": null,
    "cdn_frontdoor_rule_set_ids": null,
    "enabled": true,
    "forwarding_protocol": "HttpsOnly",
    "https_redirect_enabled": false,
    "link_to_default_domain": false,
    "name": "PrivateGPTRoute",
    "patterns_to_match": [
    "/*"
    ],
    "supported_protocols": [
    "Http",
    "Https"
    ]
    }
    | no | -| [cdn\_security\_policy](#input\_cdn\_security\_policy) | type = object({
    name = (Required) The name of the CDN security policy to create.
    patterns\_to\_match = (Required) The list of patterns to match for this policy. Defaults to `["/*"]`.
    }) |
    object({
    name = string
    patterns_to_match = list(string)
    })
    |
    {
    "name": "PrivateGPTSecurityPolicy",
    "patterns_to_match": [
    "/*"
    ]
    }
    | no | -| [cdn\_sku\_name](#input\_cdn\_sku\_name) | Specifies the SKU for the CDN Front Door Profile. Possible values include 'Standard\_AzureFrontDoor' and 'Premium\_AzureFrontDoor'. | `string` | `"Standard_AzureFrontDoor"` | no | -| [create\_dns\_zone](#input\_create\_dns\_zone) | Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided. | `bool` | `false` | no | -| [create\_front\_door\_cdn](#input\_create\_front\_door\_cdn) | Create a Front Door profile. | `bool` | `false` | no | -| [create\_model\_deployment](#input\_create\_model\_deployment) | Create the model deployment. | `bool` | `false` | no | -| [create\_openai\_service](#input\_create\_openai\_service) | Create the OpenAI service. | `bool` | `false` | no | -| [custom\_domain\_config](#input\_custom\_domain\_config) | type = object({
    zone\_name = (Required) The name of the DNS zone to create the CNAME and TXT record in for the CDN Front Door Custom domain.
    host\_name = (Required) The host name of the DNS record to create. (e.g. Contoso)
    ttl = (Optional) The TTL of the DNS record to create. (e.g. 3600)
    tls = optional(list(object({
    certificate\_type = (Optional) Defines the source of the SSL certificate. Possible values include 'CustomerCertificate' and 'ManagedCertificate'. Defaults to 'ManagedCertificate'.
    NOTE: It may take up to 15 minutes for the Front Door Service to validate the state and Domain ownership of the Custom Domain.
    minimum\_tls\_version = (Optional) TLS protocol version that will be used for Https. Possible values include TLS10 and TLS12. Defaults to TLS12.
    }))))
    }) |
    object({
    zone_name = string
    host_name = string
    ttl = optional(number, 3600)
    tls = optional(list(object({
    certificate_type = optional(string, "ManagedCertificate")
    minimum_tls_version = optional(string, "TLS12")
    })))
    })
    |
    {
    "host_name": "PrivateGPT",
    "tls": [
    {
    "certificate_type": "ManagedCertificate",
    "minimum_tls_version": "TLS12"
    }
    ],
    "ttl": 3600,
    "zone_name": "mydomain7335.com"
    }
    | no | -| [dns\_resource\_group\_name](#input\_dns\_resource\_group\_name) | The name of the resource group to create the DNS zone in / or where the existing zone is hosted. | `string` | n/a | yes | -| [key\_vault\_access\_permission](#input\_key\_vault\_access\_permission) | The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`. | `list(string)` |
    [
    "Key Vault Secrets User"
    ]
    | no | -| [key\_vault\_id](#input\_key\_vault\_id) | (Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set. | `string` | `""` | no | -| [keyvault\_firewall\_allowed\_ips](#input\_keyvault\_firewall\_allowed\_ips) | value of keyvault firewall allowed ip rules. | `list(string)` | `[]` | no | -| [keyvault\_firewall\_bypass](#input\_keyvault\_firewall\_bypass) | List of keyvault firewall rules to bypass. | `string` | `"AzureServices"` | no | -| [keyvault\_firewall\_default\_action](#input\_keyvault\_firewall\_default\_action) | Default action for keyvault firewall rules. | `string` | `"Deny"` | no | -| [keyvault\_firewall\_virtual\_network\_subnet\_ids](#input\_keyvault\_firewall\_virtual\_network\_subnet\_ids) | value of keyvault firewall allowed virtual network subnet ids. | `list(string)` | `[]` | no | -| [kv\_config](#input\_kv\_config) | Key Vault configuration object to create azure key vault to store openai account details. |
    object({
    name = string
    sku = string
    })
    |
    {
    "name": "gptkv",
    "sku": "standard"
    }
    | no | -| [laws\_name](#input\_laws\_name) | Name of the log analytics workspace to create. | `string` | `"gptlaws"` | no | -| [laws\_retention\_in\_days](#input\_laws\_retention\_in\_days) | Retention in days of the log analytics workspace to create. | `number` | `30` | no | -| [laws\_sku](#input\_laws\_sku) | SKU of the log analytics workspace to create. | `string` | `"PerGB2018"` | no | -| [location](#input\_location) | Azure region where resources will be hosted. | `string` | `"uksouth"` | no | -| [model\_deployment](#input\_model\_deployment) | type = list(object({
    deployment\_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created.
    model\_name = {
    model\_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI.
    model\_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created.
    model\_version = (Required) The version of Cognitive Services Account Deployment model.
    }
    scale = {
    scale\_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created.
    scale\_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created.
    scale\_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created.
    scale\_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created.
    scale\_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created.
    }
    rai\_policy\_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created.
    })) |
    list(object({
    deployment_id = string
    model_name = string
    model_format = string
    model_version = string
    scale_type = string
    scale_tier = optional(string)
    scale_size = optional(number)
    scale_family = optional(string)
    scale_capacity = optional(number)
    rai_policy_name = optional(string)
    }))
    | `[]` | no | -| [openai\_account\_name](#input\_openai\_account\_name) | Name of the OpenAI service. | `string` | `"demo-account"` | no | -| [openai\_custom\_subdomain\_name](#input\_openai\_custom\_subdomain\_name) | The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name) | `string` | `"demo-account"` | no | -| [openai\_identity](#input\_openai\_identity) | type = object({
    type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
    identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
    }) |
    object({
    type = string
    })
    |
    {
    "type": "SystemAssigned"
    }
    | no | -| [openai\_local\_auth\_enabled](#input\_openai\_local\_auth\_enabled) | Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`. | `bool` | `true` | no | -| [openai\_outbound\_network\_access\_restricted](#input\_openai\_outbound\_network\_access\_restricted) | Whether or not outbound network access is restricted. Defaults to `false`. | `bool` | `false` | no | -| [openai\_public\_network\_access\_enabled](#input\_openai\_public\_network\_access\_enabled) | Whether or not public network access is enabled. Defaults to `false`. | `bool` | `true` | no | -| [openai\_sku\_name](#input\_openai\_sku\_name) | SKU name of the OpenAI service. | `string` | `"S0"` | no | -| [resource\_group\_name](#input\_resource\_group\_name) | Name of the resource group to create where the cognitive account OpenAI service is hosted. | `string` | n/a | yes | -| [tags](#input\_tags) | A map of key value pairs that is used to tag resources created. | `map(string)` | `{}` | no | - -## Outputs - -No outputs. - diff --git a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/common.auto.tfvars b/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/common.auto.tfvars deleted file mode 100644 index 6147241..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/common.auto.tfvars +++ /dev/null @@ -1,239 +0,0 @@ -### Common Variables ### -resource_group_name = "TF-Module-Example2-Cognitive-GPT" -location = "uksouth" -tags = { - Terraform = "True" - Description = "Private ChatGPT hosted on Azure OpenAI" - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" -} - -### OpenAI Service Module Inputs ### -kv_config = { - name = "openaikv2158" - sku = "standard" -} -keyvault_firewall_default_action = "Deny" -keyvault_firewall_bypass = "AzureServices" -keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs -keyvault_firewall_virtual_network_subnet_ids = [] - -### Create OpenAI Service ### -create_openai_service = true -openai_account_name = "openaiacc2158" -openai_custom_subdomain_name = "openaiacc2158" #translates to "https://openaiacc2158.openai.azure.com/" -openai_sku_name = "S0" -openai_local_auth_enabled = true -openai_outbound_network_access_restricted = false -openai_public_network_access_enabled = true -openai_identity = { - type = "SystemAssigned" -} - -### Create Model deployment ### -create_model_deployment = true -model_deployment = [ - # { - # deployment_id = "gpt35turbo" ## Example of "gpt-35-turbo" - # model_name = "gpt-35-turbo" - # model_format = "OpenAI" - # model_version = "0613" - # scale_type = "Standard" - # scale_capacity = 16 - # }, - # { - # deployment_id = "gpt4" ## Example of "gpt-4" - # model_name = "gpt-4" - # model_format = "OpenAI" - # model_version = "0613" - # scale_type = "Standard" - # scale_capacity = 16 - # }, - { - deployment_id = "gpt35turbo16k" - model_name = "gpt-35-turbo-16k" - model_format = "OpenAI" - model_version = "0613" - scale_type = "Standard" - scale_capacity = 34 # 34K == Roughly 204 RPM (Requests per minute) - }, - { - deployment_id = "gpt432k" ## latest model - model_name = "gpt-4-32k" - model_format = "OpenAI" - model_version = "0613" - scale_type = "Standard" - scale_capacity = 34 # 34K == Roughly 204 RPM (Requests per minute) - } -] - -### log analytics workspace for container apps ### -laws_name = "openailaws2158" -laws_sku = "PerGB2018" -laws_retention_in_days = 30 - -### Container App Enviornment ### -cae_name = "openaicae2158" - -### Container App ### -ca_name = "openaica2158" -ca_revision_mode = "Single" -ca_identity = { - type = "SystemAssigned" -} -ca_ingress = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - latest_revision = true - percentage = 100 - } -} -ca_container_config = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 2 - memory = "4Gi" - min_replicas = 0 - max_replicas = 5 - - ## Environment Variables (Required)## - env = [ - { - name = "OPENAI_API_KEY" - secret_name = "openai-api-key" #see locals.tf (Can also be added from key vault created by module, or existing key) - }, - { - name = "OPENAI_API_HOST" - secret_name = "openai-api-host" #see locals.tf (Can also be added from key vault created by module, or existing host/endpoint) - }, - { - name = "OPENAI_API_TYPE" - value = "azure" - }, - { - name = "AZURE_DEPLOYMENT_ID" #see model_deployment variable (deployment_id) - value = "gpt432k" - }, - { - name = "DEFAULT_MODEL" #see model_deployment variable (model_name) - value = "gpt-4-32k" - } - ] -} - -### key vault access ### -key_vault_access_permission = ["Key Vault Secrets User"] - -### CDN - Front Door ### -create_front_door_cdn = true -create_dns_zone = true # Set to false if you already have a DNS zone, set to true if you want to create a new one -dns_resource_group_name = "TF-Module-Example2-Cognitive-GPT" -custom_domain_config = { - zone_name = "newzone2158.com" - host_name = "privategpt" - ttl = 600 - tls = [{ - certificate_type = "ManagedCertificate" - minimum_tls_version = "TLS12" - }] -} - -# CDN PROFILE -cdn_profile_name = "openaifd2158" -cdn_sku_name = "Standard_AzureFrontDoor" - -# CDN ENDPOINTS -cdn_endpoint = { - name = "PrivateGPT" - enabled = true -} - -# CDN ORIGIN GROUPS -cdn_origin_groups = [ - { - name = "PrivateGPTOriginGroup" - session_affinity_enabled = false - restore_traffic_time_to_healed_or_new_endpoint_in_minutes = 5 - health_probe = { - interval_in_seconds = 100 - path = "/" - protocol = "Https" - request_type = "HEAD" - } - load_balancing = { - additional_latency_in_milliseconds = 50 - sample_size = 4 - successful_samples_required = 3 - } - } -] - -# GPT CDN ORIGIN -cdn_gpt_origin = { - name = "PrivateGPTOrigin" - origin_group_name = "PrivateGPTOriginGroup" - enabled = true - certificate_name_check_enabled = true - http_port = 80 - https_port = 443 - priority = 1 - weight = 1000 -} - -# CDN ROUTE RULES -cdn_route = { - name = "PrivateGPTRoute" - enabled = true - forwarding_protocol = "HttpsOnly" - https_redirect_enabled = true - patterns_to_match = ["/*"] - supported_protocols = ["Http", "Https"] - cdn_frontdoor_origin_path = null - cdn_frontdoor_rule_set_ids = null - link_to_default_domain = false - cache = { - query_string_caching_behavior = "IgnoreQueryString" - query_strings = [] - compression_enabled = false - content_types_to_compress = [] - } -} - -# CDN FIREWALL POLICIES -cdn_firewall_policy = { - create_waf = true - name = "PrivateGPTWAF2158" - enabled = true - mode = "Prevention" - custom_block_response_body = "WW91ciByZXF1ZXN0IGhhcyBiZWVuIGJsb2NrZWQu" - custom_block_response_status_code = 403 - custom_rules = [ - { - name = "AllowedIPs" - action = "Block" - enabled = true - priority = 100 - type = "MatchRule" - rate_limit_duration_in_minutes = 1 - rate_limit_threshold = 10 - match_conditions = [ - { - negation_condition = true - match_values = ["86.106.76.66"] #Add your Allowed IPs here (e.g. ["x.x.x.x", "x.x.x.x"]) - match_variable = "RemoteAddr" - operator = "IPMatch" - transforms = [] - } - ] - } - ] -} - -# CDN SECURITY POLICY (WAF) -cdn_security_policy = { - name = "PrivateGPTSecurityPolicy" - patterns_to_match = ["/*"] -} \ No newline at end of file diff --git a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/data.tf b/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/data.tf deleted file mode 100644 index 6bc0ced..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/data.tf +++ /dev/null @@ -1,5 +0,0 @@ -data "azurerm_key_vault" "gpt" { - name = var.kv_config.name - resource_group_name = azurerm_resource_group.rg.name - depends_on = [module.private-chatgpt-openai.key_vault_id] -} \ No newline at end of file diff --git a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/locals.tf b/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/locals.tf deleted file mode 100644 index efefc1f..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/locals.tf +++ /dev/null @@ -1,12 +0,0 @@ -locals { - ca_secrets = [ - { - name = "openai-api-key" - value = "${module.private-chatgpt-openai.openai_primary_key}" - }, - { - name = "openai-api-host" - value = "${module.private-chatgpt-openai.openai_endpoint}" - } - ] -} \ No newline at end of file diff --git a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/main.tf b/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/main.tf deleted file mode 100644 index a6c80c6..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/main.tf +++ /dev/null @@ -1,94 +0,0 @@ -terraform { - #backend "azurerm" {} - backend "local" { path = "terraform-example2.tfstate" } -} - -provider "azurerm" { - features { - key_vault { - purge_soft_delete_on_destroy = true - } - } -} - -################################################# -# PRE-REQS # -################################################# -### Resource group to deploy the container apps private ChatGPT instance and supporting resources into -resource "azurerm_resource_group" "rg" { - name = var.resource_group_name - location = var.location - tags = var.tags -} - -################################################## -# MODULE TO TEST # -################################################## -module "private-chatgpt-openai" { - source = "Pwd9000-ML/openai-private-chatgpt/azurerm" - version = ">= 1.1.0" - - #common - location = var.location - tags = var.tags - - #keyvault (OpenAI Service Account details) - kv_config = var.kv_config - keyvault_resource_group_name = azurerm_resource_group.rg.name - keyvault_firewall_default_action = var.keyvault_firewall_default_action - keyvault_firewall_bypass = var.keyvault_firewall_bypass - keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips - keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids - - #Create OpenAI Service? - create_openai_service = var.create_openai_service - openai_resource_group_name = azurerm_resource_group.rg.name - openai_account_name = var.openai_account_name - openai_custom_subdomain_name = var.openai_custom_subdomain_name - openai_sku_name = var.openai_sku_name - openai_local_auth_enabled = var.openai_local_auth_enabled - openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted - openai_public_network_access_enabled = var.openai_public_network_access_enabled - openai_identity = var.openai_identity - - #Create Model Deployment? - create_model_deployment = var.create_model_deployment - model_deployment = var.model_deployment - - #Create a solution log analytics workspace to store logs from our container apps instance - laws_name = var.laws_name - laws_sku = var.laws_sku - laws_retention_in_days = var.laws_retention_in_days - - #Create Container App Enviornment - cae_name = var.cae_name - - #Create a container app instance - ca_resource_group_name = azurerm_resource_group.rg.name - ca_name = var.ca_name - ca_revision_mode = var.ca_revision_mode - ca_identity = var.ca_identity - ca_container_config = var.ca_container_config - - #Create a container app secrets - ca_secrets = local.ca_secrets - - #key vault access - key_vault_access_permission = var.key_vault_access_permission - key_vault_id = data.azurerm_key_vault.gpt.id - - #Create front door CDN - create_front_door_cdn = var.create_front_door_cdn - cdn_resource_group_name = azurerm_resource_group.rg.name - create_dns_zone = var.create_dns_zone - dns_resource_group_name = var.dns_resource_group_name - custom_domain_config = var.custom_domain_config - cdn_profile_name = var.cdn_profile_name - cdn_sku_name = var.cdn_sku_name - cdn_endpoint = var.cdn_endpoint - cdn_origin_groups = var.cdn_origin_groups - cdn_gpt_origin = var.cdn_gpt_origin - cdn_route = var.cdn_route - cdn_firewall_policy = var.cdn_firewall_policy - cdn_security_policy = var.cdn_security_policy -} \ No newline at end of file diff --git a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/variables.tf b/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/variables.tf deleted file mode 100644 index 4522b4e..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_new_DNS_zone/variables.tf +++ /dev/null @@ -1,630 +0,0 @@ -### common ### -variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." -} - -variable "tags" { - type = map(string) - default = {} - description = "A map of key value pairs that is used to tag resources created." -} - -### solution resource group ### -variable "resource_group_name" { - type = string - description = "Name of the resource group to create where the cognitive account OpenAI service is hosted." - nullable = false -} - -### OpenAI service Module params ### -### key vault ### -variable "kv_config" { - type = object({ - name = string - sku = string - }) - default = { - name = "gptkv" - sku = "standard" - } - description = "Key Vault configuration object to create azure key vault to store openai account details." - nullable = false -} - -variable "keyvault_firewall_default_action" { - type = string - default = "Deny" - description = "Default action for keyvault firewall rules." -} - -variable "keyvault_firewall_bypass" { - type = string - default = "AzureServices" - description = "List of keyvault firewall rules to bypass." -} - -variable "keyvault_firewall_allowed_ips" { - type = list(string) - default = [] - description = "value of keyvault firewall allowed ip rules." -} - -variable "keyvault_firewall_virtual_network_subnet_ids" { - type = list(string) - default = [] - description = "value of keyvault firewall allowed virtual network subnet ids." -} - -### openai service ### -variable "create_openai_service" { - type = bool - description = "Create the OpenAI service." - default = false -} - -variable "openai_account_name" { - type = string - description = "Name of the OpenAI service." - default = "demo-account" -} - -variable "openai_custom_subdomain_name" { - type = string - description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" - default = "demo-account" -} - -variable "openai_sku_name" { - type = string - description = "SKU name of the OpenAI service." - default = "S0" -} - -variable "openai_local_auth_enabled" { - type = bool - default = true - description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." -} - -variable "openai_outbound_network_access_restricted" { - type = bool - default = false - description = "Whether or not outbound network access is restricted. Defaults to `false`." -} - -variable "openai_public_network_access_enabled" { - type = bool - default = true - description = "Whether or not public network access is enabled. Defaults to `false`." -} - -variable "openai_identity" { - type = object({ - type = string - }) - default = { - type = "SystemAssigned" - } - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -### model deployment ### -variable "create_model_deployment" { - type = bool - description = "Create the model deployment." - default = false -} - -variable "model_deployment" { - type = list(object({ - deployment_id = string - model_name = string - model_format = string - model_version = string - scale_type = string - scale_tier = optional(string) - scale_size = optional(number) - scale_family = optional(string) - scale_capacity = optional(number) - rai_policy_name = optional(string) - })) - default = [] - description = <<-DESCRIPTION - type = list(object({ - deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. - model_name = { - model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. - model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. - model_version = (Required) The version of Cognitive Services Account Deployment model. - } - scale = { - scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. - scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. - scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. - scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. - scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. - } - rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. - })) - DESCRIPTION - nullable = false -} - -### log analytics workspace ### -variable "laws_name" { - type = string - description = "Name of the log analytics workspace to create." - default = "gptlaws" -} - -variable "laws_sku" { - type = string - description = "SKU of the log analytics workspace to create." - default = "PerGB2018" -} - -variable "laws_retention_in_days" { - type = number - description = "Retention in days of the log analytics workspace to create." - default = 30 -} - -### container app environment ### -variable "cae_name" { - type = string - description = "Name of the container app environment to create." - default = "gptcae" -} - -### container app ### -variable "ca_name" { - type = string - description = "Name of the container app to create." - default = "gptca" -} - -variable "ca_revision_mode" { - type = string - description = "Revision mode of the container app to create." - default = "Single" -} - -variable "ca_identity" { - type = object({ - type = string - identity_ids = optional(list(string)) - }) - default = null - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -variable "ca_ingress" { - type = object({ - allow_insecure_connections = optional(bool) - external_enabled = optional(bool) - target_port = number - transport = optional(string) - traffic_weight = optional(object({ - percentage = number - latest_revision = optional(bool) - })) - }) - default = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - percentage = 100 - latest_revision = true - } - } - description = <<-DESCRIPTION - type = object({ - allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. - external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. - target_port = (Required) The port to use for the container app. Defaults to `3000`. - transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. - type = object({ - percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. - latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. - }) - DESCRIPTION -} - -variable "ca_container_config" { - type = object({ - name = string - image = string - cpu = number - memory = string - min_replicas = optional(number, 0) - max_replicas = optional(number, 10) - env = optional(list(object({ - name = string - secret_name = optional(string) - value = optional(string) - }))) - }) - default = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 1 - memory = "2Gi" - min_replicas = 0 - max_replicas = 10 - env = [] - } - description = <<-DESCRIPTION - type = object({ - name = (Required) The name of the container. - image = (Required) The name of the container image. - cpu = (Required) The number of CPU cores to allocate to the container. - memory = (Required) The amount of memory to allocate to the container in GB. - min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. - max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. - env = list(object({ - name = (Required) The name of the environment variable. - secret_name = (Optional) The name of the secret to use for the environment variable. - value = (Optional) The value of the environment variable. - })) - }) - DESCRIPTION -} - -variable "ca_secrets" { - type = list(object({ - name = string - value = string - })) - default = [ - { - name = "secret1" - value = "value1" - }, - { - name = "secret2" - value = "value2" - } - ] - description = <<-DESCRIPTION - type = list(object({ - name = (Required) The name of the secret. - value = (Required) The value of the secret. - })) - DESCRIPTION -} - -# Key Vault Access # -### key vault access ### -variable "key_vault_access_permission" { - type = list(string) - default = ["Key Vault Secrets User"] - description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -} - -variable "key_vault_id" { - type = string - default = "" - description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." -} - -# DNS zone # -variable "create_dns_zone" { - description = "Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided." - type = bool - default = false -} - -variable "dns_resource_group_name" { - description = "The name of the resource group to create the DNS zone in / or where the existing zone is hosted." - type = string - nullable = false -} - -variable "custom_domain_config" { - type = object({ - zone_name = string - host_name = string - ttl = optional(number, 3600) - tls = optional(list(object({ - certificate_type = optional(string, "ManagedCertificate") - minimum_tls_version = optional(string, "TLS12") - }))) - }) - default = { - zone_name = "mydomain7335.com" - host_name = "PrivateGPT" - ttl = 3600 - tls = [{ - certificate_type = "ManagedCertificate" - minimum_tls_version = "TLS12" - }] - } - description = <<-DESCRIPTION - type = object({ - zone_name = (Required) The name of the DNS zone to create the CNAME and TXT record in for the CDN Front Door Custom domain. - host_name = (Required) The host name of the DNS record to create. (e.g. Contoso) - ttl = (Optional) The TTL of the DNS record to create. (e.g. 3600) - tls = optional(list(object({ - certificate_type = (Optional) Defines the source of the SSL certificate. Possible values include 'CustomerCertificate' and 'ManagedCertificate'. Defaults to 'ManagedCertificate'. - NOTE: It may take up to 15 minutes for the Front Door Service to validate the state and Domain ownership of the Custom Domain. - minimum_tls_version = (Optional) TLS protocol version that will be used for Https. Possible values include TLS10 and TLS12. Defaults to TLS12. - })))) - }) - DESCRIPTION -} - -# Front Door # -variable "create_front_door_cdn" { - description = "Create a Front Door profile." - type = bool - default = false -} - -variable "cdn_profile_name" { - description = "The name of the CDN profile to create." - type = string - default = "example-cdn-profile" -} - -variable "cdn_sku_name" { - description = "Specifies the SKU for the CDN Front Door Profile. Possible values include 'Standard_AzureFrontDoor' and 'Premium_AzureFrontDoor'." - type = string - default = "Standard_AzureFrontDoor" -} - -variable "cdn_endpoint" { - type = object({ - name = string - enabled = optional(bool, true) - }) - default = { - name = "PrivateGPT" - enabled = true - } - description = < -## Requirements - -No requirements. - -## Providers - -| Name | Version | -|------|---------| -| [azurerm](#provider\_azurerm) | n/a | - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [private-chatgpt-openai](#module\_private-chatgpt-openai) | Pwd9000-ML/openai-private-chatgpt/azurerm | >= 1.1.0 | - -## Resources - -| Name | Type | -|------|------| -| [azurerm_resource_group.rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | -| [azurerm_key_vault.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/key_vault) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [ca\_container\_config](#input\_ca\_container\_config) | type = object({
    name = (Required) The name of the container.
    image = (Required) The name of the container image.
    cpu = (Required) The number of CPU cores to allocate to the container.
    memory = (Required) The amount of memory to allocate to the container in GB.
    min\_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`.
    max\_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`.
    env = list(object({
    name = (Required) The name of the environment variable.
    secret\_name = (Optional) The name of the secret to use for the environment variable.
    value = (Optional) The value of the environment variable.
    }))
    }) |
    object({
    name = string
    image = string
    cpu = number
    memory = string
    min_replicas = optional(number, 0)
    max_replicas = optional(number, 10)
    env = optional(list(object({
    name = string
    secret_name = optional(string)
    value = optional(string)
    })))
    })
    |
    {
    "cpu": 1,
    "env": [],
    "image": "ghcr.io/pwd9000-ml/chatbot-ui:main",
    "max_replicas": 10,
    "memory": "2Gi",
    "min_replicas": 0,
    "name": "gpt-chatbot-ui"
    }
    | no | -| [ca\_identity](#input\_ca\_identity) | type = object({
    type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
    identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
    }) |
    object({
    type = string
    identity_ids = optional(list(string))
    })
    | `null` | no | -| [ca\_ingress](#input\_ca\_ingress) | type = object({
    allow\_insecure\_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`.
    external\_enabled = (Optional) Enable external access to the container app. Defaults to `true`.
    target\_port = (Required) The port to use for the container app. Defaults to `3000`.
    transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`.
    type = object({
    percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`.
    latest\_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`.
    }) |
    object({
    allow_insecure_connections = optional(bool)
    external_enabled = optional(bool)
    target_port = number
    transport = optional(string)
    traffic_weight = optional(object({
    percentage = number
    latest_revision = optional(bool)
    }))
    })
    |
    {
    "allow_insecure_connections": false,
    "external_enabled": true,
    "target_port": 3000,
    "traffic_weight": {
    "latest_revision": true,
    "percentage": 100
    },
    "transport": "auto"
    }
    | no | -| [ca\_name](#input\_ca\_name) | Name of the container app to create. | `string` | `"gptca"` | no | -| [ca\_revision\_mode](#input\_ca\_revision\_mode) | Revision mode of the container app to create. | `string` | `"Single"` | no | -| [ca\_secrets](#input\_ca\_secrets) | type = list(object({
    name = (Required) The name of the secret.
    value = (Required) The value of the secret.
    })) |
    list(object({
    name = string
    value = string
    }))
    |
    [
    {
    "name": "secret1",
    "value": "value1"
    },
    {
    "name": "secret2",
    "value": "value2"
    }
    ]
    | no | -| [cae\_name](#input\_cae\_name) | Name of the container app environment to create. | `string` | `"gptcae"` | no | -| [create\_front\_door\_cdn](#input\_create\_front\_door\_cdn) | Create a Front Door profile. | `bool` | `false` | no | -| [create\_model\_deployment](#input\_create\_model\_deployment) | Create the model deployment. | `bool` | `false` | no | -| [create\_openai\_service](#input\_create\_openai\_service) | Create the OpenAI service. | `bool` | `false` | no | -| [key\_vault\_access\_permission](#input\_key\_vault\_access\_permission) | The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`. | `list(string)` |
    [
    "Key Vault Secrets User"
    ]
    | no | -| [key\_vault\_id](#input\_key\_vault\_id) | (Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set. | `string` | `""` | no | -| [keyvault\_firewall\_allowed\_ips](#input\_keyvault\_firewall\_allowed\_ips) | value of keyvault firewall allowed ip rules. | `list(string)` | `[]` | no | -| [keyvault\_firewall\_bypass](#input\_keyvault\_firewall\_bypass) | List of keyvault firewall rules to bypass. | `string` | `"AzureServices"` | no | -| [keyvault\_firewall\_default\_action](#input\_keyvault\_firewall\_default\_action) | Default action for keyvault firewall rules. | `string` | `"Deny"` | no | -| [keyvault\_firewall\_virtual\_network\_subnet\_ids](#input\_keyvault\_firewall\_virtual\_network\_subnet\_ids) | value of keyvault firewall allowed virtual network subnet ids. | `list(string)` | `[]` | no | -| [kv\_config](#input\_kv\_config) | Key Vault configuration object to create azure key vault to store openai account details. |
    object({
    name = string
    sku = string
    })
    |
    {
    "name": "gptkv",
    "sku": "standard"
    }
    | no | -| [laws\_name](#input\_laws\_name) | Name of the log analytics workspace to create. | `string` | `"gptlaws"` | no | -| [laws\_retention\_in\_days](#input\_laws\_retention\_in\_days) | Retention in days of the log analytics workspace to create. | `number` | `30` | no | -| [laws\_sku](#input\_laws\_sku) | SKU of the log analytics workspace to create. | `string` | `"PerGB2018"` | no | -| [location](#input\_location) | Azure region where resources will be hosted. | `string` | `"uksouth"` | no | -| [model\_deployment](#input\_model\_deployment) | type = list(object({
    deployment\_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created.
    model\_name = {
    model\_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI.
    model\_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created.
    model\_version = (Required) The version of Cognitive Services Account Deployment model.
    }
    scale = {
    scale\_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created.
    scale\_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created.
    scale\_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created.
    scale\_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created.
    scale\_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created.
    }
    rai\_policy\_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created.
    })) |
    list(object({
    deployment_id = string
    model_name = string
    model_format = string
    model_version = string
    scale_type = string
    scale_tier = optional(string)
    scale_size = optional(number)
    scale_family = optional(string)
    scale_capacity = optional(number)
    rai_policy_name = optional(string)
    }))
    | `[]` | no | -| [openai\_account\_name](#input\_openai\_account\_name) | Name of the OpenAI service. | `string` | `"demo-account"` | no | -| [openai\_custom\_subdomain\_name](#input\_openai\_custom\_subdomain\_name) | The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name) | `string` | `"demo-account"` | no | -| [openai\_identity](#input\_openai\_identity) | type = object({
    type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`.
    identity\_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account.
    }) |
    object({
    type = string
    })
    |
    {
    "type": "SystemAssigned"
    }
    | no | -| [openai\_local\_auth\_enabled](#input\_openai\_local\_auth\_enabled) | Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`. | `bool` | `true` | no | -| [openai\_outbound\_network\_access\_restricted](#input\_openai\_outbound\_network\_access\_restricted) | Whether or not outbound network access is restricted. Defaults to `false`. | `bool` | `false` | no | -| [openai\_public\_network\_access\_enabled](#input\_openai\_public\_network\_access\_enabled) | Whether or not public network access is enabled. Defaults to `false`. | `bool` | `true` | no | -| [openai\_sku\_name](#input\_openai\_sku\_name) | SKU name of the OpenAI service. | `string` | `"S0"` | no | -| [resource\_group\_name](#input\_resource\_group\_name) | Name of the resource group to create where the cognitive account OpenAI service is hosted. | `string` | n/a | yes | -| [tags](#input\_tags) | A map of key value pairs that is used to tag resources created. | `map(string)` | `{}` | no | - -## Outputs - -No outputs. - diff --git a/examples/PrivateGPT_without_AFD_WAF/common.auto.tfvars b/examples/PrivateGPT_without_AFD_WAF/common.auto.tfvars deleted file mode 100644 index c758873..0000000 --- a/examples/PrivateGPT_without_AFD_WAF/common.auto.tfvars +++ /dev/null @@ -1,132 +0,0 @@ -### Common Variables ### -resource_group_name = "TF-Module-Example3-Cognitive-GPT" -location = "uksouth" -tags = { - Terraform = "True" - Description = "Private ChatGPT hosted on Azure OpenAI" - Author = "Marcel Lupo" - GitHub = "https://github.com/Pwd9000-ML/terraform-azurerm-openai-private-chatgpt" -} - -### OpenAI Service Module Inputs ### -kv_config = { - name = "openaikv2159" - sku = "standard" -} -keyvault_firewall_default_action = "Deny" -keyvault_firewall_bypass = "AzureServices" -keyvault_firewall_allowed_ips = ["0.0.0.0/0"] #for testing purposes only - allow all IPs -keyvault_firewall_virtual_network_subnet_ids = [] - -### Create OpenAI Service ### -create_openai_service = true -openai_account_name = "openaiacc2159" -openai_custom_subdomain_name = "openaiacc2159" #translates to "https://openaiacc2159.openai.azure.com/" -openai_sku_name = "S0" -openai_local_auth_enabled = true -openai_outbound_network_access_restricted = false -openai_public_network_access_enabled = true -openai_identity = { - type = "SystemAssigned" -} - -### Create Model deployment ### -create_model_deployment = true -model_deployment = [ - # { - # deployment_id = "gpt35turbo" ## Example of "gpt-35-turbo" - # model_name = "gpt-35-turbo" - # model_format = "OpenAI" - # model_version = "0613" - # scale_type = "Standard" - # scale_capacity = 16 - # }, - # { - # deployment_id = "gpt4" ## Example of "gpt-4" - # model_name = "gpt-4" - # model_format = "OpenAI" - # model_version = "0613" - # scale_type = "Standard" - # scale_capacity = 16 - # }, - { - deployment_id = "gpt35turbo16k" - model_name = "gpt-35-turbo-16k" - model_format = "OpenAI" - model_version = "0613" - scale_type = "Standard" - scale_capacity = 34 # 34K == Roughly 204 RPM (Requests per minute) - }, - { - deployment_id = "gpt432k" ## latest model - model_name = "gpt-4-32k" - model_format = "OpenAI" - model_version = "0613" - scale_type = "Standard" - scale_capacity = 26 # 34K == Roughly 204 RPM (Requests per minute) - } -] - -### log analytics workspace for container apps ### -laws_name = "openailaws2159" -laws_sku = "PerGB2018" -laws_retention_in_days = 30 - -### Container App Enviornment ### -cae_name = "openaicae2159" - -### Container App ### -ca_name = "openaica2159" -ca_revision_mode = "Single" -ca_identity = { - type = "SystemAssigned" -} -ca_ingress = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - latest_revision = true - percentage = 100 - } -} -ca_container_config = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 2 - memory = "4Gi" - min_replicas = 0 - max_replicas = 5 - - ## Environment Variables (Required)## - env = [ - { - name = "OPENAI_API_KEY" - secret_name = "openai-api-key" #see locals.tf (Can also be added from key vault created by module, or existing key) - }, - { - name = "OPENAI_API_HOST" - secret_name = "openai-api-host" #see locals.tf (Can also be added from key vault created by module, or existing host/endpoint) - }, - { - name = "OPENAI_API_TYPE" - value = "azure" - }, - { - name = "AZURE_DEPLOYMENT_ID" #see model_deployment variable (deployment_id) - value = "gpt432k" - }, - { - name = "DEFAULT_MODEL" #see model_deployment variable (model_name) - value = "gpt-4-32k" - } - ] -} - -### key vault access ### -key_vault_access_permission = ["Key Vault Secrets User"] - -### CDN - Front Door ### -create_front_door_cdn = false - diff --git a/examples/PrivateGPT_without_AFD_WAF/data.tf b/examples/PrivateGPT_without_AFD_WAF/data.tf deleted file mode 100644 index 6bc0ced..0000000 --- a/examples/PrivateGPT_without_AFD_WAF/data.tf +++ /dev/null @@ -1,5 +0,0 @@ -data "azurerm_key_vault" "gpt" { - name = var.kv_config.name - resource_group_name = azurerm_resource_group.rg.name - depends_on = [module.private-chatgpt-openai.key_vault_id] -} \ No newline at end of file diff --git a/examples/PrivateGPT_without_AFD_WAF/locals.tf b/examples/PrivateGPT_without_AFD_WAF/locals.tf deleted file mode 100644 index efefc1f..0000000 --- a/examples/PrivateGPT_without_AFD_WAF/locals.tf +++ /dev/null @@ -1,12 +0,0 @@ -locals { - ca_secrets = [ - { - name = "openai-api-key" - value = "${module.private-chatgpt-openai.openai_primary_key}" - }, - { - name = "openai-api-host" - value = "${module.private-chatgpt-openai.openai_endpoint}" - } - ] -} \ No newline at end of file diff --git a/examples/PrivateGPT_without_AFD_WAF/main.tf b/examples/PrivateGPT_without_AFD_WAF/main.tf deleted file mode 100644 index 888d588..0000000 --- a/examples/PrivateGPT_without_AFD_WAF/main.tf +++ /dev/null @@ -1,82 +0,0 @@ -terraform { - #backend "azurerm" {} - backend "local" { path = "terraform-example2.tfstate" } -} - -provider "azurerm" { - features { - key_vault { - purge_soft_delete_on_destroy = true - } - } -} - -################################################# -# PRE-REQS # -################################################# -### Resource group to deploy the container apps private ChatGPT instance and supporting resources into -resource "azurerm_resource_group" "rg" { - name = var.resource_group_name - location = var.location - tags = var.tags -} - -################################################## -# MODULE TO TEST # -################################################## -module "private-chatgpt-openai" { - source = "Pwd9000-ML/openai-private-chatgpt/azurerm" - version = ">= 1.1.0" - - #common - location = var.location - tags = var.tags - - #keyvault (OpenAI Service Account details) - kv_config = var.kv_config - keyvault_resource_group_name = azurerm_resource_group.rg.name - keyvault_firewall_default_action = var.keyvault_firewall_default_action - keyvault_firewall_bypass = var.keyvault_firewall_bypass - keyvault_firewall_allowed_ips = var.keyvault_firewall_allowed_ips - keyvault_firewall_virtual_network_subnet_ids = var.keyvault_firewall_virtual_network_subnet_ids - - #Create OpenAI Service? - create_openai_service = var.create_openai_service - openai_resource_group_name = azurerm_resource_group.rg.name - openai_account_name = var.openai_account_name - openai_custom_subdomain_name = var.openai_custom_subdomain_name - openai_sku_name = var.openai_sku_name - openai_local_auth_enabled = var.openai_local_auth_enabled - openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted - openai_public_network_access_enabled = var.openai_public_network_access_enabled - openai_identity = var.openai_identity - - #Create Model Deployment? - create_model_deployment = var.create_model_deployment - model_deployment = var.model_deployment - - #Create a solution log analytics workspace to store logs from our container apps instance - laws_name = var.laws_name - laws_sku = var.laws_sku - laws_retention_in_days = var.laws_retention_in_days - - #Create Container App Enviornment - cae_name = var.cae_name - - #Create a container app instance - ca_resource_group_name = azurerm_resource_group.rg.name - ca_name = var.ca_name - ca_revision_mode = var.ca_revision_mode - ca_identity = var.ca_identity - ca_container_config = var.ca_container_config - - #Create a container app secrets - ca_secrets = local.ca_secrets - - #key vault access - key_vault_access_permission = var.key_vault_access_permission - key_vault_id = data.azurerm_key_vault.gpt.id - - #Create front door CDN - create_front_door_cdn = var.create_front_door_cdn -} \ No newline at end of file diff --git a/examples/PrivateGPT_without_AFD_WAF/variables.tf b/examples/PrivateGPT_without_AFD_WAF/variables.tf deleted file mode 100644 index 0d0f007..0000000 --- a/examples/PrivateGPT_without_AFD_WAF/variables.tf +++ /dev/null @@ -1,330 +0,0 @@ -### common ### -variable "location" { - type = string - default = "uksouth" - description = "Azure region where resources will be hosted." -} - -variable "tags" { - type = map(string) - default = {} - description = "A map of key value pairs that is used to tag resources created." -} - -### solution resource group ### -variable "resource_group_name" { - type = string - description = "Name of the resource group to create where the cognitive account OpenAI service is hosted." - nullable = false -} - -### OpenAI service Module params ### -### key vault ### -variable "kv_config" { - type = object({ - name = string - sku = string - }) - default = { - name = "gptkv" - sku = "standard" - } - description = "Key Vault configuration object to create azure key vault to store openai account details." - nullable = false -} - -variable "keyvault_firewall_default_action" { - type = string - default = "Deny" - description = "Default action for keyvault firewall rules." -} - -variable "keyvault_firewall_bypass" { - type = string - default = "AzureServices" - description = "List of keyvault firewall rules to bypass." -} - -variable "keyvault_firewall_allowed_ips" { - type = list(string) - default = [] - description = "value of keyvault firewall allowed ip rules." -} - -variable "keyvault_firewall_virtual_network_subnet_ids" { - type = list(string) - default = [] - description = "value of keyvault firewall allowed virtual network subnet ids." -} - -### openai service ### -variable "create_openai_service" { - type = bool - description = "Create the OpenAI service." - default = false -} - -variable "openai_account_name" { - type = string - description = "Name of the OpenAI service." - default = "demo-account" -} - -variable "openai_custom_subdomain_name" { - type = string - description = "The subdomain name used for token-based authentication. Changing this forces a new resource to be created. (normally the same as the account name)" - default = "demo-account" -} - -variable "openai_sku_name" { - type = string - description = "SKU name of the OpenAI service." - default = "S0" -} - -variable "openai_local_auth_enabled" { - type = bool - default = true - description = "Whether local authentication methods is enabled for the Cognitive Account. Defaults to `true`." -} - -variable "openai_outbound_network_access_restricted" { - type = bool - default = false - description = "Whether or not outbound network access is restricted. Defaults to `false`." -} - -variable "openai_public_network_access_enabled" { - type = bool - default = true - description = "Whether or not public network access is enabled. Defaults to `false`." -} - -variable "openai_identity" { - type = object({ - type = string - }) - default = { - type = "SystemAssigned" - } - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -### model deployment ### -variable "create_model_deployment" { - type = bool - description = "Create the model deployment." - default = false -} - -variable "model_deployment" { - type = list(object({ - deployment_id = string - model_name = string - model_format = string - model_version = string - scale_type = string - scale_tier = optional(string) - scale_size = optional(number) - scale_family = optional(string) - scale_capacity = optional(number) - rai_policy_name = optional(string) - })) - default = [] - description = <<-DESCRIPTION - type = list(object({ - deployment_id = (Required) The name of the Cognitive Services Account `Model Deployment`. Changing this forces a new resource to be created. - model_name = { - model_format = (Required) The format of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. Possible value is OpenAI. - model_name = (Required) The name of the Cognitive Services Account Deployment model. Changing this forces a new resource to be created. - model_version = (Required) The version of Cognitive Services Account Deployment model. - } - scale = { - scale_type = (Required) Deployment scale type. Possible value is Standard. Changing this forces a new resource to be created. - scale_tier = (Optional) Possible values are Free, Basic, Standard, Premium, Enterprise. Changing this forces a new resource to be created. - scale_size = (Optional) The SKU size. When the name field is the combination of tier and some other value, this would be the standalone code. Changing this forces a new resource to be created. - scale_family = (Optional) If the service has different generations of hardware, for the same SKU, then that can be captured here. Changing this forces a new resource to be created. - scale_capacity = (Optional) Tokens-per-Minute (TPM). If the SKU supports scale out/in then the capacity integer should be included. If scale out/in is not possible for the resource this may be omitted. Default value is 1. Changing this forces a new resource to be created. - } - rai_policy_name = (Optional) The name of RAI policy. Changing this forces a new resource to be created. - })) - DESCRIPTION - nullable = false -} - -### log analytics workspace ### -variable "laws_name" { - type = string - description = "Name of the log analytics workspace to create." - default = "gptlaws" -} - -variable "laws_sku" { - type = string - description = "SKU of the log analytics workspace to create." - default = "PerGB2018" -} - -variable "laws_retention_in_days" { - type = number - description = "Retention in days of the log analytics workspace to create." - default = 30 -} - -### container app environment ### -variable "cae_name" { - type = string - description = "Name of the container app environment to create." - default = "gptcae" -} - -### container app ### -variable "ca_name" { - type = string - description = "Name of the container app to create." - default = "gptca" -} - -variable "ca_revision_mode" { - type = string - description = "Revision mode of the container app to create." - default = "Single" -} - -variable "ca_identity" { - type = object({ - type = string - identity_ids = optional(list(string)) - }) - default = null - description = <<-DESCRIPTION - type = object({ - type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. - identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. - }) - DESCRIPTION -} - -variable "ca_ingress" { - type = object({ - allow_insecure_connections = optional(bool) - external_enabled = optional(bool) - target_port = number - transport = optional(string) - traffic_weight = optional(object({ - percentage = number - latest_revision = optional(bool) - })) - }) - default = { - allow_insecure_connections = false - external_enabled = true - target_port = 3000 - transport = "auto" - traffic_weight = { - percentage = 100 - latest_revision = true - } - } - description = <<-DESCRIPTION - type = object({ - allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. - external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. - target_port = (Required) The port to use for the container app. Defaults to `3000`. - transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. - type = object({ - percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. - latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. - }) - DESCRIPTION -} - -variable "ca_container_config" { - type = object({ - name = string - image = string - cpu = number - memory = string - min_replicas = optional(number, 0) - max_replicas = optional(number, 10) - env = optional(list(object({ - name = string - secret_name = optional(string) - value = optional(string) - }))) - }) - default = { - name = "gpt-chatbot-ui" - image = "ghcr.io/pwd9000-ml/chatbot-ui:main" - cpu = 1 - memory = "2Gi" - min_replicas = 0 - max_replicas = 10 - env = [] - } - description = <<-DESCRIPTION - type = object({ - name = (Required) The name of the container. - image = (Required) The name of the container image. - cpu = (Required) The number of CPU cores to allocate to the container. - memory = (Required) The amount of memory to allocate to the container in GB. - min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. - max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. - env = list(object({ - name = (Required) The name of the environment variable. - secret_name = (Optional) The name of the secret to use for the environment variable. - value = (Optional) The value of the environment variable. - })) - }) - DESCRIPTION -} - -variable "ca_secrets" { - type = list(object({ - name = string - value = string - })) - default = [ - { - name = "secret1" - value = "value1" - }, - { - name = "secret2" - value = "value2" - } - ] - description = <<-DESCRIPTION - type = list(object({ - name = (Required) The name of the secret. - value = (Required) The value of the secret. - })) - DESCRIPTION -} - -# Key Vault Access # -### key vault access ### -variable "key_vault_access_permission" { - type = list(string) - default = ["Key Vault Secrets User"] - description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -} - -variable "key_vault_id" { - type = string - default = "" - description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." -} - -# Front Door # -variable "create_front_door_cdn" { - description = "Create a Front Door profile." - type = bool - default = false -} - diff --git a/main.tf b/main.tf deleted file mode 100644 index 6b51afc..0000000 --- a/main.tf +++ /dev/null @@ -1,92 +0,0 @@ -# ############################################### -# # OpenAI Service # -# ############################################### -# ### Create OpenAI Service ### - - -# ### Create openai networking for CosmosDB and Web App (Optional) ### - - -# ### Create a CosmosDB account running MongoDB to store chat data (Optional) ### - -# ### Create the Web App ### -# # # 7.) Create a Linux Web App running chatbot container. -# # module "openai_app" { -# # source = "./modules/librechat_app" -# # app_resource_group_name = var.cosmosdb_resource_group_name -# # location = var.location -# # tags = var.tags - -# # app_service_sku_name = var.app_service_sku_name -# # app_service_name = var.app_service_name -# # app_name = var.app_name -# # app_title = var.app_title -# # app_custom_footer = var.app_custom_footer - - -# # } - - -# # 8.) grant the container app access a the key vault (optional). - -# ##module "privategpt_chatbot_container_apps" { -# ## source = "./modules/container_app" -# ## -# ## #common -# ## ca_resource_group_name = var.ca_resource_group_name -# ## location = var.location -# ## tags = var.tags -# ## -# ## #log analytics workspace -# ## laws_name = var.laws_name -# ## laws_sku = var.laws_sku -# ## laws_retention_in_days = var.laws_retention_in_days -# ## -# ## #container app environment -# ## cae_name = var.cae_name -# ## -# ## #container app -# ## ca_name = var.ca_name -# ## ca_revision_mode = var.ca_revision_mode -# ## ca_identity = var.ca_identity -# ## ca_ingress = var.ca_ingress -# ## ca_container_config = var.ca_container_config -# ## ca_secrets = var.ca_secrets -# ## -# ## #key vault access -# ## key_vault_access_permission = var.key_vault_access_permission #Set to `null` if no Key Vault access is needed on CA identity. -# ## key_vault_id = var.key_vault_id #Provide the key vault id if key_vault_access_permission is not null. -# ## -# ## depends_on = [module.openai] -# ##} - -# ### Front solution with an Azure front door (optional) ### -# # 9.) Deploy Azure Front Door. -# # 10.) Setup a custom domain with AFD managed certificate. -# # 11.) Optionally create an Azure DNS Zone or use an existing one for the custom domain. (e.g PrivateGPT.mydomain.com) -# # 12.) Create a CNAME and TXT record in the custom DNS zone. -# # 13.) Setup and apply AFD WAF policy for the front door with allowed IPs custom rule. (Optional) -# #module "azure_frontdoor_cdn" { -# # count = var.create_front_door_cdn ? 1 : 0 -# # source = "./modules/cdn_frontdoor" - -# #create_dns_zone -# # create_dns_zone = var.create_dns_zone -# # dns_resource_group_name = var.dns_resource_group_name -# # custom_domain_config = var.custom_domain_config - -# #deploy front door -# # cdn_resource_group_name = var.cdn_resource_group_name -# # cdn_profile_name = var.cdn_profile_name -# # cdn_sku_name = var.cdn_sku_name -# ## cdn_endpoint = var.cdn_endpoint -# # cdn_origin_groups = var.cdn_origin_groups -# # cdn_gpt_origin = local.cdn_gpt_origin -# # cdn_route = var.cdn_route - -# #deploy firewall policy -# # cdn_firewall_policy = var.cdn_firewall_policy -# # cdn_security_policy = var.cdn_security_policy -# # tags = var.tags -# # depends_on = [module.privategpt_chatbot_container_apps] -# #} \ No newline at end of file diff --git a/tests/auto_test1/data.tf b/tests/auto_test1/data.tf deleted file mode 100644 index 3b9fde2..0000000 --- a/tests/auto_test1/data.tf +++ /dev/null @@ -1,11 +0,0 @@ -# data "azurerm_subnet" "openai_subnet" { -# name = var.subnet_config.subnet_name -# virtual_network_name = var.virtual_network_name -# resource_group_name = var.resource_group_name -# } - -# data "azurerm_key_vault" "gpt" { -# name = local.kv_config.name -# resource_group_name = azurerm_resource_group.rg.name -# depends_on = [module.private-chatgpt-openai.key_vault_id] -# } \ No newline at end of file diff --git a/tests/auto_test1/locals.tf b/tests/auto_test1/locals.tf deleted file mode 100644 index ff86d91..0000000 --- a/tests/auto_test1/locals.tf +++ /dev/null @@ -1,50 +0,0 @@ -# locals { -# ## locals config for key vault firewall rules ## -# kv_net_rules = [ -# { -# default_action = var.keyvault_firewall_default_action -# bypass = var.keyvault_firewall_bypass -# ip_rules = var.keyvault_firewall_allowed_ips -# # virtual_network_subnet_name = var.virtual_network_subnet_name -# } -# ] -# } - - -# locals { -# # Container App Secrets -# ca_secrets = [ -# { -# name = "openai-api-key" -# value = "${module.private-chatgpt-openai.openai_primary_key}" -# }, -# { -# name = "openai-api-host" -# value = "${module.private-chatgpt-openai.openai_endpoint}" -# } -# ] - -# # Key Vault Config (with ranodm number suffix) -# kv_config = { -# name = "gptkv${random_integer.number.result}" -# sku = "standard" -# } - -# # Custom Domain Config (with ranodm number suffix) -# custom_domain_config = { -# zone_name = "gpt${random_integer.number.result}.com" -# host_name = "PrivateGPT" -# ttl = 600 -# tls = [{ -# certificate_type = "ManagedCertificate" -# minimum_tls_version = "TLS12" -# }] -# } - -# #override the variable values for the WAF name to be unique (for automated tests) -# cdn_firewall_policy = merge( -# var.cdn_firewall_policy, -# { name = "PrivateGPTWAF${random_integer.number.result}" } -# ) - -# } \ No newline at end of file diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 6cb68ee..4111408 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -102,10 +102,10 @@ module "private-chatgpt-openai" { libre_app_custom_footer = var.libre_app_custom_footer libre_app_host = var.libre_app_host libre_app_port = var.libre_app_port + libre_app_docker_image = var.libre_app_docker_image libre_app_mongo_uri = var.libre_app_mongo_uri libre_app_domain_client = var.libre_app_domain_client libre_app_domain_server = var.libre_app_domain_server - libre_app_docker_image = var.libre_app_docker_image # Debug Config libre_app_debug_logging = var.libre_app_debug_logging @@ -127,7 +127,8 @@ module "private-chatgpt-openai" { libre_app_debug_plugins = var.libre_app_debug_plugins libre_app_plugins_creds_key = var.libre_app_plugins_creds_key libre_app_plugins_creds_iv = var.libre_app_plugins_creds_iv - libre_app_plugin_models = var.libre_app_plugin_models + # libre_app_plugin_models = var.libre_app_plugin_models + # libre_app_plugins_use_azure = var.libre_app_plugins_use_azure # Search libre_app_enable_meilisearch = var.libre_app_enable_meilisearch @@ -142,85 +143,4 @@ module "private-chatgpt-openai" { libre_app_allow_social_registration = var.libre_app_allow_social_registration libre_app_jwt_secret = var.libre_app_jwt_secret libre_app_jwt_refresh_secret = var.libre_app_jwt_refresh_secret - -} - - - - - -# #Create OpenAI Service? -# create_openai_service = var.create_openai_service -# openai_resource_group_name = azurerm_resource_group.rg.name -# openai_account_name = "${var.openai_account_name}${random_integer.number.result}" -# openai_custom_subdomain_name = "${var.openai_custom_subdomain_name}${random_integer.number.result}" -# openai_sku_name = var.openai_sku_name -# openai_local_auth_enabled = var.openai_local_auth_enabled -# openai_outbound_network_access_restricted = var.openai_outbound_network_access_restricted -# openai_public_network_access_enabled = var.openai_public_network_access_enabled -# openai_identity = var.openai_identity - -# #Create Model Deployment? -# create_model_deployment = var.create_model_deployment -# model_deployment = var.model_deployment - -# #Create networking for CosmosDB and Web App (Optional) -# create_openai_networking = var.create_openai_networking -# network_resource_group_name = azurerm_resource_group.rg.name -# virtual_network_name = "${var.virtual_network_name}${random_integer.number.result}" -# vnet_address_space = var.vnet_address_space -# subnet_config = var.subnet_config - -# #Create a CosmosDB account running MongoDB to store chat data (Optional) -# create_cosmosdb = var.create_cosmosdb -# cosmosdb_name = "${var.cosmosdb_name}${random_integer.number.result}" -# cosmosdb_resource_group_name = var.cosmosdb_resource_group_name -# cosmosdb_offer_type = var.cosmosdb_offer_type -# cosmosdb_kind = var.cosmosdb_kind -# cosmosdb_automatic_failover = var.cosmosdb_automatic_failover -# use_cosmosdb_free_tier = var.use_cosmosdb_free_tier -# cosmosdb_consistency_level = var.cosmosdb_consistency_level -# cosmosdb_max_interval_in_seconds = var.cosmosdb_max_interval_in_seconds -# cosmosdb_max_staleness_prefix = var.cosmosdb_max_staleness_prefix -# cosmosdb_geo_locations = var.cosmosdb_geo_locations -# cosmosdb_capabilities = var.cosmosdb_capabilities -# cosmosdb_is_virtual_network_filter_enabled = var.cosmosdb_is_virtual_network_filter_enabled -# cosmosdb_public_network_access_enabled = var.cosmosdb_public_network_access_enabled - -# #Create a solution log analytics workspace to store logs from our container apps instance -# #laws_name = "${var.laws_name}${random_integer.number.result}" -# #laws_sku = var.laws_sku -# #laws_retention_in_days = var.laws_retention_in_days - -# #Create Container App Enviornment -# #cae_name = "${var.cae_name}${random_integer.number.result}" - -# #Create a container app instance -# #ca_resource_group_name = azurerm_resource_group.rg.name -# #ca_name = "${var.ca_name}${random_integer.number.result}" -# #ca_revision_mode = var.ca_revision_mode -# #ca_identity = var.ca_identity -# #ca_container_config = var.ca_container_config - -# #Create a container app secrets -# #ca_secrets = local.ca_secrets - -# #key vault access -# #key_vault_access_permission = var.key_vault_access_permission -# #key_vault_id = data.azurerm_key_vault.gpt.id - -# #Create front door CDN -# create_front_door_cdn = var.create_front_door_cdn -# cdn_resource_group_name = azurerm_resource_group.rg.name -# create_dns_zone = var.create_dns_zone -# dns_resource_group_name = azurerm_resource_group.rg.name -# custom_domain_config = local.custom_domain_config -# cdn_profile_name = "${var.cdn_profile_name}${random_integer.number.result}" -# cdn_sku_name = var.cdn_sku_name -# cdn_endpoint = var.cdn_endpoint -# cdn_origin_groups = var.cdn_origin_groups -# cdn_gpt_origin = var.cdn_gpt_origin -# cdn_route = var.cdn_route -# cdn_firewall_policy = local.cdn_firewall_policy -# cdn_security_policy = var.cdn_security_policy -# } \ No newline at end of file +} \ No newline at end of file diff --git a/tests/auto_test1/testing.auto.tfvars b/tests/auto_test1/testing.auto.tfvars index 8ba580b..1edcfac 100644 --- a/tests/auto_test1/testing.auto.tfvars +++ b/tests/auto_test1/testing.auto.tfvars @@ -127,10 +127,10 @@ libre_app_title = "Azure OpenAI LibreChat" libre_app_custom_footer = "Privately hosted chat app powered by Azure OpenAI and LibreChat" libre_app_host = "0.0.0.0" libre_app_port = 80 +libre_app_docker_image = "ghcr.io/danny-avila/librechat-dev-api:latest" libre_app_mongo_uri = null libre_app_domain_client = "http://localhost:80" libre_app_domain_server = "http://localhost:80" -libre_app_docker_image = "ghcr.io/danny-avila/librechat-dev-api:latest" # debug logging libre_app_debug_logging = true @@ -152,8 +152,8 @@ libre_app_az_oai_dall3_deployment_name = "dall-e-3" libre_app_debug_plugins = true libre_app_plugins_creds_key = null libre_app_plugins_creds_iv = null -libre_app_plugin_models = "gpt-35-turbo,gpt-4,gpt-4-vision-preview" -libre_app_plugins_use_azure = true +# libre_app_plugin_models = "gpt-35-turbo,gpt-4,gpt-4-vision-preview" +# libre_app_plugins_use_azure = true # Search libre_app_enable_meilisearch = false diff --git a/tests/auto_test1/variables.tf b/tests/auto_test1/variables.tf index cac8430..c8551e4 100644 --- a/tests/auto_test1/variables.tf +++ b/tests/auto_test1/variables.tf @@ -422,6 +422,12 @@ variable "libre_app_port" { default = 3080 } +variable "libre_app_docker_image" { + type = string + description = "The Docker Image to use for the App Service." + default = "ghcr.io/danny-avila/librechat-dev-api:latest" +} + variable "libre_app_mongo_uri" { type = string description = "The MongoDB Connection String to connect to." @@ -441,12 +447,6 @@ variable "libre_app_domain_server" { default = "http://localhost:3080" } -variable "libre_app_docker_image" { - type = string - description = "The Docker Image to use for the App Service." - default = "ghcr.io/danny-avila/librechat-dev-api:latest" -} - # Debug logging variable "libre_app_debug_logging" { type = bool @@ -532,17 +532,17 @@ variable "libre_app_plugins_creds_iv" { sensitive = true } -variable "libre_app_plugin_models" { - type = string - description = "Libre App Plugin Models e.g. 'gpt-4,dall-e-3'" - default = "gpt-4,dall-e-3" -} +# variable "libre_app_plugin_models" { +# type = string +# description = "Libre App Plugin Models e.g. 'gpt-4,dall-e-3'" +# default = "gpt-4,dall-e-3" +# } -variable "libre_app_plugins_use_azure" { - type = bool - description = "Libre App Plugins Use Azure, required for Azure OpenAI Plugins e.g. 'dall-e-3'" - default = true -} +# variable "libre_app_plugins_use_azure" { +# type = bool +# description = "Libre App Plugins Use Azure, required for Azure OpenAI Plugins e.g. 'dall-e-3'" +# default = true +# } # Search variable "libre_app_enable_meilisearch" { diff --git a/variables.tf b/variables.tf index 7eb99ac..20966b0 100644 --- a/variables.tf +++ b/variables.tf @@ -427,6 +427,13 @@ variable "libre_app_port" { default = 3080 } +variable "libre_app_docker_image" { + type = string + description = "The Docker Image to use for the App Service." + default = "ghcr.io/danny-avila/librechat-dev-api:latest" +} + + variable "libre_app_mongo_uri" { type = string description = "The MongoDB Connection String to connect to." @@ -446,12 +453,6 @@ variable "libre_app_domain_server" { default = "http://localhost:3080" } -variable "libre_app_docker_image" { - type = string - description = "The Docker Image to use for the App Service." - default = "ghcr.io/danny-avila/librechat-dev-api:latest" -} - # Debug logging variable "libre_app_debug_logging" { type = bool @@ -537,17 +538,17 @@ variable "libre_app_plugins_creds_iv" { sensitive = true } -variable "libre_app_plugin_models" { - type = string - description = "Libre App Plugin Models e.g. 'gpt-4,dall-e-3'" - default = "gpt-4,dall-e-3" -} +# variable "libre_app_plugin_models" { +# type = string +# description = "Libre App Plugin Models e.g. 'gpt-4,dall-e-3'" +# default = "gpt-4,dall-e-3" +# } -variable "libre_app_plugins_use_azure" { - type = bool - description = "Libre App Plugins Use Azure, required for Azure OpenAI Plugins e.g. 'dall-e-3'" - default = true -} +# variable "libre_app_plugins_use_azure" { +# type = bool +# description = "Libre App Plugins Use Azure, required for Azure OpenAI Plugins e.g. 'dall-e-3'" +# default = true +# } #TODO # Search @@ -613,497 +614,4 @@ variable "libre_app_jwt_refresh_secret" { description = "JWT Refresh Secret" default = null sensitive = true -} - -# ################################### -# ### Container App Module params ### -# ################################### -# #variable "ca_resource_group_name" { -# # type = string -# # description = "Name of the resource group to create the Container App and supporting solution resources in." -# # nullable = false -# #} - -# ### log analytics workspace ### -# #variable "laws_name" { -# # type = string -# # description = "Name of the log analytics workspace to create." -# # default = "gptlaws" -# #} - -# #variable "laws_sku" { -# # type = string -# # description = "SKU of the log analytics workspace to create." -# # default = "PerGB2018" -# #} - -# #variable "laws_retention_in_days" { -# # type = number -# # description = "Retention in days of the log analytics workspace to create." -# # default = 30 -# #} - -# ### container app environment ### -# #variable "cae_name" { -# # type = string -# # description = "Name of the container app environment to create." -# # default = "gptcae" -# #} - - -# ### container app ### -# #variable "ca_name" { -# # type = string -# # description = "Name of the container app to create." -# # default = "gptca" -# #} - -# #variable "ca_revision_mode" { -# # type = string -# # description = "Revision mode of the container app to create." -# # default = "Single" -# #} - -# #variable "ca_identity" { -# # type = object({ -# # type = string -# # identity_ids = optional(list(string)) -# # }) -# # default = null -# # description = <<-DESCRIPTION -# # type = object({ -# # type = (Required) The type of the Identity. Possible values are `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. -# # identity_ids = (Optional) Specifies a list of User Assigned Managed Identity IDs to be assigned to this OpenAI Account. -# # }) -# # DESCRIPTION -# #} - -# #variable "ca_ingress" { -# # type = object({ -# # allow_insecure_connections = optional(bool) -# # external_enabled = optional(bool) -# # target_port = number -# # transport = optional(string) -# # traffic_weight = optional(object({ -# # percentage = number -# # latest_revision = optional(bool) -# # })) -# # }) -# # default = { -# # allow_insecure_connections = false -# # external_enabled = true -# # target_port = 3000 -# # transport = "auto" -# # traffic_weight = { -# # percentage = 100 -# # latest_revision = true -# # } -# # } -# # description = <<-DESCRIPTION -# # type = object({ -# # allow_insecure_connections = (Optional) Allow insecure connections to the container app. Defaults to `false`. -# # external_enabled = (Optional) Enable external access to the container app. Defaults to `true`. -# # target_port = (Required) The port to use for the container app. Defaults to `3000`. -# # transport = (Optional) The transport protocol to use for the container app. Defaults to `auto`. -# # type = object({ -# # percentage = (Required) The percentage of traffic to route to the container app. Defaults to `100`. -# # latest_revision = (Optional) The percentage of traffic to route to the container app. Defaults to `true`. -# # }) -# # DESCRIPTION -# #} - -# #variable "ca_container_config" { -# # type = object({ -# # name = string -# # image = string -# # cpu = number -# # memory = string -# # min_replicas = optional(number, 0) -# # max_replicas = optional(number, 10) -# # env = optional(list(object({ -# # name = string -# # secret_name = optional(string) -# # value = optional(string) -# # }))) -# # }) -# # default = { -# # name = "gpt-chatbot-ui" -# # image = "ghcr.io/pwd9000-ml/chatbot-ui:main" -# # cpu = 1 -# # memory = "2Gi" -# # min_replicas = 0 -# # max_replicas = 10 -# # env = [] -# # } -# # description = <<-DESCRIPTION -# # type = object({ -# # name = (Required) The name of the container. -# # image = (Required) The name of the container image. -# # cpu = (Required) The number of CPU cores to allocate to the container. -# # memory = (Required) The amount of memory to allocate to the container in GB. -# # min_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`. -# # max_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`. -# # env = list(object({ -# # name = (Required) The name of the environment variable. -# # secret_name = (Optional) The name of the secret to use for the environment variable. -# # value = (Optional) The value of the environment variable. -# # })) -# # }) -# # DESCRIPTION -# #} - -# #variable "ca_secrets" { -# # type = list(object({ -# # name = string -# # value = string -# # })) -# # default = [ -# # { -# # name = "secret1" -# # value = "value1" -# # }, -# # { -# # name = "secret2" -# # value = "value2" -# # } -# # ] -# # description = <<-DESCRIPTION -# # type = list(object({ -# # name = (Required) The name of the secret. -# # value = (Required) The value of the secret. -# # })) -# # DESCRIPTION -# #} - -# ### key vault access ### -# #variable "key_vault_access_permission" { -# # type = list(string) -# # default = ["Key Vault Secrets User"] -# # description = "The permission to grant the container app to the key vault. Set this variable to `null` if no Key Vault access is needed. Defaults to `Key Vault Secrets User`." -# #} - -# #variable "key_vault_id" { -# # type = string -# # description = "(Optional) - The id of the key vault to grant access to. Only required if `key_vault_access_permission` is set." -# # default = "" -# #} - -# #################################### -# ### CDN Front Door Module params ### -# #################################### -# # DNS zone ## -# variable "create_dns_zone" { -# description = "Create a DNS zone for the CDN profile. If set to false, an existing DNS zone must be provided." -# type = bool -# default = false -# } - -# variable "dns_resource_group_name" { -# description = "The name of the resource group to create the DNS zone in / or where the existing zone is hosted." -# type = string -# nullable = false -# default = "dns-rg-01" -# } - -# variable "custom_domain_config" { -# type = object({ -# zone_name = string -# host_name = string -# ttl = optional(number, 3600) -# tls = optional(list(object({ -# certificate_type = optional(string, "ManagedCertificate") -# minimum_tls_version = optional(string, "TLS12") -# }))) -# }) -# default = { -# zone_name = "mydomain7335.com" -# host_name = "PrivateGPT" -# ttl = 3600 -# tls = [{ -# certificate_type = "ManagedCertificate" -# minimum_tls_version = "TLS12" -# }] -# } -# description = <<-DESCRIPTION -# type = object({ -# zone_name = (Required) The name of the DNS zone to create the CNAME and TXT record in for the CDN Front Door Custom domain. -# host_name = (Required) The host name of the DNS record to create. (e.g. Contoso) -# ttl = (Optional) The TTL of the DNS record to create. (e.g. 3600) -# tls = optional(list(object({ -# certificate_type = (Optional) Defines the source of the SSL certificate. Possible values include 'CustomerCertificate' and 'ManagedCertificate'. Defaults to 'ManagedCertificate'. -# NOTE: It may take up to 15 minutes for the Front Door Service to validate the state and Domain ownership of the Custom Domain. -# minimum_tls_version = (Optional) TLS protocol version that will be used for Https. Possible values include TLS10 and TLS12. Defaults to TLS12. -# })))) -# }) -# DESCRIPTION -# } - - -# # Front Door # -# variable "create_front_door_cdn" { -# description = "Create a Front Door profile." -# type = bool -# default = false -# } - -# variable "cdn_resource_group_name" { -# type = string -# description = "Name of the resource group to create the CDN Front Door in." -# nullable = false -# default = "cdn-rg-01" -# } - -# variable "cdn_profile_name" { -# description = "The name of the CDN profile to create." -# type = string -# default = "example-cdn-profile" -# } - -# variable "cdn_sku_name" { -# description = "Specifies the SKU for the CDN Front Door Profile. Possible values include 'Standard_AzureFrontDoor' and 'Premium_AzureFrontDoor'." -# type = string -# default = "Standard_AzureFrontDoor" -# } - -# variable "cdn_endpoint" { -# type = object({ -# name = string -# enabled = optional(bool, true) -# }) -# default = { -# name = "PrivateGPT" -# enabled = true -# } -# description = < Date: Mon, 22 Jan 2024 23:55:47 +0000 Subject: [PATCH 163/163] lint --- 06_librechat_app_config.tf | 6 +++--- tests/auto_test1/main.tf | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/06_librechat_app_config.tf b/06_librechat_app_config.tf index b212ae1..54227a2 100644 --- a/06_librechat_app_config.tf +++ b/06_librechat_app_config.tf @@ -34,9 +34,9 @@ locals { ### Plugins ### # NOTE: You need a fixed key and IV. a 32-byte key (64 characters in hex) and 16-byte IV (32 characters in hex) # Warning: If you don't set them, the app will crash on startup. - DEBUG_PLUGINS = var.libre_app_debug_plugins - CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" - CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" + DEBUG_PLUGINS = var.libre_app_debug_plugins + CREDS_KEY = var.libre_app_plugins_creds_key != null ? var.libre_app_plugins_creds_key : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_key.id})" + CREDS_IV = var.libre_app_plugins_creds_iv != null ? var.libre_app_plugins_creds_iv : "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.libre_app_creds_iv.id})" #PLUGIN_MODELS = var.libre_app_plugin_models #PLUGINS_USE_AZURE = var.libre_app_plugins_use_azure diff --git a/tests/auto_test1/main.tf b/tests/auto_test1/main.tf index 4111408..3bcdfa9 100644 --- a/tests/auto_test1/main.tf +++ b/tests/auto_test1/main.tf @@ -102,7 +102,7 @@ module "private-chatgpt-openai" { libre_app_custom_footer = var.libre_app_custom_footer libre_app_host = var.libre_app_host libre_app_port = var.libre_app_port - libre_app_docker_image = var.libre_app_docker_image + libre_app_docker_image = var.libre_app_docker_image libre_app_mongo_uri = var.libre_app_mongo_uri libre_app_domain_client = var.libre_app_domain_client libre_app_domain_server = var.libre_app_domain_server

    7lfNl0%S0A?T3ZkG|Be@Wf4twK=rZHcjT4!tn`}^m za5h5O8oe<3Y^~+r_6OPi`92+QXJ_XVUczK(WEj&=_w5{ZGs@QTH9vn}2i*3M%;Mm* z$nvni`}zA7<49*aF4`E^ds3ME-7cTeeZj60z((M>_tNRPu+(sNW zQ9D+(D~j5=ioEmgbOX>G2hsfpMdc(^utN zj{a_C-=B#Z`U_F1?G~uBKY{4X%^sW&KXR#aUp!9~eW#5*mT zfj=a{`yqHwT1K;+rE}b?z?HIIF-rF0P}Aj@q+$QftOtt>_;dTsUCe zw&dDsIQJP!d^M`m+1+g*v&X9WW%<>_O*8Vc^l`@_)|G0LF-h7xB4q&Hfof;%XL88U zqQNm4CtQ`o*9r*#XlB0o&=~~WpFn)i9d7YAdX^=$vY8`2_J<`@;QBs)^{4CjH!LZ% z6#%2FD&AS@{f%)?h+(WN^_<7va2U=y)tE0ZNH6C3Av!KUb!_hRICQ$X-~yjZOvmp2 z+a(}4ICn6cBO(;t_@1AiZb1?BwTgq%MU&mXrbZjlt=V1DS%<>lGpWI6Zhj4*`%lN* z_Tm0BQ_7Y?f8#xr?~jXFG)3^rE-nWX3wxpVC04{3*=342B6VCk_iS2AJN`^{p}hZC zTK1#D7r0;$)gEeuv7`dU%sr=m%Cd-V?9Y60wjqYA%4v9w$!$3vbq3TqQ1DQ1>RALC z{(HZ8#XyKsw8B#!nSZRQ2H&2p0u5VU{sk;EYd^+#ILoF!mq~B$aQr#peAr0MZq#SC z;lzKu`Equ5;upqOVQ~MYw#qJ2Zb&JKL0jjsj}TJ58?vpT-fs`PkV}Ew6k<6?l6eoJ z{}YwA?)8_KXCbC$!N|-|`GTltVYN(z@6XTw7A+KMMbWqd4-`Q) z+=hV!+V28#bE(}{aMda4uM=It5dr^d%WGpcH%rK9vGyIh@6Q-0Ha2!|iM)bBOGDcJQyv@s8n0Hm!EOy|5iOAk2A z^Npn?zq-0tg5I~!qsfKD?d;eg5C|u`ddoPlSaJCCsV`)@0yS+leEq}O_89IGKelS^ z?+?f0k?ZAuhf^JUuY3*|l;C=3ggine|KWxc+tF019dvaLgABUS-|cYfy~1;@r~Q+A z-x_O={8D_Hafx3xu|2_OG$dj`-LbZ{wr>X3gAY@yG!WA$*U`E*pRR_=C9z^DD=SM% zNiFpyGEITWlu^(5{4j`E0iTRE^w;TYNZ5LM7cwWK{5=9)p2gF1?8-E}*u17C=uRGm zZk!GtD}rl<{NG#}!0JrkSWg>{nrupFvte+-I`6l6a|q|OC}I&ZSXg4sCXdO)iV_le z^ymMy0MCU2dX3QDPd9j`@F1V{|I_(E%(dc<&!qZp0j)v&ePmhrdXV>@DoGRN-fpX~1kq_@;(YkDs$J)v zV7?f_)BkisIM@2ZYVuqL4osa`esZVQ61&NT)&K`t3hR|;36RA@uo zzpD6jK)3V1dZzr|3zzJyb^U(wP`x@}UQ5w6k0CX{B4O(4WN+9%ng7${eu%sGQ2SITBd|u(MzF%e9Vwm}y zFZGAS-cKhn8UP;)uDqrzU(4fHPxSM)!R6rhy}yMB_tFG_+iGigjJb2h!`P%vVlCiQ zRUUd^wH+twW{jRcb9vD7;^1OEi<*W8nOZ8M4V<}TO~@e?rCPS=LvEW{VlV{>=8uXc z$&KC%VZ2t_X>Mwg{LBiWQ_d7@Pvx;>H~sZI_H%e(sIG8aZ@5wk^K|`3p_ac>q+f{2 zdIU~Lr(X){c}4*lAC0sXHJji6yte4qd+k2Ul&cD@L8^~7oH<>9Y+3rWCj`8(1)g-> zg%oz%SvSeN_Q-@4(*ru-?tc71Mm_r*Hd)dXIFa?~=O3ok)oCQ(8lZon;ISBMiy-0J z?_;Vk7%Nf*g>n`wAms4y^i*oD;Uld53@lIxq1sPCSk8R4u5&pse4@3qzD{>ZlM*N3 zYy&225=aDLw%0qZTRo&4e|75Rtm0Z9XPXHlk!_E2AWwRy-0JT#|ZQ_8P8oSqc#NocA3;F6$Z2l2~!l(d0Q`dAqpR5BjHo zq^mN7Ic-njg1M0RS?VltfU7hb|NQ#Zr|slAWmnZ3Jh^e0=h@B-QQ#AT?`s2T!!4ff z0fB+5ZDh+(bIR=3aI1wH)aF(jHZ|LEk8i%cu(L02s*dfyD5CJ-9=O812d*fz4z+LDBZ!Ca6yJS? zEl?Vj*}*3`@~MMQ4cEpvH0vx1KPrTnwj(Et7T^*OZ82$7yhdZ0ar#liQD<~TVRv!x z>3JO=R#KJ~PyOQI#e&3+TC<8U#fiW!7%1~S5Cel@q*RNGQpCg4Q%ueH&-cKMh1S+0 z4)LT`@Iz4?WnsZ>iv=V*>96dOzI+&fys(|EqiiwVMwsD$wiK^b5fl;vtU#gix9EYD z*9BC-TU*W5J-Ko7h~f*MOe^&@4V`>qi;MTe0_iKgJg;7+AJ6*esepcenEz0XM^{hef+%6UMWvtZlYo05M%=g|>yl*6!g(?>uv|@i5Q8XuUhEC~T$Pvy^x! z&_F>Noc{XZh+M0Ty>PueX7KRv5H&O!$`(5ZoEa`DY1s9}krCKoS)|w8Ad~;`Y*#n- zvn8Ma5Vs+vrsB1-dfqo5DHZ7l-e_oOgz>EOC1Qx&9LI++=6pW@YAb>p%Vv7Hz)Ol^ z^~O)usyKlyL@!=MO9W$w{viCEWGMIBjEsq{Cbz~y<7M~2fTA7ZG3958+;WjzJ*GKn z@$k*bA4OCR`g)sgT!*IZ?d>0~;GW6x(09Dok-p6>5M1cEpL5<)Fj&K z+UpjZ7s{6U1;6!My~zAgu{);@mPc=%lWci|eTz%do|mV!D+ySqCT7tge|#UI#q}g9 zn4l3bzUkaMT1(M+DZ~VXm;|25S9?<0zd!IlCI5feTTA3}oq9jp)*C_`iHu5@zns9K zeW`^SUwGW{RX+Pt5ZU9GIP=BBiX13QeM_ODe+@Tp_dG!bz-o!MHtc}xyFVp*?OFr1 z5fO+hKS|gxiDD}$^ZizGyZ1r8I#098@O&-Jc7NJ5EA;y|5G#BKQIy?oQ8YFv{I;A9 zv<1k`dmJeqgwvAgFl$ymkcTrrym1!|!{*m1mn?kDVAyRCg?&7$EeTTY&MP}pYi6!PzPo5I5wTHE3}c`Clx`u zi+t=4Me|n9cN_QE9ady!9m|6Bvc^8Jl0JSu--_Y*L80@SV1~P9jTXf7DNf(L1~0!L zMPbo+LTf8m62UVos{O5Y{<@1}gCHrkRecO0iyZ<_VlYL(d>;g9quDxpD!0XN3to7t zG&E!zdzW`Nd)^!4B}BL9%Q%$sGAi3(7#bd(5LK3=(via27+8Q=#y3rrYWY{R-jYVY*xFhkZV1C?pgPTWT>oVb z()*+jJ=oT&HQmO&-8}sqH4BsC2~Dq;$#NZQjxD?3XDRD8X~uzlW1BOf zM(6NP_$(5!SwMcdMgV;5V)yS82hn=A-W=~uz zJ?M%SK{;{`G#0)+0oOvJ0v^QoNou9yu15?S! z3QBUZ6K)uC2{+m@<}o2an`>dadQ_2~l(QaqJ8-=I;*#D_A{J{c)7wp|DB96Zyyobx?2NU6~(oWYl-> zewFfDgh4T1G}km^O|Q^Kw~CGvBG&>(!y;#%S1{Vu3qiuF#VOja)(;1 z-x^=2L;wk*a=gB|j>}c)gR?MEwPFhS%NX-4Qd)&uOFnb3MJy5+&vpRMk1B%Fb?7!V}1LnWg4oJ>v3P>%2h(F|BH95(outO_3{R-C(m;H9zH zSYW2{+vfNlFp?ihE<~!^_=&(lRw^V$0pYBf8rH<1QS|ikWQz_FWqiqMYHAug5-to` z`0+y~_EsU~u+*d6;sRqJm7M&tAIlB2ckzj z8F1!TC&%c}aWB6;Cd(X!U5UoZb&3xt2}z$gX2YQgpA`xm)fYd2!_sZF?yz8KT_>o z)0!|Jg+Pp1W_uShs@tW=qZN3N73ouNn(FpS2)_k)QQ>w*volBp`8CTLvvxG2Er~Zv zvRhfe|3*^er~B4VVl=-AyJ*Qb`3)FK%?KZsA92t^RD-j~`qT*pZ|ULJyPl&1TZy{? z%1p}aGliFSBRZ14)e;|%gL1Sf3q}-+dw2=|W(#^KhBI>{Amm)_yf4QX25LDldm)Y= zdB&J)`yg)XP4z9S!t|3e3%L>wAM--0IxN*yMfyB>X)F`mVcxdITM9+1(e-X@-8Asj zKnGUNR8d`Nsov??_Gb9H{#H;J57({656e`0nc0h?jiT&u(`guy(i= z{JTwAxxGSke%9J0h1Tkg@8U*QG=wS>g7TkiEMo9%0I`Ex)2P9ywY0Uxw5bW=9IVgZh&>y|o-tj?+v?*_OFX~6xq)#%9Z#l=l|*cTu#P({qMRi> z-Xs}@Om!jtJ@7O$kL2WTvHj72C;c-Snxp7k3E3Pwb)%ojfR8h3L z*ZfH3dIkDSJD>AdA7qpV9w;h^Qa^kb18ts2l9IHloF*8y+yRX-`D<$?p-GnH3YK0S zR#_G~1~Qt)G}SaL)t#EifyK_ZOgLHN_fpl-er~zL%GU*ioUc9jfpRfLN;aihwuCtr z7fE|Fkp4`WESBo!!qhFus=Bf7YRSKjHUT61_?aWcMh@u2@TL1qI|mo^$2wz+`x0;( z@m)dJKQNzqTL;W(w){UD6$2VHF$xzvUf%;&8bq~`p)mN6n<7E*TVdzE8f%MR7E0hB zC|wKTP6>Y7qo+IZxsA+}@@CBmr{!H;4NPE>;nulY`oe%Y*#6dL0G4ypEYR zpKxV{x(pW!W{3VRVJcBlD!ouuD}|f$AzG$~hf!dxXFV0_$HjCjuxkIXy7-Xn4L@Pe zOPX+GxegfaBaJTs*b{fxM}sS=(1f6HDuIm!R8X)J@W^c}U?il*QweP>7poTr1mrXj z^l4K%Eh-0}`J>^W=DQ$%pu`brybMIhW9mZX;rOc1gePG%82PgZQc@ag1zi*jLVT2; z=E6=z%7)p_d-I0BoMK%VUVly~DiJ4IeNJaCinN{^Eer`9kfm2m=mgOXHbo?pfxJfMg(16P=i@3uQZTs!%+!XnybA)Nv2YWOk~fNZN~5WW2!wlg1c{NoR`rK2 z@rW10`XOiONH%6HfPs(~> zvk!o7Z7S}B=L+L2s^nqM+$I|YOW{0B8+-Vxf4q)VoioBDw7!+(d|eCBFS2h`);N+yA-=75w*q{TD9tlt zQ(e`PSJYp;&)xvw>ycxU&6pS7w|kt97TxK#hUB;RIN9EWtyTQdx~!>?ucdDz$CjN$ z?xTKx`Y@{cO0|WbI@*-eTIHiz@Bi9{s~szz2o<@!?8W}f0cLTKC#S_hou-sxY4BfH zo5=Uo#3fW0gZ?JOf{)LGYk&k$eWZhN#c;>ms5gTBO3=b_#9(nu6Xiougb(t={CFx7 zFOY+lZ-jeE)nxbrP2vt9DDv#UsMkn=gvbZN_qGV8bKjA_Rn<=y1m z%1iefF7 zqy~kgY(C@Ks6mQ*8GXu-z}z0$u(T0heX_T%v%9-M-&%=j3*|S^noVV}Q*FFAaG~i` z9X&m+b_{>3M?odCFs4JwYH_5M$WWxdiIMC1Oh7U*cp6V;=xqt@CK-?=n<;WgE*ndc zvVOdzP|sF`At!_QNE28pgq2i`w72Obvw5t%rly)A*I}~ZDi_VnkpvGr{XN#+Jh3iP zGGcdg-OrZHZxU97C0>En@N>ui(DhAWNqli#1nhaQ`v8co=5_Rk0j!@*#gmFBn{tr$ zx`EV~+cG_IOtB8xm?}qa**GM#nB?&~&v1M@$Rv;BEAj1v;mNd~lr2=t!6EZ@?!f6W z>?6r9HZ;bsJTJ_bTuM162ILudH$=0-oJarM&el5yGUpVbHW>S!1z|l4Gu%x$4U-ff zNacaqf8y^14NMDIzY<3*RP{CXGxCt~mk~uzfXT(dT< zf0T&iuO>X)s{At)Nv?GqfN?`%b8e579wGPDsUkq)pwn$y^CXVWRx@3@R zA>gc=p~U=oJa4mgbj{M&WP@uH#QQaUowdx;yWf(pj4XWX^VfDX>;V&k-B=E{0J2U) zg1J*R0}cFetCMh z$Ct1M{oij-Zd2mf<#=nPOjw?O^mUed_O|iUj&Zxh&iKy=AiS<0};X6`q0 z@`5K#jsp<+>#|)v7pqBKzDUg-!?Ru0ozHWKL>3wyx!}LD z0{Oz@8ILSl6W?(Hxu6kp%k{aC)8;%8&w{mq5!d?P4@AB@Z>5Z8R%4}t>9i1~s#G|B zH~qjZ=c&`e7{$hkn>Q4AnkQvef0u0n8A}n?M-mRct+5S0z&~`BBEt&#yD* zvc>Z%KonG5L9B83_`_nsn@&R#wJh}Gcm%P9{_dCLF!LDt?2fxfXxNL9FXx}e;v^)c zdR@HHbE;Inqs>WBb5d=_PmRVG==M=vpm9FkTVU&sL)kF50rncY?N22_Z(;Pi#vEZM zNY**@Oos7oIrGS;*zN2D?FFwZmajH*j5gG`;=_D2!9_yj)|7mb9>y$B;}~f2LeLL# ziiq8~!tgQWw8Z0rR)&{*qU~?5T{*~Q@jqXP7dLEV`*-JsdtDUD-~!+UnyP+vx&!m3 z>6WpJ)yZ*v>5jd$8`m-WN;@2>?^4{8}xy9=e(^q8ut`P-Ts9u`gg zaw>zYwng^gNOvR!S!80oG)5wU8f<|cAu2UXU+1L{a&USa^Hs|FxLP%B6$=WMYUB3n z1fI}@6MoE{+$tGt(nfPUPYceEFE+p@)mQQDPruH`EHI)y_<8 zS~v#?$NjVAGnV9qY{P!$xNlIT6I&)ikI!PN+{i7Wg7P95yb4amI1#*ksYHk5CRoH! zX~$S}H3|jdmrBP>`92CQ%V@>}f9kdB$BQ!lqwjm-(v60T9MhSCvd(c5d9`1|vj=PFQyqcR3&6{o%$3&xkQ zI53Ndhu^5TM49dVqxl|gwAEM05G3(O#ZfA6r<0TKj>!!wYeixN7 zjvRR@L6R^|PN)ax<=K-3Tm5?qbkgg{;!E;_eB<*!!7n;}kypP?xB@y zyv;_iM2m`y5GJb9+2x0V(fOVq#u<}vz7nr2$U%MV~6QY*V*UBU?zMIw?NRdH6&5V%DfqBYPq|0=lH7qxrj)* zI$+b`Ilq(4VR1k{Bxqzwtxl70AL#5vwL43_aR6gf*r$F?+==~O24BT^GixsUMW zFr?J{5PUM6sv=Hyg-tRpG1nBTVmIn?>|x0{@(l83toS=(+ackl_Tsh%Irhb6!NI|G z(U;lvBn@V^3=V|=`W5$!J=PXSPC6`qfTU}^O!F)$&MB|cESPCboJX-&zcZ7n;X{&kfVPj zDCIVE!_Dh~CbzKG{Yt7-Q;_HjA?<6CF)yZnvajyPHMUwf3&+o*ENojX|L2#CoY$8t z)C+J*6N6hd!vD;wEk?4gjoQ^;&pa>jRwf&l8Ab!y7TOJs7(L|A&lIlS;w<{O0I>V@ z^K1DZk$`i1{8tRL6_aD?(%{R~4CUNri}_2^JkbToHXc&oW^Rm5_6DSZUTz7bhj>Un z2p#%MNmmm|xQb-|pN`;+eQv7%v;Z(?8hLt?1WGM&@ujIMBP#I~u#>MC4rAyCBdUoh zQ^bnpLP=mVV`cB&%3zR&vo66tzQ!DXkiuaUBqJatZZTS*Xf|D)UndRP6u0!fE?{|I z<6;>c#A)C~wh&DxRj0=Uac}i$-ZXsNqTNi4X}iaL4U}3t`@<*XN@aaiC9z-x{X!fk zq$>RoH*6W~{Fhjh-9m*2Cq!jKIqJ$D^V2S*{Hy6TO>c^tOLJ7FoSM(^`)}$Fb(&uP zbioSTzTA5ho#u$H*~mPsy{NmZq7Bk+0++%1#$QZENbq~TlmqkVNIWI3Nx6%M_T~E5 z`#KE{)s_V#C9-25 z7}+rp``NxJG5KHqeCMaUwC=eU{%%xkERkkU^-F6@P-uPsnT{!QdTc}YW3s%L<|C4l zQTVJn#QksRW>9wl!o-!V zj}gJ*hg!Fa3ed-u!CfOskwi)y>Y-y@&o%a_a7Uje7<{1X$4d4gL7HHdH@p0&_%GG7 zOKSwgI@_dR^2Bve{VrfQe9)7D!j$HU9QPw5n@L~wb{avnmF>N2oQXPe<4SA`!KLnG z>%vzQZ#^$KGt@$4%>F9ds&c#V_?h0SeWf@Hgr*PEXokLOSs%aP#eMyGqO$kmDDO*f zTk=l+zTjp#4DIZMK4{jowe7e!7xPpeWuQ0{rPE22{}*t-sV3;XfzI=;($|f7@?Z(znrv~~ z#sVX^GN`{DDgKHXr8dZX2TB2nv19hPUjn?8RC(|(-qSbuZ8pXQ?v14mgGO>od6J6 zbYki)%f@b?|C_y+P+^)1ZS$>lL4A_mRk%r+`DgVNC|I z=9hpI;_W6)@Arf2T61C0oSYm+GSdpZ<^X9qz!-g_8j|y1e^BJE$O|9ll0q#n7z#C(6bty<Jh1dM5u z>vxZQIF_+V)46ALzAk@t0LnzI=Jz7!67lZl*80e{YSh1S64-uZ_4QRW3Mig@@8e*q>fa)8g%r%2)yQ;$P5Ay(u`C%=_^ zT+0~}*ll@6C8z8|H4QTmAsn_2ULZA`MB%;yc$)D^Ak(OXNNG9; zcz^hn9tva>zQY)$(C<#l5m5%msNtDMIMnj|&bwCiZ35nG@708Au3bZ#BF2mn|>OiTe`7}cj^v6Ce{o*A-Z1z z{(euki1@fL6RQ?CtZcC4yW{wReki%y7F*BWdj7$MM?ALcw2kZ6uNE8$9eQ zrW}@gDa0vIkkhH)cCyP}W>GKji1H0SzI^GKXZNV4l%GM-NXC?s;(unoi24v$-k9#O zug25GcL;pBWLBe%p`2pkc1I#y`BG}#gA2NIr$1H^soV>0bmk1;zo^lh_E$PW$b?Bu zm}co0nUkK%p!>pFoxZ1vNaf}AD_UFP+Xkba&7Ifp$uhs!#VA-bv|uUlGE6|u#RUU{ z^1Pf~ZcZj&hcSmy;KMW@`FFLG)bDRqw0_1{zMtW8x-(OVJ!o6MO8B}n9rtkr^94W( z60ypkG)-3MzX1HY#@|9>lXm=fYvEb<9moWctvg?5G>|cJUnEy=0fT2H8Ag@zPzlK~ z6UFsMLhTv$j4(vcW!A*VGaoH5Xi!^*;rfa{4j($QEsyoH`Z>$&F+Kbh6v`!q^b|HawB#rU66t|`6@8g5c99ee&N?2NqOE@YTgC-;i%h# zxj)zuD+e@^;Ex2JG(Dr1uZg5ney)y78!dE8!!OmK9BFda99NL0#E$`v*)C4=`)WRn z9XE%D;F)B@8^E~gS zaV5&-o=qSTzDZnl;Kwc3 zL*B)Q$EnNf)$7WDfKLthdrbTnXEc~S+3&?npyETlv7nUB5bhuTv^ywegf&nJKPj$U z-d?T<1~1lZ$JYkPp;zq>yGSuf@LL8`gm>S8dA_TcyDb{&xSIo|#rN+LImL23B)Ury zsKdd*Q7sv>R(Jc{QCT8RKsgJI?{sZ<;tBt5wCb$ws0@0h_xrIsPVrR)LUG6wtu z7>!bS>#hBq)&_QGh$sKsWqmEyTl&2*y=P@dlaxSd97(QwWzddjBW62H)~O}Du!+9R z7x~Ud?CgjOuyh=zDsviJ{+7z0w@o&fgylfT2D&1efapDq^zBkzB1s$!!gNyo0~L! z1gu)=Dy=9uZt;{@MhwXSz|#7}u`^)pFT5~`g7MQR@ZU4AD~SM5R&ZdozUuu|W6qbL z0;2YYP&XJ(EiBok#JlJY4y!|o$|KXFOoz33Pyx+z7>^-vKuluZA26n$i5UaHwX8#Gs~D2^G5}=De(SGP z6mULu;ON5N%vH+OPa@Dc^zquPRF}8wLVq;XB`mO)W7dWqb$xkCPd3L8Q(EgHCiWqJ zM9jc{gtq<;p9Jnyw_O+j$)BA$6TCE#&+1xxFg4!jVh<^`4Z&w-XfCA^&iUM)OBDKz zlqz^&vBhQm3j|s{=)%~jWXrB+^m1Qj5}L-)Q3-2=4f*1*2J0?PX&Qc7!u?2PLo^n- z4nH*Nc7@{C@M2~79L16Q8QQfCE>V%L3wKB5UVER99Nd%o3 zgkLn%qUg@ltu>zPTYdbtCHED zK~i>S1CRD>bklzBinM9<=d}px%ymY5{*3K7JQNFMc&DVVUk_E3XdvkWZwtc8mNWRja_$f%thZT7UoMV9mQn3#X7yfftiSLIG=jpv-2|{NJ zDABKj$&V#2VLZP~N$OpIN1K!``*#5NVLs!5W5&OGPv&Dq%QR2VoY$P?e*X*;iJNoW}H6{k`iBa zytqAfNiX7tZX(J;rHMxF@qGb9qT z4R&yu*?l^!#m#4EopGA?uqbNmO7 zGZwJ>R})-tm?Z8(LJIH{Dl{?xcs)9ckBIYb-AA+wwNDs7g*4c`XO0E`<^*StE`}ZR zMG%=lCVNQuptI%2NQ15CFxzNH)NBY9R`#KZ-oL`wTaj$%F^E~t-8~ujG)9HMwMbU z2LD1wR`0~6j5?_>SBYcx*~$&&M)> zWgyEq@zlUU=Jw`V%{9MLw=%XJV>{EowS+Nv5338^{=1Kh5b$x;&^wBDQ8-likiH1)E1L~_AqpH7AlH=H zEQxMX=1YUo(j$Fms?v5*Y&`MWoNVlVS@VbF7oE6rT1=^=nmBT``jo*H&FEu89Js&c z+;-LO9Rx!c=lOmUU`;Rvmnz&-5#|VN9Y=ajtE(C5dx}ddLtNE<1Y>+Ie;Q2-^7rO1 zD&0--A{L#vp`#l~pK7m98rOs!KIju5hdu$mHe;5)`~IJ(wpR)4kr9Ju{7ecudyCMR zH6V*fQ}i1Dg1`eDFH%^pi02En1E~#7{$zEf;;m{%0Xt^$UeQ1$hw|hAZb4RY)-LuH zUoMzS0rqPO8bb^o4`H_8Qrc1Cb9+?|6Tvg0&)>~@Okt(@h#-1bBZ{g6_cW}!( zM`JK^B|(1GWEMzMn9?>y0j9z2b*sJ#=1d!Ii+U9trEl;~@)^fLr!xnWkE1J0F60A7 zh9E=XKmyN@^23m$_iS((SEUmnDR5??Gbgj%z46cRfewvZiu7xABw+~n*c3j=0o=a< z;~ns^ysa|u^j(a-Z-$hC`@;pgO|H!;!~G9{iF0bp!Kf&69e?_OQgHUaJU~1KHpPcD zvNmsTYPYta#~XJ>^oZ)}zTcxK@Y|FY!`tTha4s!wf!9sk3~`w!qg{ibe39?FzIfJ- z)+LBZjgP50>BA)rTlO0%E}U$WCXj4@T19kMRFe0+UoA(0A{xw}!(f~9a(_a6zvMEg z$lFTlbtmrI_2DJ1=mV?aTyyVWnF+#HkhEjyuYMiE4cc+ZgG4#Q@x|8< zyTcDl!-n~?199AJP+j)>mMxmpw`nz@@~<(G`cc7U!smn?8iI<0^Tm3O+AE9fZwGWS zws0Y*kZ_uM?tlhq0v$tNk9b%AyU8xt{T!s+R=J~ZNq`*sBOR?y?F#Xnvr&%_iG>jJ zTJV2qSA2(-HGU+Yw%r*B@W-?X`Aq&??O~XuM#KHaD~j{+@Ricy4AgfJ-?>oZV7q31PL)n5!~1Z~sPew$`LKx)ma}j& z5TeU?c|ZSrj41|NeO29tiAwfmZ7Mj83Q(g#?v5_U{%Z}^6+}8C9HdzBF7A5+pZT_*sKmJY*RhF>!m9%E@E#B}e&UzCJ70>;b z7m*qV=-GE@Y>#@WHY&pz_ zaXHQ!Nat!ZsS#~P(9zN9gXRO%RHq&r%igdMWwjeK=5EQRZMn`ne~7l1bAyxZ{*{Y6 zuU%-Qo&UC|G+6gzhkB?cA`5zq>ihmBIO#Hs%m>Tl$OHEj!i_-W#I#Ll?{_(kIM*=f z7ao-PBAt(CI4XsIlOgalEalw~TL27d;{^rD;dFRGOq?vODbATo2cP@iNzmEpynU_1 zK25owZ>q#Jdgc0z90mlaz5NbB{fMV?m6YBwUQpiC$PqbYgIy{IU4#LEaS$K`865q( z`#}Sp7m=KW+C>vHf%PF=Twp}Dg-(2ajfIKdc;AwHS4D2G3bur= z0MEI_DOG_kZn(ZP55VgQ)!(XAys{~Shhy7{B2st4f3rrgUm_O3`$oKeJ33^CiiSm5 zh>AP$#K7Xd7b54_NkRxEb~qK_TrSdWCS_)UwTIH}iR{mu-Nd5w#xs8pKM5Kcd~AEB zL<7I{HN2gh$N6cB9Z1+fq$sFP82=Qhj7p)q;#iNctdpep!E5GiLOJxOOzB*Yp6v9T zkp!u+P}KRp7sdlwOiB}NHO9XOh`ncn@KrTloqS~IbWfh-o8e}c=ufc_?i`=WflP-| zHtrdL@O3F6il3L!pMW|Xb%*U8GxwH2;OEcep_O{UCtk?*rt;!e4!m>bU_@HIB!on} zUWr>Z|20a5kY@CeU;uRA%L}dGV>7l~@6&8Ix2?-Vp5TOL^q=LkxP{KfvyUEwTY-{D z_XP9rs#FlG$}{YQx}hV;E)@Ll#6e#UhlIp@D0Q7Z5F8pc+nF;axAoa0lus`!=VXA@ zpk9zh{=GXG@XaeR;su|S>rhL`++~lAe^@|kUW+0P4HG4ns=V${;qtwb<#Qsv7ezTK zcx9a}I*ec{eMRSkO0N=yA5HcNf$TVoud5SUaKTRL3DUm%Z>PKvYibGWY*)nv?9m>e z)~h;IM4#+T6P;ls&jW%bdq84(%6)r;a)Fhk|KDbPor|WJK1l)?DNPFV7UJy`)a;k! zX%>-{yQCipw+mZf)Zvhub;Yu;bI7~J&QC8aE@M*Ed}WGF(+bS4-V`8Bzg)B zdKC<$UGUQP2NB-w??Q^fU8douO+^O*3EF>QBY6Wi*og+~n?ji%jdoxsNNoP>gdHkS zu73oo^U!cP^h_{^<<&khcWJC_-T2Y5`c*I!`E5w zh997Ht2O@Qx$|BsuI$%Xw%xU^Kp1@1DAVpavnEgnHiCxLQg*H!ZBj^4cw^;b*tpZ+52K! zL;^KV&P(F=$8BgLGKsa{JLak!@Fg?5eXCXjy#X>=66rr?Q)C#_66n^nDV^`@R$Il( zHjL-KqCMK4=6OuW0L&)OU=jz;bhWkkyr|)y8WLDumK+fnJx^S{Exh{A@*a!W8IJx) z^IxOz)0npoH^~r($~+SzX_Z191-1xR^5J?=NsLE6jXWlPeg8o|@vDQyF9Z44W5(|r z2m2E(U5e&NNh{=Zo$!p(rfV+~?Mq);2r0f1Zeq2muDiVIY-tTl;*m7hTmJF}=Lt=W z^R(ST-MP0u5aa5sgS1n^FJo`E3!9R_kAIg59wbT=^W(!Sw0$J+ zBSN`a-?EN-`s+D|(fV&S7Sm(Yt9FLurOzN0xUy3drQan;?>_bpx3oro@^H7|lf~(e z$WXb?h&wY3<#d1V=(;$<(;~Bs{gBfK3d~Gj=xpX1Wf}TXgj<^MjP}eBkEOL|tlMxk zw8%e{?9G2y^+wQ%Fl<(jIKfN!H`wllU|RNCJ*R;bL6UHfrG5BvjTM)8C$5&=SAA~W zUPa#-^uJgD^fKR-3c}%~^JieuP9saY8kO!ECeZn4j1qNJUAG^W_U72Bs3x}$+#7Mu z6xTP;6wTd~LGK7RH9;Z<^2cR8rT{K0;?ZYgOg9X9P&vYY1T&dWR~I$;T=xC5=FY9W zqt-aP z<-NBVlGVgVr+tzB!TutX{^+1l-o6?40u4+34WHBcQdJSBGzpSTb9Tw=YO}N-8pX;G zV;>auOV^Hz<6MoJotMjvvz3e&C%a*w6jEXj`zZ`YRrCN{nDx}NmE+Rc3o1|B=T5ID zcO0obNA-u21=lD^0+7nezS3OVFrQ|-U?lITE$bGjHE?%_P=!r*w2vlzC=fLBY$lmB zn3GdKjmi0#_0DG9Q?KN8H0M=BmYj;CO?`Qo)yhzU>AJ4`HtFc>qEMVtCoYvpG_}Wz zh0!dzeE&<(F;nA|0XenDvv8_lCh+{|2fpwTy1m;GQQeaM>xXGUqta^`2#V}27aN9F z=sZvmM%G}}lhl?+Rd>I^ph;g+I{nx!l#qB_{tFlRMmiPLxIP0U_=9I}Hhi%-EWg5n z6K*heSTrY!C_vg17j$I-M;ru&0Ix@hi+!c_*b^D@5kI&9wB|)=>DwPk0KeZ%Qgp^w zbT7TQ6Zg=dP)M8&misyD+SvFOCOCXT7lYa#D$Q2rd-xzQ=!KQG_9uV3BYFvJOk~Nl z?&-sCdY!l|2gF@C8mG3ePu|FOMpO4BA^|J}@6^hi~MjE+yiy~#uvvFqq>(GvWzM2Y|Io2caEDl{Y@5DFoNgsi$<%N9W zSIuJGrobF3W*?q}8qu-S>72Lt>rYUxB3pxHGT2>aY=cvp(X+m)+nQ(k16S8@R4v4RyL!O{R~vN1RTP ztE0pEcbS(3^K`2%BzdXx!4vnq1UiGsc-i~Jmz`H?8f$FkLn}-N=_H!t<}p_nO$uUI z#$msiRa-k{B0(c)n{>EEc!%W`a+BMMZ-W_{V)dGjN@&qG7o~1F+t~Zta>s)5A>NjF z;rPKX&s99!j(Wovz5pu87wBjkRNeMUyY4Ncr^zSsp4q8y_FBh|bEWLR*)WY=n^tn~ z941^OyT9buZmP2u`uwh@Wx!^l!L4BKQCTN}n?|!vquDE5vSr=x_kK>-ylF6JJ0O#^ zo(W2;nF|~q9?rb%9T=Z|eSCegB*#Y0%1xL!R9mBip(`2Az`-%{#wg$?){0wqt>i_M zbU2MI#5I0WOd-b2i`I zP_AxA`C4jCG1s+G5^Fdc(*ok6+cPFw7MwD==hqLtSUNARF@AXdBGYbuNBz_9=TpB_ zcW-*7Ea?}j&6ioKb>E==wPc;-82UG_snYisqVl4hIf`P1#JQx<_LTPb(q72q=?&N@ zC3N9Z>TdWLR)?TtDdLI`gd=zRh)<QI6;auENrXuX=>R}cYz0b0<_3WwR zv3l)KPmOyK0)^wq*wZTR3rKr_H6F9Y{$qNvG}=LK&fR>)##3(I!UzNFT+eIyA;d!S zr{3IX?YS(JqkOV?!mX|Mva>I81Ikmw*brO1(lX2+4M)n-c8_2aYtWr3fH2DPPG>1c0R=HbNnn?sL zqFisx+N1!d-*r|nLHoRWT=%IYXZD64jL#9bj4nx}pOcZkm5Noq!SVN30uYh{GCLzx zXPO`)uiO1!v41<@B4+JmI`x8LPv+hPp~p`Xwo-gVEOz&jU+2@I4Mz5PZO`egGb zX7Z^{_50UplotdUoFon;f?#ibda6703>K?Be>hnbWa+%(9J+SS6H`=Wfb}T5&Gv41 zClc(6AyW>CZI{hs@+fFfM1F9M4Hq%LYo-tdf;BB zIxVk*2Rbu6z@)7uWJWQWck$$#%b{fHhNaCbjADymVv<^1Iqh+1nunx!A32i#oF4GA z#6KHcGv*_8Bm$x%dj%B!EHQRQ2+z3KGs!d zVM@3^K$B-zG&gGN;yUPt>({sqsjyKw@?l0H1#cB;*jRQK6{t9Yyk(+Di}vZ$Q8&Lk zmej!M`6=9*(VgsGqX=F;toV~n@ghq-q7ccbA1`$s(?vYgX~p;9lEF`Y?)N_tmjp;! z`s8viT&mFC6rGOcblZ~wb1u2Q(cgRLH4~3|-OXYiu%Nn&>9_T$uxENmw1ksdMA@C!}u^et_4i5%UJBl=czt3ne~ThTRcP&o3W+3}61+w3e-c|pfWr9Wf^ zUosg}SnIe-t*kTgt*n$ZEC8BT5*&_=k2@eS`R%eF_{WU6h5VSn=a2{mmtW>7oR$+% z&3wLPUGD6x(fLzOwvXt6{=vC#>2>P?#+9QpnjuxCpW~XC%`hs4zwE;{=s19bA@7KW z!SO{v5jf6cc|E=(%D^>AU4q#LJRQL;FfIZ7{CGuoZxW7T9*$ga9!AxzjzkRcsn7oT z${V(L56g2ubu=#gGCH5u{>mK;AYx|-1oWA{WP-MoXaI1Mo7O2nY6tgpS0b$0Td%*H zj{vmzk;7WsP%ihU4i!PY{uaHlt1nPld?C_h3}>32JVe*>6RY68%-oU|PS3682HUG2 z;dJ(IS1=`Rsh9V)eHNs7HrPC|`A5S<+7t8q1I-?4w}b*LUYbMQ<*>;XVGF(-qD97Y z6U&_Bdvlm_`+mMZfu{s{qQ*w=XlIt6q>+&2b?$8gRJ)d4Q_nC1AT1D5P^@Y8^370g zrycDmR-KD%ZhD2pRsh`y1wsY|LjXvV*SrG$CTn!QGbj=D360I~0*I{gG|<9H1Ct$w zPgVdl;F{YzA^w97^!#X26xJxn{nFDp&7CfpZF+o^Sbfaah5x&J9xVMMhJDd}Lsuf! zRxKlpE?mXDECC8h3z0diebX{=sT^CD3)?ZBZ}W~w zw{{&;R+|wk5|$Vk0S~CE9NCM{Ye*WfWWfV+nv9NJjMndI@^Jz?Nb$HIe*)cVruJ3V zEEDLLUkphivU0}HFrs;wM)I&gk}Gi;@+fWUi_@}!7DT`I>6=qv=$ zt`4PW+zS5$-Ko*jfj5%Z(AkIV}2JaVo=z zRu^w`h~tNboL`BLzyqqspf@n%<$`x@(3n!8zO&=qpC76vq61Qr449*Sq@6p${j(mYCGSB1dgWx^Ro9BZZ4DrnFQ)6u>8e z5vya`vv$9n)pq-6DPs<5;Oo8ve&x-;zGhL#BK};(Gk|qh?DQ1$4de3HvTo2d+)6D34&SF$f8!k*&-CE+(sEg9zpLwyG2%L1lhZB&Y6C8O3{ zx2bqj?__QyYXqNV&ND>!&v|Jj&Ru4l`n$%-XL4tV$#aEYd5b*o075(giTA^vP9HFZ$J+hX%EW^> z05PQp1xsK-9m-K3AQnm}H}Hy~+bjYx;~@ZYF%OV2@`R9O!&5C%Ay`)gvj!W@+n*h0 zmsiwsiH%(Kf>P9-LWj@FP5O3<%#}xs4AXQxKCcz)H#8vcs$0aw1xas~A#%MXPVK_o zRupZWy{nVb(oE!@olzVT@3_0D8r5sPcoY%_Lcg97Jy$RfchtDM8FF{KY5XobrL-&m zdj~cBxh+eZuC{NC6`U#GGZ)j@*63|MHtR?z$MICx*;d`m#O(wftGhGC5PPHrrI5`J zpQ3TIC zFeN_R1$B;znzksr4%@RmQHRZEz5mrSwyr&R=DZ4!)C;E<5fy^(c&YP-869>jN5cMF z&(Kc=VT{K<=b}RyeppMtO{rAEIfl5*f;BcKMVxn?XEe{OI~^?jzBndJMDlFA^#8z$ zVSa*+B(dPr`u76*)$53h=nw3m9F0iG=BHn zzXUuI74|wgpRCol(cA-Ow+m@6_WLJ_WV)IK10VvbPBrhG_&X9xn$9DZ%ZZ<3eK{7= z&!^4M&w7(y;=;R?-`YTkor{*kI*wQ z$=n(7o3Hdq@tbok;5!`M~VSOw_-jsDerWuP+@Xr@msbu zgul$!l6@B|i78Ah^W1a968+)Bm+C#@)9Xbh7}G8X4HN*v!2Wi!I^MF4c=y@29RTEP zt=(VVAJ@=pV@Qs>PL+TkzRn&>%u6QGp`{Ti^W~DuS1So+P2#INiZ6Ki##1OhMfhrJ z>)CSi{*#!vyEu|W>zC0(<+W$dj=LORdbCl#P9u`3gC|?{Zl1>9PbHvz3BA5#?8KaaS3<~}0u%cGJYP>sgkm$QG0U;XA*{P&CUm4@JC(-8dHFA5M_UG1s6r61+Z%`XpV72@o5# zPBY`*_r4DWqBHyg=!4QQ@VHLO9JZuEg*#;vkt#%ja@4}veg-INn*Qe@J*TTsDCWHG zrg$}s+f@g(G18XWU#fR@`O*TkpMNl^FMv5_THU!<<+$ks_%HsUPP zK$BOIAp11NWPoz_H5G)oD+1+qOJtW#aB`Nfl#Dbc6O+KABA; z1Wfs%!9AoyA=&w4ef ztoVf!V`OARe~q!Dp~mWGx{&>8b;OPFtX4y%SWIt3C9Z?+ZCLk%?!M(7oH_mpm+qi0 z1f=jusbvq_+UN?4d$mR6Z=um)7u4=B|1LkD^yoV`CvM()4a-HJ6w}pml5<~gW2`=x zgAyW})0O^PiR`lhNgoaws4E%bIBO7V2SB=|2_`IND$OHh!;BkYHNNY$u%U2AdFS}9 zhlV=1H@5?4w@|}y7pLv3B5N7;K*;=dLXeDb^?~!f?HlqR$LqHVs`u};?;LxMad{a% zpk$uvbbDdG7=tJhKi_(+uQ2k9Q75719CQ#QrGN@ebIBEj|L!WtU z<}&wp>o-}aJ6a{6cE1SaBS-M0u-fw?=|`$2LuQiRg6<7=9IEGR_1#%M?w)iuFU*0v zXy^IWRx_!}*1zinA1pR4D-}BTr9M7*myt74u0uyz^opNhf;k8uSrYjM#E`fBpiN3Q ze!`B40}$hN2l>$eW+AL7F@1dd?ME^fB@wH3TvT~b71Eg(FDCO!>;%AgUs|?xGvpf` z6+e&!wznywAl7^P@O>lXeM9~o`M%?RtA)i=?_uO*!I+482+6ivyx!lAsxV@2sG1@M zxdCWcVW=hxqyisVb?Wu^7TRR*AV$q9a|VP$M^Z?-a4OD=QSoo?r2_ztEHqxg6=Z-qn%OQ`x?@$);-UMg4$@&Bn-cvE#{x7mJ;yToP4b?n<&T2 z7v5J*ty7zzMQz00lPc?EU?3*a$0uqe&@>h12ha%9&{3Y8snLyI`WeZMUQWUzL{x;VPccta`Z>EPt^Y8P4e2elC#`OL} z#?WcNagT+|G`pW$fARV74GeV~7kKb|5*gzz5lgSY(eMieC%KrMtCP&;S;8Vx6x@0Tb8;FE(FK_nNK#WZpt-Q3b@ay>ROJbcKg%Xc6i zW)SRJ=Xvca;;f2TIJxlCN2ZD?lB<;0|BF^vQYH;Wb`|1I&-oDP`@Fn9XzjsxoxbG1 z98KL%5Di*un%rCtF)3227_1QO7a^onMxZfl;_Ec@2#FmL5! zo6K1_DeDA#-VgFxNbIV|SlNXDsHRA9BXD8=e$HoE(6G$J)qd=Fs9qs16~q2A<`GiK z>#BU&GzscVuiG1@kA!TJZtNE?hRE7S%3e)j%|bU52P=Hc=VL=dMt!>*m9ruF8M?P^VGKPbeQk+ z3R-9T8~U1!X~PaBQ4sk`0EssVeMbGq>n7Ysy!CBDzD5rn`K#;JD$5q9UnZp6+}@yL zV8|S<%m7uDdMQb`h!qq}pIv+>Kv*35Z)+rMd(`Ul1JLpR7lH6Ez!CG83BFw1&_vv( z`fA(w2w8nL`g5gJScORl6MJv_Zg(@w>q@109;w8*!!3*CM8Bo42pE2ISJ_nc8#k42 zxSrY5fP?56wp4N#@P*>3u>b-$9-F z9~QAWZJ-Cq^ZjY~zqbKK2VSj&lJ`vTdR3z9D=5c=A_!^nlI5Fb4gIk0c-8F^lSE#K zX6Asu?y0DM?KBVnzCb(xJByy5WUyFmj=#YcFNn#)^d>_%-y{*_Hl~;>SF49;UY5HREpp!^7!q1O^@RLZfEwgH+?+J* zU1%fb@f5Jey03EEv+-|dE5DmlDw*B&SJTS3GLLEQ@A*1f*XJHXlmtl;adC0ba^L0d zhvd242K<9<1;#;yXRhN+$GZ@@SQ@B}?!blK#r6oT)Y37}d}kYw619RifI(-5*I2?o z2=CH5rT={sw4fbrB%J%2PN6aGAjt8h1O9_aurqjq@!SbL4>rRP)t49I4pV3mbKnh~ zae41f7Pi;ol*Ms-ebk#~?7mKEjUT&x%=!db*t114YbHWfg=aUkdY4ClJh;eHT$1BGnlv7v$WF&gIC)8Hs495PT(;#m) z>jWS|nW(z{4`$(!up77mdkTxIR{i*a(kRTI$Jg?#t8=@yOB7JD2A zI-$7obMIOv^a4LOti#xMS3ht%d&Kn-edRTZ5@nmd-^n69F(n1A5ReAa#QFZk0PMVN z1BDap-oGs&D*}(@xWMs(vy!7hKY8bbAXDIESrGu;l4tWC*{mvT})h z`KPJXY8LKsc>*c0fd>t1YWZdFljQZ2T&v%kFfj1eXczz~X6j80?s`ZCbNA7LYa)Dft z9o*#`e?s`eksMu^o7Z4aRjsqBFJU*$-s$|hHL!rM#uZOdK$GZL&vjhZ9&j>{1Hn%r z{T@_hd-9_T_XEGjK5_Q-cccWYEU4>Ao7m98wrq!;*2z`Oc|#yHt(U{~I|Jl{8BT|m z4z{N?Ky4b5ek3)Vjp+49cL?!UvIl>O1CuF;h(#=flq-TDr>4=_oZ+vHMFy`7DiZPJ zmL8jeA_OS;<(ox-(jo>a6@s|I2QYYr6@ZV^+J~xM1pDCfOt`&vJt(m;_Hdp6;G}>-sw1Z7`o#L}?2Bvg#?ISq`CO{&J8j?qpsYw-Ja#USHc#_oLzGV-55A znXJW8cmQPw%rDcZ59r1~*Z=L1%d|hdwA%vR$votPS+}Fl_}YZ0nVfk4cx3M=5M9vy z9{5n+1El&93gW-MC1_wk&JVS`+#n3zS#~3!HhGU1uEhD&bTF7Z$xZXWGmqATcDK8X zahS+{Shm74@-bDU@B?XP<79S$fS=0Zn*Y0RN>vwl&Sm>rd21{>zV9nAwl4YcgtQ|y zSw4J_nM)khReW7IbkwqZv3Wa&dB%17<|<+Na>+hp)>Yw9yFMf-#eQIU)fyerI5k=O zDZS=i4J`fnt=$s+mOwLM!0lG+`u(2oH8%JA!rJTl)8v~s7?Nw0^J^0L%f#;!BUlMs zQqEO! zV4xV#(`l@ZMKk{=Q~N~KeJTp%b4Th`6#o|rFC8lGt+)Rq<}z;v+aT_9`=dfdBJ#>@ zr;uqu7xbS{?Ba#=RB9gWrYnzcn4QV0qzpmu(2>08_Wnf!RX6?E4U@x9fMWjz1c}L6 zzDy+5guih#w*>xaF<0LESod%xSEHpFSGw~3!EwHtg>n*~9Fh9uc8$$+w$O44h}iR* zyB%JCyicUw-d~xgGS0hF4+$m>Amhb0><;Ph!4gE(0D^yJQDA&#u3cjmrW_gLI?KPW zqfHjTVDyh0KRW8wJIDmk0h(_+Xw|-|D?e2-x!)m+cOSUWvODWuFF*B2EXHVAixz|m z{tp>Nl)}Xu3(U_8U#*(IJTH-zL|qZTWorCVQ|Bb9TyztEvJymq{@ z&Q~H2+W!G}XytJgkxbbF%$5{AWw5lwZ0bVWUG%lY1FAJUBaZgwzS-C28^U8D_;P4R zyKIcLTgXhye*k6(2z~F0?LUZ3z&k~;m?#WLd*O$f<$yqvJ1%s<-4K%zs2tX;|EmG?axx!0MKnouKp2)8_2#bUzY5{|}L(Er71hjYqVzwLp_k4y3J%er3S-U=SJR>)4D0v8~&6BNGpm#1nnOLWFojj zxH+y0TZI4E6(s7<`{2^_Ia8f|ybI(300QwJfF`5gAjvj@IpU@-eM^bnw9Q>-Ae$ugu6DAti3yyd?n79$4m=}+-nyyPh`YSB{O8?=@NgjUQ>qEXErq5z%aO?BdUE=$5W z=jDgSpF(*xYpf|GL&*ccB#Qjqp~UAZ$2q;D4Nkk6)jIVKNMrWZ7UP(pMsc@iG&~2; z@YP#{_ecd@D>wRkWB#a#SC;Sb7ZxK({>$1mmB7}lpJ!^W2 z8bL5}=MH<2$OoP|Y?Y7-FQC-@e9yXo^*@w|{@@YDkFqz<;7dKch?}y-RPM_qf3^yA z3SdD0#`XCJqPq`u@iiPcF{eXFo=FDK0UV4x;m0M$a`98sD9pRK6VhqC9p^v_EEq@_ z(rfW$$T10@2bS3JT3pq}q5tZ4y7#4KV~e4o3--^h|9_#;Ry8}{^HlP=`@n3WXeH%l zcbDna&q!kc#E-lAKmuAeC=0;H^muVxj?j~>8E-(fQqcXRXypVHOd>hj5hM-fvQFYx zxC8`Q%J#4H&F32H0>!x`vz>9}HYSP^pylFh+M~9Rd1PFQ^++u-G_Z@0dx6ct{Y54c z!}hNQ7km5=yfzsWfgDF202y_ys02er*1&}2u>mY5H?W8uYZvCpzqN~%Un6IRL)J%> zdufrei+Iy=guUtM(hOFmCg^NB3BI@QG%7lwG3=vJ>HaPc3~vGFF%<|=T0ByOV|dN8 zpBTDLJOxwK`)XQ0-w0y6PQNi5;clmQdx2OPxU7hL=EKNlxyj$ldV(;Zl%bx&nD(EZ z)$m$IelO4%+TDKm4h1Z%83hiyB0Us$N49(v{&%`lojB4nEkuGBj~o-Xo4MoQ0V=Ek zP??>)G!(bIFLo@`PWFNKyA=NUiFw3h*wvv!`_GU6Ay{p3Se@?70@DL{8nO-1!d{+k zXfdzc5teGx`QFKT;D@`ro%}2|zm{WxHTQ0%V+Wp#T*w`J_TmAk17;hjfm#KUE}GLd zdoxzK$+y6XBv+GC$kQgo&3$Z(NIiI)qoV&}o~033`AnXDheR8|HAD~{LV=7wD}`xH z-snHSiF!}cQM8T!{&Er_#sA!ACdUigtFu=CZ*7nGR9Gjj9^T398STBE+_vd=>)Q=7 zMn;J56#0yR1^1G(_WvcC>m#<>%6FaZ1u!)K`fX+N8gQex*s=pC2rG0PTO=2=(W8hyzVZ3kJLYueJ*e@$N*DsRW$a zfi84R_ zBN0f@qqSSzl=p3Z_0;fYh46>tQ9u6A*EBXS8?P34e_^r2r+bdVKxi?KL;S2znE3fm zYMLphNYvq8kX?ytJD|R<4ig8_7qXeLcNIPk5=wtcIVrduS3V$>x>>weCiXWh!9#>4 z;wAA`3i9#*fE~-Gr!FzGTeq5g2c~Ar)`kI>S>gA%e0N&qBghf}@o;kxDO-Zi4m4nA;t@nPx@~i} zQnsDfZF&0ayhO9ws_@>9m46Loyk$S^sXzF>JbHF$H4m7#u_x-clSf4-;%0qojJK5D z5iaP4PIKA0JQrb%;*jNCQ^>gdX>o7SXKlh%X@NySoepumdLM&D7-2idtN%Gckvj;x z>8$|fhhzjkVbrOw#Fc&f`|IykHUGY3V0$8v%jn8kWTP1UCKEhUKko>g5OjO3HYb1m zN!vPv_kJuup_}`a+c+TI!QPNfYdYU^A z)Kl90aS82gKyrkpNj-M)<$Yvl1SsVjfH6hYkLwNx`N1B%nkl#?bbRrQtO$7rS`|*9RhbT{!%usj#y{xwQ;2X#nib0EmF)j0tn@52X;*H4y2C2;J z$&KO`wX#enXXdDay~nyPn;yWf<1V+jb(hPi)5`HK@JAD+i>0lygt0sXx1lt)wKbQO z!P(THaV*^jEX}ME)EAXU=)i?nx!XpfMiX0sF_6snsKLqlMaqv?gS!5(e}axZT5w;_ z!k=~G|Fcf8p^p%P87cfw1nI+MKnajOHYZQ*ExQC75J9P6d8WoBmeS%)pxMES8|-wI z7&_gu<a6#ziR*Z!hSMgUonbPfZ03LHeTaubbNS-0 zQN)%;(Akyc279i-@S_k=$a@Th$|K4hau-xow5J7&ASPKvn(q!FW2rFSUx~_I-1wOV z+3_mHC2iSfZJrXumh;*+b`fIv66HGBpnqVLx8q!G75uv_D-A661lNCkjuLVdl(3U;U$gv!LPffyC?&*I1pr~HXNZOHP+3g>e+;OU25k6r*9ov!TH-cLbZ zkHxsx+i$4PU$IyEvNX5YE+V@guX&rRyLqfWRXcD58hvIjJ=7<={1|7zCHD)&Rrqo|B&UlsT6cw z5>_Tt)bd`BSG&1eF`Sm98^``?oWCa|#V)AR0Rc>hKZ;Bw>>j4gJw!O!11xL~A@*4}K z%O?hbvgKz+l_w=eC3-Ezoozr$MzyNy%claVRYK=dxG|?|)AQ!pXZR`BSf_lk)~z3Q zr&p0&MI*8(CKaR-t~chpt)6^S=ZCj6LVU>c z#jmr|Ws*I2B!sI}2W0Pi|6j?UIf>-OpWqUx{&YqBKj`szn+oGcqUXqpsj}gpa@|km z@(vn=9IR%uUydS`3jO+pltb}BLXyH(^KSZf!(nj)z`~LoZO>CtQ&X!q$Te_c+tMpo z!CEMfHzr;>?FSwzT&&SnrROAj^2IYODZ*t)Kj&y^=mkOWA0GVb^V#d|HEQ=xKHXF+ z81=X{t7XIyQ68O8@a`Ba8?{t1{yu~Ys3ZLyge!Fp>k;`c%gx805WG~Dn6oYwVPQ|; zm4KeRiQ*R*hyEX`-a07i?)@JYLF)0C5)tX{?rs!Vy1N^G zZ@fR>XXg2rVRm-TUgwT`?zhJ~DJd@#T87MbjH4dos!&~EL+ z<0>5&dSRMeYSmE)iN*7t8ET7Ohe})@o@YCoxX^E@&}Z-<5wvMWE}&2qwp3sKj~7ga z$@rXP$@7SXZG8~zSyZQSZaK+&vr&k^r;w{h1U~vRFuhiO_i>ca6MeE&GcG0%rQ&~r zn&LYr!#6LZ^~0{VgJ22kQy)g_)eZ{H$PGLcl)LLYAQdbzB$r%wM1i62XTUkuXT9|T zsr}Q0??}5VBsWQ)d?9@9>qkFiwGl{pKV}QqWZm;pa^L@`uQNlbc7J?ze01`@&o|cb z*aP+S_US*=Sy}H)hC7)s2+Wf~y;x-g>$)4{1~UBZ7#hod<(%B)AdrE#}x+^5kiUY_C`g*?sB{3vU zXY7_XKkdcx(A^Ro;Z@%?!HILnEZh$uewbiD;vWqp9#CS`UOpX~Sv$L%odE-O#z7lT z8+&&WLE_ez?y+QI@)h#A6fd|Z78?#RFMu`{*K@JSO7kjWG8Saq9O14ZkPJxsN5)+) zo@Wl6LBDMI?jeu+CFNfOP#Ky|=$V z9e=FlH{5_C&1Zf!hFIHp5u^lL2BQ#3bARNJKm)Q74cut|&lM7U)F;CGw^PL3295dN zC*ys2&0>bJ6p4y?Zolc(Y z8_FbGq`%&Pr9-uJ50EKJWkf!W8W4!Vu!zV(`*WOiW`qsKlOW*Gd&Hkaupe|={*@4S zLz1~Dm>$Kuq4qTX;=DPkbD_iPRhnx4lG3lgIGHaF!nhxy{vBafGIA55u)s9dyjeXS z7s|i=WU`dLJ)`nhyl%}iGbQDxqHd$XfGagO{>fY7tY)xP@*JzyrCf5EcISnXc;B8W zV->Y-8}AR9tV5Nb-w3KEyM945H9j)h55=;WRPChR-I;9v$TrcSU}fPDa0bMEeI|uo zWzUz>azoZO?m8!S&f}E#N+)(tj}w6O{n_?PYi3*q!n2JTbZ^n>b>UXZTV480TY2d} z79v6Z&rX~s5-5;}v=xW=zo@wr4FA%;69JP}jUkte7G?sM zIXhYo?vUTH(a=gX;35(${53n<+O~awwR*8WiC=4#M!gZN-lB)SZa{YWI9yJi=d9I7 zK!IKG!B-B$*x>4y5iYNrThjwhPR^Sspm)(=dNU1YNMil2O1m{`ZEtmE z;g2$pgP~uEzqY5(u)n?>8<>~qFdbzs!U~w;xM#2B8KVk>YechQG>BgaWtG&Xp|C{0 zUoP9Wr4lX_=R9@%WNo}w%pZUSz(`Pf*5xa6JGopasiU=&jx$2pyQYXl%$t;*gR{n$ zNk62Xyq~>eiFp&QHs2GMo##MOgh$hFzY&kO%=3=!{K|?h`h=(2<-?>HV6&4>t{!pG zj2<;mq+V@oebdmKASV6;LX6(WhKDlmbVO=J-iuH@u{+uhgh519r~xwsT+WM&;>6-> zEoY_Sp^o!3w2o|usD;p`2aY#GF{BhQn}VXwh*w(7;x$*y=(qpU90xGu3uv9OjDeeZ zk6f#L%)KCI7IaAs8va~vJY1vIBV5{&?iGqsU`KfH;xit+Vv|h8He=)SZqs69!@>@4g+) z|I}zr!e4Rm87fz`CJA7;b_7o^ZI3w(UyEca#@0nq`vZ9PYU)3~cdZ9$29GnW9Pd>| zn0#}Qit%KT^tIk> z7zmg9`;Y%Yu)w*(i~gG8cu%o0-)<5vq4_m+w>B#D)dfOYamF|~o12mNmfk_Z#vqJd zcvYwfh+qVS1Xn+rzwj8r^mFt8r0w=wP1W}aWyr}_-)K-uMuVM?kwf{2h?^fqOtY!5 zBeIyzS&Q1FBh|WOP&634{zBVHPoBHte|Rcegz^@+tznlgr>Oo(f@lhl6rh zd&IzMx>~nd&sCUo_iPKg#eK?C+A$+>1H|{C?2xdUMNpDFtKJY*S?~z1*}9Y{^D#kY z-$+9{Uar1JTO&GpL^g|4vX_oopPSWREEUXn4hWuCL#y)qLrFv8%D>ox1)oiY$sh0C-q*S@+3F zO3Dilz9;eUv0gspTNup&4c4%u+xn@+-Hmy1a-U;qLAY@mKfEF3@~x)wWihc9d;T7d zs>p9;BylGMvMBr(__bpnt{HhD4+v42%U0nM z=bH@#%h^kTw5c|gM!a~Uz%%0jW_vsAdQZI*<=W2?qyd)@4lk+gr!P)04uOZzCpofl#Ds!Oguj{T7nTv{LaT!vZy71)ti` zrkmYd^TyX~ZZXCJim9P_{m^DOoO^OQzy!m0^113$t$yeivA$i@V=)~KOK`E;x|v*) zn=!DLhs$Bc2q?(Npn<)tI!RG$zU;!ILr2XiCClWiRA z7WRtAU;`NfqSrBpz~DR@fSCSGRs*C*1U-vTu4YMyFu8jAvo3Z(LEH?Hi5o7AH2dQI zK?hvE{Uo1Z!wT;6=NWAq96soutUNTMOMM{}2n&Inx%^a|P4JH;h7!{I69+@9 z&5Ah8V*uZw6qTxAP2Vu|j+vO589%@;4|?$bv;fIo@sRYF z5$IT8G+QmcDGh)Ak@^s&_w&{{Luikaf z2+{j7Zvf!T;B$cObMM3bvw2K>TKA%TH<&Ru_)9G=78qKT5cmEd!K2o`5M9>g4=8CN zHfUJu{onv(8B!e=G(4SkGndV-CfnU>N_iXzOc|${23)6+1*VBW0yIRF7Jy9F>VF{C zeFL(9Ub$B3Wct#8I6zNeR3XOP+KzszTUo$Z68cdo3kR!d+QPeY5VW!!P`S`?qdv+v z>QbJ44Eq5mLMI^lD8VTrebZrQUZF1F1@R{eZ@EN~Eu zWh$cIB^pZ%y?Ny|__NrF{U4nB0g=;B1!j{$KZf=PEN2|LtRMxJvID?E{uS*8&?V#< zxx2O*W>7sEY-446`^n*q_5GB(cDKqEBa~0@uCHSj z{GWIc(Ayqoeg!5~Ai5a)OEB!$zql@#(K=eu-i|^=yjxtxa^Yy>J36+~b5PLV(7dGv zeqtp#ym0BF!l+71I(HP36-0ZAXi}y!rDVLT>jD1I2 zIIfmdrhu#~)%B$E4=vTkfD_I^!O2EA)>RJJKa*asVFBw$M|V4bjpD5B`<2IJ8>_(Y zNOx(0;8DOvqrJ^HULHAacTqiz}_z&e+^Q(N5&6tVqpDZ-h4bhsn)>ex*0x8cja;CGDAB# zXzpP4n)8VQ&+E3L&1rWCS7m}C)UBGHCwCahv6bO?1ZgBXV_N%+K+kNBI;7&#A+;S| z%1`zx6jbB1;qH@>I-Nvh{tfhY!XIzpIE84rPREfipR0m2ceGkP$ny#$oyONgN}%+! zH2jdS9|f{RjiMMx+ze|Gw{$p3I@)_l#R z&ryE+t-TFkJ}NGV;AeiYxgSZrHCz*}$P$w7MZcfcLg{WWmdkEj3^AtU_DrPzIqJnd z)SXCj+vrL{(0_`l-Zi5mF4Sq7f2Oyjd>K$uH~r&_@(TOII0lY05b!7~&h({vb%k4T zzy-ynBv~IbXXKeNN+f%(M3=-fFOSh~D)N(;oRjW|JI@bY0jKr`u#nMpV_o2bO7$FV zKTyJ6WQU$1OJwRG@kH!Be$nVElKT1B79%4 zx-?|wT5z3zrdSId$Zt`~PNdZ)28sPET(>?8t~nMrG{sxX|wNuM8Yjv=cu;r=#!a-ApEOI-E&XeFu=~e8XpM zm)$bTPr4OtL_Y>`U$HTn)o1Gp;fSW#|N;LSvmM1l)FT; zo~hLLuunko#=J6BTEMN+jiMjO0=Gby#E5P*H5&@Gx?6(IbLr6fPqM}}-EP))7 zr+fR`eew|~p5IuN1%|#g64ohD&)+xA!%~d1p7CSYVCHq6;06-#QoomqZiimk)n0uD zngAtM6?1&5;};C4UrFL;?%=g>wnbVLyZbJoj7Aqk8O)@EBo81km44XsA9CA9bvm9{ za(lDSrqzb5)#gQ%T&4@?TfdNA02WQhexiXaE|w~jp#Va4W=-bpg)N$erxUS^MmMZN z`=A`PYt5KHSu(!XJ0xrc43ts$0Ur5bdT_4tbZAo z9-_coWpJJ7?AB2tLlgQnrcyd3$>M?cqYeCt7$lnk#}{WP@FB$777<7j@V*6vmoKOO zYHNtl-@`!LZbD-p^|BKIcG7n%S=XyLPy7|m28L1nioLZFk6-e7IKt)k;5-Kmyg_&c z78U5WVQO(^{fb%)svM2!UfNMIe;n4`>~6SRjxG;6__fbEM4~B5l=XLRnUjFyv5l(Y zY3bdg%Qo+GgDsl-xUS-Q$ZUzeDKf9U=9jOHY*hAXJ@M13<@zJO7z-ZP9#w1Suk3i> zMV{7|hn1K+*C%ekfK~GK8=B|39T3f=l1z5w7t`_ArmZL8tebIfgJ6&D|F@5-?8jj| zS{lo=2jGu{>cIZ%+#D<3c zQEYoMuLbfylLhhMD=Eg~MUFH-xZiD0t<7DEbP9g&!g`qx*n1><#Dq|Lf0@(|J)1Xc(sNVc0kM{ORs!6p9BOc>WqX$wlnD(e&M9ESt03|N2Et zqC5wmTbD8X#tCLU5n+u=g;XL#(HEu=U1dSuiaEc}QY`Y1AAUUEJ2#A9BeK~@8|(Q@ zeB|&S&cU{7CYPM=3uS_enoM%SKaUhO9xt|H*uldOj0~A6dS8sMe1K4R-e*zx@)rZz(x9TWipzY%!DKo(ZW+*X2QKC{n9|_S;rW4rB7eQL(P`us|a>mY?U;2M% zz_8g$9R7fs8*!}?S=$9P?GN~36M54q_A$TojUh5cA>*8U`~jVai?8^lll2L0dfFmd;~tbT03 zIEtGn90TDZvVeUszCEayuy9oi2+0m_(~thY0YNs6CjKx9O{`Y;&cG2Aw86 zSD%V)p8H%Y?s@pA5OHo$yvqFaBBPHOef7{6;$C3+7hFxkWp(&&*?@|54_{OgX`Kz5 z4=*{hXnk6d{T%vDS}PGfSb^R?vZkimJ|EsZ5W0PaO<&n8QqH(ZBEZ1z2C>|!ywMQx z`uMMJf3}wxT;#=$JCsvh(hOr6bgzz()R#EaK8M)!Yf z;`e?AybZR$nce64`n zz7^A4&uY0(vtZ0a%B#6eV}&d>xi=p((EL3h;IQs_TWx%|SfjjOzugp4r`r0&-Y1vh zvqhuwk(8-Yd3&v7vZaN0B%KE*Jed|P%c+Jqp7QU-1^@l+#_e5EHsQQSE%n@m)A*gm zeDeMC<+4H+b(`&(N|90+s23mOdUjV~!BqTPKH8QBOO-*_B#0Z4Yw{az46ZLs;seJx zAWsbM>gqDK+#|7(KK#OghXaJjL)qxV|9L)dXZW_>6FM+Bh!zlSQQnFTV8ES#*60){ zfe*_oE*p`4^HN@3{7v7FK6^|#=YCE)q+!{($aG8j-FZQ2SC5=9yqWebR-W3(mlmtu z2%1z@Ph#@HTE#o}wuEfLHEPGYjdB%kj}C zCl$+KL)m(p`jdmHXVJ;~;fKgkr9ppPsNUxAf`)_k?#N|oA-K|TZ%TlESuW)%1{8V;c1BEkYg6#G}6hObfuF`(N*KwGYwFlPq0r)z#V z@JG#s;A8{{IWVZFRz%YEa#AAjL{uTLMauMrJ9sS*Ri~IwBC{&-_IozeA_DIY4&3!B zdj4j^T7+beggt${UbT^XQI=pNJBi#2C$9LglucAp)Mu^SK7WkwWTd}JbSaS>cK`j0TWXoI<;_(?H8ZUdd%PV_Kr+a1dTj8@m-sYb^CF^?A5pGJa zG$eV+wS!l1i`tXBo+;`+FDjQAEhtJq=Y=Q%tIM zr323qd(-K^X<9o^%$E0>Tv9xH6IIt=C>M2G^6XwV&WG(DO6&ExNTi51b(AM6v3b28V5Q4S*axH$8KtP56ai0pckXcT_<-#m8wJ0KY1aC4&ch8LSBi31ZR#8IGQOn67Uv zqiL9-bm4CSk+Q`Fi~tt8)<$|;&nlw z1Pmny+MU-c1zO!=P%c95~oqo2fWzS>$KELeD@#mmNR6~H8NkLCkY>8#+ zsdU;r{piD+frR0v>;ElOPM;>N)!ZdOx=$itXm;Z1t&M0 zn8{KvWnZjh@q#l>@OIk8I{W6`#hZ|`EU73#Z1CM6tj&UPF4ddD8k92~_4~_d*&ZVk z1(D6IM?};Y?1ScgO^@f0aq2T08_$PxUj53RHs(xW+F}+BMsG0nCH9NRLxBYK*#e%K z{C%ozo!CoacVtQj{OdXUr&2s-9*%l!^GtM|D3s!I8OX|dH;y)B#Qd!bhIEeyiz#^! z7!iLqL{lChIu>Vhy?@rvv?4Y#w?MJSWWVHLjM;U|Z-dlgd4Y+VBYF(>!d+DwpAGgM zm3!++e?glRQ`xw43`@K`EvHe!PL^D-41|cisWfL_W3l5p; z*7`+$MQaCpG*~tu&aGQ)Wg>^SW5-D%4@R*qoG<8t4-9t3B9m^q-pO*euI7?D!otF0 z10%5B)Qk?e># z7o#dEYag*ACfA=Dj~!4gr&HY5ITC5Xx9j85@3EopJ|1;XXE|)|^CB19(j4m+uhNt< zT(M)^q+UD;LhRf4qdo2=3jI~~Pxaw5r)eKbpx>J#(&==4XzE!_%d(@h9!E;`X@~JZ z1G^Pc!^p(}6Ju0w3q+5Um6iR1;X$rNOWX+ynLrws)RL4S=~;k%YQ{dyi92}Y)}`pl z>PSi3?Z+p(qYBBIHTC1-EasaPU-;hw6>taF0{c!u;z$)QO768GE+_hfVxA=Zcb?ns zHO;Ce{augDA>}}RL}VAAI<;^ax_4OdB&<~;QlfiA$d~tu?~6ZPkoTj)v&ms6`1Wo5 z5*^-3nP(d{kgVXbZbEE-K7acgo2IL~J2o!KcjS=J@c=@g&ehm;Gkv@^k^cOfX5LC# zZ@i4i<@TB9bnxCPBIy4sCB87w`iwW7n*Rw3orKWQ3bEN{A4->N>yM4!OBPRg*nlXI zm^gf`pnxcxG%e!-wSn8G;QN?>*cO zn9oU^C+fv|6xUnUFAdzQ&Q7*R=5(2^O1e{5v2KJ?B9u})496e#S~osh&P;*8wi}y+ z!f;$>DHN)?TRrl!V-KO{-V@I;VLVgZWyo8Fxn%`T)T~L>0E4DAhC-?uQAm~xOoX!x zgJ}w3YQ}==P$cQvfBDH~{iAmI?%!loJ%I2|tXe z2gHThmu>Gn$~e}0OZwPh+dSR(7y2t7pXv`f2O_DZtKv*lP2P?D&ZXYPC`TAT<}ikC zLqAofJ)6xczd}w__bA1;Gn)jL%G`U{cpjjPT#^r1;M<8#uNA{arAO5}=)nSo7 zE}^ppTTY~SOXtL#Q(U8Zg&7oHb|XU^%_C9nY*9aE+4(jYJYiZX0PgE4wo_GM=G~*f zEClNs1eSHd(mj3E2$ZWn-mG5RJvPU^bi2eS3bf8Qh*=UquqTpt_|gP+j&Ek9(~N}6CwuqD?rD<46|sp5$p)=d zF9dXqY@i)D2jn+NqBQnaaJRW6)dZ=LwcR~GhBK_+nPfY)D4ftE#-zk|{rMo$#Fz#7 zROD6$xpqz@3GAZfYG)%c2UtN*p$o*GYSAIH?YAhaXA&g6hg=RY-#QncfzJ&g3oWU= zRHZ!wdBn1=1bj+~wXL{sy$NW+RV>F)bz0$#J?NkP*3J=)XhV!RhJxt~57CYcKmYBr zc=H?r%A+&$!eFWo<*4kL=Dyum7Ih!i#-&$<5J0wxrS;(>XERN*1t21H_wRoau>r@% zU~76IlUQgW1Q*QSfLiLKJMzyarb+`7$ObEiw z1=j5xA?BhnG~LJ3g(g~7+#%jNOWLe>9zc?x>#1n)1>A8>Rj>+!|Ie)UP z9yXY6iU)ktSG0_8!e;prLWbSUsQ4|gZ3y;0>zEszL(6mfq*zF{5LPAq9eWBI7L`bL zW#ujtH?owc{^K1fSi^$)EgD#>cRx95nyVdN`vN;?c$PE|Fx*vi+fJ(Wc5j0zWxKXz(*#``2)e~2&ROF@w_69zkPU*VjQ7+TxqHiuh&L3f%`HhDm)`r~lmjt& zMucJETf?I(aqV2Y$WfP~ zC21Qwa?RTOI;i|r1ItlFwYuJ;hD#lx5{UKJzPTvmW5moZEm$ZLPXb!4gWe;B;VAgV5{Hy+Gi$yl5>F2yB zRCKXci0jp_iA&E-djslhu3?bN3z8i9Gu^@U63lvu4qCuQi5MOp=A(4M!O&I`cn%{h zYoTiSgH{g4_^Z^uzS8CW@+Eza!Z6-Z>)1XC$m89e_?zsxW=+?!MVtFw(FWE`jnd|! zKV6N?XtgCNPfeJZuV&?^JTeMXYTZ{_Tviv`YQwX!!P#YyB9%=n@Ww_M?8ux?yw0A9 zYOp@O##qLf^7pX0qb+dnQpd&I*`zlKbzpYgQ=&=%HYlILCy8+a8dU^{SXtauxP_>J zeXrc5pCmCi_5W!B;{FmdJO0##2f~9yE~Cy8R+r})wZV_;{F;e@V$W^<5au@r?MwKQoil^8k;W6e35=L zxt-QHLYZ8F?xr4Z-UOOdMDKbB%`a!QEJk&Zr2Orbn5=~h_?$kO`fS8#)WOG;R%OX) zms2vprO+8Wg2#&U8Ur4m;(Le~rdZ1(UA0iIyx@;FGO#5R&kN7oGb)*vw4^8)3Y?X( zKEmGJegTdk*sS;<9RtPKm;-gr!D+lRNw3hD0DfuG{tG^4w4$#8Blzcn-0yw6gRj?) zi|1Ff0r>lgNOkUK2tPfeDIF;Ue8lm5^Gf;Z9iKZTClH|R!t^?~I zna0RcONeOa(7J0I|JsZ_^eZF7nv54pVNf9~bCrX(oJbk+CJp-Z=ALdoI2Bmzw5UvF z5Pwx2TD)tE*=AP%-&@y(sWC17qV2jSTdUN6U^r7X&_RcYT6eE0gjjUFb)Q}?y5aj6 zZ^2DMNsQ+dTQX#R-9e}&+N_0QVCqTmEcKA)lV^q}~ z9czY9Rm-~a8!u>c-Dh1HxWXBy?i%+Ovv(8-9vz6Pv>i<=4X-*iEIA>#thY+{lzK9- z{j@0f^6Z;Qf)d!^s9CksF%>I$l@)Aye`;`Gg+94qP)GMss6YNZ3*m;FeNXjK|#Zd$P4|Sa}u*K9Zxi7G z1)f5m9z7m{kU!?7OTr@`eE)eq3pAERdFkxf0{wRUOMfcIrBDO+2%X#qiQ2L|f-OD_W!yNlP$;v{GP}f)5vDiqLprtaTL*)>m9a5ZH02d-;D1Sk z<~mbEXe;Nab}LID!3mh7dZE-%6l0|@v456lS;~UXV1~A_ZY6Y=Zoh2w*6odh+KXp& z+ANYiRE)cf{(KNnu*XN<6^l8nlzVaPQl?iPTB;sOp-J_dCUXME zL>0`ZQjvPQ&ppN&9@h%O_wMIW-Gbw&jflhUM&yc}I!639gk`vlzJB?x4M#yzYA=h= zK30|gV=gK%^eQ)J4q;Sv&^#(!&FB2wji8AV-v}si?+*pdwQ$#{or5c3PY8d)HTFFz)X0LHG4Ssz$ZZg>m);3oxAl(w>RL63(+^g{quxz_+=nG#Blo zmba+WbI26G#ls^A^rq;4mq|ci2z?BPiA~s>1INvL zxvaZhxgt|xfGC5YS_$QdgoEB8QPM8yU}Huhq#~AQWy0TYcEM<*`*2hmf^)Iup1+1Y zYOoyRrG|4RqPK6V8aRBTI}{#-FBUb%k^Cut{N{eyO_&yq->SovFR$`+$&o@7mP*At zu`dk8+0?EDvQBAN-5fCX>}_*g5~NO+6mmdT&L+~R`a!I)Zks2+oMh5lG?`(=^7=X1nj*ven1!Rn!{1GYt@3_&+8Vy% zq4M1jdza$T+qh6cSMC74s{Rk9)$n~24k~?B5s}zWmK4&W>0S&7 zX#E(cMOqI>Qu0&ogK&geB1U=FArm!ck>;?7oPt0*F_Yet7vD%8!3S!N89N$Jjr)>s zd*&$h8@~p^5#@<9=pO3oM*xRg#&obNU673 z=!aI`#jbJAmE`DP1P%f_%W9$9&+LVbyQb`Fxux~N9~_-B+c-=|8BGtlz|v9doV;Lb zuaft_P=@4NW<8J4nLp5lCb+?lM~ zje7Xk96KUF(D86q zb^J{j8Xl`z<)cV_fTUsHPgESVpa3|KK6!#4iF76;A_14YY?ow%!=MC9ty&~p8-)2R zjhIjVgej{i*V$}+R30?zuO{3&M9V&aeDF~qFBsK(r2qM$h=$3v;jaz$k`H_U0#x{H zfC&epi26pGPjCYEG73t>3kE_X-%2o5rQt4XB5F{{YJeJg@?S05+|8prwe@}Ot8Wu2 zxdNm~3B*>SBALt8hC`am3bndH>nl$f;2uUTh~6h~%vE{$fUeP<*p* z{22F1&ONkkpuunWkLO3e1OtkbE-W zznaVLCZh*tNtuh@H+W0kewxQ+{P?yZC)h>@tNdlorv>2!a*F&{g(wHs0}I^q#bTu6 zSbVidsI;gOaSTDiErJlwPWn==x?N#B5Wa>3I{349*ZvVlUgxSeayhOtzBOSRerw#g z8M75|_c6f6=m}?Oo$ZbG!p1|OWlQ-wn@bL6QgdU^(S8wq#*Re>5=eK_b^9yq$So)q)##J>~DsFcLi2M9Ygex?W`ES<{} zwh%3aM7a6mvAM;cNZ=;J;fxN zvBYzH_#L~8ETrO~{s!XD5y#k2SUW6mdb16C#UJJKd4k5j3;1aFX5TIWn?QAy`f$=+ZFl~m3hUUNH1W`UY8TxCCpu};j?x0=9* zgZOQ5$`*v7*=7?POh*JQmo@(ie^`q64>rtzuTWcG$bXo&YxxjmwHAuWs%l=;%8=^uus__sKgOedj%soD9mbK>IjEnV0wC zn_t9MW-g4cd=mx&p4!&eq0 zIwVO_jp|G7Gzom^wLCB{v4i|+w!ZA>mqte*ak(V+r65WB&ljHDU8cu%453kv!2jn7RLWuf!8Ki*qJaGt%ijb(v0 zYnjZ}MRSR-t^f{o_fIv|uKTZMk6r`{3QBIvaANxMmPckqMa7DPllIE}qk1AMfrm>a ziS}}O-KdhXHVxB~`GZQmXOCR^#PlK=yAdh|29$AH?XcYLptacGBQ)Ng1MiciJ=~RB zc#O1&A`u87FZF*1*Qs=lQYeTlQaTeam;m?I&6mgsZ=n531ojvqvA=%+$a{XIR>uG;x(k)#c7x7Jju+h(y zmd-aVW`C}xr+O!xj~7)pe<{R6n8(q-)xgpq9()d96gUn)cy6Ymwi^z??GcB;&MR*# z!>+VD(;=DD@`ZF1vy^HGHqTKoX&8Og8CBGz4aF~bSBRvRds4u_4_&6ku( zgwG1m5tBMt09Ll+c9 z)8G85Anb__HkQdoR``j!$l!TzmfQA~mQ=`a%pZjvO|qvw9vwsbT4V$L$ODLYh^jM8 z?!l|nEnYy!!vev9)^W|Qr|d!uG932`yIL~vY;nszF1uL%qZlTH;2fnZDQ4#BQ7aA4 z2wpld%IR#Fd+W2bLJp{O5HmY{{tRdJMkZ_du>{{@Q%akMSUL|6pOu%3M7KpnyHu5{ybUMW3Q0R z{Ot~kwFu=|iFi{c$43rGp6mpu@Mp~Z5mwT8gAp!jRIOlXE>}YIL&IHN@Vtc!pkMF_ z!V^pbSL$JiVp{JX$y%Q7!7l5ms&-dMN>6o@vG|wL7unyyJKjG>yxxrWviZ87G!P%O zJ;Ke)wjtFovwhIEZ{{f3jaRD@dQV^}IYcQm4U1>kwQ||QXg+HCo4vX9%hlDDv1tIK z7V}JkY3^i<3H%Q2n&)CAoGCs5qp>9N_svG02PT6wP5^B#xF3y+|+_6X@M` z9+?n(3>8I=={&t@ch|%Q#68wU7&r?yPaVdElvRU&Upl5B6Zxr^1oCp=BqAZ1el2En zTjmA~421JU0>qPg$0^5v$|jMVO<6K5fs`J2F8L#ABGBPxrZ3R?y8A;SK1af9$A5uv zykm1C{58Rf1}^wkFFM)tDGY>76QM$&qCNYrn?>$#@vJ)I%K`pcj1upoW#qUfAW+k{ z2nV^*u&yM$XN~^Jh)0Y|5`2j}-WAnwg-@{mF4l0C;3a6*Tu4|E!Rx8{C)Ri42j)S2 zsomc=@UHo=hJwXkJ4~6^(g=RAW5@z2<*q9SwkbX6=}6REvDdbqR5i-?tfq%qpyM(n zPi=p^Gz|m<)4Y^FLuG-c3%q$}KE2Tq4fF&ev)zZUD^Wn6;fgC{0tvLoksT--Rn$mIU498*w zUc3r)J(!NYtawzEK_AElk>0Y{eW|xa9AnJ4;04(iaa11_LOyl>@W4Y>4utofPVB1Tp%!1mX-?uh)^`vs09JmmdTv%XE|$A zEB)hxf{Cy}rn<}bxuk>K*(#IJo5T(Lqevf}^6n6T>Emh7ZAHrLAAWsY3Wd>GOC4ILJ^!0-JQ86Gh4seg$os|THFUP@H-;=6h>FziW@B6*KbIw}+V=ULh+;h)8bImo^=oDNX9sNutBBV!QPIunN z-X%;*;Ko%78yE<*@IIJl4dEbN(wWWaupQGPDAf&LtG0-ps%8eQuN%H$^wpvBhcKe{ zUhcooQ%3!5iBmM03zf%&NJe+$PKqYQ`N?Er;=2hpEV|PFDr-`nA;Hz335<*!LrESu zS(#B;c*{u!qL2MPFG<&#Wc1cj=zCks+c#Bi9C#D8$1iCm0;zc3jmIYDX&H5tjZ5sv zCDFZ;k)q8R)dqNbV*v;WY~Y_(Zmg2ouclPx)a$iuA$?a#1f)^cFP)A_@63j3yvMk#lDx){W8b+zt#yqL`UkM<=j_ECPhzM0N! zy&{X!roQH=h}AuX%l*Vrwq>}J`0<*C0P^!y zZA;R2wl@VXz5EyEqzHjM%jJ~DReT0q{pm{LBZ3lrTvCdw-_i{eJ?7Q9Rdv{W7yD( z1X)Ja_({UKC~>lFpZ8_iSJNFy)*!LtJxo&y_e1gl}404i2l zS5i{YYOa#9cC}VyC#~sV_L0=(VYCTq;Htem0V}u-CLOX=C;<7#A45QZKWQ1jeGSlMQ~l!~w2=I}gL-Qcb40fv6?fW!#y)v@hq}He zVy1lX@v&_{VBkBUzs?HYA3eV*GfosJF!_dohu=ZP>_m4zi9Uq*p}5A!h=?vx6%z=D zZdwT2rltp!NlJCojO|K8k|H(l!onGPo?4rx+bGbyo2 zkMzTZN-;23z9OJUz~#dq{JTV+bnl^2tu+%DuQWRoVjqcA@4Vn!;{EuiLRb% zN-VIyVlztCxYtES#MD64_ z5e0cLRjShw#B8?a6HS)4IUMHxJYusvG9Ou<=-%1R$0vy|vL)*_738`O7mYLW_G1NZ z8)h5ff@ZB9jMe#dq#xtC>9;lq1ZY_V>sC)sC;-}pJTS&Ylak48MIwcLb2cIzo1GmE zEN;L7m^UsLlj`hl=cXC%hxK*G9j_|fF3d}`Ycb>L6$s{A@ljoj zjy(k)@DNgo0g%>J_-e+_MO{BF*sB)6UpS+L^2F20 zcL7vW^R=P1XpOkc%r_g7(g5djCMHZ&b#iiF6C`>?j}`b2rm>44Dhe6Xwye8LUKgs6 zQDi(G)Fd9-b;E@gwqrD}u~YHS6?hen^CO!dLo0fWJb~pK2Q~8sZ&L$a=Z)=L=PUAO zJrc~1(ocfNHY%a4J@3vaE?qOm!)R3$aR5>nJYp!Z@(Fco-OMV6C@gfx_m}aOr=KY` zzOdNcxnL;McD5s(G6tvH?b%h3)Dbx0pucFFrSZ3=B~?@PQm zC`&N)JK~=j28;|o-&!bY@@W%d6_xd)7v^mwr&Q^iU-ijffsvK0P<#CxKz7Rv)e7!8 zqRgH9tmmRxufZ38v7s-REm72eBev7ZnhC!spgsBtNTs}D z-iD8V9ipdK3hf=Pbr-K=qD^ahJo6t9c_{`qd$87=H*A3KWW z>IBiLLc!~i!Bf24UwT8-;QAY{mkT66<$aZT{6PPDe&mJNYJitB*gl3xRD6&MsAkSH zc?r<&#Mi<#?s1@fSzqLhKMYog_OCU&CWTl5EPxXL5D@KfQOIKQnExM_zLyRg1VasM zaPt6m6WHnz#fR>&p_zaA1zG-xf!4iQ6wv?4#Zru9oVuAFwxn6q+v=?9rVBXYiPl&! z+6J6RZvWK04QiT(=%R8;NnUTX(bm)A#_{>rBkeU!QNubGGS4I$&g6ppli@WfwywBl zlSqrTW*WE6kR|?MZ>XTNj_Y;brrcF5*1$$J8Y*4y)0MEX(_g=dYnPe6&Hyd0;+L&f z|8W60QA9%_RB+uG7z}^@#yHGd^|jpYe&+r|@Hz-b#>2ytq-WEG0KKc{3&OjDm$-QL zzb(cWaJ$Vb!|75{nYzT8aEL#5&+20A#4lvDPa|Krn0gBSxF8^csL2Y`z;Q^>t=H?D zA3KLGGUrwgL@43Zm0)zm$rNdT0;B~!tf}4T-1RCCw0R7>q0pb6d!H3q;)lEmsosI# zpfuO&gTEzN7G8P3jsRPAx@kYg_kB7E^Pv z3Ix*$8z*|3$r2AAwaf#rGU;sJZ+8ZAT(M7|z0qqOAE+ zYk#5sW6Be-YGM_I5moh+{vhFS4(>#>!qc0>JYoG=MYimDUxr>G+;Yh<2}u zho=RspmkCF*H)&(X7G){hRRfZhRiUzHqH-}WRYi%L7qZbVW_D?%E7C#9Ll;Bbm5E2 zO9?mHGC5q0BiYnb7~eIh=g>LP31?5XG}jnSLOGF9HzD<|OPzkr81psnIvhgb(LIlT zJYpov;t43^Py5AIZp7zT5x5`LogU|maxEd?vsa~Y=BHij9xXMW9Cr=@r1X|2uLVcrf_yVaWt(a(~J0A7pi*Ge3Niu?;Tff?YY z56l4)s`CM-r98Mbga1zF$+mGlpD#nsIr{5P+t_F70xzLH5Gk!#hC|zh-RtDLWF7Tp zG(#6B-LfxbfjSw;ff#5BLP>_R$nliJL1iE753<$Kq-Pju3GvbD!E>&A{egbRR9nvWo@JQ32jMnY)T(u_~Cw+&ubTzW9w%Hvpn%B9jf zP6-)BmY!+zW(5$G#pY#2{(0rv*I{}_8DGMu*tI`r{>+)QE0Cm7e2@sk`P2YwYsACd z70-ZX?xcAj8SS#y-5Zv=6?mXp(?F>gM~;2%d5!~UzWM{AA^i49F++0c+;#v^W-0JY z1&E|P0x=O=cg3YCH=2YN@11)ZRpVex;sw1LEhi=6?ZJ3P_W? z^^7^lQ7l)!j9n}GmrAYxv{hC(@ZTUoAvg{NWc*I(1L`HVv`#k7v6Y|x#cd7XTX0ZN zD*}QV094d| zi(~sF$B4dR2X-mT=zA|x1};6m5;PHC3sWP6LU-ov$lP_ zzv(z)ZMn|vWELMB6YKb?u|0K{CF0?CcdwC&_f8@GIoVqQ2wbQ(NdH+}_UktUQ3*&9 zyT)->&09qjh%=Z*Ce#(En59#TGs9Y8(#iqw(a}?e&*C)lqB06>eFT|RWNn-AdEO<1dF;aTrx@9YExfMmFj6bykSlUlx$7@sNv0^&MH z1cQIB!Hj!)I-JfK@X!!D8XP|)j)j2ol=^7m%9N?3ivqrVeZl<=v@?zv`03N=xBRcu z{#ewmgG6xSD+O11ZFq2rqEYj~607YM?25$+)^LZ+m9SX_q#0whfPQ1e25i|uHPXQi zRp3S#SBtV#!fsQUA`a#j4w<##Dkmx8N~^|vR&|YuGI2u-ynl4mP%n#4AlA&adFJIE zE3k$pz8unojcMENudn-JR%_mEv&xA(k-e_FeVNn^@;1%9ZP?@=GKEy+RCJ*kefUF& z9whtGaAX@}=1$e0ZL~zFOc44b{9pv6KKppKfYw$oQXBur zTc#U*j~bAmLH+izSULDZH4>i1P+ugfWVQ?bEqDgYdNFbON^Sk>?U7+q#=OGon2^;X zrRyDA@|Jg>g=bC*fZQ6Zv~4%&hlX;k13e~05B-y24rPLd6hmx3LQZ}U?0tQIKnDL2 z@HY5O>wPfSAOiJwZp^T^cZwy&)1c6Oui`w{{P_ZvMq;ID+u5j*Tk9AUiuQ4(`&B#} z?_${g^)X%lyt*7OA(v|~{L4L~y$z~PB~EvYmKkV{qgLx68F^qal3UA>KULC(H=aj@ z@hrG#C#erexsE`GRCp*UoZJ(HT4OE?VL|KM=Z)Oxjlvdf6dEVo=<$$Oh695rQWD?VHgn|i-FEM&U_CCgp)1%oZ3LeGH8W75Hep7qjHkL4J zz|M_t2UNDa4aI69Bq{G0;2-FEsJG7NA7`uEW`2Um*amijyzRFDh&JvuEj%vt0wu56(Dby6+M7Xb`O6fec9u_k`9l^ zG_5+>BDEWC3Vz8xMl}x{(yxcDbi?`9*P8wPY)A9R^*T|<){BZ3l{p=-_lKFz)OTqX z0bGo?D=*)(NyiSviug&thbuut+*oYL`B{ZGwV)FL2nNf#x*`je$zgZJy*C?6nc!FL z!yc!Re;F?wY>PPhwK8Wv`{fvo0GU0NiPRaaYF5#JGgrS>59MQnP!ms@EU#iraMtSK z2R*YAHN14$zK0q>=(^0@|NOA+!Dyncp}HHCi5M)*gm&wp*DQdMS^-dp`jBxHeh~Ze zRU#r+A)QAlQ|Xyu313a8@$KK_zIU-!QVQzFp0%`8nRbcdtv-T^>I1U#CyWDn%}drY>1 zz`5`-1DG1$-tV)f)Ms4=AT%P#uOx(lkkhv1>B}pvUgEB_n9g9sK1FpWy&IS1@mKi ztvTZ%bN2qDOU6}gmKEV(?$Ls7s-e2=qP^xTQ%&wpjit`K5osL3hpudAFe z)L;`(zFe*j79;5S8TC$IawXxsD6E+Qk z_|UR>{m=q#6_|rB%MMv4#xO;3Pj!LoFgdm<5liK;pZ{w-Bu|#ta>GF~f3@1s+)ONE zPy=50(zwxVku%~*oLY{^?$i>UOwaLgs%3e!x4Wj~N-#d?5wl1VJrr)lF#7<56(*tG z#(~{)6O~;~9i3w5oYKQetLLGAcO2!BIwqF{8_kCZ7Sf%`kc#E7Gt3r>_EkafjYUT2 zzTI(A6;T4Oo*xl^pK5xhe!T zFp?_jeW}`qys?1s7*QLD8Z#!VepFnwMGiN~7!i(d1mnjT*Gbh5?aA{G;vSu8UIjeg zph6FLV4G&dYI)=jDtK{ECs^nC3v_6Klu5EJYqGKrbqLc~gH^T2$4mOXIXv^&bjj~O zRjxD?%D~g8b{0?R+Q>`12;DGvRsg0;-o;5`D9C#IHy*@ zRZRA4BI^B}3z&P_jEzMvGW`!IN3GA$v?}-B2QJZ7LS=(k(QyVPuV-$^rc-ZcUx zOJ6MT@BvcL*2L1jscX@{wy@>(8=;fM6R?Z|61j5iQRJz!C@gsp(Cvp6~RnUXG z4?k7p1p*)m&{ME%BPnC<_Cl?W@!v2J0?@BkoBt>00)&mK1d%>qL^akO32dY|-p}*f zn|Ttp*|PiD!MGy-+rXhaKai^$ed0=2`sv1H?tCbAU#Ju!{N#}$OGnkyf^D)a6YTF~ z_dGLEq=J@>LCTZ+Si6kvJZH9Jzv!qKG>=;cG~7loj81xLkh+5AKiDSnkeoaog5EPw zjl|ie6Z53^WnkZ=h{2`wyR~qJGrdvFO)ciggrT*@xl1lJtb`0Q1=A8$x zlfTw2iTzTrU00XhA0jX4arCDaWD#K#fL@j{qU;LLVMTUt?PtI)3Vgo?S6pIQg? zGJWy1Lb_4!3F*I1*Y~4DUkdm3rJZVdC~MMx`LIO0p}NMBVJN9ZlLa*e5pmEWzb>Em zoA`1!fTQzIRFI`)1KM!FdV1~uq;qx#5k!?Cp~}B2Y>i3QI`9i8Qcrej?1>;#pZB_e z6v9UL*~}2Pm>33Fg+{>Zzej71GLQ1YvnUwanf7Qzj|S+RFw@wy1_(tks5kBVDz`Zpi8dx-#(NpGSGl|P0s00C2Ymtz|4hRUr3LABN6i!AC)>@ z`k)^L4bA9Lu1h|q%BveWEoCROa}5>6Ez?`6Y0mTLQ?k=?@PjQW-3qg>-G@fl!E|~| zR_uWVZdvcYUH5ymdq__H@rR(y&@umE?0qsOfv~IAy9uhKQD-c#k3quGdjZD=FuURR z*nP`q!%^*#{@Tlx#@kP){@jn1jYTJY^^8k|DHd$Ln6fL2zeqe(Rapnwv~t>@f*gpe zUD?zDM|`H(qAui%h3|()rO+Fg2yjq+QDfaX-2`+sST-1Q?B!(0$~KO*A0ZgiR*3NH z!B=hW*^eP5`wMD6hw1(afSJ)iQk`it(}&Y1|D;Gf3WH!)w5i*=d-lH15ta zw`8713J)J~(xPc^9LcPGFAjv!7r(+;@?ogDOKQelFZaR}3u;mHzIuNg5`%dnYxoY+ z3?n5oKJ45U49JxjMo;;OHsdhWqd(+QvkB0l0Yj_vmVMsbyT_J>h!FZyDY=m79`aOa zt0`gUE`$Y~6t-N#rJG}Z;}W zwOS`~j)GprHji&Vz*0bj^b+giw9B}awFr2jJlSefYn{LOGHe;zk5i-;vNO@@8v_*L zq8Rem_8T9uIwdj!1GWCXGvd3eT8wmyXo zK09s+>Dh2Oy7Ru@;wj_Rys)a2Snnp0pX+5iC29$&LqlDlAFY)t(W&}L9mds&T#I*p zClz%?0Fr)C9E~J!sI3s)oA3wMg$IamlvIi^wpHP8lV3;G57ErQDmTsACTY3f7@xmM#;yA%TnR}|A2w@^XUzWtgQfW30_lJY^xxk+r@n8VJI2udOw(SNw9kwB zdMNxM@aZM&TI}AQjND`;Js+5d%|Jl|vl`Xi@V|xaj~r`h)W{EhmCpt7L7rCLv_~~6 z17x20TeoBwYW1vIey88XCsQ^pMl6o}xybb+TneHVJ?}3bkVhM-gkJJrb6-1Zzb6Hs z0MA$H5k*Dx^vFr*%f@^K^sUTJSD9T9n>6Sj1PAaZmpbt)P1NMu4tyDHDZ+R<^H5Uwru zGpD!d_A+>9=})sd!}`BsCUDEBUl1xbq$ZvA%NGK28=4EJ)luT3Bc3E;ZQTQK9 zcX}dsaQ&AuvB70O_og_Qrtvi)sMwS|&eCo18r0m^PB!@FW&5~5>nO5F)Tn2{X5eRS z#u9aMAyzKh>h`t~F3iq>Gz6@@B)E`!64&CW<4hZSa#1Yn321|Z^6!@!RKp31bDQwCqjnIR_2{b#MzueT@Zt`r zds3s&>7F~esmm-1<{A?gfSpq0LLf?=s=or|*m=>YouJySu1k8yOJ{d&lUEGn@ zx@Yh*a5Jv@`s@U?BIWJG*{WGv7Fg>QcWe6g6)z2*J*0(=+{j+gz2*4wGkj*E&lIeC zu(MmPiBF)Sjt`9aBpYhOUe?J+Q^R8#m@V2V9UEXDrxSx6zNfs7O_f|bBgA#T%wN8q zO275Bq5>{fNvZE(3U{&Ls%NlX;j2aT65Q-oPiA^sz5~*F{pOSSQOip{9+WJ>=j%)< zg)o<=p=?vw_T%7cwLH$zONl7X4#%3}5gw+x|M!rPx9tBgehlCOz%P&hcO4}ry;cKK zp$XC8U?U%UOKfaypQK^MZCaw%Rb)iz;GihgA5O}N5uz4iww8|7Z&}@e_pL|Od*iMO zhRhVStP^~keD z@c()P?muCWO5;C2CmaEp*E!!fFf87GOjq20@ct9y`3Uy9aLn)V;qsndPtGGrFq3aN zI0%oWABpUpFf85EgiWNK4Ezu%@9d92?ff|?b?+cThGZh|%C5;; zUxfD8+O6T^wM~vP5`Br8i2v&Uz5jFU&mN&8@PGcV3Ncf-KK&6fqg?o2+MoCWiP`*u zU`KXn8HsS4^8uYpIaeTfXcmt+F!M)JI3c$`BG+D6nnC_g!M|gMeR3!tqQ&r$@_)qS zs@2d>2&e&#Wdxz(5?oM;JM+eSdlR*{kCvmiD$BEa`Jz!?u}tVuQC+hqyFQ&(uB1nT zwff<^zwMi@`h)0HTlKHr>1q6WA$W(5E$X0@!onikMhLI2#|Eze(T?)<54us#1mv2Y zVJ06!_ycK(X8i%nz1Fdp;?(*7l;9A$2}u7>CQ%bLL%AZqQq)G_Os#lw)RgnUtdAU4 zLaqcciwwN!pzD<&1;^WHhQj?6u{P5*vhD`{UrG2)Iu5lY6(f^8LB@t+(JabZV_KN# zeLwAA$qNaxNn#~q&YTGY(ZGK6U*8h?V+vI$0U%++7W}ItafcK?vvkYCf$DbGI8OT3lG#jmi1qgD5ubmG7r4jrUz-+~-&y4?pV9 z+_yL~)5&G*6!Lj>AkC)4x@9*3(!27#&$XBz`q<%%+oo9T_TJQDE*; zI%U&7CP2lhzSQ0jtq>=gZ< z)W%BU*`(!G^@#J~LRX>C3&WKa^|qhdzP4DgH9;o&LHv@yO8R@LL40^(Qz$T_}1I(gx+ z{m}2g@df;}E*}Aw&?#q7rrl@c6buF5XC%pX7qtqxLd?dxOE-J#7GVGee_4X~OA;k3=*KWi_YX?0og0(9-i<|>Xs67d+*7;dOi?aN)!uEG>i#Bnhayz@R|aK;1_ioIRv6L1K0e;R45mfvH! z+ixCm8F_cr=)_fP)1->ASLr0Byff^%1gwo}&@$2+xgx%WELZLXZFfQWAH?7q7p5j;Z zr70(A^>2M63w|A#^qIDGJFt1qfovy=j2(Vj$Ni073VSCveiR>GsaEw5N465o)mp3h zEnKPdmwJ&K*9t~Y z)x9j}sq;tj`|B?iT~_6(uu!i;YaT){b{|D;@mbzstNb3o13-?Wrs^-2vNdw4!TIGZNm4_I_2=;WyOdnj zHu_NBQ7prY>O_d@?__zPBii!*PytfGzcUpW_(}L-1M2W?UX8J8gG0I#9Nf&r6=(hN zb^y+HWT|;&u#n3ux=xp#g?poDG?w|_*#8Cr(o-p$K;Ik=CKQOQkxYg z^DX8Vv)WOD=x^Yt9^{dinL5P3`Hg?%bd~P>M4u#u7oW8T#XLUvK&Ujej;2Z1_CMV$L^FgfzH@A7j-=|5-Xhmb|gfj%Nn^a+)Yym9WeshF)x zS`-!hRc!-BX2Z9&(;hh^Q?(4V=L^=w>g$3H-72MsuJxigM7t>$Z)+!Sdk2S*zht*6 z!pUFTSbAU^296Vz$(7z<(^R&-*js-6*_;+w=Sct^d})a*#Q+QaQ@eQaVMnnW^zk@` zt8i@+Af8)0V2VMMfMcRAIx$qQrHzAyW-ZB(&&`w1?5zdED7m1(l+_nQ`DLi}TAD5i zi=e2K83C1lsZ8TZW~-&;3*!BT>5(6q4m zOdBP3f`)Rw6pmj6w(o~vOz}Q@+s~O@af5ltY{%<#GgaHkD-log#WJC<*zSW^V{}Ga zP;{kw)~h%GH;+1_PQp`wx0WE-rB$Lmkw|>DU9R{_G*_#Z5*;z=Qb;euN3*q>28y1mn)a6#n3jem*bgTRywtQqo3w#E%F9SPP4_7$hR4BD z{5jgziyAC7I|;*So}D_Z=;Tu5sFf7{SwT>I zpDXp1MS-t8+b{wj9<1ujFr&TTDtKjcwG_mq3;5|<9a`NaJ%NQX(`r6SC#K^v%-JFM zNv1_rz%Uk$_2*H)n;*z4K|f;<)ov@9Y^vDMwLcba*ps0IK$1%Inn|1&9SP~= z(gK1oNPEaExmq^|l8sh70&z0)BDeq*ZrKc89FXUqJV~qt_oHUGNMcr4|Bq6A!|Q^f z`&;+v*KIs`PmPN|u@p}&w-4Bl8(a%D4}YZ0f2e1t0uHJ04j1}^{?qz!J|yA%bI`LZ zKZ0XYU5RLrFFN*Ev$4xbdFm;Q=v)0j1<7b>V~sY=VREEXInWWfC~i8z3xppk{I6=4 z>=Z_g>K%0&uC>ws&g0{LZw}%Z5t=S_oj6h;5=0o0^B4!o@_jkPzc_hkaER?vcksq^ zx`l%#+#Xr@`IMzZo`XKhrPpiSpi<;Vlw#!H`23_aHah#$-}wSm9VyO(s9mHCzl8R& z?Qs;IOZ-?0VnVQ@2ngo*B_;;vNcm$cPkFDk4s4pTYk|f7@Bx~vozn8TVeuG?mdCO~6{sNPJvH?75ya8ZH zNh>!Zf8JtgzsvRO{_Oc!Ug@@YfSlkD$!3Lne3xVsovZl%j!<95pk09p4jNeM561O# z0k7MBdbO*kbpGjERc{4^wcwvEgJuwQRKR1j>U+vSpE({yKNCv*hx5@E~U)1se!; z6LJ+|Q4TiJzdI0R>o+kdwPSrGL{Pz-ztzuCNvT<6Nv_1C{6c{RPUFKd7lMLrgaS?fBfTttRo$k0B@{t7jTjRnBtO+-7WbTH zTVsa+vUL`VaUw)~lgy}HnDa;qlncwU$^QD@;w}E!VD}zAxOe%<(8(#+rb)_1w!3o)nZsPuvOvFSu%?8OZR&w{}PtLhKDd(X0%-K*EG8#q z^|4#;G5kCJO=x~xLWo(Xj?I__;G}l;zV^0!5Vo9Q%W$01oN{An*r9GZ=^=eL#RM_- z99P@o^}PKuAhFos$nJV-EM*)%+?J6}!b3p~eH%^Q_Od+rtjR%KK$Gu5Oo^{{CysF5 zQrsSHiX{hlo83JVGO^Ao;}x-gj$3i%NUdRz0fxtb^X>?tI5CvSYI;1JE_9#8k7+3% zOflAcSUKQpy=1X@7aI9#@ zMUR+UlPeO2xUU?=S`Ce2GjwKlSw%aWoIe{4<|iwFK0MwVacvE{!PQnZk^Z5J>RC}z z(8l2~m*ZkB@z-P(3SU+$J-$e;Sn_h93&UrQ+NO{0$1$V1q}X$*oz&Bptm96$O_Sw`cuj-0Aq<9Gn$q&ZTL)MAYT>)fi?_V2?!Bx~@PXI7_Sm=-P==ts-V^(U z2gs#KT7~#fC4Oy{if zaujy+3}7g0?9zd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 Si#`hQr>S;FwNS each if var.create_openai_networking == false } -# name = each.value.subnet_name -# virtual_network_name = var.virtual_network_name -# resource_group_name = var.network_resource_group_name -# } - -# data "azurerm_cosmosdb_account" "mongo" { -# for_each = { for each in var.cosmosdb_name : each.value => each if var.create_cosmosdb == false } -# name = each.value -# resource_group_name = var.cosmosdb_resource_group_name -# } \ No newline at end of file diff --git a/examples/Coming_soon.md b/examples/Coming_soon.md new file mode 100644 index 0000000..e69de29 diff --git a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/README.md b/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/README.md deleted file mode 100644 index d33f89c..0000000 --- a/examples/PrivateGPT_w_AFD_WAF_existing_DNS_zone/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# Private ChatGPT with Azure Front Door + Firewall on existing DNS zone - -This example will create a Privately hosted instance of ChatBot/ChatGPT on Azure OpenAI with AFD + WAF using an existing DNS zone for the custom domain configuration. This example will create the following: - -## Prerequisites - -- Create a resource group to deploy all resources for the solution. - -## Create OpenAI Service - -1. Create an Azure Key Vault to store the OpenAI account details. -2. Create an OpenAI service account. - Other options include: - - Specify an already existing OpenAI service account to use. - -3. Create OpenAI language model deployments on the OpenAI service. (e.g. GPT-3, GPT-4, etc.) -4. Store the OpenAI account and model details in the key vault for consumption. - -## Create a container app ChatBot UI linked with OpenAI service hosted in Azure - -1. Create a container app log analytics workspace (to link with container app). -2. Create a container app environment. -3. Create a container app instance hosting chatbot-ui from image/container. -4. Link chatbot-ui with corresponding OpenAI account and language model deployment. -5. Grant the container app access to the key vault to retrieve secrets (optional). - -## Front solution with an Azure front door (optional) - -1. Deploy Azure Front Door to front solution with CDN + WAF. -2. Setup a custom domain in Azure Front Door with AFD managed certificate. - Other options include: - - This example specifies an already existing DNZ zone to use. (e.g. `existingzone.com` - see `common.auto.tfvars`) - - **Note:** Remember to add the zone to your DNS registrar as the module creates a TXT auth. (Certificates fully managed by AFD) - -3. Create a CNAME and TXT record in the custom DNS zone. (e.g. `privategpt.existingzone.com`) -4. Setup and apply an AFD WAF policy with `IPAllow list` for allowed IPs to connect using a custom rule. - - -## Requirements - -No requirements. - -## Providers - -| Name | Version | -|------|---------| -| [azurerm](#provider\_azurerm) | n/a | - -## Modules - -| Name | Source | Version | -|------|--------|---------| -| [private-chatgpt-openai](#module\_private-chatgpt-openai) | Pwd9000-ML/openai-private-chatgpt/azurerm | >= 1.1.0 | - -## Resources - -| Name | Type | -|------|------| -| [azurerm_resource_group.rg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | -| [azurerm_key_vault.gpt](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/key_vault) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [ca\_container\_config](#input\_ca\_container\_config) | type = object({
    name = (Required) The name of the container.
    image = (Required) The name of the container image.
    cpu = (Required) The number of CPU cores to allocate to the container.
    memory = (Required) The amount of memory to allocate to the container in GB.
    min\_replicas = (Optional) The minimum number of replicas to run. Defaults to `0`.
    max\_replicas = (Optional) The maximum number of replicas to run. Defaults to `10`.
    env = list(object({
    name = (Required) The name of the environment variable.
    secret\_name = (Optional) The name of the secret to use for the environment variable.
    value = (Optional) The value of the environment variable.
    }))
    }) |