diff --git a/cli/azd/extensions/azure.ai.rle/.gitignore b/cli/azd/extensions/azure.ai.rle/.gitignore new file mode 100644 index 00000000000..77c2ef685c7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/.gitignore @@ -0,0 +1,3 @@ +bin/ +artifacts/ +registry-artifacts/ \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.rle/.golangci.yaml b/cli/azd/extensions/azure.ai.rle/.golangci.yaml new file mode 100644 index 00000000000..2c65a85a219 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/.golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md new file mode 100644 index 00000000000..befabd392e7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History + +## 0.1.0-preview + +- Initial preview scaffold for the RLE extension with `create`, `modify`, and `version` commands. \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md new file mode 100644 index 00000000000..7168ca91a2e --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -0,0 +1,153 @@ +# Azure AI RLE extension for azd + +The `azure.ai.rle` extension adds the `azd ai rle` command group. + +## Local setup + +### 1. Install prerequisites + +Install: + +- Azure Developer CLI (`azd`): https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd +- Go: https://go.dev/doc/install +- Git, for local development and for fetching the managed Loom recipe: https://git-scm.com/downloads +- Python: https://www.python.org/downloads/ +- uv, for running the Loom training recipe: https://docs.astral.sh/uv/getting-started/installation/ + +Verify: + +```powershell +azd version +go version +git --version +python --version +``` + +`az login` is not required for the current `init`/`deploy` flow because deploy calls the RLE control plane directly. + +### 2. Check out the branch + +```powershell +git fetch origin +git checkout farhannawaz/rle-cli +cd cli\azd\extensions\azure.ai.rle +``` + +### 3. Configure the RLE control plane + +```powershell +$env:RLE_ENDPOINT = "http://localhost:5000" +$env:RLE_PROJECT_NAME = "demo-3" +``` + +`http://localhost:5000` is also the built-in default, so you can omit `RLE_ENDPOINT` when using a local RLE control plane. To target the hosted control plane, set `RLE_ENDPOINT` to its URL. + +For `invoke`, provide the Azure AI project endpoint as a parameter: + +```powershell +az login +``` + +The environment image is no longer configured through an environment variable. By default, +`deploy` registers a per-environment image named after the environment (for example +`devrle.azurecr.io/code-rl:latest`). Use `--image ` to deploy a specific prebuilt +image instead. + +### 4. Install the extension into azd + +Run these commands from the extension directory: + +```powershell +azd extension install microsoft.azd.extensions +azd x build +azd x pack +azd x publish +azd extension install azure.ai.rle --source local --force +``` + +`azd x build` builds the extension artifacts. `azd x pack` and `azd x publish` +register them in the local azd extension source, and `azd extension install` +makes the `azd ai rle` command group available in any terminal. + +Verify: + +```powershell +azd ai rle --help +azd ai rle version +``` + +After making local code changes, rebuild and update the installed extension with: + +```powershell +azd x build +azd x pack +azd x publish +azd extension install azure.ai.rle --source local --force +``` + +### 5. Initialize a local RLE session + +```powershell +azd ai rle init code_rl +``` + +Init creates a local session folder named `code_rl`, including an OpenEnv-style FastAPI package, `Dockerfile`, `rle.yaml`, and azd-managed dependencies under `.azd-rle\deps`. + +Deploy from the session folder: + +```powershell +cd .\code_rl +azd ai rle deploy --project omi-build-demo-uae +``` + +Deploy creates or updates the RLE environment and saves the project plus environment id/version locally in `.azd-rle.json`. + +### 6. Run Loom training + +```powershell +azd ai rle invoke ` + --recipe code_rl_with_rle ` + --project-endpoint "https://omi-build-demo-uae.services.ai.azure.com/api/projects/omi-build-demo-uae" +``` + +Invoke runs the selected Loom recipe's `train_azure.py` entrypoint with values from `.azd-rle.json`. +It passes the deployed RLE environment id, project, and control-plane endpoint to the Loom recipe, +which uses `rle_sdk` to lease sandboxes and call `reset`/`step` during training. + +Invoke fetches Loom branch `code_rl_with_rle` into `.azd-rle\recipes\loom` +and uses the RLE SDK wheel copied by `init`. You do not need a separate local Loom checkout +or a separately installed RLE SDK package. The managed Git checkout is shallow, single-branch, +and tagless (`--depth 1 --single-branch --no-tags`). In the future this recipe dependency can +move from Git to a published package. + +After fetching Loom, invoke patches only the managed copy of `loom-cookbook\pyproject.toml` +so `uv` resolves `azure-ai-finetuning-sessions` from the fetched Loom checkout and `rle-sdk` +from `.azd-rle\deps`. + +The default invoke settings are: + +```text +num_tasks=4 +model_name=Qwen/Qwen3-32B +renderer_name=qwen3_disable_thinking +max_tokens=1200 +lora_rank=32 +group_size=4 +groups_per_batch=1 +max_steps=1 +loss_fn=importance_sampling +seed=42 +eval_every=999999 +save_every=999999 +remove_constant_reward_groups=true +``` + +Override the recipe, task count, or model with flags, for example: + +```powershell +azd ai rle invoke ` + --recipe code_rl_with_rle ` + --project-endpoint "https://omi-build-demo-uae.services.ai.azure.com/api/projects/omi-build-demo-uae" ` + --num-tasks 4 ` + --model-name "Qwen/Qwen3-32B" +``` diff --git a/cli/azd/extensions/azure.ai.rle/build.ps1 b/cli/azd/extensions/azure.ai.rle/build.ps1 new file mode 100644 index 00000000000..68e546148e6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/build.ps1 @@ -0,0 +1,78 @@ +# Ensure script fails on any error +$ErrorActionPreference = 'Stop' + +# Get the directory of the script +$EXTENSION_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Change to the script directory +Set-Location -Path $EXTENSION_DIR + +# Create a safe version of EXTENSION_ID replacing dots with dashes +$EXTENSION_ID_SAFE = $env:EXTENSION_ID -replace '\.', '-' + +# Define output directory +$OUTPUT_DIR = if ($env:OUTPUT_DIR) { $env:OUTPUT_DIR } else { Join-Path $EXTENSION_DIR "bin" } + +# Create output directory if it doesn't exist +if (-not (Test-Path -Path $OUTPUT_DIR)) { + New-Item -ItemType Directory -Path $OUTPUT_DIR | Out-Null +} + +# Get Git commit hash and build date +$COMMIT = git rev-parse HEAD +if ($LASTEXITCODE -ne 0) { + Write-Host "Error: Failed to get git commit hash" + exit 1 +} +$BUILD_DATE = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") + +# List of OS and architecture combinations +if ($env:EXTENSION_PLATFORM) { + $PLATFORMS = @($env:EXTENSION_PLATFORM) +} +else { + $PLATFORMS = @( + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64" + ) +} + +$APP_PATH = "$env:EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +foreach ($PLATFORM in $PLATFORMS) { + $OS, $ARCH = $PLATFORM -split '/' + + $OUTPUT_NAME = Join-Path $OUTPUT_DIR "$EXTENSION_ID_SAFE-$OS-$ARCH" + + if ($OS -eq "windows") { + $OUTPUT_NAME += ".exe" + } + + Write-Host "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + if (Test-Path -Path $OUTPUT_NAME) { + Remove-Item -Path $OUTPUT_NAME -Force + } + + # Set environment variables for Go build + $env:GOOS = $OS + $env:GOARCH = $ARCH + + go build ` + -ldflags="-X '$APP_PATH.Version=$env:EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" ` + -o $OUTPUT_NAME + + if ($LASTEXITCODE -ne 0) { + Write-Host "An error occurred while building for $OS/$ARCH" + exit 1 + } +} + +Write-Host "Build completed successfully!" +Write-Host "Binaries are located in the $OUTPUT_DIR directory." \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.rle/build.sh b/cli/azd/extensions/azure.ai.rle/build.sh new file mode 100644 index 00000000000..c20ec2d747b --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/build.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Get the directory of the script +EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change to the script directory +cd "$EXTENSION_DIR" || exit + +# Create a safe version of EXTENSION_ID replacing dots with dashes +EXTENSION_ID_SAFE="${EXTENSION_ID//./-}" + +# Define output directory +OUTPUT_DIR="${OUTPUT_DIR:-$EXTENSION_DIR/bin}" + +# Create output and target directories if they don't exist +mkdir -p "$OUTPUT_DIR" + +# Get Git commit hash and build date +COMMIT=$(git rev-parse HEAD) +BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# List of OS and architecture combinations +if [ -n "$EXTENSION_PLATFORM" ]; then + PLATFORMS=("$EXTENSION_PLATFORM") +else + PLATFORMS=( + "windows/amd64" + "windows/arm64" + "darwin/amd64" + "darwin/arm64" + "linux/amd64" + "linux/arm64" + ) +fi + +APP_PATH="$EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +for PLATFORM in "${PLATFORMS[@]}"; do + OS=$(echo "$PLATFORM" | cut -d'/' -f1) + ARCH=$(echo "$PLATFORM" | cut -d'/' -f2) + + OUTPUT_NAME="$OUTPUT_DIR/$EXTENSION_ID_SAFE-$OS-$ARCH" + + if [ "$OS" = "windows" ]; then + OUTPUT_NAME+='.exe' + fi + + echo "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + [ -f "$OUTPUT_NAME" ] && rm -f "$OUTPUT_NAME" + + # Set environment variables for Go build + GOOS=$OS GOARCH=$ARCH go build \ + -ldflags="-X '$APP_PATH.Version=$EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" \ + -o "$OUTPUT_NAME" + + if [ $? -ne 0 ]; then + echo "An error occurred while building for $OS/$ARCH" + exit 1 + fi +done + +echo "Build completed successfully!" +echo "Binaries are located in the $OUTPUT_DIR directory." \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.rle/cspell.yaml b/cli/azd/extensions/azure.ai.rle/cspell.yaml new file mode 100644 index 00000000000..49e12f4764b --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/cspell.yaml @@ -0,0 +1,4 @@ +import: ../../.vscode/cspell.yaml +words: + - RLE + - azdai \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.rle/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml new file mode 100644 index 00000000000..6867c640f92 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json +capabilities: + - custom-commands + - metadata +description: Manage RLE resources from your terminal. (Preview) +displayName: RLE (Preview) +id: azure.ai.rle +language: go +namespace: ai.rle +tags: + - ai + - rle +usage: azd ai rle [options] +version: 0.1.0-preview +examples: + - name: init + description: Scaffold a local RLE environment. + usage: azd ai rle init code_rl + - name: deploy + description: Create or update the RLE environment. + usage: azd ai rle deploy + - name: invoke + description: Run the Loom RLE training recipe. + usage: azd ai rle invoke --recipe code_rl_with_rle diff --git a/cli/azd/extensions/azure.ai.rle/go.mod b/cli/azd/extensions/azure.ai.rle/go.mod new file mode 100644 index 00000000000..9db2bbc90e8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/go.mod @@ -0,0 +1,102 @@ +module azure.ai.rle + +go 1.26.4 + +require ( + github.com/azure/azure-dev/cli/azd v1.25.0 + github.com/fatih/color v1.18.0 + github.com/spf13/cobra v1.10.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/braydonk/yaml v0.9.0 // indirect + github.com/buger/goterm v1.0.4 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/drone/envsubst v1.0.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golobby/container/v3 v3.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jmespath-community/go-jmespath v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect + github.com/microsoft/go-deviceid v1.0.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/theckman/yacspin v0.13.12 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/cli/azd/extensions/azure.ai.rle/go.sum b/cli/azd/extensions/azure.ai.rle/go.sum new file mode 100644 index 00000000000..2f50b8bcfd1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/go.sum @@ -0,0 +1,310 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b/go.mod h1:XjvqMUpGd3Xn9Jtzk/4GEBCSoBX0eB2RyriXgne0IdM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.25.0 h1:gb8Ah5ntUcUAKIDBhCdpx8xxDWSAtCLGyck+Y50QZhw= +github.com/azure/azure-dev/cli/azd v1.25.0/go.mod h1:1ZoZZlUbK8FMTZRibM9hEo/UqSaEXA+SFeIKpya4fsY= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/braydonk/yaml v0.9.0 h1:ewGMrVmEVpsm3VwXQDR388sLg5+aQ8Yihp6/hc4m+h4= +github.com/braydonk/yaml v0.9.0/go.mod h1:hcm3h581tudlirk8XEUPDBAimBPbmnL0Y45hCRl47N4= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 h1:a5q2sWiet6kgqucSGjYN1jhT2cn4bMKUwprtm2IGRto= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= +github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golobby/container/v3 v3.3.2 h1:7u+RgNnsdVlhGoS8gY4EXAG601vpMMzLZlYqSp77Quw= +github.com/golobby/container/v3 v3.3.2/go.mod h1:RDdKpnKpV1Of11PFBe7Dxc2C1k2KaLE4FD47FflAmj0= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= +github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= +github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= +github.com/microsoft/go-deviceid v1.0.0 h1:i5AQ654Xk9kfvwJeKQm3w2+eT1+ImBDVEpAR0AjpP40= +github.com/microsoft/go-deviceid v1.0.0/go.mod h1:KY13FeVdHkzD8gy+6T8+kVmD/7RMpTaWW75K+T4uZWg= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/assets/rle_sdk-0.1.3-py3-none-any.whl b/cli/azd/extensions/azure.ai.rle/internal/cmd/assets/rle_sdk-0.1.3-py3-none-any.whl new file mode 100644 index 00000000000..7dc7c2c382b Binary files /dev/null and b/cli/azd/extensions/azure.ai.rle/internal/cmd/assets/rle_sdk-0.1.3-py3-none-any.whl differ diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go new file mode 100644 index 00000000000..d14e7d272b0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "io" + "strings" + + "github.com/fatih/color" +) + +const bannerArt = `███████╗ ██████╗ ██╗ ██╗███╗ ██╗██████╗ ██████╗ ██╗ ██╗ +██╔════╝██╔═══██╗██║ ██║████╗ ██║██╔══██╗██╔══██╗╚██╗ ██╔╝ +█████╗ ██║ ██║██║ ██║██╔██╗ ██║██║ ██║██████╔╝ ╚████╔╝ +██╔══╝ ██║ ██║██║ ██║██║╚██╗██║██║ ██║██╔══██╗ ╚██╔╝ +██║ ╚██████╔╝╚██████╔╝██║ ╚████║██████╔╝██║ ██║ ██║ +╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ + ` + +func printBanner(w io.Writer) { + purple := color.RGB(109, 53, 255).Add(color.Bold) + fmt.Fprintln(w) + + for line := range strings.SplitSeq(bannerArt, "\n") { + purple.Fprintln(w, line) //nolint:gosec // Banner output errors are non-critical. + } + + fmt.Fprintf(w, "v%s\n\n", Version) //nolint:gosec // Banner output errors are non-critical. +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go new file mode 100644 index 00000000000..4cfdbd9fa13 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -0,0 +1,435 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const ( + defaultControlPlaneEndpoint = "http://localhost:5000" + defaultAccountName = "local" + defaultProjectName = "demo" +) + +type rleClient struct { + baseUrl string + httpClient *http.Client +} + +type environmentCreateRequest struct { + Id string `json:"id"` + Name string `json:"name"` + VersionLabel string `json:"versionLabel"` + Runtime runtimeSpec `json:"runtime"` + World worldSpec `json:"world"` + Compliance complianceSpec `json:"compliance"` +} + +type v1EnvironmentRequest struct { + Name string `json:"name,omitempty"` + AcrImagePath string `json:"acrImagePath"` +} + +type runtimeSpec struct { + Mode string `json:"mode"` + Image string `json:"image,omitempty"` +} + +type worldSpec struct { + Capability string `json:"capability"` +} + +type complianceSpec struct { + Boundary string `json:"boundary"` +} + +type environmentResource struct { + Id string `json:"id"` + ProjectId string `json:"projectId,omitempty"` + Name string `json:"name,omitempty"` + AcrImagePath string `json:"acrImagePath,omitempty"` + Version string `json:"version,omitempty"` + CreatedAtUtc string `json:"createdAtUtc,omitempty"` + UpdatedAtUtc string `json:"updatedAtUtc,omitempty"` + AccountName string `json:"accountName,omitempty"` + ProjectName string `json:"projectName,omitempty"` + VersionLabel string `json:"versionLabel,omitempty"` + Manifest environmentCreateRequest `json:"manifest,omitempty"` +} + +type listEnvironmentsResponse struct { + Value []environmentResource `json:"value"` +} + +type environmentVersion struct { + EnvironmentId string `json:"environmentId,omitempty"` + ProjectId string `json:"projectId,omitempty"` + Version string `json:"version,omitempty"` + AcrImagePath string `json:"acrImagePath,omitempty"` + CreatedAt string `json:"createdAtUtc,omitempty"` +} + +type sandboxCreateRequest struct { + Version string `json:"version,omitempty"` + Cpu string `json:"cpu,omitempty"` + Memory string `json:"memory,omitempty"` + Disk string `json:"disk,omitempty"` +} + +type sandboxResource struct { + Id string `json:"id"` + ProjectId string `json:"projectId,omitempty"` + EnvironmentId string `json:"environmentId,omitempty"` + Version string `json:"version,omitempty"` + DiskImageId string `json:"diskImageId,omitempty"` + AdcSandboxId string `json:"adcSandboxId,omitempty"` + Url string `json:"url,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"createdAtUtc,omitempty"` + UpdatedAt string `json:"updatedAtUtc,omitempty"` +} + +type listSandboxesResponse struct { + Value []sandboxResource `json:"value"` +} + +type environmentInstanceCreateRequest struct { + VersionLabel string `json:"versionLabel,omitempty"` + Provider string `json:"provider,omitempty"` + Environment map[string]string `json:"environmentVariables,omitempty"` +} + +type environmentInstanceResource struct { + Id string `json:"id"` + EnvironmentId string `json:"environmentId,omitempty"` + VersionLabel string `json:"versionLabel,omitempty"` + Status string `json:"status,omitempty"` + Provider string `json:"provider,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type rleHTTPError struct { + statusCode int + body string +} + +func (e *rleHTTPError) Error() string { + return fmt.Sprintf("RLE control plane returned HTTP %d: %s", e.statusCode, strings.TrimSpace(e.body)) +} + +// isNotFoundError reports whether err is an RLE control plane error with HTTP 404 status. +func isNotFoundError(err error) bool { + if httpErr, ok := errors.AsType[*rleHTTPError](err); ok { + return httpErr.statusCode == http.StatusNotFound + } + return false +} + +func newRleClient(endpoint string) *rleClient { + return &rleClient{ + baseUrl: strings.TrimRight(endpoint, "/"), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func resolveControlPlaneEndpoint(endpoint string) string { + if endpoint != "" { + return endpoint + } + if endpoint = os.Getenv("AZD_RLE_CONTROL_PLANE"); endpoint != "" { + return endpoint + } + if endpoint = os.Getenv("RLE_ENDPOINT"); endpoint != "" { + return endpoint + } + if endpoint = os.Getenv("RLE_CONTROL_PLANE"); endpoint != "" { + return endpoint + } + return defaultControlPlaneEndpoint +} + +func (c *rleClient) createOrUpdateEnvironment( + ctx context.Context, + account string, + project string, + environmentId string, + request environmentCreateRequest, +) (*environmentResource, error) { + path := fmt.Sprintf( + "/rle/v1.0/accounts/%s/projects/%s/environments/%s", + url.PathEscape(account), + url.PathEscape(project), + url.PathEscape(environmentId), + ) + + var result environmentResource + if err := c.do(ctx, http.MethodPut, path, request, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) createV1Environment( + ctx context.Context, + project string, + request v1EnvironmentRequest, +) (*environmentResource, error) { + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments", + url.PathEscape(project), + ) + + var result environmentResource + if err := c.do(ctx, http.MethodPost, path, request, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) updateV1Environment( + ctx context.Context, + project string, + environmentId string, + request v1EnvironmentRequest, +) (*environmentResource, error) { + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments/%s", + url.PathEscape(project), + url.PathEscape(environmentId), + ) + + var result environmentResource + if err := c.do(ctx, http.MethodPut, path, request, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) createEnvironmentInstance( + ctx context.Context, + account string, + project string, + environmentId string, + request environmentInstanceCreateRequest, +) (*environmentInstanceResource, error) { + path := fmt.Sprintf( + "/rle/v1.0/accounts/%s/projects/%s/environments/%s/instances", + url.PathEscape(account), + url.PathEscape(project), + url.PathEscape(environmentId), + ) + + var result environmentInstanceResource + if err := c.do(ctx, http.MethodPost, path, request, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) listEnvironments( + ctx context.Context, + account string, + project string, +) ([]environmentResource, error) { + _ = account + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments", + url.PathEscape(project), + ) + + var result listEnvironmentsResponse + if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { + return nil, err + } + + return result.Value, nil +} + +func (c *rleClient) getEnvironment( + ctx context.Context, + account string, + project string, + environmentId string, +) (*environmentResource, error) { + _ = account + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments/%s", + url.PathEscape(project), + url.PathEscape(environmentId), + ) + + var result environmentResource + if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) listEnvironmentVersions( + ctx context.Context, + account string, + project string, + environmentId string, +) ([]environmentVersion, error) { + _ = account + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments/%s/versions", + url.PathEscape(project), + url.PathEscape(environmentId), + ) + + var result []environmentVersion + if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { + return nil, err + } + + return result, nil +} + +func (c *rleClient) createSandbox( + ctx context.Context, + account string, + project string, + environmentId string, + request sandboxCreateRequest, +) (*sandboxResource, error) { + _ = account + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments/%s/sandboxes", + url.PathEscape(project), + url.PathEscape(environmentId), + ) + + var result sandboxResource + if err := c.do(ctx, http.MethodPost, path, request, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) listSandboxes( + ctx context.Context, + account string, + project string, + environmentId string, +) ([]sandboxResource, error) { + _ = account + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments/%s/sandboxes", + url.PathEscape(project), + url.PathEscape(environmentId), + ) + + var result listSandboxesResponse + if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { + return nil, err + } + + return result.Value, nil +} + +func (c *rleClient) getSandbox( + ctx context.Context, + account string, + project string, + environmentId string, + sandboxId string, +) (*sandboxResource, error) { + _ = account + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments/%s/sandboxes/%s", + url.PathEscape(project), + url.PathEscape(environmentId), + url.PathEscape(sandboxId), + ) + + var result sandboxResource + if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) do(ctx context.Context, method string, path string, body any, target any) error { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + reader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseUrl+path, reader) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("call RLE control plane %s: %w", c.baseUrl, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read RLE response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &rleHTTPError{statusCode: resp.StatusCode, body: string(respBody)} + } + + if target == nil || len(respBody) == 0 { + return nil + } + if err := json.Unmarshal(respBody, target); err != nil { + return fmt.Errorf("decode RLE response: %w", err) + } + + return nil +} + +func newEnvironmentCreateRequest(environmentId string, name string, image string, version string) environmentCreateRequest { + return environmentCreateRequest{ + Id: environmentId, + Name: name, + VersionLabel: version, + Runtime: runtimeSpec{ + Mode: "container", + Image: image, + }, + World: worldSpec{ + Capability: "eval", + }, + Compliance: complianceSpec{ + Boundary: "foundry", + }, + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go new file mode 100644 index 00000000000..289769336e7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type sharedFlags struct { + endpoint string + account string + project string +} + +type createFlags struct { + sharedFlags + recipe string + image string + version string +} + +func newCreateCommand() *cobra.Command { + flags := &createFlags{ + sharedFlags: sharedFlags{ + account: defaultAccountName, + project: defaultProjectName, + }, + recipe: defaultRecipeName, + version: "1.0.0", + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create or update an RLE environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + environmentId := slug(name) + image, err := resolveRecipeImage(flags.recipe, flags.image) + if err != nil { + return err + } + + client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) + environment, err := client.createOrUpdateEnvironment( + cmd.Context(), + flags.account, + flags.project, + environmentId, + newEnvironmentCreateRequest(environmentId, name, image, flags.version), + ) + if err != nil { + return serviceError(err) + } + + if isJsonOutput(cmd) { + return printJson(cmd, environment) + } + + return printEnvironmentCreated(cmd, environment, flags) + }, + } + + addSharedFlags(cmd, &flags.sharedFlags) + cmd.Flags().StringVar(&flags.recipe, "recipe", flags.recipe, "Recipe to use for the RLE environment") + cmd.Flags().StringVar(&flags.image, "image", "", "Container image for the RLE environment. Overrides --recipe") + cmd.Flags().StringVar(&flags.version, "version-label", flags.version, "Environment version label") + return cmd +} + +func serviceError(err error) error { + return &azdext.ServiceError{ + Message: err.Error(), + ServiceName: "rle-control-plane", + Suggestion: fmt.Sprintf( + "Ensure the RLE control plane is running and reachable, e.g. %s.", + defaultControlPlaneEndpoint, + ), + } +} + +func addSharedFlags(cmd *cobra.Command, flags *sharedFlags) { + cmd.Flags().StringVar( + &flags.endpoint, + "endpoint", + "", + fmt.Sprintf( + "RLE control plane endpoint. Defaults to AZD_RLE_CONTROL_PLANE, RLE_CONTROL_PLANE, or %s", + defaultControlPlaneEndpoint, + ), + ) + cmd.Flags().StringVar(&flags.account, "account", flags.account, "RLE account name") + cmd.Flags().StringVar(&flags.project, "project", flags.project, "RLE project name") +} + +func slug(name string) string { + var builder strings.Builder + lastDash := false + for _, r := range strings.ToLower(strings.TrimSpace(name)) { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + builder.WriteRune(r) + lastDash = false + continue + } + if !lastDash && builder.Len() > 0 { + builder.WriteRune('-') + lastDash = true + } + } + return strings.Trim(builder.String(), "-") +} + +func printEnvironmentCreated(cmd *cobra.Command, environment *environmentResource, flags *createFlags) error { + nextCommand := nextSandboxCreateCommand(environment.Id, flags.sharedFlags) + _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "Environment created: %s\nName: %s\nImage: %s\n\nNext:\n %s\n", + environment.Id, + environment.Name, + environment.Manifest.Runtime.Image, + nextCommand, + ) + return err +} + +func nextSandboxCreateCommand(environmentId string, flags sharedFlags) string { + command := fmt.Sprintf("azd ai rle sandbox create %s --wait", environmentId) + command = appendNonDefaultSharedFlags(command, flags) + return command +} + +func appendNonDefaultSharedFlags(command string, flags sharedFlags) string { + if flags.project != "" && flags.project != defaultProjectName { + command += fmt.Sprintf(" --project %s", flags.project) + } + if flags.account != "" && flags.account != defaultAccountName { + command += fmt.Sprintf(" --account %s", flags.account) + } + if flags.endpoint != "" { + command += fmt.Sprintf(" --endpoint %s", flags.endpoint) + } + return command +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies.go new file mode 100644 index 00000000000..e2b9bbb40d8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies.go @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + _ "embed" + "os" + "path/filepath" +) + +const ( + rleManagedDir = ".azd-rle" + rleDepsDir = "deps" + bundledRleSdkWheelName = "rle_sdk-0.1.3-py3-none-any.whl" + defaultLoomRecipeRepo = "https://msdata.visualstudio.com/DefaultCollection/Vienna/_git/loom" + defaultLoomRecipeRef = "code_rl_with_rle" +) + +//go:embed assets/rle_sdk-0.1.3-py3-none-any.whl +var bundledRleSdkWheel []byte + +func materializeBundledRleSdk(sessionDir string) (string, error) { + depsDir := filepath.Join(sessionDir, rleManagedDir, rleDepsDir) + if err := os.MkdirAll(depsDir, 0700); err != nil { + return "", err + } + + targetPath := filepath.Join(depsDir, bundledRleSdkWheelName) + existing, err := os.ReadFile(targetPath) + if err == nil && bytes.Equal(existing, bundledRleSdkWheel) { + return targetPath, nil + } + if err := os.WriteFile(targetPath, bundledRleSdkWheel, 0600); err != nil { + return "", err + } + return targetPath, nil +} + +func bundledRleSdkPath(sessionDir string) string { + return filepath.Join(sessionDir, rleManagedDir, rleDepsDir, bundledRleSdkWheelName) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies_test.go new file mode 100644 index 00000000000..f52d1b1c9cd --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies_test.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMaterializeBundledRleSdkWritesWheel(t *testing.T) { + sessionDir := t.TempDir() + + wheelPath, err := materializeBundledRleSdk(sessionDir) + if err != nil { + t.Fatal(err) + } + + expectedPath := filepath.Join(sessionDir, rleManagedDir, rleDepsDir, bundledRleSdkWheelName) + if wheelPath != expectedPath { + t.Fatalf("expected wheel path %q, got %q", expectedPath, wheelPath) + } + + data, err := os.ReadFile(wheelPath) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 { + t.Fatal("expected bundled wheel to be non-empty") + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go new file mode 100644 index 00000000000..e41dc00b9ae --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type rleDeployFlags struct { + project string + image string + registry string + skipBuild bool +} + +func newDeployCommand() *cobra.Command { + flags := &rleDeployFlags{} + + cmd := &cobra.Command{ + Use: "deploy", + Short: "Create or update the RLE environment", + RunE: func(cmd *cobra.Command, args []string) error { + // Load the persisted session state. When .azd-rle.json is absent we treat this as a + // first-time bootstrap and initialize the state in-place (it is persisted on success), + // so `deploy` can run without a prior `init` as long as the folder has an rle.yaml. + state, err := loadRleState() + initialized := err == nil + if err != nil { + if localErr, ok := errors.AsType[*azdext.LocalError](err); !ok || + localErr.Code != "rle_project_not_initialized" { + return err + } + } + + manifest, err := loadRleManifest(rleManifestFile) + manifestExists := err == nil + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + if !initialized && !manifestExists { + return &azdext.LocalError{ + Message: "RLE session has not been initialized.", + Code: "rle_project_not_initialized", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Run azd ai rle init first, or add an " + rleManifestFile + + " manifest to this folder, then re-run deploy.", + } + } + + if !initialized { + state = defaultRleState("", defaultRecipeName) + if _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "No %s found; initializing a new RLE session from %s.\n", + rleStateFile, + rleManifestFile, + ); err != nil { + return err + } + } + + if manifestExists { + manifestState, err := stateFromManifest(manifest) + if err != nil { + return err + } + state.Name = firstNonEmpty(manifestState.Name, state.Name) + state.Account = firstNonEmpty(manifestState.Account, state.Account) + state.Project = firstNonEmpty(manifestState.Project, state.Project) + state.Endpoint = firstNonEmpty(manifestState.Endpoint, state.Endpoint) + state.Image = firstNonEmpty(manifestState.Image, state.Image) + } + state.Project = firstNonEmpty(flags.project, state.Project) + + // Resolve the image to deploy. Priority: --image flag, then the manifest/state + // image, then a per-environment default derived from the environment name so each + // environment gets its own repository in the target registry. + image, err := resolveRecipeImage(state.Recipe, firstNonEmpty(flags.image, state.Image)) + if err != nil { + return err + } + if image == "" { + if state.Name == "" { + return &azdext.LocalError{ + Message: "Unable to determine the environment image.", + Code: "rle_image_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Pass --image or set the environment name so a default image can be derived.", + } + } + image = defaultRegistryLoginServer + "/" + slug(state.Name) + ":latest" + } + + // Host-qualify the image so the control plane / ADC can pull it: the disk-image + // conversion uses the image reference verbatim as the pull source, so a bare + // "name:tag" is rewritten to "/name:tag". + loginServer, repoTag := splitImageHost(image) + if loginServer == "" { + loginServer = normalizeRegistryLoginServer(flags.registry) + } + if loginServer == "" { + return &azdext.LocalError{ + Message: "No container registry was specified for the environment image.", + Code: "rle_registry_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Pass --registry (e.g. devrle) or use a host-qualified image reference.", + } + } + image = loginServer + "/" + repoTag + + environmentId := firstNonEmpty(state.EnvironmentId, slug(state.Name)) + client := newRleClient(resolveControlPlaneEndpoint("")) + request := v1EnvironmentRequest{ + Name: state.Name, + AcrImagePath: image, + } + + var environment *environmentResource + created := state.EnvironmentId == "" + action := "Creating" + if !created { + action = "Updating" + } + + // Build the local Dockerfile and push it to the registry before registering, so the + // environment always points at an image that actually exists in the target ACR. + registryName := registryShortName(loginServer) + switch { + case flags.skipBuild: + if _, err := fmt.Fprintf(cmd.OutOrStdout(), + "Skipping build (--skip-build); using image '%s'.\n", image); err != nil { + return err + } + case !fileExists("Dockerfile"): + if _, err := fmt.Fprintf(cmd.OutOrStdout(), + "No Dockerfile in current directory; skipping build and using image '%s'.\n", image); err != nil { + return err + } + default: + if _, err := fmt.Fprintf(cmd.OutOrStdout(), + "Building and pushing '%s' to registry '%s' (az acr build) ...\n", + repoTag, registryName); err != nil { + return err + } + build := exec.CommandContext(cmd.Context(), + "az", "acr", "build", "--registry", registryName, "--image", repoTag, ".") + build.Stdout = cmd.OutOrStdout() + build.Stderr = cmd.ErrOrStderr() + if err := build.Run(); err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf( + "Failed to build and push image '%s' to registry '%s': %v", + repoTag, registryName, err), + Code: "rle_acr_build_failed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Ensure 'az login' is done, you have push access to the registry, " + + "and a valid Dockerfile is present. Use --skip-build to register a prebuilt image.", + } + } + } + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s environment '%s' (image=%s) ...\n", action, state.Name, image); err != nil { + return err + } + if state.EnvironmentId == "" { + environment, err = client.createV1Environment(cmd.Context(), state.Project, request) + } else { + environment, err = client.updateV1Environment(cmd.Context(), state.Project, environmentId, request) + if isNotFoundError(err) { + // The recorded environment no longer exists in the target project + // (e.g. the project changed or the control plane was reset). Recreate it. + if _, msgErr := fmt.Fprintf( + cmd.OutOrStdout(), + "Environment '%s' not found in project '%s'; creating a new one.\n", + environmentId, + state.Project, + ); msgErr != nil { + return msgErr + } + created = true + environment, err = client.createV1Environment(cmd.Context(), state.Project, request) + } + } + if err != nil { + return serviceError(err) + } + state.EnvironmentId = environment.Id + state.EnvironmentVersion = firstNonEmpty(environment.Version, environment.VersionLabel, environment.Manifest.VersionLabel) + state.InstanceId = "" + state.InstanceEndpoint = "" + if err := saveRleState(state); err != nil { + return err + } + + label := "Created" + if !created { + label = "Updated" + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "\n%s environment '%s' (%s).\n", label, state.Name, state.EnvironmentId); err != nil { + return err + } + body, err := json.MarshalIndent(environmentOutput{ + Id: environment.Id, + ProjectId: firstNonEmpty(environment.ProjectId, state.Project), + Name: firstNonEmpty(environment.Name, state.Name), + AcrImagePath: firstNonEmpty(environment.AcrImagePath, image), + Version: state.EnvironmentVersion, + CreatedAtUtc: environment.CreatedAtUtc, + UpdatedAtUtc: environment.UpdatedAtUtc, + }, "", " ") + if err != nil { + return err + } + if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(body)); err != nil { + return err + } + return nil + }, + } + + cmd.Flags().StringVar(&flags.project, "project", "", + "RLE project name. Defaults to the project saved in .azd-rle.json.") + cmd.Flags().StringVar(&flags.image, "image", "", + "Image reference to deploy (overrides the per-environment default derived from the environment name)") + cmd.Flags().StringVar(&flags.registry, "registry", "devrle", + "Container registry (short name or login server) to build and push the environment image into") + cmd.Flags().BoolVar(&flags.skipBuild, "skip-build", false, + "Skip building/pushing the image and register the existing image reference as-is") + return cmd +} + +// splitImageHost splits a container image reference into its registry host (login server) +// and the remaining repository:tag. The leading segment is treated as a host only when it +// looks like one (contains '.' or ':' or is "localhost"); otherwise there is no host. +func splitImageHost(image string) (host string, repoTag string) { + image = strings.TrimSpace(image) + if slash := strings.IndexByte(image, '/'); slash > 0 { + first := image[:slash] + if first == "localhost" || strings.ContainsAny(first, ".:") { + return first, image[slash+1:] + } + } + return "", image +} + +// normalizeRegistryLoginServer turns a registry short name ("devrle") into a login server +// ("devrle.azurecr.io"). A value that already contains a '.' is assumed to be a login server. +func normalizeRegistryLoginServer(registry string) string { + registry = strings.TrimSpace(registry) + if registry == "" { + return "" + } + if strings.Contains(registry, ".") { + return registry + } + return registry + ".azurecr.io" +} + +// registryShortName returns the ACR short name (the part before ".azurecr.io") for use with +// "az acr build --registry". +func registryShortName(loginServer string) string { + if host, _, ok := strings.Cut(loginServer, "."); ok { + return host + } + return loginServer +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +type environmentOutput struct { + Id string `json:"id"` + ProjectId string `json:"projectId"` + Name string `json:"name"` + AcrImagePath string `json:"acrImagePath"` + Version string `json:"version"` + CreatedAtUtc string `json:"createdAtUtc"` + UpdatedAtUtc string `json:"updatedAtUtc"` +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go new file mode 100644 index 00000000000..2f467872ac6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type rleInitFlags struct { + path string + image string + force bool +} + +func newInitCommand() *cobra.Command { + flags := &rleInitFlags{} + + cmd := &cobra.Command{ + Use: "init ", + Short: "Scaffold a local RLE environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + envName, err := validateEnvName(args[0]) + if err != nil { + return &azdext.LocalError{ + Message: err.Error(), + Code: "rle_invalid_environment_name", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use snake_case starting with a letter, for example code_rl.", + } + } + + sessionDir, err := scaffoldRleSession(envName, flags.path, flags.image, flags.force) + if err != nil { + return err + } + + state := defaultRleState(envName, defaultRecipeName) + if err := saveRleStateIn(sessionDir, state); err != nil { + return err + } + if _, err := materializeBundledRleSdk(sessionDir); err != nil { + return err + } + + displayDir, err := filepath.Abs(sessionDir) + if err != nil { + return err + } + _, err = fmt.Fprintf( + cmd.OutOrStdout(), + "Created OpenEnv-style environment at: %s\nNext steps:\n cd %s\n azd ai rle deploy\n", + displayDir, + displayDir, + ) + return err + }, + } + + cmd.Flags().StringVar(&flags.path, "path", ".", "Directory where the RLE session folder is created") + cmd.Flags().StringVar(&flags.image, "image", "", + "Image reference written to rle.yaml (defaults to one derived from the environment name at deploy time)") + cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite local RLE state if it already exists") + return cmd +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go new file mode 100644 index 00000000000..560be3e45f8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type rleInvokeFlags struct { + recipe string + projectEndpoint string + numTasks int + modelName string + rendererName string + maxTokens int + loraRank int + groupSize int + groupsPerBatch int + maxSteps int + lossFn string + seed int + evalEvery int + saveEvery int + removeConstantRewardGroups bool +} + +func newInvokeCommand() *cobra.Command { + flags := &rleInvokeFlags{ + recipe: defaultRecipeName, + numTasks: 4, + modelName: "Qwen/Qwen3-32B", + rendererName: "qwen3_disable_thinking", + maxTokens: 1200, + loraRank: 32, + groupSize: 4, + groupsPerBatch: 1, + maxSteps: 1, + lossFn: "importance_sampling", + seed: 42, + evalEvery: 999999, + saveEvery: 999999, + removeConstantRewardGroups: true, + } + + cmd := &cobra.Command{ + Use: "invoke", + Short: "Run the Loom RLE training recipe", + RunE: func(cmd *cobra.Command, args []string) error { + state, err := loadRleState() + if err != nil { + return err + } + if state.EnvironmentId == "" { + return &azdext.LocalError{ + Message: "RLE environment has not been deployed.", + Code: "rle_environment_not_deployed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Run azd ai rle deploy from this session folder before invoking training.", + } + } + + if flags.projectEndpoint == "" { + return &azdext.LocalError{ + Message: "Azure AI project endpoint is required.", + Code: "rle_project_endpoint_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Pass --project-endpoint with the Azure AI Foundry project endpoint.", + } + } + recipeName, err := validateRecipeName(flags.recipe) + if err != nil { + return &azdext.LocalError{ + Message: err.Error(), + Code: "rle_invalid_recipe_name", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use a Loom recipe folder name in snake_case, for example code_rl_with_rle.", + } + } + + if _, err := exec.LookPath("git"); err != nil { + return &azdext.LocalError{ + Message: "Could not find \"git\" on PATH.", + Code: "rle_git_not_found", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Install Git so azd can fetch the managed Loom recipe.", + } + } + rleSdkPackage, err := resolveRleSdkPackage() + if err != nil { + return err + } + loomPath, err := resolveInvokeLoomPath(cmd, recipeName, rleSdkPackage) + if err != nil { + return err + } + + controlPlane := resolveControlPlaneEndpoint("") + invokeArgs := buildLoomInvokeArgs(state, state.Project, controlPlane, flags) + commandArgs := append([]string{"run", "--extra", "code_rl", "python", "-m", loomRecipeModule(recipeName)}, invokeArgs...) + + if _, err := exec.LookPath("uv"); err != nil { + return &azdext.LocalError{ + Message: "Could not find \"uv\" on PATH.", + Code: "rle_uv_not_found", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Install uv and try again.", + } + } + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Running Loom RLE training for environment %s\n", state.EnvironmentId); err != nil { + return err + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Working directory: %s\n", loomPath); err != nil { + return err + } + + process := exec.CommandContext(cmd.Context(), "uv", commandArgs...) //nolint:gosec // Arguments are user-selected CLI parameters. + process.Dir = loomPath + process.Stdout = cmd.OutOrStdout() + process.Stderr = cmd.ErrOrStderr() + process.Stdin = os.Stdin + process.Env = os.Environ() + if err := process.Run(); err != nil { + return fmt.Errorf("run Loom RLE training: %w", err) + } + return nil + }, + } + + cmd.Flags().StringVar(&flags.recipe, "recipe", flags.recipe, "Loom cookbook recipe to fetch and run.") + cmd.Flags().StringVar(&flags.projectEndpoint, "project-endpoint", flags.projectEndpoint, "Azure AI Foundry project endpoint.") + cmd.Flags().IntVar(&flags.numTasks, "num-tasks", flags.numTasks, "Number of RLE tasks/seeds to train over.") + cmd.Flags().StringVar(&flags.modelName, "model-name", flags.modelName, "Training model name.") + return cmd +} + +func buildLoomInvokeArgs(state rleState, project string, controlPlane string, flags *rleInvokeFlags) []string { + args := []string{ + "project_endpoint=" + flags.projectEndpoint, + "env_id=" + state.EnvironmentId, + "project=" + project, + "control_plane=" + controlPlane, + "num_tasks=" + strconv.Itoa(flags.numTasks), + "model_name=" + flags.modelName, + "renderer_name=" + flags.rendererName, + "max_tokens=" + strconv.Itoa(flags.maxTokens), + "lora_rank=" + strconv.Itoa(flags.loraRank), + "group_size=" + strconv.Itoa(flags.groupSize), + "groups_per_batch=" + strconv.Itoa(flags.groupsPerBatch), + "loss_fn=" + flags.lossFn, + "seed=" + strconv.Itoa(flags.seed), + "eval_every=" + strconv.Itoa(flags.evalEvery), + "save_every=" + strconv.Itoa(flags.saveEvery), + "remove_constant_reward_groups=" + strconv.FormatBool(flags.removeConstantRewardGroups), + } + if flags.maxSteps > 0 { + args = append(args, "max_steps="+strconv.Itoa(flags.maxSteps)) + } + return args +} + +func resolveInvokeLoomPath(cmd *cobra.Command, recipeName string, rleSdkPackage string) (string, error) { + return ensureManagedLoomRecipe(cmd, ".", defaultLoomRecipeRepo, defaultLoomRecipeRef, recipeName, rleSdkPackage) +} + +func resolveLoomPath(path string) (string, error) { + if path == "" { + return "", &azdext.LocalError{ + Message: "Loom cookbook path is required.", + Code: "rle_loom_path_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Run azd ai rle invoke again so azd can fetch the managed Loom recipe.", + } + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Loom cookbook path %q could not be read.", abs), + Code: "rle_loom_path_missing", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Delete the managed checkout, then run azd ai rle invoke again.", + } + } + if !info.IsDir() { + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Loom cookbook path %q is not a directory.", abs), + Code: "rle_loom_path_not_directory", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Delete the managed checkout, then run azd ai rle invoke again.", + } + } + return abs, nil +} + +func resolveRleSdkPackage() (string, error) { + if _, err := os.Stat(bundledRleSdkPath(".")); err != nil { + return materializeBundledRleSdk(".") + } + return filepath.Abs(bundledRleSdkPath(".")) +} + +func ensureManagedLoomRecipe(cmd *cobra.Command, sessionDir string, repo string, ref string, recipeName string, rleSdkPackage string) (string, error) { + repoDir := managedLoomRepoPath(sessionDir) + if _, err := os.Stat(filepath.Join(repoDir, ".git")); err == nil { + return prepareManagedLoomCookbook(sessionDir, recipeName, rleSdkPackage) + } + + if _, err := os.Stat(repoDir); err == nil { + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Managed Loom recipe path %q exists but is not a Git checkout.", repoDir), + Code: "rle_recipe_path_invalid", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Delete the path, then run azd ai rle init again.", + } + } + + if err := os.MkdirAll(filepath.Dir(repoDir), 0700); err != nil { + return "", err + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Fetching Loom recipe %s from %s\n", ref, repo); err != nil { + return "", err + } + if err := runGit(cmd, "", "clone", "--depth", "1", "--no-tags", "--branch", ref, "--single-branch", repo, repoDir); err != nil { + return "", err + } + return prepareManagedLoomCookbook(sessionDir, recipeName, rleSdkPackage) +} + +func runGit(cmd *cobra.Command, dir string, args ...string) error { + process := exec.CommandContext(cmd.Context(), "git", args...) //nolint:gosec // Arguments are fixed command shapes or user-selected repo/ref. + process.Dir = dir + process.Stdout = cmd.OutOrStdout() + process.Stderr = cmd.ErrOrStderr() + process.Stdin = os.Stdin + process.Env = os.Environ() + if err := process.Run(); err != nil { + return fmt.Errorf("run git %s: %w", joinCommandArgs(args), err) + } + return nil +} + +func managedLoomRepoPath(sessionDir string) string { + return filepath.Join(sessionDir, rleManagedDir, "recipes", "loom") +} + +func managedLoomCookbookPath(sessionDir string) string { + return filepath.Join(managedLoomRepoPath(sessionDir), "loom-cookbook") +} + +func prepareManagedLoomCookbook(sessionDir string, recipeName string, rleSdkPackage string) (string, error) { + cookbookPath, err := resolveLoomPath(managedLoomCookbookPath(sessionDir)) + if err != nil { + return "", err + } + if err := ensureLoomRecipeExists(cookbookPath, recipeName); err != nil { + return "", err + } + return cookbookPath, ensureLoomLocalSources(cookbookPath, rleSdkPackage) +} + +func ensureLoomRecipeExists(cookbookPath string, recipeName string) error { + recipeEntrypoint := filepath.Join(cookbookPath, "loom_cookbook", "recipes", recipeName, "train_azure.py") + if _, err := os.Stat(recipeEntrypoint); err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf("Loom recipe %q does not have a train_azure.py entrypoint.", recipeName), + Code: "rle_loom_recipe_missing", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Choose a Loom cookbook recipe that supports Azure/RLE training.", + } + } + return nil +} + +func loomRecipeModule(recipeName string) string { + return fmt.Sprintf("loom_cookbook.recipes.%s.train_azure", recipeName) +} + +func ensureLoomLocalSources(cookbookPath string, rleSdkPackage string) error { + pyprojectPath := filepath.Join(cookbookPath, "pyproject.toml") + data, err := os.ReadFile(pyprojectPath) + if err != nil { + return err + } + contents := string(data) + lines := []string{} + if !strings.Contains(contents, "[tool.uv.sources]") { + lines = append(lines, "[tool.uv.sources]") + } + if !strings.Contains(contents, "azure-ai-finetuning-sessions = { path = \"../azure-ai-finetuning-sessions\" }") { + lines = append(lines, "azure-ai-finetuning-sessions = { path = \"../azure-ai-finetuning-sessions\" }") + } + if !strings.Contains(contents, "rle-sdk = { path = ") { + relativeRleSdkPackage, err := filepath.Rel(cookbookPath, rleSdkPackage) + if err != nil { + return err + } + lines = append(lines, fmt.Sprintf("rle-sdk = { path = %q }", filepath.ToSlash(relativeRleSdkPackage))) + } + if len(lines) == 0 { + return nil + } + + contents = strings.TrimRight(contents, "\r\n") + "\n\n" + strings.Join(lines, "\n") + "\n" + return os.WriteFile(pyprojectPath, []byte(contents), 0600) +} + +func joinCommandArgs(args []string) string { + result := "" + for i, arg := range args { + if i > 0 { + result += " " + } + result += strconv.Quote(arg) + } + return result +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go new file mode 100644 index 00000000000..f1f1193f441 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestBuildLoomInvokeArgsUsesStateAndFlags(t *testing.T) { + state := rleState{ + EnvironmentId: "env-123", + } + flags := &rleInvokeFlags{ + projectEndpoint: "https://example.services.ai.azure.com/api/projects/p", + numTasks: 4, + modelName: "Qwen/Qwen3-32B", + rendererName: "qwen3_disable_thinking", + maxTokens: 1200, + loraRank: 32, + groupSize: 4, + groupsPerBatch: 1, + maxSteps: 1, + lossFn: "importance_sampling", + seed: 42, + evalEvery: 999999, + saveEvery: 999999, + removeConstantRewardGroups: true, + } + + args := buildLoomInvokeArgs(state, "demo-3", "https://rle.example", flags) + + expected := []string{ + "project_endpoint=https://example.services.ai.azure.com/api/projects/p", + "env_id=env-123", + "project=demo-3", + "control_plane=https://rle.example", + "num_tasks=4", + "model_name=Qwen/Qwen3-32B", + "renderer_name=qwen3_disable_thinking", + "max_tokens=1200", + "lora_rank=32", + "group_size=4", + "groups_per_batch=1", + "max_steps=1", + "loss_fn=importance_sampling", + "seed=42", + "eval_every=999999", + "save_every=999999", + "remove_constant_reward_groups=true", + } + for _, arg := range expected { + if !slices.Contains(args, arg) { + t.Fatalf("expected args to contain %q, got %#v", arg, args) + } + } +} + +func TestBuildLoomInvokeArgsOmitsMaxStepsWhenZero(t *testing.T) { + state := rleState{ + EnvironmentId: "env-123", + } + flags := &rleInvokeFlags{ + projectEndpoint: "https://example.services.ai.azure.com/api/projects/p", + maxSteps: 0, + } + + args := buildLoomInvokeArgs(state, "demo-3", "http://localhost:5000", flags) + + if slices.Contains(args, "max_steps=0") { + t.Fatalf("expected max_steps to be omitted, got %#v", args) + } +} + +func TestLoomRecipeModuleUsesRecipeName(t *testing.T) { + module := loomRecipeModule("code_rl_with_rle") + expected := "loom_cookbook.recipes.code_rl_with_rle.train_azure" + if module != expected { + t.Fatalf("expected %q, got %q", expected, module) + } +} + +func TestEnsureLoomRecipeExistsRequiresTrainAzureEntrypoint(t *testing.T) { + root := t.TempDir() + recipeDir := filepath.Join(root, "loom_cookbook", "recipes", "code_rl_with_rle") + if err := os.MkdirAll(recipeDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(recipeDir, "train_azure.py"), []byte(""), 0600); err != nil { + t.Fatal(err) + } + + if err := ensureLoomRecipeExists(root, "code_rl_with_rle"); err != nil { + t.Fatal(err) + } + if err := ensureLoomRecipeExists(root, "missing_recipe"); err == nil { + t.Fatal("expected missing recipe to fail") + } +} + +func TestEnsureLoomLocalSourcesAddsLocalSources(t *testing.T) { + root := t.TempDir() + cookbookPath := filepath.Join(root, ".azd-rle", "recipes", "loom", "loom-cookbook") + if err := os.MkdirAll(cookbookPath, 0700); err != nil { + t.Fatal(err) + } + pyprojectPath := filepath.Join(cookbookPath, "pyproject.toml") + if err := os.WriteFile(pyprojectPath, []byte("[project]\nname = \"loom-cookbook\"\n"), 0600); err != nil { + t.Fatal(err) + } + rleSdkPackage := filepath.Join(root, ".azd-rle", "deps", "rle_sdk-0.1.3-py3-none-any.whl") + + if err := ensureLoomLocalSources(cookbookPath, rleSdkPackage); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(pyprojectPath) + if err != nil { + t.Fatal(err) + } + contents := string(data) + expectedSources := []string{ + "[tool.uv.sources]", + "azure-ai-finetuning-sessions = { path = \"../azure-ai-finetuning-sessions\" }", + "rle-sdk = { path = \"../../../deps/rle_sdk-0.1.3-py3-none-any.whl\" }", + } + for _, expected := range expectedSources { + if !strings.Contains(contents, expected) { + t.Fatalf("expected pyproject to contain %q, got %s", expected, contents) + } + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go new file mode 100644 index 00000000000..5c02ecc26f5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" +) + +func newListCommand() *cobra.Command { + flags := &sharedFlags{ + account: defaultAccountName, + project: defaultProjectName, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List RLE environments", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) + environments, err := client.listEnvironments(cmd.Context(), flags.account, flags.project) + if err != nil { + return serviceError(err) + } + + encoded, err := json.MarshalIndent(environments, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) + return err + }, + } + + addSharedFlags(cmd, flags) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go new file mode 100644 index 00000000000..31da4b7c03c --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "os" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "gopkg.in/yaml.v3" +) + +type rleManifest struct { + Name string `yaml:"name"` + Account string `yaml:"account"` + Project string `yaml:"project"` + Endpoint string `yaml:"endpoint"` + Image string `yaml:"image"` + Environment rleManifestEnvironment `yaml:"environment"` +} + +type rleManifestEnvironment struct { + Image string `yaml:"image"` +} + +func loadRleManifest(path string) (rleManifest, error) { + data, err := os.ReadFile(path) //nolint:gosec // Manifest path is provided by the user. + if err != nil { + return rleManifest{}, err + } + + expanded, err := expandManifestEnv(string(data)) + if err != nil { + return rleManifest{}, err + } + + var manifest rleManifest + if err := yaml.Unmarshal([]byte(expanded), &manifest); err != nil { + return rleManifest{}, err + } + + return manifest, nil +} + +func expandManifestEnv(content string) (string, error) { + missing := map[string]struct{}{} + expanded := os.Expand(content, func(name string) string { + value, ok := os.LookupEnv(name) + if !ok { + missing[name] = struct{}{} + } + return value + }) + + if len(missing) == 0 { + return expanded, nil + } + + names := make([]string, 0, len(missing)) + for name := range missing { + names = append(names, name) + } + slices.Sort(names) + + return "", &azdext.LocalError{ + Message: fmt.Sprintf("RLE manifest references unset environment variable(s): %s.", strings.Join(names, ", ")), + Code: "rle_manifest_env_missing", + Category: azdext.LocalErrorCategoryUser, + Suggestion: fmt.Sprintf( + "Set %s, then run the command again.", + strings.Join(names, ", "), + ), + } +} + +func stateFromManifest(manifest rleManifest) (rleState, error) { + state := defaultRleState(firstNonEmpty(manifest.Name, "code_rl"), defaultRecipeName) + state.Account = firstNonEmpty(manifest.Account, defaultAccountName) + state.Project = firstNonEmpty(manifest.Project, defaultProjectName) + state.Endpoint = manifest.Endpoint + + image, err := resolveRecipeImage(state.Recipe, firstNonEmpty(manifest.Image, manifest.Environment.Image)) + if err != nil { + return rleState{}, err + } + state.Image = image + + return state, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest_test.go new file mode 100644 index 00000000000..9cbea4fabf8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest_test.go @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" +) + +func TestExpandManifestEnvUsesEnvironmentVariables(t *testing.T) { + t.Setenv("RLE_PROJECT_NAME", "demo") + t.Setenv("RLE_ACR_IMAGE", "example.azurecr.io/code:latest") + + expanded, err := expandManifestEnv("project: ${RLE_PROJECT_NAME}\nimage: ${RLE_ACR_IMAGE}\n") + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(expanded, "project: demo") { + t.Fatalf("expected project env var to be expanded, got %q", expanded) + } + if !strings.Contains(expanded, "image: example.azurecr.io/code:latest") { + t.Fatalf("expected image env var to be expanded, got %q", expanded) + } +} + +func TestExpandManifestEnvRejectsMissingEnvironmentVariables(t *testing.T) { + if _, err := expandManifestEnv("project: ${RLE_MISSING_PROJECT}\n"); err == nil { + t.Fatal("expected missing environment variable to fail") + } +} + +func TestStateFromManifestUsesResolvedImage(t *testing.T) { + state, err := stateFromManifest(rleManifest{ + Name: "code_rl", + Project: "demo", + Endpoint: "http://localhost:5000", + Image: "example.azurecr.io/code:latest", + }) + if err != nil { + t.Fatal(err) + } + + if state.Project != "demo" { + t.Fatalf("expected project demo, got %q", state.Project) + } + if state.Endpoint != "http://localhost:5000" { + t.Fatalf("expected endpoint, got %q", state.Endpoint) + } + if state.Name != "code_rl" { + t.Fatalf("expected default environment name, got %q", state.Name) + } + if state.Image != "example.azurecr.io/code:latest" { + t.Fatalf("expected resolved image, got %q", state.Image) + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/metadata.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/metadata.go new file mode 100644 index 00000000000..2c38b67413d --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/metadata.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newMetadataCommand(rootCmd *cobra.Command) *cobra.Command { + return azdext.NewMetadataCommand("1.0", "azure.ai.rle", func() *cobra.Command { + return rootCmd + }) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go new file mode 100644 index 00000000000..b38763c0032 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const defaultRecipeName = "code_rl_with_rle" + +// defaultRegistryLoginServer hosts derived per-environment images when no explicit +// image is provided. +const defaultRegistryLoginServer = "devrle.azurecr.io" + +// recipeImages maps a recipe name to a default image. An empty value means the image +// repository is derived from the environment name at deploy time. +var recipeImages = map[string]string{ + defaultRecipeName: "", +} + +func resolveRecipeImage(recipe string, imageOverride string) (string, error) { + if imageOverride != "" { + return imageOverride, nil + } + + image, ok := recipeImages[recipe] + if !ok { + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Unknown RLE recipe %q.", recipe), + Code: "rle_unknown_recipe", + Category: azdext.LocalErrorCategoryUser, + Suggestion: fmt.Sprintf("Use recipe %s or provide an image in rle.yaml.", defaultRecipeName), + } + } + + return image, nil +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go new file mode 100644 index 00000000000..3925136f1c4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "testing" + +func TestResolveRecipeImageDerivesFromEnvName(t *testing.T) { + image, err := resolveRecipeImage(defaultRecipeName, "") + if err != nil { + t.Fatal(err) + } + if image != "" { + t.Fatalf("expected empty image so it is derived from the environment name, got %q", image) + } +} + +func TestResolveRecipeImageAllowsOverride(t *testing.T) { + image, err := resolveRecipeImage("unknown", "example.azurecr.io/custom:latest") + if err != nil { + t.Fatal(err) + } + if image != "example.azurecr.io/custom:latest" { + t.Fatalf("expected override image, got %q", image) + } +} + +func TestResolveRecipeImageRejectsUnknownRecipe(t *testing.T) { + if _, err := resolveRecipeImage("unknown", ""); err == nil { + t.Fatal("expected unknown recipe to fail") + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go new file mode 100644 index 00000000000..6e6dd231297 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "rle", + Use: "rle [options]", + Short: fmt.Sprintf("Manage RLE resources from your terminal. %s", color.YellowString("(Preview)")), + }) + + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + + defaultHelp := rootCmd.HelpFunc() + rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if cmd == rootCmd { + printBanner(cmd.OutOrStdout()) + } + defaultHelp(cmd, args) + }) + + rootCmd.AddCommand(newDeployCommand()) + rootCmd.AddCommand(newInitCommand()) + rootCmd.AddCommand(newInvokeCommand()) + rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) + rootCmd.AddCommand(newMetadataCommand(rootCmd)) + + return rootCmd +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go new file mode 100644 index 00000000000..5678499bd79 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "testing" + +func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { + rootCmd := NewRootCommand() + + for _, commandName := range []string{"deploy", "init", "invoke", "version", "metadata"} { + if command, _, err := rootCmd.Find([]string{commandName}); err != nil || command.Name() != commandName { + t.Fatalf("expected command %q to be registered", commandName) + } + } +} + +func TestNewRootCommandHidesOldLowLevelCommands(t *testing.T) { + rootCmd := NewRootCommand() + + for _, commandName := range []string{"create", "list", "provision", "sandbox", "show", "versions"} { + if command, _, err := rootCmd.Find([]string{commandName}); err == nil && command.Name() == commandName { + t.Fatalf("expected command %q to be hidden from the root surface", commandName) + } + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go new file mode 100644 index 00000000000..e1b448e75b8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type sandboxFlags struct { + sharedFlags + version string + cpu string + memory string + disk string + wait bool + timeout time.Duration + interval time.Duration +} + +func newSandboxCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "sandbox ", + Short: "Manage RLE environment sandboxes", + } + + cmd.AddCommand(newSandboxCreateCommand()) + cmd.AddCommand(newSandboxListCommand()) + cmd.AddCommand(newSandboxShowCommand()) + return cmd +} + +func newSandboxCreateCommand() *cobra.Command { + flags := &sandboxFlags{ + sharedFlags: sharedFlags{ + account: defaultAccountName, + project: defaultProjectName, + }, + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create an RLE sandbox", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) + request := sandboxCreateRequest{ + Version: flags.version, + Cpu: flags.cpu, + Memory: flags.memory, + Disk: flags.disk, + } + + sandbox, err := createSandbox(cmd, client, flags, args[0], request) + if err != nil { + return sandboxCreateError(err) + } + + if !isJsonOutput(cmd) { + return printSandboxCreated(cmd, sandbox) + } + + return printJson(cmd, sandbox) + }, + } + + addSharedFlags(cmd, &flags.sharedFlags) + cmd.Flags().StringVar(&flags.version, "version-label", "", "Environment version to sandbox. Defaults to latest") + cmd.Flags().StringVar(&flags.cpu, "cpu", "", "Sandbox CPU request. Defaults to server configuration") + cmd.Flags().StringVar(&flags.memory, "memory", "", "Sandbox memory request. Defaults to server configuration") + cmd.Flags().StringVar(&flags.disk, "disk", "", "Sandbox disk request. Defaults to server configuration") + cmd.Flags().BoolVar(&flags.wait, "wait", false, "Wait until disk image conversion is ready and sandbox creation succeeds") + cmd.Flags().DurationVar(&flags.timeout, "wait-timeout", 30*time.Minute, "Maximum time to wait for sandbox creation") + cmd.Flags().DurationVar(&flags.interval, "wait-interval", 30*time.Second, "Time to wait between sandbox creation attempts") + return cmd +} + +func newSandboxListCommand() *cobra.Command { + flags := &sharedFlags{ + account: defaultAccountName, + project: defaultProjectName, + } + + cmd := &cobra.Command{ + Use: "list ", + Short: "List RLE sandboxes for an environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) + sandboxes, err := client.listSandboxes(cmd.Context(), flags.account, flags.project, args[0]) + if err != nil { + return serviceError(err) + } + + return printJson(cmd, sandboxes) + }, + } + + addSharedFlags(cmd, flags) + return cmd +} + +func newSandboxShowCommand() *cobra.Command { + flags := &sharedFlags{ + account: defaultAccountName, + project: defaultProjectName, + } + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show an RLE sandbox", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) + sandbox, err := client.getSandbox(cmd.Context(), flags.account, flags.project, args[0], args[1]) + if err != nil { + return serviceError(err) + } + + return printJson(cmd, sandbox) + }, + } + + addSharedFlags(cmd, flags) + return cmd +} + +func createSandbox( + cmd *cobra.Command, + client *rleClient, + flags *sandboxFlags, + environmentId string, + request sandboxCreateRequest, +) (*sandboxResource, error) { + if !flags.wait { + return client.createSandbox(cmd.Context(), flags.account, flags.project, environmentId, request) + } + if flags.timeout <= 0 { + return nil, waitFlagError("wait-timeout") + } + if flags.interval <= 0 { + return nil, waitFlagError("wait-interval") + } + + return createSandboxWithWait( + cmd.Context(), + client, + flags.account, + flags.project, + environmentId, + request, + flags.timeout, + flags.interval, + func(message string, interval time.Duration) { + fmt.Fprintf(cmd.ErrOrStderr(), "%s Retrying in %s.\n", message, interval) + }, + ) +} + +func waitFlagError(flagName string) error { + return &azdext.LocalError{ + Message: fmt.Sprintf("--%s must be greater than 0.", flagName), + Code: "rle_invalid_wait_option", + Category: azdext.LocalErrorCategoryUser, + } +} + +func sandboxCreateError(err error) error { + var localErr *azdext.LocalError + if errors.As(err, &localErr) { + return err + } + + var httpErr *rleHTTPError + if errors.As(err, &httpErr) && httpErr.statusCode == 409 { + message := extractRleErrorMessage(httpErr.body) + return &azdext.LocalError{ + Message: message, + Code: "rle_sandbox_not_ready", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "The server creates the disk image asynchronously after environment creation. Retry sandbox create after conversion is ready; if the status is Failed, recreate/update the environment with a valid ACR image.", + } + } + + return serviceError(err) +} + +func retryableSandboxCreateError(err error) (string, bool) { + var httpErr *rleHTTPError + if !errors.As(err, &httpErr) || httpErr.statusCode != 409 { + return "", false + } + + message := extractRleErrorMessage(httpErr.body) + return message, strings.Contains(message, "conversion status: Pending") || + strings.Contains(message, "conversion status: NotRequested") +} + +func createSandboxWithWait( + ctx context.Context, + client *rleClient, + account string, + project string, + environmentId string, + request sandboxCreateRequest, + timeout time.Duration, + interval time.Duration, + onRetry func(message string, interval time.Duration), +) (*sandboxResource, error) { + if timeout <= 0 { + return nil, waitFlagError("wait-timeout") + } + if interval <= 0 { + return nil, waitFlagError("wait-interval") + } + + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var lastMessage string + for { + sandbox, err := client.createSandbox(waitCtx, account, project, environmentId, request) + if err == nil { + return sandbox, nil + } + if errors.Is(err, context.DeadlineExceeded) { + return nil, sandboxCreateWaitTimeout(timeout, lastMessage) + } + + message, retry := retryableSandboxCreateError(err) + if !retry { + return nil, err + } + lastMessage = message + if onRetry != nil { + onRetry(message, interval) + } + + timer := time.NewTimer(interval) + select { + case <-waitCtx.Done(): + timer.Stop() + return nil, sandboxCreateWaitTimeout(timeout, lastMessage) + case <-timer.C: + } + } +} + +func sandboxCreateWaitTimeout(timeout time.Duration, lastMessage string) error { + message := fmt.Sprintf("Timed out after %s waiting for sandbox creation.", timeout) + if lastMessage != "" { + message = fmt.Sprintf("%s Last response: %s", message, lastMessage) + } + + return &azdext.LocalError{ + Message: message, + Code: "rle_sandbox_wait_timeout", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Check the RLE control plane logs for disk image conversion status, then retry sandbox create.", + } +} + +func extractRleErrorMessage(body string) string { + var payload struct { + Error any `json:"error"` + } + + if err := json.Unmarshal([]byte(body), &payload); err == nil && payload.Error != nil { + switch value := payload.Error.(type) { + case string: + return value + case map[string]any: + if message, ok := value["message"].(string); ok && message != "" { + return message + } + } + } + + return strings.TrimSpace(body) +} + +func printJson(cmd *cobra.Command, value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) + return err +} + +func printSandboxCreated(cmd *cobra.Command, sandbox *sandboxResource) error { + _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "Sandbox created: %s\nStatus: %s\nURL: %s\n", + sandbox.Id, + sandbox.Status, + sandbox.Url, + ) + return err +} + +func isJsonOutput(cmd *cobra.Command) bool { + flag := cmd.Flag("output") + return flag != nil && strings.EqualFold(flag.Value.String(), "json") +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox_test.go new file mode 100644 index 00000000000..021bc1a139b --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox_test.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "testing" + +func TestRetryableSandboxCreateError(t *testing.T) { + err := &rleHTTPError{ + statusCode: 409, + body: `{"error":"Environment 'env' version '1' does not have a ready disk image (conversion status: Pending)."}`, + } + + message, retry := retryableSandboxCreateError(err) + if !retry { + t.Fatal("expected pending conversion error to be retryable") + } + if message == "" { + t.Fatal("expected retry message") + } +} + +func TestFailedSandboxCreateErrorIsNotRetryable(t *testing.T) { + err := &rleHTTPError{ + statusCode: 409, + body: `{"error":"Environment 'env' version '1' does not have a ready disk image (conversion status: Failed)."}`, + } + + _, retry := retryableSandboxCreateError(err) + if retry { + t.Fatal("expected failed conversion error not to be retryable") + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go new file mode 100644 index 00000000000..a4dbca60dae --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go @@ -0,0 +1,533 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +var envNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + +func validateEnvName(name string) (string, error) { + name = strings.TrimSpace(name) + if !envNamePattern.MatchString(name) { + return "", fmt.Errorf("invalid environment name %q", name) + } + return name, nil +} + +func validateRecipeName(name string) (string, error) { + name = strings.TrimSpace(name) + if !envNamePattern.MatchString(name) { + return "", fmt.Errorf("invalid recipe name %q", name) + } + return name, nil +} + +func scaffoldRleSession(name string, dest string, image string, force bool) (string, error) { + envClass := toPascal(name) + envTitle := toTitle(name) + sessionDir := filepath.Join(dest, name) + if entries, err := os.ReadDir(sessionDir); err == nil && len(entries) > 0 && !force { + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Directory %q already exists and is not empty.", sessionDir), + Code: "rle_session_exists", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use --force to overwrite generated files, or choose a different environment name.", + } + } else if err != nil && !os.IsNotExist(err) { + return "", err + } + + files := map[string]string{ + "models.py": modelsTemplate, + "client.py": clientTemplate, + "Dockerfile": dockerfileTemplate, + "requirements.txt": requirementsTemplate, + rleManifestFile: manifestTemplate, + "README.md": readmeTemplate, + ".dockerignore": dockerignoreTemplate, + "rle_runtime.py": runtimeTemplate, + "server/" + name + "_environment.py": environmentTemplate, + "server/app.py": appTemplate, + "server/__init__.py": "", + } + + replacements := map[string]string{ + "__ENV_NAME__": name, + "__ENV_CLASS__": envClass, + "__ENV_TITLE__": envTitle, + "__IMAGE__": image, + } + + for relativePath, content := range files { + for token, value := range replacements { + content = strings.ReplaceAll(content, token, value) + } + content = strings.ReplaceAll(content, "\n", "\r\n") + fullPath := filepath.Join(sessionDir, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return "", err + } + if err := os.WriteFile(fullPath, []byte(content), 0600); err != nil { + return "", err + } + } + + return sessionDir, nil +} + +func toPascal(name string) string { + parts := strings.Split(name, "_") + for i, part := range parts { + if part == "" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, "") +} + +func toTitle(name string) string { + parts := strings.Split(name, "_") + for i, part := range parts { + if part == "" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, " ") +} + +const manifestTemplate = `# RLE environment manifest (OpenEnv-style). +spec_version: 1 +name: __ENV_NAME__ +type: rle +runtime: fastapi +app: server.app:app +port: 8000 +image: __IMAGE__ +` + +const requirementsTemplate = `fastapi>=0.110 +uvicorn>=0.29 +pydantic>=2.6 +` + +const dockerignoreTemplate = `__pycache__/ +*.pyc +.venv/ +venv/ +.git/ +.pytest_cache/ +` + +const modelsTemplate = `"""Data models for the __ENV_TITLE__ environment.""" + +from pydantic import Field + +from rle_runtime import Action, Observation + + +class __ENV_CLASS__Action(Action): + """Action for the __ENV_TITLE__ environment.""" + + message: str = Field(..., description="Message to send to the environment") + + +class __ENV_CLASS__Observation(Observation): + """Observation returned by the __ENV_TITLE__ environment.""" + + echoed_message: str = Field(default="", description="The echoed message") + message_length: int = Field(default=0, description="Length of the echoed message") +` + +const environmentTemplate = `"""__ENV_TITLE__ environment implementation.""" + +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from rle_runtime import Environment, State + +from models import __ENV_CLASS__Action, __ENV_CLASS__Observation + + +class __ENV_CLASS__Environment(Environment): + """A simple echo environment. + + Replace the reset/step logic below with your own environment dynamics. + """ + + def __init__(self) -> None: + self._episode_id: Optional[str] = None + self._step_count: int = 0 + + def reset( + self, + seed: Optional[int] = None, + episode_id: Optional[str] = None, + **kwargs: Any, + ) -> __ENV_CLASS__Observation: + self._episode_id = episode_id or str(uuid.uuid4()) + self._step_count = 0 + return __ENV_CLASS__Observation( + echoed_message="", + message_length=0, + done=False, + reward=None, + ) + + def step(self, action: __ENV_CLASS__Action, **kwargs: Any) -> __ENV_CLASS__Observation: + self._step_count += 1 + message = action.message + return __ENV_CLASS__Observation( + echoed_message=message, + message_length=len(message), + done=False, + reward=float(len(message)), + ) + + @property + def state(self) -> State: + return State(episode_id=self._episode_id, step_count=self._step_count) +` + +const appTemplate = `"""FastAPI application entrypoint for the __ENV_TITLE__ environment. + +Exposes the OpenEnv-compatible contract: + POST /reset, POST /step, GET /state, GET /health, GET /metadata, GET /schema + +Run locally: + uvicorn server.app:app --host 0.0.0.0 --port 8000 +""" + +from rle_runtime import create_app + +from models import __ENV_CLASS__Action, __ENV_CLASS__Observation +from server.__ENV_NAME___environment import __ENV_CLASS__Environment + +app = create_app( + __ENV_CLASS__Environment, + __ENV_CLASS__Action, + __ENV_CLASS__Observation, + env_name="__ENV_NAME__", +) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) +` + +const clientTemplate = `"""Minimal HTTP client for the __ENV_TITLE__ environment. + +Use this for quick local testing against either: + - the container directly (e.g. http://localhost:32891), or + - the Foundry data plane gateway, e.g. + http://localhost:5001/rle/v1.0/projects//environments//instances/ + +Example: + from client import __ENV_CLASS__Client + + base = ("http://localhost:5001/rle/v1.0/projects/proj1" + "/environments//instances/") + c = __ENV_CLASS__Client(base) + print(c.reset()) + print(c.step({"message": "hello"})) + print(c.state()) +""" + +from __future__ import annotations + +import json +import urllib.request +from typing import Any, Dict, Optional + + +class __ENV_CLASS__Client: + def __init__(self, base_url: str, timeout_s: float = 30.0) -> None: + self.base_url = base_url.rstrip("/") + self.timeout_s = timeout_s + + def _post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + data = json.dumps(body or {}).encode("utf-8") + req = urllib.request.Request( + f"{self.base_url}{path}", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self.timeout_s) as resp: + return json.loads(resp.read().decode("utf-8")) + + def _get(self, path: str) -> Dict[str, Any]: + req = urllib.request.Request(f"{self.base_url}{path}", method="GET") + with urllib.request.urlopen(req, timeout=self.timeout_s) as resp: + return json.loads(resp.read().decode("utf-8")) + + def reset(self, seed: Optional[int] = None, episode_id: Optional[str] = None) -> Dict[str, Any]: + return self._post("/reset", {"seed": seed, "episode_id": episode_id}) + + def step(self, action: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/step", {"action": action}) + + def state(self) -> Dict[str, Any]: + return self._get("/state") +` + +const dockerfileTemplate = `# __ENV_TITLE__ RLE environment image. +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies first for better layer caching. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the environment code. +COPY . . + +# OpenEnv-style environments always listen on port 8000. +EXPOSE 8000 + +# Container-level health check (pure Python, no curl needed). +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health', timeout=2).status==200 else 1)" || exit 1 + +CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"] +` + +const readmeTemplate = `# __ENV_TITLE__ RLE Environment + +An OpenEnv-style Reinforcement Learning Environment generated by the ` + "`rle`" + ` CLI. + +## Layout + +| File | Purpose | +| --- | --- | +| ` + "`rle_runtime.py`" + ` | Self-contained OpenEnv-compatible runtime (base models + FastAPI factory). | +| ` + "`models.py`" + ` | ` + "`__ENV_CLASS__Action`" + ` / ` + "`__ENV_CLASS__Observation`" + ` data models. | +| ` + "`server/__ENV_NAME___environment.py`" + ` | ` + "`__ENV_CLASS__Environment`" + ` — your ` + "`reset`" + `/` + "`step`" + `/` + "`state`" + ` logic. | +| ` + "`server/app.py`" + ` | FastAPI app exposing ` + "`/reset`" + `, ` + "`/step`" + `, ` + "`/state`" + `, ` + "`/health`" + `. | +| ` + "`client.py`" + ` | Minimal HTTP client for testing. | +| ` + "`Dockerfile`" + ` | Builds the environment image (listens on ` + "`:8000`" + `). | +| ` + "`rle.yaml`" + ` | Environment manifest. | + +## Run locally (no Docker) + +` + "```bash" + ` +pip install -r requirements.txt +uvicorn server.app:app --host 0.0.0.0 --port 8000 +# in another shell: +curl -X POST localhost:8000/reset +curl -X POST localhost:8000/step -H 'content-type: application/json' \ + -d '{"action": {"message": "hello"}}' +curl localhost:8000/state +` + "```" + ` + +## Deploy into Foundry + +` + "```bash" + ` +rle deploy --project --path . --name __ENV_NAME__ \ + --control-plane http://localhost:5000 +` + "```" + ` + +This builds the image, ensures an environment definition exists via the control +plane, deploys an instance, and prints the data plane endpoint you can call +` + "`reset`" + `/` + "`step`" + `/` + "`state`" + ` against. +` + +const runtimeTemplate = `# rle_runtime.py +# +# Self-contained, OpenEnv-compatible runtime for RLE environment containers. +# +# This single file provides: +# - Base data models: Action, Observation, State +# - The Environment abstract base class (reset / step / state) +# - create_app(): a FastAPI factory exposing the OpenEnv simulation contract: +# POST /reset -> {observation, reward, done, metadata} +# POST /step -> {observation, reward, done, metadata} +# GET /state -> State as dict +# GET /health -> {status: "healthy"} +# GET /metadata, GET /schema +# +# It intentionally has NO dependency on the ` + "`openenv`" + ` package so that generated +# environments are fully standalone and runnable with only fastapi/uvicorn/pydantic. +from __future__ import annotations + +import logging +import socket +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Type + +from fastapi import Body, FastAPI +from pydantic import BaseModel, ConfigDict, Field + +# The container's hostname is, by default, the short Docker container id (we do +# not pass --hostname), so it can be used directly with ` + "`docker logs `" + `. +CONTAINER_ID = socket.gethostname() + + +def _make_logger(env_name: str) -> logging.Logger: + """A stdout logger whose lines are captured by ` + "`docker logs`" + `.""" + logger = logging.getLogger(f"rle.{env_name}") + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s [rle] %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + logger.propagate = False + return logger + + +# --------------------------------------------------------------------------- +# Core data models (mirror openenv.core.env_server.types) +# --------------------------------------------------------------------------- +class Action(BaseModel): + """Base class for all environment actions.""" + + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class Observation(BaseModel): + """Base class for all environment observations.""" + + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + done: bool = Field(default=False, description="Whether the episode has terminated") + reward: Optional[float] = Field(default=None, description="Reward from last action") + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class State(BaseModel): + """Base class for environment state.""" + + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) + episode_id: Optional[str] = Field(default=None) + step_count: int = Field(default=0) + + +class Environment(ABC): + """Gym-style environment base class. + + Subclasses implement reset(), step(), and the ` + "`state`" + ` property. + """ + + @abstractmethod + def reset( + self, seed: Optional[int] = None, episode_id: Optional[str] = None, **kwargs: Any + ) -> Observation: + """Reset the environment and return the initial observation.""" + + @abstractmethod + def step(self, action: Action, **kwargs: Any) -> Observation: + """Apply an action and return the resulting observation.""" + + @property + @abstractmethod + def state(self) -> State: + """Return the current environment state.""" + + +# --------------------------------------------------------------------------- +# Wire models (mirror the OpenEnv HTTP contract) +# --------------------------------------------------------------------------- +class ResetRequest(BaseModel): + model_config = ConfigDict(extra="allow") + seed: Optional[int] = None + episode_id: Optional[str] = None + + +class StepRequest(BaseModel): + model_config = ConfigDict(extra="allow") + action: Dict[str, Any] + timeout_s: Optional[float] = None + request_id: Optional[str] = None + + +def _serialize_observation(obs: Observation) -> Dict[str, Any]: + """Convert an Observation into the OpenEnv wire format.""" + obs_dict = obs.model_dump(exclude={"reward", "done"}) + result: Dict[str, Any] = { + "observation": obs_dict, + "reward": obs.reward, + "done": obs.done, + } + if obs.metadata: + result["metadata"] = obs.metadata + return result + + +def create_app( + environment_cls: Type[Environment], + action_cls: Type[Action], + observation_cls: Type[Observation], + env_name: str = "rle-env", +) -> FastAPI: + """Build a FastAPI app exposing reset/step/state for the given environment. + + A single environment instance is created at startup (single-session + simulation mode), which matches how the data plane proxies stateless calls. + """ + app = FastAPI(title=f"{env_name} (RLE)", version="1.0.0") + env: Environment = environment_cls() + log = _make_logger(env_name) + + @app.get("/health", tags=["Health"]) + def health() -> Dict[str, str]: + return {"status": "healthy"} + + @app.get("/metadata", tags=["Environment Info"]) + def metadata() -> Dict[str, Any]: + return { + "name": env_name, + "description": f"{env_name} RLE environment", + "version": "1.0.0", + "container": CONTAINER_ID, + } + + @app.get("/schema", tags=["Schema"]) + def schema() -> Dict[str, Any]: + return { + "action": action_cls.model_json_schema(), + "observation": observation_cls.model_json_schema(), + "state": State.model_json_schema(), + } + + @app.post("/reset", tags=["Environment Control"]) + def reset(request: ResetRequest = Body(default_factory=ResetRequest)) -> Dict[str, Any]: + obs = env.reset(seed=request.seed, episode_id=request.episode_id) + st = env.state + log.info( + "[%s] RESET episode=%s seed=%s -> done=%s reward=%s", + CONTAINER_ID, st.episode_id, request.seed, obs.done, obs.reward, + ) + return _serialize_observation(obs) + + @app.post("/step", tags=["Environment Control"]) + def step(request: StepRequest) -> Dict[str, Any]: + action = action_cls.model_validate(request.action) + obs = env.step(action) + st = env.state + log.info( + "[%s] STEP episode=%s step=%s action=%s -> reward=%s done=%s", + CONTAINER_ID, st.episode_id, st.step_count, request.action, obs.reward, obs.done, + ) + return _serialize_observation(obs) + + @app.get("/state", tags=["State Management"]) + def state() -> Dict[str, Any]: + return env.state.model_dump() + + return app +` diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go new file mode 100644 index 00000000000..a6726cafcdb --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" +) + +func newShowCommand() *cobra.Command { + flags := &sharedFlags{ + account: defaultAccountName, + project: defaultProjectName, + } + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show an RLE environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) + environment, err := client.getEnvironment(cmd.Context(), flags.account, flags.project, args[0]) + if err != nil { + return serviceError(err) + } + + encoded, err := json.MarshalIndent(environment, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) + return err + }, + } + + addSharedFlags(cmd, flags) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go new file mode 100644 index 00000000000..a0f4d4c8c58 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + rleStateFile = ".azd-rle.json" + rleManifestFile = "rle.yaml" +) + +type rleState struct { + Name string `json:"name"` + Recipe string `json:"recipe"` + Image string `json:"image,omitempty"` + Account string `json:"account"` + Project string `json:"project"` + Endpoint string `json:"endpoint,omitempty"` + EnvironmentId string `json:"environmentId,omitempty"` + EnvironmentVersion string `json:"environmentVersion,omitempty"` + InstanceId string `json:"instanceId,omitempty"` + InstanceEndpoint string `json:"instanceEndpoint,omitempty"` +} + +func defaultRleState(name string, recipe string) rleState { + return rleState{ + Name: name, + Recipe: recipe, + Account: defaultAccountName, + Project: defaultProjectName, + } +} + +func loadRleState() (rleState, error) { + data, err := os.ReadFile(stateFilePath(".")) + if errors.Is(err, os.ErrNotExist) { + return rleState{}, &azdext.LocalError{ + Message: "RLE session has not been initialized.", + Code: "rle_project_not_initialized", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Run azd ai rle init first, then run commands from the created session folder.", + } + } + if err != nil { + return rleState{}, err + } + + var state rleState + if err := json.Unmarshal(data, &state); err != nil { + return rleState{}, err + } + if state.Account == "" { + state.Account = defaultAccountName + } + if state.Project == "" { + state.Project = defaultProjectName + } + if state.Recipe == "" { + state.Recipe = defaultRecipeName + } + return state, nil +} + +func saveRleState(state rleState) error { + return saveRleStateIn(".", state) +} + +func saveRleStateIn(dir string, state rleState) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(stateFilePath(dir), append(data, '\n'), 0600) +} + +func stateFilePath(dir string) string { + return filepath.Join(dir, rleStateFile) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/version.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/version.go new file mode 100644 index 00000000000..80b2e338d90 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/version.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +var ( + // Version is populated at build time. + Version = "dev" + // Commit is populated at build time. + Commit = "none" + // BuildDate is populated at build time. + BuildDate = "unknown" +) + +func newVersionCommand(outputFormat *string) *cobra.Command { + return azdext.NewVersionCommand("azure.ai.rle", Version, outputFormat) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/versions.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/versions.go new file mode 100644 index 00000000000..a2beed37eb5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/versions.go @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" +) + +func newVersionsCommand() *cobra.Command { + flags := &sharedFlags{ + account: defaultAccountName, + project: defaultProjectName, + } + + cmd := &cobra.Command{ + Use: "versions ", + Short: "List RLE environment versions", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) + versions, err := client.listEnvironmentVersions(cmd.Context(), flags.account, flags.project, args[0]) + if err != nil { + return serviceError(err) + } + + encoded, err := json.MarshalIndent(versions, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) + return err + }, + } + + addSharedFlags(cmd, flags) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.rle/main.go b/cli/azd/extensions/azure.ai.rle/main.go new file mode 100644 index 00000000000..8b1b4ad4457 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/main.go @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package main + +import ( + "azure.ai.rle/internal/cmd" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func main() { + azdext.Run(cmd.NewRootCommand()) +} diff --git a/cli/azd/extensions/azure.ai.rle/version.txt b/cli/azd/extensions/azure.ai.rle/version.txt new file mode 100644 index 00000000000..b727e6cbb8a --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/version.txt @@ -0,0 +1 @@ +0.1.0-preview \ No newline at end of file