Skip to content

Commit a83cbb4

Browse files
Merge pull request #33 from spacelift-io/add-autoscaling-2
feat: autoscaling
2 parents f6d7c81 + b9dfc94 commit a83cbb4

File tree

14 files changed

+651
-10
lines changed

14 files changed

+651
-10
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ override.tf.json
3030

3131
.terraform.lock.hcl
3232
local.tfvars
33+
34+
# Autoscaler downloaded/generated files
35+
01*
36+
ec2-workerpool-autoscaler_*.zip
37+
autoscaler-function.zip

autoscaler.tf

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module "autoscaler" {
2+
count = local.autoscaling_enabled ? 1 : 0
3+
source = "./autoscaler"
4+
5+
autoscaling_configuration = var.autoscaling_configuration
6+
base_name = local.namespace
7+
key_vault_id = var.autoscaling_configuration.key_vault_id
8+
resource_group = var.resource_group
9+
spacelift_api_credentials = var.spacelift_api_credentials
10+
subnet_id = var.subnet_id
11+
tags = var.tags
12+
vmss_resource_id = azurerm_linux_virtual_machine_scale_set.this.id
13+
worker_pool_id = var.worker_pool_id
14+
}

autoscaler/autoscaler.tf

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
locals {
2+
download_folder = var.worker_pool_id
3+
architecture = coalesce(var.autoscaling_configuration.architecture, "amd64")
4+
5+
# TODO:// might need to rename this after repo name changes
6+
autoscaler_zip = "${local.download_folder}/ec2-workerpool-autoscaler_azurefunc_linux_${local.architecture}.zip"
7+
autoscaler_version = coalesce(var.autoscaling_configuration.version, "latest")
8+
function_name = "${var.base_name}-vmss-autoscaler"
9+
10+
function_package_dir = "${path.module}/function_package"
11+
generated_package_zip = "${local.download_folder}/autoscaler-function.zip"
12+
}
13+
14+
# Download the autoscaler binary from GitHub releases
15+
resource "null_resource" "download" {
16+
triggers = {
17+
# Always re-download if version is "latest" or if the file doesn't exist
18+
keeper = (
19+
local.autoscaler_version == "latest" || !fileexists(local.autoscaler_zip)
20+
? timestamp()
21+
: local.autoscaler_version
22+
)
23+
}
24+
25+
provisioner "local-exec" {
26+
command = "${path.module}/download.sh ${local.autoscaler_version} ${local.architecture} ${local.download_folder}"
27+
}
28+
}
29+
30+
resource "null_resource" "package" {
31+
depends_on = [null_resource.download]
32+
33+
triggers = {
34+
download_trigger = null_resource.download.id
35+
script_hash = filesha256("${local.function_package_dir}/package.sh")
36+
host_json_hash = filesha256("${local.function_package_dir}/host.json")
37+
function_hash = filesha256("${local.function_package_dir}/AutoscalerTimer/function.json")
38+
}
39+
40+
provisioner "local-exec" {
41+
command = "${local.function_package_dir}/package.sh ${local.autoscaler_zip} ${local.generated_package_zip}"
42+
}
43+
}
44+
45+
data "local_file" "function_package" {
46+
depends_on = [null_resource.package]
47+
filename = local.generated_package_zip
48+
}
49+
50+
# Storage account for the Function App
51+
resource "azurerm_storage_account" "autoscaler" {
52+
name = lower(substr(replace("${var.base_name}auto", "-", ""), 0, 24))
53+
resource_group_name = var.resource_group.name
54+
location = var.resource_group.location
55+
account_tier = "Standard"
56+
account_replication_type = "LRS"
57+
58+
tags = merge(var.tags, {
59+
WorkerPoolID = var.worker_pool_id
60+
Component = "Autoscaler"
61+
})
62+
}
63+
64+
# Upload the function package to blob storage
65+
resource "azurerm_storage_container" "autoscaler" {
66+
name = "function-releases"
67+
storage_account_id = azurerm_storage_account.autoscaler.id
68+
container_access_type = "private"
69+
}
70+
71+
resource "azurerm_storage_blob" "autoscaler" {
72+
name = "autoscaler-function-${data.local_file.function_package.content_base64sha256}.zip"
73+
storage_account_name = azurerm_storage_account.autoscaler.name
74+
storage_container_name = azurerm_storage_container.autoscaler.name
75+
type = "Block"
76+
source = local.generated_package_zip
77+
78+
depends_on = [null_resource.package]
79+
}
80+
81+
# Grant the Function App's managed identity read access to the blob storage
82+
# This allows WEBSITE_RUN_FROM_PACKAGE to work without a SAS token
83+
resource "azurerm_role_assignment" "autoscaler_blob_reader" {
84+
scope = azurerm_storage_account.autoscaler.id
85+
role_definition_name = "Storage Blob Data Reader"
86+
principal_id = azurerm_linux_function_app.autoscaler.identity[0].principal_id
87+
}
88+
89+
# App Service Plan for the Function App
90+
resource "azurerm_service_plan" "autoscaler" {
91+
name = "${var.base_name}-autoscaler-plan"
92+
resource_group_name = var.resource_group.name
93+
location = var.resource_group.location
94+
os_type = "Linux"
95+
sku_name = "S1"
96+
97+
tags = merge(var.tags, {
98+
WorkerPoolID = var.worker_pool_id
99+
Component = "Autoscaler"
100+
})
101+
}
102+
103+
# Linux Function App for autoscaling using custom handler
104+
resource "azurerm_linux_function_app" "autoscaler" {
105+
name = local.function_name
106+
resource_group_name = var.resource_group.name
107+
location = var.resource_group.location
108+
109+
storage_account_name = azurerm_storage_account.autoscaler.name
110+
storage_account_access_key = azurerm_storage_account.autoscaler.primary_access_key
111+
service_plan_id = azurerm_service_plan.autoscaler.id
112+
113+
# Enable system-assigned managed identity for Azure resource access
114+
identity {
115+
type = "SystemAssigned"
116+
}
117+
118+
site_config {
119+
application_stack {
120+
use_custom_runtime = true
121+
}
122+
123+
application_insights_connection_string = azurerm_application_insights.autoscaler.connection_string
124+
application_insights_key = azurerm_application_insights.autoscaler.instrumentation_key
125+
126+
cors {
127+
allowed_origins = ["https://portal.azure.com"]
128+
}
129+
}
130+
131+
app_settings = {
132+
FUNCTIONS_WORKER_RUNTIME = "custom"
133+
WEBSITE_RUN_FROM_PACKAGE = azurerm_storage_blob.autoscaler.url
134+
WEBSITE_RUN_FROM_PACKAGE_BLOB_MI_RESOURCE_ID = ""
135+
AzureWebJobsDisableHomepage = "true"
136+
WEBSITE_ENABLE_SYNC_UPDATE_SITE = "true"
137+
WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT = "1"
138+
139+
# Timer trigger schedule (cron format for Azure Functions)
140+
SCHEDULE_EXPRESSION = coalesce(var.autoscaling_configuration.schedule_expression, "0 * * * * *")
141+
142+
# Spacelift API configuration
143+
SPACELIFT_API_KEY_ID = var.spacelift_api_credentials.api_key_id
144+
SPACELIFT_API_KEY_ENDPOINT = var.spacelift_api_credentials.api_key_endpoint
145+
SPACELIFT_API_KEY_SECRET_NAME = azurerm_key_vault_secret.spacelift_api_key.name
146+
SPACELIFT_WORKER_POOL_ID = var.worker_pool_id
147+
148+
# Azure Key Vault configuration
149+
AZURE_KEY_VAULT_NAME = var.key_vault_id != null ? split("/", var.key_vault_id)[8] : azurerm_key_vault.autoscaler[0].name
150+
AZURE_SECRET_NAME = azurerm_key_vault_secret.spacelift_api_key.name
151+
152+
# Azure VMSS configuration
153+
AUTOSCALING_GROUP_ARN = var.vmss_resource_id
154+
AUTOSCALING_REGION = var.resource_group.location
155+
156+
# Autoscaling limits
157+
AUTOSCALING_MAX_CREATE = var.autoscaling_configuration.max_create != null ? var.autoscaling_configuration.max_create : 1
158+
AUTOSCALING_MAX_KILL = var.autoscaling_configuration.max_terminate != null ? var.autoscaling_configuration.max_terminate : 1
159+
AUTOSCALING_SCALE_DOWN_DELAY = var.autoscaling_configuration.scale_down_delay != null ? var.autoscaling_configuration.scale_down_delay : 0
160+
161+
AZURE_AUTOSCALING_MIN_SIZE = coalesce(try(var.autoscaling_configuration.scale.min, null), -1)
162+
AZURE_AUTOSCALING_MAX_SIZE = coalesce(try(var.autoscaling_configuration.scale.max, null), 5)
163+
}
164+
165+
tags = merge(var.tags, {
166+
WorkerPoolID = var.worker_pool_id
167+
Component = "Autoscaler"
168+
})
169+
170+
lifecycle {
171+
# Ignore changes to zip_deploy_file to prevent constant redeployment
172+
ignore_changes = [
173+
zip_deploy_file
174+
]
175+
}
176+
}
177+
178+
# Application Insights for monitoring the Function App
179+
resource "azurerm_application_insights" "autoscaler" {
180+
name = "${var.base_name}-autoscaler-insights"
181+
resource_group_name = var.resource_group.name
182+
location = var.resource_group.location
183+
application_type = "other"
184+
185+
tags = merge(var.tags, {
186+
WorkerPoolID = var.worker_pool_id
187+
Component = "Autoscaler"
188+
})
189+
}

