From c78db9b1c4db71b23b31c02709eb9606a57786a6 Mon Sep 17 00:00:00 2001 From: Tim Penhey Date: Thu, 3 Jul 2025 10:26:09 +1200 Subject: [PATCH 01/10] Allow hyphens in the prefix. --- storage.tf | 9 +++++++-- vars.tf | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/storage.tf b/storage.tf index 840b425..0a009d0 100644 --- a/storage.tf +++ b/storage.tf @@ -14,6 +14,11 @@ # # SPDX-License-Identifier: Apache-2.0 +locals { + # Storage account name must be 3-24 characters and cannot contain hyphens. + prefix_no_hyphens = replace(var.prefix, "-", "") +} + resource "random_string" "storage_account_suffix" { special = false length = 24 @@ -22,8 +27,8 @@ resource "random_string" "storage_account_suffix" { } resource "azurerm_storage_account" "stacklet" { - # there is a global uniquness constraing on storage account names, as well as a length requirement of 3-24 characters - name = substr("${var.prefix}${random_string.storage_account_suffix.result}", 0, 23) + # there is a global uniqueness constraint on storage account names, as well as a length requirement of 3-24 characters + name = substr("${local.prefix_no_hyphens}${random_string.storage_account_suffix.result}", 0, 23) resource_group_name = azurerm_resource_group.stacklet_rg.name location = azurerm_resource_group.stacklet_rg.location account_tier = "Standard" diff --git a/vars.tf b/vars.tf index a59777b..7efab38 100644 --- a/vars.tf +++ b/vars.tf @@ -18,14 +18,14 @@ variable "prefix" { type = string description = "A Prefix for all of the generated resources" validation { - condition = can(regex("^[a-z0-9]+$", var.prefix)) - error_message = "Prefix should contain only numbers and lowercase letters" + condition = can(regex("^[a-z](-?[a-z0-9]+)*$", var.prefix)) + error_message = "Prefix must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens" } } variable "resource_group_location" { type = string - description = "Resource Group location for generated resoruces" + description = "Resource Group location for generated resources" } variable "event_grid_topic_name" { From 981adfb98375599b53551b4fdc544a9c003d9715 Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 23 Jul 2025 17:00:46 +1000 Subject: [PATCH 02/10] Use a real resource for app role assignment Rather than shelling out to `az rest`. Upgrades will need to `terraform import azuread_app_role_assignment.stacklet_app_role_assignment /servicePrincipals//appRoleAssignedTo/`. The service principal ID is the `object_id` field of `azuread_service_principal.stacklet_sp[0]`, and you can find the role assignment ID with `az rest --method GET --uri "https://graph.microsoft.com/v1.0/servicePrincipals//appRoleAssignedTo"` --- locals.tf | 2 -- main.tf | 15 ++++----------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/locals.tf b/locals.tf index 7c5bb10..776a47b 100644 --- a/locals.tf +++ b/locals.tf @@ -15,9 +15,7 @@ # SPDX-License-Identifier: Apache-2.0 locals { - object_id = azurerm_user_assigned_identity.stacklet_identity.principal_id app_role_id = var.azuread_application == null ? random_uuid.app_role_uuid.id : data.azuread_application.stacklet_application[0].app_role_ids.AssumeRoleWithWebIdentity - resource_id = local.azuread_service_principal.object_id audience = "api://stacklet/provider/azure/${var.aws_target_prefix}" diff --git a/main.tf b/main.tf index 64c359a..efd6efd 100644 --- a/main.tf +++ b/main.tf @@ -85,15 +85,8 @@ data "azuread_service_principal" "stacklet_sp" { display_name = var.azuread_application } -resource "null_resource" "stacklet" { - depends_on = [local.azuread_application, local.azuread_service_principal] - provisioner "local-exec" { - command = < Date: Thu, 3 Jul 2025 14:39:32 +1200 Subject: [PATCH 03/10] Provide a default for the resource group location. --- vars.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/vars.tf b/vars.tf index 7efab38..8baa42d 100644 --- a/vars.tf +++ b/vars.tf @@ -26,6 +26,7 @@ variable "prefix" { variable "resource_group_location" { type = string description = "Resource Group location for generated resources" + default = "East US" } variable "event_grid_topic_name" { From 4ab7f2cb5e959a17511a7aa618b603d58cadcfaf Mon Sep 17 00:00:00 2001 From: Tim Penhey Date: Thu, 3 Jul 2025 15:20:47 +1200 Subject: [PATCH 04/10] Be explicit about the subscription id. --- provider.tf | 14 ++++++++++++++ vars.tf | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/provider.tf b/provider.tf index 53619ad..ae17940 100644 --- a/provider.tf +++ b/provider.tf @@ -14,6 +14,20 @@ # # SPDX-License-Identifier: Apache-2.0 +# Note: Unlike AWS provider, Azure provider (azurerm) does not support +# default_tags configuration. We use local.tags instead to achieve +# consistent tagging across all resources. +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">=4.35.0" + } + } +} + provider "azurerm" { features {} + + subscription_id = var.subscription_id } diff --git a/vars.tf b/vars.tf index 8baa42d..8479db6 100644 --- a/vars.tf +++ b/vars.tf @@ -14,6 +14,12 @@ # # SPDX-License-Identifier: Apache-2.0 +variable "subscription_id" { + type = string + description = "Azure subscription ID. This could also be set using the ARM_SUBSCRIPTION_ID environment variable." + default = null +} + variable "prefix" { type = string description = "A Prefix for all of the generated resources" From 22b70d45fe121b74025cdc5e545d8166c6cc84af Mon Sep 17 00:00:00 2001 From: Tim Penhey Date: Thu, 3 Jul 2025 10:30:36 +1200 Subject: [PATCH 05/10] The subscription env var wasn't used by the function, so don't set it. --- function.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/function.tf b/function.tf index 1fbdaa0..956a9a1 100644 --- a/function.tf +++ b/function.tf @@ -52,7 +52,6 @@ resource "azurerm_linux_function_app" "stacklet" { AZURE_CLIENT_ID = azurerm_user_assigned_identity.stacklet_identity.client_id AZURE_AUDIENCE = local.audience AZURE_STORAGE_QUEUE_NAME = azurerm_storage_queue.stacklet.name - AZURE_SUBSCRIPTION_ID = data.azurerm_subscription.current.subscription_id AWS_TARGET_ACCOUNT = var.aws_target_account AWS_TARGET_REGION = var.aws_target_region AWS_TARGET_ROLE_NAME = var.aws_target_role_name From 1afed0e6b893ae33aa4411ac05ddae95b592b2dc Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 23 Jul 2025 17:31:33 +1000 Subject: [PATCH 06/10] Allow resource group name to be configured --- main.tf | 6 +++++- vars.tf | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/main.tf b/main.tf index efd6efd..4029090 100644 --- a/main.tf +++ b/main.tf @@ -27,8 +27,12 @@ data "azurerm_role_definition" "builtin" { resource "random_uuid" "app_role_uuid" {} +locals { + resource_group_name = var.resource_group_name == null ? "${var.prefix}-stacklet-relay" : var.resource_group_name +} + resource "azurerm_resource_group" "stacklet_rg" { - name = var.prefix + name = local.resource_group_name location = var.resource_group_location tags = local.tags } diff --git a/vars.tf b/vars.tf index 8479db6..4aa7e83 100644 --- a/vars.tf +++ b/vars.tf @@ -29,6 +29,12 @@ variable "prefix" { } } +variable "resource_group_name" { + type = string + description = "Resource Group name for generated resources" + default = null +} + variable "resource_group_location" { type = string description = "Resource Group location for generated resources" From e9322313663dc4148b505687bf95df9256f0a15a Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 23 Jul 2025 17:33:24 +1000 Subject: [PATCH 07/10] Allow the resource group to be force-deleted. Handy for testing --- provider.tf | 6 +++++- vars.tf | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/provider.tf b/provider.tf index ae17940..63161fa 100644 --- a/provider.tf +++ b/provider.tf @@ -27,7 +27,11 @@ terraform { } provider "azurerm" { - features {} + features { + resource_group { + prevent_deletion_if_contains_resources = !var.force_delete_resource_group + } + } subscription_id = var.subscription_id } diff --git a/vars.tf b/vars.tf index 4aa7e83..1dbbc6c 100644 --- a/vars.tf +++ b/vars.tf @@ -41,6 +41,12 @@ variable "resource_group_location" { default = "East US" } +variable "force_delete_resource_group" { + type = bool + description = "Force delete the resource group when terraform destroy is run" + default = false +} + variable "event_grid_topic_name" { type = string description = "System Topic Name for subscription events if it already exists" From 8bbb650a60ceb889f5cd00b2042540e9069716be Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 23 Jul 2025 19:01:36 +1000 Subject: [PATCH 08/10] Fix azurerm_linux_function_app plan noise See https://github.com/hashicorp/terraform-provider-azurerm/issues/16569 for discussion. --- function.tf | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/function.tf b/function.tf index 956a9a1..c40e6f5 100644 --- a/function.tf +++ b/function.tf @@ -44,11 +44,11 @@ resource "azurerm_linux_function_app" "stacklet" { application_stack { python_version = "3.10" } + application_insights_key = azurerm_application_insights.stacklet.instrumentation_key } app_settings = { SCM_DO_BUILD_DURING_DEPLOYMENT = true - APPINSIGHTS_INSTRUMENTATIONKEY = azurerm_application_insights.stacklet.instrumentation_key AZURE_CLIENT_ID = azurerm_user_assigned_identity.stacklet_identity.client_id AZURE_AUDIENCE = local.audience AZURE_STORAGE_QUEUE_NAME = azurerm_storage_queue.stacklet.name @@ -64,6 +64,12 @@ resource "azurerm_linux_function_app" "stacklet" { identity_ids = [azurerm_user_assigned_identity.stacklet_identity.id] } tags = local.tags + + lifecycle { + ignore_changes = [ + tags["hidden-link: /app-insights-resource-id"] + ] + } } resource "local_file" "function_json" { From 05861199a34a0e3b5f37186240096c8a75dc87cd Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 23 Jul 2025 19:12:38 +1000 Subject: [PATCH 09/10] Fix DOS line endings --- function-app-v1/ProviderRelay/__init__.py | 184 +++++++++++----------- 1 file changed, 92 insertions(+), 92 deletions(-) diff --git a/function-app-v1/ProviderRelay/__init__.py b/function-app-v1/ProviderRelay/__init__.py index 061eeb2..cd76574 100644 --- a/function-app-v1/ProviderRelay/__init__.py +++ b/function-app-v1/ProviderRelay/__init__.py @@ -1,92 +1,92 @@ -# Copyright 2024 Stacklet -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 - -import json -import logging -import os - -import azure.functions as func - -from azure.identity import DefaultAzureCredential - -import boto3 -import botocore - - -def get_session(client_id, audience, role_arn): - client = boto3.client("sts") - creds = DefaultAzureCredential( - managed_identity_client=client_id, exclude_environment_credential=True - ) - token = creds.get_token(audience) - try: - res = client.assume_role_with_web_identity( - WebIdentityToken=token.token, - RoleArn=role_arn, - RoleSessionName="StackletAzureRelay", - ) - except Exception as e: - logging.error(f"unable to assume role:{e}") - raise - - session = boto3.session.Session( - aws_access_key_id=res["Credentials"]["AccessKeyId"], - aws_secret_access_key=res["Credentials"]["SecretAccessKey"], - aws_session_token=res["Credentials"]["SessionToken"], - ) - logging.info("Got session") - return session - - -def main(msg: func.QueueMessage): - client_id = os.environ["AZURE_CLIENT_ID"] - audience = os.environ["AZURE_AUDIENCE"] - - target_account = os.environ["AWS_TARGET_ACCOUNT"] - region = os.environ["AWS_TARGET_REGION"] - role_name = os.environ["AWS_TARGET_ROLE_NAME"] - partition = os.environ["AWS_TARGET_PARTITION"] - role_arn = f"arn:{partition}:iam::{target_account}:role/{role_name}" - - session = get_session(client_id, audience, role_arn) - events_client = session.client("events", region_name=region) - - body_string = msg.get_body().decode("utf-8") - body = json.loads(body_string) - source = body["data"]["operationName"].split("/")[0] - - try: - logging.info('Forwarding event to Stacklet') - logging.info(body_string) - events_client.put_events( - Entries=[ - { - "Time": msg.insertion_time, - "Source": source, - "DetailType": "CloudEvent/Azure System Topic Event", - "Detail": body_string, - "EventBusName": os.environ["AWS_TARGET_EVENT_BUS"], - } - ] - ) - except botocore.exceptions.ClientError as e: - if e.response["Error"]["Code"] == "AccessDeniedException" and str(e).endswith( - "with an explicit deny in a resource-based policy" - ): - logging.warning("skipping event") - return - logging.error(f"failed to put event:{e}") - raise +# Copyright 2024 Stacklet +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +import json +import logging +import os + +import azure.functions as func + +from azure.identity import DefaultAzureCredential + +import boto3 +import botocore + + +def get_session(client_id, audience, role_arn): + client = boto3.client("sts") + creds = DefaultAzureCredential( + managed_identity_client=client_id, exclude_environment_credential=True + ) + token = creds.get_token(audience) + try: + res = client.assume_role_with_web_identity( + WebIdentityToken=token.token, + RoleArn=role_arn, + RoleSessionName="StackletAzureRelay", + ) + except Exception as e: + logging.error(f"unable to assume role:{e}") + raise + + session = boto3.session.Session( + aws_access_key_id=res["Credentials"]["AccessKeyId"], + aws_secret_access_key=res["Credentials"]["SecretAccessKey"], + aws_session_token=res["Credentials"]["SessionToken"], + ) + logging.info("Got session") + return session + + +def main(msg: func.QueueMessage): + client_id = os.environ["AZURE_CLIENT_ID"] + audience = os.environ["AZURE_AUDIENCE"] + + target_account = os.environ["AWS_TARGET_ACCOUNT"] + region = os.environ["AWS_TARGET_REGION"] + role_name = os.environ["AWS_TARGET_ROLE_NAME"] + partition = os.environ["AWS_TARGET_PARTITION"] + role_arn = f"arn:{partition}:iam::{target_account}:role/{role_name}" + + session = get_session(client_id, audience, role_arn) + events_client = session.client("events", region_name=region) + + body_string = msg.get_body().decode("utf-8") + body = json.loads(body_string) + source = body["data"]["operationName"].split("/")[0] + + try: + logging.info('Forwarding event to Stacklet') + logging.info(body_string) + events_client.put_events( + Entries=[ + { + "Time": msg.insertion_time, + "Source": source, + "DetailType": "CloudEvent/Azure System Topic Event", + "Detail": body_string, + "EventBusName": os.environ["AWS_TARGET_EVENT_BUS"], + } + ] + ) + except botocore.exceptions.ClientError as e: + if e.response["Error"]["Code"] == "AccessDeniedException" and str(e).endswith( + "with an explicit deny in a resource-based policy" + ): + logging.warning("skipping event") + return + logging.error(f"failed to put event:{e}") + raise From ba65ba2b348a2af3c417f95d4407e39fe30627a7 Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 23 Jul 2025 19:23:40 +1000 Subject: [PATCH 10/10] Fix resource group name to default to prefix without suffix --- main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.tf b/main.tf index 4029090..fb66127 100644 --- a/main.tf +++ b/main.tf @@ -28,7 +28,7 @@ data "azurerm_role_definition" "builtin" { resource "random_uuid" "app_role_uuid" {} locals { - resource_group_name = var.resource_group_name == null ? "${var.prefix}-stacklet-relay" : var.resource_group_name + resource_group_name = coalesce(var.resource_group_name, var.prefix) } resource "azurerm_resource_group" "stacklet_rg" {