Compare commits

..

17 Commits

Author SHA1 Message Date
4ff0a7205f Update build and run scripts to support Apple's container. 2025-12-21 23:59:00 +01:00
5162b183bf Fix: added a default location. 2025-12-21 23:58:16 +01:00
b42c659560 Remove enable_rbac_authorization from azurerm_key_vault resource due to deprecation. 2025-11-13 20:53:26 +01:00
55971f7d89 Fix shebang line in build.sh for proper script execution 2025-09-09 21:31:48 +02:00
c3a632ed4b Improve container tool detection logic in build.sh for better reliability 2025-09-09 21:27:17 +02:00
f6061fb9a0 Add platform specification to build command in build.sh 2025-09-09 21:21:40 +02:00
218db54a08 Refactor image chooser functions to improve clarity and remove unnecessary rerun calls; add SKU list display for selected publishers and offers. 2025-09-09 21:18:02 +02:00
2c50e4ea17 Updated documentation. 2025-08-21 21:30:05 +02:00
023de7e88d Formatting corrections. 2025-08-19 07:25:51 +02:00
a240e62e75 Added addition environment variable. 2025-08-19 07:25:41 +02:00
6e60dc7199 Fixed incorrect COPY instruction that did not copy the directory contents. 2025-08-19 05:23:14 +00:00
c6349e2577 Updated Dockerfile to add new files to the image. 2025-08-18 23:10:28 +02:00
4b86f2bd57 Reengineered app. Added region selection. 2025-08-18 23:10:01 +02:00
2f465a8217 Updated .gitignore. 2025-08-18 23:08:49 +02:00
86281742da Updated certificate binding instructions. 2025-08-15 21:34:26 +02:00
d7fb56eb41 Added CLI command to bind a certificate to Azure Container App. 2025-08-15 20:55:47 +02:00
f21739c250 Added a custom domain name and validation records. 2025-08-15 20:45:55 +02:00
13 changed files with 300 additions and 68 deletions

5
.gitignore vendored
View File

@@ -8,7 +8,12 @@
# Python
**/.venv
**/.env
**/playground.py
# Azure Secrets and Configuration.
/.acr-pat
/azure.env
# MacOS Finder files
**/.DS_Store

View File

