Compare commits
34 Commits
6e60dc7199
...
feature/vm
| Author | SHA1 | Date | |
|---|---|---|---|
| 86d9a3d014 | |||
| 778a74c49a | |||
| cc4ff948c5 | |||
| 5304b07d17 | |||
| 3984b8684f | |||
| 8079162117 | |||
| 733ad9b6b8 | |||
| f64f9bd3e8 | |||
| de9ba78089 | |||
| 3c4e10eda7 | |||
| 287dfc0b8b | |||
| 5561f10958 | |||
| 85dd574991 | |||
| 4f97dc3362 | |||
| 9100f71ab5 | |||
| 0d12f24dec | |||
| 055f51aa55 | |||
| 8087daa518 | |||
| 5fd91a96e8 | |||
| 28d0720ffa | |||
| 599ac49a76 | |||
| 4c7d38399b | |||
| 176fa5ead2 | |||
| aca4998da7 | |||
| 4ff0a7205f | |||
| 5162b183bf | |||
| b42c659560 | |||
| 55971f7d89 | |||
| c3a632ed4b | |||
| f6061fb9a0 | |||
| 218db54a08 | |||
| 2c50e4ea17 | |||
| 023de7e88d | |||
| a240e62e75 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -12,8 +12,14 @@
|
||||
**/playground.py
|
||||
|
||||
# Azure Secrets and Configuration.
|
||||
/.acr-pat
|
||||
/azure.env
|
||||
.acr-pat
|
||||
azure.env
|
||||
|
||||
# MacOS Finder files
|
||||
**/.DS_Store
|
||||
|
||||
# Node/React rewrite outputs
|
||||
**/node_modules
|
||||
**/dist
|
||||
|
||||
AGENTS.md
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sławomir Koszewski
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
80
README.md
80
README.md
@@ -1,26 +1,78 @@
|
||||
# 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.
|
||||
Azure Image Chooser is a Node.js 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 has a TypeScript backend (Express) and a React frontend (Vite). At the time of writing this, Node.js 24 is used by the container image and is the recommended version for local runs.
|
||||
|
||||
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.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Required for both local and container run:
|
||||
|
||||
- `AZURE_SUBSCRIPTION_ID`: Azure subscription used for Marketplace queries.
|
||||
|
||||
Authentication variables (only needed when identity is not provided by the environment):
|
||||
|
||||
- `AZURE_TENANT_ID`: Microsoft Entra tenant ID for service principal auth.
|
||||
- `AZURE_CLIENT_ID`: Service principal (app registration) client ID.
|
||||
- `AZURE_CLIENT_SECRET`: Service principal client secret.
|
||||
|
||||
Optional:
|
||||
|
||||
- `PORT`: Backend listening port. Default is `3000`.
|
||||
|
||||
Local run notes:
|
||||
|
||||
- `AZURE_SUBSCRIPTION_ID` must be set.
|
||||
- Use either Azure CLI login (`az login`) or the service principal variables above.
|
||||
|
||||
Container run notes:
|
||||
|
||||
- `AZURE_SUBSCRIPTION_ID` must be passed to the container.
|
||||
- Pass `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` unless your container runtime provides identity (for example Managed Identity / Workload Identity).
|
||||
|
||||
## Running on a local machine
|
||||
|
||||
Load environment variables from the repository environment file:
|
||||
|
||||
```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
|
||||
set -a; source azure.env; set +a
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Add Certificate Binding
|
||||
Execute the following commands to install dependencies, build, and run the app:
|
||||
|
||||
```shell
|
||||
# Find the certificate name
|
||||
CERTIFICATE_LOWERCASE_NAME=$(az containerapp env certificate list -g $RESOURCE_GROUP -n $ENVIRONMENT --query '[].name' -o tsv)
|
||||
cd app/backend
|
||||
npm ci
|
||||
|
||||
# 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
|
||||
cd ../frontend
|
||||
npm ci
|
||||
|
||||
cd backend
|
||||
npm run build
|
||||
|
||||
cd ../frontend
|
||||
npm run build
|
||||
|
||||
cd ../backend
|
||||
npm run start
|
||||
```
|
||||
|
||||
The app will block the terminal and start a web server on port 3000. Open http://localhost:3000 in your browser.
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Build and run the container:
|
||||
|
||||
```shell
|
||||
docker build -t azure-image-chooser-node ./app
|
||||
docker run --rm -p 3000:3000 \
|
||||
-e AZURE_SUBSCRIPTION_ID="subscription_id" \
|
||||
-e AZURE_CLIENT_ID="client_id" \
|
||||
-e AZURE_CLIENT_SECRET="client_secret" \
|
||||
-e AZURE_TENANT_ID="tenant_id" \
|
||||
azure-image-chooser-node
|
||||
```
|
||||
|
||||
> NOTE: As with local runs, you can omit AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, and AZURE_TENANT_ID when the runtime environment already provides Azure credentials.
|
||||
|
||||
15
app-streamlit/Dockerfile
Normal file
15
app-streamlit/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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" ]
|
||||
CMD [ "run", "image-chooser.py" ]
|
||||
46
app-streamlit/README.md
Normal file
46
app-streamlit/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
|
||||
Azure Image Chooser is written in Python and requires Python interpreter. At the time of writing this, Python 3.13 is the latest.
|
||||
|
||||
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
|
||||
source .venv/bin/activate
|
||||
python -m pip install pip --upgrade
|
||||
pip install -r requirements.txt
|
||||
cd app
|
||||
streamlit run image-chooser.py
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
14
app-streamlit/build.sh
Executable file
14
app-streamlit/build.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!//usr/bin/env bash
|
||||
|
||||
IMAGE_NAME="azure-image-chooser"
|
||||
IMAGE="docker.io/skoszewski/$IMAGE_NAME:latest"
|
||||
# IMAGE="skdomlab.azurecr.io/$IMAGE_NAME"
|
||||
|
||||
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
|
||||
8
app-streamlit/entrypoint.sh
Executable file
8
app-streamlit/entrypoint.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$1" = "-s" ] || [ "$1" = "--shell" ]; then
|
||||
shift
|
||||
exec bash $@
|
||||
fi
|
||||
|
||||
exec streamlit $@
|
||||
@@ -33,19 +33,15 @@ def clear_image_versions():
|
||||
|
||||
def on_publisher_changed():
|
||||
clear_offers()
|
||||
# st.rerun()
|
||||
|
||||
def on_offer_changed():
|
||||
clear_skus()
|
||||
# st.rerun()
|
||||
|
||||
def on_sku_changed():
|
||||
clear_skus()
|
||||
# st.rerun()
|
||||
|
||||
def on_image_version_changed():
|
||||
clear_image_versions()
|
||||
# st.rerun()
|
||||
|
||||
def version_key(v):
|
||||
return [int(x) for x in v.split('.')]
|
||||
@@ -75,8 +71,11 @@ def get_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")
|
||||
default_location = getenv("AZURE_LOCATION", "westeurope")
|
||||
|
||||
credential = DefaultAzureCredential()
|
||||
|
||||
@@ -107,7 +106,7 @@ 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()
|
||||
# st.stop()
|
||||
|
||||
# Offers
|
||||
if 'offers' not in st.session_state and is_valid('selected_publisher'):
|
||||
@@ -117,9 +116,9 @@ 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()
|
||||
# st.stop()
|
||||
# else:
|
||||
# st.stop()
|
||||
|
||||
# SKUs
|
||||
if 'skus' not in st.session_state and is_valid('selected_publisher') and is_valid('selected_offer'):
|
||||
@@ -129,9 +128,9 @@ 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()
|
||||
# st.stop()
|
||||
# else:
|
||||
# st.stop()
|
||||
|
||||
# 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'):
|
||||
@@ -148,9 +147,9 @@ 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()
|
||||
# st.stop()
|
||||
# else:
|
||||
# st.stop()
|
||||
|
||||
if is_valid('selected_image_version'):
|
||||
st.subheader("Usage example")
|
||||
@@ -158,9 +157,6 @@ if is_valid('selected_image_version'):
|
||||
with open("templates.json") as f:
|
||||
templates = json.load(f)
|
||||
|
||||
def usage_scenario_label(item):
|
||||
return item['label']
|
||||
|
||||
layout = st.columns(4)
|
||||
|
||||
selected_file = layout[0].selectbox('Select usage scenario:', options=templates, format_func=usage_scenario_label)
|
||||
@@ -175,3 +171,9 @@ if is_valid('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)
|
||||
37
app-streamlit/run-container.sh
Executable file
37
app-streamlit/run-container.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/azure.env" ]; then
|
||||
source "$SCRIPT_DIR/azure.env"
|
||||
fi
|
||||
|
||||
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 command -v container > /dev/null; then
|
||||
CMD="container"
|
||||
else
|
||||
echo "No suitable container tool found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$CMD run --rm -it "${RUN_ARGS[@]}" $IMAGE
|
||||
17
app-streamlit/templates.json
Normal file
17
app-streamlit/templates.json
Normal 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"
|
||||
}
|
||||
]
|
||||
17
app-streamlit/templates/arm_vm.jsonc
Normal file
17
app-streamlit/templates/arm_vm.jsonc
Normal 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 }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
app-streamlit/templates/azurerm_hcl.tpl
Normal file
6
app-streamlit/templates/azurerm_hcl.tpl
Normal file
@@ -0,0 +1,6 @@
|
||||
source_image_reference = {
|
||||
publisher = "{{ publisher }}"
|
||||
offer = "{{ offer }}"
|
||||
sku = "{{ sku }}"
|
||||
version = "{{ version }}"
|
||||
}
|
||||
1
app-streamlit/templates/shell.tpl
Normal file
1
app-streamlit/templates/shell.tpl
Normal file
@@ -0,0 +1 @@
|
||||
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}
|
||||
@@ -1,15 +1,50 @@
|
||||
FROM python:3.13-slim
|
||||
# Build stage
|
||||
FROM node:24-trixie-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
# Copy sources required for build
|
||||
COPY backend/*.json backend/
|
||||
COPY backend/src backend/src
|
||||
|
||||
RUN pip install --root-user-action=ignore --no-cache-dir -r requirements.txt
|
||||
COPY frontend/*.json frontend/
|
||||
COPY frontend/vite.config.ts frontend/vite.config.ts
|
||||
COPY frontend/index.html frontend/index.html
|
||||
COPY frontend/src frontend/src
|
||||
COPY frontend/test frontend/test
|
||||
|
||||
COPY image-chooser.py .
|
||||
COPY templates/ templates/
|
||||
COPY templates.json .
|
||||
COPY ./entrypoint.sh /
|
||||
COPY templates templates
|
||||
COPY templates.json templates.json
|
||||
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
CMD [ "run", "image-chooser.py" ]
|
||||
# Build backend and frontend
|
||||
RUN cd backend && npm ci && npm run build && npm prune --omit=dev
|
||||
RUN cd frontend && npm ci && npm run build
|
||||
|
||||
# Build the container
|
||||
FROM node:24-trixie-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=build /app/dist dist
|
||||
COPY --from=build /app/templates templates
|
||||
COPY --from=build /app/templates.json templates.json
|
||||
COPY --from=build /app/backend/node_modules dist/backend/node_modules
|
||||
|
||||
# Copy entrypoint and healthcheck scripts
|
||||
COPY entrypoint.sh entrypoint.sh
|
||||
COPY healthcheck.js healthcheck.js
|
||||
|
||||
# Ensure entrypoint script is executable
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Set environment variables and expose port
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
# Configure health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["node", "/app/healthcheck.js"]
|
||||
|
||||
# Configure entrypoint
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
3244
app/backend/package-lock.json
generated
Normal file
3244
app/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
app/backend/package.json
Normal file
32
app/backend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "azure-image-chooser-backend",
|
||||
"version": "1.0.0",
|
||||
"author": "Sławomir Koszewski",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "node ../dist/backend/server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/arm-compute": "^23.3.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"nunjucks": "^3.2.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/nunjucks": "^3.2.6",
|
||||
"@types/node": "^24.9.1",
|
||||
"typescript": "^6.0.3",
|
||||
"tsx": "^4.20.6",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
181
app/backend/src/azure-service.ts
Normal file
181
app/backend/src/azure-service.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ComputeManagementClient } from "@azure/arm-compute";
|
||||
import { DefaultAzureCredential } from "@azure/identity";
|
||||
import { MemoryCache } from "./cache";
|
||||
import { sortImageVersionsIfSemantic } from "./version";
|
||||
import type { LocationOption, VmSkuOption } from "./types";
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export class AzureImageService {
|
||||
private readonly credential = new DefaultAzureCredential();
|
||||
|
||||
private readonly computeClient: ComputeManagementClient;
|
||||
|
||||
private readonly cache = new MemoryCache();
|
||||
|
||||
public constructor(private readonly subscriptionId: string) {
|
||||
this.computeClient = new ComputeManagementClient(this.credential, subscriptionId);
|
||||
}
|
||||
|
||||
public async getLocations(): Promise<LocationOption[]> {
|
||||
const cacheKey = "locations";
|
||||
const cached = this.cache.get<LocationOption[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const token = await this.credential.getToken("https://management.azure.com/.default");
|
||||
const url = `https://management.azure.com/subscriptions/${this.subscriptionId}/locations?api-version=2022-12-01`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token?.token ?? ""}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Azure locations: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
value?: Array<{ name?: string; displayName?: string; metadata?: { regionType?: string } }>;
|
||||
};
|
||||
|
||||
const allLocations = (payload.value ?? [])
|
||||
.filter((loc) => Boolean(loc.name))
|
||||
.map((loc) => ({
|
||||
name: loc.name as string,
|
||||
displayName: loc.displayName ?? (loc.name as string),
|
||||
regionType: loc.metadata?.regionType
|
||||
}));
|
||||
|
||||
const physical = allLocations.filter((loc) => loc.regionType?.toLowerCase() === "physical");
|
||||
const locations = (physical.length > 0 ? physical : allLocations).map((loc) => ({
|
||||
name: loc.name,
|
||||
displayName: loc.displayName
|
||||
}));
|
||||
|
||||
locations.sort((a, b) => a.name.localeCompare(b.name));
|
||||
this.cache.set(cacheKey, locations, CACHE_TTL_MS);
|
||||
return locations;
|
||||
}
|
||||
|
||||
public async getPublishers(location: string): Promise<string[]> {
|
||||
const cacheKey = `publishers:${location}`;
|
||||
const cached = this.cache.get<string[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await this.computeClient.virtualMachineImages.listPublishers(location);
|
||||
const publishers = this.extractNames(response).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
this.cache.set(cacheKey, publishers, CACHE_TTL_MS);
|
||||
return publishers;
|
||||
}
|
||||
|
||||
public async getOffers(location: string, publisher: string): Promise<string[]> {
|
||||
const cacheKey = `offers:${location}:${publisher}`;
|
||||
const cached = this.cache.get<string[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await this.computeClient.virtualMachineImages.listOffers(location, publisher);
|
||||
const offers = this.extractNames(response).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
this.cache.set(cacheKey, offers, CACHE_TTL_MS);
|
||||
return offers;
|
||||
}
|
||||
|
||||
public async getSkus(location: string, publisher: string, offer: string): Promise<string[]> {
|
||||
const cacheKey = `skus:${location}:${publisher}:${offer}`;
|
||||
const cached = this.cache.get<string[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await this.computeClient.virtualMachineImages.listSkus(location, publisher, offer);
|
||||
const skus = this.extractNames(response).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
this.cache.set(cacheKey, skus, CACHE_TTL_MS);
|
||||
return skus;
|
||||
}
|
||||
|
||||
public async getVersions(location: string, publisher: string, offer: string, sku: string): Promise<string[]> {
|
||||
const cacheKey = `versions:${location}:${publisher}:${offer}:${sku}`;
|
||||
const cached = this.cache.get<string[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await this.computeClient.virtualMachineImages.list(location, publisher, offer, sku);
|
||||
const versions = this.extractNames(response);
|
||||
|
||||
const sorted = sortImageVersionsIfSemantic(versions);
|
||||
this.cache.set(cacheKey, sorted, CACHE_TTL_MS);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
public async getVmSkus(location: string): Promise<VmSkuOption[]> {
|
||||
const cacheKey = `vm-skus:${location}`;
|
||||
const cached = this.cache.get<VmSkuOption[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const targetLocation = location.toLowerCase();
|
||||
const filter = `location eq '${location}'`;
|
||||
const vmSkus: VmSkuOption[] = [];
|
||||
|
||||
for await (const sku of this.computeClient.resourceSkus.list({ filter })) {
|
||||
if ((sku.resourceType ?? "").toLowerCase() !== "virtualmachines") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skuLocations = (sku.locations ?? []).map((item) => item.toLowerCase());
|
||||
const matchesLocation = skuLocations.includes(targetLocation);
|
||||
if (!matchesLocation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const capabilities = new Map(
|
||||
(sku.capabilities ?? []).map((item) => [item.name?.toLowerCase() ?? "", item.value ?? ""])
|
||||
);
|
||||
|
||||
vmSkus.push({
|
||||
name: sku.name ?? "",
|
||||
size: sku.size ?? "",
|
||||
family: sku.family ?? "",
|
||||
tier: sku.tier ?? "",
|
||||
vcpus: this.toNumber(capabilities.get("vcpus")),
|
||||
memoryGb: this.toNumber(capabilities.get("memorygb")),
|
||||
maxDataDiskCount: this.toNumber(capabilities.get("maxdatadiskcount"))
|
||||
});
|
||||
}
|
||||
|
||||
vmSkus.sort((a, b) => a.name.localeCompare(b.name));
|
||||
this.cache.set(cacheKey, vmSkus, CACHE_TTL_MS);
|
||||
return vmSkus;
|
||||
}
|
||||
|
||||
private extractNames(source: unknown): string[] {
|
||||
const items = Array.isArray(source)
|
||||
? source
|
||||
: typeof source === "object" && source !== null && "value" in source && Array.isArray((source as { value?: unknown }).value)
|
||||
? ((source as { value: unknown[] }).value as unknown[])
|
||||
: [];
|
||||
|
||||
return items
|
||||
.map((item) => (typeof item === "object" && item !== null && "name" in item ? (item as { name?: string }).name : undefined))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
private toNumber(value: string | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
}
|
||||
29
app/backend/src/cache.ts
Normal file
29
app/backend/src/cache.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
type CacheEntry<T> = {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export class MemoryCache {
|
||||
private readonly store = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
public get<T>(key: string): T | undefined {
|
||||
const hit = this.store.get(key);
|
||||
if (!hit) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Date.now() >= hit.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hit.value as T;
|
||||
}
|
||||
|
||||
public set<T>(key: string, value: T, ttlMs: number): void {
|
||||
this.store.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + ttlMs
|
||||
});
|
||||
}
|
||||
}
|
||||
205
app/backend/src/server.ts
Normal file
205
app/backend/src/server.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import { z } from "zod";
|
||||
import { AzureImageService } from "./azure-service";
|
||||
import { TemplateService } from "./template-service";
|
||||
|
||||
const findAppRoot = (): string => {
|
||||
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(candidate, "templates.json"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to resolve app root");
|
||||
};
|
||||
|
||||
const queryLocation = z.object({ location: z.string().min(1) });
|
||||
const queryOffer = z.object({ location: z.string().min(1), publisher: z.string().min(1) });
|
||||
const querySku = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1) });
|
||||
const queryVersion = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1), sku: z.string().min(1) });
|
||||
|
||||
const renderBody = z.object({
|
||||
templateFile: z.string().min(1),
|
||||
selection: z.object({
|
||||
location: z.string().min(1),
|
||||
publisher: z.string().min(1),
|
||||
offer: z.string().min(1),
|
||||
sku: z.string().min(1),
|
||||
version: z.string().min(1)
|
||||
})
|
||||
});
|
||||
|
||||
const makeApp = () => {
|
||||
const app = express();
|
||||
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const azure = subscriptionId ? new AzureImageService(subscriptionId) : null;
|
||||
const templates = new TemplateService();
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
if (!subscriptionId) {
|
||||
res.status(500).json({
|
||||
status: "error",
|
||||
message: "Missing AZURE_SUBSCRIPTION_ID"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
const requireAzure = (): AzureImageService => {
|
||||
if (!azure) {
|
||||
throw new Error("Missing AZURE_SUBSCRIPTION_ID");
|
||||
}
|
||||
|
||||
return azure;
|
||||
};
|
||||
|
||||
app.get("/api/locations", async (_req, res, next) => {
|
||||
try {
|
||||
res.json(await requireAzure().getLocations());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/publishers", async (req, res, next) => {
|
||||
try {
|
||||
const { location } = queryLocation.parse(req.query);
|
||||
res.json(await requireAzure().getPublishers(location));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/offers", async (req, res, next) => {
|
||||
try {
|
||||
const { location, publisher } = queryOffer.parse(req.query);
|
||||
res.json(await requireAzure().getOffers(location, publisher));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/skus", async (req, res, next) => {
|
||||
try {
|
||||
const { location, publisher, offer } = querySku.parse(req.query);
|
||||
res.json(await requireAzure().getSkus(location, publisher, offer));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/versions", async (req, res, next) => {
|
||||
try {
|
||||
const { location, publisher, offer, sku } = queryVersion.parse(req.query);
|
||||
res.json(await requireAzure().getVersions(location, publisher, offer, sku));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/vm-skus", async (req, res, next) => {
|
||||
try {
|
||||
const { location } = queryLocation.parse(req.query);
|
||||
res.json(await requireAzure().getVmSkus(location));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/templates", (_req, res) => {
|
||||
res.json(templates.getTemplates());
|
||||
});
|
||||
|
||||
app.post("/api/render", (req, res, next) => {
|
||||
try {
|
||||
const payload = renderBody.parse(req.body);
|
||||
const rendered = templates.render(payload.templateFile, payload.selection);
|
||||
res.json({ rendered });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/sku-export", async (req, res, next) => {
|
||||
try {
|
||||
const { location, publisher, offer } = querySku.parse(req.query);
|
||||
const skus = await requireAzure().getSkus(location, publisher, offer);
|
||||
res.json({ rendered: templates.buildSkuExport(skus) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ message: "Invalid request", issues: err.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : "Unexpected error";
|
||||
res.status(500).json({ message });
|
||||
});
|
||||
|
||||
const frontendRoot = join(findAppRoot(), "dist/frontend");
|
||||
if (existsSync(frontendRoot)) {
|
||||
app.use(express.static(frontendRoot));
|
||||
app.get(/^(?!\/api).*/, (_req, res) => {
|
||||
res.sendFile(join(frontendRoot, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
return { app, port, host };
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const { app, port, host } = makeApp();
|
||||
const server = app.listen(port, host, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`azure-image-chooser listening on ${host}:${port}`);
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = (signal: NodeJS.Signals) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
shuttingDown = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`received ${signal}, shutting down`);
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("graceful shutdown failed", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force-exit if connections do not close in time.
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("shutdown timeout reached, forcing exit");
|
||||
process.exit(1);
|
||||
}, 10_000).unref();
|
||||
};
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
}
|
||||
|
||||
export { makeApp };
|
||||
41
app/backend/src/template-service.ts
Normal file
41
app/backend/src/template-service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import nunjucks from "nunjucks";
|
||||
import type { ImageSelection, UsageTemplate } from "./types";
|
||||
|
||||
const findAppRoot = (): string => {
|
||||
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(candidate, "templates.json"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to resolve app template root");
|
||||
};
|
||||
|
||||
export class TemplateService {
|
||||
private readonly appRoot = findAppRoot();
|
||||
|
||||
private readonly env = nunjucks.configure(join(this.appRoot, "templates"), {
|
||||
autoescape: false,
|
||||
noCache: true
|
||||
});
|
||||
|
||||
private readonly templates: UsageTemplate[] = JSON.parse(
|
||||
readFileSync(join(this.appRoot, "templates.json"), "utf8")
|
||||
) as UsageTemplate[];
|
||||
|
||||
public getTemplates(): UsageTemplate[] {
|
||||
return this.templates;
|
||||
}
|
||||
|
||||
public render(templateFile: string, selection: ImageSelection): string {
|
||||
return this.env.render(templateFile, selection);
|
||||
}
|
||||
|
||||
public buildSkuExport(skus: string[]): string {
|
||||
return `[\n${skus.map((sku) => `\t\"${sku}\"`).join(",\n")}\n]`;
|
||||
}
|
||||
}
|
||||
28
app/backend/src/types.ts
Normal file
28
app/backend/src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type LocationOption = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type UsageTemplate = {
|
||||
label: string;
|
||||
language: string;
|
||||
file: string;
|
||||
};
|
||||
|
||||
export type ImageSelection = {
|
||||
location: string;
|
||||
publisher: string;
|
||||
offer: string;
|
||||
sku: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type VmSkuOption = {
|
||||
name: string;
|
||||
size: string;
|
||||
family: string;
|
||||
tier: string;
|
||||
vcpus: number;
|
||||
memoryGb: number;
|
||||
maxDataDiskCount: number;
|
||||
};
|
||||
22
app/backend/src/version.ts
Normal file
22
app/backend/src/version.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const SEMVER_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
||||
|
||||
const semverSortKey = (value: string): number[] => value.split(".").map((part) => Number.parseInt(part, 10));
|
||||
|
||||
export const sortImageVersionsIfSemantic = (versions: string[]): string[] => {
|
||||
if (!versions.every((value) => SEMVER_PATTERN.test(value))) {
|
||||
return [...versions].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
return [...versions].sort((a, b) => {
|
||||
const aParts = semverSortKey(a);
|
||||
const bParts = semverSortKey(b);
|
||||
|
||||
for (let i = 0; i < aParts.length; i += 1) {
|
||||
if (aParts[i] !== bParts[i]) {
|
||||
return aParts[i] - bParts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
14
app/backend/test/version.test.ts
Normal file
14
app/backend/test/version.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sortImageVersionsIfSemantic } from "../src/version";
|
||||
|
||||
describe("sortImageVersionsIfSemantic", () => {
|
||||
it("sorts only semantic versions", () => {
|
||||
const sorted = sortImageVersionsIfSemantic(["1.10.0", "1.2.0", "2.0.0"]);
|
||||
expect(sorted).toEqual(["1.2.0", "1.10.0", "2.0.0"]);
|
||||
});
|
||||
|
||||
it("sorts non-semantic versions naturally", () => {
|
||||
const original = ["latest", "1.0.0", "beta"];
|
||||
expect(sortImageVersionsIfSemantic(original)).toEqual(["1.0.0", "beta", "latest"]);
|
||||
});
|
||||
});
|
||||
16
app/backend/tsconfig.json
Normal file
16
app/backend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "../dist/backend",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
app/build.sh
Executable file
18
app/build.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
REGISTRY="registry.koszewscy.waw.pl"
|
||||
IMAGE_NAME="azure-image-chooser-node"
|
||||
|
||||
if command -v container >/dev/null; then
|
||||
container build \
|
||||
-t $REGISTRY/$IMAGE_NAME:latest \
|
||||
--arch arm64 \
|
||||
--arch amd64 \
|
||||
. && \
|
||||
container image push \
|
||||
--arch amd64 \
|
||||
$REGISTRY/$IMAGE_NAME:latest
|
||||
else
|
||||
echo "No compatible container builder found." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,8 +1,4 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "$1" = "-s" ] || [ "$1" = "--shell" ]; then
|
||||
shift
|
||||
exec bash $@
|
||||
fi
|
||||
|
||||
exec streamlit $@
|
||||
exec node /app/dist/backend/server.js
|
||||
|
||||
12
app/frontend/index.html
Normal file
12
app/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Azure Image Chooser</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4465
app/frontend/package-lock.json
generated
Normal file
4465
app/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
app/frontend/package.json
Normal file
35
app/frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "azure-image-chooser-frontend",
|
||||
"version": "1.0.0",
|
||||
"author": "Sławomir Koszewski",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^9.0.0",
|
||||
"@mui/material": "^9.0.0",
|
||||
"@mui/x-data-grid": "^9.0.2",
|
||||
"@tanstack/react-query": "^5.99.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^27.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
196
app/frontend/src/App.tsx
Normal file
196
app/frontend/src/App.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Toolbar,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { TOOLS } from "./tools/toolRegistry";
|
||||
|
||||
const App = () => {
|
||||
const [activeToolId, setActiveToolId] = useState<string>(TOOLS[0]?.id ?? "");
|
||||
const [toolsAnchorEl, setToolsAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const activeTool = useMemo(
|
||||
() => TOOLS.find((tool) => tool.id === activeToolId) ?? TOOLS[0],
|
||||
[activeToolId]
|
||||
);
|
||||
|
||||
const toolsMenuOpen = Boolean(toolsAnchorEl);
|
||||
|
||||
const openToolsMenu = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
setToolsAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const closeToolsMenu = () => {
|
||||
setToolsAnchorEl(null);
|
||||
};
|
||||
|
||||
const selectTool = (toolId: string) => {
|
||||
setActiveToolId(toolId);
|
||||
closeToolsMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
color="default"
|
||||
elevation={0}
|
||||
sx={{ borderBottom: 1, borderColor: "divider", display: { xs: "block", md: "none" } }}
|
||||
>
|
||||
<Toolbar sx={{ gap: 1 }}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={openToolsMenu}
|
||||
aria-label="Open tools menu"
|
||||
aria-controls={toolsMenuOpen ? "tools-menu" : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={toolsMenuOpen ? "true" : undefined}
|
||||
edge="start"
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<Typography sx={{ flexGrow: 1, fontWeight: 500 }} noWrap>
|
||||
{activeTool?.name ?? "Tool"}
|
||||
</Typography>
|
||||
|
||||
<Button variant="text" color="inherit" onClick={() => setHelpOpen(true)}>
|
||||
Help
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Menu id="tools-menu" anchorEl={toolsAnchorEl} open={toolsMenuOpen} onClose={closeToolsMenu}>
|
||||
{TOOLS.map((tool) => (
|
||||
<MenuItem key={tool.id} selected={tool.id === activeTool?.id} onClick={() => selectTool(tool.id)}>
|
||||
{tool.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<Drawer anchor="right" open={helpOpen} onClose={() => setHelpOpen(false)}>
|
||||
<Box sx={{ width: { xs: "85vw", sm: 360 }, p: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Help
|
||||
</Typography>
|
||||
{activeTool?.helpContent ? (
|
||||
<Box sx={{ color: "text.secondary", whiteSpace: "pre-wrap" }}>{activeTool.helpContent}</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary">No help available for this tool.</Typography>
|
||||
)}
|
||||
<List sx={{ mt: 2 }}>
|
||||
<ListItemButton onClick={() => setHelpOpen(false)}>
|
||||
<ListItemText primary="Close" />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
<Box sx={{ display: { xs: "block", md: "none" }, p: 2 }}>{activeTool ? activeTool.render() : null}</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "none", md: "grid" },
|
||||
minHeight: "100vh",
|
||||
gridTemplateColumns: {
|
||||
md: "240px minmax(0, 1fr)",
|
||||
lg: "240px minmax(0, 1fr) 360px"
|
||||
},
|
||||
gap: 0,
|
||||
p: 0
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="aside"
|
||||
sx={{
|
||||
py: 3,
|
||||
pl: 3,
|
||||
pr: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Tools
|
||||
</Typography>
|
||||
<List
|
||||
disablePadding
|
||||
sx={{
|
||||
bgcolor: "#fff",
|
||||
borderRadius: 1
|
||||
}}
|
||||
>
|
||||
{TOOLS.map((tool) => (
|
||||
<ListItem key={tool.id} disablePadding>
|
||||
<Box
|
||||
role="option"
|
||||
aria-selected={tool.id === activeTool?.id}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveToolId(tool.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setActiveToolId(tool.id);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
width: "100%",
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
cursor: "pointer",
|
||||
borderLeft: 2,
|
||||
borderColor: tool.id === activeTool?.id ? "primary.main" : "transparent"
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={tool.name}
|
||||
slotProps={{
|
||||
primary: {
|
||||
sx: {
|
||||
fontWeight: tool.id === activeTool?.id ? 600 : 400
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0, p: 3 }}>{activeTool ? activeTool.render() : null}</Box>
|
||||
|
||||
<Box
|
||||
component="aside"
|
||||
sx={{
|
||||
gridColumn: { md: "2", lg: "auto" },
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Help
|
||||
</Typography>
|
||||
{activeTool?.helpContent ? (
|
||||
<Box sx={{ color: "text.secondary", whiteSpace: "pre-wrap" }}>{activeTool.helpContent}</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary">No help available for this tool.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
56
app/frontend/src/api.ts
Normal file
56
app/frontend/src/api.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { LocationOption, SelectionState, UsageTemplate, VmSkuOption } from "./types";
|
||||
|
||||
const json = async <T>(path: string): Promise<T> => {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
let details = "";
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: string };
|
||||
details = payload.message ?? "";
|
||||
} catch {
|
||||
// Ignore non-JSON error payloads.
|
||||
}
|
||||
|
||||
throw new Error(details || `Request failed for ${path} (${response.status})`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
health: () => json<{ status: string; message?: string }>("/api/health"),
|
||||
locations: () => json<LocationOption[]>("/api/locations"),
|
||||
publishers: (location: string) => json<string[]>(`/api/publishers?location=${encodeURIComponent(location)}`),
|
||||
offers: (location: string, publisher: string) =>
|
||||
json<string[]>(`/api/offers?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}`),
|
||||
skus: (location: string, publisher: string, offer: string) =>
|
||||
json<string[]>(
|
||||
`/api/skus?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}`
|
||||
),
|
||||
versions: (location: string, publisher: string, offer: string, sku: string) =>
|
||||
json<string[]>(
|
||||
`/api/versions?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}&sku=${encodeURIComponent(sku)}`
|
||||
),
|
||||
vmSkus: (location: string) => json<VmSkuOption[]>(`/api/vm-skus?location=${encodeURIComponent(location)}`),
|
||||
templates: () => json<UsageTemplate[]>("/api/templates"),
|
||||
render: async (templateFile: string, selection: SelectionState): Promise<string> => {
|
||||
const response = await fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ templateFile, selection })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to render template");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { rendered: string };
|
||||
return payload.rendered;
|
||||
},
|
||||
skuExport: async (location: string, publisher: string, offer: string): Promise<string> => {
|
||||
const payload = await json<{ rendered: string }>(
|
||||
`/api/sku-export?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}`
|
||||
);
|
||||
return payload.rendered;
|
||||
}
|
||||
};
|
||||
1
app/frontend/src/help/image-chooser-help.md
Normal file
1
app/frontend/src/help/image-chooser-help.md
Normal file
@@ -0,0 +1 @@
|
||||
Use this tool to select an Azure marketplace image and generate reusable snippets.
|
||||
1
app/frontend/src/help/vm-sku-chooser-help.md
Normal file
1
app/frontend/src/help/vm-sku-chooser-help.md
Normal file
@@ -0,0 +1 @@
|
||||
Use this tool to list VM SKUs available in a selected Azure region.
|
||||
33
app/frontend/src/location-helper.ts
Normal file
33
app/frontend/src/location-helper.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const SHARED_LOCATION_COOKIE_NAME = "location";
|
||||
|
||||
export const readSharedLocation = (): string => {
|
||||
if (typeof document === "undefined") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const cookies = document.cookie ? document.cookie.split("; ") : [];
|
||||
for (const entry of cookies) {
|
||||
const separatorIndex = entry.indexOf("=");
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = entry.slice(0, separatorIndex);
|
||||
if (key !== SHARED_LOCATION_COOKIE_NAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return decodeURIComponent(entry.slice(separatorIndex + 1));
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
export const writeSharedLocation = (location: string): void => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep selected location for 30 days across tools.
|
||||
document.cookie = `${SHARED_LOCATION_COOKIE_NAME}=${encodeURIComponent(location)}; Max-Age=${60 * 60 * 24 * 30}; Path=/; SameSite=Lax`;
|
||||
};
|
||||
16
app/frontend/src/main.tsx
Normal file
16
app/frontend/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CssBaseline } from "@mui/material";
|
||||
import App from "./App";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
16
app/frontend/src/tools/EmptyTool.tsx
Normal file
16
app/frontend/src/tools/EmptyTool.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
|
||||
const EmptyTool = () => {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 500 }}>
|
||||
Empty Tool
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
This tool is intentionally empty.
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyTool;
|
||||
295
app/frontend/src/tools/ImageChooserTool.tsx
Normal file
295
app/frontend/src/tools/ImageChooserTool.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { readSharedLocation, writeSharedLocation } from "../location-helper";
|
||||
import type { SelectionState } from "../types";
|
||||
|
||||
const EMPTY_SELECTION: SelectionState = {
|
||||
location: "",
|
||||
publisher: "",
|
||||
offer: "",
|
||||
sku: "",
|
||||
version: ""
|
||||
};
|
||||
|
||||
const ImageChooserTool = () => {
|
||||
const [selection, setSelection] = useState<SelectionState>(() => ({
|
||||
...EMPTY_SELECTION,
|
||||
location: readSharedLocation()
|
||||
}));
|
||||
const [templateFile, setTemplateFile] = useState("");
|
||||
const [renderedUsage, setRenderedUsage] = useState("");
|
||||
const [skuExport, setSkuExport] = useState("");
|
||||
const [renderError, setRenderError] = useState("");
|
||||
|
||||
const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
|
||||
const locationsQuery = useQuery({ queryKey: ["locations"], queryFn: api.locations });
|
||||
const publishersQuery = useQuery({
|
||||
queryKey: ["publishers", selection.location],
|
||||
queryFn: () => api.publishers(selection.location),
|
||||
enabled: Boolean(selection.location)
|
||||
});
|
||||
const offersQuery = useQuery({
|
||||
queryKey: ["offers", selection.location, selection.publisher],
|
||||
queryFn: () => api.offers(selection.location, selection.publisher),
|
||||
enabled: Boolean(selection.location && selection.publisher)
|
||||
});
|
||||
const skusQuery = useQuery({
|
||||
queryKey: ["skus", selection.location, selection.publisher, selection.offer],
|
||||
queryFn: () => api.skus(selection.location, selection.publisher, selection.offer),
|
||||
enabled: Boolean(selection.location && selection.publisher && selection.offer)
|
||||
});
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: ["versions", selection.location, selection.publisher, selection.offer, selection.sku],
|
||||
queryFn: () => api.versions(selection.location, selection.publisher, selection.offer, selection.sku),
|
||||
enabled: Boolean(selection.location && selection.publisher && selection.offer && selection.sku)
|
||||
});
|
||||
const templatesQuery = useQuery({ queryKey: ["templates"], queryFn: api.templates });
|
||||
|
||||
useEffect(() => {
|
||||
setSelection((previous) => ({ ...previous, publisher: "", offer: "", sku: "", version: "" }));
|
||||
setRenderedUsage("");
|
||||
setSkuExport("");
|
||||
}, [selection.location]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection((previous) => ({ ...previous, offer: "", sku: "", version: "" }));
|
||||
setRenderedUsage("");
|
||||
setSkuExport("");
|
||||
}, [selection.publisher]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection((previous) => ({ ...previous, sku: "", version: "" }));
|
||||
setRenderedUsage("");
|
||||
}, [selection.offer]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection((previous) => ({ ...previous, version: "" }));
|
||||
setRenderedUsage("");
|
||||
}, [selection.sku]);
|
||||
|
||||
const canRender = useMemo(
|
||||
() => Boolean(selection.location && selection.publisher && selection.offer && selection.sku && selection.version && templateFile),
|
||||
[selection, templateFile]
|
||||
);
|
||||
|
||||
const publishers = useMemo(
|
||||
() => [...(publishersQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
[publishersQuery.data]
|
||||
);
|
||||
const offers = useMemo(
|
||||
() => [...(offersQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
[offersQuery.data]
|
||||
);
|
||||
const skus = useMemo(
|
||||
() => [...(skusQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
[skusQuery.data]
|
||||
);
|
||||
const versions = useMemo(
|
||||
() => [...(versionsQuery.data ?? [])].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })),
|
||||
[versionsQuery.data]
|
||||
);
|
||||
|
||||
const containsFilter = (options: string[], inputValue: string) => {
|
||||
const query = inputValue.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return options.filter((option) => option.toLowerCase().includes(query));
|
||||
};
|
||||
|
||||
const onRender = async () => {
|
||||
setRenderError("");
|
||||
try {
|
||||
const [usage, skuBlock] = await Promise.all([
|
||||
api.render(templateFile, selection),
|
||||
api.skuExport(selection.location, selection.publisher, selection.offer)
|
||||
]);
|
||||
setRenderedUsage(usage);
|
||||
setSkuExport(skuBlock);
|
||||
} catch (error) {
|
||||
setRenderError(error instanceof Error ? error.message : "Unexpected render error");
|
||||
}
|
||||
};
|
||||
|
||||
const loading = locationsQuery.isLoading || templatesQuery.isLoading;
|
||||
const healthError = healthQuery.error instanceof Error ? healthQuery.error.message : "";
|
||||
const appNotConfigured = healthError.includes("Missing AZURE_SUBSCRIPTION_ID");
|
||||
const locationsError = locationsQuery.error instanceof Error ? locationsQuery.error.message : "Failed to load locations";
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 500 }}>
|
||||
Azure Image Chooser
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Select a marketplace image and generate reusable snippets.
|
||||
</Typography>
|
||||
|
||||
{appNotConfigured ? (
|
||||
<Alert severity="warning">
|
||||
App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<CircularProgress />
|
||||
) : locationsQuery.isError ? (
|
||||
<Alert severity={appNotConfigured ? "warning" : "error"}>{locationsError}</Alert>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="location-label">Location</InputLabel>
|
||||
<Select
|
||||
labelId="location-label"
|
||||
label="Location"
|
||||
value={selection.location}
|
||||
onChange={(event) => {
|
||||
const nextLocation = event.target.value;
|
||||
setSelection((prev) => ({ ...prev, location: nextLocation }));
|
||||
writeSharedLocation(nextLocation);
|
||||
}}
|
||||
>
|
||||
{(locationsQuery.data ?? []).map((location) => (
|
||||
<MenuItem key={location.name} value={location.name}>
|
||||
{location.displayName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Autocomplete
|
||||
options={publishers}
|
||||
value={selection.publisher || null}
|
||||
disabled={!selection.location}
|
||||
onChange={(_event, value) => setSelection((prev) => ({ ...prev, publisher: value ?? "" }))}
|
||||
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
||||
renderInput={(params) => <TextField {...params} label="Publisher" />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Autocomplete
|
||||
options={offers}
|
||||
value={selection.offer || null}
|
||||
disabled={!selection.publisher}
|
||||
onChange={(_event, value) => setSelection((prev) => ({ ...prev, offer: value ?? "" }))}
|
||||
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
||||
renderInput={(params) => <TextField {...params} label="Offer" />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Autocomplete
|
||||
options={skus}
|
||||
value={selection.sku || null}
|
||||
disabled={!selection.offer}
|
||||
onChange={(_event, value) => setSelection((prev) => ({ ...prev, sku: value ?? "" }))}
|
||||
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
||||
renderInput={(params) => <TextField {...params} label="SKU" />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth disabled={!selection.sku}>
|
||||
<InputLabel id="version-label">Version</InputLabel>
|
||||
<Select
|
||||
labelId="version-label"
|
||||
label="Version"
|
||||
value={selection.version}
|
||||
onChange={(event) => setSelection((prev) => ({ ...prev, version: event.target.value }))}
|
||||
>
|
||||
{versions.map((version) => (
|
||||
<MenuItem key={version} value={version}>
|
||||
{version}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="template-label">Usage scenario</InputLabel>
|
||||
<Select
|
||||
labelId="template-label"
|
||||
label="Usage scenario"
|
||||
value={templateFile}
|
||||
onChange={(event) => setTemplateFile(event.target.value)}
|
||||
>
|
||||
{(templatesQuery.data ?? []).map((template) => (
|
||||
<MenuItem key={template.file} value={template.file}>
|
||||
{template.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Button fullWidth variant="contained" sx={{ height: "100%" }} color="primary" onClick={onRender} disabled={!canRender}>
|
||||
Generate usage
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{renderError ? <Alert severity="error">{renderError}</Alert> : null}
|
||||
|
||||
{renderedUsage ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Usage snippet</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
|
||||
>
|
||||
{renderedUsage}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{skuExport ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Available SKUs</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
|
||||
>
|
||||
{skuExport}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageChooserTool;
|
||||
288
app/frontend/src/tools/VmSkuChooserTool.tsx
Normal file
288
app/frontend/src/tools/VmSkuChooserTool.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { readSharedLocation, writeSharedLocation } from "../location-helper";
|
||||
import type { VmSkuOption } from "../types";
|
||||
|
||||
const HEADER_CLASS_NAME = "vm-sku-grid-header";
|
||||
|
||||
type VmSkuRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
vcpus: number;
|
||||
memoryGb: number;
|
||||
maxDataDiskCount: number;
|
||||
};
|
||||
|
||||
const formatValue = (value: number | null): string => {
|
||||
if (value === null) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
};
|
||||
|
||||
const extractFamilyCode = (sku: VmSkuOption): string => {
|
||||
const skuName = sku.name ?? "";
|
||||
const skuFamily = sku.family ?? "";
|
||||
|
||||
const byName = skuName.match(/^Standard_([A-Za-z])/i);
|
||||
if (byName?.[1]) {
|
||||
return byName[1].toUpperCase();
|
||||
}
|
||||
|
||||
const byFamily = skuFamily.match(/([A-Za-z])Family/i);
|
||||
if (byFamily?.[1]) {
|
||||
return byFamily[1].toUpperCase();
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const VmSkuChooserTool = () => {
|
||||
const [location, setLocation] = useState(() => readSharedLocation());
|
||||
const [nameFilter, setNameFilter] = useState("");
|
||||
const [familyFilter, setFamilyFilter] = useState("");
|
||||
|
||||
const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
|
||||
const locationsQuery = useQuery({ queryKey: ["locations"], queryFn: api.locations });
|
||||
const vmSkusQuery = useQuery({
|
||||
queryKey: ["vm-skus", location],
|
||||
queryFn: () => api.vmSkus(location),
|
||||
enabled: Boolean(location)
|
||||
});
|
||||
|
||||
const healthError = healthQuery.error instanceof Error ? healthQuery.error.message : "";
|
||||
const appNotConfigured = healthError.includes("Missing AZURE_SUBSCRIPTION_ID");
|
||||
|
||||
const familyOptions = useMemo(() => {
|
||||
const source = vmSkusQuery.data ?? [];
|
||||
const families = new Set<string>();
|
||||
|
||||
for (const sku of source) {
|
||||
const code = extractFamilyCode(sku);
|
||||
if (code) {
|
||||
families.add(code);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(families).sort((a, b) => a.localeCompare(b));
|
||||
}, [vmSkusQuery.data]);
|
||||
|
||||
const filteredSkus = useMemo(() => {
|
||||
const source = vmSkusQuery.data ?? [];
|
||||
const nameQuery = nameFilter.trim().toLowerCase();
|
||||
|
||||
return source.filter((item) => {
|
||||
const itemName = item.name ?? "";
|
||||
if (nameQuery && !itemName.toLowerCase().includes(nameQuery)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const familyCode = extractFamilyCode(item);
|
||||
if (familyFilter && familyCode !== familyFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [vmSkusQuery.data, nameFilter, familyFilter]);
|
||||
|
||||
const rows = useMemo<VmSkuRow[]>(
|
||||
() =>
|
||||
filteredSkus.map((sku) => ({
|
||||
id: sku.name || `${sku.size || "sku"}-${sku.family || "unknown"}`,
|
||||
name: sku.name || "-",
|
||||
vcpus: sku.vcpus,
|
||||
memoryGb: sku.memoryGb,
|
||||
maxDataDiskCount: sku.maxDataDiskCount
|
||||
})),
|
||||
[filteredSkus]
|
||||
);
|
||||
|
||||
const columns = useMemo<GridColDef<VmSkuRow>[]>(
|
||||
() => [
|
||||
{
|
||||
field: "name",
|
||||
headerName: "SKU",
|
||||
minWidth: 220,
|
||||
flex: 1.4,
|
||||
headerClassName: HEADER_CLASS_NAME
|
||||
},
|
||||
{
|
||||
field: "vcpus",
|
||||
headerName: "vCPUs",
|
||||
type: "number",
|
||||
minWidth: 110,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (value) => formatValue(typeof value === "number" ? value : null),
|
||||
headerClassName: HEADER_CLASS_NAME
|
||||
},
|
||||
{
|
||||
field: "memoryGb",
|
||||
headerName: "Memory (GB)",
|
||||
type: "number",
|
||||
minWidth: 130,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (value) => formatValue(typeof value === "number" ? value : null),
|
||||
headerClassName: HEADER_CLASS_NAME
|
||||
},
|
||||
{
|
||||
field: "maxDataDiskCount",
|
||||
headerName: "Max Data Disks",
|
||||
type: "number",
|
||||
minWidth: 150,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (value) => formatValue(typeof value === "number" ? value : null),
|
||||
headerClassName: HEADER_CLASS_NAME
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 500 }}>
|
||||
VM SKU Chooser
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Browse available VM SKUs in a region using Azure API data.
|
||||
</Typography>
|
||||
|
||||
{appNotConfigured ? (
|
||||
<Alert severity="warning">
|
||||
App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
lg: "repeat(2, minmax(0, 1fr))"
|
||||
},
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="vm-sku-location-label">Location</InputLabel>
|
||||
<Select
|
||||
labelId="vm-sku-location-label"
|
||||
label="Location"
|
||||
value={location}
|
||||
onChange={(event) => {
|
||||
const nextLocation = event.target.value;
|
||||
setLocation(nextLocation);
|
||||
writeSharedLocation(nextLocation);
|
||||
}}
|
||||
>
|
||||
{(locationsQuery.data ?? []).map((option) => (
|
||||
<MenuItem key={option.name} value={option.name}>
|
||||
{option.displayName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Filter by SKU name"
|
||||
value={nameFilter}
|
||||
onChange={(event) => setNameFilter(event.target.value)}
|
||||
disabled={!location}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, minmax(0, 1fr))"
|
||||
},
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<FormControl fullWidth disabled={!location}>
|
||||
<InputLabel id="vm-sku-family-filter-label">VM Family</InputLabel>
|
||||
<Select
|
||||
labelId="vm-sku-family-filter-label"
|
||||
label="VM Family"
|
||||
value={familyFilter}
|
||||
onChange={(event) => setFamilyFilter(event.target.value)}
|
||||
>
|
||||
<MenuItem value="">All families</MenuItem>
|
||||
{familyOptions.map((family) => (
|
||||
<MenuItem key={family} value={family}>
|
||||
{family}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{locationsQuery.isLoading ? <CircularProgress /> : null}
|
||||
{locationsQuery.error instanceof Error ? <Alert severity={appNotConfigured ? "warning" : "error"}>{locationsQuery.error.message}</Alert> : null}
|
||||
|
||||
{vmSkusQuery.isLoading ? <CircularProgress /> : null}
|
||||
{vmSkusQuery.error instanceof Error ? <Alert severity="error">{vmSkusQuery.error.message}</Alert> : null}
|
||||
|
||||
{location && !vmSkusQuery.isLoading && !vmSkusQuery.isError ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6">Available SKUs ({filteredSkus.length})</Typography>
|
||||
<Box sx={{ height: 520 }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
sx={{
|
||||
[`& .${HEADER_CLASS_NAME}`]: {
|
||||
bgcolor: "grey.200"
|
||||
}
|
||||
}}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: { pageSize: 25, page: 0 }
|
||||
}
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{rows.length === 0 ? <Typography color="text.secondary">No SKUs found for this filter.</Typography> : null}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default VmSkuChooserTool;
|
||||
33
app/frontend/src/tools/toolRegistry.tsx
Normal file
33
app/frontend/src/tools/toolRegistry.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import ImageChooserTool from "./ImageChooserTool";
|
||||
import EmptyTool from "./EmptyTool";
|
||||
import VmSkuChooserTool from "./VmSkuChooserTool";
|
||||
import imageChooserHelp from "../help/image-chooser-help.md?raw";
|
||||
import vmSkuChooserHelp from "../help/vm-sku-chooser-help.md?raw";
|
||||
import type { ToolDefinition } from "./types";
|
||||
|
||||
const isProductionMode =
|
||||
import.meta.env.PROD || import.meta.env.MODE === "production" || import.meta.env.VITE_NODE === "production";
|
||||
|
||||
export const TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
id: "image-chooser",
|
||||
name: "Azure Image Chooser",
|
||||
helpContent: imageChooserHelp,
|
||||
render: () => <ImageChooserTool />
|
||||
},
|
||||
{
|
||||
id: "vm-sku-chooser",
|
||||
name: "VM SKU Chooser",
|
||||
helpContent: vmSkuChooserHelp,
|
||||
render: () => <VmSkuChooserTool />
|
||||
},
|
||||
...(!isProductionMode
|
||||
? [
|
||||
{
|
||||
id: "empty-tool",
|
||||
name: "Empty Tool",
|
||||
render: () => <EmptyTool />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
8
app/frontend/src/tools/types.ts
Normal file
8
app/frontend/src/tools/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export type ToolDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
helpContent?: string;
|
||||
render: () => ReactElement;
|
||||
};
|
||||
28
app/frontend/src/types.ts
Normal file
28
app/frontend/src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type LocationOption = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type UsageTemplate = {
|
||||
label: string;
|
||||
language: string;
|
||||
file: string;
|
||||
};
|
||||
|
||||
export type SelectionState = {
|
||||
location: string;
|
||||
publisher: string;
|
||||
offer: string;
|
||||
sku: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type VmSkuOption = {
|
||||
name: string;
|
||||
size: string;
|
||||
family: string;
|
||||
tier: string;
|
||||
vcpus: number;
|
||||
memoryGb: number;
|
||||
maxDataDiskCount: number;
|
||||
};
|
||||
57
app/frontend/test/app.test.tsx
Normal file
57
app/frontend/test/app.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import App from "../src/App";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const createResponse = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
|
||||
describe("App", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/locations") {
|
||||
return Promise.resolve(createResponse([{ name: "westeurope", displayName: "West Europe" }]));
|
||||
}
|
||||
|
||||
if (url === "/api/templates") {
|
||||
return Promise.resolve(createResponse([{ label: "Azure CLI", language: "shell", file: "shell.tpl" }]));
|
||||
}
|
||||
|
||||
return Promise.resolve(createResponse([]));
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("renders application heading", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
const theme = createTheme();
|
||||
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Azure Image Chooser")).toBeInTheDocument();
|
||||
view.unmount();
|
||||
client.clear();
|
||||
});
|
||||
});
|
||||
1
app/frontend/test/setup.ts
Normal file
1
app/frontend/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
15
app/frontend/tsconfig.json
Normal file
15
app/frontend/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src", "test", "vite.config.ts"]
|
||||
}
|
||||
24
app/frontend/vite.config.ts
Normal file
24
app/frontend/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../dist/frontend",
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
8
app/frontend/vitest.config.ts
Normal file
8
app/frontend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./test/setup.ts"]
|
||||
}
|
||||
});
|
||||
39
app/healthcheck.js
Normal file
39
app/healthcheck.js
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const http = require("node:http");
|
||||
|
||||
const port = Number.parseInt(process.env.PORT || "3000", 10);
|
||||
const path = process.env.HEALTHCHECK_PATH || "/api/health";
|
||||
const timeoutMs = Number.parseInt(process.env.HEALTHCHECK_TIMEOUT_MS || "3000", 10);
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
path,
|
||||
method: "GET",
|
||||
timeout: timeoutMs
|
||||
},
|
||||
(res) => {
|
||||
// Drain the response so the socket can close cleanly.
|
||||
res.resume();
|
||||
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400) {
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
);
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error("healthcheck timeout"));
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.end();
|
||||
@@ -1,17 +1,22 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"label": "Bicep VM image reference",
|
||||
"language": "bicep",
|
||||
"file": "bicep_vm.tpl"
|
||||
}
|
||||
]
|
||||
|
||||
14
app/templates/bicep_vm.tpl
Normal file
14
app/templates/bicep_vm.tpl
Normal file
@@ -0,0 +1,14 @@
|
||||
resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-03-01' = {
|
||||
name: 'example-vm'
|
||||
location: resourceGroup().location
|
||||
properties: {
|
||||
storageProfile: {
|
||||
imageReference: {
|
||||
publisher: '{{ publisher }}'
|
||||
offer: '{{ offer }}'
|
||||
sku: '{{ sku }}'
|
||||
version: '{{ version }}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}
|
||||
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}
|
||||
|
||||
17
build.sh
17
build.sh
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
IMAGE_NAME="azure-image-chooser"
|
||||
#IMAGE="docker.io/skoszewski/$IMAGE_NAME"
|
||||
IMAGE="skdomlab.azurecr.io/$IMAGE_NAME"
|
||||
|
||||
if [ "$(basename $(command -v docker))" = "docker" ]; then
|
||||
CMD="docker"
|
||||
elif [ "$(basename $(command -v podman))" = "podman" ]; then
|
||||
CMD="podman"
|
||||
else
|
||||
echo "No suitable container tool found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$CMD build -t $IMAGE app
|
||||
$CMD push $IMAGE
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/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
|
||||
fi
|
||||
|
||||
if [ "$(basename $(command -v docker))" = "docker" ]; then
|
||||
CMD="docker"
|
||||
elif [ "$(basename $(command -v podman))" = "podman" ]; then
|
||||
CMD="podman"
|
||||
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
|
||||
@@ -30,7 +30,7 @@ data "azuread_user" "az_lab_admin" {
|
||||
|
||||
locals {
|
||||
kv_secret_name = "azure-client-secret"
|
||||
app_name = "${var.project_name}-app"
|
||||
app_name = "${var.project_name}-app"
|
||||
}
|
||||
|
||||
resource "azurerm_resource_group" "rg" {
|
||||
@@ -52,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" {
|
||||
@@ -120,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,19 +188,19 @@ resource "azurerm_dns_txt_record" "domain_verification" {
|
||||
}
|
||||
|
||||
resource "azurerm_dns_cname_record" "app_record" {
|
||||
name = var.project_name
|
||||
zone_name = var.dns_zone_name
|
||||
name = var.project_name
|
||||
zone_name = var.dns_zone_name
|
||||
resource_group_name = var.dns_zone_resource_group_name
|
||||
ttl = 300
|
||||
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."), ".")
|
||||
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 ]
|
||||
ignore_changes = [certificate_binding_type, container_app_environment_certificate_id]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user