From bce184454c3a1eb6343a2962ac1d2786a39fb982 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Tue, 23 Jun 2026 17:21:49 +0530 Subject: [PATCH 01/11] feat: ai rle v1 --- cli/azd/extensions/azure.ai.rle/.gitignore | 3 + .../extensions/azure.ai.rle/.golangci.yaml | 17 + cli/azd/extensions/azure.ai.rle/CHANGELOG.md | 5 + cli/azd/extensions/azure.ai.rle/README.md | 73 +++++ cli/azd/extensions/azure.ai.rle/build.ps1 | 78 +++++ cli/azd/extensions/azure.ai.rle/build.sh | 66 ++++ cli/azd/extensions/azure.ai.rle/cspell.yaml | 4 + .../extensions/azure.ai.rle/extension.yaml | 21 ++ cli/azd/extensions/azure.ai.rle/go.mod | 102 ++++++ cli/azd/extensions/azure.ai.rle/go.sum | 310 ++++++++++++++++++ .../azure.ai.rle/internal/cmd/create.go | 31 ++ .../azure.ai.rle/internal/cmd/metadata.go | 15 + .../azure.ai.rle/internal/cmd/modify.go | 17 + .../azure.ai.rle/internal/cmd/root.go | 32 ++ .../azure.ai.rle/internal/cmd/root_test.go | 16 + .../azure.ai.rle/internal/cmd/version.go | 22 ++ cli/azd/extensions/azure.ai.rle/main.go | 14 + cli/azd/extensions/azure.ai.rle/version.txt | 1 + 18 files changed, 827 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.rle/.gitignore create mode 100644 cli/azd/extensions/azure.ai.rle/.golangci.yaml create mode 100644 cli/azd/extensions/azure.ai.rle/CHANGELOG.md create mode 100644 cli/azd/extensions/azure.ai.rle/README.md create mode 100644 cli/azd/extensions/azure.ai.rle/build.ps1 create mode 100644 cli/azd/extensions/azure.ai.rle/build.sh create mode 100644 cli/azd/extensions/azure.ai.rle/cspell.yaml create mode 100644 cli/azd/extensions/azure.ai.rle/extension.yaml create mode 100644 cli/azd/extensions/azure.ai.rle/go.mod create mode 100644 cli/azd/extensions/azure.ai.rle/go.sum create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/create.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/metadata.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/modify.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/root.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/version.go create mode 100644 cli/azd/extensions/azure.ai.rle/main.go create mode 100644 cli/azd/extensions/azure.ai.rle/version.txt 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..f23972fc103 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -0,0 +1,73 @@ +# Azure AI RLE extension for azd + +The `azure.ai.rle` extension adds the `azd ai rle` command group. + +## Commands + +```bash +azd ai rle create +azd ai rle modify +azd ai rle version +``` + +The initial `create` and `modify` commands are scaffolded command entry points. They currently return structured not-implemented errors until the RLE service workflow is added. + +## Build + +```bash +go build ./... +``` + +## Test + +```bash +go test ./... +``` + +## Private team testing + +Use this workflow to test the extension from a private branch without publishing anything publicly. + +From the extension directory: + +```powershell +azd extension install microsoft.azd.extensions + +azd x build +azd x pack -o .\registry-artifacts + +$registry = Join-Path $env:USERPROFILE ".azd\rle-registry.json" +New-Item -ItemType Directory -Force -Path (Split-Path $registry) | Out-Null +if (-not (Test-Path $registry)) { + '{"schemaVersion":"1.0","extensions":[]}' | Set-Content -Path $registry -Encoding utf8 +} + +azd x publish --registry $registry --artifacts ".\registry-artifacts\*.zip,.\registry-artifacts\*.tar.gz" +azd extension source remove rle-local 2>$null +azd extension source add -n rle-local -t file -l $registry +azd extension install azure.ai.rle --source rle-local --force +``` + +Then verify the command is available: + +```powershell +azd ai rle --help +azd ai rle version +azd ai rle create test-rle +azd ai rle modify test-rle +``` + +The `create` and `modify` commands currently return structured not-implemented errors. That is expected until the RLE service workflow is added. + +To share with teammates, push this extension code to a private branch. Each teammate can pull the branch and run the same commands locally. The generated registry uses absolute paths to artifacts on the local machine, so each teammate should generate their own local registry from their checkout. + +To update an existing local install after making code changes, rerun: + +```powershell +$registry = Join-Path $env:USERPROFILE ".azd\rle-registry.json" + +azd x build +azd x pack -o .\registry-artifacts +azd x publish --registry $registry --artifacts ".\registry-artifacts\*.zip,.\registry-artifacts\*.tar.gz" +azd extension install azure.ai.rle --source rle-local --force +``` \ No newline at end of file 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..678c62ec7ea --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -0,0 +1,21 @@ +# 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: create + description: Create a new RLE resource. + usage: azd ai rle create + - name: modify + description: Modify an existing RLE resource. + usage: azd ai rle modify \ No newline at end of file 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..9bde226d7fd --- /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 +) + +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 + gopkg.in/yaml.v3 v3.0.1 // 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/create.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go new file mode 100644 index 00000000000..cf2078f7af6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go @@ -0,0 +1,31 @@ +// 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/spf13/cobra" +) + +func newCreateCommand() *cobra.Command { + return &cobra.Command{ + Use: "create ", + Short: "Create a new RLE resource", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return notImplementedError("create", args[0]) + }, + } +} + +func notImplementedError(commandName string, resourceName string) error { + return &azdext.LocalError{ + Message: fmt.Sprintf("azd ai rle %s is not implemented yet for %q.", commandName, resourceName), + Code: fmt.Sprintf("%s_not_implemented", commandName), + Category: azdext.LocalErrorCategoryCompatibility, + Suggestion: "Add the RLE service workflow for this command, then try again.", + } +} 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/modify.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/modify.go new file mode 100644 index 00000000000..b6e07ecac3d --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/modify.go @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "github.com/spf13/cobra" + +func newModifyCommand() *cobra.Command { + return &cobra.Command{ + Use: "modify ", + Short: "Modify an existing RLE resource", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return notImplementedError("modify", args[0]) + }, + } +} 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..8e1889fbbf9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -0,0 +1,32 @@ +// 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}) + + rootCmd.AddCommand(newCreateCommand()) + rootCmd.AddCommand(newModifyCommand()) + 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..cf68de762fb --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go @@ -0,0 +1,16 @@ +// 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{"create", "modify", "version", "metadata"} { + if command, _, err := rootCmd.Find([]string{commandName}); err != nil || command.Name() != commandName { + t.Fatalf("expected command %q to be registered", commandName) + } + } +} 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/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 From a03310d973c9a5761c099fd3ccaf9900b5543b5d Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Tue, 23 Jun 2026 19:25:12 +0530 Subject: [PATCH 02/11] fix: working setup --- cli/azd/extensions/azure.ai.rle/README.md | 83 ++++- .../extensions/azure.ai.rle/extension.yaml | 13 +- .../azure.ai.rle/internal/cmd/client.go | 314 ++++++++++++++++++ .../azure.ai.rle/internal/cmd/create.go | 94 +++++- .../azure.ai.rle/internal/cmd/list.go | 41 +++ .../azure.ai.rle/internal/cmd/root.go | 4 + .../azure.ai.rle/internal/cmd/sandbox.go | 169 ++++++++++ .../azure.ai.rle/internal/cmd/show.go | 41 +++ .../azure.ai.rle/internal/cmd/versions.go | 41 +++ 9 files changed, 787 insertions(+), 13 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/client.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/list.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/show.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/versions.go diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index f23972fc103..77dfbbad177 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -5,12 +5,20 @@ The `azure.ai.rle` extension adds the `azd ai rle` command group. ## Commands ```bash -azd ai rle create +azd ai rle create [--endpoint http://localhost:5000] [--account local] [--project demo] +azd ai rle list [--endpoint http://localhost:5000] [--account local] [--project demo] +azd ai rle show [--endpoint http://localhost:5000] [--account local] [--project demo] +azd ai rle versions [--endpoint http://localhost:5000] [--account local] [--project demo] +azd ai rle sandbox create [--endpoint http://localhost:5000] [--project demo] +azd ai rle sandbox list [--endpoint http://localhost:5000] [--project demo] +azd ai rle sandbox show [--endpoint http://localhost:5000] [--project demo] azd ai rle modify azd ai rle version ``` -The initial `create` and `modify` commands are scaffolded command entry points. They currently return structured not-implemented errors until the RLE service workflow is added. +The `create`, `list`, `show`, `versions`, and `sandbox` commands call the RLE control plane directly. The endpoint defaults to `AZD_RLE_CONTROL_PLANE`, then `RLE_CONTROL_PLANE`, then `http://localhost:5000`. If `RLE_BEARER_TOKEN` is set, it is sent as the bearer token. + +The `modify` command is still a scaffolded command entry point and returns a structured not-implemented error until the RLE service workflow is added. ## Build @@ -24,9 +32,31 @@ go build ./... go test ./... ``` -## Private team testing +## Local laptop testing from a branch + +Use this workflow to test the extension from a private branch without publishing anything publicly. Start the local RLE control plane by following the [RLE service setup](https://msdata.visualstudio.com/Vienna/_git/vienna?path=/src/azureml-api/src/RLE), then point this extension at that endpoint. The examples below assume the control plane is running at `http://localhost:5000` and is using the local project-scoped RLE routes. + +### 1. Check out the branch + +```powershell +cd C:\Users\\source\repos +git clone https://github.com/Azure/azure-dev.git +cd C:\Users\\source\repos\azure-dev +git fetch origin farhannawaz/rle-cli +git checkout farhannawaz/rle-cli +cd C:\Users\\source\repos\azure-dev\cli\azd\extensions\azure.ai.rle +``` + +If the repo is already cloned: -Use this workflow to test the extension from a private branch without publishing anything publicly. +```powershell +cd C:\Users\\source\repos\azure-dev +git fetch origin farhannawaz/rle-cli +git checkout farhannawaz/rle-cli +cd C:\Users\\source\repos\azure-dev\cli\azd\extensions\azure.ai.rle +``` + +### 2. Install the local extension into azd From the extension directory: @@ -48,16 +78,53 @@ azd extension source add -n rle-local -t file -l $registry azd extension install azure.ai.rle --source rle-local --force ``` -Then verify the command is available: +Then verify the command is available. ```powershell azd ai rle --help azd ai rle version -azd ai rle create test-rle -azd ai rle modify test-rle ``` -The `create` and `modify` commands currently return structured not-implemented errors. That is expected until the RLE service workflow is added. +### 3. Run environment create and sandbox provisioning + +Create an RLE environment from an ACR image. The first positional argument is the environment name. The server returns a generated environment id; use that id for later commands. + +```powershell +azd ai rle create coding-env-e2e ` + --endpoint http://localhost:5000 ` + --project demo ` + --image devrle.azurecr.io/coding_env:latest +``` + +List environments and copy the generated environment id. + +```powershell +azd ai rle list ` + --endpoint http://localhost:5000 ` + --project demo +``` + +Create a sandbox for the environment. The server creates the disk image asynchronously after environment creation, so this command can initially return `conversion status: Pending`. Retry the same command until it succeeds or reports `Failed`. + +```powershell +azd ai rle sandbox create ` + --endpoint http://localhost:5000 ` + --project demo +``` + +When sandbox creation succeeds, the response includes the sandbox id, ADC sandbox id, status, and URL. You can query it later with: + +```powershell +azd ai rle sandbox list ` + --endpoint http://localhost:5000 ` + --project demo + +azd ai rle sandbox show ` + --endpoint http://localhost:5000 ` + --project demo +``` + +The `modify` command currently returns a structured not-implemented error. That is expected until the RLE service workflow is added. To share with teammates, push this extension code to a private branch. Each teammate can pull the branch and run the same commands locally. The generated registry uses absolute paths to artifacts on the local machine, so each teammate should generate their own local registry from their checkout. diff --git a/cli/azd/extensions/azure.ai.rle/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml index 678c62ec7ea..60e77dd92f9 100644 --- a/cli/azd/extensions/azure.ai.rle/extension.yaml +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -14,8 +14,17 @@ usage: azd ai rle [options] version: 0.1.0-preview examples: - name: create - description: Create a new RLE resource. - usage: azd ai rle create + description: Create or update an RLE environment. + usage: azd ai rle create --endpoint http://localhost:5000 --account local --project demo + - name: list + description: List RLE environments. + usage: azd ai rle list --endpoint http://localhost:5000 --account local --project demo + - name: show + description: Show an RLE environment. + usage: azd ai rle show --endpoint http://localhost:5000 --account local --project demo + - name: versions + description: List RLE environment versions. + usage: azd ai rle versions --endpoint http://localhost:5000 --account local --project demo - name: modify description: Modify an existing RLE resource. usage: azd ai rle modify \ No newline at end of file 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..e7eb89578a1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "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 environmentManifest struct { + Name string `json:"name"` + AcrImagePath string `json:"acrImagePath"` +} + +type environmentResource struct { + Id string `json:"id"` + Name string `json:"name"` + ProjectId string `json:"projectId,omitempty"` + AcrImagePath string `json:"acrImagePath,omitempty"` + Version string `json:"version,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 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)) +} + +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_CONTROL_PLANE"); endpoint != "" { + return endpoint + } + return defaultControlPlaneEndpoint +} + +func (c *rleClient) createOrUpdateEnvironment( + ctx context.Context, + account string, + project string, + environmentId string, + manifest environmentManifest, +) (*environmentResource, error) { + _ = account + _ = environmentId + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments", + url.PathEscape(project), + ) + + var result environmentResource + if err := c.do(ctx, http.MethodPost, path, manifest, &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") + } + if token := os.Getenv("RLE_BEARER_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + 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 newManifest(environmentId string, name string, image string, version string) environmentManifest { + _ = environmentId + _ = version + return environmentManifest{ + Name: name, + AcrImagePath: image, + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go index cf2078f7af6..bef4a03c7e2 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go @@ -4,21 +4,67 @@ package cmd import ( + "encoding/json" "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 + image string + version string +} + func newCreateCommand() *cobra.Command { - return &cobra.Command{ + flags := &createFlags{ + sharedFlags: sharedFlags{ + account: defaultAccountName, + project: defaultProjectName, + }, + version: "1.0.0", + } + + cmd := &cobra.Command{ Use: "create ", - Short: "Create a new RLE resource", + Short: "Create or update an RLE environment", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return notImplementedError("create", args[0]) + name := args[0] + environmentId := slug(name) + client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) + environment, err := client.createOrUpdateEnvironment( + cmd.Context(), + flags.account, + flags.project, + environmentId, + newManifest(environmentId, name, flags.image, flags.version), + ) + 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.sharedFlags) + cmd.Flags().StringVar(&flags.image, "image", "", "Container image for the RLE environment") + cmd.Flags().StringVar(&flags.version, "version-label", flags.version, "Environment version label") + return cmd } func notImplementedError(commandName string, resourceName string) error { @@ -29,3 +75,45 @@ func notImplementedError(commandName string, resourceName string) error { Suggestion: "Add the RLE service workflow for this command, then try again.", } } + +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(), "-") +} 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/root.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go index 8e1889fbbf9..b606c1c641c 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -24,7 +24,11 @@ func NewRootCommand() *cobra.Command { rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) rootCmd.AddCommand(newCreateCommand()) + rootCmd.AddCommand(newListCommand()) rootCmd.AddCommand(newModifyCommand()) + rootCmd.AddCommand(newSandboxCommand()) + rootCmd.AddCommand(newShowCommand()) + rootCmd.AddCommand(newVersionsCommand()) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) 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..af37775ac1e --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "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 +} + +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)) + sandbox, err := client.createSandbox( + cmd.Context(), + flags.account, + flags.project, + args[0], + sandboxCreateRequest{ + Version: flags.version, + Cpu: flags.cpu, + Memory: flags.memory, + Disk: flags.disk, + }, + ) + if err != nil { + return sandboxCreateError(err) + } + + 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") + 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 sandboxCreateError(err error) error { + 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 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 +} 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/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 +} From 71b054df95a9a4447854cd7d05ceb297983daaf0 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Tue, 23 Jun 2026 19:51:49 +0530 Subject: [PATCH 03/11] fix: update README --- cli/azd/extensions/azure.ai.rle/README.md | 68 +++++++++++++------ .../extensions/azure.ai.rle/extension.yaml | 12 +++- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 77dfbbad177..f460d0836c2 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -12,14 +12,11 @@ azd ai rle versions [--endpoint http://localhost:5000] [--accou azd ai rle sandbox create [--endpoint http://localhost:5000] [--project demo] azd ai rle sandbox list [--endpoint http://localhost:5000] [--project demo] azd ai rle sandbox show [--endpoint http://localhost:5000] [--project demo] -azd ai rle modify azd ai rle version ``` The `create`, `list`, `show`, `versions`, and `sandbox` commands call the RLE control plane directly. The endpoint defaults to `AZD_RLE_CONTROL_PLANE`, then `RLE_CONTROL_PLANE`, then `http://localhost:5000`. If `RLE_BEARER_TOKEN` is set, it is sent as the bearer token. -The `modify` command is still a scaffolded command entry point and returns a structured not-implemented error until the RLE service workflow is added. - ## Build ```bash @@ -34,21 +31,38 @@ go test ./... ## Local laptop testing from a branch -Use this workflow to test the extension from a private branch without publishing anything publicly. Start the local RLE control plane by following the [RLE service setup](https://msdata.visualstudio.com/Vienna/_git/vienna?path=/src/azureml-api/src/RLE), then point this extension at that endpoint. The examples below assume the control plane is running at `http://localhost:5000` and is using the local project-scoped RLE routes. +Use this workflow to test the extension from a private branch without publishing anything publicly. + +Prerequisites: + +- `azd` is installed. +- Go is installed. +- The local RLE control plane is running. Follow the [RLE service setup](https://msdata.visualstudio.com/Vienna/_git/vienna?path=/src/azureml-api/src/RLE) for service setup; this README only covers the azd extension side. + +The examples below assume the RLE control plane is running at `http://localhost:5000` and is using the local project-scoped RLE routes. ### 1. Check out the branch ```powershell cd C:\Users\\source\repos -git clone https://github.com/Azure/azure-dev.git +git clone https://github.com/farhann1/azure-dev.git cd C:\Users\\source\repos\azure-dev -git fetch origin farhannawaz/rle-cli git checkout farhannawaz/rle-cli cd C:\Users\\source\repos\azure-dev\cli\azd\extensions\azure.ai.rle ``` If the repo is already cloned: +```powershell +cd C:\Users\\source\repos\azure-dev +git remote add farhann1 https://github.com/farhann1/azure-dev.git 2>$null +git fetch farhann1 farhannawaz/rle-cli +git checkout -B farhannawaz/rle-cli farhann1/farhannawaz/rle-cli +cd C:\Users\\source\repos\azure-dev\cli\azd\extensions\azure.ai.rle +``` + +If your local `origin` already points to Farhan's fork, this shorter form also works: + ```powershell cd C:\Users\\source\repos\azure-dev git fetch origin farhannawaz/rle-cli @@ -87,44 +101,60 @@ azd ai rle version ### 3. Run environment create and sandbox provisioning +Set common values used by the commands. + +```powershell +$endpoint = "http://localhost:5000" +$project = "demo" +$image = "devrle.azurecr.io/coding_env:latest" +$environmentName = "coding-env-e2e" +$env:AZD_RLE_CONTROL_PLANE = $endpoint +``` + Create an RLE environment from an ACR image. The first positional argument is the environment name. The server returns a generated environment id; use that id for later commands. ```powershell -azd ai rle create coding-env-e2e ` - --endpoint http://localhost:5000 ` - --project demo ` - --image devrle.azurecr.io/coding_env:latest +azd ai rle create $environmentName ` + --project $project ` + --image $image ``` List environments and copy the generated environment id. ```powershell azd ai rle list ` - --endpoint http://localhost:5000 ` - --project demo + --project $project ``` Create a sandbox for the environment. The server creates the disk image asynchronously after environment creation, so this command can initially return `conversion status: Pending`. Retry the same command until it succeeds or reports `Failed`. ```powershell azd ai rle sandbox create ` - --endpoint http://localhost:5000 ` - --project demo + --project $project ``` When sandbox creation succeeds, the response includes the sandbox id, ADC sandbox id, status, and URL. You can query it later with: ```powershell azd ai rle sandbox list ` - --endpoint http://localhost:5000 ` - --project demo + --project $project azd ai rle sandbox show ` - --endpoint http://localhost:5000 ` - --project demo + --project $project ``` -The `modify` command currently returns a structured not-implemented error. That is expected until the RLE service workflow is added. +Optional retry loop for demos: + +```powershell +$environmentId = "" +for ($attempt = 1; $attempt -le 40; $attempt++) { + $output = azd ai rle sandbox create $environmentId --project $project 2>&1 + $output + if ($LASTEXITCODE -eq 0) { break } + if (($output | Out-String) -match "conversion status: Failed") { throw "Disk image conversion failed." } + Start-Sleep -Seconds 10 +} +``` To share with teammates, push this extension code to a private branch. Each teammate can pull the branch and run the same commands locally. The generated registry uses absolute paths to artifacts on the local machine, so each teammate should generate their own local registry from their checkout. diff --git a/cli/azd/extensions/azure.ai.rle/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml index 60e77dd92f9..b97184b3636 100644 --- a/cli/azd/extensions/azure.ai.rle/extension.yaml +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -25,6 +25,12 @@ examples: - name: versions description: List RLE environment versions. usage: azd ai rle versions --endpoint http://localhost:5000 --account local --project demo - - name: modify - description: Modify an existing RLE resource. - usage: azd ai rle modify \ No newline at end of file + - name: sandbox create + description: Create an RLE sandbox for an environment. + usage: azd ai rle sandbox create --endpoint http://localhost:5000 --project demo + - name: sandbox list + description: List RLE sandboxes for an environment. + usage: azd ai rle sandbox list --endpoint http://localhost:5000 --project demo + - name: sandbox show + description: Show an RLE sandbox. + usage: azd ai rle sandbox show --endpoint http://localhost:5000 --project demo From 12f70ca089d1a824061ef8f8c2f3e2ca37072e49 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Tue, 23 Jun 2026 20:04:08 +0530 Subject: [PATCH 04/11] fix: update README --- cli/azd/extensions/azure.ai.rle/README.md | 132 +++++----------------- 1 file changed, 26 insertions(+), 106 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index f460d0836c2..6dfa0b96bf8 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -2,77 +2,29 @@ The `azure.ai.rle` extension adds the `azd ai rle` command group. -## Commands - -```bash -azd ai rle create [--endpoint http://localhost:5000] [--account local] [--project demo] -azd ai rle list [--endpoint http://localhost:5000] [--account local] [--project demo] -azd ai rle show [--endpoint http://localhost:5000] [--account local] [--project demo] -azd ai rle versions [--endpoint http://localhost:5000] [--account local] [--project demo] -azd ai rle sandbox create [--endpoint http://localhost:5000] [--project demo] -azd ai rle sandbox list [--endpoint http://localhost:5000] [--project demo] -azd ai rle sandbox show [--endpoint http://localhost:5000] [--project demo] -azd ai rle version -``` - -The `create`, `list`, `show`, `versions`, and `sandbox` commands call the RLE control plane directly. The endpoint defaults to `AZD_RLE_CONTROL_PLANE`, then `RLE_CONTROL_PLANE`, then `http://localhost:5000`. If `RLE_BEARER_TOKEN` is set, it is sent as the bearer token. - -## Build - -```bash -go build ./... -``` - -## Test - -```bash -go test ./... -``` - -## Local laptop testing from a branch - -Use this workflow to test the extension from a private branch without publishing anything publicly. - -Prerequisites: - -- `azd` is installed. -- Go is installed. -- The local RLE control plane is running. Follow the [RLE service setup](https://msdata.visualstudio.com/Vienna/_git/vienna?path=/src/azureml-api/src/RLE) for service setup; this README only covers the azd extension side. - -The examples below assume the RLE control plane is running at `http://localhost:5000` and is using the local project-scoped RLE routes. +## Local setup ### 1. Check out the branch ```powershell -cd C:\Users\\source\repos -git clone https://github.com/farhann1/azure-dev.git -cd C:\Users\\source\repos\azure-dev +git fetch origin git checkout farhannawaz/rle-cli -cd C:\Users\\source\repos\azure-dev\cli\azd\extensions\azure.ai.rle +cd cli\azd\extensions\azure.ai.rle ``` -If the repo is already cloned: +### 2. Start the local RLE control plane -```powershell -cd C:\Users\\source\repos\azure-dev -git remote add farhann1 https://github.com/farhann1/azure-dev.git 2>$null -git fetch farhann1 farhannawaz/rle-cli -git checkout -B farhannawaz/rle-cli farhann1/farhannawaz/rle-cli -cd C:\Users\\source\repos\azure-dev\cli\azd\extensions\azure.ai.rle -``` +Follow the existing [RLE service setup](https://msdata.visualstudio.com/Vienna/_git/vienna?path=/src/azureml-api/src/RLE). -If your local `origin` already points to Farhan's fork, this shorter form also works: +The examples below assume the RLE control plane is running at: -```powershell -cd C:\Users\\source\repos\azure-dev -git fetch origin farhannawaz/rle-cli -git checkout farhannawaz/rle-cli -cd C:\Users\\source\repos\azure-dev\cli\azd\extensions\azure.ai.rle +```text +http://localhost:5000 ``` -### 2. Install the local extension into azd +### 3. Install the extension into azd -From the extension directory: +Run these commands from the extension directory: ```powershell azd extension install microsoft.azd.extensions @@ -83,7 +35,7 @@ azd x pack -o .\registry-artifacts $registry = Join-Path $env:USERPROFILE ".azd\rle-registry.json" New-Item -ItemType Directory -Force -Path (Split-Path $registry) | Out-Null if (-not (Test-Path $registry)) { - '{"schemaVersion":"1.0","extensions":[]}' | Set-Content -Path $registry -Encoding utf8 + '{"schemaVersion":"1.0","extensions":[]}' | Set-Content -Path $registry -Encoding utf8 } azd x publish --registry $registry --artifacts ".\registry-artifacts\*.zip,.\registry-artifacts\*.tar.gz" @@ -92,79 +44,47 @@ azd extension source add -n rle-local -t file -l $registry azd extension install azure.ai.rle --source rle-local --force ``` -Then verify the command is available. +Verify: ```powershell azd ai rle --help azd ai rle version ``` -### 3. Run environment create and sandbox provisioning - -Set common values used by the commands. +### 4. Create an RLE environment ```powershell -$endpoint = "http://localhost:5000" +$env:AZD_RLE_CONTROL_PLANE = "http://localhost:5000" $project = "demo" $image = "devrle.azurecr.io/coding_env:latest" $environmentName = "coding-env-e2e" -$env:AZD_RLE_CONTROL_PLANE = $endpoint -``` - -Create an RLE environment from an ACR image. The first positional argument is the environment name. The server returns a generated environment id; use that id for later commands. -```powershell azd ai rle create $environmentName ` --project $project ` --image $image ``` -List environments and copy the generated environment id. - -```powershell -azd ai rle list ` - --project $project -``` +Copy the generated environment id from the output. -Create a sandbox for the environment. The server creates the disk image asynchronously after environment creation, so this command can initially return `conversion status: Pending`. Retry the same command until it succeeds or reports `Failed`. +You can list or show environments with: ```powershell -azd ai rle sandbox create ` - --project $project +azd ai rle list --project $project +azd ai rle show --project $project +azd ai rle versions --project $project ``` -When sandbox creation succeeds, the response includes the sandbox id, ADC sandbox id, status, and URL. You can query it later with: - -```powershell -azd ai rle sandbox list ` - --project $project - -azd ai rle sandbox show ` - --project $project -``` +### 5. Create a sandbox -Optional retry loop for demos: +Disk image conversion starts automatically after environment creation. If sandbox creation returns `conversion status: Pending`, wait and retry the same command. ```powershell -$environmentId = "" -for ($attempt = 1; $attempt -le 40; $attempt++) { - $output = azd ai rle sandbox create $environmentId --project $project 2>&1 - $output - if ($LASTEXITCODE -eq 0) { break } - if (($output | Out-String) -match "conversion status: Failed") { throw "Disk image conversion failed." } - Start-Sleep -Seconds 10 -} +azd ai rle sandbox create --project $project ``` -To share with teammates, push this extension code to a private branch. Each teammate can pull the branch and run the same commands locally. The generated registry uses absolute paths to artifacts on the local machine, so each teammate should generate their own local registry from their checkout. - -To update an existing local install after making code changes, rerun: +After the sandbox is created, inspect it with: ```powershell -$registry = Join-Path $env:USERPROFILE ".azd\rle-registry.json" - -azd x build -azd x pack -o .\registry-artifacts -azd x publish --registry $registry --artifacts ".\registry-artifacts\*.zip,.\registry-artifacts\*.tar.gz" -azd extension install azure.ai.rle --source rle-local --force -``` \ No newline at end of file +azd ai rle sandbox list --project $project +azd ai rle sandbox show --project $project +``` From 26727d7d2c1c08148d2014ddf66e4796ef353de2 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 24 Jun 2026 01:59:00 +0530 Subject: [PATCH 05/11] fix: support init and deploy commands --- cli/azd/extensions/azure.ai.rle/README.md | 47 +- .../extensions/azure.ai.rle/extension.yaml | 30 +- cli/azd/extensions/azure.ai.rle/go.mod | 2 +- .../azure.ai.rle/internal/cmd/banner.go | 31 ++ .../azure.ai.rle/internal/cmd/client.go | 148 ++++- .../azure.ai.rle/internal/cmd/create.go | 63 ++- .../azure.ai.rle/internal/cmd/deploy.go | 117 ++++ .../azure.ai.rle/internal/cmd/init.go | 66 +++ .../azure.ai.rle/internal/cmd/invoke.go | 24 + .../azure.ai.rle/internal/cmd/manifest.go | 101 ++++ .../internal/cmd/manifest_test.go | 57 ++ .../azure.ai.rle/internal/cmd/modify.go | 17 - .../azure.ai.rle/internal/cmd/recipe.go | 37 ++ .../azure.ai.rle/internal/cmd/recipe_test.go | 32 ++ .../azure.ai.rle/internal/cmd/root.go | 17 +- .../azure.ai.rle/internal/cmd/root_test.go | 12 +- .../azure.ai.rle/internal/cmd/sandbox.go | 176 +++++- .../azure.ai.rle/internal/cmd/sandbox_test.go | 33 ++ .../azure.ai.rle/internal/cmd/scaffold.go | 525 ++++++++++++++++++ .../azure.ai.rle/internal/cmd/state.go | 86 +++ 20 files changed, 1496 insertions(+), 125 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/init.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/manifest_test.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/modify.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox_test.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/state.go diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 6dfa0b96bf8..1610e311e3e 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -12,16 +12,16 @@ git checkout farhannawaz/rle-cli cd cli\azd\extensions\azure.ai.rle ``` -### 2. Start the local RLE control plane +### 2. Configure the RLE control plane -Follow the existing [RLE service setup](https://msdata.visualstudio.com/Vienna/_git/vienna?path=/src/azureml-api/src/RLE). - -The examples below assume the RLE control plane is running at: - -```text -http://localhost:5000 +```powershell +$env:RLE_ENDPOINT = "https://rle-controlplane.orangeground-ba9696de.eastus2.azurecontainerapps.io" +$env:RLE_PROJECT_NAME = "demo-3" +$env:RLE_ACR_IMAGE = "devrle.azurecr.io/coding_env:latest" ``` +To target a local control plane instead, set `RLE_ENDPOINT` to `http://localhost:5000`. + ### 3. Install the extension into azd Run these commands from the extension directory: @@ -51,40 +51,27 @@ azd ai rle --help azd ai rle version ``` -### 4. Create an RLE environment +### 4. Initialize a local RLE session ```powershell -$env:AZD_RLE_CONTROL_PLANE = "http://localhost:5000" -$project = "demo" -$image = "devrle.azurecr.io/coding_env:latest" -$environmentName = "coding-env-e2e" - -azd ai rle create $environmentName ` - --project $project ` - --image $image +azd ai rle init code_rl ``` -Copy the generated environment id from the output. +Init creates a local session folder named `code_rl`, including an OpenEnv-style FastAPI package, `Dockerfile`, and `rle.yaml`. -You can list or show environments with: +Deploy from the session folder: ```powershell -azd ai rle list --project $project -azd ai rle show --project $project -azd ai rle versions --project $project +cd .\code_rl +azd ai rle deploy ``` -### 5. Create a sandbox +Deploy creates or updates the RLE environment and saves the environment id/version locally. -Disk image conversion starts automatically after environment creation. If sandbox creation returns `conversion status: Pending`, wait and retry the same command. +### 5. Training placeholder ```powershell -azd ai rle sandbox create --project $project +azd ai rle invoke ``` -After the sandbox is created, inspect it with: - -```powershell -azd ai rle sandbox list --project $project -azd ai rle sandbox show --project $project -``` +This command is a placeholder for now. Later, it will trigger the actual training job for the deployed RLE environment. diff --git a/cli/azd/extensions/azure.ai.rle/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml index b97184b3636..25f72ab11cf 100644 --- a/cli/azd/extensions/azure.ai.rle/extension.yaml +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -13,24 +13,12 @@ tags: usage: azd ai rle [options] version: 0.1.0-preview examples: - - name: create - description: Create or update an RLE environment. - usage: azd ai rle create --endpoint http://localhost:5000 --account local --project demo - - name: list - description: List RLE environments. - usage: azd ai rle list --endpoint http://localhost:5000 --account local --project demo - - name: show - description: Show an RLE environment. - usage: azd ai rle show --endpoint http://localhost:5000 --account local --project demo - - name: versions - description: List RLE environment versions. - usage: azd ai rle versions --endpoint http://localhost:5000 --account local --project demo - - name: sandbox create - description: Create an RLE sandbox for an environment. - usage: azd ai rle sandbox create --endpoint http://localhost:5000 --project demo - - name: sandbox list - description: List RLE sandboxes for an environment. - usage: azd ai rle sandbox list --endpoint http://localhost:5000 --project demo - - name: sandbox show - description: Show an RLE sandbox. - usage: azd ai rle sandbox show --endpoint http://localhost:5000 --project demo + - 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: Placeholder for triggering an RLE training job. + usage: azd ai rle invoke diff --git a/cli/azd/extensions/azure.ai.rle/go.mod b/cli/azd/extensions/azure.ai.rle/go.mod index 9bde226d7fd..9db2bbc90e8 100644 --- a/cli/azd/extensions/azure.ai.rle/go.mod +++ b/cli/azd/extensions/azure.ai.rle/go.mod @@ -6,6 +6,7 @@ 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 ( @@ -98,5 +99,4 @@ require ( 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 - gopkg.in/yaml.v3 v3.0.1 // indirect ) 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 index e7eb89578a1..97104a563a7 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -27,17 +27,45 @@ type rleClient struct { httpClient *http.Client } -type environmentManifest struct { - Name string `json:"name"` +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"` - Name string `json:"name"` - ProjectId string `json:"projectId,omitempty"` - AcrImagePath string `json:"acrImagePath,omitempty"` - Version string `json:"version,omitempty"` + 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 { @@ -77,6 +105,22 @@ 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 @@ -102,6 +146,9 @@ func resolveControlPlaneEndpoint(endpoint string) string { 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 } @@ -113,17 +160,77 @@ func (c *rleClient) createOrUpdateEnvironment( account string, project string, environmentId string, - manifest environmentManifest, + 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) { - _ = account - _ = environmentId path := fmt.Sprintf( "/rle/v1.0/projects/%s/environments", url.PathEscape(project), ) var result environmentResource - if err := c.do(ctx, http.MethodPost, path, manifest, &result); err != nil { + 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 } @@ -304,11 +411,20 @@ func (c *rleClient) do(ctx context.Context, method string, path string, body any return nil } -func newManifest(environmentId string, name string, image string, version string) environmentManifest { - _ = environmentId - _ = version - return environmentManifest{ +func newEnvironmentCreateRequest(environmentId string, name string, image string, version string) environmentCreateRequest { + return environmentCreateRequest{ + Id: environmentId, Name: name, - AcrImagePath: image, + 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 index bef4a03c7e2..289769336e7 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go @@ -4,7 +4,6 @@ package cmd import ( - "encoding/json" "fmt" "strings" @@ -20,6 +19,7 @@ type sharedFlags struct { type createFlags struct { sharedFlags + recipe string image string version string } @@ -30,6 +30,7 @@ func newCreateCommand() *cobra.Command { account: defaultAccountName, project: defaultProjectName, }, + recipe: defaultRecipeName, version: "1.0.0", } @@ -40,42 +41,38 @@ func newCreateCommand() *cobra.Command { 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, - newManifest(environmentId, name, flags.image, flags.version), + newEnvironmentCreateRequest(environmentId, name, image, flags.version), ) if err != nil { return serviceError(err) } - encoded, err := json.MarshalIndent(environment, "", " ") - if err != nil { - return err + if isJsonOutput(cmd) { + return printJson(cmd, environment) } - _, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) - return err + + return printEnvironmentCreated(cmd, environment, flags) }, } addSharedFlags(cmd, &flags.sharedFlags) - cmd.Flags().StringVar(&flags.image, "image", "", "Container image for the RLE environment") + 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 notImplementedError(commandName string, resourceName string) error { - return &azdext.LocalError{ - Message: fmt.Sprintf("azd ai rle %s is not implemented yet for %q.", commandName, resourceName), - Code: fmt.Sprintf("%s_not_implemented", commandName), - Category: azdext.LocalErrorCategoryCompatibility, - Suggestion: "Add the RLE service workflow for this command, then try again.", - } -} - func serviceError(err error) error { return &azdext.ServiceError{ Message: err.Error(), @@ -117,3 +114,35 @@ func slug(name string) string { } 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/deploy.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go new file mode 100644 index 00000000000..9cbad79696c --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newDeployCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "deploy", + Short: "Create or update the RLE environment", + RunE: func(cmd *cobra.Command, args []string) error { + state, err := loadRleState() + if err != nil { + return err + } + + manifest, err := loadRleManifest(rleManifestFile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err == nil { + 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(os.Getenv("RLE_PROJECT_NAME"), manifestState.Project, state.Project) + state.Endpoint = firstNonEmpty(manifestState.Endpoint, state.Endpoint) + state.Image = firstNonEmpty(manifestState.Image, state.Image) + } + + image, err := resolveRecipeImage(state.Recipe, state.Image) + if err != nil { + return err + } + environmentId := firstNonEmpty(state.EnvironmentId, slug(state.Name)) + client := newRleClient(resolveControlPlaneEndpoint(state.Endpoint)) + request := v1EnvironmentRequest{ + Name: state.Name, + AcrImagePath: image, + } + + var environment *environmentResource + created := state.EnvironmentId == "" + action := "Creating" + if !created { + action = "Updating" + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Skipping build; using existing image '%s'.\n", image); err != nil { + return err + } + 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 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 + }, + } + + return cmd +} + +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..880794728c5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -0,0 +1,66 @@ +// 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 + } + + 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", "${RLE_ACR_IMAGE}", "Image reference written to rle.yaml") + 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..9c595a44fb9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -0,0 +1,24 @@ +// 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 newInvokeCommand() *cobra.Command { + return &cobra.Command{ + Use: "invoke", + Short: "Trigger an RLE training job (placeholder)", + RunE: func(cmd *cobra.Command, args []string) error { + return &azdext.LocalError{ + Message: "azd ai rle invoke is not implemented yet.", + Code: "rle_invoke_not_implemented", + Category: azdext.LocalErrorCategoryCompatibility, + Suggestion: "Use this command later to trigger the training job for the deployed RLE environment.", + } + }, + } +} 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..617707eed3c --- /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 azd ai rle init 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/modify.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/modify.go deleted file mode 100644 index b6e07ecac3d..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/modify.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import "github.com/spf13/cobra" - -func newModifyCommand() *cobra.Command { - return &cobra.Command{ - Use: "modify ", - Short: "Modify an existing RLE resource", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return notImplementedError("modify", args[0]) - }, - } -} 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..cf4ae75dbdc --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go @@ -0,0 +1,37 @@ +// 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" + +var recipeImages = map[string]string{ + defaultRecipeName: "devrle.azurecr.io/coding_env:latest", +} + +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 explicit image with --image.", + 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..6747085ed74 --- /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 TestResolveRecipeImageUsesCodeRlDefault(t *testing.T) { + image, err := resolveRecipeImage(defaultRecipeName, "") + if err != nil { + t.Fatal(err) + } + if image != "devrle.azurecr.io/coding_env:latest" { + t.Fatalf("expected code_rl image, 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 index b606c1c641c..6e6dd231297 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -23,12 +23,17 @@ func NewRootCommand() *cobra.Command { rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) - rootCmd.AddCommand(newCreateCommand()) - rootCmd.AddCommand(newListCommand()) - rootCmd.AddCommand(newModifyCommand()) - rootCmd.AddCommand(newSandboxCommand()) - rootCmd.AddCommand(newShowCommand()) - rootCmd.AddCommand(newVersionsCommand()) + 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)) 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 index cf68de762fb..5678499bd79 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go @@ -8,9 +8,19 @@ import "testing" func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { rootCmd := NewRootCommand() - for _, commandName := range []string{"create", "modify", "version", "metadata"} { + 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 index af37775ac1e..e1b448e75b8 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go @@ -4,10 +4,12 @@ package cmd import ( + "context" "encoding/json" "errors" "fmt" "strings" + "time" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" @@ -15,10 +17,13 @@ import ( type sandboxFlags struct { sharedFlags - version string - cpu string - memory string - disk string + version string + cpu string + memory string + disk string + wait bool + timeout time.Duration + interval time.Duration } func newSandboxCommand() *cobra.Command { @@ -47,22 +52,22 @@ func newSandboxCreateCommand() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) - sandbox, err := client.createSandbox( - cmd.Context(), - flags.account, - flags.project, - args[0], - sandboxCreateRequest{ - Version: flags.version, - Cpu: flags.cpu, - Memory: flags.memory, - Disk: flags.disk, - }, - ) + 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) }, } @@ -72,6 +77,9 @@ func newSandboxCreateCommand() *cobra.Command { 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 } @@ -125,7 +133,52 @@ func newSandboxShowCommand() *cobra.Command { 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) @@ -140,6 +193,81 @@ func sandboxCreateError(err error) error { 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"` @@ -167,3 +295,19 @@ func printJson(cmd *cobra.Command, value any) error { _, 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..774d685899e --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go @@ -0,0 +1,525 @@ +// 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 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/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) +} From 1f1b1d7288dffa936be870964860ed2f44f9fce9 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 24 Jun 2026 02:07:21 +0530 Subject: [PATCH 06/11] fix: update README --- cli/azd/extensions/azure.ai.rle/README.md | 43 +++++++++++++---------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 1610e311e3e..655fcadb7db 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -4,7 +4,25 @@ The `azure.ai.rle` extension adds the `azd ai rle` command group. ## Local setup -### 1. Check out the branch +### 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: https://git-scm.com/downloads + +Verify: + +```powershell +azd version +go version +git --version +``` + +`az login` is not required for the current `init`/`deploy` flow because deploy calls the RLE control plane directly. If the control plane later requires auth, set `RLE_BEARER_TOKEN` before deploy. + +### 2. Check out the branch ```powershell git fetch origin @@ -12,7 +30,7 @@ git checkout farhannawaz/rle-cli cd cli\azd\extensions\azure.ai.rle ``` -### 2. Configure the RLE control plane +### 3. Configure the RLE control plane ```powershell $env:RLE_ENDPOINT = "https://rle-controlplane.orangeground-ba9696de.eastus2.azurecontainerapps.io" @@ -22,28 +40,17 @@ $env:RLE_ACR_IMAGE = "devrle.azurecr.io/coding_env:latest" To target a local control plane instead, set `RLE_ENDPOINT` to `http://localhost:5000`. -### 3. Install the extension into azd +### 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 -o .\registry-artifacts - -$registry = Join-Path $env:USERPROFILE ".azd\rle-registry.json" -New-Item -ItemType Directory -Force -Path (Split-Path $registry) | Out-Null -if (-not (Test-Path $registry)) { - '{"schemaVersion":"1.0","extensions":[]}' | Set-Content -Path $registry -Encoding utf8 -} - -azd x publish --registry $registry --artifacts ".\registry-artifacts\*.zip,.\registry-artifacts\*.tar.gz" -azd extension source remove rle-local 2>$null -azd extension source add -n rle-local -t file -l $registry -azd extension install azure.ai.rle --source rle-local --force ``` +`azd x build` normally builds and installs the local extension for the current user, so `azd ai rle` should be available immediately after it succeeds. + Verify: ```powershell @@ -51,7 +58,7 @@ azd ai rle --help azd ai rle version ``` -### 4. Initialize a local RLE session +### 5. Initialize a local RLE session ```powershell azd ai rle init code_rl @@ -68,7 +75,7 @@ azd ai rle deploy Deploy creates or updates the RLE environment and saves the environment id/version locally. -### 5. Training placeholder +### 6. Training placeholder ```powershell azd ai rle invoke From aaa8d4f15e3d8634ef28883d46854e4887cc1a83 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 24 Jun 2026 02:47:43 +0530 Subject: [PATCH 07/11] fix: update README --- cli/azd/extensions/azure.ai.rle/README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 655fcadb7db..7470e18a32b 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -47,9 +47,14 @@ 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` normally builds and installs the local extension for the current user, so `azd ai rle` should be available immediately after it succeeds. +`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: @@ -58,6 +63,15 @@ 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 From d621e5214612821c48a40639a7010e0240a0ac62 Mon Sep 17 00:00:00 2001 From: Sujit Kamireddy Date: Tue, 23 Jun 2026 22:51:09 -0700 Subject: [PATCH 08/11] update deploy --- .../azure.ai.rle/internal/cmd/client.go | 9 ++ .../azure.ai.rle/internal/cmd/deploy.go | 117 +++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go index 97104a563a7..bfaa25e4063 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -130,6 +131,14 @@ 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, "/"), diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go index 9cbad79696c..af4a36f3c67 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -8,11 +8,19 @@ import ( "errors" "fmt" "os" + "os/exec" + "strings" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) func newDeployCommand() *cobra.Command { + flags := struct { + registry string + skipBuild bool + }{} + cmd := &cobra.Command{ Use: "deploy", Short: "Create or update the RLE environment", @@ -42,6 +50,24 @@ func newDeployCommand() *cobra.Command { if err != nil { return err } + + // 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(state.Endpoint)) request := v1EnvironmentRequest{ @@ -55,9 +81,36 @@ func newDeployCommand() *cobra.Command { if !created { action = "Updating" } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Skipping build; using existing image '%s'.\n", image); err != nil { - return err + + // 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 } @@ -65,6 +118,20 @@ func newDeployCommand() *cobra.Command { 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) @@ -103,9 +170,55 @@ func newDeployCommand() *cobra.Command { }, } + 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"` From 9dc600d003ebf8f8c540f1d4a0edf472d3fae703 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 24 Jun 2026 22:08:48 +0530 Subject: [PATCH 09/11] fix: add support for invoke command --- cli/azd/extensions/azure.ai.rle/README.md | 73 +++- .../extensions/azure.ai.rle/extension.yaml | 4 +- .../cmd/assets/rle_sdk-0.1.3-py3-none-any.whl | Bin 0 -> 14099 bytes .../azure.ai.rle/internal/cmd/client.go | 4 - .../azure.ai.rle/internal/cmd/dependencies.go | 43 +++ .../internal/cmd/dependencies_test.go | 32 ++ .../azure.ai.rle/internal/cmd/deploy.go | 12 +- .../azure.ai.rle/internal/cmd/init.go | 7 +- .../azure.ai.rle/internal/cmd/invoke.go | 319 +++++++++++++++++- .../azure.ai.rle/internal/cmd/invoke_test.go | 136 ++++++++ .../azure.ai.rle/internal/cmd/manifest.go | 2 +- .../azure.ai.rle/internal/cmd/recipe.go | 27 +- .../azure.ai.rle/internal/cmd/recipe_test.go | 10 +- .../azure.ai.rle/internal/cmd/scaffold.go | 8 + 14 files changed, 627 insertions(+), 50 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/assets/rle_sdk-0.1.3-py3-none-any.whl create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies_test.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 7470e18a32b..65e8ddc16d1 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -10,7 +10,9 @@ Install: - Azure Developer CLI (`azd`): https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd - Go: https://go.dev/doc/install -- Git: https://git-scm.com/downloads +- 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: @@ -18,9 +20,10 @@ Verify: 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. If the control plane later requires auth, set `RLE_BEARER_TOKEN` before deploy. +`az login` is not required for the current `init`/`deploy` flow because deploy calls the RLE control plane directly. ### 2. Check out the branch @@ -33,12 +36,18 @@ cd cli\azd\extensions\azure.ai.rle ### 3. Configure the RLE control plane ```powershell -$env:RLE_ENDPOINT = "https://rle-controlplane.orangeground-ba9696de.eastus2.azurecontainerapps.io" -$env:RLE_PROJECT_NAME = "demo-3" +$env:RLE_ENDPOINT = "http://localhost:5000" $env:RLE_ACR_IMAGE = "devrle.azurecr.io/coding_env:latest" ``` -To target a local control plane instead, set `RLE_ENDPOINT` to `http://localhost:5000`. +`http://localhost:5000` is also the built-in default, so you can omit `RLE_ENDPOINT` when using a local RLE control plane. +`RLE_ACR_IMAGE` is required by deploy and is expanded from the generated `rle.yaml`. + +For `invoke`, provide the Azure AI project endpoint as a parameter: + +```powershell +az login +``` ### 4. Install the extension into azd @@ -78,21 +87,63 @@ azd extension install azure.ai.rle --source local --force azd ai rle init code_rl ``` -Init creates a local session folder named `code_rl`, including an OpenEnv-style FastAPI package, `Dockerfile`, and `rle.yaml`. +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 +azd ai rle deploy --project omi-build-demo-uae ``` -Deploy creates or updates the RLE environment and saves the environment id/version locally. +Deploy creates or updates the RLE environment and saves the project plus environment id/version locally in `.azd-rle.json`. -### 6. Training placeholder +### 6. Run Loom training ```powershell -azd ai rle invoke +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" ``` -This command is a placeholder for now. Later, it will trigger the actual training job for the deployed RLE environment. +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/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml index 25f72ab11cf..6867c640f92 100644 --- a/cli/azd/extensions/azure.ai.rle/extension.yaml +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -20,5 +20,5 @@ examples: description: Create or update the RLE environment. usage: azd ai rle deploy - name: invoke - description: Placeholder for triggering an RLE training job. - usage: azd ai rle 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/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 0000000000000000000000000000000000000000..7dc7c2c382b6ed35efc3091cfd402eb02b9c3b43 GIT binary patch literal 14099 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 literal 0 HcmV?d00001 diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go index 97104a563a7..59e8679b62c 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -382,10 +382,6 @@ func (c *rleClient) do(ctx context.Context, method string, path string, body any if body != nil { req.Header.Set("Content-Type", "application/json") } - if token := os.Getenv("RLE_BEARER_TOKEN"); token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("call RLE control plane %s: %w", c.baseUrl, err) 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 index 9cbad79696c..5caf1dfd533 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -12,7 +12,13 @@ import ( "github.com/spf13/cobra" ) +type rleDeployFlags struct { + project string +} + func newDeployCommand() *cobra.Command { + flags := &rleDeployFlags{} + cmd := &cobra.Command{ Use: "deploy", Short: "Create or update the RLE environment", @@ -33,17 +39,18 @@ func newDeployCommand() *cobra.Command { } state.Name = firstNonEmpty(manifestState.Name, state.Name) state.Account = firstNonEmpty(manifestState.Account, state.Account) - state.Project = firstNonEmpty(os.Getenv("RLE_PROJECT_NAME"), manifestState.Project, state.Project) + 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) image, err := resolveRecipeImage(state.Recipe, state.Image) if err != nil { return err } environmentId := firstNonEmpty(state.EnvironmentId, slug(state.Name)) - client := newRleClient(resolveControlPlaneEndpoint(state.Endpoint)) + client := newRleClient(resolveControlPlaneEndpoint("")) request := v1EnvironmentRequest{ Name: state.Name, AcrImagePath: image, @@ -103,6 +110,7 @@ func newDeployCommand() *cobra.Command { }, } + cmd.Flags().StringVar(&flags.project, "project", "", "RLE project name. Defaults to the project saved in .azd-rle.json.") return cmd } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go index 880794728c5..f283f8340e0 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -13,7 +13,6 @@ import ( type rleInitFlags struct { path string - image string force bool } @@ -35,7 +34,7 @@ func newInitCommand() *cobra.Command { } } - sessionDir, err := scaffoldRleSession(envName, flags.path, flags.image, flags.force) + sessionDir, err := scaffoldRleSession(envName, flags.path, "${RLE_ACR_IMAGE}", flags.force) if err != nil { return err } @@ -44,6 +43,9 @@ func newInitCommand() *cobra.Command { 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 { @@ -60,7 +62,6 @@ func newInitCommand() *cobra.Command { } cmd.Flags().StringVar(&flags.path, "path", ".", "Directory where the RLE session folder is created") - cmd.Flags().StringVar(&flags.image, "image", "${RLE_ACR_IMAGE}", "Image reference written to rle.yaml") 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 index 9c595a44fb9..560be3e45f8 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -4,21 +4,326 @@ 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 { - return &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: "Trigger an RLE training job (placeholder)", + Short: "Run the Loom RLE training recipe", RunE: func(cmd *cobra.Command, args []string) error { - return &azdext.LocalError{ - Message: "azd ai rle invoke is not implemented yet.", - Code: "rle_invoke_not_implemented", - Category: azdext.LocalErrorCategoryCompatibility, - Suggestion: "Use this command later to trigger the training job for the deployed RLE environment.", + 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/manifest.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go index 617707eed3c..31da4b7c03c 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go @@ -70,7 +70,7 @@ func expandManifestEnv(content string) (string, error) { Code: "rle_manifest_env_missing", Category: azdext.LocalErrorCategoryUser, Suggestion: fmt.Sprintf( - "Set %s, then run azd ai rle init again.", + "Set %s, then run the command again.", strings.Join(names, ", "), ), } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go index cf4ae75dbdc..ccde29a3bcd 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go @@ -9,29 +9,26 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -const defaultRecipeName = "code_rl" - -var recipeImages = map[string]string{ - defaultRecipeName: "devrle.azurecr.io/coding_env:latest", -} +const defaultRecipeName = "code_rl_with_rle" func resolveRecipeImage(recipe string, imageOverride string) (string, error) { if imageOverride != "" { return imageOverride, nil } - image, ok := recipeImages[recipe] - if !ok { + if recipe != defaultRecipeName { 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 explicit image with --image.", - defaultRecipeName, - ), + 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 + return "", &azdext.LocalError{ + Message: "RLE environment image is required.", + Code: "rle_image_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Set RLE_ACR_IMAGE, then run azd ai rle deploy again.", + } } 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 index 6747085ed74..4561ebc8650 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go @@ -5,13 +5,13 @@ package cmd import "testing" -func TestResolveRecipeImageUsesCodeRlDefault(t *testing.T) { +func TestResolveRecipeImageRequiresCodeRlImage(t *testing.T) { image, err := resolveRecipeImage(defaultRecipeName, "") - if err != nil { - t.Fatal(err) + if err == nil { + t.Fatal("expected missing code_rl image to fail") } - if image != "devrle.azurecr.io/coding_env:latest" { - t.Fatalf("expected code_rl image, got %q", image) + if image != "" { + t.Fatalf("expected no image, got %q", image) } } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go index 774d685899e..a4dbca60dae 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go @@ -23,6 +23,14 @@ func validateEnvName(name string) (string, error) { 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) From a95e826c2a009e8dcc0c90fe00ba5238fdafddd0 Mon Sep 17 00:00:00 2001 From: Sujit Kamireddy Date: Wed, 24 Jun 2026 15:43:46 -0700 Subject: [PATCH 10/11] Update init and deploy commands . deploy doesn't need init anymore --- cli/azd/extensions/azure.ai.rle/README.md | 10 +++- .../azure.ai.rle/internal/cmd/deploy.go | 58 +++++++++++++++++-- .../azure.ai.rle/internal/cmd/init.go | 5 +- .../azure.ai.rle/internal/cmd/recipe.go | 20 ++++--- .../azure.ai.rle/internal/cmd/recipe_test.go | 8 +-- 5 files changed, 82 insertions(+), 19 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 65e8ddc16d1..7168ca91a2e 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -37,11 +37,10 @@ cd cli\azd\extensions\azure.ai.rle ```powershell $env:RLE_ENDPOINT = "http://localhost:5000" -$env:RLE_ACR_IMAGE = "devrle.azurecr.io/coding_env:latest" +$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. -`RLE_ACR_IMAGE` is required by deploy and is expanded from the generated `rle.yaml`. +`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: @@ -49,6 +48,11 @@ For `invoke`, provide the Azure AI project endpoint as a parameter: 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: diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go index 37f9bb7e3d9..753cb776957 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -9,11 +9,13 @@ import ( "fmt" "os" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) type rleDeployFlags struct { project string + image string } func newDeployCommand() *cobra.Command { @@ -23,16 +25,47 @@ func newDeployCommand() *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 { - return err + 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 err == nil { + + 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 @@ -45,10 +78,24 @@ func newDeployCommand() *cobra.Command { } state.Project = firstNonEmpty(flags.project, state.Project) - image, err := resolveRecipeImage(state.Recipe, state.Image) + // 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" + } environmentId := firstNonEmpty(state.EnvironmentId, slug(state.Name)) client := newRleClient(resolveControlPlaneEndpoint("")) @@ -127,7 +174,10 @@ func newDeployCommand() *cobra.Command { }, } - cmd.Flags().StringVar(&flags.project, "project", "", "RLE project name. Defaults to the project saved in .azd-rle.json.") + 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)") return cmd } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go index f283f8340e0..2f467872ac6 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -13,6 +13,7 @@ import ( type rleInitFlags struct { path string + image string force bool } @@ -34,7 +35,7 @@ func newInitCommand() *cobra.Command { } } - sessionDir, err := scaffoldRleSession(envName, flags.path, "${RLE_ACR_IMAGE}", flags.force) + sessionDir, err := scaffoldRleSession(envName, flags.path, flags.image, flags.force) if err != nil { return err } @@ -62,6 +63,8 @@ func newInitCommand() *cobra.Command { } 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/recipe.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go index ccde29a3bcd..b38763c0032 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go @@ -11,12 +11,23 @@ import ( 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 } - if recipe != defaultRecipeName { + image, ok := recipeImages[recipe] + if !ok { return "", &azdext.LocalError{ Message: fmt.Sprintf("Unknown RLE recipe %q.", recipe), Code: "rle_unknown_recipe", @@ -25,10 +36,5 @@ func resolveRecipeImage(recipe string, imageOverride string) (string, error) { } } - return "", &azdext.LocalError{ - Message: "RLE environment image is required.", - Code: "rle_image_required", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Set RLE_ACR_IMAGE, then run azd ai rle deploy again.", - } + 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 index 4561ebc8650..3925136f1c4 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go @@ -5,13 +5,13 @@ package cmd import "testing" -func TestResolveRecipeImageRequiresCodeRlImage(t *testing.T) { +func TestResolveRecipeImageDerivesFromEnvName(t *testing.T) { image, err := resolveRecipeImage(defaultRecipeName, "") - if err == nil { - t.Fatal("expected missing code_rl image to fail") + if err != nil { + t.Fatal(err) } if image != "" { - t.Fatalf("expected no image, got %q", image) + t.Fatalf("expected empty image so it is derived from the environment name, got %q", image) } } From f3ab2d2e34e24b0b89ebdad850e2cc35aa9b5de9 Mon Sep 17 00:00:00 2001 From: Sujit Kamireddy Date: Wed, 24 Jun 2026 16:07:04 -0700 Subject: [PATCH 11/11] bring back deploy changes lost due to merge conflicts --- .../azure.ai.rle/internal/cmd/deploy.go | 107 +++++++++++++++++- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go index 753cb776957..e41dc00b9ae 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -8,14 +8,18 @@ import ( "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 + project string + image string + registry string + skipBuild bool } func newDeployCommand() *cobra.Command { @@ -97,6 +101,23 @@ func newDeployCommand() *cobra.Command { 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{ @@ -111,8 +132,41 @@ func newDeployCommand() *cobra.Command { action = "Updating" } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Skipping build; using existing image '%s'.\n", image); err != nil { - return err + // 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 { @@ -178,9 +232,54 @@ func newDeployCommand() *cobra.Command { "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"`