@@ -1,16 +1,46 @@
## Azure Image Chooser
# Azure Image Chooser
Azure Image Chooser is a [Streamlit](https://streamlit.io) application that allows users to select Azure VM images from the Azure Marketplace.
It is in its preliminary version and is subject to development and change. It is provided here for you convenience and may not include all features or functionality of the final product.
Azure Image Chooser is written in Python and requires Python interpreter. At the time of writing this, Python 3.13 is the latest.
Azure Image Chooser is written in Python and requires Python interpreter. At the time of writing this, Python 3.13 is the latest. Execute the following commands to run the app:
You can run it on your local machine or deploy to any platform that runs containers. A Docker file and Terraform code to deploy to the Azure are provided.
## Running on a local machine
Create a Python development environment file `.env`:
```shell
AZURE_SUBSCRIPTION_ID="subscription_id"
AZURE_CLIENT_ID="client_id"
AZURE_CLIENT_SECRET="client_secret"
AZURE_TENANT_ID="tenant_id"
AZURE_LOCATION="westeurope"
```
> NOTE: Replace the placeholder values with your actual values. Omit `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and `AZURE_TENANT_ID` if you are using Azure CLI authentication.
Execute the following commands to run the app:
```shell
python3 -m venv .venv
.venv/bin/python -m pip install pip --upgrade
.venv/bin/pip install streamlit azure-identity azure-mgmt-compute
.venv/bin/streamlit run image-chooser.py
source .venv/bin/activate
python -m pip install pip --upgrade
pip install -r requirements.txt
cd app
streamlit run image-chooser.py
```
You have to be authenticated in Azure CLI. The app will block terminal and start a web server. Follow the instructions in the terminal to access the app.
The app will block terminal and start a web server. Follow the instructions in the terminal to access the app.
## Add Certificate Binding for Azure Deployment
Unfortunately, as of now, the Terraform Azure Resource Manager Provider does not support binding certificates to container apps. You can still bind the certificate using the Azure CLI.
```shell
# Find the certificate name
CERTIFICATE_LOWERCASE_NAME=$(az containerapp env certificate list -g $RESOURCE_GROUP -n $ENVIRONMENT --query '[].name' -o tsv)
# Bind the certificate to the container app
az containerapp hostname bind --hostname $DOMAIN_NAME -g $RESOURCE_GROUP -n $CONTAINER_APP --environment $ENVIRONMENT --certificate $CERTIFICATE_LOWERCASE_NAME --validation-method CNAME
```

View File

@@ -7,6 +7,8 @@ COPY requirements.txt .
RUN pip install --root-user-action=ignore --no-cache-dir -r requirements.txt
COPY image-chooser.py .
COPY templates/ templates/
COPY templates.json .
COPY ./entrypoint.sh /
ENTRYPOINT [ "/entrypoint.sh" ]

View File

@@ -2,19 +2,49 @@ import streamlit as st
import pandas as pd
import re
from azure.identity import DefaultAzureCredential
from azure.mgmt.resource import SubscriptionClient
from azure.mgmt.compute import ComputeManagementClient
from jinja2 import Template, Environment, FileSystemLoader
import json
from os import getenv
from dotenv import load_dotenv
load_dotenv()
def is_valid(key_name: str):
return key_name in st.session_state and st.session_state[key_name] is not None and len(st.session_state[key_name]) > 0
def is_valid_and_not_equal(key_name: str, value: str):
return key_name in st.session_state and st.session_state[key_name] != value
def clear_offers():
if 'offers' in st.session_state: del st.session_state.offers
if 'selected_offer' in st.session_state: del st.session_state.selected_offer
clear_skus()
def clear_selected_sku():
if 'selected_sku' in st.session_state: del st.session_state.selected_sku
def clear_skus():
if 'skus' in st.session_state: del st.session_state.skus
clear_selected_sku()
if 'selected_sku' in st.session_state: del st.session_state.selected_sku
clear_image_versions()
def clear_image_versions():
if 'image_versions' in st.session_state: del st.session_state.image_versions
if 'selected_image_version' in st.session_state: del st.session_state.selected_image_version
def on_publisher_changed():
clear_offers()
def on_offer_changed():
clear_skus()
def on_sku_changed():
clear_skus()
def on_image_version_changed():
clear_image_versions()
def version_key(v):
return [int(x) for x in v.split('.')]
@st.cache_data
def get_publishers(location: str):
@@ -32,57 +62,118 @@ def get_skus(location: str, publisher: str, offer: str):
def get_image_versions(location: str, publisher: str, offer: str, sku: str):
return [version.name for version in compute_client.virtual_machine_images.list(location, publisher, offer, sku)]
subscription_id = "c885a276-c882-483f-b216-42f73715161d"
location = "westeurope"
@st.cache_data
def get_locations():
subscription_client = SubscriptionClient(credential)
locations = sorted(subscription_client.subscriptions.list_locations(getenv('AZURE_SUBSCRIPTION_ID')), key=lambda l: l.name)
return [
loc for loc in locations
if loc.metadata.region_type == 'Physical'
]
def usage_scenario_label(item):
return item['label']
subscription_id = getenv("AZURE_SUBSCRIPTION_ID")
default_location = getenv("AZURE_LOCATION", "westeurope")
credential = DefaultAzureCredential()
locations = get_locations()
compute_client = ComputeManagementClient(credential, subscription_id)
st.set_page_config(page_title='Azure Image Chooser', layout='wide')
st.title('Azure Image Chooser')
left_col, middle_col, right_col = st.columns(3)
location_cols = st.columns(4)
location = location_cols[0].selectbox('Select Azure Location',
options=[{"name": loc.name, "display_name": loc.display_name} for loc in locations],
index=locations.index(next((l for l in locations if l.name == default_location), default_location)),
format_func=lambda loc: loc['display_name']
)['name']
publisher_col, offer_col, sku_col, version_col = st.columns(4)
# Publishers
if 'publishers' not in st.session_state:
st.session_state.publishers = get_publishers(location)
clear_offers()
selected_publisher = left_col.selectbox('Select Publisher', options=st.session_state.publishers)
if is_valid('publishers'):
st.session_state.selected_publisher = publisher_col.selectbox('Select Publisher', options=st.session_state.publishers, on_change=on_publisher_changed, index=None)
else:
st.error("No publishers found. Please check your Azure subscription and location.")
# st.stop()
if 'selected_publisher' not in st.session_state or selected_publisher != st.session_state.selected_publisher:
st.session_state.selected_publisher = selected_publisher
clear_offers()
if 'offers' not in st.session_state:
# Offers
if 'offers' not in st.session_state and is_valid('selected_publisher'):
st.session_state.offers = get_offers(location, st.session_state.selected_publisher)
clear_skus()
selected_offer = middle_col.selectbox('Select Offer', options=st.session_state.offers)
if is_valid('offers'):
st.session_state.selected_offer = offer_col.selectbox('Select Offer', options=st.session_state.offers, on_change=on_offer_changed, index=None)
elif is_valid('selected_publisher'):
st.info("No offers found for the selected publisher. Please select a different publisher.")
# st.stop()
# else:
# st.stop()
if 'selected_offer' not in st.session_state or selected_offer != st.session_state.selected_offer:
st.session_state.selected_offer = selected_offer
clear_skus()
if 'skus' not in st.session_state:
# SKUs
if 'skus' not in st.session_state and is_valid('selected_publisher') and is_valid('selected_offer'):
st.session_state.skus = get_skus(location, st.session_state.selected_publisher, st.session_state.selected_offer)
clear_selected_sku()
selected_sku = right_col.selectbox('Select SKU', options=st.session_state.skus)
if is_valid('skus'):
st.session_state.selected_sku = sku_col.selectbox('Select SKU', options=st.session_state.skus, on_change=on_sku_changed, index=None)
elif is_valid('selected_offer'):
st.info("No SKUs found for the selected offer. Please select a different offer.")
# st.stop()
# else:
# st.stop()
if 'selected_sku' not in st.session_state or selected_sku != st.session_state.selected_sku:
st.session_state.selected_sku = selected_sku
# Image versions
if 'image_versions' not in st.session_state and is_valid('selected_publisher') and is_valid('selected_offer') and is_valid('selected_sku'):
image_versions = get_image_versions(location, st.session_state.selected_publisher, st.session_state.selected_offer, st.session_state.selected_sku)
# Check if all image version string match the re.
regex = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+$')
def version_key(v):
return [int(x) for x in v.split('.')]
if all(regex.match(version) for version in image_versions):
image_versions = sorted(image_versions, key=version_key)
regex = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+$')
st.session_state.image_versions = image_versions
# Display available image versions
images_versions = get_image_versions(location, st.session_state.selected_publisher, st.session_state.selected_offer, st.session_state.selected_sku)
if is_valid('image_versions'):
st.session_state.selected_image_version = version_col.selectbox('Select Image Version', options=st.session_state.image_versions, index=None)
elif is_valid('selected_sku'):
st.info("No image versions found for the selected SKU. Please select a different SKU.")
# st.stop()
# else:
# st.stop()
# Check if all image version string match the re.
if all(regex.match(version) for version in images_versions):
images_versions = sorted(images_versions, key=version_key)
if is_valid('selected_image_version'):
st.subheader("Usage example")
st.dataframe(images_versions, hide_index=True, column_config={"value": "Image Version"})
with open("templates.json") as f:
templates = json.load(f)
layout = st.columns(4)
selected_file = layout[0].selectbox('Select usage scenario:', options=templates, format_func=usage_scenario_label)
env = Environment(loader=FileSystemLoader("templates"))
tpl = env.get_template(selected_file['file'])
rendered = tpl.render(
publisher=st.session_state.selected_publisher,
offer=st.session_state.selected_offer,
sku=st.session_state.selected_sku,
version=st.session_state.selected_image_version
)
st.code(rendered, language=selected_file['language'])
if is_valid('selected_publisher') and is_valid('selected_offer') and is_valid('skus'):
sku_list = '[\n' + ',\n'.join(f'\t"{sku}"' for sku in st.session_state['skus']) +'\n]'
st.subheader('Available SKUs')
st.markdown('The below HCL code is suitable to be used as SKU validation set.')
st.code(sku_list)

View File

@@ -1,3 +1,5 @@
streamlit
azure-identity
azure.mgmt.resource
azure-mgmt-compute
python-dotenv

17
app/templates.json Normal file
View File

@@ -0,0 +1,17 @@
[
{
"label": "Terraform VM image reference",
"language": "hcl",
"file": "azurerm_hcl.tpl"
},
{
"label": "Azure CLI",
"language": "shell",
"file": "shell.tpl"
},
{
"label": "Azure Resource Manager Template",
"language": "json",
"file": "arm_vm.jsonc"
}
]

View File

@@ -0,0 +1,17 @@
{
// This is a partial Azure virtual machine resource template.
"type": "Microsoft.Compute/virtualMachines",
"apiVersion": "2022-03-01",
"name": "example-vm",
"location": "westeurope",
"properties": {
"storageProfile": {
"imageReference": {
"publisher": "{{ publisher }}",
"offer": "{{ offer }}",
"sku": "{{ sku }}",
"version": "{{ version }}"
}
}
}
}

View File

@@ -0,0 +1,6 @@
source_image_reference = {
publisher = "{{ publisher }}"
offer = "{{ offer }}"
sku = "{{ sku }}"
version = "{{ version }}"
}

1
app/templates/shell.tpl Normal file
View File

@@ -0,0 +1 @@
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}

View File

@@ -1,17 +1,14 @@
#!/bin/bash
#!//usr/bin/env bash
IMAGE_NAME="azure-image-chooser"
#IMAGE="docker.io/skoszewski/$IMAGE_NAME"
IMAGE="skdomlab.azurecr.io/$IMAGE_NAME"
IMAGE="docker.io/skoszewski/$IMAGE_NAME:latest"
# IMAGE="skdomlab.azurecr.io/$IMAGE_NAME"
if [ "$(basename $(command -v docker))" = "docker" ]; then
CMD="docker"
elif [ "$(basename $(command -v podman))" = "podman" ]; then
CMD="podman"
if command -v docker > /dev/null; then
docker buildx build -t $IMAGE app
elif command -v container > /dev/null; then
container build -t $IMAGE app
else
echo "No suitable container tool found"
exit 1
fi
$CMD build -t $IMAGE app
$CMD push $IMAGE
fi

View File

@@ -1,24 +1,37 @@
#!/bin/bash
#!/usr/bin/env bash
if [ -z "$AZURE_CLIENT_ID" ] || [ -z "$AZURE_TENANT_ID" ] || [ -z "$AZURE_CLIENT_SECRET" ] || [ -z "$AZURE_SUBSCRIPTION_ID" ]; then
echo "One or more environment variables are not set."
exit 1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/azure.env" ]; then
source "$SCRIPT_DIR/azure.env"
fi
if [ "$(basename $(command -v docker))" = "docker" ]; then
REQUIRED_VARS=("AZURE_CLIENT_ID" "AZURE_TENANT_ID" "AZURE_CLIENT_SECRET" "AZURE_SUBSCRIPTION_ID")
for VAR in "${REQUIRED_VARS[@]}"; do
if [ -z "${!VAR}" ]; then
echo "Environment variable $VAR is not set."
exit 1
fi
done
IMAGE_NAME="azure-image-chooser"
IMAGE="docker.io/skoszewski/$IMAGE_NAME:latest"
RUN_ARGS=(
"--env" "AZURE_CLIENT_ID=$AZURE_CLIENT_ID"
"--env" "AZURE_TENANT_ID=$AZURE_TENANT_ID"
"--env" "AZURE_CLIENT_SECRET=$AZURE_CLIENT_SECRET"
"--env" "AZURE_SUBSCRIPTION_ID=$AZURE_SUBSCRIPTION_ID"
"-p" "8501:8501"
)
if command -v docker > /dev/null; then
CMD="docker"
elif [ "$(basename $(command -v podman))" = "podman" ]; then
CMD="podman"
elif command -v container > /dev/null; then
CMD="container"
else
echo "No suitable container tool found"
exit 1
fi
$CMD run --rm \
-it \
-e AZURE_CLIENT_ID="$AZURE_CLIENT_ID" \
-e AZURE_TENANT_ID="$AZURE_TENANT_ID" \
-e AZURE_CLIENT_SECRET="$AZURE_CLIENT_SECRET" \
-e AZURE_SUBSCRIPTION_ID="$AZURE_SUBSCRIPTION_ID" \
-p 8501:8501 \
azure-image-chooser
$CMD run --rm -it "${RUN_ARGS[@]}" $IMAGE

View File

@@ -30,6 +30,7 @@ data "azuread_user" "az_lab_admin" {
locals {
kv_secret_name = "azure-client-secret"
app_name = "${var.project_name}-app"
}
resource "azurerm_resource_group" "rg" {
@@ -51,7 +52,6 @@ resource "azurerm_key_vault" "kv" {
resource_group_name = azurerm_resource_group.rg.name
sku_name = "standard"
tenant_id = data.azurerm_client_config.current.tenant_id
enable_rbac_authorization = true
}
resource "azurerm_role_assignment" "app_assignment" {
@@ -82,7 +82,7 @@ resource "azurerm_container_app_environment" "env" {
}
resource "azurerm_container_app" "app" {
name = "${var.project_name}-app"
name = local.app_name
container_app_environment_id = azurerm_container_app_environment.env.id
resource_group_name = azurerm_resource_group.rg.name
revision_mode = "Single"
@@ -119,6 +119,11 @@ resource "azurerm_container_app" "app" {
name = "AZURE_SUBSCRIPTION_ID"
value = var.subscription_id
}
env {
name = "AZURE_LOCATION"
value = azurerm_resource_group.rg.location
}
}
}
@@ -165,3 +170,37 @@ data "azurerm_container_registry" "acr" {
name = "skdomlab"
resource_group_name = "dom-lab-common"
}
data "azurerm_dns_zone" "lab_dns_zone" {
name = var.dns_zone_name
resource_group_name = var.dns_zone_resource_group_name
}
resource "azurerm_dns_txt_record" "domain_verification" {
name = "asuid.${var.project_name}"
resource_group_name = data.azurerm_dns_zone.lab_dns_zone.resource_group_name
zone_name = data.azurerm_dns_zone.lab_dns_zone.name
ttl = 300
record {
value = azurerm_container_app.app.custom_domain_verification_id
}
}
resource "azurerm_dns_cname_record" "app_record" {
name = var.project_name
zone_name = var.dns_zone_name
resource_group_name = var.dns_zone_resource_group_name
ttl = 300
record = "${local.app_name}.${azurerm_container_app_environment.env.default_domain}"
}
resource "azurerm_container_app_custom_domain" "custom_domain" {
name = trimsuffix(trimprefix(azurerm_dns_txt_record.domain_verification.fqdn, "asuid."), ".")
container_app_id = azurerm_container_app.app.id
lifecycle {
ignore_changes = [certificate_binding_type, container_app_environment_certificate_id]
}
}

View File

@@ -25,3 +25,15 @@ variable "project_name" {
description = "The name used to construct Azure resource names."
type = string
}
variable "dns_zone_name" {
description = "The name of the DNS zone for domain verification."
type = string
default = "lab.koszewscy.waw.pl"
}
variable "dns_zone_resource_group_name" {
description = "The name of the resource group containing the DNS zone."
type = string
default = "dom-lab-zones"
}