autoscaler/download.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env sh
2+
set -e
3+
4+
code_version=$1
5+
code_architecture=$2
6+
downloadFolder=$3
7+
8+
# TODO:// might need to rename this after repo name changes
9+
zip_name="ec2-workerpool-autoscaler_azurefunc_linux_${code_architecture}.zip"
10+
11+
if [ "$code_version" != "latest" ]; then
12+
# TODO:// might need to rename this after repo name changes
13+
download_url="https://github.com/spacelift-io/ec2-workerpool-autoscaler/releases/download/${code_version}/${zip_name}"
14+
else
15+
tmpfile=$(mktemp /tmp/spacelift-request-headers.XXXXXX)
16+
if [ -n "${GITHUB_TOKEN}" ]; then
17+
# TODO:// might need to rename this after repo name changes
18+
request=$(curl -D "$tmpfile" -X GET --header "Authorization: Bearer ${GITHUB_TOKEN}" -sS "https://api.github.com/repos/spacelift-io/ec2-workerpool-autoscaler/releases/latest")
19+
else
20+
# TODO:// might need to rename this after repo name changes
21+
request=$(curl -D "$tmpfile" -X GET -sS "https://api.github.com/repos/spacelift-io/ec2-workerpool-autoscaler/releases/latest")
22+
fi
23+
ratelimit=$(cat "$tmpfile" | grep x-ratelimit-remaining | awk '{print $2}' | tr -d '\012\015')
24+
rm "$tmpfile"
25+
if [ "$ratelimit" = "0" ]; then
26+
echo "Github API rate limit exceeded, cannot find latest version. Please try again later or version pin the module."
27+
exit 1
28+
else
29+
echo "Github API rate limit remaining: '$ratelimit'"
30+
31+
release=$(printf '%s' "$request" | jq -r --arg ZIP "$zip_name" '.assets[] | select(.name==$ZIP)')
32+
33+
release_date=$(echo "$release" | jq -r '.created_at')
34+
download_url=$(echo "$release" | jq -r '.browser_download_url')
35+
36+
echo "Downloading Details:"
37+
echo " Release Name: $code_version"
38+
echo " Release Date: $release_date"
39+
echo " Download URL: $download_url"
40+
fi
41+
fi
42+
43+
mkdir -p "$downloadFolder"
44+
cd "$downloadFolder"
45+
curl -L -O "$download_url"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"bindings": [
3+
{
4+
"name": "Timer",
5+
"type": "timerTrigger",
6+
"direction": "in",
7+
"schedule": "%SCHEDULE_EXPRESSION%",
8+
"useMonitor": true
9+
}
10+
]
11+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"version": "2.0",
3+
"extensionBundle": {
4+
"id": "Microsoft.Azure.Functions.ExtensionBundle",
5+
"version": "[4.*, 5.0.0)"
6+
},
7+
"logging": {
8+
"logLevel": {
9+
"default": "Information",
10+
"Host.Results": "Information",
11+
"Function": "Information",
12+
"Host.Aggregator": "Information"
13+
},
14+
"applicationInsights": {
15+
"samplingSettings": {
16+
"isEnabled": true,
17+
"maxTelemetryItemsPerSecond": 5
18+
}
19+
}
20+
},
21+
"customHandler": {
22+
"description": {
23+
"defaultExecutablePath": "bootstrap",
24+
"workingDirectory": "",
25+
"arguments": []
26+
},
27+
"enableForwardingHttpRequest": false
28+
},
29+
"functionTimeout": "00:05:00"
30+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/bin/bash
2+
set -e
3+
4+
unset CDPATH
5+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
6+
7+
AUTOSCALER_ZIP_PATH="$1"
8+
OUTPUT_ZIP="$2"
9+
10+
if [ -z "$AUTOSCALER_ZIP_PATH" ] || [ -z "$OUTPUT_ZIP" ]; then
11+
echo "Usage: $0 <autoscaler-zip-path> <output-zip-path>"
12+
exit 1
13+
fi
14+
15+
# Convert relative paths to absolute
16+
if [[ "$AUTOSCALER_ZIP_PATH" != /* ]]; then
17+
AUTOSCALER_ZIP_PATH="$PWD/$AUTOSCALER_ZIP_PATH"
18+
fi
19+
if [[ "$OUTPUT_ZIP" != /* ]]; then
20+
OUTPUT_ZIP="$PWD/$OUTPUT_ZIP"
21+
fi
22+
23+
OUTPUT_DIR="$(dirname "$OUTPUT_ZIP")"
24+
mkdir -p "$OUTPUT_DIR"
25+
26+
PACKAGE_DIR="$(mktemp -d)"
27+
trap "rm -rf \"$PACKAGE_DIR\"" EXIT
28+
29+
echo "Packaging Azure Function autoscaler..."
30+
31+
mkdir -p "${PACKAGE_DIR}/AutoscalerTimer"
32+
33+
if [ ! -f "$AUTOSCALER_ZIP_PATH" ]; then
34+
echo "Error: autoscaler zip not found at $AUTOSCALER_ZIP_PATH"
35+
exit 1
36+
fi
37+
38+
echo "Extracting autoscaler binary..."
39+
unzip -o -q "$AUTOSCALER_ZIP_PATH" -d "$PACKAGE_DIR"
40+
41+
if [ -f "${PACKAGE_DIR}/bootstrap" ]; then
42+
chmod +x "${PACKAGE_DIR}/bootstrap"
43+
elif [ -f "${PACKAGE_DIR}/azure-vmss-workerpool-autoscaler" ]; then
44+
mv "${PACKAGE_DIR}/azure-vmss-workerpool-autoscaler" "${PACKAGE_DIR}/bootstrap"
45+
chmod +x "${PACKAGE_DIR}/bootstrap"
46+
else
47+
echo "Error: No bootstrap or azure-vmss-workerpool-autoscaler binary found in zip"
48+
ls -la "$PACKAGE_DIR"
49+
exit 1
50+
fi
51+
52+
echo "Copying function configuration..."
53+
cp "$SCRIPT_DIR/host.json" "$PACKAGE_DIR/host.json"
54+
cp "$SCRIPT_DIR/AutoscalerTimer/function.json" "$PACKAGE_DIR/AutoscalerTimer/function.json"
55+
56+
echo "Creating deployment package..."
57+
cd "${PACKAGE_DIR}"
58+
zip -r -q "$OUTPUT_ZIP" ./*
59+
60+
echo "Package created successfully at $OUTPUT_ZIP"
61+
echo "Package size: $(du -h "$OUTPUT_ZIP" | cut -f1)"

0 commit comments

Comments
 (0)