diff --git a/.gitignore b/.gitignore index 600a4e9..0c8dfd6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -# Created by https://www.toptal.com/developers/gitignore/api/python,linux -# Edit at https://www.toptal.com/developers/gitignore?templates=python,linux +# Created by https://www.toptal.com/developers/gitignore/api/linux,python,terraform +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,python,terraform ### Linux ### *~ @@ -188,4 +188,40 @@ poetry.toml # LSP config files pyrightconfig.json -# End of https://www.toptal.com/developers/gitignore/api/python,linux +### Terraform ### +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +# End of https://www.toptal.com/developers/gitignore/api/linux,python,terraform diff --git a/awx/uptime_app.yaml b/awx/uptime_app.yaml new file mode 100644 index 0000000..bb54a2e --- /dev/null +++ b/awx/uptime_app.yaml @@ -0,0 +1,167 @@ +--- +- name: Deploy Uptime Kuma on GCP and Configure Non-Interactively + hosts: localhost + connection: local + gather_facts: false + + vars: + # GCP Configuration + gcp_project: "your-gcp-project-id" + gcp_region: "us-central1" + gcp_zone: "us-central1-a" + instance_name: "uptime-kuma-vm" + machine_type: "e2-small" # 2GB RAM is plenty for Uptime Kuma + static_ip_name: "uptime-kuma-ip" + firewall_rule_name: "allow-uptime-kuma-3001" + + # Uptime Kuma Credentials & Target + kuma_admin_user: "admin" + kuma_admin_pass: "ChangeMeImmediately123!" + target_monitor_name: "Target Web App" + target_monitor_url: "https://example.com" + + tasks: + # ------------------------------------------------------------- + # 1. Local Prerequisites + # ------------------------------------------------------------- + - name: Ensure uptime-kuma-api Python library is installed locally + ansible.builtin.pip: + name: uptime-kuma-api + state: present + + # ------------------------------------------------------------- + # 2. Allocate Static Public IP (Idempotent) + # ------------------------------------------------------------- + - name: Check if static IP exists + ansible.builtin.command: > + gcloud compute addresses describe {{ static_ip_name }} + --region={{ gcp_region }} + --project={{ gcp_project }} + register: ip_check + failed_when: false + changed_when: false + + - name: Allocate static external IP in GCP + ansible.builtin.command: > + gcloud compute addresses create {{ static_ip_name }} + --region={{ gcp_region }} + --project={{ gcp_project }} + --description="Static IP for Uptime Kuma" + when: ip_check.rc != 0 + + # ------------------------------------------------------------- + # 3. Create Firewall Rule (Port 3001) + # ------------------------------------------------------------- + - name: Check if firewall rule exists + ansible.builtin.command: > + gcloud compute firewall-rules describe {{ firewall_rule_name }} + --project={{ gcp_project }} + register: fw_check + failed_when: false + changed_when: false + + - name: Allow TCP traffic to port 3001 + ansible.builtin.command: > + gcloud compute firewall-rules create {{ firewall_rule_name }} + --project={{ gcp_project }} + --allow=tcp:3001 + --target-tags=uptime-kuma + --source-ranges=0.0.0.0/0 + --description="Allow traffic to Uptime Kuma UI & API" + when: fw_check.rc != 0 + + # ------------------------------------------------------------- + # 4. Provision VM with Container-Optimized OS + # ------------------------------------------------------------- + - name: Check if instance already exists + ansible.builtin.command: > + gcloud compute instances describe {{ instance_name }} + --zone={{ gcp_zone }} + --project={{ gcp_project }} + register: instance_check + failed_when: false + changed_when: false + + - name: Launch Compute Engine VM with Uptime Kuma container + ansible.builtin.command: > + gcloud compute instances create-with-container {{ instance_name }} + --project={{ gcp_project }} + --zone={{ gcp_zone }} + --machine-type={{ machine_type }} + --tags=uptime-kuma + --address={{ static_ip_name }} + --boot-disk-size=20GB + --container-image=louislam/uptime-kuma:1 + --container-mount-host-path=host-path=/var/uptime-kuma,mount-path=/app/data,mode=rw + when: instance_check.rc != 0 + + # ------------------------------------------------------------- + # 5. Fetch Public IP and Wait for Service Readiness + # ------------------------------------------------------------- + - name: Fetch instance public IP address + ansible.builtin.command: > + gcloud compute instances describe {{ instance_name }} + --zone={{ gcp_zone }} + --project={{ gcp_project }} + --format="value(networkInterfaces[0].accessConfigs[0].natIP)" + register: vm_ip + changed_when: false + + - name: Display Uptime Kuma URL + ansible.builtin.debug: + msg: "Uptime Kuma is booting at: http://{{ vm_ip.stdout.strip() }}:3001" + + - name: Wait for Uptime Kuma to respond with HTTP 200 + ansible.builtin.uri: + url: "http://{{ vm_ip.stdout.strip() }}:3001" + status_code: 200 + register: readiness + until: readiness.status == 200 + retries: 30 + delay: 6 + + # ------------------------------------------------------------- + # 6. Non-Interactive Configuration via API + # ------------------------------------------------------------- + - name: Perform headless setup and add monitor + ansible.builtin.command: + cmd: > + python3 -c " + import sys + from uptime_kuma_api import UptimeKumaApi, MonitorType + + kuma_url = 'http://{{ vm_ip.stdout.strip() }}:3001' + api = UptimeKumaApi(kuma_url) + + # 1. Initial admin account creation if not yet initialized + if api.need_setup(): + api.setup('{{ kuma_admin_user }}', '{{ kuma_admin_pass }}') + print('ADMIN_CREATED') + + # 2. Login + api.login('{{ kuma_admin_user }}', '{{ kuma_admin_pass }}') + + # 3. Add monitor idempotently + current_monitors = [m['name'] for m in api.get_monitors()] + if '{{ target_monitor_name }}' not in current_monitors: + api.add_monitor( + type=MonitorType.HTTP, + name='{{ target_monitor_name }}', + url='{{ target_monitor_url }}', + interval=60 + ) + print('MONITOR_ADDED') + else: + print('MONITOR_ALREADY_EXISTS') + + api.disconnect() + " + register: setup_output + changed_when: "'MONITOR_ADDED' in setup_output.stdout" + + - name: Show setup summary + ansible.builtin.debug: + msg: + - "Setup Status: {{ setup_output.stdout.strip() }}" + - "Access UI at: http://{{ vm_ip.stdout.strip() }}:3001" + - "Username: {{ kuma_admin_user }}" diff --git a/iac/.terraform.lock.hcl b/iac/.terraform.lock.hcl index 1c950cc..a44f1f8 100644 --- a/iac/.terraform.lock.hcl +++ b/iac/.terraform.lock.hcl @@ -1,42 +1,6 @@ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. -provider "registry.opentofu.org/hashicorp/archive" { - version = "2.8.1" - hashes = [ - "h1:+zxWNwWTN6nQH4UsAHVU2dHFeQ1dbyti1imUwXo2UkM=", - "h1:0nQbGAtw2FkzedC/AoP5stVvIEQT7ae04UDUE7K+rkQ=", - "h1:4BH/NzcaIQ+HtfGhBo9boDdYTYLimD9Aw81bXyx4PQ8=", - "h1:6cIzOvUMhFPYYCVpuraJCp1OBKQfIdt+vweg/t90nqY=", - "h1:D0qy1mIABEFzQEwuASo4g+oGRgxodYsHhbU2VzmafkY=", - "h1:Dwonz53sAPonmKnk+YNwbjw0uEatDVPTj/dtfCwBQVw=", - "h1:FQLyzsDwRhDvov/FU4I/HTnt9jdNFivK1hmBVxhgj5Y=", - "h1:FwIDfXJo5yqRq9DjC8u3cXfGVQhHje3bpdxUufTT4wI=", - "h1:M561F8TZn57Si9KJ8ycdyGGOIDSx6uzeILfn3ODTZ7I=", - "h1:Yg4us0ttDVnZnECv/eo1Scg2D00bZ8ENxD47TVt/HYQ=", - "h1:l7EcsA0WkfQ1ylEm7q1xfJei3bUMFriPybZLIuRCkzg=", - "h1:oOm3HeeUHp36HGT2EXEoLX+B3mGz+MIF8BazNsAOB7I=", - "h1:rhohSLLoDQyaUzetRvvlrbjZ2LAEfVCyUdD/Or10jos=", - "h1:wcukYOssMr3IzGWQUMQkX7On5WeBmBTNIpvPsK3ac90=", - "h1:xTRO4rh/BOjz11omsMID2q0MCchmCTX+tgI6v5/OMQw=", - "zh:167dcb2f3dfef941d300e4899c6b1852592e98cd5e20782289369bf0c81512f0", - "zh:1e0bbf0e538631a49746ff23c7008c7de7d0850e4477a1aee7685cc9881a942a", - "zh:4310e3fc60442f17165fef1a97eae8be3778ae9611a526eb5b19647ffbab98bc", - "zh:5700d38d2dbf82f1c88dd72aae3cadd12b177554a4ef99bea9bb949fb1961412", - "zh:69e2209971a4f87e45c01d3e7581f6864abb1502e7c44809b4b0ffdfdc1d3d5d", - "zh:6adbd69f1279d29dfe17fc7eab8e72fffa9ab0c562f87a3f73aaf5d63fe352d8", - "zh:76e3cdadb03e6c8a7a5d535d7b3c2509a7e5ba9c37fac66ca360d271f5dc5c17", - "zh:8a5bb2ce0746b28f12770e473022076c55e32ea942b514150cf2b07fa673d48a", - "zh:9612186dd905850d20144101c722da59a2de660c2bf931a786ac63fe3766f5a6", - "zh:a77922d3edcc1288d52e5d225e0b98d6ae8b3ef8ef4f27a40a6c11136489b886", - "zh:ac70801bfaa2d39e339c39d4989199981b4d4dcbd0cf1c65a9755940ef2a80ee", - "zh:af382792a3ca715ef9f740f18ee1b1c4256e757e93fd5204ab5f6c1757ba2555", - "zh:c2bd89b4fc81883f410851f3e4499ee9b2d14039b97a04a2edc9fb4577bcb38e", - "zh:f9a8ee2c68f67bc6eefbce642594d4fd51247542599bed8ac47ec8139615c2ce", - "zh:fa64f6907c6daf715ad0c435b410fc352567118297f143ee2760230b4592bfde", - ] -} - provider "registry.opentofu.org/hashicorp/azurerm" { version = "4.81.0" constraints = "~> 4.0" diff --git a/iac/function_app.zip b/iac/function_app.zip index 186e38a..1387116 100644 Binary files a/iac/function_app.zip and b/iac/function_app.zip differ diff --git a/iac/functions.tf b/iac/functions.tf index da0d5ea..7ef2f7a 100644 --- a/iac/functions.tf +++ b/iac/functions.tf @@ -28,13 +28,6 @@ resource "azurerm_storage_account" "example" { account_replication_type = var.sa_account_replication_type } -# Create a storage container -resource "azurerm_storage_container" "example" { - name = "example-flexcontainer" - storage_account_id = azurerm_storage_account.example.id - container_access_type = "private" -} - # Create a Log Analytics workspace for Application Insights resource "azurerm_log_analytics_workspace" "example" { name = coalesce(var.ws_name, random_string.name.result) @@ -50,92 +43,48 @@ resource "azurerm_application_insights" "example" { location = azurerm_resource_group.example.location resource_group_name = azurerm_resource_group.example.name application_type = "web" - workspace_id = azurerm_log_analytics_workspace.example.id + workspace_id = azurerm_log_analytics_workspace.example.id } -# Create a service plan +# Create a service plan. Custom container images require a Dedicated (or Premium) +# plan - Consumption/Flex Consumption plans don't support bring-your-own-container. resource "azurerm_service_plan" "example" { name = coalesce(var.asp_name, random_string.name.result) resource_group_name = azurerm_resource_group.example.name location = azurerm_resource_group.example.location - sku_name = "FC1" + sku_name = var.asp_sku_name os_type = "Linux" } -data "archive_file" "function_app" { - type = "zip" - source_dir = "${path.module}/.." - output_path = "${path.module}/function_app.zip" - excludes = [".git", ".gitea", "iac", "helm-chart", ".terraform*", ".vscode"] -} - -resource "azurerm_storage_blob" "appcode" { - name = "app-${random_string.name.result}.zip" - storage_account_name = azurerm_storage_account.example.name - storage_container_name = azurerm_storage_container.example.name - type = "Block" - source = data.archive_file.function_app.output_path -} - -data "azurerm_storage_account_sas" "sas" { - connection_string = azurerm_storage_account.example.primary_connection_string - https_only = true - - resource_types { - service = false - container = false - object = true - } - - services { - blob = true - queue = false - table = false - file = false - } - - start = "2023-01-01T00:00:00Z" - expiry = "2029-01-01T00:00:00Z" - - permissions { - read = true - write = false - delete = false - list = false - add = false - create = false - update = false - process = false - tag = false - filter = false - } -} - -# Create a function app -resource "azurerm_function_app_flex_consumption" "example" { +# Create a function app that runs the prebuilt container image straight from Docker Hub +resource "azurerm_linux_function_app" "example" { name = coalesce(var.fa_name, random_string.name.result) resource_group_name = azurerm_resource_group.example.name location = azurerm_resource_group.example.location service_plan_id = azurerm_service_plan.example.id - storage_container_type = "blobContainer" - storage_container_endpoint = "${azurerm_storage_account.example.primary_blob_endpoint}${azurerm_storage_container.example.name}" - storage_authentication_type = "StorageAccountConnectionString" - storage_access_key = azurerm_storage_account.example.primary_access_key - runtime_name = var.runtime_name - runtime_version = var.runtime_version - maximum_instance_count = 50 - instance_memory_in_mb = 2048 + storage_account_name = azurerm_storage_account.example.name + storage_account_access_key = azurerm_storage_account.example.primary_access_key site_config { application_insights_connection_string = azurerm_application_insights.example.connection_string + + application_stack { + docker { + registry_url = "https://index.docker.io" + image_name = var.container_image_name + image_tag = var.container_image_tag + } + } } app_settings = { - "OLLAMA_HOST" = "http://harbor.freshbrewed.science:11444" - "OLLAMA_MODEL" = "gemma4:e4b" - "JOKE_PROMPT" = "tell me a dad joke about programming" - "PORT" = "80" - "WEBSITE_RUN_FROM_PACKAGE" = "${azurerm_storage_account.example.primary_blob_endpoint}${azurerm_storage_container.example.name}/${azurerm_storage_blob.appcode.name}${data.azurerm_storage_account_sas.sas.sas}" + "OLLAMA_HOST" = "http://harbor.freshbrewed.science:11444" + "OLLAMA_MODEL" = "gemma4:e4b" + "JOKE_PROMPT" = "tell me a dad joke about programming" + "PORT" = "80" + "WEBSITES_PORT" = "80" + "WEBSITES_ENABLE_APP_SERVICE_STORAGE" = "false" } } + diff --git a/iac/outputs.tf b/iac/outputs.tf index 7f42c46..7d5ea79 100644 --- a/iac/outputs.tf +++ b/iac/outputs.tf @@ -12,9 +12,9 @@ output "asp_name" { } output "fa_name" { - value = azurerm_function_app_flex_consumption.example.name + value = azurerm_linux_function_app.example.name } output "fa_url" { - value = "https://${azurerm_function_app_flex_consumption.example.name}.azurewebsites.net" + value = "https://${azurerm_linux_function_app.example.name}.azurewebsites.net" } diff --git a/iac/variables.tf b/iac/variables.tf index 6b74787..4f87431 100644 --- a/iac/variables.tf +++ b/iac/variables.tf @@ -89,14 +89,20 @@ variable "fa_name" { default = "" } -variable "runtime_name" { - description = "The name of the language worker runtime." +variable "asp_sku_name" { + description = "The SKU of the App Service Plan hosting the Function App. Must be a Dedicated or Premium SKU to support custom containers (e.g. B1, P1v2, EP1)." type = string - default = "python" # Allowed: dotnet-isolated, java, node, powershell, python + default = "B1" } -variable "runtime_version" { - description = "The version of the language worker runtime." +variable "container_image_name" { + description = "The Docker Hub image name (without tag) to deploy to the Function App." type = string - default = "3.11" # Supported versions: see https://aka.ms/flexfxversions + default = "idjohnson/dadjokeapp" +} + +variable "container_image_tag" { + description = "The tag of the Docker Hub image to deploy to the Function App." + type = string + default = "0.1" }