From bce184454c3a1eb6343a2962ac1d2786a39fb982 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Tue, 23 Jun 2026 17:21:49 +0530 Subject: [PATCH 01/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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 zcmaKT19W8Fx^0CO+w9o3ZQHifvC~1vHafO#t7ALqsAJoDecpZd|Iaz^+*>vFs8Lm8 z&aw7aYt6N5?)fRofPtd{001ZeP;y0A5MnFO01N=ghXepn|9t9XYi8hVYQtb)U}bOR zVqie;==oiBJ!YL9$@fqLE(x>=Q@=@UOV>8Q?j%1|%x&jh1__*;E}G7qDn?rQ0Y~hc zk7zfe!5p?;=O(KV1Xjw(&dvmv1^IaW_7!sqqBA3s%`P6=RK9}a=cz$$5x}h0`_%mk zx2YRWLUWTA@V*Io!!|smTXtGw2g=Wm#w1*hWRH6X+Y@Sex|D7@Dk8)Wp+d?w%WXJV z$mRpo9uA&7UL-x+yyeYdR?KzNaQ(JhR3rt7W5Ih+TW*`Qut=09e!&LqXHaz^uh9od zb1GBq=3dEQ!p$AL5>iF=l=A|HXW6yz7Q>Dl#h@m1SGz+9PHQ^=fgaLe(R|=s^ex9O z7bk6uO$EByo|2TvYvQ|<>bb-Ub-9eO5{W_Y{a-IsaMYm63Q zaWoHvJyd|#!caQ5s@_il(P;uef(>*ZR%H?iZ~bj|8mSM@;$dHE{cX2?xRWfmIJhBR zfHvoTy9AMtt09rBFq+eQoh05}!Y`h+{Uvhj313>JhX0UI`WFtnOp;8~M~GghpXaM} zQ)idf)`o76He-1$Q7svJOw5lkxL^=5SIT*LqHt_HzYw>U&Ufc)ryi$i-LCjwhYGWz{f& z!(97lkMy_dsDxiMj$3U;8dzB9@rW`%VBLSu56LCEt&uIh&(I;O-&nG_xH@}%3w;}w zO=%0UKbGTwtUo-g*Xnust}|R!Vmh2$7kBMV>;1+r^F4L5+#f{oul4Bezogr_U=bh% z0szQ@0stugydKS*oE)5-|13u}#dYaHMkL>94LD!xq6aV-8Yp_G27Nmz!sWd_&Fc8Bbw6iE-ULTXU9l>7L#20fl7KZv&MSkuR!wz@hWzmEh{g zCZu*6?%uSF2)g^#;5l(xS#MqkA{#$+G4jT z0Xt2|N5tEG1tKHSRP!PFwg|Hx+_OB*3hv6e&pxgjqa}2KzYC86zM^|<8#Uqy0RYUx z0|3bXTzFeEBWJTeb+6LUaad*8im$tAFmjCmX1%3_ouu+53oN77XNMeAsT0aO4>PFAq;0~qRw`Hp z=sd_4Vbm2wt!QbS{Xp;gBa2+ZS*Bo6mO}gF21j@)?G%bMxr-8!CPu?!7sW`1q7pL} z)fTPGRcT&SrYJ9s zp)JkyN4lYL5SP^HGU#c`;e}t;HsFC-Wz_1N81+|C!3w`}WAZ#i5NwKe5~0;(y_fiJ z#sa4@N|f-1mG;Ww%o@JyS!SEje>u!aE~=k^v6N8aR4Gnrt{BsTDd}=rEQXC=XVQP5C_@u7x$dSH{iHnU7!-u6|wUvOV@!Rrz}5MZNHZ`3PYR5e}B^Z>dX_ z6-P^V9{e&Va=lo0e@-5rApEWOiy4TCqH^6GZL1``JAu0k$X$_PY#7l?s%yNcEIF`( z+&-(T)2w0`ww}XBZMw1FE@PWrzK_k@R)XYa21*zd)1sqnS8Qd9Ul$_uU7+2^3IisN z`C6pj;9^_lYKGS%yjoIMhd&%$Je}cf>TJ?hl9?FC6cT-sWo8K91Ziw8EWeEzP4N-> zjke8oIv2sv%TcUg34Jx1`vqt>>T$pJX2*H_=}vO!&`K$zNEyR_M-P0D?8-!xmDC@c zZ58Nj@4SHTSKHDHOflf{uur`2?qkQ-v{K=%FE2MV3&M!$tuuGCXv6*5W?~mIRbhj{ zJnBQPw^y-_MlS9FHkVG>4_hJHJecqe-aoMQp*Kp~>Qq4`t27)`a6>KYxwbIFbK3B3(tUxZr`oYSy_mI64_UZYKr`WBe$opWqF@n$yKe( zp`K?A7L>9}_|Yfm{9(hpL!jbIqA%yWkjd%x73b8sM!?Tv@CHF$#4UR}3J{4F_O;3S zTKiuM1AegQ2u&KA&T+>nH1Ki=SaWMGOC4wI@E%-CDf{WTr)(Y+u2btinWHbz(0<|v z%P`s2X%$18_p;RHh!6)p)psXp;lgRdCXe*7WDg_RvRKbKK0wazSs^t+I&k-74}*tx zQhr})##Q{8xgNP3IF@o??P)`*26E-B=__5=&3D!^%>&2#&qff^FHi}udN!YQm~0$L zjIKt_d<|XUq8~~)!v^Jszw;}r?ttg>sI_3At`awr75sEtWrzj#vK@JT&g?f?J zAJRDFkUqOxq({rRbqHkkO$cl!EBcFT-#=tg+juWCz|2z5QORc$L7F85B1$ zxhR2n{y*RP{I$=lBg+}nKWQIIz4nTwNAaBkFuWaSULPBKlwvirpWol?iV>G?k=IjB z+W^vAhI&bNXEH=DxPR;PKQ>5SE@^@rZ~(yXk52!~25IMDYG(V74YEo@#^Dz`QunC_ z+(IO{t!d7_eH^Hpv!x=iZdL~A%!zDFCQYWCbiZl)rN=#FA*dd|L+&&vmFLGH2ZMb^ z@v`r@4dXAjIjuLBEG5a6`|St3ZlCsV6}MXvq-~qd<2zrXW`7#!HS6k?@^A*kz6&wY zw1eS&-H&MXIUrK*o|4IP z5Ug$mjVPwXTm6OYZA3OYI0v^Z{Z+ z`D}qiZ1(%^5L?`7sa4;}?sipe`vLAww5A1Q4pEUNNAg8d0d5{1ij-A?ZA(MXI-M!p z)e1JbQLfC@l9ORMx0FyNytO{uzurOzdpga_HTre2!K(x_)MG+S9KH5uJH;A2tuD!9npfyw)m z+9a?%iuF>{RYpn-H5B8v3st$b`bRlt1yKx^JXdkk$)-#B z-2qm_Re_7WM_z&6d(UF`G@O`8wc{ywOzEVfp;D~ZpF1$9??aES$KzVw5BKN3Kiv%E_`eY&pX<>srM|w9NtSt|+E0@RtWO2X=_^W0kQjY}v?I)@LlGN4uduj|2cX=C;N4eSF>k{U zDV}US1_sIrC{)4mcFd-8jaccBu^e$SRYKBnt^FY%TZb`}baUxZwLIqTVY+nLY1|BD zcExrI``aR-(Yrt^3CH|Y3)+n5IxO5wW(`6?etKt`&q9eewn^NO5*n}knfL+Ll$l?N zt=nNH6Q(W2%KXSt+8Ch%Er}Vpl!y!J)@H{s8|6WGA470F;`KiF(k_SP^{%1@S^d@N zx(n;D8isbsaSTtjr1YBu(FQmjELG#3s%QGqr+lo>fP-BmtUSE;gyXi8NJ^V{)qxsLeKTCAoAp&VPvxXX&J~h zp9<@8n5zgVh~OskBCpwIwMO+bn3bMB!kmD3r`75sly%$r(UbpqH@TUHirgfAm*B6= z-SBYl||!arTf8d*8~QO7QwQ~O`+7#{)(LX^0o zyHk|*?Hh!st(wbUJC2LL8g@Wnh~?&y#Yosh7fR!-tC)QA4fE~yO?XFOK%uhok(x({ z6D5vCKt!D0=ONwo+%#$$%_p@`$_T48kL)!4L3dk@!NlY1J|XIsmE_qhd>C95sA1W& zQrpGnQcJqIZo;o{W2T2W4@;mI?tvF2emGueTv14STW{Pzxv$2SnOX)_@k9++-c33F zp;u`GdGq{Z>#8t&y5x-*9o4NtHlIv~)OF~j2(;z38;S>VvMy@9~n@IE2rU2JIQvaVUlvJ!BQ_}uzF&i)nIv%^9#{}sUU26f?9lP zVR+JKN+GWLetS4%MkaOu`;-{ln9~(XPj#YlZK3%oEqXFqHq}m3L|>}JeMX6)q;uR0 zN^#491}J#?T}o#RVuBiPB*TqP=ROS;fs2v|bfu)Hc*bF&OFg)c&S3#8@;*(#b9Ob0^0g0t-ZV2t z>S$ALGMG+4vI)aA+&EQ+ggz|qt&F_!nCn7NPpRgFO?3#|ic*F~;e5MVaO^eFQz4n=xN+ygOFI9oiBO+|l#G=%T}W${41M~fc>(f3jU-Hw z+>DV~iRP+-Av~c(_F=TRvX;@G8F8u{oYxFSi?$TJES0TR5O1ZTZlV9Fx1a7Qe>*NC zJJNZO{9DHXCZpJ%Z-O25fjoTMQQ#)fi~OuGm&v0X0vgVavZMxAB#&4AF}^@4=vZ%G zQE?uYkhkDIy_ThNrL1(xs){N)?}R9Ux^rjWT95JrbtRIL1i3$gXx>sZF;E9TZ51Ll z-;ZMbjd9`$<^(5Je0pW|CBIw-DNf}I$4^tgkMf>cu0UB3uX$Sv23;ibXaL778IAlm zL~H^Me`~N}7#N(oJmh^8em9qKq$<>kY!f{uuk~)A$?D(@;o%|S%JS17 zP^2TAJ6N?$tAn1sWk5EQCLhJ86=cX(zke^Tty2VrxRVo|<4iG>KSC^m03JaX-=~I$ zkC&Gd&WDG`%k=I%i{(ga%7K|@QuPj{*-#CW9*oE4pbOKyMMn9(alB{@hAifpYc1*v z!H_Z0@)T>u;_dZgB~vt0rr!V(-F*7*L(ju~93~SGcQB$ixVndOV=E`JANyaj9#aR^ zj0IyvZ2J%jY87X$lAP3Dc^_qkM-|(s7aPkt$~(}$TF-F z_HB5grThj#pCyVpGhpr~)j`WF35bv*o?E5Nq^jV5gJ}^O2DHji!waygLdO)QXBkl; z#SlHa_Dg*|c?ZC|B48FF4NYF9$JJ$`JNo9vGXtFi#hR_uYt+&@q>v!027hdsl1f#2 z#CMXKi=E3N;$Wp>S-lgXe3wdDFgoIRPzrZpvf4}nnKooHTW?+Fx7NvKOTWTY^b599 z!m}Fvq2?EshA9OlD2Xy2Ae4J!<@mH?_r!wtc|Q7@(kZVF%E?q|F3G_~5&C2;u;Ku1 zn8UQ9HE{AKH=tk{9P3MT@+NJHC{=#XO=3H9YFm8u%{rn@pn27zbBouvBI)u#LM zP1vGVKb_n2#N}1;oNR@>0D-=26BQgssX;;gH_(}Mx!5dsG-70ld}SSzfRTwL=cd~> zp`666&x!e_IK{q4G;}czz5T(>*3%rUNnWDXeYIRL7$f1W0?yZ+yS>oe0_Rf|y5Q?8 zU?Jlf9aEZtq6_PBjCcrr8r#|mPRwul+(W0_k_)$ogWqmEOd*0<>)&Qw(L9~sHg*&) zr#2Rt5u}CtzF&5o!I%w^jHRDv$}DjL!!izI!tgmj&05M>%$Bn(PwXRQE}qbd{9k=B z%goNEDAZps6pGH>`4jCfC}K_netB+*oxrfKbib;QXx7+0p^o!a#a606HYf&VGBQ}> z>`z@ID69joQvEVo-@~4d2DY-?&<|#d2)Mh@HfJS1UA$x!C2BlvshaU;GhHN`Y;o^i z8#sx1lNRT1<%se~=R`9^>&yM578{o1e=|miDhUX-HSIvpF|Rp_h_Eub;*9;#|@;^9nDncH{T`_%@3f8YYDu6jih)mahf4 zMsAj`OhIsPqCHSE5||@$VwNjSzB}!*??(GP-yDB`8w-yXpy2y;c;%d`Zg0U>c>p7{RKCuKV%*x{agGH+Ofe9Y(`; zmJf+)V}B*j6-ihPdr9TSYxX;(?d5oym@xk%S(oFs}9hf^2;JMMwL^4=xO%!yO&N*L|E_hLB+A#PtM z_?pncM%x?CLw@g)`&(O`8R!Wk$h}zU`Z_C5Vda#dfMuxn$-Ws3sBW@LY<>saE!0gf zzB);+{b#iVc!^GLH?@bf&xrGLY^~wMVWzlsbl>D|=nV`6ac7)5=EO}>Vubgwu7EZeU< zi+4)1evFXgy?TD>VX!hGzh8)c8^Wf*3%6~p8d+A=aJ_y64L|%+UckqT4v4y_J2xS) zKRcorSL*`AuxjkG>c$IXfAYFx_tqpBO_D1wE$o`?gFh~Gj&5CE@oT8Z&u)m8GELgj zuxbbf6T#4Bm&Re0aP{I;7iRnOy_x;uUL)c44RtYh<*+j>ds49jrFSYZz2W8squPd~ z2dkvomVnxJuwel)<^8&qHX~E~oAc2QPAA|`?ovyKqDC0JvE9b3qj=FxSr~RBeIE96 z6?O(x7S#^6QdB7(bwS|Bj+}D4rS$o{GBc}&k?GO3tJH$~gpm`30zX7_XI_Q+NIkO7LD9v4%mR)Q&KUCl&36ERQz@GVP6U?++X0G%} z^PF^jnGq3_90Voo%yq&wXW;Uty(aW8x`k)>3BBJjUfaX~@LFEAX_AeRli+9KR=5R# z|HhU>?E5~{?zi_eRAU1P88#yfBex<^mJ!s>A^GQaz}X1q*|IB&9#i5}6|RlqNO_kY zrl66z*A2=%`fq14vwRzolpuDMuPu*K-qblGnT$>J6Ck|wV$C5XCLx%*=j+P;_7J)e zX!Rn_QRuuZk1wjUW?P9xNSldb3N)f%6E_(pdbAG|#7*?Vw@Gr|IoLUE6havM4VU%r zLPxewmdDUXmF{VG9kc1|*lXsqrqh>PY4ywNZv@{jNjbt#qf!Cu&oH~uY$K;YvZlH# z)6&N`zgi2SL;*q+?t@2CNy?IOpC)3o!F$%91nF&B5+EZW8t++Q^)hP+jAv-mE9v;~ z8%GjUmEKUvy2sxzYMmo`rMiL~P}i07`t8Lz3SBB^_5-0^JTVd5ST>quwnpb}U^2%> zbR94>TWq#V%h@^ull9flL1vK{cW#=$IWsIBwx77H+{N${fbg~NB!CYOWR-A&?QR>b zhZ3EX>#3ln!S;0VBp2@zfh-`PL?as42xPZsFC(iFVgfaRzhQ72@ySG1|KtNRN(Q)<_==orA{n94Q}gJ0CFlbWL4Ed#&_H@Z@+C) z3GCc0k{3A(8c4C_>2%1uwsw&qx4=Ul+1{Gq+}GC}`#2Uj^Q zr31FVh45XTVCks(*6dVuRk_)>+)nMm+P%je)VF%gH#`9G*ShLda;$mr01uJfBgiRz zu^PDT+>iO@FbDeLExTcQ_w=7x(eWR}!M=B8@_yd-+uGlnx!aMPbHAsh(J%t1CX>Tw z7Xm((cPJgA=G9#Y;YMD^&B?+@b5jBaEW;^hs$ylnO)1s9Z|-LEuuao-42Vv8F3Khc zxi2}c<6p)3Mf~o~-;|@-NFe8wom+ol(v->6(dlq`3`Aft$t|bN#2H9#FNMTT|HP39 z@pPK{RIxOCUNaT!r(8{FQrhKh|II*fiK!4Bt62A&Z3;`+q%rPc*}i2+X5}>&;*ILWKg+z zyo^B7iKMz|g+f<5A3~bgyrg0kvmlGx+i1XIq|9Y{SclNn+w)c*UykGaU zP*yg(hpo>}<~B#0j^Mnb=yH*}MF)Jest<9d_AK zJ_T|DXN;Aonvz}C3B2e#TN^XG`fWkaWv<2n7i#9*CVK@AZBQ0i)>KcgV#Yv-?i0Sh?ZO#TBSmL4n_Nd3n#Bh?h*Ys zj^AZ&h2oX%?pRwM0Uw#a*u1Jv9HH4x6(Yy@{nnJ`|lFj$%P zPstERS!BG-^ol2ly)!4kE}Fyr=1itJot29ZtHkG<6wnRSV=_vOreqN9n#>3SGgel$ zwQq&55pyQrdR9uhh2KNR!|OB@(ADcQVLNcJQED)sBob&Y7wwvIQ=xmvI2^5SHDQ`w zt(P96UaylDJmR8pN?~WyDMA;$3MA{?nP=W@130EkenV0a+f)_%x$r8DwAh5l6owfY zX_p-|HWc{mF}(Ctx|%S|Avk8&80Ynk<32-k3$2jAmMWJ|*jVbkPC&9z6UcXgp&_qHR!K$-ANuj)QGOo5SInO z)|?QN<$+E2MZ!+R9eEoP4o^|{*4ZjIm3$H+SV{7DU7x{eaRySaLxNWQ^o82^!+QSO743Us zYp?<}8*>~O+fL#5m$Q=b+ZL50S?M^Rbm$a-j>nhyAA@z2c}G9&qOMIg>32L6e@Yb2 zef@drMJ=X9>mT|N*DtvnlW<sI#oIw8Q;fOImU82(c`$=_#(gp3$jWB5mT- z5%TK-`5PRxkR{%G(3;B)KzYBg$$h73@%hp{(`w_aR%RM%OkI|rS5@ixO0nXj1JpdQ zx0fo~@IU_K{scJ?sFd@wv)qSm0a8^Da7d#%ivjC1K7xC68UIv*2V zm{IHAQI!g7c6DV#l<@p@J4&5dbs!xT>ly2FbN$;FKe|rr+a=<0n9IH_6=FBm?hI@U znFtNt<4ivd=rpsr4_U)?6Rd@VFiyWOsWHEeGdThh2GX|)Rl|tG6GXQkQ?ctw4lu{E zX0B{WGVX1WEyU<3tHWhk`=QUW=Hn5W-$bpl*_9ToJn8Be3*U=O`AqiW*Hc3`3NT(P z$4(SkXbK`jg|$SNzzWft6vWO%Z3+I&_L^lwB!R*O$8+%2&rO^k{~ z7RU2^_@0IxejWm_;0z^%deCU|V+|PT11$SsvCdqq61ELjXlAsx{)Q^(e3*!SR&*RI zG8Bd*TrhPO1;*)ogr>}bA6t*x6Z!6#hXPrC-ez)(qFByefiOXv#6i!@-MAc*G$I%i z+H|!kBqWGz49=77Je=D@_-=4Gg59s66*h^!&MzCIuMHJP>$fc$n1>d#9Sw;sB|>42 zDn&(?`1j%UQG}LN2K1U0#Q^IZZ_)=eoVa%lz7)M7@}&(9`qqLSfScbXSX0tQx^dT* zfZBySh2C%E~8Bp4c-9Dy1ud){0+C8>9h zfqMNww2`2!7Xo_`i^!zJV>G%mp3uU=#;?39XR~-?XySc{Xl(Ok6X1h-K@(hu3&_`} z8|ESzmCAg?iOU_lOI!?Z_gB+n`rz0UbC~%eFK*Lv7+qSis;b8eS;}v(S!b?nf`V>t zq!*@cq>03Clo!TY<1wPh`%aXPH?C_B44m>w-jmg0%i(1O%g7p`2vTdY&@}4GU+lLm zgg0I56yISxR`U})HUKuTuLRBy=B`oH{W|%&OW%%#OZEHXa1I#i)>JD6#Zfsc%J-?+ z7HMq?Ns{}v6-Ol8Ua(R}oJ!ga+pIXisMeDzWhKg8hPDZ-wRAhOT^-djy*6_*Ae_`z5 z>FE^KTC8J>kA&>gpLf``LD3!!UR!f3&8#{U(iAcBs|4ra&L*1#??xv}nrRblrOHJR z%l_%t%a5->-VW-Tl`lwfuS8quO1hp}O)PY}W@N1rp`TBcELZGVe0zxa^Vne@JF)H3 z!^aS&=q!!BGjO_FqoS;t;zMBdfWz}^W`+0>g?bp4w8%V9+~BFY{HMyAluP+e1#|MN zlIL{)?xM(Khzrex-_M;F)yVpNeRo>A2LVs0?BBZedKRGjpPI@?pt$>fV`<$2WIFYY zJXoe5Fkm4+oj%oTy-lHj#RZ%?_P87Kp~8D&H5vl6AgW0vLC_-K5;NU5n=XeRpEQOE z;Xq@C8c+Rm?z%`e&C|j(&p3lVF)8L}Z)hok&@5PM5@Bu*?#}K`(6~YK$z^{fYyR<# zp51vXs#1`dk@gCzgj=x;Y8~1&CvZr=LnnRMoIGF4uC3fLA3M#_zBIYLLs%;GGWU*yp8$SXd8HU(|H=hg6{VOGH{GC3}~~WwV(__8P1MpDusJ(SK>Be`g36& zwhsRKes~hziy|ABu#@(w0QY9v8LZ68inWOqmGaBu&Xx3Ew{tg)`Q;(7GsjuvH}^i3 z6~S#ucv1K$KoOS&b7?F{lJF5ubhu1bLWL;;D;0d^PxQ{jJk~T`!~oSHB3nie6Vj{W zAHMAM5Xy_C15bDLZUd)HO~+H#o^#btH}QU}KBgaWZmv}%gPU;_I25yqPy0J*Orxo6 z1tf$%KwG&(Cz2Q?11-SFviF?60!&7iY>Ym+3;ibwm(zPaLYIWw$XG5xz#P>SCEy5$ zkWv1G>jY$jAT%+8n}3hV;2c@2+nnTTBqS;Us!;k0Fs;N*Gnp!bK*Zg-8}bmSH`f)% zzVE_En{Uyxo3h!fLIg=l)FEsg@}&vraB9I)3U4%8W>owhTg??UPxo@1`Rv{JOq{6{ zP5*Ub2rivEofigjtE1XzP2ZcjG_fAs{nwAt#|8@5531O)j<;jUtQZ2a1X!9cRCtab zFDHWrtqnvSCLP8yDmqa&mZHrV=wDBw2(AlJZptCw8o0WF4LZ_f)~{$89}hoZO5RC! zFyuuhp~-8-79l7-yP4fgirux$XNrATsm_L}@*xo)UtT~*eh!FiAA#N%tVc}g8l5%) z)+eJ{)WoXCxVlTAP2?nkYz6YlxyiXf4pv>NAf_{1C04qFU3e97J7FUAChSEAq`dht z8v0Cjt9P4Ld|1uD4>e>KF^i7Sg!tDeov_PHMvQ^YECvV@-VSJIH2J9(3F%n1b*AN? zDzWEmxGopf=9~7bZ+5B73H(CULbPjN{7PJMUPfiteP%CsIoYs+*Y#TdnZ1K)OBc?bQ5TO9@CLej%eXO*(q#vTZaT~k@g+l2H0 zj*N$#F$QdtqN!d7vvY$t~U5b!w8nQf`V4nMt;oS@GG)<`qQQ_UicI=#3@gK0|;$%sCF6Bb# zA}%#pWF67BBVwaIo8_ub-`zuW5d z62z5;F6W)-Hnp|Mc7nM|wd$40?YQ=cq?9jgLJ_VH^%Wz}L*FC+@bjoazBpgcR4R+I z9aT}qT5L;Y2VmaM(5*V?C9a+bFS>=2Z0LG4zki4ZN&2Uu^OpI3AxP`QD~{}TD3trq z`&clp7t2+6>eL&TQJ?MnoXeO4=^r(!s;v4 z1UpC&oin0u2n6#;7I#-j5)aG+2W7y$a8jpIkgw{)-V=^D8FAj(f{X>(W<#nWD!ku= z1_9JDt($LQ4%m3F#1~XX&E`P{B*~Rp(_WR+8?G|mCQB$3HxQ3oWcGbZZyFj&_n7!< zD)%)GAQeD8U}Qh$0q|dIK_Pn(q5G!>I)4uQ|D_fhl44@A5PeKAe+mIHqgOJsg1{`* z4sMX^LPJK{0GmT@SomdJ#D43s8Nal?f88j3 z1P|23SM@O5&C^EE_HwTbtY7gsF6C%1(}&OPSGafgV7MWu?D+ne=fao1j~0>M zRu|BZ+KlkEQs|KExJ ze}@03XzAbZx<8BOFZf?U(|^YQr{LA!`1wEfR)u|-zooH mfBu`YF@S~ne;(a`sga@##Gght008FCv*C|^Nwfar-Twh9e?pZ2 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/25] 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/25] 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"` From 14de10124c4290027c37b57c90e5e3dfa76868ad Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Mon, 29 Jun 2026 20:38:53 +0530 Subject: [PATCH 12/25] fix: v1 draft --- cli/azd/extensions/azure.ai.rle/CHANGELOG.md | 2 +- cli/azd/extensions/azure.ai.rle/README.md | 194 +++-- .../extensions/azure.ai.rle/extension.yaml | 11 +- .../cmd/assets/rle_sdk-0.1.3-py3-none-any.whl | Bin 14099 -> 0 bytes .../azure.ai.rle/internal/cmd/client.go | 304 +------- .../azure.ai.rle/internal/cmd/create.go | 148 ---- .../azure.ai.rle/internal/cmd/dependencies.go | 43 -- .../internal/cmd/dependencies_test.go | 32 - .../azure.ai.rle/internal/cmd/deploy.go | 169 +--- .../azure.ai.rle/internal/cmd/init.go | 115 ++- .../azure.ai.rle/internal/cmd/invoke.go | 721 ++++++++++++------ .../azure.ai.rle/internal/cmd/invoke_test.go | 217 +++--- .../azure.ai.rle/internal/cmd/list.go | 41 - .../azure.ai.rle/internal/cmd/manifest.go | 188 ++++- .../internal/cmd/manifest_test.go | 173 ++++- .../azure.ai.rle/internal/cmd/recipe.go | 40 - .../azure.ai.rle/internal/cmd/recipe_test.go | 32 - .../azure.ai.rle/internal/cmd/root.go | 1 + .../azure.ai.rle/internal/cmd/root_test.go | 152 +++- .../azure.ai.rle/internal/cmd/sandbox.go | 313 -------- .../azure.ai.rle/internal/cmd/sandbox_test.go | 33 - .../azure.ai.rle/internal/cmd/scaffold.go | 150 ++-- .../azure.ai.rle/internal/cmd/show.go | 41 - .../azure.ai.rle/internal/cmd/state.go | 25 +- .../azure.ai.rle/internal/cmd/versions.go | 41 - 25 files changed, 1451 insertions(+), 1735 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/assets/rle_sdk-0.1.3-py3-none-any.whl delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/create.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies_test.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/list.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox_test.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/show.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/versions.go diff --git a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md index befabd392e7..37fa1cafe66 100644 --- a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md @@ -2,4 +2,4 @@ ## 0.1.0-preview -- Initial preview scaffold for the RLE extension with `create`, `modify`, and `version` commands. \ No newline at end of file +- Initial preview scaffold for the RLE extension with `init`, `run`, `invoke`, `deploy`, 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 index 7168ca91a2e..75e14ae033e 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -1,61 +1,43 @@ # Azure AI RLE extension for azd + -The `azure.ai.rle` extension adds the `azd ai rle` command group. +Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: initialize a local session, run the environment container locally, invoke it through a shell, and register it with the RLE control plane. -## Local setup - -### 1. Install prerequisites +## Prerequisites Install: - Azure Developer CLI (`azd`): https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd -- Go: https://go.dev/doc/install -- Git, for local development and for fetching the managed Loom recipe: https://git-scm.com/downloads -- Python: https://www.python.org/downloads/ -- uv, for running the Loom training recipe: https://docs.astral.sh/uv/getting-started/installation/ +- Azure CLI (`az`): https://learn.microsoft.com/cli/azure/install-azure-cli +- Docker Desktop: https://www.docker.com/products/docker-desktop/ +- Go, if installing from this local source checkout: https://go.dev/doc/install +- Git, if installing from this local source checkout: https://git-scm.com/downloads Verify: ```powershell azd version +az version +docker version go version git --version -python --version -``` - -`az login` is not required for the current `init`/`deploy` flow because deploy calls the RLE control plane directly. - -### 2. Check out the branch - -```powershell -git fetch origin -git checkout farhannawaz/rle-cli -cd cli\azd\extensions\azure.ai.rle ``` -### 3. Configure the RLE control plane +Sign in before pulling private ACR images or deploying: ```powershell -$env:RLE_ENDPOINT = "http://localhost:5000" -$env:RLE_PROJECT_NAME = "demo-3" +az login ``` -`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: +If the local or environment image is in a private ACR, also sign in to that registry: ```powershell -az login +az acr login --name ``` -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. +## Install the extension from this checkout -### 4. Install the extension into azd - -Run these commands from the extension directory: +From `cli\azd\extensions\azure.ai.rle`: ```powershell azd extension install microsoft.azd.extensions @@ -65,10 +47,6 @@ azd x publish azd extension install azure.ai.rle --source local --force ``` -`azd x build` builds the extension artifacts. `azd x pack` and `azd x publish` -register them in the local azd extension source, and `azd extension install` -makes the `azd ai rle` command group available in any terminal. - Verify: ```powershell @@ -76,7 +54,7 @@ azd ai rle --help azd ai rle version ``` -After making local code changes, rebuild and update the installed extension with: +After code changes, rerun: ```powershell azd x build @@ -85,69 +63,121 @@ azd x publish azd extension install azure.ai.rle --source local --force ``` -### 5. Initialize a local RLE session +## Configure the RLE control plane + +The extension defaults to `http://localhost:5000`. To target another control plane: + +```powershell +$env:RLE_ENDPOINT = "https://" +``` + +## Quickstart + +### 1. Initialize an environment session + +Default echo session: + +```powershell +azd ai rle init +cd .\echo_env +``` + +The default echo session is driven by an embedded manifest. The generated `rle.yaml` includes: + +```yaml +name: echo_env +template: + name: echo_env + kind: openenv + environment: + image: devrle.azurecr.io/echo-rl:latest +``` + +To initialize from an existing manifest instead: ```powershell -azd ai rle init code_rl +azd ai rle init -m .\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`. +`-m` accepts a local path or HTTPS/GitHub blob URL. The session folder name is always inferred from `name` or `template.name` in the manifest. -Deploy from the session folder: +### 2. Run locally ```powershell -cd .\code_rl -azd ai rle deploy --project omi-build-demo-uae +azd ai rle run ``` -Deploy creates or updates the RLE environment and saves the project plus environment id/version locally in `.azd-rle.json`. +`run` starts a Docker container and waits for `/health`. It uses `template.local.image` when present; otherwise it uses `template.environment.image`. The command does not build an image locally. -### 6. Run Loom training +Use a custom host port: ```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" +azd ai rle run --port 9000 ``` -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. +The selected port is saved in `.azd-rle.json` and reused by local invoke. -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. +### 3. Invoke locally -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`. +```powershell +azd ai rle invoke --local +``` -The default invoke settings are: +Inside the shell: ```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: +rle> health +rle> reset {"seed":0} +rle> step {"message":"hello"} +rle> state +rle> exit +``` + +Supported shell commands: + +| Command | Calls | +|---|---| +| `health` | `GET /health` | +| `reset [json]` | `POST /reset` | +| `step ` | `POST /step` with `{ "action": }` | +| `state` | `GET /state` | +| `metadata` | `GET /metadata` | +| `schema` | `GET /schema` | +| `exit` / `quit` | Exit shell | + +Per-request timeout: ```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" +azd ai rle invoke --local --timeout 60 ``` + +### 4. Deploy/register + +```powershell +azd ai rle deploy --project-id +``` + +Deploy registers `template.environment.image` from `rle.yaml` with the RLE control plane and saves the project/environment details in `.azd-rle.json`. + +## Manifest shape + +```yaml +name: code_rl +description: Code RL OpenEnv environment. +template: + name: code_rl + kind: openenv + local: + image: devrle.azurecr.io/code-rl:latest + environment: + image: devrle.azurecr.io/code-rl:prod +``` + +`template.local.image` is optional. If omitted, local run/invoke use `template.environment.image`. + +## Current limitations + +- `run` is local only and requires Docker. +- `invoke` supports local shell mode only (`--local`). +- `deploy` requires `--project-id`; project selection/creation is not part of `init`. +- The extension does not build or push images. Images must already exist and be accessible. diff --git a/cli/azd/extensions/azure.ai.rle/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml index 6867c640f92..ce5e9b292ab 100644 --- a/cli/azd/extensions/azure.ai.rle/extension.yaml +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -15,10 +15,13 @@ version: 0.1.0-preview examples: - name: init description: Scaffold a local RLE environment. - usage: azd ai rle init code_rl + usage: azd ai rle init - name: deploy description: Create or update the RLE environment. - usage: azd ai rle deploy + usage: azd ai rle deploy --project-id + - name: run + description: Pull and run the local RLE environment container. + usage: azd ai rle run - name: invoke - description: Run the Loom RLE training recipe. - usage: azd ai rle invoke --recipe code_rl_with_rle + description: Open an OpenEnv runtime shell. + usage: azd ai rle invoke --local 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 deleted file mode 100644 index 7dc7c2c382b6ed35efc3091cfd402eb02b9c3b43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14099 zcmaKT19W8Fx^0CO+w9o3ZQHifvC~1vHafO#t7ALqsAJoDecpZd|Iaz^+*>vFs8Lm8 z&aw7aYt6N5?)fRofPtd{001ZeP;y0A5MnFO01N=ghXepn|9t9XYi8hVYQtb)U}bOR zVqie;==oiBJ!YL9$@fqLE(x>=Q@=@UOV>8Q?j%1|%x&jh1__*;E}G7qDn?rQ0Y~hc zk7zfe!5p?;=O(KV1Xjw(&dvmv1^IaW_7!sqqBA3s%`P6=RK9}a=cz$$5x}h0`_%mk zx2YRWLUWTA@V*Io!!|smTXtGw2g=Wm#w1*hWRH6X+Y@Sex|D7@Dk8)Wp+d?w%WXJV z$mRpo9uA&7UL-x+yyeYdR?KzNaQ(JhR3rt7W5Ih+TW*`Qut=09e!&LqXHaz^uh9od zb1GBq=3dEQ!p$AL5>iF=l=A|HXW6yz7Q>Dl#h@m1SGz+9PHQ^=fgaLe(R|=s^ex9O z7bk6uO$EByo|2TvYvQ|<>bb-Ub-9eO5{W_Y{a-IsaMYm63Q zaWoHvJyd|#!caQ5s@_il(P;uef(>*ZR%H?iZ~bj|8mSM@;$dHE{cX2?xRWfmIJhBR zfHvoTy9AMtt09rBFq+eQoh05}!Y`h+{Uvhj313>JhX0UI`WFtnOp;8~M~GghpXaM} zQ)idf)`o76He-1$Q7svJOw5lkxL^=5SIT*LqHt_HzYw>U&Ufc)ryi$i-LCjwhYGWz{f& z!(97lkMy_dsDxiMj$3U;8dzB9@rW`%VBLSu56LCEt&uIh&(I;O-&nG_xH@}%3w;}w zO=%0UKbGTwtUo-g*Xnust}|R!Vmh2$7kBMV>;1+r^F4L5+#f{oul4Bezogr_U=bh% z0szQ@0stugydKS*oE)5-|13u}#dYaHMkL>94LD!xq6aV-8Yp_G27Nmz!sWd_&Fc8Bbw6iE-ULTXU9l>7L#20fl7KZv&MSkuR!wz@hWzmEh{g zCZu*6?%uSF2)g^#;5l(xS#MqkA{#$+G4jT z0Xt2|N5tEG1tKHSRP!PFwg|Hx+_OB*3hv6e&pxgjqa}2KzYC86zM^|<8#Uqy0RYUx z0|3bXTzFeEBWJTeb+6LUaad*8im$tAFmjCmX1%3_ouu+53oN77XNMeAsT0aO4>PFAq;0~qRw`Hp z=sd_4Vbm2wt!QbS{Xp;gBa2+ZS*Bo6mO}gF21j@)?G%bMxr-8!CPu?!7sW`1q7pL} z)fTPGRcT&SrYJ9s zp)JkyN4lYL5SP^HGU#c`;e}t;HsFC-Wz_1N81+|C!3w`}WAZ#i5NwKe5~0;(y_fiJ z#sa4@N|f-1mG;Ww%o@JyS!SEje>u!aE~=k^v6N8aR4Gnrt{BsTDd}=rEQXC=XVQP5C_@u7x$dSH{iHnU7!-u6|wUvOV@!Rrz}5MZNHZ`3PYR5e}B^Z>dX_ z6-P^V9{e&Va=lo0e@-5rApEWOiy4TCqH^6GZL1``JAu0k$X$_PY#7l?s%yNcEIF`( z+&-(T)2w0`ww}XBZMw1FE@PWrzK_k@R)XYa21*zd)1sqnS8Qd9Ul$_uU7+2^3IisN z`C6pj;9^_lYKGS%yjoIMhd&%$Je}cf>TJ?hl9?FC6cT-sWo8K91Ziw8EWeEzP4N-> zjke8oIv2sv%TcUg34Jx1`vqt>>T$pJX2*H_=}vO!&`K$zNEyR_M-P0D?8-!xmDC@c zZ58Nj@4SHTSKHDHOflf{uur`2?qkQ-v{K=%FE2MV3&M!$tuuGCXv6*5W?~mIRbhj{ zJnBQPw^y-_MlS9FHkVG>4_hJHJecqe-aoMQp*Kp~>Qq4`t27)`a6>KYxwbIFbK3B3(tUxZr`oYSy_mI64_UZYKr`WBe$opWqF@n$yKe( zp`K?A7L>9}_|Yfm{9(hpL!jbIqA%yWkjd%x73b8sM!?Tv@CHF$#4UR}3J{4F_O;3S zTKiuM1AegQ2u&KA&T+>nH1Ki=SaWMGOC4wI@E%-CDf{WTr)(Y+u2btinWHbz(0<|v z%P`s2X%$18_p;RHh!6)p)psXp;lgRdCXe*7WDg_RvRKbKK0wazSs^t+I&k-74}*tx zQhr})##Q{8xgNP3IF@o??P)`*26E-B=__5=&3D!^%>&2#&qff^FHi}udN!YQm~0$L zjIKt_d<|XUq8~~)!v^Jszw;}r?ttg>sI_3At`awr75sEtWrzj#vK@JT&g?f?J zAJRDFkUqOxq({rRbqHkkO$cl!EBcFT-#=tg+juWCz|2z5QORc$L7F85B1$ zxhR2n{y*RP{I$=lBg+}nKWQIIz4nTwNAaBkFuWaSULPBKlwvirpWol?iV>G?k=IjB z+W^vAhI&bNXEH=DxPR;PKQ>5SE@^@rZ~(yXk52!~25IMDYG(V74YEo@#^Dz`QunC_ z+(IO{t!d7_eH^Hpv!x=iZdL~A%!zDFCQYWCbiZl)rN=#FA*dd|L+&&vmFLGH2ZMb^ z@v`r@4dXAjIjuLBEG5a6`|St3ZlCsV6}MXvq-~qd<2zrXW`7#!HS6k?@^A*kz6&wY zw1eS&-H&MXIUrK*o|4IP z5Ug$mjVPwXTm6OYZA3OYI0v^Z{Z+ z`D}qiZ1(%^5L?`7sa4;}?sipe`vLAww5A1Q4pEUNNAg8d0d5{1ij-A?ZA(MXI-M!p z)e1JbQLfC@l9ORMx0FyNytO{uzurOzdpga_HTre2!K(x_)MG+S9KH5uJH;A2tuD!9npfyw)m z+9a?%iuF>{RYpn-H5B8v3st$b`bRlt1yKx^JXdkk$)-#B z-2qm_Re_7WM_z&6d(UF`G@O`8wc{ywOzEVfp;D~ZpF1$9??aES$KzVw5BKN3Kiv%E_`eY&pX<>srM|w9NtSt|+E0@RtWO2X=_^W0kQjY}v?I)@LlGN4uduj|2cX=C;N4eSF>k{U zDV}US1_sIrC{)4mcFd-8jaccBu^e$SRYKBnt^FY%TZb`}baUxZwLIqTVY+nLY1|BD zcExrI``aR-(Yrt^3CH|Y3)+n5IxO5wW(`6?etKt`&q9eewn^NO5*n}knfL+Ll$l?N zt=nNH6Q(W2%KXSt+8Ch%Er}Vpl!y!J)@H{s8|6WGA470F;`KiF(k_SP^{%1@S^d@N zx(n;D8isbsaSTtjr1YBu(FQmjELG#3s%QGqr+lo>fP-BmtUSE;gyXi8NJ^V{)qxsLeKTCAoAp&VPvxXX&J~h zp9<@8n5zgVh~OskBCpwIwMO+bn3bMB!kmD3r`75sly%$r(UbpqH@TUHirgfAm*B6= z-SBYl||!arTf8d*8~QO7QwQ~O`+7#{)(LX^0o zyHk|*?Hh!st(wbUJC2LL8g@Wnh~?&y#Yosh7fR!-tC)QA4fE~yO?XFOK%uhok(x({ z6D5vCKt!D0=ONwo+%#$$%_p@`$_T48kL)!4L3dk@!NlY1J|XIsmE_qhd>C95sA1W& zQrpGnQcJqIZo;o{W2T2W4@;mI?tvF2emGueTv14STW{Pzxv$2SnOX)_@k9++-c33F zp;u`GdGq{Z>#8t&y5x-*9o4NtHlIv~)OF~j2(;z38;S>VvMy@9~n@IE2rU2JIQvaVUlvJ!BQ_}uzF&i)nIv%^9#{}sUU26f?9lP zVR+JKN+GWLetS4%MkaOu`;-{ln9~(XPj#YlZK3%oEqXFqHq}m3L|>}JeMX6)q;uR0 zN^#491}J#?T}o#RVuBiPB*TqP=ROS;fs2v|bfu)Hc*bF&OFg)c&S3#8@;*(#b9Ob0^0g0t-ZV2t z>S$ALGMG+4vI)aA+&EQ+ggz|qt&F_!nCn7NPpRgFO?3#|ic*F~;e5MVaO^eFQz4n=xN+ygOFI9oiBO+|l#G=%T}W${41M~fc>(f3jU-Hw z+>DV~iRP+-Av~c(_F=TRvX;@G8F8u{oYxFSi?$TJES0TR5O1ZTZlV9Fx1a7Qe>*NC zJJNZO{9DHXCZpJ%Z-O25fjoTMQQ#)fi~OuGm&v0X0vgVavZMxAB#&4AF}^@4=vZ%G zQE?uYkhkDIy_ThNrL1(xs){N)?}R9Ux^rjWT95JrbtRIL1i3$gXx>sZF;E9TZ51Ll z-;ZMbjd9`$<^(5Je0pW|CBIw-DNf}I$4^tgkMf>cu0UB3uX$Sv23;ibXaL778IAlm zL~H^Me`~N}7#N(oJmh^8em9qKq$<>kY!f{uuk~)A$?D(@;o%|S%JS17 zP^2TAJ6N?$tAn1sWk5EQCLhJ86=cX(zke^Tty2VrxRVo|<4iG>KSC^m03JaX-=~I$ zkC&Gd&WDG`%k=I%i{(ga%7K|@QuPj{*-#CW9*oE4pbOKyMMn9(alB{@hAifpYc1*v z!H_Z0@)T>u;_dZgB~vt0rr!V(-F*7*L(ju~93~SGcQB$ixVndOV=E`JANyaj9#aR^ zj0IyvZ2J%jY87X$lAP3Dc^_qkM-|(s7aPkt$~(}$TF-F z_HB5grThj#pCyVpGhpr~)j`WF35bv*o?E5Nq^jV5gJ}^O2DHji!waygLdO)QXBkl; z#SlHa_Dg*|c?ZC|B48FF4NYF9$JJ$`JNo9vGXtFi#hR_uYt+&@q>v!027hdsl1f#2 z#CMXKi=E3N;$Wp>S-lgXe3wdDFgoIRPzrZpvf4}nnKooHTW?+Fx7NvKOTWTY^b599 z!m}Fvq2?EshA9OlD2Xy2Ae4J!<@mH?_r!wtc|Q7@(kZVF%E?q|F3G_~5&C2;u;Ku1 zn8UQ9HE{AKH=tk{9P3MT@+NJHC{=#XO=3H9YFm8u%{rn@pn27zbBouvBI)u#LM zP1vGVKb_n2#N}1;oNR@>0D-=26BQgssX;;gH_(}Mx!5dsG-70ld}SSzfRTwL=cd~> zp`666&x!e_IK{q4G;}czz5T(>*3%rUNnWDXeYIRL7$f1W0?yZ+yS>oe0_Rf|y5Q?8 zU?Jlf9aEZtq6_PBjCcrr8r#|mPRwul+(W0_k_)$ogWqmEOd*0<>)&Qw(L9~sHg*&) zr#2Rt5u}CtzF&5o!I%w^jHRDv$}DjL!!izI!tgmj&05M>%$Bn(PwXRQE}qbd{9k=B z%goNEDAZps6pGH>`4jCfC}K_netB+*oxrfKbib;QXx7+0p^o!a#a606HYf&VGBQ}> z>`z@ID69joQvEVo-@~4d2DY-?&<|#d2)Mh@HfJS1UA$x!C2BlvshaU;GhHN`Y;o^i z8#sx1lNRT1<%se~=R`9^>&yM578{o1e=|miDhUX-HSIvpF|Rp_h_Eub;*9;#|@;^9nDncH{T`_%@3f8YYDu6jih)mahf4 zMsAj`OhIsPqCHSE5||@$VwNjSzB}!*??(GP-yDB`8w-yXpy2y;c;%d`Zg0U>c>p7{RKCuKV%*x{agGH+Ofe9Y(`; zmJf+)V}B*j6-ihPdr9TSYxX;(?d5oym@xk%S(oFs}9hf^2;JMMwL^4=xO%!yO&N*L|E_hLB+A#PtM z_?pncM%x?CLw@g)`&(O`8R!Wk$h}zU`Z_C5Vda#dfMuxn$-Ws3sBW@LY<>saE!0gf zzB);+{b#iVc!^GLH?@bf&xrGLY^~wMVWzlsbl>D|=nV`6ac7)5=EO}>Vubgwu7EZeU< zi+4)1evFXgy?TD>VX!hGzh8)c8^Wf*3%6~p8d+A=aJ_y64L|%+UckqT4v4y_J2xS) zKRcorSL*`AuxjkG>c$IXfAYFx_tqpBO_D1wE$o`?gFh~Gj&5CE@oT8Z&u)m8GELgj zuxbbf6T#4Bm&Re0aP{I;7iRnOy_x;uUL)c44RtYh<*+j>ds49jrFSYZz2W8squPd~ z2dkvomVnxJuwel)<^8&qHX~E~oAc2QPAA|`?ovyKqDC0JvE9b3qj=FxSr~RBeIE96 z6?O(x7S#^6QdB7(bwS|Bj+}D4rS$o{GBc}&k?GO3tJH$~gpm`30zX7_XI_Q+NIkO7LD9v4%mR)Q&KUCl&36ERQz@GVP6U?++X0G%} z^PF^jnGq3_90Voo%yq&wXW;Uty(aW8x`k)>3BBJjUfaX~@LFEAX_AeRli+9KR=5R# z|HhU>?E5~{?zi_eRAU1P88#yfBex<^mJ!s>A^GQaz}X1q*|IB&9#i5}6|RlqNO_kY zrl66z*A2=%`fq14vwRzolpuDMuPu*K-qblGnT$>J6Ck|wV$C5XCLx%*=j+P;_7J)e zX!Rn_QRuuZk1wjUW?P9xNSldb3N)f%6E_(pdbAG|#7*?Vw@Gr|IoLUE6havM4VU%r zLPxewmdDUXmF{VG9kc1|*lXsqrqh>PY4ywNZv@{jNjbt#qf!Cu&oH~uY$K;YvZlH# z)6&N`zgi2SL;*q+?t@2CNy?IOpC)3o!F$%91nF&B5+EZW8t++Q^)hP+jAv-mE9v;~ z8%GjUmEKUvy2sxzYMmo`rMiL~P}i07`t8Lz3SBB^_5-0^JTVd5ST>quwnpb}U^2%> zbR94>TWq#V%h@^ull9flL1vK{cW#=$IWsIBwx77H+{N${fbg~NB!CYOWR-A&?QR>b zhZ3EX>#3ln!S;0VBp2@zfh-`PL?as42xPZsFC(iFVgfaRzhQ72@ySG1|KtNRN(Q)<_==orA{n94Q}gJ0CFlbWL4Ed#&_H@Z@+C) z3GCc0k{3A(8c4C_>2%1uwsw&qx4=Ul+1{Gq+}GC}`#2Uj^Q zr31FVh45XTVCks(*6dVuRk_)>+)nMm+P%je)VF%gH#`9G*ShLda;$mr01uJfBgiRz zu^PDT+>iO@FbDeLExTcQ_w=7x(eWR}!M=B8@_yd-+uGlnx!aMPbHAsh(J%t1CX>Tw z7Xm((cPJgA=G9#Y;YMD^&B?+@b5jBaEW;^hs$ylnO)1s9Z|-LEuuao-42Vv8F3Khc zxi2}c<6p)3Mf~o~-;|@-NFe8wom+ol(v->6(dlq`3`Aft$t|bN#2H9#FNMTT|HP39 z@pPK{RIxOCUNaT!r(8{FQrhKh|II*fiK!4Bt62A&Z3;`+q%rPc*}i2+X5}>&;*ILWKg+z zyo^B7iKMz|g+f<5A3~bgyrg0kvmlGx+i1XIq|9Y{SclNn+w)c*UykGaU zP*yg(hpo>}<~B#0j^Mnb=yH*}MF)Jest<9d_AK zJ_T|DXN;Aonvz}C3B2e#TN^XG`fWkaWv<2n7i#9*CVK@AZBQ0i)>KcgV#Yv-?i0Sh?ZO#TBSmL4n_Nd3n#Bh?h*Ys zj^AZ&h2oX%?pRwM0Uw#a*u1Jv9HH4x6(Yy@{nnJ`|lFj$%P zPstERS!BG-^ol2ly)!4kE}Fyr=1itJot29ZtHkG<6wnRSV=_vOreqN9n#>3SGgel$ zwQq&55pyQrdR9uhh2KNR!|OB@(ADcQVLNcJQED)sBob&Y7wwvIQ=xmvI2^5SHDQ`w zt(P96UaylDJmR8pN?~WyDMA;$3MA{?nP=W@130EkenV0a+f)_%x$r8DwAh5l6owfY zX_p-|HWc{mF}(Ctx|%S|Avk8&80Ynk<32-k3$2jAmMWJ|*jVbkPC&9z6UcXgp&_qHR!K$-ANuj)QGOo5SInO z)|?QN<$+E2MZ!+R9eEoP4o^|{*4ZjIm3$H+SV{7DU7x{eaRySaLxNWQ^o82^!+QSO743Us zYp?<}8*>~O+fL#5m$Q=b+ZL50S?M^Rbm$a-j>nhyAA@z2c}G9&qOMIg>32L6e@Yb2 zef@drMJ=X9>mT|N*DtvnlW<sI#oIw8Q;fOImU82(c`$=_#(gp3$jWB5mT- z5%TK-`5PRxkR{%G(3;B)KzYBg$$h73@%hp{(`w_aR%RM%OkI|rS5@ixO0nXj1JpdQ zx0fo~@IU_K{scJ?sFd@wv)qSm0a8^Da7d#%ivjC1K7xC68UIv*2V zm{IHAQI!g7c6DV#l<@p@J4&5dbs!xT>ly2FbN$;FKe|rr+a=<0n9IH_6=FBm?hI@U znFtNt<4ivd=rpsr4_U)?6Rd@VFiyWOsWHEeGdThh2GX|)Rl|tG6GXQkQ?ctw4lu{E zX0B{WGVX1WEyU<3tHWhk`=QUW=Hn5W-$bpl*_9ToJn8Be3*U=O`AqiW*Hc3`3NT(P z$4(SkXbK`jg|$SNzzWft6vWO%Z3+I&_L^lwB!R*O$8+%2&rO^k{~ z7RU2^_@0IxejWm_;0z^%deCU|V+|PT11$SsvCdqq61ELjXlAsx{)Q^(e3*!SR&*RI zG8Bd*TrhPO1;*)ogr>}bA6t*x6Z!6#hXPrC-ez)(qFByefiOXv#6i!@-MAc*G$I%i z+H|!kBqWGz49=77Je=D@_-=4Gg59s66*h^!&MzCIuMHJP>$fc$n1>d#9Sw;sB|>42 zDn&(?`1j%UQG}LN2K1U0#Q^IZZ_)=eoVa%lz7)M7@}&(9`qqLSfScbXSX0tQx^dT* zfZBySh2C%E~8Bp4c-9Dy1ud){0+C8>9h zfqMNww2`2!7Xo_`i^!zJV>G%mp3uU=#;?39XR~-?XySc{Xl(Ok6X1h-K@(hu3&_`} z8|ESzmCAg?iOU_lOI!?Z_gB+n`rz0UbC~%eFK*Lv7+qSis;b8eS;}v(S!b?nf`V>t zq!*@cq>03Clo!TY<1wPh`%aXPH?C_B44m>w-jmg0%i(1O%g7p`2vTdY&@}4GU+lLm zgg0I56yISxR`U})HUKuTuLRBy=B`oH{W|%&OW%%#OZEHXa1I#i)>JD6#Zfsc%J-?+ z7HMq?Ns{}v6-Ol8Ua(R}oJ!ga+pIXisMeDzWhKg8hPDZ-wRAhOT^-djy*6_*Ae_`z5 z>FE^KTC8J>kA&>gpLf``LD3!!UR!f3&8#{U(iAcBs|4ra&L*1#??xv}nrRblrOHJR z%l_%t%a5->-VW-Tl`lwfuS8quO1hp}O)PY}W@N1rp`TBcELZGVe0zxa^Vne@JF)H3 z!^aS&=q!!BGjO_FqoS;t;zMBdfWz}^W`+0>g?bp4w8%V9+~BFY{HMyAluP+e1#|MN zlIL{)?xM(Khzrex-_M;F)yVpNeRo>A2LVs0?BBZedKRGjpPI@?pt$>fV`<$2WIFYY zJXoe5Fkm4+oj%oTy-lHj#RZ%?_P87Kp~8D&H5vl6AgW0vLC_-K5;NU5n=XeRpEQOE z;Xq@C8c+Rm?z%`e&C|j(&p3lVF)8L}Z)hok&@5PM5@Bu*?#}K`(6~YK$z^{fYyR<# zp51vXs#1`dk@gCzgj=x;Y8~1&CvZr=LnnRMoIGF4uC3fLA3M#_zBIYLLs%;GGWU*yp8$SXd8HU(|H=hg6{VOGH{GC3}~~WwV(__8P1MpDusJ(SK>Be`g36& zwhsRKes~hziy|ABu#@(w0QY9v8LZ68inWOqmGaBu&Xx3Ew{tg)`Q;(7GsjuvH}^i3 z6~S#ucv1K$KoOS&b7?F{lJF5ubhu1bLWL;;D;0d^PxQ{jJk~T`!~oSHB3nie6Vj{W zAHMAM5Xy_C15bDLZUd)HO~+H#o^#btH}QU}KBgaWZmv}%gPU;_I25yqPy0J*Orxo6 z1tf$%KwG&(Cz2Q?11-SFviF?60!&7iY>Ym+3;ibwm(zPaLYIWw$XG5xz#P>SCEy5$ zkWv1G>jY$jAT%+8n}3hV;2c@2+nnTTBqS;Us!;k0Fs;N*Gnp!bK*Zg-8}bmSH`f)% zzVE_En{Uyxo3h!fLIg=l)FEsg@}&vraB9I)3U4%8W>owhTg??UPxo@1`Rv{JOq{6{ zP5*Ub2rivEofigjtE1XzP2ZcjG_fAs{nwAt#|8@5531O)j<;jUtQZ2a1X!9cRCtab zFDHWrtqnvSCLP8yDmqa&mZHrV=wDBw2(AlJZptCw8o0WF4LZ_f)~{$89}hoZO5RC! zFyuuhp~-8-79l7-yP4fgirux$XNrATsm_L}@*xo)UtT~*eh!FiAA#N%tVc}g8l5%) z)+eJ{)WoXCxVlTAP2?nkYz6YlxyiXf4pv>NAf_{1C04qFU3e97J7FUAChSEAq`dht z8v0Cjt9P4Ld|1uD4>e>KF^i7Sg!tDeov_PHMvQ^YECvV@-VSJIH2J9(3F%n1b*AN? zDzWEmxGopf=9~7bZ+5B73H(CULbPjN{7PJMUPfiteP%CsIoYs+*Y#TdnZ1K)OBc?bQ5TO9@CLej%eXO*(q#vTZaT~k@g+l2H0 zj*N$#F$QdtqN!d7vvY$t~U5b!w8nQf`V4nMt;oS@GG)<`qQQ_UicI=#3@gK0|;$%sCF6Bb# zA}%#pWF67BBVwaIo8_ub-`zuW5d z62z5;F6W)-Hnp|Mc7nM|wd$40?YQ=cq?9jgLJ_VH^%Wz}L*FC+@bjoazBpgcR4R+I z9aT}qT5L;Y2VmaM(5*V?C9a+bFS>=2Z0LG4zki4ZN&2Uu^OpI3AxP`QD~{}TD3trq z`&clp7t2+6>eL&TQJ?MnoXeO4=^r(!s;v4 z1UpC&oin0u2n6#;7I#-j5)aG+2W7y$a8jpIkgw{)-V=^D8FAj(f{X>(W<#nWD!ku= z1_9JDt($LQ4%m3F#1~XX&E`P{B*~Rp(_WR+8?G|mCQB$3HxQ3oWcGbZZyFj&_n7!< zD)%)GAQeD8U}Qh$0q|dIK_Pn(q5G!>I)4uQ|D_fhl44@A5PeKAe+mIHqgOJsg1{`* z4sMX^LPJK{0GmT@SomdJ#D43s8Nal?f88j3 z1P|23SM@O5&C^EE_HwTbtY7gsF6C%1(}&OPSGafgV7MWu?D+ne=fao1j~0>M zRu|BZ+KlkEQs|KExJ ze}@03XzAbZx<8BOFZf?U(|^YQr{LA!`1wEfR)u|-zooH mfBu`YF@S~ne;(a`sga@##Gght008FCv*C|^Nwfar-Twh9e?pZ2 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 4cfdbd9fa13..8ef2c8e079a 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -15,12 +15,12 @@ import ( "os" "strings" "time" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) const ( defaultControlPlaneEndpoint = "http://localhost:5000" - defaultAccountName = "local" - defaultProjectName = "demo" ) type rleClient struct { @@ -28,98 +28,20 @@ type rleClient struct { httpClient *http.Client } -type environmentCreateRequest struct { - Id string `json:"id"` - Name string `json:"name"` - VersionLabel string `json:"versionLabel"` - Runtime runtimeSpec `json:"runtime"` - World worldSpec `json:"world"` - Compliance complianceSpec `json:"compliance"` -} - type v1EnvironmentRequest struct { Name string `json:"name,omitempty"` AcrImagePath string `json:"acrImagePath"` } -type runtimeSpec struct { - Mode string `json:"mode"` - Image string `json:"image,omitempty"` -} - -type worldSpec struct { - Capability string `json:"capability"` -} - -type complianceSpec struct { - Boundary string `json:"boundary"` -} - type environmentResource struct { - Id string `json:"id"` - ProjectId string `json:"projectId,omitempty"` - Name string `json:"name,omitempty"` - AcrImagePath string `json:"acrImagePath,omitempty"` - Version string `json:"version,omitempty"` - CreatedAtUtc string `json:"createdAtUtc,omitempty"` - UpdatedAtUtc string `json:"updatedAtUtc,omitempty"` - AccountName string `json:"accountName,omitempty"` - ProjectName string `json:"projectName,omitempty"` - VersionLabel string `json:"versionLabel,omitempty"` - Manifest environmentCreateRequest `json:"manifest,omitempty"` -} - -type listEnvironmentsResponse struct { - Value []environmentResource `json:"value"` -} - -type environmentVersion struct { - EnvironmentId string `json:"environmentId,omitempty"` - ProjectId string `json:"projectId,omitempty"` - Version string `json:"version,omitempty"` - AcrImagePath string `json:"acrImagePath,omitempty"` - CreatedAt string `json:"createdAtUtc,omitempty"` -} - -type sandboxCreateRequest struct { - Version string `json:"version,omitempty"` - Cpu string `json:"cpu,omitempty"` - Memory string `json:"memory,omitempty"` - Disk string `json:"disk,omitempty"` -} - -type sandboxResource struct { - Id string `json:"id"` - ProjectId string `json:"projectId,omitempty"` - EnvironmentId string `json:"environmentId,omitempty"` - Version string `json:"version,omitempty"` - DiskImageId string `json:"diskImageId,omitempty"` - AdcSandboxId string `json:"adcSandboxId,omitempty"` - Url string `json:"url,omitempty"` - Status string `json:"status,omitempty"` - Error string `json:"error,omitempty"` - CreatedAt string `json:"createdAtUtc,omitempty"` - UpdatedAt string `json:"updatedAtUtc,omitempty"` -} - -type listSandboxesResponse struct { - Value []sandboxResource `json:"value"` -} - -type environmentInstanceCreateRequest struct { - VersionLabel string `json:"versionLabel,omitempty"` - Provider string `json:"provider,omitempty"` - Environment map[string]string `json:"environmentVariables,omitempty"` -} - -type environmentInstanceResource struct { - Id string `json:"id"` - EnvironmentId string `json:"environmentId,omitempty"` - VersionLabel string `json:"versionLabel,omitempty"` - Status string `json:"status,omitempty"` - Provider string `json:"provider,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - Reason string `json:"reason,omitempty"` + 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"` + VersionLabel string `json:"versionLabel,omitempty"` } type rleHTTPError struct { @@ -131,6 +53,17 @@ func (e *rleHTTPError) Error() string { return fmt.Sprintf("RLE control plane returned HTTP %d: %s", e.statusCode, strings.TrimSpace(e.body)) } +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, + ), + } +} + // 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 { @@ -152,40 +85,12 @@ func resolveControlPlaneEndpoint(endpoint string) string { if endpoint != "" { return endpoint } - if endpoint = os.Getenv("AZD_RLE_CONTROL_PLANE"); endpoint != "" { - return endpoint - } if endpoint = os.Getenv("RLE_ENDPOINT"); endpoint != "" { return endpoint } - if endpoint = os.Getenv("RLE_CONTROL_PLANE"); endpoint != "" { - return endpoint - } return defaultControlPlaneEndpoint } -func (c *rleClient) createOrUpdateEnvironment( - ctx context.Context, - account string, - project string, - environmentId string, - request environmentCreateRequest, -) (*environmentResource, error) { - path := fmt.Sprintf( - "/rle/v1.0/accounts/%s/projects/%s/environments/%s", - url.PathEscape(account), - url.PathEscape(project), - url.PathEscape(environmentId), - ) - - var result environmentResource - if err := c.do(ctx, http.MethodPut, path, request, &result); err != nil { - return nil, err - } - - return &result, nil -} - func (c *rleClient) createV1Environment( ctx context.Context, project string, @@ -224,155 +129,6 @@ func (c *rleClient) updateV1Environment( return &result, nil } -func (c *rleClient) createEnvironmentInstance( - ctx context.Context, - account string, - project string, - environmentId string, - request environmentInstanceCreateRequest, -) (*environmentInstanceResource, error) { - path := fmt.Sprintf( - "/rle/v1.0/accounts/%s/projects/%s/environments/%s/instances", - url.PathEscape(account), - url.PathEscape(project), - url.PathEscape(environmentId), - ) - - var result environmentInstanceResource - if err := c.do(ctx, http.MethodPost, path, request, &result); err != nil { - return nil, err - } - - return &result, nil -} - -func (c *rleClient) listEnvironments( - ctx context.Context, - account string, - project string, -) ([]environmentResource, error) { - _ = account - path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments", - url.PathEscape(project), - ) - - var result listEnvironmentsResponse - if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { - return nil, err - } - - return result.Value, nil -} - -func (c *rleClient) getEnvironment( - ctx context.Context, - account string, - project string, - environmentId string, -) (*environmentResource, error) { - _ = account - path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments/%s", - url.PathEscape(project), - url.PathEscape(environmentId), - ) - - var result environmentResource - if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { - return nil, err - } - - return &result, nil -} - -func (c *rleClient) listEnvironmentVersions( - ctx context.Context, - account string, - project string, - environmentId string, -) ([]environmentVersion, error) { - _ = account - path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments/%s/versions", - url.PathEscape(project), - url.PathEscape(environmentId), - ) - - var result []environmentVersion - if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { - return nil, err - } - - return result, nil -} - -func (c *rleClient) createSandbox( - ctx context.Context, - account string, - project string, - environmentId string, - request sandboxCreateRequest, -) (*sandboxResource, error) { - _ = account - path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments/%s/sandboxes", - url.PathEscape(project), - url.PathEscape(environmentId), - ) - - var result sandboxResource - if err := c.do(ctx, http.MethodPost, path, request, &result); err != nil { - return nil, err - } - - return &result, nil -} - -func (c *rleClient) listSandboxes( - ctx context.Context, - account string, - project string, - environmentId string, -) ([]sandboxResource, error) { - _ = account - path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments/%s/sandboxes", - url.PathEscape(project), - url.PathEscape(environmentId), - ) - - var result listSandboxesResponse - if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { - return nil, err - } - - return result.Value, nil -} - -func (c *rleClient) getSandbox( - ctx context.Context, - account string, - project string, - environmentId string, - sandboxId string, -) (*sandboxResource, error) { - _ = account - path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments/%s/sandboxes/%s", - url.PathEscape(project), - url.PathEscape(environmentId), - url.PathEscape(sandboxId), - ) - - var result sandboxResource - if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { - return nil, err - } - - return &result, nil -} - func (c *rleClient) do(ctx context.Context, method string, path string, body any, target any) error { var reader io.Reader if body != nil { @@ -415,21 +171,3 @@ func (c *rleClient) do(ctx context.Context, method string, path string, body any return nil } - -func newEnvironmentCreateRequest(environmentId string, name string, image string, version string) environmentCreateRequest { - return environmentCreateRequest{ - Id: environmentId, - Name: name, - VersionLabel: version, - Runtime: runtimeSpec{ - Mode: "container", - Image: image, - }, - World: worldSpec{ - Capability: "eval", - }, - Compliance: complianceSpec{ - Boundary: "foundry", - }, - } -} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go deleted file mode 100644 index 289769336e7..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/create.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "fmt" - "strings" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -type sharedFlags struct { - endpoint string - account string - project string -} - -type createFlags struct { - sharedFlags - recipe string - image string - version string -} - -func newCreateCommand() *cobra.Command { - flags := &createFlags{ - sharedFlags: sharedFlags{ - account: defaultAccountName, - project: defaultProjectName, - }, - recipe: defaultRecipeName, - version: "1.0.0", - } - - cmd := &cobra.Command{ - Use: "create ", - Short: "Create or update an RLE environment", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - name := args[0] - environmentId := slug(name) - image, err := resolveRecipeImage(flags.recipe, flags.image) - if err != nil { - return err - } - - client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) - environment, err := client.createOrUpdateEnvironment( - cmd.Context(), - flags.account, - flags.project, - environmentId, - newEnvironmentCreateRequest(environmentId, name, image, flags.version), - ) - if err != nil { - return serviceError(err) - } - - if isJsonOutput(cmd) { - return printJson(cmd, environment) - } - - return printEnvironmentCreated(cmd, environment, flags) - }, - } - - addSharedFlags(cmd, &flags.sharedFlags) - cmd.Flags().StringVar(&flags.recipe, "recipe", flags.recipe, "Recipe to use for the RLE environment") - cmd.Flags().StringVar(&flags.image, "image", "", "Container image for the RLE environment. Overrides --recipe") - cmd.Flags().StringVar(&flags.version, "version-label", flags.version, "Environment version label") - return cmd -} - -func serviceError(err error) error { - return &azdext.ServiceError{ - Message: err.Error(), - ServiceName: "rle-control-plane", - Suggestion: fmt.Sprintf( - "Ensure the RLE control plane is running and reachable, e.g. %s.", - defaultControlPlaneEndpoint, - ), - } -} - -func addSharedFlags(cmd *cobra.Command, flags *sharedFlags) { - cmd.Flags().StringVar( - &flags.endpoint, - "endpoint", - "", - fmt.Sprintf( - "RLE control plane endpoint. Defaults to AZD_RLE_CONTROL_PLANE, RLE_CONTROL_PLANE, or %s", - defaultControlPlaneEndpoint, - ), - ) - cmd.Flags().StringVar(&flags.account, "account", flags.account, "RLE account name") - cmd.Flags().StringVar(&flags.project, "project", flags.project, "RLE project name") -} - -func slug(name string) string { - var builder strings.Builder - lastDash := false - for _, r := range strings.ToLower(strings.TrimSpace(name)) { - if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { - builder.WriteRune(r) - lastDash = false - continue - } - if !lastDash && builder.Len() > 0 { - builder.WriteRune('-') - lastDash = true - } - } - return strings.Trim(builder.String(), "-") -} - -func printEnvironmentCreated(cmd *cobra.Command, environment *environmentResource, flags *createFlags) error { - nextCommand := nextSandboxCreateCommand(environment.Id, flags.sharedFlags) - _, err := fmt.Fprintf( - cmd.OutOrStdout(), - "Environment created: %s\nName: %s\nImage: %s\n\nNext:\n %s\n", - environment.Id, - environment.Name, - environment.Manifest.Runtime.Image, - nextCommand, - ) - return err -} - -func nextSandboxCreateCommand(environmentId string, flags sharedFlags) string { - command := fmt.Sprintf("azd ai rle sandbox create %s --wait", environmentId) - command = appendNonDefaultSharedFlags(command, flags) - return command -} - -func appendNonDefaultSharedFlags(command string, flags sharedFlags) string { - if flags.project != "" && flags.project != defaultProjectName { - command += fmt.Sprintf(" --project %s", flags.project) - } - if flags.account != "" && flags.account != defaultAccountName { - command += fmt.Sprintf(" --account %s", flags.account) - } - if flags.endpoint != "" { - command += fmt.Sprintf(" --endpoint %s", flags.endpoint) - } - return command -} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies.go deleted file mode 100644 index e2b9bbb40d8..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies.go +++ /dev/null @@ -1,43 +0,0 @@ -// 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 deleted file mode 100644 index f52d1b1c9cd..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/dependencies_test.go +++ /dev/null @@ -1,32 +0,0 @@ -// 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 e41dc00b9ae..524e4d19c38 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -8,18 +8,13 @@ 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 - registry string - skipBuild bool + projectId string } func newDeployCommand() *cobra.Command { @@ -28,6 +23,7 @@ func newDeployCommand() *cobra.Command { cmd := &cobra.Command{ Use: "deploy", Short: "Create or update the RLE environment", + Args: cobra.NoArgs, 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), @@ -52,13 +48,13 @@ func newDeployCommand() *cobra.Command { 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 + + Suggestion: "Run azd ai rle init first, or add an " + rleManifestFile + " manifest to this folder, then re-run deploy.", } } if !initialized { - state = defaultRleState("", defaultRecipeName) + state = defaultRleState("") if _, err := fmt.Fprintf( cmd.OutOrStdout(), "No %s found; initializing a new RLE session from %s.\n", @@ -75,49 +71,28 @@ func newDeployCommand() *cobra.Command { return err } state.Name = firstNonEmpty(manifestState.Name, state.Name) - state.Account = firstNonEmpty(manifestState.Account, state.Account) - state.Project = firstNonEmpty(manifestState.Project, state.Project) - state.Endpoint = firstNonEmpty(manifestState.Endpoint, state.Endpoint) - state.Image = firstNonEmpty(manifestState.Image, state.Image) + state.LocalImage = manifestState.LocalImage + state.Image = manifestState.Image } - state.Project = firstNonEmpty(flags.project, state.Project) - - // Resolve the image to deploy. Priority: --image flag, then the manifest/state - // image, then a per-environment default derived from the environment name so each - // environment gets its own repository in the target registry. - image, err := resolveRecipeImage(state.Recipe, firstNonEmpty(flags.image, state.Image)) - if err != nil { - return err - } - if image == "" { - if state.Name == "" { - return &azdext.LocalError{ - Message: "Unable to determine the environment image.", - Code: "rle_image_required", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Pass --image or set the environment name so a default image can be derived.", - } + state.Project = firstNonEmpty(flags.projectId, state.Project) + if state.Project == "" { + return &azdext.LocalError{ + Message: "RLE project is required for deploy.", + Code: "rle_project_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Pass --project-id when running azd ai rle deploy.", } - 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 == "" { + image := state.Image + if image == "" { return &azdext.LocalError{ - Message: "No container registry was specified for the environment image.", - Code: "rle_registry_required", + Message: "RLE environment image is required for deploy.", + Code: "rle_image_required", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Pass --registry (e.g. devrle) or use a host-qualified image reference.", + Suggestion: "Set template.environment.image in rle.yaml, then rerun deploy.", } } - image = loginServer + "/" + repoTag - environmentId := firstNonEmpty(state.EnvironmentId, slug(state.Name)) client := newRleClient(resolveControlPlaneEndpoint("")) request := v1EnvironmentRequest{ @@ -132,44 +107,13 @@ func newDeployCommand() *cobra.Command { action = "Updating" } - // Build the local Dockerfile and push it to the registry before registering, so the - // environment always points at an image that actually exists in the target ACR. - registryName := registryShortName(loginServer) - switch { - case flags.skipBuild: - if _, err := fmt.Fprintf(cmd.OutOrStdout(), - "Skipping build (--skip-build); using image '%s'.\n", image); err != nil { - return err - } - case !fileExists("Dockerfile"): - if _, err := fmt.Fprintf(cmd.OutOrStdout(), - "No Dockerfile in current directory; skipping build and using image '%s'.\n", image); err != nil { - return err - } - default: - if _, err := fmt.Fprintf(cmd.OutOrStdout(), - "Building and pushing '%s' to registry '%s' (az acr build) ...\n", - repoTag, registryName); err != nil { - return err - } - build := exec.CommandContext(cmd.Context(), - "az", "acr", "build", "--registry", registryName, "--image", repoTag, ".") - build.Stdout = cmd.OutOrStdout() - build.Stderr = cmd.ErrOrStderr() - if err := build.Run(); err != nil { - return &azdext.LocalError{ - Message: fmt.Sprintf( - "Failed to build and push image '%s' to registry '%s': %v", - repoTag, registryName, err), - Code: "rle_acr_build_failed", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Ensure 'az login' is done, you have push access to the registry, " + - "and a valid Dockerfile is present. Use --skip-build to register a prebuilt image.", - } - } - } - - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s environment '%s' (image=%s) ...\n", action, state.Name, image); err != nil { + if _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "%s environment '%s' (image=%s) ...\n", + action, + state.Name, + image, + ); err != nil { return err } if state.EnvironmentId == "" { @@ -195,9 +139,7 @@ func newDeployCommand() *cobra.Command { return serviceError(err) } state.EnvironmentId = environment.Id - state.EnvironmentVersion = firstNonEmpty(environment.Version, environment.VersionLabel, environment.Manifest.VersionLabel) - state.InstanceId = "" - state.InstanceEndpoint = "" + state.EnvironmentVersion = firstNonEmpty(environment.Version, environment.VersionLabel) if err := saveRleState(state); err != nil { return err } @@ -206,7 +148,13 @@ func newDeployCommand() *cobra.Command { if !created { label = "Updated" } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "\n%s environment '%s' (%s).\n", label, state.Name, state.EnvironmentId); err != nil { + 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{ @@ -228,58 +176,11 @@ 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.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") + cmd.Flags().StringVar(&flags.projectId, "project-id", "", + "RLE project id to deploy into. Required on first deploy; later deploys reuse the saved value.") 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"` 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 2f467872ac6..ff02d95af44 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -5,27 +5,67 @@ package cmd import ( "fmt" + "os" "path/filepath" + "strings" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) type rleInitFlags struct { - path string - image string - force bool + manifest string + force bool } func newInitCommand() *cobra.Command { flags := &rleInitFlags{} cmd := &cobra.Command{ - Use: "init ", + Use: "init", Short: "Scaffold a local RLE environment", - Args: cobra.ExactArgs(1), + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + if initUsedLongManifestFlag(os.Args[1:]) { + return &azdext.LocalError{ + Message: "--manifest is not supported.", + Code: "rle_manifest_long_flag_unsupported", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use -m .", + } + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { - envName, err := validateEnvName(args[0]) + var manifestBytes []byte + if flags.manifest != "" { + var err error + manifestBytes, err = readManifestContent(cmd.Context(), flags.manifest) + if err != nil { + return err + } + } else { + manifestBytes = []byte(defaultEchoManifest) + } + + manifest, err := parseRleManifest(manifestBytes) + if err != nil { + return err + } + manifestState, err := stateFromManifest(manifest) + if err != nil { + return err + } + envName := manifestState.Name + if envName == "" { + return &azdext.LocalError{ + Message: "RLE manifest name is required.", + Code: "rle_environment_name_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Set name or template.name in the manifest.", + } + } + envName, err = validateEnvName(envName) if err != nil { return &azdext.LocalError{ Message: err.Error(), @@ -35,16 +75,21 @@ func newInitCommand() *cobra.Command { } } - sessionDir, err := scaffoldRleSession(envName, flags.path, flags.image, flags.force) + sessionDir, err := scaffoldRleSession( + envName, + ".", + manifestState.LocalImage, + manifestState.Image, + flags.force, + ) if err != nil { return err } - - state := defaultRleState(envName, defaultRecipeName) - if err := saveRleStateIn(sessionDir, state); err != nil { + if err := writeManifestToSession(manifestBytes, sessionDir); err != nil { return err } - if _, err := materializeBundledRleSdk(sessionDir); err != nil { + + if err := saveRleStateIn(sessionDir, manifestState); err != nil { return err } @@ -54,7 +99,12 @@ func newInitCommand() *cobra.Command { } _, err = fmt.Fprintf( cmd.OutOrStdout(), - "Created OpenEnv-style environment at: %s\nNext steps:\n cd %s\n azd ai rle deploy\n", + "Created OpenEnv-style environment at: %s\n"+ + "Next steps:\n"+ + " cd %s\n"+ + " azd ai rle run\n"+ + " azd ai rle invoke --local\n"+ + " azd ai rle deploy --project-id \n", displayDir, displayDir, ) @@ -62,9 +112,42 @@ 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") + cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + var help strings.Builder + help.WriteString("Scaffold a local RLE environment\n") + help.WriteString("Usage:\n") + help.WriteString(" rle init [flags]\n") + help.WriteString("Flags:\n") + help.WriteString(" --force Overwrite generated files in an existing non-empty session directory\n") + help.WriteString(" -h, --help help for init\n") + help.WriteString(" -m string Path or HTTPS URL to an RLE manifest to copy into the session.\n") + if cmd.InheritedFlags().HasAvailableFlags() { + help.WriteString("Global Flags:\n") + help.WriteString(cmd.InheritedFlags().FlagUsages()) + } + _, _ = fmt.Fprint(cmd.OutOrStdout(), help.String()) + }) + cmd.Flags().StringVarP( + &flags.manifest, + "manifest", + "m", + "", + "Path or HTTPS URL to an RLE manifest to copy into the session.", + ) + _ = cmd.Flags().MarkHidden("manifest") + cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite generated files in an existing non-empty session directory") return cmd } + +func initUsedLongManifestFlag(args []string) bool { + for _, arg := range args { + if arg == "--manifest" || strings.HasPrefix(arg, "--manifest=") { + return true + } + } + return false +} + +func writeManifestToSession(data []byte, sessionDir string) error { + return os.WriteFile(filepath.Join(sessionDir, rleManifestFile), data, 0600) +} 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 560be3e45f8..a023e4ad3d6 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -1,329 +1,606 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// cspell:ignore openenv + package cmd import ( + "bufio" + "bytes" + "encoding/json" + "errors" "fmt" + "io" + "net/http" "os" "os/exec" - "path/filepath" "strconv" "strings" + "time" "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 +type openEnvInvokeFlags struct { + local bool + port int + persistPort bool + timeout int + action string + body string } func newInvokeCommand() *cobra.Command { - flags := &rleInvokeFlags{ - recipe: defaultRecipeName, - numTasks: 4, - modelName: "Qwen/Qwen3-32B", - rendererName: "qwen3_disable_thinking", - maxTokens: 1200, - loraRank: 32, - groupSize: 4, - groupsPerBatch: 1, - maxSteps: 1, - lossFn: "importance_sampling", - seed: 42, - evalEvery: 999999, - saveEvery: 999999, - removeConstantRewardGroups: true, + flags := &openEnvInvokeFlags{ + timeout: 30, } cmd := &cobra.Command{ Use: "invoke", - Short: "Run the Loom RLE training recipe", + Short: "Open an OpenEnv runtime shell", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - state, err := loadRleState() - if err != nil { - return err - } - if state.EnvironmentId == "" { - return &azdext.LocalError{ - Message: "RLE environment has not been deployed.", - Code: "rle_environment_not_deployed", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Run azd ai rle deploy from this session folder before invoking training.", - } - } - - if flags.projectEndpoint == "" { - return &azdext.LocalError{ - Message: "Azure AI project endpoint is required.", - Code: "rle_project_endpoint_required", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Pass --project-endpoint with the Azure AI Foundry project endpoint.", - } - } - recipeName, err := validateRecipeName(flags.recipe) - if err != nil { + if !flags.local { return &azdext.LocalError{ - Message: err.Error(), - Code: "rle_invalid_recipe_name", + Message: "Remote invoke is not supported yet.", + Code: "rle_remote_invoke_not_supported", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Use a Loom recipe folder name in snake_case, for example code_rl_with_rle.", + Suggestion: "Pass --local to invoke the local OpenEnv runtime.", } } - - 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) + baseUrl, err := ensureLocalContainerEndpoint(cmd, flags) if err != nil { return err } + return runOpenEnvShell(cmd.InOrStdin(), cmd.OutOrStdout(), baseUrl, flags.timeout) + }, + } - controlPlane := resolveControlPlaneEndpoint("") - invokeArgs := buildLoomInvokeArgs(state, state.Project, controlPlane, flags) - commandArgs := append([]string{"run", "--extra", "code_rl", "python", "-m", loomRecipeModule(recipeName)}, invokeArgs...) + cmd.Flags().BoolVar( + &flags.local, + "local", + false, + "Run the configured local Docker image if needed, then call that container.", + ) + cmd.Flags().IntVar( + &flags.timeout, + "timeout", + flags.timeout, + "Per-command OpenEnv request timeout in seconds (0 for no timeout).", + ) + return cmd +} - 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.", - } - } +func newRunCommand() *cobra.Command { + flags := &openEnvInvokeFlags{ + persistPort: true, + } - 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 { + cmd := &cobra.Command{ + Use: "run", + Short: "Pull and run the local RLE environment container", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + baseUrl, err := ensureLocalContainerEndpoint(cmd, flags) + if 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 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Local RLE environment is running at %s\n", baseUrl) + return err }, } - 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.") + cmd.Flags().IntVar( + &flags.port, + "port", + 0, + "Host port mapped to the local Docker container. Defaults to the saved port or 8000.", + ) 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 +const defaultPort = 8000 + +func normalizeOpenEnvOperation(operation string) (string, error) { + operation = strings.Trim(strings.ToLower(operation), "/") + switch operation { + case "reset", "step", "state", "health", "metadata", "schema": + return operation, nil + default: + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Unknown OpenEnv operation %q.", operation), + Code: "rle_unknown_openenv_operation", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use one of: reset, step, state, health, metadata, schema.", + } + } } -func resolveInvokeLoomPath(cmd *cobra.Command, recipeName string, rleSdkPackage string) (string, error) { - return ensureManagedLoomRecipe(cmd, ".", defaultLoomRecipeRepo, defaultLoomRecipeRef, recipeName, rleSdkPackage) +func runOpenEnvShell(input io.Reader, output io.Writer, baseUrl string, timeout int) error { + fmt.Fprintln(output, "OpenEnv shell. Type help for commands, exit to quit.") + scanner := bufio.NewScanner(input) + for { + fmt.Fprint(output, "rle> ") + if !scanner.Scan() { + fmt.Fprintln(output) + return scanner.Err() + } + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if strings.EqualFold(line, "exit") || strings.EqualFold(line, "quit") { + return nil + } + if strings.EqualFold(line, "help") { + printOpenEnvShellHelp(output) + continue + } + + operation, payload, _ := strings.Cut(line, " ") + operation, err := normalizeOpenEnvOperation(operation) + if err != nil { + fmt.Fprintf(output, "error: %v\n", err) + continue + } + payload = strings.TrimSpace(payload) + flags := &openEnvInvokeFlags{timeout: timeout} + switch operation { + case "reset": + flags.body = payload + case "step": + if payload == "" { + fmt.Fprintln(output, "error: step requires a JSON action payload, for example: step {\"message\":\"hello\"}") + continue + } + flags.action = payload + default: + if payload != "" { + fmt.Fprintf(output, "error: %s does not accept a JSON payload\n", operation) + continue + } + } + + response, err := callOpenEnv(baseUrl, operation, flags) + if err != nil { + fmt.Fprintf(output, "error: %v\n", err) + continue + } + fmt.Fprintln(output, response) + } +} + +func printOpenEnvShellHelp(output io.Writer) { + fmt.Fprintln(output, "Commands:") + fmt.Fprintln(output, " health") + fmt.Fprintln(output, " reset [json]") + fmt.Fprintln(output, " step [json-action]") + fmt.Fprintln(output, " state") + fmt.Fprintln(output, " metadata") + fmt.Fprintln(output, " schema") + fmt.Fprintln(output, " exit") } -func resolveLoomPath(path string) (string, error) { - if path == "" { +func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *openEnvInvokeFlags) (string, error) { + state, err := loadRleState() + if err != nil { + return "", err + } + port := resolvePort(flags, state) + if port <= 0 { + return "", &azdext.LocalError{ + Message: "--port must be greater than 0.", + Code: "rle_invalid_local_port", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Pass a valid host port, for example --port 8000.", + } + } + if _, err := exec.LookPath("docker"); err != nil { return "", &azdext.LocalError{ - Message: "Loom cookbook path is required.", - Code: "rle_loom_path_required", + Message: "Could not find \"docker\" on PATH.", + Code: "rle_docker_not_found", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Run azd ai rle invoke again so azd can fetch the managed Loom recipe.", + Suggestion: "Install/start Docker Desktop, then retry the command.", } } - abs, err := filepath.Abs(path) + + image, err := localRuntimeImage(state) if err != nil { return "", err } - info, err := os.Stat(abs) - if err != nil { + container := localContainerName(state.Name) + baseUrl := fmt.Sprintf("http://localhost:%d", port) + running, exists := localContainerStatus(cmd, container) + switch { + case running: + if !shouldRecreateContainer(cmd, container, port, image) { + if err := waitForOpenEnvHealth(baseUrl, 30*time.Second); err != nil { + return "", err + } + if err := savePortIfRequested(flags, state, port); err != nil { + return "", err + } + return baseUrl, nil + } + case exists: + if !shouldRecreateContainer(cmd, container, port, image) { + if _, err := fmt.Fprintf( + cmd.ErrOrStderr(), + "Starting existing local container %s ...\n", + container, + ); err != nil { + return "", err + } + if err := runDocker(cmd, "start", container); err == nil { + if err := waitForOpenEnvHealth(baseUrl, 30*time.Second); err != nil { + return "", err + } + if err := savePortIfRequested(flags, state, port); err != nil { + return "", err + } + return baseUrl, nil + } + if _, err := fmt.Fprintf( + cmd.ErrOrStderr(), + "Existing local container %s did not start; recreating it ...\n", + container, + ); err != nil { + return "", err + } + } + } + + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Pulling local runtime image %s ...\n", image); err != nil { + return "", err + } + if err := runDocker(cmd, "pull", image); err != nil { return "", &azdext.LocalError{ - Message: fmt.Sprintf("Loom cookbook path %q could not be read.", abs), - Code: "rle_loom_path_missing", + Message: fmt.Sprintf("Failed to pull Docker image %q: %v", image, err), + Code: "rle_local_docker_pull_failed", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Delete the managed checkout, then run azd ai rle invoke again.", + Suggestion: dockerPullFailureSuggestion(image), } } - if !info.IsDir() { + + if _, exists := localContainerStatus(cmd, container); exists { + _ = runDocker(cmd, "rm", "-f", container) + } + if _, err := fmt.Fprintf( + cmd.ErrOrStderr(), + "Starting local container %s on port %d ...\n", + container, + port, + ); err != nil { + return "", err + } + portMapping := fmt.Sprintf("%d:8000", port) + if err := runDocker(cmd, + "run", "-d", + "--name", container, + "--label", localContainerImageLabel+"="+image, + "-p", portMapping, + image); err != nil { return "", &azdext.LocalError{ - Message: fmt.Sprintf("Loom cookbook path %q is not a directory.", abs), - Code: "rle_loom_path_not_directory", + Message: fmt.Sprintf("Failed to start local Docker container %q: %v", container, err), + Code: "rle_local_docker_run_failed", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Delete the managed checkout, then run azd ai rle invoke again.", + Suggestion: "Ensure the selected --port is free, or pass a different port.", } } - return abs, nil + + if err := waitForOpenEnvHealth(baseUrl, 30*time.Second); err != nil { + return "", err + } + if err := savePortIfRequested(flags, state, port); err != nil { + return "", err + } + return baseUrl, nil } -func resolveRleSdkPackage() (string, error) { - if _, err := os.Stat(bundledRleSdkPath(".")); err != nil { - return materializeBundledRleSdk(".") +func resolvePort(flags *openEnvInvokeFlags, state rleState) int { + if flags.port > 0 { + return flags.port + } + if state.Port > 0 { + return state.Port } - return filepath.Abs(bundledRleSdkPath(".")) + return defaultPort } -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) +func savePortIfRequested(flags *openEnvInvokeFlags, state rleState, port int) error { + if !flags.persistPort || state.Port == port { + return nil } + state.Port = port + return saveRleState(state) +} - 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.", +func localRuntimeImage(state rleState) (string, error) { + manifest, manifestErr := loadRleManifest(rleManifestFile) + if manifestErr == nil { + image := localImageFromManifest(manifest) + if strings.TrimSpace(image) != "" { + return strings.TrimSpace(image), nil } + return "", missingLocalRuntimeImageError() + } else if !errors.Is(manifestErr, os.ErrNotExist) { + return "", manifestErr + } + if strings.TrimSpace(state.LocalImage) != "" { + return strings.TrimSpace(state.LocalImage), nil } + if strings.TrimSpace(state.Image) != "" { + return strings.TrimSpace(state.Image), nil + } + return "", missingLocalRuntimeImageError() +} - if err := os.MkdirAll(filepath.Dir(repoDir), 0700); err != nil { - return "", err +func missingLocalRuntimeImageError() error { + return &azdext.LocalError{ + Message: "No local runtime image is configured.", + Code: "rle_local_image_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Set template.local.image or template.environment.image in rle.yaml, then rerun the command.", } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Fetching Loom recipe %s from %s\n", ref, repo); err != nil { - return "", err +} + +func dockerPullFailureSuggestion(image string) string { + if registry, ok := acrRegistryName(image); ok { + return fmt.Sprintf( + "Run az acr login --name %s, or set template.local.image in %s to an image available to Docker.", + registry, + rleManifestFile, + ) } - if err := runGit(cmd, "", "clone", "--depth", "1", "--no-tags", "--branch", ref, "--single-branch", repo, repoDir); err != nil { - return "", err + return fmt.Sprintf( + "Sign in to the image registry if required, or set template.local.image in %s to an image available to Docker.", + rleManifestFile, + ) +} + +func acrRegistryName(image string) (string, bool) { + host := strings.ToLower(strings.TrimSpace(strings.SplitN(image, "/", 2)[0])) + const suffix = ".azurecr.io" + if !strings.HasSuffix(host, suffix) { + return "", false + } + registry := strings.TrimSuffix(host, suffix) + if registry == "" { + return "", false } - return prepareManagedLoomCookbook(sessionDir, recipeName, rleSdkPackage) + return registry, true } -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 +func localContainerStatus(cmd *cobra.Command, container string) (running bool, exists bool) { + //nolint:gosec // Fixed docker inspect command; container is a generated local name. + process := exec.CommandContext(cmd.Context(), "docker", "inspect", "-f", "{{.State.Running}}", container) process.Env = os.Environ() - if err := process.Run(); err != nil { - return fmt.Errorf("run git %s: %w", joinCommandArgs(args), err) + output, err := process.Output() + if err != nil { + return false, false } - return nil + return strings.TrimSpace(string(output)) == "true", true } -func managedLoomRepoPath(sessionDir string) string { - return filepath.Join(sessionDir, rleManagedDir, "recipes", "loom") +const localContainerImageLabel = "azd.ai.rle.local-image" + +func shouldRecreateContainer(cmd *cobra.Command, container string, requestedPort int, expectedImage string) bool { + actualPort, ok := localContainerHostPort(cmd, container) + if !ok { + _, _ = fmt.Fprintf( + cmd.ErrOrStderr(), + "Existing local container %s has no usable port mapping; recreating it ...\n", + container, + ) + return true + } + if actualPort != requestedPort { + _, _ = fmt.Fprintf( + cmd.ErrOrStderr(), + "Existing local container %s uses port %d; recreating it on port %d ...\n", + container, + actualPort, + requestedPort, + ) + return true + } + + actualImage, ok := localContainerExpectedImage(cmd, container) + if ok && actualImage == expectedImage { + return false + } + _, _ = fmt.Fprintf( + cmd.ErrOrStderr(), + "Existing local container %s was created for a different image; recreating it ...\n", + container, + ) + return true } -func managedLoomCookbookPath(sessionDir string) string { - return filepath.Join(managedLoomRepoPath(sessionDir), "loom-cookbook") +func localContainerHostPort(cmd *cobra.Command, container string) (int, bool) { + //nolint:gosec // Fixed docker inspect command; container is a generated local name. + process := exec.CommandContext( + cmd.Context(), + "docker", + "inspect", + "-f", + `{{(index (index .NetworkSettings.Ports "8000/tcp") 0).HostPort}}`, + container, + ) + process.Env = os.Environ() + output, err := process.Output() + if err != nil { + return 0, false + } + port, err := strconv.Atoi(strings.TrimSpace(string(output))) + if err != nil { + return 0, false + } + return port, true } -func prepareManagedLoomCookbook(sessionDir string, recipeName string, rleSdkPackage string) (string, error) { - cookbookPath, err := resolveLoomPath(managedLoomCookbookPath(sessionDir)) +func localContainerExpectedImage(cmd *cobra.Command, container string) (string, bool) { + //nolint:gosec // Fixed docker inspect command; container is a generated local name. + process := exec.CommandContext( + cmd.Context(), + "docker", + "inspect", + "-f", + "{{ index .Config.Labels \""+localContainerImageLabel+"\" }}", + container, + ) + process.Env = os.Environ() + output, err := process.Output() if err != nil { - return "", err + return "", false } - if err := ensureLoomRecipeExists(cookbookPath, recipeName); err != nil { - return "", err + image := strings.TrimSpace(string(output)) + if image == "" || image == "" { + return "", false } - return cookbookPath, ensureLoomLocalSources(cookbookPath, rleSdkPackage) + return image, true } -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.", +func runDocker(cmd *cobra.Command, args ...string) error { + //nolint:gosec // Fixed docker command shapes with selected names/tags. + process := exec.CommandContext(cmd.Context(), "docker", args...) + process.Stdout = cmd.OutOrStdout() + process.Stderr = cmd.ErrOrStderr() + process.Env = os.Environ() + return process.Run() +} + +func localContainerName(envName string) string { + return "azd-rle-" + slug(firstNonEmpty(envName, "environment")) +} + +func waitForOpenEnvHealth(baseUrl string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var lastErr error + client := &http.Client{Timeout: 2 * time.Second} + for time.Now().Before(deadline) { + resp, err := client.Get(strings.TrimRight(baseUrl, "/") + "/health") //nolint:gosec // Local user-selected endpoint. + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + lastErr = fmt.Errorf("health returned HTTP %d", resp.StatusCode) + } else { + lastErr = err } + time.Sleep(time.Second) + } + return &azdext.LocalError{ + Message: fmt.Sprintf("Local OpenEnv container did not become healthy at %s: %v", baseUrl, lastErr), + Code: "rle_local_container_not_ready", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Check docker logs for the generated container, then retry.", } - return nil } -func loomRecipeModule(recipeName string) string { - return fmt.Sprintf("loom_cookbook.recipes.%s.train_azure", recipeName) -} +func callOpenEnv(baseUrl string, operation string, flags *openEnvInvokeFlags) (string, error) { + method := http.MethodGet + var body io.Reader + if operation == "reset" || operation == "step" { + method = http.MethodPost + requestBody, err := openEnvRequestBody(operation, flags) + if err != nil { + return "", err + } + body = bytes.NewReader(requestBody) + } -func ensureLoomLocalSources(cookbookPath string, rleSdkPackage string) error { - pyprojectPath := filepath.Join(cookbookPath, "pyproject.toml") - data, err := os.ReadFile(pyprojectPath) + url := strings.TrimRight(baseUrl, "/") + "/" + operation + req, err := http.NewRequest(method, url, body) if err != nil { - return err + return "", err } - contents := string(data) - lines := []string{} - if !strings.Contains(contents, "[tool.uv.sources]") { - lines = append(lines, "[tool.uv.sources]") + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") } - 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\" }") + + client := openEnvHTTPClient(flags.timeout) + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("call OpenEnv %s %s: %w", method, url, err) } - if !strings.Contains(contents, "rle-sdk = { path = ") { - relativeRleSdkPackage, err := filepath.Rel(cookbookPath, rleSdkPackage) - if err != nil { - return err + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", &azdext.LocalError{ + Message: fmt.Sprintf("OpenEnv endpoint returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))), + Code: "rle_openenv_call_failed", + Category: azdext.LocalErrorCategoryUser, } - lines = append(lines, fmt.Sprintf("rle-sdk = { path = %q }", filepath.ToSlash(relativeRleSdkPackage))) } - if len(lines) == 0 { - return nil + + return prettyJson(data), nil +} + +func openEnvHTTPClient(timeoutSeconds int) *http.Client { + if timeoutSeconds <= 0 { + return &http.Client{} } + return &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second} +} - contents = strings.TrimRight(contents, "\r\n") + "\n\n" + strings.Join(lines, "\n") + "\n" - return os.WriteFile(pyprojectPath, []byte(contents), 0600) +func openEnvRequestBody(operation string, flags *openEnvInvokeFlags) ([]byte, error) { + if strings.TrimSpace(flags.body) != "" { + return validateJsonObject(flags.body, "body") + } + if operation == "reset" { + return []byte("{}"), nil + } + + action, err := validateJsonObject(flags.action, "action") + if err != nil { + return nil, err + } + var actionValue any + if err := json.Unmarshal(action, &actionValue); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"action": actionValue}) } -func joinCommandArgs(args []string) string { - result := "" - for i, arg := range args { - if i > 0 { - result += " " +func validateJsonObject(value string, flagName string) ([]byte, error) { + var decoded map[string]any + data := []byte(value) + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf("--%s must be valid JSON object: %v", flagName, err), + Code: "rle_invalid_json", + Category: azdext.LocalErrorCategoryUser, + Suggestion: jsonFlagSuggestion(flagName), } - result += strconv.Quote(arg) } - return result + return data, nil +} + +func jsonFlagSuggestion(flagName string) string { + if flagName == "body" { + return "Use reset {\"seed\":0} or another JSON object." + } + return "Use step {\"message\":\"hello\"} or another JSON object." +} + +func prettyJson(data []byte) string { + var decoded any + if err := json.Unmarshal(data, &decoded); err != nil { + return string(data) + } + formatted, err := json.MarshalIndent(decoded, "", " ") + if err != nil { + return string(data) + } + return string(formatted) } 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 index f1f1193f441..b4de31692ae 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -4,133 +4,166 @@ package cmd import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" "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, +func TestOpenEnvRequestBodyWrapsAction(t *testing.T) { + body, err := openEnvRequestBody("step", &openEnvInvokeFlags{action: `{"message":"hello"}`}) + if err != nil { + t.Fatal(err) } + if string(body) != `{"action":{"message":"hello"}}` { + t.Fatalf("unexpected body: %s", body) + } +} - 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 TestOpenEnvRequestBodyUsesRawBody(t *testing.T) { + body, err := openEnvRequestBody("step", &openEnvInvokeFlags{ + action: `{"message":"hello"}`, + body: `{"action":{"message":"override"},"metadata":{"x":1}}`, + }) + if err != nil { + t.Fatal(err) + } + expected := `{"action":{"message":"override"},"metadata":{"x":1}}` + if string(body) != expected { + t.Fatalf("expected %s, got %s", expected, body) } } -func TestBuildLoomInvokeArgsOmitsMaxStepsWhenZero(t *testing.T) { - state := rleState{ - EnvironmentId: "env-123", +func TestNormalizeOpenEnvOperation(t *testing.T) { + operation, err := normalizeOpenEnvOperation("/STEP") + if err != nil { + t.Fatal(err) } - flags := &rleInvokeFlags{ - projectEndpoint: "https://example.services.ai.azure.com/api/projects/p", - maxSteps: 0, + if operation != "step" { + t.Fatalf("expected step, got %q", operation) } + if _, err := normalizeOpenEnvOperation("unknown"); err == nil { + t.Fatal("expected unknown operation to fail") + } +} - args := buildLoomInvokeArgs(state, "demo-3", "http://localhost:5000", flags) +func TestRunOpenEnvShellCallsCommands(t *testing.T) { + var stepBody map[string]any + var resetBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/health": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/reset": + if err := json.NewDecoder(r.Body).Decode(&resetBody); err != nil { + t.Errorf("decode reset body: %v", err) + } + _, _ = w.Write([]byte(`{"reset":true}`)) + case "/step": + if err := json.NewDecoder(r.Body).Decode(&stepBody); err != nil { + t.Errorf("decode step body: %v", err) + } + _, _ = w.Write([]byte(`{"reward":1}`)) + case "/state": + _, _ = w.Write([]byte(`{"state":"ready"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() - if slices.Contains(args, "max_steps=0") { - t.Fatalf("expected max_steps to be omitted, got %#v", args) + input := strings.NewReader("health\nreset {\"seed\":0}\nstep {\"message\":\"hello\"}\nstate\nexit\n") + var output bytes.Buffer + if err := runOpenEnvShell(input, &output, server.URL, 30); err != nil { + t.Fatal(err) } -} -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) + if resetBody["seed"] != float64(0) { + t.Fatalf("expected reset seed body, got %#v", resetBody) + } + action, ok := stepBody["action"].(map[string]any) + if !ok || action["message"] != "hello" { + t.Fatalf("expected wrapped step action, got %#v", stepBody) + } + if !strings.Contains(output.String(), `"reward": 1`) { + t.Fatalf("expected pretty step response in output, got %s", output.String()) } } -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 { +func TestRunOpenEnvShellRequiresStepPayload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/step" { + t.Fatal("step without payload should not call the runtime") + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + input := strings.NewReader("step\nexit\n") + var output bytes.Buffer + if err := runOpenEnvShell(input, &output, server.URL, 30); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(recipeDir, "train_azure.py"), []byte(""), 0600); err != nil { - t.Fatal(err) + if !strings.Contains(output.String(), "step requires a JSON action payload") { + t.Fatalf("expected step payload error, got %s", output.String()) } +} - if err := ensureLoomRecipeExists(root, "code_rl_with_rle"); err != nil { - t.Fatal(err) +func TestLocalContainerNamesUseEnvironmentName(t *testing.T) { + if name := localContainerName("code_rl"); name != "azd-rle-code-rl" { + t.Fatalf("expected local container name, got %q", name) + } +} + +func TestDockerPullFailureSuggestionUsesAcrRegistryName(t *testing.T) { + suggestion := dockerPullFailureSuggestion("devrle.azurecr.io/echo-rl:latest") + if !strings.Contains(suggestion, "az acr login --name devrle") { + t.Fatalf("expected ACR login command, got %q", suggestion) } - if err := ensureLoomRecipeExists(root, "missing_recipe"); err == nil { - t.Fatal("expected missing recipe to fail") + if strings.Contains(suggestion, "") { + t.Fatalf("expected concrete registry name, got %q", suggestion) } } -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 { +func TestLocalRuntimeImageTreatsManifestAsAuthoritative(t *testing.T) { + tempDir := t.TempDir() + originalDir, err := os.Getwd() + if 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 { + if err := os.Chdir(tempDir); err != nil { t.Fatal(err) } - rleSdkPackage := filepath.Join(root, ".azd-rle", "deps", "rle_sdk-0.1.3-py3-none-any.whl") + t.Cleanup(func() { + if err := os.Chdir(originalDir); err != nil { + t.Fatal(err) + } + }) - if err := ensureLoomLocalSources(cookbookPath, rleSdkPackage); err != nil { + manifest := []byte(` +name: code_rl +template: + kind: openenv + local: + image: + environment: + image: +`) + if err := os.WriteFile(filepath.Join(tempDir, rleManifestFile), manifest, 0600); 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) - } + _, err = localRuntimeImage(rleState{ + Name: "code_rl", + LocalImage: "example.azurecr.io/stale-local:latest", + Image: "example.azurecr.io/stale-env:latest", + }) + if err == nil { + t.Fatal("expected empty manifest images to require an image") } } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go deleted file mode 100644 index 5c02ecc26f5..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/spf13/cobra" -) - -func newListCommand() *cobra.Command { - flags := &sharedFlags{ - account: defaultAccountName, - project: defaultProjectName, - } - - cmd := &cobra.Command{ - Use: "list", - Short: "List RLE environments", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) - environments, err := client.listEnvironments(cmd.Context(), flags.account, flags.project) - if err != nil { - return serviceError(err) - } - - encoded, err := json.MarshalIndent(environments, "", " ") - if err != nil { - return err - } - _, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) - return err - }, - } - - addSharedFlags(cmd, flags) - return cmd -} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go index 31da4b7c03c..1459c88e266 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go @@ -1,25 +1,56 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// cspell:ignore openenv + package cmd import ( + "context" "fmt" + "io" + "net/http" + "net/url" "os" + "regexp" "slices" "strings" + "time" "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"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Kind string `yaml:"kind"` + LocalImage string `yaml:"local_image"` + Image string `yaml:"image"` + RegistrationImage string `yaml:"registration_image"` + Template rleManifestTemplate `yaml:"template"` + Local rleManifestLocal `yaml:"local"` + Registration rleManifestRegistration `yaml:"registration"` + Environment rleManifestEnvironment `yaml:"environment"` +} + +type rleManifestTemplate struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Local rleManifestLocal `yaml:"local"` + Environment rleManifestEnvironment `yaml:"environment"` + Registration rleManifestRegistration `yaml:"registration"` + Image string `yaml:"image"` + LocalImage string `yaml:"local_image"` + RegistrationImage string `yaml:"registration_image"` +} + +type rleManifestLocal struct { + Image string `yaml:"image"` +} + +type rleManifestRegistration struct { + Image string `yaml:"image"` } type rleManifestEnvironment struct { @@ -27,11 +58,14 @@ type rleManifestEnvironment struct { } func loadRleManifest(path string) (rleManifest, error) { - data, err := os.ReadFile(path) //nolint:gosec // Manifest path is provided by the user. + data, err := readManifestContent(context.Background(), path) if err != nil { return rleManifest{}, err } + return parseRleManifest(data) +} +func parseRleManifest(data []byte) (rleManifest, error) { expanded, err := expandManifestEnv(string(data)) if err != nil { return rleManifest{}, err @@ -41,13 +75,98 @@ func loadRleManifest(path string) (rleManifest, error) { if err := yaml.Unmarshal([]byte(expanded), &manifest); err != nil { return rleManifest{}, err } + if err := validateManifestKind(manifest); err != nil { + return rleManifest{}, err + } return manifest, nil } +func readManifestContent(ctx context.Context, path string) ([]byte, error) { + if isRemoteManifestUrl(path) { + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(path)), "https://") { + return nil, &azdext.LocalError{ + Message: "Remote RLE manifest URLs must use HTTPS.", + Code: "rle_manifest_url_insecure", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use an https:// URL or a local manifest path.", + } + } + downloadUrl := normalizeManifestUrl(path) + requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, downloadUrl, nil) + if err != nil { + return nil, err + } + resp, err := manifestHTTPClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf("Failed to download RLE manifest from %s: HTTP %d.", downloadUrl, resp.StatusCode), + Code: "rle_manifest_download_failed", + Category: azdext.LocalErrorCategoryUser, + } + } + const maxManifestBytes = 1 << 20 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxManifestBytes { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf("RLE manifest from %s is larger than 1 MiB.", downloadUrl), + Code: "rle_manifest_too_large", + Category: azdext.LocalErrorCategoryUser, + } + } + return data, nil + } + return os.ReadFile(path) //nolint:gosec // Manifest path is provided by the user. +} + +func manifestHTTPClient() *http.Client { + return &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if !strings.EqualFold(req.URL.Scheme, "https") { + return &azdext.LocalError{ + Message: "Remote RLE manifest redirects must stay on HTTPS.", + Code: "rle_manifest_url_insecure_redirect", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use an HTTPS manifest URL that does not redirect to HTTP.", + } + } + return nil + }, + } +} + +func normalizeManifestUrl(raw string) string { + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Host, "github.com") { + return raw + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 5 || parts[2] != "blob" { + return raw + } + owner, repo, ref := parts[0], parts[1], parts[3] + path := strings.Join(parts[4:], "/") + return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", owner, repo, ref, path) +} + +func isRemoteManifestUrl(path string) bool { + value := strings.ToLower(strings.TrimSpace(path)) + return strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "http://") +} + func expandManifestEnv(content string) (string, error) { missing := map[string]struct{}{} - expanded := os.Expand(content, func(name string) string { + expanded := manifestEnvPattern.ReplaceAllStringFunc(content, func(token string) string { + name := token[2 : len(token)-1] value, ok := os.LookupEnv(name) if !ok { missing[name] = struct{}{} @@ -76,21 +195,56 @@ func expandManifestEnv(content string) (string, error) { } } +var manifestEnvPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + 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 + state := defaultRleState(firstNonEmpty(manifest.Name, manifest.Template.Name)) - image, err := resolveRecipeImage(state.Recipe, firstNonEmpty(manifest.Image, manifest.Environment.Image)) - if err != nil { - return rleState{}, err - } - state.Image = image + state.LocalImage = localImageFromManifest(manifest) + state.Image = registrationImageFromManifest(manifest) return state, nil } +func localImageFromManifest(manifest rleManifest) string { + return firstNonEmpty( + manifest.Template.Local.Image, + manifest.Template.LocalImage, + manifest.Local.Image, + manifest.LocalImage, + manifest.Template.Image, + manifest.Image, + manifest.Template.Environment.Image, + manifest.Environment.Image, + ) +} + +func registrationImageFromManifest(manifest rleManifest) string { + return firstNonEmpty( + manifest.Template.Environment.Image, + manifest.Template.Registration.Image, + manifest.Template.RegistrationImage, + manifest.Environment.Image, + manifest.Registration.Image, + manifest.RegistrationImage, + manifest.Template.Image, + manifest.Image, + ) +} + +func validateManifestKind(manifest rleManifest) error { + kind := strings.ToLower(firstNonEmpty(manifest.Template.Kind, manifest.Kind)) + if kind == "" || kind == "openenv" { + return nil + } + return &azdext.LocalError{ + Message: fmt.Sprintf("Unsupported RLE manifest kind %q.", kind), + Code: "rle_manifest_kind_unsupported", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use template.kind: openenv.", + } +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { 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 index 9cbea4fabf8..b702ac0e64b 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest_test.go @@ -4,49 +4,68 @@ package cmd import ( + "context" + "net/http" + "os" + "path/filepath" "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") + expanded, err := expandManifestEnv("image: ${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 { + if _, err := expandManifestEnv("image: ${RLE_MISSING_IMAGE}\n"); err == nil { t.Fatal("expected missing environment variable to fail") } } +func TestExpandManifestEnvIgnoresUnbracedDollarReferences(t *testing.T) { + manifest := "# yaml-language-server: $schema=https://example/rle.schema.json\nname: code_rl\n" + + expanded, err := expandManifestEnv(manifest) + if err != nil { + t.Fatal(err) + } + if expanded != manifest { + t.Fatalf("expected unbraced dollar reference to be preserved, got %q", expanded) + } +} + +func TestExpandManifestEnvPreservesLiteralDollarText(t *testing.T) { + manifest := "description: Price is $5 and schema hint is $schema\n" + + expanded, err := expandManifestEnv(manifest) + if err != nil { + t.Fatal(err) + } + if expanded != manifest { + t.Fatalf("expected literal dollar text to be preserved, got %q", expanded) + } +} + func TestStateFromManifestUsesResolvedImage(t *testing.T) { state, err := stateFromManifest(rleManifest{ - Name: "code_rl", - Project: "demo", - Endpoint: "http://localhost:5000", - Image: "example.azurecr.io/code:latest", + Name: "code_rl", + 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.Project != "" { + t.Fatalf("expected manifest not to set project, got %q", state.Project) } if state.Name != "code_rl" { t.Fatalf("expected default environment name, got %q", state.Name) @@ -54,4 +73,128 @@ func TestStateFromManifestUsesResolvedImage(t *testing.T) { if state.Image != "example.azurecr.io/code:latest" { t.Fatalf("expected resolved image, got %q", state.Image) } + if state.LocalImage != "example.azurecr.io/code:latest" { + t.Fatalf("expected local image fallback, got %q", state.LocalImage) + } +} + +func TestStateFromManifestSupportsSeparateLocalAndRegistrationImages(t *testing.T) { + state, err := stateFromManifest(rleManifest{ + Name: "code_rl", + Template: rleManifestTemplate{ + Kind: "openenv", + Local: rleManifestLocal{ + Image: "example.azurecr.io/code:smoke", + }, + Environment: rleManifestEnvironment{ + Image: "example.azurecr.io/code:deepcoder", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + if state.LocalImage != "example.azurecr.io/code:smoke" { + t.Fatalf("expected local image, got %q", state.LocalImage) + } + if state.Image != "example.azurecr.io/code:deepcoder" { + t.Fatalf("expected registration image, got %q", state.Image) + } +} + +func TestStateFromManifestUsesTemplateName(t *testing.T) { + state, err := stateFromManifest(rleManifest{ + Template: rleManifestTemplate{ + Name: "code_rl", + Kind: "openenv", + Local: rleManifestLocal{ + Image: "example.azurecr.io/code:smoke", + }, + Environment: rleManifestEnvironment{ + Image: "example.azurecr.io/code:deepcoder", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "code_rl" { + t.Fatalf("expected template name, got %q", state.Name) + } +} + +func TestValidateManifestKindRejectsUnsupportedKinds(t *testing.T) { + err := validateManifestKind(rleManifest{ + Template: rleManifestTemplate{Kind: "hosted"}, + }) + if err == nil { + t.Fatal("expected unsupported manifest kind to fail") + } +} + +func TestNormalizeManifestUrlConvertsGitHubBlobUrl(t *testing.T) { + got := normalizeManifestUrl("https://github.com/owner/repo/blob/main/path/to/rle.yaml") + expected := "https://raw.githubusercontent.com/owner/repo/main/path/to/rle.yaml" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestReadManifestContentRejectsHttpUrls(t *testing.T) { + _, err := readManifestContent(context.Background(), "http://example.com/rle.yaml") + if err == nil { + t.Fatal("expected http manifest URL to fail") + } + _, err = readManifestContent(context.Background(), "https://github.com/owner/repo/blob/main/rle.yaml") + if err == nil { + t.Fatal("expected http GitHub manifest URL to fail") + } +} + +func TestManifestHTTPClientRejectsHttpRedirects(t *testing.T) { + redirectReq, err := http.NewRequest(http.MethodGet, "http://example.com/rle.yaml", nil) + if err != nil { + t.Fatal(err) + } + + if err := manifestHTTPClient().CheckRedirect(redirectReq, nil); err == nil { + t.Fatal("expected http redirect target to fail") + } +} + +func TestLoadManifestAllowsFutureSourceFields(t *testing.T) { + manifestPath := filepath.Join(t.TempDir(), "rle.yaml") + manifest := []byte(` +name: code_rl +template: + name: code_rl + kind: openenv + source: + type: huggingface-space + repo: openenv/echo_env + local: + image: example.azurecr.io/code:smoke + environment: + image: example.azurecr.io/code:prod +`) + if err := os.WriteFile(manifestPath, manifest, 0600); err != nil { + t.Fatal(err) + } + + loaded, err := loadRleManifest(manifestPath) + if err != nil { + t.Fatal(err) + } + + state, err := stateFromManifest(loaded) + if err != nil { + t.Fatal(err) + } + if state.LocalImage != "example.azurecr.io/code:smoke" { + t.Fatalf("expected local image, got %q", state.LocalImage) + } + if state.Image != "example.azurecr.io/code:prod" { + t.Fatalf("expected environment image, got %q", state.Image) + } } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go deleted file mode 100644 index b38763c0032..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "fmt" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -const defaultRecipeName = "code_rl_with_rle" - -// defaultRegistryLoginServer hosts derived per-environment images when no explicit -// image is provided. -const defaultRegistryLoginServer = "devrle.azurecr.io" - -// recipeImages maps a recipe name to a default image. An empty value means the image -// repository is derived from the environment name at deploy time. -var recipeImages = map[string]string{ - defaultRecipeName: "", -} - -func resolveRecipeImage(recipe string, imageOverride string) (string, error) { - if imageOverride != "" { - return imageOverride, nil - } - - image, ok := recipeImages[recipe] - if !ok { - return "", &azdext.LocalError{ - Message: fmt.Sprintf("Unknown RLE recipe %q.", recipe), - Code: "rle_unknown_recipe", - Category: azdext.LocalErrorCategoryUser, - Suggestion: fmt.Sprintf("Use recipe %s or provide an image in rle.yaml.", defaultRecipeName), - } - } - - return image, nil -} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go deleted file mode 100644 index 3925136f1c4..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/recipe_test.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import "testing" - -func TestResolveRecipeImageDerivesFromEnvName(t *testing.T) { - image, err := resolveRecipeImage(defaultRecipeName, "") - if err != nil { - t.Fatal(err) - } - if image != "" { - t.Fatalf("expected empty image so it is derived from the environment name, got %q", image) - } -} - -func TestResolveRecipeImageAllowsOverride(t *testing.T) { - image, err := resolveRecipeImage("unknown", "example.azurecr.io/custom:latest") - if err != nil { - t.Fatal(err) - } - if image != "example.azurecr.io/custom:latest" { - t.Fatalf("expected override image, got %q", image) - } -} - -func TestResolveRecipeImageRejectsUnknownRecipe(t *testing.T) { - if _, err := resolveRecipeImage("unknown", ""); err == nil { - t.Fatal("expected unknown recipe to fail") - } -} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go index 6e6dd231297..98f8514d178 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -34,6 +34,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newDeployCommand()) rootCmd.AddCommand(newInitCommand()) rootCmd.AddCommand(newInvokeCommand()) + rootCmd.AddCommand(newRunCommand()) 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 5678499bd79..5bdd316deea 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 @@ -3,24 +3,164 @@ package cmd -import "testing" +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { rootCmd := NewRootCommand() - for _, commandName := range []string{"deploy", "init", "invoke", "version", "metadata"} { + for _, commandName := range []string{"deploy", "init", "invoke", "run", "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) { +func TestDeployExposesProjectIdFlag(t *testing.T) { rootCmd := NewRootCommand() + command, _, err := rootCmd.Find([]string{"deploy"}) + if err != nil { + t.Fatalf("expected deploy command to be registered: %v", err) + } + if flag := command.Flags().Lookup("project-id"); flag == nil { + t.Fatal("expected deploy to expose --project-id") + } +} + +func TestLifecycleFlagsAlignWithHostedAgentConventions(t *testing.T) { + rootCmd := NewRootCommand() + + initCommand, _, err := rootCmd.Find([]string{"init"}) + if err != nil { + t.Fatalf("expected init command to be registered: %v", err) + } + var initHelp bytes.Buffer + initCommand.SetOut(&initHelp) + if err := initCommand.Help(); err != nil { + t.Fatal(err) + } + if strings.Contains(initHelp.String(), "--manifest") { + t.Fatal("expected init help not to expose --manifest") + } + if !strings.Contains(initHelp.String(), "-m string") { + t.Fatal("expected init help to expose -m") + } + + runCommand, _, err := rootCmd.Find([]string{"run"}) + if err != nil { + t.Fatalf("expected run command to be registered: %v", err) + } + if flag := runCommand.Flags().Lookup("port"); flag == nil { + t.Fatal("expected run to expose --port") + } + + invokeCommand, _, err := rootCmd.Find([]string{"invoke"}) + if err != nil { + t.Fatalf("expected invoke command to be registered: %v", err) + } + if flag := invokeCommand.Flags().Lookup("timeout"); flag == nil { + t.Fatal("expected invoke to expose --timeout") + } + if flag := invokeCommand.Flags().Lookup("local"); flag == nil { + t.Fatal("expected invoke to expose --local") + } +} + +func TestInitParsesManifestShorthandOnly(t *testing.T) { + if initUsedLongManifestFlag([]string{"init", "-m", "rle.yaml"}) { + t.Fatal("expected -m to be allowed") + } + if !initUsedLongManifestFlag([]string{"init", "--manifest", "rle.yaml"}) { + t.Fatal("expected --manifest to be detected") + } + if !initUsedLongManifestFlag([]string{"init", "--manifest=rle.yaml"}) { + t.Fatal("expected --manifest=... to be detected") + } +} - 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) +func TestLifecycleCommandsRejectPositionalArguments(t *testing.T) { + rootCmd := NewRootCommand() + + for _, commandName := range []string{"deploy", "init", "invoke", "run"} { + command, _, err := rootCmd.Find([]string{commandName}) + if err != nil { + t.Fatalf("expected command %q to be registered: %v", commandName, err) + } + if err := command.Args(command, []string{"unexpected"}); err == nil { + t.Fatalf("expected command %q to reject positional arguments", commandName) } } } + +func TestInitUsesEmbeddedEchoManifestByDefault(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + command := newInitCommand() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + + sessionDir := filepath.Join(tempDir, "echo_env") + manifestBytes, err := os.ReadFile(filepath.Join(sessionDir, rleManifestFile)) + if err != nil { + t.Fatal(err) + } + if got := string(manifestBytes); got != defaultEchoManifest { + t.Fatalf("expected embedded echo manifest, got:\n%s", got) + } + + stateBytes, err := os.ReadFile(filepath.Join(sessionDir, rleStateFile)) + if err != nil { + t.Fatal(err) + } + var state rleState + if err := json.Unmarshal(stateBytes, &state); err != nil { + t.Fatal(err) + } + if state.Name != "echo_env" { + t.Fatalf("expected echo_env state name, got %q", state.Name) + } + if state.Image != "devrle.azurecr.io/echo-rl:latest" { + t.Fatalf("expected echo image from manifest, got %q", state.Image) + } + if state.LocalImage != "devrle.azurecr.io/echo-rl:latest" { + t.Fatalf("expected local image fallback from manifest, got %q", state.LocalImage) + } +} + +func TestInitInfersNameFromManifestOnly(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + manifestPath := filepath.Join(tempDir, "custom-rle.yaml") + if err := os.WriteFile(manifestPath, []byte(`template: + name: code_rl + kind: openenv + environment: + image: example.azurecr.io/code:latest +`), 0600); err != nil { + t.Fatal(err) + } + + command := newInitCommand() + command.SetArgs([]string{"-m", manifestPath}) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(filepath.Join(tempDir, "code_rl", rleStateFile)); err != nil { + t.Fatal(err) + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go deleted file mode 100644 index e1b448e75b8..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox.go +++ /dev/null @@ -1,313 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -type sandboxFlags struct { - sharedFlags - version string - cpu string - memory string - disk string - wait bool - timeout time.Duration - interval time.Duration -} - -func newSandboxCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "sandbox ", - Short: "Manage RLE environment sandboxes", - } - - cmd.AddCommand(newSandboxCreateCommand()) - cmd.AddCommand(newSandboxListCommand()) - cmd.AddCommand(newSandboxShowCommand()) - return cmd -} - -func newSandboxCreateCommand() *cobra.Command { - flags := &sandboxFlags{ - sharedFlags: sharedFlags{ - account: defaultAccountName, - project: defaultProjectName, - }, - } - - cmd := &cobra.Command{ - Use: "create ", - Short: "Create an RLE sandbox", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) - request := sandboxCreateRequest{ - Version: flags.version, - Cpu: flags.cpu, - Memory: flags.memory, - Disk: flags.disk, - } - - sandbox, err := createSandbox(cmd, client, flags, args[0], request) - if err != nil { - return sandboxCreateError(err) - } - - if !isJsonOutput(cmd) { - return printSandboxCreated(cmd, sandbox) - } - - return printJson(cmd, sandbox) - }, - } - - addSharedFlags(cmd, &flags.sharedFlags) - cmd.Flags().StringVar(&flags.version, "version-label", "", "Environment version to sandbox. Defaults to latest") - cmd.Flags().StringVar(&flags.cpu, "cpu", "", "Sandbox CPU request. Defaults to server configuration") - cmd.Flags().StringVar(&flags.memory, "memory", "", "Sandbox memory request. Defaults to server configuration") - cmd.Flags().StringVar(&flags.disk, "disk", "", "Sandbox disk request. Defaults to server configuration") - cmd.Flags().BoolVar(&flags.wait, "wait", false, "Wait until disk image conversion is ready and sandbox creation succeeds") - cmd.Flags().DurationVar(&flags.timeout, "wait-timeout", 30*time.Minute, "Maximum time to wait for sandbox creation") - cmd.Flags().DurationVar(&flags.interval, "wait-interval", 30*time.Second, "Time to wait between sandbox creation attempts") - return cmd -} - -func newSandboxListCommand() *cobra.Command { - flags := &sharedFlags{ - account: defaultAccountName, - project: defaultProjectName, - } - - cmd := &cobra.Command{ - Use: "list ", - Short: "List RLE sandboxes for an environment", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) - sandboxes, err := client.listSandboxes(cmd.Context(), flags.account, flags.project, args[0]) - if err != nil { - return serviceError(err) - } - - return printJson(cmd, sandboxes) - }, - } - - addSharedFlags(cmd, flags) - return cmd -} - -func newSandboxShowCommand() *cobra.Command { - flags := &sharedFlags{ - account: defaultAccountName, - project: defaultProjectName, - } - - cmd := &cobra.Command{ - Use: "show ", - Short: "Show an RLE sandbox", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) - sandbox, err := client.getSandbox(cmd.Context(), flags.account, flags.project, args[0], args[1]) - if err != nil { - return serviceError(err) - } - - return printJson(cmd, sandbox) - }, - } - - addSharedFlags(cmd, flags) - return cmd -} - -func createSandbox( - cmd *cobra.Command, - client *rleClient, - flags *sandboxFlags, - environmentId string, - request sandboxCreateRequest, -) (*sandboxResource, error) { - if !flags.wait { - return client.createSandbox(cmd.Context(), flags.account, flags.project, environmentId, request) - } - if flags.timeout <= 0 { - return nil, waitFlagError("wait-timeout") - } - if flags.interval <= 0 { - return nil, waitFlagError("wait-interval") - } - - return createSandboxWithWait( - cmd.Context(), - client, - flags.account, - flags.project, - environmentId, - request, - flags.timeout, - flags.interval, - func(message string, interval time.Duration) { - fmt.Fprintf(cmd.ErrOrStderr(), "%s Retrying in %s.\n", message, interval) - }, - ) -} - -func waitFlagError(flagName string) error { - return &azdext.LocalError{ - Message: fmt.Sprintf("--%s must be greater than 0.", flagName), - Code: "rle_invalid_wait_option", - Category: azdext.LocalErrorCategoryUser, - } -} - -func sandboxCreateError(err error) error { - var localErr *azdext.LocalError - if errors.As(err, &localErr) { - return err - } - - var httpErr *rleHTTPError - if errors.As(err, &httpErr) && httpErr.statusCode == 409 { - message := extractRleErrorMessage(httpErr.body) - return &azdext.LocalError{ - Message: message, - Code: "rle_sandbox_not_ready", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "The server creates the disk image asynchronously after environment creation. Retry sandbox create after conversion is ready; if the status is Failed, recreate/update the environment with a valid ACR image.", - } - } - - return serviceError(err) -} - -func retryableSandboxCreateError(err error) (string, bool) { - var httpErr *rleHTTPError - if !errors.As(err, &httpErr) || httpErr.statusCode != 409 { - return "", false - } - - message := extractRleErrorMessage(httpErr.body) - return message, strings.Contains(message, "conversion status: Pending") || - strings.Contains(message, "conversion status: NotRequested") -} - -func createSandboxWithWait( - ctx context.Context, - client *rleClient, - account string, - project string, - environmentId string, - request sandboxCreateRequest, - timeout time.Duration, - interval time.Duration, - onRetry func(message string, interval time.Duration), -) (*sandboxResource, error) { - if timeout <= 0 { - return nil, waitFlagError("wait-timeout") - } - if interval <= 0 { - return nil, waitFlagError("wait-interval") - } - - waitCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - var lastMessage string - for { - sandbox, err := client.createSandbox(waitCtx, account, project, environmentId, request) - if err == nil { - return sandbox, nil - } - if errors.Is(err, context.DeadlineExceeded) { - return nil, sandboxCreateWaitTimeout(timeout, lastMessage) - } - - message, retry := retryableSandboxCreateError(err) - if !retry { - return nil, err - } - lastMessage = message - if onRetry != nil { - onRetry(message, interval) - } - - timer := time.NewTimer(interval) - select { - case <-waitCtx.Done(): - timer.Stop() - return nil, sandboxCreateWaitTimeout(timeout, lastMessage) - case <-timer.C: - } - } -} - -func sandboxCreateWaitTimeout(timeout time.Duration, lastMessage string) error { - message := fmt.Sprintf("Timed out after %s waiting for sandbox creation.", timeout) - if lastMessage != "" { - message = fmt.Sprintf("%s Last response: %s", message, lastMessage) - } - - return &azdext.LocalError{ - Message: message, - Code: "rle_sandbox_wait_timeout", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Check the RLE control plane logs for disk image conversion status, then retry sandbox create.", - } -} - -func extractRleErrorMessage(body string) string { - var payload struct { - Error any `json:"error"` - } - - if err := json.Unmarshal([]byte(body), &payload); err == nil && payload.Error != nil { - switch value := payload.Error.(type) { - case string: - return value - case map[string]any: - if message, ok := value["message"].(string); ok && message != "" { - return message - } - } - } - - return strings.TrimSpace(body) -} - -func printJson(cmd *cobra.Command, value any) error { - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - _, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) - return err -} - -func printSandboxCreated(cmd *cobra.Command, sandbox *sandboxResource) error { - _, err := fmt.Fprintf( - cmd.OutOrStdout(), - "Sandbox created: %s\nStatus: %s\nURL: %s\n", - sandbox.Id, - sandbox.Status, - sandbox.Url, - ) - return err -} - -func isJsonOutput(cmd *cobra.Command) bool { - flag := cmd.Flag("output") - return flag != nil && strings.EqualFold(flag.Value.String(), "json") -} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox_test.go deleted file mode 100644 index 021bc1a139b..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/sandbox_test.go +++ /dev/null @@ -1,33 +0,0 @@ -// 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 index a4dbca60dae..f0863ad86f4 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// cspell:ignore abstractmethod asctime devrle gethostname kwargs openenv rstrip urlopen + package cmd import ( @@ -15,6 +17,17 @@ import ( var envNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) +const defaultEchoManifest = `# RLE environment manifest (OpenEnv-style). +spec_version: 1 +name: echo_env +description: A local echo OpenEnv environment. +template: + name: echo_env + kind: openenv + environment: + image: devrle.azurecr.io/echo-rl:latest +` + func validateEnvName(name string) (string, error) { name = strings.TrimSpace(name) if !envNamePattern.MatchString(name) { @@ -23,15 +36,24 @@ 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) +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 name, nil + return strings.Trim(builder.String(), "-") } -func scaffoldRleSession(name string, dest string, image string, force bool) (string, error) { +func scaffoldRleSession(name string, dest string, localImage string, registrationImage string, force bool) (string, error) { envClass := toPascal(name) envTitle := toTitle(name) sessionDir := filepath.Join(dest, name) @@ -49,11 +71,9 @@ func scaffoldRleSession(name string, dest string, image string, force bool) (str 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, @@ -61,10 +81,11 @@ func scaffoldRleSession(name string, dest string, image string, force bool) (str } replacements := map[string]string{ - "__ENV_NAME__": name, - "__ENV_CLASS__": envClass, - "__ENV_TITLE__": envTitle, - "__IMAGE__": image, + "__ENV_NAME__": name, + "__ENV_CLASS__": envClass, + "__ENV_TITLE__": envTitle, + "__LOCAL_IMAGE__": localImage, + "__IMAGE__": registrationImage, } for relativePath, content := range files { @@ -109,11 +130,14 @@ func toTitle(name string) string { 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__ +description: A local echo OpenEnv environment. +template: + name: __ENV_NAME__ + kind: openenv + local: + image: __LOCAL_IMAGE__ + environment: + image: __IMAGE__ ` const requirementsTemplate = `fastapi>=0.110 @@ -121,14 +145,6 @@ 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 @@ -139,20 +155,17 @@ 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") + message: str = Field(default="hello", description="Message to echo") 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") + message: str = Field(default="", description="Echo response") ` const environmentTemplate = `"""__ENV_TITLE__ environment implementation.""" -from __future__ import annotations - import uuid from typing import Any, Optional @@ -162,10 +175,7 @@ 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. - """ + """A minimal echo environment for smoke testing the OpenEnv runtime.""" def __init__(self) -> None: self._episode_id: Optional[str] = None @@ -180,20 +190,17 @@ class __ENV_CLASS__Environment(Environment): self._episode_id = episode_id or str(uuid.uuid4()) self._step_count = 0 return __ENV_CLASS__Observation( - echoed_message="", - message_length=0, + message="Echo environment reset.", done=False, - reward=None, + reward=0.0, ) 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), + message=f"echo: {action.message}", done=False, - reward=float(len(message)), + reward=0.0, ) @property @@ -231,16 +238,13 @@ if __name__ == "__main__": 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/ +Use this for quick local testing against the container directly +(for example, http://localhost:32891). Example: from client import __ENV_CLASS__Client - base = ("http://localhost:5001/rle/v1.0/projects/proj1" - "/environments//instances/") + base = "http://localhost:8000" c = __ENV_CLASS__Client(base) print(c.reset()) print(c.step({"message": "hello"})) @@ -285,28 +289,6 @@ class __ENV_CLASS__Client: 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. @@ -315,12 +297,11 @@ An OpenEnv-style Reinforcement Learning Environment generated by the ` + "`rle`" | File | Purpose | | --- | --- | -| ` + "`rle_runtime.py`" + ` | Self-contained OpenEnv-compatible runtime (base models + FastAPI factory). | +| ` + "`rle_runtime.py`" + ` | OpenEnv-compatible runtime and 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`" + `. | +| ` + "`server/__ENV_NAME___environment.py`" + ` | Environment reset/step/state logic. | +| ` + "`server/app.py`" + ` | FastAPI app exposing the OpenEnv endpoints. | | ` + "`client.py`" + ` | Minimal HTTP client for testing. | -| ` + "`Dockerfile`" + ` | Builds the environment image (listens on ` + "`:8000`" + `). | | ` + "`rle.yaml`" + ` | Environment manifest. | ## Run locally (no Docker) @@ -335,16 +316,27 @@ curl -X POST localhost:8000/step -H 'content-type: application/json' \ curl localhost:8000/state ` + "```" + ` -## Deploy into Foundry +## Run with azd ` + "```bash" + ` -rle deploy --project --path . --name __ENV_NAME__ \ - --control-plane http://localhost:5000 +azd ai rle run +azd ai rle invoke --local ` + "```" + ` -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. +Inside the invoke shell: + +` + "```text" + ` +rle> health +rle> reset {"seed":0} +rle> step {"message":"hello"} +rle> state +rle> exit +` + "```" + ` + +` + "`run`" + ` uses ` + "`template.local.image`" + ` when set, falls back to +` + "`template.environment.image`" + `, and runs that image locally with Docker. + +` + "`deploy`" + ` registers ` + "`template.environment.image`" + ` with RLE. ` const runtimeTemplate = `# rle_runtime.py diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go deleted file mode 100644 index a6726cafcdb..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/spf13/cobra" -) - -func newShowCommand() *cobra.Command { - flags := &sharedFlags{ - account: defaultAccountName, - project: defaultProjectName, - } - - cmd := &cobra.Command{ - Use: "show ", - Short: "Show an RLE environment", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client := newRleClient(resolveControlPlaneEndpoint(flags.endpoint)) - environment, err := client.getEnvironment(cmd.Context(), flags.account, flags.project, args[0]) - if err != nil { - return serviceError(err) - } - - encoded, err := json.MarshalIndent(environment, "", " ") - if err != nil { - return err - } - _, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) - return err - }, - } - - addSharedFlags(cmd, flags) - return cmd -} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go index a0f4d4c8c58..f01ec435ab4 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go @@ -19,23 +19,17 @@ const ( type rleState struct { Name string `json:"name"` - Recipe string `json:"recipe"` + LocalImage string `json:"localImage,omitempty"` Image string `json:"image,omitempty"` - Account string `json:"account"` + Port int `json:"port,omitempty"` 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 { +func defaultRleState(name string) rleState { return rleState{ - Name: name, - Recipe: recipe, - Account: defaultAccountName, - Project: defaultProjectName, + Name: name, } } @@ -46,7 +40,7 @@ func loadRleState() (rleState, error) { 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.", + Suggestion: "Run azd ai rle init first, then run commands from the created session folder.", } } if err != nil { @@ -57,15 +51,6 @@ func loadRleState() (rleState, error) { 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 } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/versions.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/versions.go deleted file mode 100644 index a2beed37eb5..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/versions.go +++ /dev/null @@ -1,41 +0,0 @@ -// 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 3a57bd36adf2f8064b8366e5857142482abf4b22 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Mon, 29 Jun 2026 20:55:55 +0530 Subject: [PATCH 13/25] fix: update README --- cli/azd/extensions/azure.ai.rle/README.md | 57 +++++++------------ .../azure.ai.rle/internal/cmd/invoke.go | 23 +------- .../azure.ai.rle/internal/cmd/invoke_test.go | 10 ++-- 3 files changed, 27 insertions(+), 63 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 75e14ae033e..ac6c458db74 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -8,7 +8,6 @@ Quickstart for the `azd ai rle` preview extension. The extension manages an Open Install: - Azure Developer CLI (`azd`): https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd -- Azure CLI (`az`): https://learn.microsoft.com/cli/azure/install-azure-cli - Docker Desktop: https://www.docker.com/products/docker-desktop/ - Go, if installing from this local source checkout: https://go.dev/doc/install - Git, if installing from this local source checkout: https://git-scm.com/downloads @@ -17,22 +16,7 @@ Verify: ```powershell azd version -az version docker version -go version -git --version -``` - -Sign in before pulling private ACR images or deploying: - -```powershell -az login -``` - -If the local or environment image is in a private ACR, also sign in to that registry: - -```powershell -az acr login --name ``` ## Install the extension from this checkout @@ -93,6 +77,22 @@ template: image: devrle.azurecr.io/echo-rl:latest ``` +A custom manifest uses the same OpenEnv-style shape: + +```yaml +name: code_rl +description: Code RL OpenEnv environment. +template: + name: code_rl + kind: openenv + local: + image: devrle.azurecr.io/code_rl-env:latest + environment: + image: devrle.azurecr.io/code_rl-env:latest +``` + +`template.local.image` is optional. If omitted, local run/invoke use `template.environment.image`. + To initialize from an existing manifest instead: ```powershell @@ -159,25 +159,8 @@ azd ai rle deploy --project-id Deploy registers `template.environment.image` from `rle.yaml` with the RLE control plane and saves the project/environment details in `.azd-rle.json`. -## Manifest shape - -```yaml -name: code_rl -description: Code RL OpenEnv environment. -template: - name: code_rl - kind: openenv - local: - image: devrle.azurecr.io/code-rl:latest - environment: - image: devrle.azurecr.io/code-rl:prod -``` - -`template.local.image` is optional. If omitted, local run/invoke use `template.environment.image`. - -## Current limitations +## Upcoming features -- `run` is local only and requires Docker. -- `invoke` supports local shell mode only (`--local`). -- `deploy` requires `--project-id`; project selection/creation is not part of `init`. -- The extension does not build or push images. Images must already exist and be accessible. +- Remote environment invocation with `azd ai rle invoke`. +- Playground UI for exploring and testing RLE environments interactively. +- `azd provision` integration to choose an existing Foundry project or create a new one. 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 a023e4ad3d6..2166ec377f4 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -347,32 +347,13 @@ func missingLocalRuntimeImageError() error { } func dockerPullFailureSuggestion(image string) string { - if registry, ok := acrRegistryName(image); ok { - return fmt.Sprintf( - "Run az acr login --name %s, or set template.local.image in %s to an image available to Docker.", - registry, - rleManifestFile, - ) - } return fmt.Sprintf( - "Sign in to the image registry if required, or set template.local.image in %s to an image available to Docker.", + "Ensure Docker can pull %s, or set template.local.image in %s to an image available to Docker.", + image, rleManifestFile, ) } -func acrRegistryName(image string) (string, bool) { - host := strings.ToLower(strings.TrimSpace(strings.SplitN(image, "/", 2)[0])) - const suffix = ".azurecr.io" - if !strings.HasSuffix(host, suffix) { - return "", false - } - registry := strings.TrimSuffix(host, suffix) - if registry == "" { - return "", false - } - return registry, true -} - func localContainerStatus(cmd *cobra.Command, container string) (running bool, exists bool) { //nolint:gosec // Fixed docker inspect command; container is a generated local name. process := exec.CommandContext(cmd.Context(), "docker", "inspect", "-f", "{{.State.Running}}", container) 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 index b4de31692ae..b196b5b9af8 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -120,13 +120,13 @@ func TestLocalContainerNamesUseEnvironmentName(t *testing.T) { } } -func TestDockerPullFailureSuggestionUsesAcrRegistryName(t *testing.T) { +func TestDockerPullFailureSuggestionUsesImageName(t *testing.T) { suggestion := dockerPullFailureSuggestion("devrle.azurecr.io/echo-rl:latest") - if !strings.Contains(suggestion, "az acr login --name devrle") { - t.Fatalf("expected ACR login command, got %q", suggestion) + if !strings.Contains(suggestion, "Ensure Docker can pull devrle.azurecr.io/echo-rl:latest") { + t.Fatalf("expected Docker pull guidance, got %q", suggestion) } - if strings.Contains(suggestion, "") { - t.Fatalf("expected concrete registry name, got %q", suggestion) + if strings.Contains(suggestion, "az ") { + t.Fatalf("did not expect Azure sign-in guidance, got %q", suggestion) } } From 29dd67ccd27cc1707b2851b68df3cdc8d3ebacfa Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Mon, 29 Jun 2026 21:05:29 +0530 Subject: [PATCH 14/25] fix: draft spec --- cli/azd/extensions/azure.ai.rle/SPEC.md | 350 ++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.rle/SPEC.md diff --git a/cli/azd/extensions/azure.ai.rle/SPEC.md b/cli/azd/extensions/azure.ai.rle/SPEC.md new file mode 100644 index 00000000000..28864d37612 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/SPEC.md @@ -0,0 +1,350 @@ +# RLE Environment Registration - OpenEnv Manifest + + +This spec defines the `azd ai rle` preview workflow for managing Reinforcement Learning Environment (RLE) +recipes using an OpenEnv-style manifest. + +The goal is to move recipe/environment handling out of client-side SDK code and into a declarative manifest +that `azd` can use to initialize, run, invoke, and register RLE environments. + +RLE environment registration follows a manifest-first sequence: + +1. **Init** - User initializes an RLE session from an embedded default manifest or an existing `rle.yaml`. +2. **Run locally** - `azd` pulls and runs the environment Docker image locally. +3. **Invoke locally** - User interacts with the running OpenEnv runtime through a local shell. +4. **Deploy/register** - `azd` registers the environment image with the RLE control plane. + +--- + +## Goals + +- Provide a simple `azd ai rle` workflow for OpenEnv-style RLE environments. +- Represent environment recipes as an `rle.yaml` manifest. +- Support local validation before registering the environment. +- Keep training/client SDKs focused on training, not recipe/environment lifecycle management. +- Establish the azd command shape for future remote invoke, playground UI, and `azd provision` integration. + +--- + +## Preview Scope + +The preview focuses on existing Docker images. The manifest references images that Docker can already pull. + +The preview uses explicit project selection during deployment: + +```powershell +azd ai rle deploy --project-id +``` + +Remote invocation, playground UI, and Foundry project provisioning are planned follow-up features. + +--- + +## Manifest Contract + +An RLE environment is described by an OpenEnv-style manifest. + +```yaml +name: code_rl +description: Code RL OpenEnv environment. +template: + name: code_rl + kind: openenv + local: + image: devrle.azurecr.io/code_rl-env:latest + environment: + image: devrle.azurecr.io/code_rl-env:latest +``` + +| Field | Required | Description | +|---|---:|---| +| `name` | Yes | RLE session/environment name. Used for local folder/session state. | +| `description` | No | Human-readable environment description. | +| `template.name` | Yes | Template/environment name. Used as fallback when `name` is absent. | +| `template.kind` | Yes | Must be `openenv` for this workflow. | +| `template.local.image` | No | Docker image used for local run/invoke. | +| `template.environment.image` | Yes | Docker image registered with the RLE control plane. | + +If `template.local.image` is omitted, local `run` and `invoke --local` use `template.environment.image`. + +--- + +## CLI UX + +### Step 1: Initialize an RLE Session + +Default echo environment: + +```powershell +azd ai rle init +cd .\echo_env +``` + +The default command uses an embedded manifest: + +```yaml +name: echo_env +template: + name: echo_env + kind: openenv + environment: + image: devrle.azurecr.io/echo-rl:latest +``` + +Initialize from an existing manifest: + +```powershell +azd ai rle init -m .\rle.yaml +``` + +`-m` accepts a local path or HTTPS/GitHub blob URL. + +The session folder name is inferred from `name` or `template.name`. + +--- + +### Step 2: Run Locally + +```powershell +azd ai rle run +``` + +Behavior: + +1. Loads `.azd-rle.json` and `rle.yaml`. +2. Resolves local image: + - `template.local.image`, if present. + - Otherwise `template.environment.image`. +3. Pulls the image with Docker. +4. Starts a local container. +5. Waits for `GET /health`. + +Custom port: + +```powershell +azd ai rle run --port 9000 +``` + +The selected port is persisted in `.azd-rle.json`. + +--- + +### Step 3: Invoke Locally + +```powershell +azd ai rle invoke --local +``` + +The command opens an interactive OpenEnv shell. + +```text +rle> health +rle> reset {"seed":0} +rle> step {"message":"hello"} +rle> state +rle> metadata +rle> schema +rle> exit +``` + +| Shell command | OpenEnv API | +|---|---| +| `health` | `GET /health` | +| `reset [json]` | `POST /reset` | +| `step ` | `POST /step` with `{ "action": }` | +| `state` | `GET /state` | +| `metadata` | `GET /metadata` | +| `schema` | `GET /schema` | + +--- + +### Step 4: Deploy/Register Environment + +```powershell +azd ai rle deploy --project-id +``` + +Deploy registers `template.environment.image` with the RLE control plane. + +Request shape: + +```json +{ + "name": "code_rl", + "acrImagePath": "devrle.azurecr.io/code_rl-env:latest" +} +``` + +Control plane endpoint resolution: + +| Source | Behavior | +|---|---| +| `RLE_ENDPOINT` | Used when set. | +| Default | `http://localhost:5000`. | + +Example endpoint override: + +```powershell +$env:RLE_ENDPOINT = "https://" +``` + +--- + +## Local State + +The extension persists local session state in `.azd-rle.json`. + +Example: + +```json +{ + "name": "code_rl", + "localImage": "devrle.azurecr.io/code_rl-env:latest", + "image": "devrle.azurecr.io/code_rl-env:latest", + "port": 8088, + "project": "", + "environmentId": "code-rl", + "environmentVersion": "1" +} +``` + +State is used to: + +- Remember the selected local port. +- Reuse project/environment registration details. +- Avoid requiring repeated manifest parsing for every command. + +--- + +## OpenEnv API Mapping + +The manifest describes the environment image. The running image must expose OpenEnv-compatible APIs. + +| OpenEnv API | Required for | +|---|---| +| `GET /health` | `run`, local readiness check, shell `health` | +| `POST /reset` | shell `reset` | +| `POST /step` | shell `step` | +| `GET /state` | shell `state` | +| `GET /metadata` | shell `metadata` | +| `GET /schema` | shell `schema` | + +--- + +## Validation Rules + +| Rule | Error behavior | +|---|---| +| Missing `rle.yaml` and no initialized state | Ask user to run `azd ai rle init` or add `rle.yaml`. | +| Missing project on deploy | Require `--project-id `. | +| Missing environment image on deploy | Require `template.environment.image`. | +| Missing local image on run/invoke | Require `template.local.image` or `template.environment.image`. | +| Docker pull fails | Tell user to ensure Docker can pull the image or set `template.local.image`. | +| OpenEnv health check fails | Surface local runtime startup failure. | +| Invalid JSON in shell command | Return command-specific JSON parsing guidance. | + +--- + +## Example: Code RL + +Manifest: + +```yaml +name: code_rl +description: Code RL OpenEnv environment. +template: + name: code_rl + kind: openenv + local: + image: devrle.azurecr.io/code_rl-env:latest + environment: + image: devrle.azurecr.io/code_rl-env:latest +``` + +Run: + +```powershell +azd ai rle init -m .\rle.yaml +cd .\code_rl +azd ai rle run --port 8088 +azd ai rle invoke --local +``` + +Step example: + +```text +rle> step {"code":"import sys\nnums=list(map(int, sys.stdin.read().split()))\nprint(sum(nums))\n"} +``` + +--- + +## Upcoming Features + +### Remote Invoke + +Future command: + +```powershell +azd ai rle invoke +``` + +Expected behavior: + +- Resolve registered environment from `.azd-rle.json`. +- Connect to remote RLE endpoint. +- Use the same OpenEnv shell commands as local invoke. + +### Playground UI + +Future experience: + +```powershell +azd ai rle playground +``` + +Expected behavior: + +- Launch a local or hosted UI for inspecting schema, reset/step behavior, metadata, and state. +- Support interactive testing without writing client code. + +### `azd provision` Integration + +Future flow: + +```powershell +azd provision +``` + +Expected behavior: + +- Choose an existing Foundry project or create a new one. +- Persist selected project information. +- Allow `azd ai rle deploy` without requiring `--project-id`. + +--- + +## Open Design Questions + +| # | Question | Context | Owner | +|---|---|---|---| +| RLE-1 | Should `azd ai rle deploy` remain a custom command or integrate into standard `azd deploy` later? | Current preview uses `azd ai rle deploy`; future azd alignment may prefer standard lifecycle hooks. | azd/RLE | +| RLE-2 | What is the final project identity model? | Preview requires `--project-id`; future `azd provision` should select/create Foundry project. | azd/RLE | +| RLE-3 | What is the remote invoke endpoint contract? | Local invoke maps directly to OpenEnv APIs. Remote invoke needs endpoint discovery and auth model. | RLE | +| RLE-4 | Should manifest support image build/push later? | Preview assumes images already exist. Future UX may include build/publish. | azd/RLE | + +--- + +## Recommendation for Preview + +Use the narrow command surface for initial review: + +```powershell +azd ai rle init +azd ai rle init -m .\rle.yaml +azd ai rle run +azd ai rle invoke --local +azd ai rle deploy --project-id +``` + +This keeps the preview focused on the core RLE environment lifecycle while leaving room for remote invoke, +playground UI, and `azd provision` integration. From c059208cccf3227db9656d4c350981fcee62073e Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Thu, 2 Jul 2026 03:14:49 +0530 Subject: [PATCH 15/25] fix: docker based builds --- cli/azd/extensions/azure.ai.rle/README.md | 91 +- cli/azd/extensions/azure.ai.rle/SPEC.md | 350 ------- .../extensions/azure.ai.rle/extension.yaml | 18 +- cli/azd/extensions/azure.ai.rle/go.mod | 2 +- .../azure.ai.rle/internal/cmd/build.go | 131 +++ .../azure.ai.rle/internal/cmd/client.go | 87 +- .../azure.ai.rle/internal/cmd/deploy.go | 106 +- .../azure.ai.rle/internal/cmd/init.go | 95 +- .../azure.ai.rle/internal/cmd/invoke.go | 956 ++++++++++++++---- .../azure.ai.rle/internal/cmd/invoke_test.go | 682 ++++++++++++- .../azure.ai.rle/internal/cmd/manifest.go | 255 ----- .../internal/cmd/manifest_test.go | 200 ---- .../azure.ai.rle/internal/cmd/root.go | 25 +- .../azure.ai.rle/internal/cmd/root_test.go | 181 +++- .../azure.ai.rle/internal/cmd/scaffold.go | 572 +++-------- .../internal/cmd/scaffold_test.go | 71 ++ .../azure.ai.rle/internal/cmd/state.go | 6 +- .../azure.ai.rle/internal/cmd/util.go | 15 + 18 files changed, 2112 insertions(+), 1731 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.rle/SPEC.md create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/build.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go delete mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/manifest_test.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold_test.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/util.go diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index ac6c458db74..b2de83db2f1 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -1,7 +1,7 @@ # Azure AI RLE extension for azd -Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: initialize a local session, run the environment container locally, invoke it through a shell, and register it with the RLE control plane. +Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: copy OpenEnv sample source, build and run the environment container, test it through a playground UI or shell, and deploy the environment image to the RLE control plane. ## Prerequisites @@ -49,14 +49,26 @@ azd extension install azure.ai.rle --source local --force ## Configure the RLE control plane -The extension defaults to `http://localhost:5000`. To target another control plane: +The extension defaults to the local RLE control plane at `http://localhost:5000`. To target another control plane: ```powershell $env:RLE_ENDPOINT = "https://" ``` +Deploy uses an ACR image for the registered RLE environment. Set the registry once: + +```powershell +$env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" +``` + ## Quickstart +Enable the preview command surface: + +```powershell +$env:AZD_AI_RLE_ENABLE = "true" +``` + ### 1. Initialize an environment session Default echo session: @@ -66,40 +78,18 @@ azd ai rle init cd .\echo_env ``` -The default echo session is driven by an embedded manifest. The generated `rle.yaml` includes: - -```yaml -name: echo_env -template: - name: echo_env - kind: openenv - environment: - image: devrle.azurecr.io/echo-rl:latest -``` - -A custom manifest uses the same OpenEnv-style shape: +The default echo session downloads `huggingface/OpenEnv`, copies `envs/echo_env` into the session folder, +and writes `.azd-rle.json` with the local environment name. -```yaml -name: code_rl -description: Code RL OpenEnv environment. -template: - name: code_rl - kind: openenv - local: - image: devrle.azurecr.io/code_rl-env:latest - environment: - image: devrle.azurecr.io/code_rl-env:latest -``` - -`template.local.image` is optional. If omitted, local run/invoke use `template.environment.image`. +The copied session does not keep `.git` metadata from the upstream repository. -To initialize from an existing manifest instead: +Name the copied echo session: ```powershell -azd ai rle init -m .\rle.yaml +azd ai rle init code_rl ``` -`-m` accepts a local path or HTTPS/GitHub blob URL. The session folder name is always inferred from `name` or `template.name` in the manifest. +For an existing source folder, skip `init` and run commands directly from that folder. ### 2. Run locally @@ -107,7 +97,9 @@ azd ai rle init -m .\rle.yaml azd ai rle run ``` -`run` starts a Docker container and waits for `/health`. It uses `template.local.image` when present; otherwise it uses `template.environment.image`. The command does not build an image locally. +`run` builds a local Docker image from the current source folder, removes any stale local container for the +same environment name, starts a fresh container, waits for `/health`, opens the playground UI at `/web`, and +keeps an OpenEnv shell attached. When the shell exits or Ctrl+C is received, `run` removes the local container. Use a custom host port: @@ -115,15 +107,22 @@ Use a custom host port: azd ai rle run --port 9000 ``` -The selected port is saved in `.azd-rle.json` and reused by local invoke. +The selected port is only used for that run and is not saved in `.azd-rle.json`. + +`run` looks for `Dockerfile` at the source root, then `server\Dockerfile`. If the Dockerfile is elsewhere, +pass it explicitly: + +```powershell +azd ai rle run --dockerfile server\Dockerfile +``` -### 3. Invoke locally +Rebuild automatically while editing local source: ```powershell -azd ai rle invoke --local +azd ai rle run --watch ``` -Inside the shell: +The shell supports these commands: ```text rle> health @@ -145,22 +144,26 @@ Supported shell commands: | `schema` | `GET /schema` | | `exit` / `quit` | Exit shell | -Per-request timeout: +### 3. Deploy/register ```powershell -azd ai rle invoke --local --timeout 60 +$env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" +azd ai rle deploy --project-id ``` -### 4. Deploy/register +Deploy builds the Docker image as `.azurecr.io/:latest`, pushes it to ACR, registers that image with the RLE control plane, and saves the project/environment details in `.azd-rle.json`. + +If needed, override the Dockerfile path the same way as local run: ```powershell -azd ai rle deploy --project-id +azd ai rle deploy --project-id --dockerfile server\Dockerfile ``` -Deploy registers `template.environment.image` from `rle.yaml` with the RLE control plane and saves the project/environment details in `.azd-rle.json`. +### 4. Invoke remotely -## Upcoming features +Remote invoke uses the deployed environment, leases a sandbox, opens the sandbox `/web` UI when available +(or a local proxy UI otherwise), keeps the shell attached, and releases the sandbox when the shell exits: -- Remote environment invocation with `azd ai rle invoke`. -- Playground UI for exploring and testing RLE environments interactively. -- `azd provision` integration to choose an existing Foundry project or create a new one. +```powershell +azd ai rle invoke --timeout 60 +``` diff --git a/cli/azd/extensions/azure.ai.rle/SPEC.md b/cli/azd/extensions/azure.ai.rle/SPEC.md deleted file mode 100644 index 28864d37612..00000000000 --- a/cli/azd/extensions/azure.ai.rle/SPEC.md +++ /dev/null @@ -1,350 +0,0 @@ -# RLE Environment Registration - OpenEnv Manifest - - -This spec defines the `azd ai rle` preview workflow for managing Reinforcement Learning Environment (RLE) -recipes using an OpenEnv-style manifest. - -The goal is to move recipe/environment handling out of client-side SDK code and into a declarative manifest -that `azd` can use to initialize, run, invoke, and register RLE environments. - -RLE environment registration follows a manifest-first sequence: - -1. **Init** - User initializes an RLE session from an embedded default manifest or an existing `rle.yaml`. -2. **Run locally** - `azd` pulls and runs the environment Docker image locally. -3. **Invoke locally** - User interacts with the running OpenEnv runtime through a local shell. -4. **Deploy/register** - `azd` registers the environment image with the RLE control plane. - ---- - -## Goals - -- Provide a simple `azd ai rle` workflow for OpenEnv-style RLE environments. -- Represent environment recipes as an `rle.yaml` manifest. -- Support local validation before registering the environment. -- Keep training/client SDKs focused on training, not recipe/environment lifecycle management. -- Establish the azd command shape for future remote invoke, playground UI, and `azd provision` integration. - ---- - -## Preview Scope - -The preview focuses on existing Docker images. The manifest references images that Docker can already pull. - -The preview uses explicit project selection during deployment: - -```powershell -azd ai rle deploy --project-id -``` - -Remote invocation, playground UI, and Foundry project provisioning are planned follow-up features. - ---- - -## Manifest Contract - -An RLE environment is described by an OpenEnv-style manifest. - -```yaml -name: code_rl -description: Code RL OpenEnv environment. -template: - name: code_rl - kind: openenv - local: - image: devrle.azurecr.io/code_rl-env:latest - environment: - image: devrle.azurecr.io/code_rl-env:latest -``` - -| Field | Required | Description | -|---|---:|---| -| `name` | Yes | RLE session/environment name. Used for local folder/session state. | -| `description` | No | Human-readable environment description. | -| `template.name` | Yes | Template/environment name. Used as fallback when `name` is absent. | -| `template.kind` | Yes | Must be `openenv` for this workflow. | -| `template.local.image` | No | Docker image used for local run/invoke. | -| `template.environment.image` | Yes | Docker image registered with the RLE control plane. | - -If `template.local.image` is omitted, local `run` and `invoke --local` use `template.environment.image`. - ---- - -## CLI UX - -### Step 1: Initialize an RLE Session - -Default echo environment: - -```powershell -azd ai rle init -cd .\echo_env -``` - -The default command uses an embedded manifest: - -```yaml -name: echo_env -template: - name: echo_env - kind: openenv - environment: - image: devrle.azurecr.io/echo-rl:latest -``` - -Initialize from an existing manifest: - -```powershell -azd ai rle init -m .\rle.yaml -``` - -`-m` accepts a local path or HTTPS/GitHub blob URL. - -The session folder name is inferred from `name` or `template.name`. - ---- - -### Step 2: Run Locally - -```powershell -azd ai rle run -``` - -Behavior: - -1. Loads `.azd-rle.json` and `rle.yaml`. -2. Resolves local image: - - `template.local.image`, if present. - - Otherwise `template.environment.image`. -3. Pulls the image with Docker. -4. Starts a local container. -5. Waits for `GET /health`. - -Custom port: - -```powershell -azd ai rle run --port 9000 -``` - -The selected port is persisted in `.azd-rle.json`. - ---- - -### Step 3: Invoke Locally - -```powershell -azd ai rle invoke --local -``` - -The command opens an interactive OpenEnv shell. - -```text -rle> health -rle> reset {"seed":0} -rle> step {"message":"hello"} -rle> state -rle> metadata -rle> schema -rle> exit -``` - -| Shell command | OpenEnv API | -|---|---| -| `health` | `GET /health` | -| `reset [json]` | `POST /reset` | -| `step ` | `POST /step` with `{ "action": }` | -| `state` | `GET /state` | -| `metadata` | `GET /metadata` | -| `schema` | `GET /schema` | - ---- - -### Step 4: Deploy/Register Environment - -```powershell -azd ai rle deploy --project-id -``` - -Deploy registers `template.environment.image` with the RLE control plane. - -Request shape: - -```json -{ - "name": "code_rl", - "acrImagePath": "devrle.azurecr.io/code_rl-env:latest" -} -``` - -Control plane endpoint resolution: - -| Source | Behavior | -|---|---| -| `RLE_ENDPOINT` | Used when set. | -| Default | `http://localhost:5000`. | - -Example endpoint override: - -```powershell -$env:RLE_ENDPOINT = "https://" -``` - ---- - -## Local State - -The extension persists local session state in `.azd-rle.json`. - -Example: - -```json -{ - "name": "code_rl", - "localImage": "devrle.azurecr.io/code_rl-env:latest", - "image": "devrle.azurecr.io/code_rl-env:latest", - "port": 8088, - "project": "", - "environmentId": "code-rl", - "environmentVersion": "1" -} -``` - -State is used to: - -- Remember the selected local port. -- Reuse project/environment registration details. -- Avoid requiring repeated manifest parsing for every command. - ---- - -## OpenEnv API Mapping - -The manifest describes the environment image. The running image must expose OpenEnv-compatible APIs. - -| OpenEnv API | Required for | -|---|---| -| `GET /health` | `run`, local readiness check, shell `health` | -| `POST /reset` | shell `reset` | -| `POST /step` | shell `step` | -| `GET /state` | shell `state` | -| `GET /metadata` | shell `metadata` | -| `GET /schema` | shell `schema` | - ---- - -## Validation Rules - -| Rule | Error behavior | -|---|---| -| Missing `rle.yaml` and no initialized state | Ask user to run `azd ai rle init` or add `rle.yaml`. | -| Missing project on deploy | Require `--project-id `. | -| Missing environment image on deploy | Require `template.environment.image`. | -| Missing local image on run/invoke | Require `template.local.image` or `template.environment.image`. | -| Docker pull fails | Tell user to ensure Docker can pull the image or set `template.local.image`. | -| OpenEnv health check fails | Surface local runtime startup failure. | -| Invalid JSON in shell command | Return command-specific JSON parsing guidance. | - ---- - -## Example: Code RL - -Manifest: - -```yaml -name: code_rl -description: Code RL OpenEnv environment. -template: - name: code_rl - kind: openenv - local: - image: devrle.azurecr.io/code_rl-env:latest - environment: - image: devrle.azurecr.io/code_rl-env:latest -``` - -Run: - -```powershell -azd ai rle init -m .\rle.yaml -cd .\code_rl -azd ai rle run --port 8088 -azd ai rle invoke --local -``` - -Step example: - -```text -rle> step {"code":"import sys\nnums=list(map(int, sys.stdin.read().split()))\nprint(sum(nums))\n"} -``` - ---- - -## Upcoming Features - -### Remote Invoke - -Future command: - -```powershell -azd ai rle invoke -``` - -Expected behavior: - -- Resolve registered environment from `.azd-rle.json`. -- Connect to remote RLE endpoint. -- Use the same OpenEnv shell commands as local invoke. - -### Playground UI - -Future experience: - -```powershell -azd ai rle playground -``` - -Expected behavior: - -- Launch a local or hosted UI for inspecting schema, reset/step behavior, metadata, and state. -- Support interactive testing without writing client code. - -### `azd provision` Integration - -Future flow: - -```powershell -azd provision -``` - -Expected behavior: - -- Choose an existing Foundry project or create a new one. -- Persist selected project information. -- Allow `azd ai rle deploy` without requiring `--project-id`. - ---- - -## Open Design Questions - -| # | Question | Context | Owner | -|---|---|---|---| -| RLE-1 | Should `azd ai rle deploy` remain a custom command or integrate into standard `azd deploy` later? | Current preview uses `azd ai rle deploy`; future azd alignment may prefer standard lifecycle hooks. | azd/RLE | -| RLE-2 | What is the final project identity model? | Preview requires `--project-id`; future `azd provision` should select/create Foundry project. | azd/RLE | -| RLE-3 | What is the remote invoke endpoint contract? | Local invoke maps directly to OpenEnv APIs. Remote invoke needs endpoint discovery and auth model. | RLE | -| RLE-4 | Should manifest support image build/push later? | Preview assumes images already exist. Future UX may include build/publish. | azd/RLE | - ---- - -## Recommendation for Preview - -Use the narrow command surface for initial review: - -```powershell -azd ai rle init -azd ai rle init -m .\rle.yaml -azd ai rle run -azd ai rle invoke --local -azd ai rle deploy --project-id -``` - -This keeps the preview focused on the core RLE environment lifecycle while leaving room for remote invoke, -playground UI, and `azd provision` integration. diff --git a/cli/azd/extensions/azure.ai.rle/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml index ce5e9b292ab..a581e16786e 100644 --- a/cli/azd/extensions/azure.ai.rle/extension.yaml +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -10,18 +10,18 @@ namespace: ai.rle tags: - ai - rle -usage: azd ai rle [options] +usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle [options] version: 0.1.0-preview examples: - name: init - description: Scaffold a local RLE environment. - usage: azd ai rle init + description: Copy the OpenEnv echo sample into a local RLE environment. + usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle init - name: deploy - description: Create or update the RLE environment. - usage: azd ai rle deploy --project-id + description: Build, push, and create or update the RLE environment. + usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io"; azd ai rle deploy --project-id - name: run - description: Pull and run the local RLE environment container. - usage: azd ai rle run + description: Build and run the local RLE environment container. + usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle run - name: invoke - description: Open an OpenEnv runtime shell. - usage: azd ai rle invoke --local + description: Lease a deployed RLE sandbox and open an OpenEnv runtime shell. + usage: $env:AZD_AI_RLE_ENABLE = "true"; 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 9db2bbc90e8..9bde226d7fd 100644 --- a/cli/azd/extensions/azure.ai.rle/go.mod +++ b/cli/azd/extensions/azure.ai.rle/go.mod @@ -6,7 +6,6 @@ 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 ( @@ -99,4 +98,5 @@ 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/build.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/build.go new file mode 100644 index 00000000000..dc3f30129a4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/build.go @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type dockerBuildOptions struct { + source string + dockerfile string +} + +func resolveDockerBuild(opts dockerBuildOptions) (source string, dockerfile string, err error) { + source, dockerfile, _, err = prepareDockerBuild(opts) + return source, dockerfile, err +} + +func prepareDockerBuild(opts dockerBuildOptions) (source string, dockerfile string, cleanup func(), err error) { + source = strings.TrimSpace(opts.source) + if source == "" { + source = "." + } + source, err = filepath.Abs(source) + if err != nil { + return "", "", nil, err + } + + dockerfile = "" + dockerfile = strings.TrimSpace(opts.dockerfile) + if dockerfile != "" { + if !isSafeRelativePath(filepath.Clean(filepath.FromSlash(dockerfile))) { + return "", "", cleanup, &azdext.LocalError{ + Message: "Dockerfile path must stay inside the source root.", + Code: "rle_dockerfile_path_invalid", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use a Dockerfile path relative to the source root without '..' segments.", + } + } + if !filepath.IsAbs(dockerfile) { + dockerfile = filepath.Join(source, dockerfile) + } + dockerfile, err = filepath.Abs(dockerfile) + if err != nil { + return "", "", cleanup, err + } + if !isPathWithinRoot(source, dockerfile) { + return "", "", cleanup, &azdext.LocalError{ + Message: "Dockerfile path must stay inside the source root.", + Code: "rle_dockerfile_path_invalid", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use a Dockerfile path relative to the source root without '..' segments.", + } + } + if exists, err := fileExists(dockerfile); err != nil { + return "", "", cleanup, err + } else if !exists { + return "", "", cleanup, missingDockerfileError(source) + } + + return source, dockerfile, cleanup, nil + } + + for _, candidate := range []string{ + filepath.Join(source, "Dockerfile"), + filepath.Join(source, "server", "Dockerfile"), + } { + if exists, err := fileExists(candidate); err != nil { + return "", "", cleanup, err + } else if exists { + return source, candidate, cleanup, nil + } + } + + return "", "", cleanup, missingDockerfileError(source) +} + +func isPathWithinRoot(root string, path string) bool { + relativePath, err := filepath.Rel(root, path) + if err != nil { + return false + } + return isSafeRelativePath(filepath.Clean(relativePath)) +} + +func fileExists(path string) (bool, error) { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + return !info.IsDir(), nil +} + +func missingDockerfileError(source string) error { + return &azdext.LocalError{ + Message: fmt.Sprintf("No Dockerfile found under %s.", source), + Code: "rle_local_dockerfile_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Add Dockerfile at the source root or server/Dockerfile, or pass --dockerfile .", + } +} + +func pushDockerImage(cmd *cobra.Command, image string) error { + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Pushing image %s ...\n", image); err != nil { + return err + } + if err := runDocker(cmd, "push", image); err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf("Failed to push Docker image %q: %v", image, err), + Code: "rle_docker_push_failed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Ensure Docker is authenticated for the target registry, then retry.", + } + } + return nil +} + +func isAcrImageReference(image string) bool { + firstSegment, _, _ := strings.Cut(strings.TrimSpace(image), "/") + return strings.HasSuffix(strings.ToLower(firstSegment), ".azurecr.io") +} 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 8ef2c8e079a..cd2cfcc5494 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -44,6 +44,23 @@ type environmentResource struct { VersionLabel string `json:"versionLabel,omitempty"` } +type sandboxCreateRequest struct { + Version string `json:"version,omitempty"` +} + +type sandboxResource struct { + Id string `json:"id"` + ProjectId string `json:"projectId,omitempty"` + EnvironmentId string `json:"environmentId,omitempty"` + Version string `json:"version,omitempty"` + Url string `json:"url,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + CreatedAtUtc string `json:"createdAtUtc,omitempty"` + UpdatedAtUtc string `json:"updatedAtUtc,omitempty"` +} + type rleHTTPError struct { statusCode int body string @@ -81,16 +98,22 @@ func newRleClient(endpoint string) *rleClient { } } -func resolveControlPlaneEndpoint(endpoint string) string { - if endpoint != "" { - return endpoint - } - if endpoint = os.Getenv("RLE_ENDPOINT"); endpoint != "" { +func resolveControlPlaneEndpoint() string { + if endpoint := os.Getenv("RLE_ENDPOINT"); endpoint != "" { return endpoint } return defaultControlPlaneEndpoint } +func isLocalControlPlaneEndpoint(endpoint string) bool { + parsed, err := url.Parse(endpoint) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + func (c *rleClient) createV1Environment( ctx context.Context, project string, @@ -129,6 +152,60 @@ func (c *rleClient) updateV1Environment( return &result, nil } +func (c *rleClient) createSandbox( + ctx context.Context, + project string, + environmentId string, + request sandboxCreateRequest, +) (*sandboxResource, error) { + 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) getSandbox( + ctx context.Context, + project string, + environmentId string, + sandboxId string, +) (*sandboxResource, error) { + path := sandboxPath(project, environmentId, 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) deleteSandbox( + ctx context.Context, + project string, + environmentId string, + sandboxId string, +) error { + return c.do(ctx, http.MethodDelete, sandboxPath(project, environmentId, sandboxId), nil, nil) +} + +func sandboxPath(project string, environmentId string, sandboxId string) string { + return fmt.Sprintf( + "/rle/v1.0/projects/%s/environments/%s/sandboxes/%s", + url.PathEscape(project), + url.PathEscape(environmentId), + url.PathEscape(sandboxId), + ) +} + func (c *rleClient) do(ctx context.Context, method string, path string, body any, target any) error { var reader io.Reader if body != nil { 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 524e4d19c38..8aa20a22656 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -8,13 +8,15 @@ import ( "errors" "fmt" "os" + "strings" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) type rleDeployFlags struct { - projectId string + projectId string + dockerfile string } func newDeployCommand() *cobra.Command { @@ -25,56 +27,17 @@ func newDeployCommand() *cobra.Command { Short: "Create or update the RLE environment", Args: cobra.NoArgs, 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 + state, initialized, err := resolveDeployState(flags) if err != nil { - if localErr, ok := errors.AsType[*azdext.LocalError](err); !ok || - localErr.Code != "rle_project_not_initialized" { - return err - } - } - - manifest, err := loadRleManifest(rleManifestFile) - manifestExists := err == nil - if err != nil && !errors.Is(err, os.ErrNotExist) { return err } - - if !initialized && !manifestExists { - return &azdext.LocalError{ - Message: "RLE session has not been initialized.", - Code: "rle_project_not_initialized", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Run azd ai rle init first, or add an " + rleManifestFile + - " manifest to this folder, then re-run deploy.", - } - } - if !initialized { - state = defaultRleState("") - if _, err := fmt.Fprintf( - cmd.OutOrStdout(), - "No %s found; initializing a new RLE session from %s.\n", - rleStateFile, - rleManifestFile, - ); err != nil { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "No %s found; using current folder as the RLE source.\n", + rleStateFile); err != nil { return err } } - if manifestExists { - manifestState, err := stateFromManifest(manifest) - if err != nil { - return err - } - state.Name = firstNonEmpty(manifestState.Name, state.Name) - state.LocalImage = manifestState.LocalImage - state.Image = manifestState.Image - } - state.Project = firstNonEmpty(flags.projectId, state.Project) if state.Project == "" { return &azdext.LocalError{ Message: "RLE project is required for deploy.", @@ -84,17 +47,30 @@ func newDeployCommand() *cobra.Command { } } - image := state.Image - if image == "" { + controlPlaneEndpoint := resolveControlPlaneEndpoint() + image, err := resolveDeployImage(flags, state) + if err != nil { + return err + } + if !isAcrImageReference(image) { return &azdext.LocalError{ - Message: "RLE environment image is required for deploy.", - Code: "rle_image_required", + Message: fmt.Sprintf("RLE deploy image must be an ACR image reference, got %q.", image), + Code: "rle_acr_image_required", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Set template.environment.image in rle.yaml, then rerun deploy.", + Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run deploy again.", } } + if err := buildLocalRuntimeImage(cmd, image, dockerBuildOptions{ + source: ".", + dockerfile: flags.dockerfile, + }); err != nil { + return err + } + if err := pushDockerImage(cmd, image); err != nil { + return err + } environmentId := firstNonEmpty(state.EnvironmentId, slug(state.Name)) - client := newRleClient(resolveControlPlaneEndpoint("")) + client := newRleClient(controlPlaneEndpoint) request := v1EnvironmentRequest{ Name: state.Name, AcrImagePath: image, @@ -178,9 +154,41 @@ func newDeployCommand() *cobra.Command { cmd.Flags().StringVar(&flags.projectId, "project-id", "", "RLE project id to deploy into. Required on first deploy; later deploys reuse the saved value.") + cmd.Flags().StringVar(&flags.dockerfile, "dockerfile", "", + "Dockerfile path relative to the current folder. Defaults to Dockerfile at the source root or server/Dockerfile.") return cmd } +func resolveDeployState(flags *rleDeployFlags) (rleState, bool, error) { + state, err := loadRleState() + initialized := err == nil + if err != nil { + if localErr, ok := errors.AsType[*azdext.LocalError](err); !ok || + localErr.Code != "rle_project_not_initialized" { + return rleState{}, false, err + } + state = defaultRleState(defaultSourceName(".")) + } + + state.Name = firstNonEmpty(state.Name, defaultSourceName(".")) + state.Project = firstNonEmpty(flags.projectId, state.Project) + + return state, initialized, nil +} + +func resolveDeployImage(flags *rleDeployFlags, state rleState) (string, error) { + registry := strings.Trim(strings.TrimSpace(os.Getenv("AZURE_CONTAINER_REGISTRY_ENDPOINT")), "/") + if registry == "" { + return "", &azdext.LocalError{ + Message: "ACR registry is required for deploy.", + Code: "rle_acr_registry_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run deploy again.", + } + } + return fmt.Sprintf("%s/%s:latest", registry, slug(state.Name)), nil +} + type environmentOutput struct { Id string `json:"id"` ProjectId string `json:"projectId"` 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 ff02d95af44..d68794478d3 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -5,7 +5,6 @@ package cmd import ( "fmt" - "os" "path/filepath" "strings" @@ -14,8 +13,7 @@ import ( ) type rleInitFlags struct { - manifest string - force bool + force bool } func newInitCommand() *cobra.Command { @@ -23,48 +21,15 @@ func newInitCommand() *cobra.Command { cmd := &cobra.Command{ Use: "init", - Short: "Scaffold a local RLE environment", - Args: cobra.NoArgs, - PreRunE: func(cmd *cobra.Command, args []string) error { - if initUsedLongManifestFlag(os.Args[1:]) { - return &azdext.LocalError{ - Message: "--manifest is not supported.", - Code: "rle_manifest_long_flag_unsupported", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Use -m .", - } - } - return nil - }, + Short: "Initialize a local RLE environment", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - var manifestBytes []byte - if flags.manifest != "" { - var err error - manifestBytes, err = readManifestContent(cmd.Context(), flags.manifest) - if err != nil { - return err - } - } else { - manifestBytes = []byte(defaultEchoManifest) - } - - manifest, err := parseRleManifest(manifestBytes) - if err != nil { - return err - } - manifestState, err := stateFromManifest(manifest) - if err != nil { - return err - } - envName := manifestState.Name - if envName == "" { - return &azdext.LocalError{ - Message: "RLE manifest name is required.", - Code: "rle_environment_name_required", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Set name or template.name in the manifest.", - } + envNameOverride := "" + if len(args) == 1 { + envNameOverride = args[0] } + envName := firstNonEmpty(envNameOverride, "echo_env") + var err error envName, err = validateEnvName(envName) if err != nil { return &azdext.LocalError{ @@ -75,21 +40,11 @@ func newInitCommand() *cobra.Command { } } - sessionDir, err := scaffoldRleSession( - envName, - ".", - manifestState.LocalImage, - manifestState.Image, - flags.force, - ) + sessionDir, err := checkoutOpenEnvEchoSampleFunc(envName, ".", flags.force) if err != nil { return err } - if err := writeManifestToSession(manifestBytes, sessionDir); err != nil { - return err - } - - if err := saveRleStateIn(sessionDir, manifestState); err != nil { + if err := saveRleStateIn(sessionDir, defaultRleState(envName)); err != nil { return err } @@ -101,9 +56,9 @@ func newInitCommand() *cobra.Command { cmd.OutOrStdout(), "Created OpenEnv-style environment at: %s\n"+ "Next steps:\n"+ - " cd %s\n"+ + " cd %q\n"+ " azd ai rle run\n"+ - " azd ai rle invoke --local\n"+ + " $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = \".azurecr.io\"\n"+ " azd ai rle deploy --project-id \n", displayDir, displayDir, @@ -114,40 +69,18 @@ func newInitCommand() *cobra.Command { cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { var help strings.Builder - help.WriteString("Scaffold a local RLE environment\n") + help.WriteString("Initialize a local RLE environment\n") help.WriteString("Usage:\n") - help.WriteString(" rle init [flags]\n") + help.WriteString(" rle init [environment-name] [flags]\n") help.WriteString("Flags:\n") help.WriteString(" --force Overwrite generated files in an existing non-empty session directory\n") help.WriteString(" -h, --help help for init\n") - help.WriteString(" -m string Path or HTTPS URL to an RLE manifest to copy into the session.\n") if cmd.InheritedFlags().HasAvailableFlags() { help.WriteString("Global Flags:\n") help.WriteString(cmd.InheritedFlags().FlagUsages()) } _, _ = fmt.Fprint(cmd.OutOrStdout(), help.String()) }) - cmd.Flags().StringVarP( - &flags.manifest, - "manifest", - "m", - "", - "Path or HTTPS URL to an RLE manifest to copy into the session.", - ) - _ = cmd.Flags().MarkHidden("manifest") cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite generated files in an existing non-empty session directory") return cmd } - -func initUsedLongManifestFlag(args []string) bool { - for _, arg := range args { - if arg == "--manifest" || strings.HasPrefix(arg, "--manifest=") { - return true - } - } - return false -} - -func writeManifestToSession(data []byte, sessionDir string) error { - return os.WriteFile(filepath.Join(sessionDir, rleManifestFile), data, 0600) -} 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 2166ec377f4..8885d4a5c06 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -8,14 +8,18 @@ package cmd import ( "bufio" "bytes" + "context" "encoding/json" "errors" "fmt" "io" + "net" "net/http" "os" "os/exec" - "strconv" + "os/signal" + "path/filepath" + "runtime" "strings" "time" @@ -24,46 +28,33 @@ import ( ) type openEnvInvokeFlags struct { - local bool - port int - persistPort bool - timeout int - action string - body string + port int + timeout int + action string + body string + name string + source string + dockerfile string + watch bool + restart bool + reuseRunning bool } func newInvokeCommand() *cobra.Command { flags := &openEnvInvokeFlags{ - timeout: 30, + timeout: 30, + reuseRunning: true, } cmd := &cobra.Command{ Use: "invoke", - Short: "Open an OpenEnv runtime shell", + Short: "Open a remote OpenEnv runtime shell", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - if !flags.local { - return &azdext.LocalError{ - Message: "Remote invoke is not supported yet.", - Code: "rle_remote_invoke_not_supported", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Pass --local to invoke the local OpenEnv runtime.", - } - } - baseUrl, err := ensureLocalContainerEndpoint(cmd, flags) - if err != nil { - return err - } - return runOpenEnvShell(cmd.InOrStdin(), cmd.OutOrStdout(), baseUrl, flags.timeout) + return invokeRemoteEnvironment(cmd, flags) }, } - cmd.Flags().BoolVar( - &flags.local, - "local", - false, - "Run the configured local Docker image if needed, then call that container.", - ) cmd.Flags().IntVar( &flags.timeout, "timeout", @@ -73,22 +64,513 @@ func newInvokeCommand() *cobra.Command { return cmd } -func newRunCommand() *cobra.Command { - flags := &openEnvInvokeFlags{ - persistPort: true, +func invokeRemoteEnvironment(cmd *cobra.Command, flags *openEnvInvokeFlags) error { + state, err := loadRleState() + if err != nil { + return err + } + if err := requireDeployedEnvironment(state); err != nil { + return err + } + + ctx, stopSignals := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer stopSignals() + + client := newRleClient(resolveControlPlaneEndpoint()) + if _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "Creating sandbox for environment %s ...\n", + state.EnvironmentId, + ); err != nil { + return err + } + + sandbox, err := leaseRemoteSandbox(ctx, cmd.OutOrStdout(), client, state) + if err != nil { + if _, ok := errors.AsType[*azdext.LocalError](err); ok { + return err + } + return serviceError(err) + } + defer func() { + if err := releaseRemoteSandbox(client, state, sandbox.Id); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to release sandbox %s: %v\n", sandbox.Id, err) + } + }() + + sandboxUrl := strings.TrimRight(firstNonEmpty(sandbox.Url, sandbox.Endpoint), "/") + if _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "Sandbox %s ready at %s\n", + sandbox.Id, + sandboxUrl, + ); err != nil { + return err + } + if err := waitForOpenEnvHealth(sandboxUrl, 60*time.Second); err != nil { + return err + } + playgroundUrl, stopPlayground, err := remotePlaygroundUrl(ctx, sandboxUrl) + if err != nil { + return err + } + defer stopPlayground() + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Playground UI: %s\n", playgroundUrl); err != nil { + return err + } + if err := openBrowserFunc(playgroundUrl); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) + } + return runOpenEnvShellWithContext(ctx, cmd.InOrStdin(), cmd.OutOrStdout(), sandboxUrl, flags.timeout) +} + +const ( + sandboxStatusRunning = "Running" + sandboxStatusFailed = "Failed" +) + +var ( + remoteSandboxCreateTimeout = 300 * time.Second + remoteSandboxPollInterval = 2 * time.Second + remoteImagePollInterval = 5 * time.Second +) + +func leaseRemoteSandbox( + ctx context.Context, + output io.Writer, + client *rleClient, + state rleState, +) (*sandboxResource, error) { + sandbox, err := createSandboxWhenImageReady(ctx, output, client, state) + if err != nil { + return nil, err + } + if strings.TrimSpace(sandbox.Id) == "" { + return nil, &azdext.LocalError{ + Message: "Control plane did not return a sandbox id.", + Code: "rle_sandbox_id_missing", + Category: azdext.LocalErrorCategoryInternal, + Suggestion: "Check the RLE control plane sandbox response, then retry.", + } + } + readySandbox, err := waitForRemoteSandbox(ctx, client, state.Project, state.EnvironmentId, sandbox) + if err != nil { + if releaseErr := releaseRemoteSandbox(client, state, sandbox.Id); releaseErr != nil { + return nil, fmt.Errorf("%w; additionally failed to release sandbox %s: %v", err, sandbox.Id, releaseErr) + } + return nil, err + } + return readySandbox, nil +} + +func createSandboxWhenImageReady( + ctx context.Context, + output io.Writer, + client *rleClient, + state rleState, +) (*sandboxResource, error) { + deadline := time.Now().Add(remoteSandboxCreateTimeout) + attempt := 0 + for { + sandbox, err := client.createSandbox(ctx, state.Project, state.EnvironmentId, sandboxCreateRequest{ + Version: state.EnvironmentVersion, + }) + if err == nil { + return sandbox, nil + } + + status, pending := sandboxLeasePendingStatus(err) + if !pending { + return nil, err + } + if time.Now().After(deadline) { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf( + "Environment image conversion was not ready after %.0f seconds (last status: %s).", + remoteSandboxCreateTimeout.Seconds(), + firstNonEmpty(status, "unknown"), + ), + Code: "rle_disk_image_conversion_timeout", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Wait for the RLE control plane image conversion to finish, then retry invoke.", + } + } + + attempt++ + if _, msgErr := fmt.Fprintf( + output, + "Environment image conversion is %s; waiting %.0f seconds before retrying sandbox lease (attempt %d) ...\n", + firstNonEmpty(status, "not ready"), + remoteImagePollInterval.Seconds(), + attempt, + ); msgErr != nil { + return nil, msgErr + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(remoteImagePollInterval): + } + } +} + +func sandboxLeasePendingStatus(err error) (string, bool) { + httpErr, ok := errors.AsType[*rleHTTPError](err) + if !ok || httpErr.statusCode != http.StatusConflict { + return "", false + } + status := strings.TrimSpace(httpErr.body) + if before, after, found := strings.Cut(httpErr.body, "conversion status:"); found { + _ = before + status = strings.TrimSpace(strings.Trim(strings.Split(after, ")")[0], `."}`)) + } + return status, true +} + +func waitForRemoteSandbox( + ctx context.Context, + client *rleClient, + project string, + environmentId string, + sandbox *sandboxResource, +) (*sandboxResource, error) { + deadline := time.Now().Add(remoteSandboxCreateTimeout) + for { + if sandbox.Status == sandboxStatusFailed { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf( + "Sandbox %s failed to start: %s", + sandbox.Id, + firstNonEmpty(sandbox.Error, "unknown error"), + ), + Code: "rle_sandbox_start_failed", + Category: azdext.LocalErrorCategoryUser, + } + } + if sandbox.Status == sandboxStatusRunning { + if strings.TrimSpace(firstNonEmpty(sandbox.Url, sandbox.Endpoint)) == "" { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf("Sandbox %s is Running but did not report a data-plane URL.", sandbox.Id), + Code: "rle_sandbox_url_missing", + Category: azdext.LocalErrorCategoryInternal, + Suggestion: "Check the RLE control plane sandbox response, then retry.", + } + } + return sandbox, nil + } + if time.Now().After(deadline) { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf( + "Sandbox %s was not ready after %.0f seconds (last status: %s).", + sandbox.Id, + remoteSandboxCreateTimeout.Seconds(), + firstNonEmpty(sandbox.Status, "unknown"), + ), + Code: "rle_sandbox_start_timeout", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Check the RLE control plane sandbox status, then retry.", + } + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(remoteSandboxPollInterval): + } + + updated, err := client.getSandbox(ctx, project, environmentId, sandbox.Id) + if err != nil { + return nil, err + } + sandbox = updated + } +} + +func releaseRemoteSandbox(client *rleClient, state rleState, sandboxId string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return client.deleteSandbox(ctx, state.Project, state.EnvironmentId, sandboxId) +} + +func remotePlaygroundUrl(ctx context.Context, sandboxUrl string) (string, func(), error) { + if remoteSandboxHasWeb(ctx, sandboxUrl) { + return strings.TrimRight(sandboxUrl, "/") + "/web", func() {}, nil + } + return startRemotePlaygroundProxy(ctx, sandboxUrl) +} + +func remoteSandboxHasWeb(ctx context.Context, sandboxUrl string) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(sandboxUrl, "/")+"/web", nil) + if err != nil { + return false + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return resp.StatusCode >= 200 && resp.StatusCode < 400 +} + +func startRemotePlaygroundProxy(ctx context.Context, sandboxUrl string) (string, func(), error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", func() {}, err + } + + server := &http.Server{ + Handler: remotePlaygroundHandler(strings.TrimRight(sandboxUrl, "/")), + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + // The shell remains usable even if the optional local UI proxy exits. + } + }() + + stop := func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + } + return "http://" + listener.Addr().String() + "/web", stop, nil +} + +func remotePlaygroundHandler(sandboxUrl string) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/web" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, remotePlaygroundHTML) + return + } + proxyOpenEnvToSandbox(w, r, sandboxUrl) + }) + return mux +} + +func proxyOpenEnvToSandbox(w http.ResponseWriter, r *http.Request, sandboxUrl string) { + operation := strings.Trim(r.URL.Path, "/") + switch operation { + case "health", "state", "metadata", "schema": + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + case "reset", "step": + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + default: + http.NotFound(w, r) + return + } + + target, err := http.NewRequestWithContext(r.Context(), r.Method, sandboxUrl+"/"+operation, r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + target.Header.Set("Accept", "application/json") + if contentType := r.Header.Get("Content-Type"); contentType != "" { + target.Header.Set("Content-Type", contentType) + } + resp, err := openEnvHTTPClient(60).Do(target) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if contentType := resp.Header.Get("Content-Type"); contentType != "" { + w.Header().Set("Content-Type", contentType) } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +const remotePlaygroundHTML = ` + + + + + RLE Remote Console + + + +
RLE Remote Consolelocal UI proxy
+
+
+

Action

+ + + + + + + +

+

Schema

+
Not loaded.
+
+
+

Output

+
Ready.
+
+
+ + +` + +func requireDeployedEnvironment(state rleState) error { + if strings.TrimSpace(state.Project) == "" { + return &azdext.LocalError{ + Message: "RLE project is required for remote invoke.", + Code: "rle_project_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run azd ai rle deploy --project-id first.", + } + } + if strings.TrimSpace(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 environment folder first.", + } + } + return nil +} + +func runOpenEnvShellWithContext( + ctx context.Context, + input io.Reader, + output io.Writer, + baseUrl string, + timeout int, +) error { + done := make(chan error, 1) + go func() { + done <- runOpenEnvShell(input, output, baseUrl, timeout) + }() + select { + case err := <-done: + return err + case <-ctx.Done(): + fmt.Fprintln(output) + return nil + } +} + +func newRunCommand() *cobra.Command { + flags := &openEnvInvokeFlags{} cmd := &cobra.Command{ Use: "run", - Short: "Pull and run the local RLE environment container", + Short: "Build and run the local RLE environment container", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + ctx, stopSignals := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer stopSignals() + baseUrl, err := ensureLocalContainerEndpoint(cmd, flags) if err != nil { return err } - _, err = fmt.Fprintf(cmd.OutOrStdout(), "Local RLE environment is running at %s\n", baseUrl) - return err + state, err := loadLocalRunState(flags) + if err != nil { + return err + } + defer func() { + if err := stopLocalContainer(cmd, state.Name); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to stop local container: %v\n", err) + } + }() + + watchDone := make(chan error, 1) + if flags.watch { + watchCtx, cancelWatch := context.WithCancel(ctx) + defer cancelWatch() + watchCmd := *cmd + watchCmd.SetContext(watchCtx) + go func() { + watchDone <- watchLocalContainer(&watchCmd, flags) + }() + } + + webUrl := baseUrl + "/web" + _, err = fmt.Fprintf( + cmd.OutOrStdout(), + "Local RLE environment is running at %s\nPlayground UI: %s\n", + baseUrl, + webUrl, + ) + if err != nil { + return err + } + if err := openBrowserFunc(webUrl); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) + } + shellErr := runOpenEnvShellWithContext(ctx, cmd.InOrStdin(), cmd.OutOrStdout(), baseUrl, 0) + if flags.watch { + select { + case err := <-watchDone: + if err != nil && shellErr == nil { + return err + } + default: + } + } + return shellErr }, } @@ -96,7 +578,19 @@ func newRunCommand() *cobra.Command { &flags.port, "port", 0, - "Host port mapped to the local Docker container. Defaults to the saved port or 8000.", + "Host port mapped to the local Docker container. Defaults to 8000.", + ) + cmd.Flags().StringVar( + &flags.dockerfile, + "dockerfile", + "", + "Dockerfile path relative to the current folder. Defaults to Dockerfile at the source root or server/Dockerfile.", + ) + cmd.Flags().BoolVar( + &flags.watch, + "watch", + false, + "Watch source files and rebuild/restart the local container when they change.", ) return cmd } @@ -184,11 +678,11 @@ func printOpenEnvShellHelp(output io.Writer) { } func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *openEnvInvokeFlags) (string, error) { - state, err := loadRleState() + state, err := loadLocalRunState(flags) if err != nil { return "", err } - port := resolvePort(flags, state) + port := resolvePort(flags) if port <= 0 { return "", &azdext.LocalError{ Message: "--port must be greater than 0.", @@ -206,66 +700,27 @@ func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *openEnvInvokeFlags) } } - image, err := localRuntimeImage(state) - if err != nil { - return "", err - } + image := localRuntimeImageForRun(flags, state) container := localContainerName(state.Name) baseUrl := fmt.Sprintf("http://localhost:%d", port) - running, exists := localContainerStatus(cmd, container) - switch { - case running: - if !shouldRecreateContainer(cmd, container, port, image) { + + if running, exists := localContainerStatus(cmd, container); exists { + if running && flags.reuseRunning && !flags.restart { if err := waitForOpenEnvHealth(baseUrl, 30*time.Second); err != nil { return "", err } - if err := savePortIfRequested(flags, state, port); err != nil { - return "", err - } return baseUrl, nil } - case exists: - if !shouldRecreateContainer(cmd, container, port, image) { - if _, err := fmt.Fprintf( - cmd.ErrOrStderr(), - "Starting existing local container %s ...\n", - container, - ); err != nil { - return "", err - } - if err := runDocker(cmd, "start", container); err == nil { - if err := waitForOpenEnvHealth(baseUrl, 30*time.Second); err != nil { - return "", err - } - if err := savePortIfRequested(flags, state, port); err != nil { - return "", err - } - return baseUrl, nil - } - if _, err := fmt.Fprintf( - cmd.ErrOrStderr(), - "Existing local container %s did not start; recreating it ...\n", - container, - ); err != nil { - return "", err - } - } + _ = runDocker(cmd, "rm", "-f", container) } - - if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Pulling local runtime image %s ...\n", image); err != nil { + if err := ensurePortAvailable(port); err != nil { return "", err } - if err := runDocker(cmd, "pull", image); err != nil { - return "", &azdext.LocalError{ - Message: fmt.Sprintf("Failed to pull Docker image %q: %v", image, err), - Code: "rle_local_docker_pull_failed", - Category: azdext.LocalErrorCategoryUser, - Suggestion: dockerPullFailureSuggestion(image), - } - } - - if _, exists := localContainerStatus(cmd, container); exists { - _ = runDocker(cmd, "rm", "-f", container) + if err := buildLocalRuntimeImage(cmd, image, dockerBuildOptions{ + source: flags.source, + dockerfile: flags.dockerfile, + }); err != nil { + return "", err } if _, err := fmt.Fprintf( cmd.ErrOrStderr(), @@ -276,174 +731,271 @@ func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *openEnvInvokeFlags) return "", err } portMapping := fmt.Sprintf("%d:8000", port) - if err := runDocker(cmd, + runArgs := []string{ "run", "-d", "--name", container, - "--label", localContainerImageLabel+"="+image, + "--label", localContainerImageLabel + "=" + image, + "-e", "ENABLE_WEB_INTERFACE=true", "-p", portMapping, - image); err != nil { + image, + } + if err := runDocker(cmd, runArgs...); err != nil { return "", &azdext.LocalError{ Message: fmt.Sprintf("Failed to start local Docker container %q: %v", container, err), Code: "rle_local_docker_run_failed", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Ensure the selected --port is free, or pass a different port.", + Suggestion: localPortSuggestion(port), + } + } + started := true + cleanupStartedContainer := func() { + if started { + _ = runDocker(cmd, "rm", "-f", container) } } if err := waitForOpenEnvHealth(baseUrl, 30*time.Second); err != nil { + cleanupStartedContainer() return "", err } - if err := savePortIfRequested(flags, state, port); err != nil { - return "", err - } + started = false return baseUrl, nil } -func resolvePort(flags *openEnvInvokeFlags, state rleState) int { - if flags.port > 0 { - return flags.port - } - if state.Port > 0 { - return state.Port +func openBrowser(url string) error { + var command string + var args []string + switch runtime.GOOS { + case "windows": + command = "rundll32" + args = []string{"url.dll,FileProtocolHandler", url} + case "darwin": + command = "open" + args = []string{url} + default: + command = "xdg-open" + args = []string{url} } - return defaultPort + return exec.Command(command, args...).Start() } -func savePortIfRequested(flags *openEnvInvokeFlags, state rleState, port int) error { - if !flags.persistPort || state.Port == port { - return nil - } - state.Port = port - return saveRleState(state) -} +var openBrowserFunc = openBrowser -func localRuntimeImage(state rleState) (string, error) { - manifest, manifestErr := loadRleManifest(rleManifestFile) - if manifestErr == nil { - image := localImageFromManifest(manifest) - if strings.TrimSpace(image) != "" { - return strings.TrimSpace(image), nil +func ensurePortAvailable(port int) error { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf("Port %d is already in use.", port), + Code: "rle_local_port_in_use", + Category: azdext.LocalErrorCategoryUser, + Suggestion: localPortSuggestion(port), } - return "", missingLocalRuntimeImageError() - } else if !errors.Is(manifestErr, os.ErrNotExist) { - return "", manifestErr - } - if strings.TrimSpace(state.LocalImage) != "" { - return strings.TrimSpace(state.LocalImage), nil } - if strings.TrimSpace(state.Image) != "" { - return strings.TrimSpace(state.Image), nil - } - return "", missingLocalRuntimeImageError() + return listener.Close() +} + +func localPortSuggestion(port int) string { + return fmt.Sprintf( + "Check containers using this port: docker ps --filter \"publish=%d\" --format \"table {{.Names}}\\t{{.Ports}}\"\n"+ + "Stop the container: docker rm -f \n"+ + "Then rerun: azd ai rle run --port %d\n"+ + "If Docker does not show a container, check the process with: netstat -ano | findstr :%d", + port, + port, + port, + ) } -func missingLocalRuntimeImageError() error { +func localContainerAlreadyRunningError(_ string, port int) error { return &azdext.LocalError{ - Message: "No local runtime image is configured.", - Code: "rle_local_image_required", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Set template.local.image or template.environment.image in rle.yaml, then rerun the command.", + Message: fmt.Sprintf("A container is already running on port %d.", port), + Code: "rle_local_container_already_running", + Category: azdext.LocalErrorCategoryUser, + Suggestion: fmt.Sprintf( + "Check it with: docker ps --filter \"publish=%d\" --format \"table {{.Names}}\\t{{.Ports}}\"\n"+ + "Stop it with: docker rm -f \n"+ + "Then rerun: azd ai rle run --port %d", + port, + port, + ), } } -func dockerPullFailureSuggestion(image string) string { - return fmt.Sprintf( - "Ensure Docker can pull %s, or set template.local.image in %s to an image available to Docker.", - image, - rleManifestFile, - ) +func stopLocalContainer(cmd *cobra.Command, environmentName string) error { + container := localContainerName(environmentName) + return runDocker(cmd, "rm", "-f", container) } -func localContainerStatus(cmd *cobra.Command, container string) (running bool, exists bool) { - //nolint:gosec // Fixed docker inspect command; container is a generated local name. - process := exec.CommandContext(cmd.Context(), "docker", "inspect", "-f", "{{.State.Running}}", container) - process.Env = os.Environ() - output, err := process.Output() +func loadLocalRunState(flags *openEnvInvokeFlags) (rleState, error) { + state, err := loadRleState() if err != nil { - return false, false + if localErr, ok := errors.AsType[*azdext.LocalError](err); !ok || + localErr.Code != "rle_project_not_initialized" { + return rleState{}, err + } + state = defaultRleState(defaultSourceName(flags.source)) } - return strings.TrimSpace(string(output)) == "true", true + + state.Name = firstNonEmpty(state.Name, defaultSourceName(flags.source)) + return state, nil } -const localContainerImageLabel = "azd.ai.rle.local-image" +func localRuntimeImageForRun(flags *openEnvInvokeFlags, state rleState) string { + return slug(firstNonEmpty(state.Name, defaultSourceName(flags.source))) + ":local" +} -func shouldRecreateContainer(cmd *cobra.Command, container string, requestedPort int, expectedImage string) bool { - actualPort, ok := localContainerHostPort(cmd, container) - if !ok { - _, _ = fmt.Fprintf( - cmd.ErrOrStderr(), - "Existing local container %s has no usable port mapping; recreating it ...\n", - container, - ) - return true +func defaultSourceName(source string) string { + source = strings.TrimSpace(source) + if source == "" { + source = "." } - if actualPort != requestedPort { - _, _ = fmt.Fprintf( - cmd.ErrOrStderr(), - "Existing local container %s uses port %d; recreating it on port %d ...\n", - container, - actualPort, - requestedPort, - ) - return true + abs, err := filepath.Abs(source) + if err != nil { + return "rle_env" } + name := filepath.Base(abs) + if name == "." || name == string(filepath.Separator) || name == "" { + return "rle_env" + } + return slug(name) +} - actualImage, ok := localContainerExpectedImage(cmd, container) - if ok && actualImage == expectedImage { - return false +func buildLocalRuntimeImage(cmd *cobra.Command, image string, opts dockerBuildOptions) error { + source, dockerfile, cleanup, err := prepareDockerBuild(opts) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() } - _, _ = fmt.Fprintf( + if _, err := fmt.Fprintf( cmd.ErrOrStderr(), - "Existing local container %s was created for a different image; recreating it ...\n", - container, - ) - return true + "Building local runtime image %s from %s ...\n", + image, + dockerfile, + ); err != nil { + return err + } + if err := runDocker(cmd, "build", "-t", image, "-f", dockerfile, source); err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf("Failed to build Docker image %q: %v", image, err), + Code: "rle_local_docker_build_failed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Fix the Dockerfile or generated environment code, then retry.", + } + } + return nil } -func localContainerHostPort(cmd *cobra.Command, container string) (int, bool) { - //nolint:gosec // Fixed docker inspect command; container is a generated local name. - process := exec.CommandContext( - cmd.Context(), - "docker", - "inspect", - "-f", - `{{(index (index .NetworkSettings.Ports "8000/tcp") 0).HostPort}}`, - container, - ) - process.Env = os.Environ() - output, err := process.Output() +func watchLocalContainer(cmd *cobra.Command, flags *openEnvInvokeFlags) error { + last, err := sourceSnapshot(flags.source) if err != nil { - return 0, false + return err + } + if _, err := fmt.Fprintln(cmd.OutOrStdout(), "Watching for source changes. Press Ctrl+C to stop."); err != nil { + return err + } + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-cmd.Context().Done(): + return nil + case <-ticker.C: + current, err := sourceSnapshot(flags.source) + if err != nil { + return err + } + if current == last { + continue + } + last = current + if _, err := fmt.Fprintln( + cmd.OutOrStdout(), + "Source change detected; rebuilding local container ...", + ); err != nil { + return err + } + restartFlags := *flags + restartFlags.restart = true + baseUrl, err := ensureLocalContainerEndpoint(cmd, &restartFlags) + if err != nil { + return err + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Local RLE environment restarted at %s\n", baseUrl); err != nil { + return err + } + } + } +} + +func sourceSnapshot(source string) (string, error) { + source = strings.TrimSpace(source) + if source == "" { + source = "." } - port, err := strconv.Atoi(strings.TrimSpace(string(output))) + source, err := filepath.Abs(source) if err != nil { - return 0, false + return "", err + } + var latest int64 + var count int + err = filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && shouldSkipWatchDir(entry.Name()) { + return filepath.SkipDir + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + count++ + if modified := info.ModTime().UnixNano(); modified > latest { + latest = modified + } + return nil + }) + if err != nil { + return "", err + } + return fmt.Sprintf("%d:%d", latest, count), nil +} + +func shouldSkipWatchDir(name string) bool { + switch name { + case ".azd", ".git", ".venv", "__pycache__", "node_modules", "venv": + return true + default: + return false + } +} + +func resolvePort(flags *openEnvInvokeFlags) int { + if flags.port > 0 { + return flags.port } - return port, true + return defaultPort } -func localContainerExpectedImage(cmd *cobra.Command, container string) (string, bool) { +func localContainerStatus(cmd *cobra.Command, container string) (running bool, exists bool) { //nolint:gosec // Fixed docker inspect command; container is a generated local name. - process := exec.CommandContext( - cmd.Context(), - "docker", - "inspect", - "-f", - "{{ index .Config.Labels \""+localContainerImageLabel+"\" }}", - container, - ) + process := exec.CommandContext(cmd.Context(), "docker", "inspect", "-f", "{{.State.Running}}", container) process.Env = os.Environ() output, err := process.Output() if err != nil { - return "", false - } - image := strings.TrimSpace(string(output)) - if image == "" || image == "" { - return "", false + return false, false } - return image, true + return strings.TrimSpace(string(output)) == "true", true } +const localContainerImageLabel = "azd.ai.rle.local-image" + func runDocker(cmd *cobra.Command, args ...string) error { //nolint:gosec // Fixed docker command shapes with selected names/tags. process := exec.CommandContext(cmd.Context(), "docker", args...) @@ -476,10 +1028,10 @@ func waitForOpenEnvHealth(baseUrl string, timeout time.Duration) error { time.Sleep(time.Second) } return &azdext.LocalError{ - Message: fmt.Sprintf("Local OpenEnv container did not become healthy at %s: %v", baseUrl, lastErr), + Message: fmt.Sprintf("OpenEnv endpoint did not become healthy at %s: %v", baseUrl, lastErr), Code: "rle_local_container_not_ready", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Check docker logs for the generated container, then retry.", + Suggestion: "Check the local container logs or remote sandbox status, then retry.", } } 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 index b196b5b9af8..92b7ac408d5 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -5,13 +5,21 @@ package cmd import ( "bytes" + "context" "encoding/json" + "errors" + "io" + "net" "net/http" "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) func TestOpenEnvRequestBodyWrapsAction(t *testing.T) { @@ -114,56 +122,672 @@ func TestRunOpenEnvShellRequiresStepPayload(t *testing.T) { } } +func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { + captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "code_rl", + Project: "project-1", + EnvironmentId: "env-1", + EnvironmentVersion: "v1", + }); err != nil { + t.Fatal(err) + } + + envServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/health": + _, _ = w.Write([]byte(`{"status":"healthy"}`)) + default: + http.NotFound(w, r) + } + })) + defer envServer.Close() + + var sandboxBody map[string]any + deleteCalled := false + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + if err := json.NewDecoder(r.Body).Decode(&sandboxBody); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","url":` + strconv.Quote(envServer.URL) + `}`)) + case r.Method == http.MethodDelete && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + t.Setenv("RLE_ENDPOINT", controlPlane.URL) + + command := newInvokeCommand() + command.SetIn(strings.NewReader("health\nexit\n")) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if sandboxBody["version"] != "v1" { + t.Fatalf("expected sandbox version, got %#v", sandboxBody) + } + if !strings.Contains(output.String(), "Sandbox sandbox-1 ready at "+envServer.URL) { + t.Fatalf("expected sandbox ready output, got %s", output.String()) + } + if !strings.Contains(output.String(), `"status": "healthy"`) { + t.Fatalf("expected remote shell health output, got %s", output.String()) + } + if !deleteCalled { + t.Fatal("expected remote invoke to release the sandbox") + } +} + +func TestInvokeRemoteUsesSandboxWebWhenAvailable(t *testing.T) { + openedUrl := captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "code_rl", + Project: "project-1", + EnvironmentId: "env-1", + }); err != nil { + t.Fatal(err) + } + + envServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + _, _ = w.Write([]byte(`{"status":"healthy"}`)) + case "/web": + _, _ = w.Write([]byte(`sandbox ui`)) + default: + http.NotFound(w, r) + } + })) + defer envServer.Close() + + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","url":` + strconv.Quote(envServer.URL) + `}`)) + case r.Method == http.MethodDelete && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + t.Setenv("RLE_ENDPOINT", controlPlane.URL) + + command := newInvokeCommand() + command.SetIn(strings.NewReader("exit\n")) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "Playground UI: "+envServer.URL+"/web") { + t.Fatalf("expected sandbox web URL, got %s", output.String()) + } + if *openedUrl != envServer.URL+"/web" { + t.Fatalf("expected browser to open sandbox web URL, got %q", *openedUrl) + } +} + +func TestRemotePlaygroundProxyForwardsToSandbox(t *testing.T) { + envServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/web": + http.NotFound(w, r) + case "/state": + _, _ = w.Write([]byte(`{"step_count":3}`)) + default: + http.NotFound(w, r) + } + })) + defer envServer.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + playgroundUrl, stop, err := remotePlaygroundUrl(ctx, envServer.URL) + if err != nil { + t.Fatal(err) + } + defer stop() + if !strings.Contains(playgroundUrl, "127.0.0.1") || !strings.HasSuffix(playgroundUrl, "/web") { + t.Fatalf("expected local playground URL, got %q", playgroundUrl) + } + + stateUrl := strings.TrimSuffix(playgroundUrl, "/web") + "/state" + resp, err := http.Get(stateUrl) //nolint:gosec // Test-only local proxy URL. + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != `{"step_count":3}` { + t.Fatalf("expected proxied state body, got %s", body) + } +} + +func TestInvokeRemotePollsSandboxUntilRunning(t *testing.T) { + captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "code_rl", + Project: "project-1", + EnvironmentId: "env-1", + }); err != nil { + t.Fatal(err) + } + + oldPollInterval := remoteSandboxPollInterval + remoteSandboxPollInterval = time.Millisecond + defer func() { remoteSandboxPollInterval = oldPollInterval }() + + envServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/health" { + _, _ = w.Write([]byte(`{"status":"healthy"}`)) + return + } + http.NotFound(w, r) + })) + defer envServer.Close() + + getCount := 0 + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Starting"}`)) + case r.Method == http.MethodGet && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + getCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","url":` + strconv.Quote(envServer.URL) + `}`)) + case r.Method == http.MethodDelete && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + t.Setenv("RLE_ENDPOINT", controlPlane.URL) + + command := newInvokeCommand() + command.SetIn(strings.NewReader("exit\n")) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if getCount != 1 { + t.Fatalf("expected one sandbox poll, got %d", getCount) + } + if !strings.Contains(output.String(), "Sandbox sandbox-1 ready at "+envServer.URL) { + t.Fatalf("expected sandbox ready output, got %s", output.String()) + } +} + +func TestInvokeRemoteFailsWhenSandboxFails(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "code_rl", + Project: "project-1", + EnvironmentId: "env-1", + }); err != nil { + t.Fatal(err) + } + + deleteCalled := false + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Failed","error":"image pull failed"}`)) + case r.Method == http.MethodDelete && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + t.Setenv("RLE_ENDPOINT", controlPlane.URL) + + command := newInvokeCommand() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + err := command.Execute() + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T: %v", err, err) + } + if localErr.Code != "rle_sandbox_start_failed" { + t.Fatalf("expected sandbox failed code, got %q", localErr.Code) + } + if !deleteCalled { + t.Fatal("expected failed sandbox to be released") + } +} + +func TestRequireDeployedEnvironmentRejectsMissingEnvironmentId(t *testing.T) { + err := requireDeployedEnvironment(rleState{Project: "project-1"}) + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) + } + if localErr.Code != "rle_environment_not_deployed" { + t.Fatalf("expected not deployed code, got %q", localErr.Code) + } +} + func TestLocalContainerNamesUseEnvironmentName(t *testing.T) { if name := localContainerName("code_rl"); name != "azd-rle-code-rl" { t.Fatalf("expected local container name, got %q", name) } } -func TestDockerPullFailureSuggestionUsesImageName(t *testing.T) { - suggestion := dockerPullFailureSuggestion("devrle.azurecr.io/echo-rl:latest") - if !strings.Contains(suggestion, "Ensure Docker can pull devrle.azurecr.io/echo-rl:latest") { - t.Fatalf("expected Docker pull guidance, got %q", suggestion) +func TestEnsurePortAvailableRejectsBoundPort(t *testing.T) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + if err := ensurePortAvailable(port); err == nil { + t.Fatal("expected bound port to fail") + } else { + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) + } + for _, expected := range []string{ + "docker ps --filter \"publish=", + "docker rm -f ", + "azd ai rle run --port", + "netstat -ano | findstr", + } { + if !strings.Contains(localErr.Suggestion, expected) { + t.Fatalf("expected suggestion to contain %q, got %q", expected, localErr.Suggestion) + } + } + } +} + +func TestLocalContainerAlreadyRunningErrorSuggestsUsingOrStoppingContainer(t *testing.T) { + err := localContainerAlreadyRunningError("azd-rle-test", 8002) + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) } - if strings.Contains(suggestion, "az ") { - t.Fatalf("did not expect Azure sign-in guidance, got %q", suggestion) + if localErr.Code != "rle_local_container_already_running" { + t.Fatalf("expected already running code, got %q", localErr.Code) + } + if !strings.Contains(localErr.Message, "port 8002") { + t.Fatalf("expected message to mention port, got %q", localErr.Message) + } + for _, expected := range []string{ + "docker ps --filter \"publish=8002\"", + "docker rm -f ", + "azd ai rle run --port 8002", + } { + if !strings.Contains(localErr.Suggestion, expected) { + t.Fatalf("expected suggestion to contain %q, got %q", expected, localErr.Suggestion) + } } } -func TestLocalRuntimeImageTreatsManifestAsAuthoritative(t *testing.T) { - tempDir := t.TempDir() - originalDir, err := os.Getwd() +func TestResolvePortDefaultsTo8000WithoutPersistedState(t *testing.T) { + if port := resolvePort(&openEnvInvokeFlags{}); port != defaultPort { + t.Fatalf("expected default port %d, got %d", defaultPort, port) + } + if port := resolvePort(&openEnvInvokeFlags{port: 9000}); port != 9000 { + t.Fatalf("expected explicit port 9000, got %d", port) + } +} + +func TestLoadLocalRunStateDefaultsToExistingFolderWithoutInit(t *testing.T) { + tempDir := filepath.Join(t.TempDir(), "My Env") + if err := os.MkdirAll(tempDir, 0755); err != nil { + t.Fatal(err) + } + t.Chdir(tempDir) + + state, err := loadLocalRunState(&openEnvInvokeFlags{source: "."}) if err != nil { t.Fatal(err) } - if err := os.Chdir(tempDir); err != nil { + if state.Name != "my-env" { + t.Fatalf("expected source-folder name, got %q", state.Name) + } + image := localRuntimeImageForRun(&openEnvInvokeFlags{source: "."}, state) + if image != "my-env:local" { + t.Fatalf("expected default local image, got %q", image) + } +} + +func TestInvokeRemoteWaitsForDiskImageConversion(t *testing.T) { + captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "code_rl", + Project: "project-1", + EnvironmentId: "env-1", + }); err != nil { t.Fatal(err) } - t.Cleanup(func() { - if err := os.Chdir(originalDir); err != nil { - t.Fatal(err) + + oldPollInterval := remoteImagePollInterval + remoteImagePollInterval = time.Millisecond + defer func() { remoteImagePollInterval = oldPollInterval }() + + envServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/health" { + _, _ = w.Write([]byte(`{"status":"healthy"}`)) + return + } + http.NotFound(w, r) + })) + defer envServer.Close() + + createCount := 0 + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + createCount++ + if createCount < 3 { + http.Error(w, "disk conversion status: Pending", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","url":` + strconv.Quote(envServer.URL) + `}`)) + case r.Method == http.MethodDelete && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) } + })) + defer controlPlane.Close() + t.Setenv("RLE_ENDPOINT", controlPlane.URL) + + command := newInvokeCommand() + command.SetIn(strings.NewReader("exit\n")) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if createCount != 3 { + t.Fatalf("expected sandbox create to retry twice, got %d calls", createCount) + } + if !strings.Contains(output.String(), "Environment image conversion is Pending; waiting") { + t.Fatalf("expected image conversion wait message, got %s", output.String()) + } + if !strings.Contains(output.String(), "Sandbox sandbox-1 ready at "+envServer.URL) { + t.Fatalf("expected sandbox ready output, got %s", output.String()) + } +} + +func TestSandboxLeasePendingStatusTreatsAnyConflictAsPending(t *testing.T) { + status, ok := sandboxLeasePendingStatus(&rleHTTPError{ + statusCode: http.StatusConflict, + body: `{"error":"different conflict"}`, }) + if !ok { + t.Fatal("expected conflict to be treated as pending") + } + if !strings.Contains(status, "different conflict") { + t.Fatalf("expected conflict body in status, got %q", status) + } +} + +func TestRunOpenEnvShellWithContextReturnsOnCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + cancel() - manifest := []byte(` -name: code_rl -template: - kind: openenv - local: - image: - environment: - image: -`) - if err := os.WriteFile(filepath.Join(tempDir, rleManifestFile), manifest, 0600); err != nil { + var output bytes.Buffer + if err := runOpenEnvShellWithContext(ctx, reader, &output, "http://127.0.0.1", 30); err != nil { t.Fatal(err) } +} - _, err = localRuntimeImage(rleState{ - Name: "code_rl", - LocalImage: "example.azurecr.io/stale-local:latest", - Image: "example.azurecr.io/stale-env:latest", +func captureBrowserOpen(t *testing.T) *string { + t.Helper() + old := openBrowserFunc + openedUrl := "" + openBrowserFunc = func(url string) error { + openedUrl = url + return nil + } + t.Cleanup(func() { + openBrowserFunc = old + }) + return &openedUrl +} + +func TestResolveDeployStateDefaultsToExistingFolderWithoutInit(t *testing.T) { + tempDir := filepath.Join(t.TempDir(), "My Env") + if err := os.MkdirAll(tempDir, 0755); err != nil { + t.Fatal(err) + } + t.Chdir(tempDir) + + state, initialized, err := resolveDeployState(&rleDeployFlags{ + projectId: "project-1", + }) + if err != nil { + t.Fatal(err) + } + if initialized { + t.Fatal("expected no saved state") + } + if state.Name != "my-env" { + t.Fatalf("expected source-folder name, got %q", state.Name) + } + if state.Project != "project-1" { + t.Fatalf("expected project flag, got %q", state.Project) + } +} + +func TestResolveDeployStateDoesNotPersistDockerfileFlag(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + state, initialized, err := resolveDeployState(&rleDeployFlags{ + projectId: "project-1", + dockerfile: "server/Dockerfile", }) - if err == nil { - t.Fatal("expected empty manifest images to require an image") + if err != nil { + t.Fatal(err) + } + if initialized { + t.Fatal("expected no saved state") + } + if state.Name != filepath.Base(tempDir) { + t.Fatalf("expected source folder name, got %q", state.Name) + } + if state.Project != "project-1" { + t.Fatalf("expected project flag, got %q", state.Project) + } +} + +func TestResolveDeployImageUsesAcrRegistryEnvironment(t *testing.T) { + t.Setenv("AZURE_CONTAINER_REGISTRY_ENDPOINT", "example.azurecr.io") + + image, err := resolveDeployImage(&rleDeployFlags{}, rleState{Name: "My Env", Project: "Project 1"}) + if err != nil { + t.Fatal(err) + } + if image != "example.azurecr.io/my-env:latest" { + t.Fatalf("expected derived ACR image, got %q", image) + } +} + +func TestResolveDeployImageRequiresAcrRegistry(t *testing.T) { + t.Setenv("AZURE_CONTAINER_REGISTRY_ENDPOINT", "") + + _, err := resolveDeployImage(&rleDeployFlags{}, rleState{Name: "my-env", Project: "project-1"}) + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) + } + if localErr.Code != "rle_acr_registry_required" { + t.Fatalf("expected registry required code, got %q", localErr.Code) + } +} + +func TestResolveDeployImageUsesRegistryEvenWhenStateExists(t *testing.T) { + t.Setenv("AZURE_CONTAINER_REGISTRY_ENDPOINT", "example.azurecr.io") + + image, err := resolveDeployImage( + &rleDeployFlags{}, + rleState{Name: "my-env", Project: "project-1", EnvironmentId: "env-1"}, + ) + if err != nil { + t.Fatal(err) + } + if image != "example.azurecr.io/my-env:latest" { + t.Fatalf("expected registry-derived image, got %q", image) + } +} + +func TestResolveDockerBuildFindsRootDockerfile(t *testing.T) { + tempDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tempDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + + source, dockerfile, err := resolveDockerBuild(dockerBuildOptions{source: tempDir}) + if err != nil { + t.Fatal(err) + } + if source != tempDir { + t.Fatalf("expected source %q, got %q", tempDir, source) + } + if dockerfile != filepath.Join(tempDir, "Dockerfile") { + t.Fatalf("expected root Dockerfile, got %q", dockerfile) + } +} + +func TestResolveDockerBuildFindsServerDockerfile(t *testing.T) { + tempDir := t.TempDir() + serverDir := filepath.Join(tempDir, "server") + if err := os.MkdirAll(serverDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(serverDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + + source, dockerfile, err := resolveDockerBuild(dockerBuildOptions{source: tempDir}) + if err != nil { + t.Fatal(err) + } + if source != tempDir { + t.Fatalf("expected source %q, got %q", tempDir, source) + } + if dockerfile != filepath.Join(serverDir, "Dockerfile") { + t.Fatalf("expected server Dockerfile, got %q", dockerfile) + } +} + +func TestResolveDockerBuildUsesDockerfileOption(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + dockerDir := filepath.Join(tempDir, "docker") + if err := os.MkdirAll(dockerDir, 0755); err != nil { + t.Fatal(err) + } + customPath := filepath.Join(dockerDir, "custom.Dockerfile") + if err := os.WriteFile(customPath, []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + _, dockerfile, err := resolveDockerBuild(dockerBuildOptions{ + source: tempDir, + dockerfile: "docker/custom.Dockerfile", + }) + if err != nil { + t.Fatal(err) + } + if dockerfile != customPath { + t.Fatalf("expected explicit Dockerfile, got %q", dockerfile) + } +} + +func TestIsAcrImageReference(t *testing.T) { + if !isAcrImageReference("myregistry.azurecr.io/echo_env:latest") { + t.Fatal("expected ACR image reference") + } + if isAcrImageReference("echo_env:latest") { + t.Fatal("did not expect local image tag to be treated as ACR") + } +} + +func TestLocalRuntimeImageForRunDefaultsToSourceFolder(t *testing.T) { + tempDir := filepath.Join(t.TempDir(), "My Env") + if err := os.MkdirAll(tempDir, 0755); err != nil { + t.Fatal(err) + } + + image := localRuntimeImageForRun( + &openEnvInvokeFlags{source: tempDir}, + rleState{Name: defaultSourceName(tempDir)}, + ) + if image != "my-env:local" { + t.Fatalf("expected source folder image, got %q", image) + } +} + +func TestResolveDockerBuildRejectsDockerfileEscapes(t *testing.T) { + tempDir := t.TempDir() + outsideDir := t.TempDir() + if err := os.WriteFile(filepath.Join(outsideDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + for _, dockerfile := range []string{ + filepath.Join("..", filepath.Base(outsideDir), "Dockerfile"), + filepath.Join(outsideDir, "Dockerfile"), + } { + if _, _, err := resolveDockerBuild(dockerBuildOptions{ + source: tempDir, + dockerfile: dockerfile, + }); err == nil { + t.Fatalf("expected Dockerfile path %q to be rejected", dockerfile) + } } } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go deleted file mode 100644 index 1459c88e266..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest.go +++ /dev/null @@ -1,255 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -// cspell:ignore openenv - -package cmd - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "os" - "regexp" - "slices" - "strings" - "time" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "gopkg.in/yaml.v3" -) - -type rleManifest struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Kind string `yaml:"kind"` - LocalImage string `yaml:"local_image"` - Image string `yaml:"image"` - RegistrationImage string `yaml:"registration_image"` - Template rleManifestTemplate `yaml:"template"` - Local rleManifestLocal `yaml:"local"` - Registration rleManifestRegistration `yaml:"registration"` - Environment rleManifestEnvironment `yaml:"environment"` -} - -type rleManifestTemplate struct { - Name string `yaml:"name"` - Kind string `yaml:"kind"` - Local rleManifestLocal `yaml:"local"` - Environment rleManifestEnvironment `yaml:"environment"` - Registration rleManifestRegistration `yaml:"registration"` - Image string `yaml:"image"` - LocalImage string `yaml:"local_image"` - RegistrationImage string `yaml:"registration_image"` -} - -type rleManifestLocal struct { - Image string `yaml:"image"` -} - -type rleManifestRegistration struct { - Image string `yaml:"image"` -} - -type rleManifestEnvironment struct { - Image string `yaml:"image"` -} - -func loadRleManifest(path string) (rleManifest, error) { - data, err := readManifestContent(context.Background(), path) - if err != nil { - return rleManifest{}, err - } - return parseRleManifest(data) -} - -func parseRleManifest(data []byte) (rleManifest, error) { - 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 - } - if err := validateManifestKind(manifest); err != nil { - return rleManifest{}, err - } - - return manifest, nil -} - -func readManifestContent(ctx context.Context, path string) ([]byte, error) { - if isRemoteManifestUrl(path) { - if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(path)), "https://") { - return nil, &azdext.LocalError{ - Message: "Remote RLE manifest URLs must use HTTPS.", - Code: "rle_manifest_url_insecure", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Use an https:// URL or a local manifest path.", - } - } - downloadUrl := normalizeManifestUrl(path) - requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, downloadUrl, nil) - if err != nil { - return nil, err - } - resp, err := manifestHTTPClient().Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, &azdext.LocalError{ - Message: fmt.Sprintf("Failed to download RLE manifest from %s: HTTP %d.", downloadUrl, resp.StatusCode), - Code: "rle_manifest_download_failed", - Category: azdext.LocalErrorCategoryUser, - } - } - const maxManifestBytes = 1 << 20 - data, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestBytes+1)) - if err != nil { - return nil, err - } - if len(data) > maxManifestBytes { - return nil, &azdext.LocalError{ - Message: fmt.Sprintf("RLE manifest from %s is larger than 1 MiB.", downloadUrl), - Code: "rle_manifest_too_large", - Category: azdext.LocalErrorCategoryUser, - } - } - return data, nil - } - return os.ReadFile(path) //nolint:gosec // Manifest path is provided by the user. -} - -func manifestHTTPClient() *http.Client { - return &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if !strings.EqualFold(req.URL.Scheme, "https") { - return &azdext.LocalError{ - Message: "Remote RLE manifest redirects must stay on HTTPS.", - Code: "rle_manifest_url_insecure_redirect", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Use an HTTPS manifest URL that does not redirect to HTTP.", - } - } - return nil - }, - } -} - -func normalizeManifestUrl(raw string) string { - parsed, err := url.Parse(raw) - if err != nil || !strings.EqualFold(parsed.Host, "github.com") { - return raw - } - parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - if len(parts) < 5 || parts[2] != "blob" { - return raw - } - owner, repo, ref := parts[0], parts[1], parts[3] - path := strings.Join(parts[4:], "/") - return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", owner, repo, ref, path) -} - -func isRemoteManifestUrl(path string) bool { - value := strings.ToLower(strings.TrimSpace(path)) - return strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "http://") -} - -func expandManifestEnv(content string) (string, error) { - missing := map[string]struct{}{} - expanded := manifestEnvPattern.ReplaceAllStringFunc(content, func(token string) string { - name := token[2 : len(token)-1] - value, ok := os.LookupEnv(name) - if !ok { - missing[name] = struct{}{} - } - return value - }) - - if len(missing) == 0 { - return expanded, nil - } - - names := make([]string, 0, len(missing)) - for name := range missing { - names = append(names, name) - } - slices.Sort(names) - - return "", &azdext.LocalError{ - Message: fmt.Sprintf("RLE manifest references unset environment variable(s): %s.", strings.Join(names, ", ")), - Code: "rle_manifest_env_missing", - Category: azdext.LocalErrorCategoryUser, - Suggestion: fmt.Sprintf( - "Set %s, then run the command again.", - strings.Join(names, ", "), - ), - } -} - -var manifestEnvPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) - -func stateFromManifest(manifest rleManifest) (rleState, error) { - state := defaultRleState(firstNonEmpty(manifest.Name, manifest.Template.Name)) - - state.LocalImage = localImageFromManifest(manifest) - state.Image = registrationImageFromManifest(manifest) - - return state, nil -} - -func localImageFromManifest(manifest rleManifest) string { - return firstNonEmpty( - manifest.Template.Local.Image, - manifest.Template.LocalImage, - manifest.Local.Image, - manifest.LocalImage, - manifest.Template.Image, - manifest.Image, - manifest.Template.Environment.Image, - manifest.Environment.Image, - ) -} - -func registrationImageFromManifest(manifest rleManifest) string { - return firstNonEmpty( - manifest.Template.Environment.Image, - manifest.Template.Registration.Image, - manifest.Template.RegistrationImage, - manifest.Environment.Image, - manifest.Registration.Image, - manifest.RegistrationImage, - manifest.Template.Image, - manifest.Image, - ) -} - -func validateManifestKind(manifest rleManifest) error { - kind := strings.ToLower(firstNonEmpty(manifest.Template.Kind, manifest.Kind)) - if kind == "" || kind == "openenv" { - return nil - } - return &azdext.LocalError{ - Message: fmt.Sprintf("Unsupported RLE manifest kind %q.", kind), - Code: "rle_manifest_kind_unsupported", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Use template.kind: openenv.", - } -} - -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 deleted file mode 100644 index b702ac0e64b..00000000000 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/manifest_test.go +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "net/http" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestExpandManifestEnvUsesEnvironmentVariables(t *testing.T) { - t.Setenv("RLE_ACR_IMAGE", "example.azurecr.io/code:latest") - - expanded, err := expandManifestEnv("image: ${RLE_ACR_IMAGE}\n") - if err != nil { - t.Fatal(err) - } - - 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("image: ${RLE_MISSING_IMAGE}\n"); err == nil { - t.Fatal("expected missing environment variable to fail") - } -} - -func TestExpandManifestEnvIgnoresUnbracedDollarReferences(t *testing.T) { - manifest := "# yaml-language-server: $schema=https://example/rle.schema.json\nname: code_rl\n" - - expanded, err := expandManifestEnv(manifest) - if err != nil { - t.Fatal(err) - } - if expanded != manifest { - t.Fatalf("expected unbraced dollar reference to be preserved, got %q", expanded) - } -} - -func TestExpandManifestEnvPreservesLiteralDollarText(t *testing.T) { - manifest := "description: Price is $5 and schema hint is $schema\n" - - expanded, err := expandManifestEnv(manifest) - if err != nil { - t.Fatal(err) - } - if expanded != manifest { - t.Fatalf("expected literal dollar text to be preserved, got %q", expanded) - } -} - -func TestStateFromManifestUsesResolvedImage(t *testing.T) { - state, err := stateFromManifest(rleManifest{ - Name: "code_rl", - Image: "example.azurecr.io/code:latest", - }) - if err != nil { - t.Fatal(err) - } - - if state.Project != "" { - t.Fatalf("expected manifest not to set project, got %q", state.Project) - } - 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) - } - if state.LocalImage != "example.azurecr.io/code:latest" { - t.Fatalf("expected local image fallback, got %q", state.LocalImage) - } -} - -func TestStateFromManifestSupportsSeparateLocalAndRegistrationImages(t *testing.T) { - state, err := stateFromManifest(rleManifest{ - Name: "code_rl", - Template: rleManifestTemplate{ - Kind: "openenv", - Local: rleManifestLocal{ - Image: "example.azurecr.io/code:smoke", - }, - Environment: rleManifestEnvironment{ - Image: "example.azurecr.io/code:deepcoder", - }, - }, - }) - if err != nil { - t.Fatal(err) - } - - if state.LocalImage != "example.azurecr.io/code:smoke" { - t.Fatalf("expected local image, got %q", state.LocalImage) - } - if state.Image != "example.azurecr.io/code:deepcoder" { - t.Fatalf("expected registration image, got %q", state.Image) - } -} - -func TestStateFromManifestUsesTemplateName(t *testing.T) { - state, err := stateFromManifest(rleManifest{ - Template: rleManifestTemplate{ - Name: "code_rl", - Kind: "openenv", - Local: rleManifestLocal{ - Image: "example.azurecr.io/code:smoke", - }, - Environment: rleManifestEnvironment{ - Image: "example.azurecr.io/code:deepcoder", - }, - }, - }) - if err != nil { - t.Fatal(err) - } - if state.Name != "code_rl" { - t.Fatalf("expected template name, got %q", state.Name) - } -} - -func TestValidateManifestKindRejectsUnsupportedKinds(t *testing.T) { - err := validateManifestKind(rleManifest{ - Template: rleManifestTemplate{Kind: "hosted"}, - }) - if err == nil { - t.Fatal("expected unsupported manifest kind to fail") - } -} - -func TestNormalizeManifestUrlConvertsGitHubBlobUrl(t *testing.T) { - got := normalizeManifestUrl("https://github.com/owner/repo/blob/main/path/to/rle.yaml") - expected := "https://raw.githubusercontent.com/owner/repo/main/path/to/rle.yaml" - if got != expected { - t.Fatalf("expected %q, got %q", expected, got) - } -} - -func TestReadManifestContentRejectsHttpUrls(t *testing.T) { - _, err := readManifestContent(context.Background(), "http://example.com/rle.yaml") - if err == nil { - t.Fatal("expected http manifest URL to fail") - } - _, err = readManifestContent(context.Background(), "https://github.com/owner/repo/blob/main/rle.yaml") - if err == nil { - t.Fatal("expected http GitHub manifest URL to fail") - } -} - -func TestManifestHTTPClientRejectsHttpRedirects(t *testing.T) { - redirectReq, err := http.NewRequest(http.MethodGet, "http://example.com/rle.yaml", nil) - if err != nil { - t.Fatal(err) - } - - if err := manifestHTTPClient().CheckRedirect(redirectReq, nil); err == nil { - t.Fatal("expected http redirect target to fail") - } -} - -func TestLoadManifestAllowsFutureSourceFields(t *testing.T) { - manifestPath := filepath.Join(t.TempDir(), "rle.yaml") - manifest := []byte(` -name: code_rl -template: - name: code_rl - kind: openenv - source: - type: huggingface-space - repo: openenv/echo_env - local: - image: example.azurecr.io/code:smoke - environment: - image: example.azurecr.io/code:prod -`) - if err := os.WriteFile(manifestPath, manifest, 0600); err != nil { - t.Fatal(err) - } - - loaded, err := loadRleManifest(manifestPath) - if err != nil { - t.Fatal(err) - } - - state, err := stateFromManifest(loaded) - if err != nil { - t.Fatal(err) - } - if state.LocalImage != "example.azurecr.io/code:smoke" { - t.Fatalf("expected local image, got %q", state.LocalImage) - } - if state.Image != "example.azurecr.io/code:prod" { - t.Fatalf("expected environment image, got %q", state.Image) - } -} 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 98f8514d178..2ec014ee434 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -5,12 +5,16 @@ package cmd import ( "fmt" + "os" + "strconv" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" "github.com/spf13/cobra" ) +const rleEnableEnvVar = "AZD_AI_RLE_ENABLE" + func NewRootCommand() *cobra.Command { rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ Name: "rle", @@ -31,12 +35,23 @@ func NewRootCommand() *cobra.Command { defaultHelp(cmd, args) }) - rootCmd.AddCommand(newDeployCommand()) - rootCmd.AddCommand(newInitCommand()) - rootCmd.AddCommand(newInvokeCommand()) - rootCmd.AddCommand(newRunCommand()) - rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) + userCommands := []*cobra.Command{ + newDeployCommand(), + newInitCommand(), + newInvokeCommand(), + newRunCommand(), + newVersionCommand(&extCtx.OutputFormat), + } + for _, command := range userCommands { + command.Hidden = !rleCommandsEnabled() + rootCmd.AddCommand(command) + } rootCmd.AddCommand(newMetadataCommand(rootCmd)) return rootCmd } + +func rleCommandsEnabled() bool { + enabled, err := strconv.ParseBool(os.Getenv(rleEnableEnvVar)) + return err == nil && enabled +} 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 5bdd316deea..ae4280e27dd 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 @@ -6,6 +6,7 @@ package cmd import ( "bytes" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -22,6 +23,39 @@ func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { } } +func TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { + t.Setenv(rleEnableEnvVar, "") + rootCmd := NewRootCommand() + for _, commandName := range []string{"deploy", "init", "invoke", "run", "version"} { + command, _, err := rootCmd.Find([]string{commandName}) + if err != nil { + t.Fatalf("expected command %q to be registered: %v", commandName, err) + } + if !command.Hidden { + t.Fatalf("expected command %q to be hidden when %s is not true", commandName, rleEnableEnvVar) + } + } + metadataCommand, _, err := rootCmd.Find([]string{"metadata"}) + if err != nil { + t.Fatalf("expected metadata command to be registered: %v", err) + } + if !metadataCommand.Hidden { + t.Fatal("expected metadata command to remain hidden") + } + + t.Setenv(rleEnableEnvVar, "true") + rootCmd = NewRootCommand() + for _, commandName := range []string{"deploy", "init", "invoke", "run", "version"} { + command, _, err := rootCmd.Find([]string{commandName}) + if err != nil { + t.Fatalf("expected command %q to be registered: %v", commandName, err) + } + if command.Hidden { + t.Fatalf("expected command %q to be visible when %s=true", commandName, rleEnableEnvVar) + } + } +} + func TestDeployExposesProjectIdFlag(t *testing.T) { rootCmd := NewRootCommand() command, _, err := rootCmd.Find([]string{"deploy"}) @@ -31,6 +65,15 @@ func TestDeployExposesProjectIdFlag(t *testing.T) { if flag := command.Flags().Lookup("project-id"); flag == nil { t.Fatal("expected deploy to expose --project-id") } + if flag := command.Flags().Lookup("image"); flag != nil { + t.Fatal("expected deploy not to expose --image") + } + if flag := command.Flags().Lookup("dockerfile"); flag == nil { + t.Fatal("expected deploy to expose --dockerfile") + } + if flag := command.Flags().Lookup("name"); flag != nil { + t.Fatal("expected deploy not to expose --name") + } } func TestLifecycleFlagsAlignWithHostedAgentConventions(t *testing.T) { @@ -48,8 +91,14 @@ func TestLifecycleFlagsAlignWithHostedAgentConventions(t *testing.T) { if strings.Contains(initHelp.String(), "--manifest") { t.Fatal("expected init help not to expose --manifest") } - if !strings.Contains(initHelp.String(), "-m string") { - t.Fatal("expected init help to expose -m") + if strings.Contains(initHelp.String(), "-m string") { + t.Fatal("expected init help not to expose -m") + } + if flag := initCommand.Flags().Lookup("manifest"); flag != nil { + t.Fatal("expected init not to expose --manifest") + } + if flag := initCommand.Flags().Lookup("name"); flag != nil { + t.Fatal("expected init not to expose --name") } runCommand, _, err := rootCmd.Find([]string{"run"}) @@ -59,6 +108,21 @@ func TestLifecycleFlagsAlignWithHostedAgentConventions(t *testing.T) { if flag := runCommand.Flags().Lookup("port"); flag == nil { t.Fatal("expected run to expose --port") } + if flag := runCommand.Flags().Lookup("dockerfile"); flag == nil { + t.Fatal("expected run to expose --dockerfile") + } + if flag := runCommand.Flags().Lookup("image"); flag != nil { + t.Fatal("expected run not to expose --image") + } + if flag := runCommand.Flags().Lookup("watch"); flag == nil { + t.Fatal("expected run to expose --watch") + } + if flag := runCommand.Flags().Lookup("source"); flag != nil { + t.Fatal("expected run not to expose --source") + } + if flag := runCommand.Flags().Lookup("name"); flag != nil { + t.Fatal("expected run not to expose --name") + } invokeCommand, _, err := rootCmd.Find([]string{"invoke"}) if err != nil { @@ -67,27 +131,33 @@ func TestLifecycleFlagsAlignWithHostedAgentConventions(t *testing.T) { if flag := invokeCommand.Flags().Lookup("timeout"); flag == nil { t.Fatal("expected invoke to expose --timeout") } - if flag := invokeCommand.Flags().Lookup("local"); flag == nil { - t.Fatal("expected invoke to expose --local") + if flag := invokeCommand.Flags().Lookup("local"); flag != nil { + t.Fatal("expected invoke not to expose --local") } -} - -func TestInitParsesManifestShorthandOnly(t *testing.T) { - if initUsedLongManifestFlag([]string{"init", "-m", "rle.yaml"}) { - t.Fatal("expected -m to be allowed") + if flag := invokeCommand.Flags().Lookup("dockerfile"); flag != nil { + t.Fatal("expected invoke not to expose --dockerfile") + } + if flag := invokeCommand.Flags().Lookup("image"); flag != nil { + t.Fatal("expected invoke not to expose --image") } - if !initUsedLongManifestFlag([]string{"init", "--manifest", "rle.yaml"}) { - t.Fatal("expected --manifest to be detected") + if flag := invokeCommand.Flags().Lookup("port"); flag != nil { + t.Fatal("expected invoke not to expose --port") } - if !initUsedLongManifestFlag([]string{"init", "--manifest=rle.yaml"}) { - t.Fatal("expected --manifest=... to be detected") + if flag := invokeCommand.Flags().Lookup("source"); flag != nil { + t.Fatal("expected invoke not to expose --source") + } + if flag := invokeCommand.Flags().Lookup("name"); flag != nil { + t.Fatal("expected invoke not to expose --name") + } + if flag := invokeCommand.Flags().Lookup("endpoint"); flag != nil { + t.Fatal("expected invoke not to expose --endpoint") } } func TestLifecycleCommandsRejectPositionalArguments(t *testing.T) { rootCmd := NewRootCommand() - for _, commandName := range []string{"deploy", "init", "invoke", "run"} { + for _, commandName := range []string{"deploy", "invoke", "run"} { command, _, err := rootCmd.Find([]string{commandName}) if err != nil { t.Fatalf("expected command %q to be registered: %v", commandName, err) @@ -96,11 +166,23 @@ func TestLifecycleCommandsRejectPositionalArguments(t *testing.T) { t.Fatalf("expected command %q to reject positional arguments", commandName) } } + + initCommand, _, err := rootCmd.Find([]string{"init"}) + if err != nil { + t.Fatalf("expected init command to be registered: %v", err) + } + if err := initCommand.Args(initCommand, []string{"custom_env"}); err != nil { + t.Fatalf("expected init to accept one positional environment name: %v", err) + } + if err := initCommand.Args(initCommand, []string{"one", "two"}); err == nil { + t.Fatal("expected init to reject multiple positional arguments") + } } -func TestInitUsesEmbeddedEchoManifestByDefault(t *testing.T) { +func TestInitCopiesOpenEnvEchoSampleByDefault(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) + stubOpenEnvEchoCheckout(t) command := newInitCommand() var output bytes.Buffer @@ -111,14 +193,6 @@ func TestInitUsesEmbeddedEchoManifestByDefault(t *testing.T) { } sessionDir := filepath.Join(tempDir, "echo_env") - manifestBytes, err := os.ReadFile(filepath.Join(sessionDir, rleManifestFile)) - if err != nil { - t.Fatal(err) - } - if got := string(manifestBytes); got != defaultEchoManifest { - t.Fatalf("expected embedded echo manifest, got:\n%s", got) - } - stateBytes, err := os.ReadFile(filepath.Join(sessionDir, rleStateFile)) if err != nil { t.Fatal(err) @@ -130,29 +204,24 @@ func TestInitUsesEmbeddedEchoManifestByDefault(t *testing.T) { if state.Name != "echo_env" { t.Fatalf("expected echo_env state name, got %q", state.Name) } - if state.Image != "devrle.azurecr.io/echo-rl:latest" { - t.Fatalf("expected echo image from manifest, got %q", state.Image) + if _, err := os.Stat(filepath.Join(sessionDir, "server", "Dockerfile")); err != nil { + t.Fatalf("expected copied OpenEnv server Dockerfile: %v", err) } - if state.LocalImage != "devrle.azurecr.io/echo-rl:latest" { - t.Fatalf("expected local image fallback from manifest, got %q", state.LocalImage) + if _, err := os.Stat(filepath.Join(sessionDir, ".git")); !os.IsNotExist(err) { + t.Fatalf("expected copied sample not to include .git metadata, got err=%v", err) + } + if !strings.Contains(output.String(), fmt.Sprintf("cd %q", sessionDir)) { + t.Fatalf("expected init output to quote cd path, got %s", output.String()) } } -func TestInitInfersNameFromManifestOnly(t *testing.T) { +func TestInitUsesPositionalNameForDefaultSample(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) - manifestPath := filepath.Join(tempDir, "custom-rle.yaml") - if err := os.WriteFile(manifestPath, []byte(`template: - name: code_rl - kind: openenv - environment: - image: example.azurecr.io/code:latest -`), 0600); err != nil { - t.Fatal(err) - } + stubOpenEnvEchoCheckout(t) command := newInitCommand() - command.SetArgs([]string{"-m", manifestPath}) + command.SetArgs([]string{"code_rl"}) var output bytes.Buffer command.SetOut(&output) command.SetErr(&output) @@ -160,7 +229,41 @@ func TestInitInfersNameFromManifestOnly(t *testing.T) { t.Fatal(err) } - if _, err := os.Stat(filepath.Join(tempDir, "code_rl", rleStateFile)); err != nil { + sessionDir := filepath.Join(tempDir, "code_rl") + stateBytes, err := os.ReadFile(filepath.Join(sessionDir, rleStateFile)) + if err != nil { + t.Fatal(err) + } + var state rleState + if err := json.Unmarshal(stateBytes, &state); err != nil { t.Fatal(err) } + if state.Name != "code_rl" { + t.Fatalf("expected code_rl state name, got %q", state.Name) + } +} + +func stubOpenEnvEchoCheckout(t *testing.T) { + t.Helper() + old := checkoutOpenEnvEchoSampleFunc + checkoutOpenEnvEchoSampleFunc = func(name string, dest string, force bool) (string, error) { + sessionDir, err := createRleSessionDir(name, dest, force) + if err != nil { + return "", err + } + serverDir := filepath.Join(sessionDir, "server") + if err := os.MkdirAll(serverDir, 0755); err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(serverDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(sessionDir, "openenv.yaml"), []byte("name: echo_env\n"), 0600); err != nil { + return "", err + } + return sessionDir, nil + } + t.Cleanup(func() { + checkoutOpenEnvEchoSampleFunc = old + }) } 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 f0863ad86f4..c8039fcdedf 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go @@ -1,13 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -// cspell:ignore abstractmethod asctime devrle gethostname kwargs openenv rstrip urlopen - package cmd import ( "fmt" "os" + "os/exec" "path/filepath" "regexp" "strings" @@ -17,17 +16,6 @@ import ( var envNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) -const defaultEchoManifest = `# RLE environment manifest (OpenEnv-style). -spec_version: 1 -name: echo_env -description: A local echo OpenEnv environment. -template: - name: echo_env - kind: openenv - environment: - image: devrle.azurecr.io/echo-rl:latest -` - func validateEnvName(name string) (string, error) { name = strings.TrimSpace(name) if !envNamePattern.MatchString(name) { @@ -53,9 +41,15 @@ func slug(name string) string { return strings.Trim(builder.String(), "-") } -func scaffoldRleSession(name string, dest string, localImage string, registrationImage string, force bool) (string, error) { - envClass := toPascal(name) - envTitle := toTitle(name) +const ( + openEnvRepoUrl = "https://github.com/huggingface/OpenEnv.git" + openEnvRepoRef = "main" + openEnvEchoSamplePath = "envs/echo_env" +) + +var checkoutOpenEnvEchoSampleFunc = checkoutOpenEnvEchoSample + +func createRleSessionDir(name string, dest string, force bool) (string, error) { sessionDir := filepath.Join(dest, name) if entries, err := os.ReadDir(sessionDir); err == nil && len(entries) > 0 && !force { return "", &azdext.LocalError{ @@ -67,459 +61,123 @@ func scaffoldRleSession(name string, dest string, localImage string, registratio } else if err != nil && !os.IsNotExist(err) { return "", err } - - files := map[string]string{ - "models.py": modelsTemplate, - "client.py": clientTemplate, - "requirements.txt": requirementsTemplate, - rleManifestFile: manifestTemplate, - "README.md": readmeTemplate, - "rle_runtime.py": runtimeTemplate, - "server/" + name + "_environment.py": environmentTemplate, - "server/app.py": appTemplate, - "server/__init__.py": "", + if force { + if err := os.RemoveAll(sessionDir); err != nil { + return "", err + } } - - replacements := map[string]string{ - "__ENV_NAME__": name, - "__ENV_CLASS__": envClass, - "__ENV_TITLE__": envTitle, - "__LOCAL_IMAGE__": localImage, - "__IMAGE__": registrationImage, + if err := os.MkdirAll(sessionDir, 0755); err != nil { + return "", err } + return sessionDir, nil +} - 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 - } +func checkoutOpenEnvEchoSample(name string, dest string, force bool) (string, error) { + sessionDir, err := createRleSessionDir(name, dest, force) + if err != nil { + return "", err + } + tempDir, err := os.MkdirTemp("", "azd-rle-openenv-*") + if err != nil { + return "", err + } + defer func() { + _ = os.RemoveAll(tempDir) + }() + + if err := runGitCheckout( + "clone", + "--depth", "1", + "--filter=blob:none", + "--sparse", + "--branch", openEnvRepoRef, + openEnvRepoUrl, + tempDir, + ); err != nil { + return "", err + } + if err := runGitCheckout("-C", tempDir, "sparse-checkout", "set", openEnvEchoSamplePath); err != nil { + return "", err } + sourceDir := filepath.Join(tempDir, filepath.FromSlash(openEnvEchoSamplePath)) + if err := copyDirectory(sourceDir, sessionDir); err != nil { + return "", err + } return sessionDir, nil } -func toPascal(name string) string { - parts := strings.Split(name, "_") - for i, part := range parts { - if part == "" { - continue +func isSafeRelativePath(path string) bool { + return path != ".." && + !filepath.IsAbs(path) && + !strings.HasPrefix(path, ".."+string(os.PathSeparator)) +} + +func runGitCheckout(args ...string) error { + 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, then retry azd ai rle init.", + } + } + process := exec.Command("git", args...) + process.Env = os.Environ() + output, err := process.CombinedOutput() + if err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf("Failed to checkout OpenEnv echo sample: %v", err), + Code: "rle_openenv_checkout_failed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: strings.TrimSpace(string(output)), } - parts[i] = strings.ToUpper(part[:1]) + part[1:] } - return strings.Join(parts, "") + return nil } -func toTitle(name string) string { - parts := strings.Split(name, "_") - for i, part := range parts { - if part == "" { - continue +func copyDirectory(sourceDir string, destDir string) error { + sourceInfo, err := os.Stat(sourceDir) + if err != nil { + return err + } + if !sourceInfo.IsDir() { + return &azdext.LocalError{ + Message: fmt.Sprintf("RLE source path %q is not a directory.", sourceDir), + Code: "rle_source_path_not_directory", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use a source directory when initializing an RLE environment.", } - parts[i] = strings.ToUpper(part[:1]) + part[1:] } - return strings.Join(parts, " ") + return filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && entry.Name() == ".git" { + return filepath.SkipDir + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + relativePath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + if relativePath == "." { + return nil + } + targetPath := filepath.Join(destDir, relativePath) + if entry.IsDir() { + return os.MkdirAll(targetPath, 0755) + } + info, err := entry.Info() + if err != nil { + return err + } + data, err := os.ReadFile(path) //nolint:gosec // path comes from the checked-out sample directory walk. + if err != nil { + return err + } + return os.WriteFile(targetPath, data, info.Mode().Perm()) + }) } - -const manifestTemplate = `# RLE environment manifest (OpenEnv-style). -spec_version: 1 -name: __ENV_NAME__ -description: A local echo OpenEnv environment. -template: - name: __ENV_NAME__ - kind: openenv - local: - image: __LOCAL_IMAGE__ - environment: - image: __IMAGE__ -` - -const requirementsTemplate = `fastapi>=0.110 -uvicorn>=0.29 -pydantic>=2.6 -` - -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(default="hello", description="Message to echo") - - -class __ENV_CLASS__Observation(Observation): - """Observation returned by the __ENV_TITLE__ environment.""" - - message: str = Field(default="", description="Echo response") -` - -const environmentTemplate = `"""__ENV_TITLE__ environment implementation.""" - -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 minimal echo environment for smoke testing the OpenEnv runtime.""" - - 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( - message="Echo environment reset.", - done=False, - reward=0.0, - ) - - def step(self, action: __ENV_CLASS__Action, **kwargs: Any) -> __ENV_CLASS__Observation: - self._step_count += 1 - return __ENV_CLASS__Observation( - message=f"echo: {action.message}", - done=False, - reward=0.0, - ) - - @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 the container directly -(for example, http://localhost:32891). - -Example: - from client import __ENV_CLASS__Client - - base = "http://localhost:8000" - 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 readmeTemplate = `# __ENV_TITLE__ RLE Environment - -An OpenEnv-style Reinforcement Learning Environment generated by the ` + "`rle`" + ` CLI. - -## Layout - -| File | Purpose | -| --- | --- | -| ` + "`rle_runtime.py`" + ` | OpenEnv-compatible runtime and FastAPI factory. | -| ` + "`models.py`" + ` | ` + "`__ENV_CLASS__Action`" + ` / ` + "`__ENV_CLASS__Observation`" + ` data models. | -| ` + "`server/__ENV_NAME___environment.py`" + ` | Environment reset/step/state logic. | -| ` + "`server/app.py`" + ` | FastAPI app exposing the OpenEnv endpoints. | -| ` + "`client.py`" + ` | Minimal HTTP client for testing. | -| ` + "`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 -` + "```" + ` - -## Run with azd - -` + "```bash" + ` -azd ai rle run -azd ai rle invoke --local -` + "```" + ` - -Inside the invoke shell: - -` + "```text" + ` -rle> health -rle> reset {"seed":0} -rle> step {"message":"hello"} -rle> state -rle> exit -` + "```" + ` - -` + "`run`" + ` uses ` + "`template.local.image`" + ` when set, falls back to -` + "`template.environment.image`" + `, and runs that image locally with Docker. - -` + "`deploy`" + ` registers ` + "`template.environment.image`" + ` with RLE. -` - -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/scaffold_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold_test.go new file mode 100644 index 00000000000..1c28d0c3675 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold_test.go @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCopyDirectorySkipsGitMetadata(t *testing.T) { + sourceDir := t.TempDir() + destDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(sourceDir, ".git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourceDir, ".git", "config"), []byte("[remote]\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + + if err := copyDirectory(sourceDir, destDir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destDir, "Dockerfile")); err != nil { + t.Fatalf("expected Dockerfile to be copied: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, ".git")); !os.IsNotExist(err) { + t.Fatalf("expected .git metadata to be skipped, got err=%v", err) + } +} + +func TestCopyDirectorySkipsSymlinksAndPreservesFileMode(t *testing.T) { + sourceDir := t.TempDir() + destDir := t.TempDir() + scriptPath := filepath.Join(sourceDir, "run.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(scriptPath, filepath.Join(sourceDir, "linked-run.sh")); err != nil { + t.Skipf("symlinks are not available in this environment: %v", err) + } + + if err := copyDirectory(sourceDir, destDir); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(destDir, "run.sh")) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0755 { + t.Fatalf("expected executable bit to be preserved, got %v", info.Mode().Perm()) + } + if _, err := os.Stat(filepath.Join(destDir, "linked-run.sh")); !os.IsNotExist(err) { + t.Fatalf("expected symlink to be skipped, got err=%v", err) + } +} + +func TestCopyDirectoryRejectsFileSource(t *testing.T) { + sourceFile := filepath.Join(t.TempDir(), "source.txt") + if err := os.WriteFile(sourceFile, []byte("content"), 0600); err != nil { + t.Fatal(err) + } + if err := copyDirectory(sourceFile, t.TempDir()); err == nil { + t.Fatal("expected file source path to be rejected") + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go index f01ec435ab4..238014703d1 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go @@ -13,15 +13,11 @@ import ( ) const ( - rleStateFile = ".azd-rle.json" - rleManifestFile = "rle.yaml" + rleStateFile = ".azd-rle.json" ) type rleState struct { Name string `json:"name"` - LocalImage string `json:"localImage,omitempty"` - Image string `json:"image,omitempty"` - Port int `json:"port,omitempty"` Project string `json:"project"` EnvironmentId string `json:"environmentId,omitempty"` EnvironmentVersion string `json:"environmentVersion,omitempty"` diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/util.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/util.go new file mode 100644 index 00000000000..1563d75c485 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/util.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "strings" + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} From 3d83b44b64f4eba44cf46621b6906e6301e80930 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Thu, 2 Jul 2026 16:49:32 +0530 Subject: [PATCH 16/25] fix: cli optimizations --- cli/azd/extensions/azure.ai.rle/README.md | 11 +- .../azure.ai.rle/internal/cmd/banner.go | 9 +- .../azure.ai.rle/internal/cmd/build.go | 8 +- .../azure.ai.rle/internal/cmd/client.go | 12 +- .../azure.ai.rle/internal/cmd/client_test.go | 32 +++++ .../azure.ai.rle/internal/cmd/deploy.go | 30 ++--- .../azure.ai.rle/internal/cmd/init.go | 9 +- .../azure.ai.rle/internal/cmd/invoke.go | 69 +++++------ .../azure.ai.rle/internal/cmd/invoke_test.go | 117 +++++++++++++----- .../azure.ai.rle/internal/cmd/root_test.go | 8 +- .../azure.ai.rle/internal/cmd/scaffold.go | 5 +- .../azure.ai.rle/internal/cmd/state.go | 2 +- 12 files changed, 195 insertions(+), 117 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index b2de83db2f1..64928a85ef2 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -1,5 +1,4 @@ # Azure AI RLE extension for azd - Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: copy OpenEnv sample source, build and run the environment container, test it through a playground UI or shell, and deploy the environment image to the RLE control plane. @@ -78,7 +77,7 @@ azd ai rle init cd .\echo_env ``` -The default echo session downloads `huggingface/OpenEnv`, copies `envs/echo_env` into the session folder, +The default echo session downloads the Hugging Face `OpenEnv` repo, copies `envs/echo_env` into the session folder, and writes `.azd-rle.json` with the local environment name. The copied session does not keep `.git` metadata from the upstream repository. @@ -89,7 +88,8 @@ Name the copied echo session: azd ai rle init code_rl ``` -For an existing source folder, skip `init` and run commands directly from that folder. +For an existing source folder, skip `init` and run commands directly from that folder. If `.azd-rle.json` +does not exist, `run` creates it with only the inferred local environment name. ### 2. Run locally @@ -100,6 +100,8 @@ azd ai rle run `run` builds a local Docker image from the current source folder, removes any stale local container for the same environment name, starts a fresh container, waits for `/health`, opens the playground UI at `/web`, and keeps an OpenEnv shell attached. When the shell exits or Ctrl+C is received, `run` removes the local container. +When `.azd-rle.json` is missing, `run` prints a message that it is using the current folder as the RLE source +and creates `.azd-rle.json` with only the `name` field. Use a custom host port: @@ -151,7 +153,8 @@ $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" azd ai rle deploy --project-id ``` -Deploy builds the Docker image as `.azurecr.io/:latest`, pushes it to ACR, registers that image with the RLE control plane, and saves the project/environment details in `.azd-rle.json`. +Deploy builds the Docker image as `.azurecr.io/-:latest`, pushes it to ACR, registers that image with the RLE control plane, and saves the project/environment details in `.azd-rle.json`. +The deploy command prints a CLI-friendly summary using `environmentId`, `acrImage`, `version`, `createdAt`, and `updatedAt`. If needed, override the Dockerfile path the same way as local run: diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go index d14e7d272b0..4a364d423b4 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go @@ -13,11 +13,10 @@ import ( const bannerArt = `███████╗ ██████╗ ██╗ ██╗███╗ ██╗██████╗ ██████╗ ██╗ ██╗ ██╔════╝██╔═══██╗██║ ██║████╗ ██║██╔══██╗██╔══██╗╚██╗ ██╔╝ -█████╗ ██║ ██║██║ ██║██╔██╗ ██║██║ ██║██████╔╝ ╚████╔╝ -██╔══╝ ██║ ██║██║ ██║██║╚██╗██║██║ ██║██╔══██╗ ╚██╔╝ -██║ ╚██████╔╝╚██████╔╝██║ ╚████║██████╔╝██║ ██║ ██║ -╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ - ` +█████╗ ██║ ██║██║ ██║██╔██╗ ██║██║ ██║██████╔╝ ╚████╔╝ +██╔══╝ ██║ ██║██║ ██║██║╚██╗██║██║ ██║██╔══██╗ ╚██╔╝ +██║ ╚██████╔╝╚██████╔╝██║ ╚████║██████╔╝██║ ██║ ██║ +╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝ ╚═╝` func printBanner(w io.Writer) { purple := color.RGB(109, 53, 255).Add(color.Bold) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/build.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/build.go index dc3f30129a4..a869c791d54 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/build.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/build.go @@ -18,11 +18,6 @@ type dockerBuildOptions struct { dockerfile string } -func resolveDockerBuild(opts dockerBuildOptions) (source string, dockerfile string, err error) { - source, dockerfile, _, err = prepareDockerBuild(opts) - return source, dockerfile, err -} - func prepareDockerBuild(opts dockerBuildOptions) (source string, dockerfile string, cleanup func(), err error) { source = strings.TrimSpace(opts.source) if source == "" { @@ -33,7 +28,6 @@ func prepareDockerBuild(opts dockerBuildOptions) (source string, dockerfile stri return "", "", nil, err } - dockerfile = "" dockerfile = strings.TrimSpace(opts.dockerfile) if dockerfile != "" { if !isSafeRelativePath(filepath.Clean(filepath.FromSlash(dockerfile))) { @@ -119,7 +113,7 @@ func pushDockerImage(cmd *cobra.Command, image string) error { Message: fmt.Sprintf("Failed to push Docker image %q: %v", image, err), Code: "rle_docker_push_failed", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Ensure Docker is authenticated for the target registry, then retry.", + Suggestion: "Run az acr login --name , then retry.", } } return nil 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 cd2cfcc5494..ad0c6190242 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -39,8 +39,8 @@ type environmentResource struct { 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"` + CreatedAt string `json:"createdAtUtc,omitempty"` + UpdatedAt string `json:"updatedAtUtc,omitempty"` VersionLabel string `json:"versionLabel,omitempty"` } @@ -57,8 +57,8 @@ type sandboxResource struct { Endpoint string `json:"endpoint,omitempty"` Status string `json:"status,omitempty"` Error string `json:"error,omitempty"` - CreatedAtUtc string `json:"createdAtUtc,omitempty"` - UpdatedAtUtc string `json:"updatedAtUtc,omitempty"` + CreatedAt string `json:"createdAtUtc,omitempty"` + UpdatedAt string `json:"updatedAtUtc,omitempty"` } type rleHTTPError struct { @@ -75,8 +75,8 @@ func serviceError(err error) error { Message: err.Error(), ServiceName: "rle-control-plane", Suggestion: fmt.Sprintf( - "Ensure the RLE control plane is running and reachable, e.g. %s.", - defaultControlPlaneEndpoint, + "Ensure the RLE control plane is running and reachable. Trying at %s; adjust if needed by setting RLE_ENDPOINT=.", + resolveControlPlaneEndpoint(), ), } } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go new file mode 100644 index 00000000000..4571327c6ac --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func TestServiceErrorSuggestionShowsCurrentEndpoint(t *testing.T) { + t.Setenv("RLE_ENDPOINT", "https://rle.example.test") + + err := serviceError(errors.New("dial tcp failed")) + serviceErr, ok := err.(*azdext.ServiceError) + if !ok { + t.Fatalf("expected ServiceError, got %T", err) + } + + for _, expected := range []string{ + "Ensure the RLE control plane is running and reachable.", + "Trying at https://rle.example.test;", + "RLE_ENDPOINT=", + } { + if !strings.Contains(serviceErr.Suggestion, expected) { + t.Fatalf("expected suggestion to contain %q, got %q", expected, serviceErr.Suggestion) + } + } +} 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 8aa20a22656..7efc3461e65 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -134,13 +134,13 @@ func newDeployCommand() *cobra.Command { 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, + EnvironmentId: environment.Id, + ProjectId: firstNonEmpty(environment.ProjectId, state.Project), + Name: firstNonEmpty(environment.Name, state.Name), + AcrImage: firstNonEmpty(environment.AcrImagePath, image), + Version: state.EnvironmentVersion, + CreatedAt: environment.CreatedAt, + UpdatedAt: environment.UpdatedAt, }, "", " ") if err != nil { return err @@ -186,15 +186,15 @@ func resolveDeployImage(flags *rleDeployFlags, state rleState) (string, error) { Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run deploy again.", } } - return fmt.Sprintf("%s/%s:latest", registry, slug(state.Name)), nil + return fmt.Sprintf("%s/%s-%s:latest", registry, slug(state.Project), slug(state.Name)), nil } 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"` + EnvironmentId string `json:"environmentId"` + ProjectId string `json:"projectId"` + Name string `json:"name"` + AcrImage string `json:"acrImage"` + Version string `json:"version"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } 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 d68794478d3..bc4d9777221 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -5,7 +5,7 @@ package cmd import ( "fmt" - "path/filepath" + "os" "strings" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -48,15 +48,12 @@ func newInitCommand() *cobra.Command { return err } - displayDir, err := filepath.Abs(sessionDir) - if err != nil { - return err - } + displayDir := "." + string(os.PathSeparator) + sessionDir _, err = fmt.Fprintf( cmd.OutOrStdout(), "Created OpenEnv-style environment at: %s\n"+ "Next steps:\n"+ - " cd %q\n"+ + " cd \"%s\"\n"+ " azd ai rle run\n"+ " $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = \".azurecr.io\"\n"+ " azd ai rle deploy --project-id \n", 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 8885d4a5c06..d8f6ea489c8 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -// cspell:ignore openenv - package cmd import ( @@ -127,6 +125,8 @@ func invokeRemoteEnvironment(cmd *cobra.Command, flags *openEnvInvokeFlags) erro const ( sandboxStatusRunning = "Running" sandboxStatusFailed = "Failed" + + remoteSandboxLeaseMaxRetries = 10 ) var ( @@ -183,26 +183,27 @@ func createSandboxWhenImageReady( if !pending { return nil, err } - if time.Now().After(deadline) { + if attempt >= remoteSandboxLeaseMaxRetries || time.Now().After(deadline) { return nil, &azdext.LocalError{ Message: fmt.Sprintf( - "Environment image conversion was not ready after %.0f seconds (last status: %s).", - remoteSandboxCreateTimeout.Seconds(), + "Sandbox was not ready for testing after %d retries (last status: %s).", + attempt, firstNonEmpty(status, "unknown"), ), - Code: "rle_disk_image_conversion_timeout", + Code: "rle_sandbox_lease_pending_timeout", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Wait for the RLE control plane image conversion to finish, then retry invoke.", + Suggestion: "Wait for the RLE control plane to finish preparing the sandbox, then retry invoke.", } } attempt++ if _, msgErr := fmt.Fprintf( output, - "Environment image conversion is %s; waiting %.0f seconds before retrying sandbox lease (attempt %d) ...\n", + "Getting sandbox ready for testing (status: %s); waiting %.0f seconds before retrying (attempt %d of %d) ...\n", firstNonEmpty(status, "not ready"), remoteImagePollInterval.Seconds(), attempt, + remoteSandboxLeaseMaxRetries, ); msgErr != nil { return nil, msgErr } @@ -406,10 +407,10 @@ const remotePlaygroundHTML = ` - - -
RLE Remote Consolelocal UI proxy
-
-
-

Action

- - - - - - - -

-

Schema

-
Not loaded.
-
-
-

Output

-
Ready.
-
-
- - -` - func requireDeployedEnvironment(state rleState) error { if strings.TrimSpace(state.ProjectEndpoint) == "" { return &azdext.LocalError{ @@ -512,646 +422,3 @@ func requireDeployedEnvironment(state rleState) error { } return nil } - -func runOpenEnvShellWithContext( - ctx context.Context, - input io.Reader, - output io.Writer, - baseUrl string, - timeout int, -) error { - done := make(chan error, 1) - go func() { - done <- runOpenEnvShell(input, output, baseUrl, timeout) - }() - select { - case err := <-done: - return err - case <-ctx.Done(): - fmt.Fprintln(output) - return nil - } -} - -func newRunCommand() *cobra.Command { - flags := &localRunFlags{ - reuseRunning: true, - } - - cmd := &cobra.Command{ - Use: "run", - Short: "Build and run the local RLE environment container", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - ctx, stopSignals := signal.NotifyContext(cmd.Context(), os.Interrupt) - defer stopSignals() - - baseUrl, err := ensureLocalContainerEndpoint(cmd, flags) - if err != nil { - return err - } - state, err := loadLocalRunState(flags, cmd.OutOrStdout()) - if err != nil { - return err - } - defer func() { - if err := stopLocalContainer(cmd, state.Name); err != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to stop local container: %v\n", err) - } - }() - - watchDone := make(chan error, 1) - if flags.watch { - watchCtx, cancelWatch := context.WithCancel(ctx) - defer cancelWatch() - watchCmd := *cmd - watchCmd.SetContext(watchCtx) - go func() { - watchDone <- watchLocalContainer(&watchCmd, flags) - }() - } - - webUrl := baseUrl + "/web" - _, err = fmt.Fprintf( - cmd.OutOrStdout(), - "Local RLE environment is running at %s\nPlayground UI: %s\n", - baseUrl, - webUrl, - ) - if err != nil { - return err - } - if err := openBrowserFunc(webUrl); err != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) - } - shellErr := runOpenEnvShellWithContext(ctx, cmd.InOrStdin(), cmd.OutOrStdout(), baseUrl, 0) - if flags.watch { - select { - case err := <-watchDone: - if err != nil && shellErr == nil { - return err - } - default: - } - } - return shellErr - }, - } - - cmd.Flags().IntVar( - &flags.port, - "port", - 0, - "Host port mapped to the local Docker container. Defaults to 8000.", - ) - cmd.Flags().StringVar( - &flags.dockerfile, - "dockerfile", - "", - "Dockerfile path relative to the current folder. Defaults to Dockerfile at the source root or server/Dockerfile.", - ) - cmd.Flags().BoolVar( - &flags.watch, - "watch", - false, - "Watch source files and rebuild/restart the local container when they change.", - ) - return cmd -} - -const defaultPort = 8000 - -func normalizeOpenEnvOperation(operation string) (string, error) { - operation = strings.Trim(strings.ToLower(operation), "/") - switch operation { - case "reset", "step", "state", "health", "metadata", "schema": - return operation, nil - default: - return "", &azdext.LocalError{ - Message: fmt.Sprintf("Unknown OpenEnv operation %q.", operation), - Code: "rle_unknown_open_env_operation", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Use one of: reset, step, state, health, metadata, schema.", - } - } -} - -func runOpenEnvShell(input io.Reader, output io.Writer, baseUrl string, timeout int) error { - fmt.Fprintln(output, "OpenEnv shell. Type help for commands, exit to quit.") - scanner := bufio.NewScanner(input) - for { - fmt.Fprint(output, "rle> ") - if !scanner.Scan() { - fmt.Fprintln(output) - return scanner.Err() - } - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - if strings.EqualFold(line, "exit") || strings.EqualFold(line, "quit") { - return nil - } - if strings.EqualFold(line, "help") { - printOpenEnvShellHelp(output) - continue - } - - operation, payload, _ := strings.Cut(line, " ") - operation, err := normalizeOpenEnvOperation(operation) - if err != nil { - fmt.Fprintf(output, "error: %v\n", err) - continue - } - payload = strings.TrimSpace(payload) - flags := &openEnvCallFlags{timeout: timeout} - switch operation { - case "reset": - flags.body = payload - case "step": - if payload == "" { - fmt.Fprintln(output, "error: step requires a JSON action payload, for example: step {\"message\":\"hello\"}") - continue - } - flags.action = payload - default: - if payload != "" { - fmt.Fprintf(output, "error: %s does not accept a JSON payload\n", operation) - continue - } - } - - response, err := callOpenEnv(baseUrl, operation, flags) - if err != nil { - fmt.Fprintf(output, "error: %v\n", err) - continue - } - fmt.Fprintln(output, response) - } -} - -func printOpenEnvShellHelp(output io.Writer) { - fmt.Fprintln(output, "Commands:") - fmt.Fprintln(output, " health") - fmt.Fprintln(output, " reset [json]") - fmt.Fprintln(output, " step [json-action]") - fmt.Fprintln(output, " state") - fmt.Fprintln(output, " metadata") - fmt.Fprintln(output, " schema") - fmt.Fprintln(output, " exit") -} - -func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *localRunFlags) (string, error) { - state, err := loadLocalRunState(flags, cmd.OutOrStdout()) - if err != nil { - return "", err - } - port := resolvePort(flags) - if port <= 0 { - return "", &azdext.LocalError{ - Message: "--port must be greater than 0.", - Code: "rle_invalid_local_port", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Pass a valid host port, for example --port 8000.", - } - } - if _, err := exec.LookPath("docker"); err != nil { - return "", &azdext.LocalError{ - Message: "Could not find \"docker\" on PATH.", - Code: "rle_docker_not_found", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Install/start Docker Desktop, then retry the command.", - } - } - - image := localRuntimeImageForRun(flags, state) - container := localContainerName(state.Name) - baseUrl := fmt.Sprintf("http://localhost:%d", port) - - if running, exists := localContainerStatus(cmd, container); exists { - if running && flags.reuseRunning && !flags.restart { - if err := waitForOpenEnvHealth(baseUrl, 30*time.Second); err != nil { - return "", err - } - return baseUrl, nil - } - _ = runDocker(cmd, "rm", "-f", container) - } - if err := ensurePortAvailable(port); err != nil { - return "", err - } - if err := buildLocalRuntimeImage(cmd, image, dockerBuildOptions{ - source: flags.source, - dockerfile: flags.dockerfile, - }); err != nil { - return "", err - } - if _, err := fmt.Fprintf( - cmd.ErrOrStderr(), - "Starting local container %s on port %d ...\n", - container, - port, - ); err != nil { - return "", err - } - portMapping := fmt.Sprintf("%d:8000", port) - runArgs := []string{ - "run", "-d", - "--name", container, - "--label", localContainerImageLabel + "=" + image, - "-e", "ENABLE_WEB_INTERFACE=true", - "-p", portMapping, - image, - } - if err := runDocker(cmd, runArgs...); err != nil { - return "", &azdext.LocalError{ - Message: fmt.Sprintf("Failed to start local Docker container %q: %v", container, err), - Code: "rle_local_docker_run_failed", - Category: azdext.LocalErrorCategoryUser, - Suggestion: localPortSuggestion(port), - } - } - started := true - cleanupStartedContainer := func() { - if started { - _ = runDocker(cmd, "rm", "-f", container) - } - } - - if err := waitForOpenEnvHealth(baseUrl, 30*time.Second); err != nil { - cleanupStartedContainer() - return "", err - } - started = false - return baseUrl, nil -} - -func openBrowser(url string) error { - var command string - var args []string - switch runtime.GOOS { - case "windows": - command = "run" + "dll32" - args = []string{"url.dll,FileProtocolHandler", url} - case "darwin": - command = "open" - args = []string{url} - default: - command = "xdg-open" - args = []string{url} - } - return exec.Command(command, args...).Start() -} - -var openBrowserFunc = openBrowser - -func ensurePortAvailable(port int) error { - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - return &azdext.LocalError{ - Message: fmt.Sprintf("Port %d is already in use.", port), - Code: "rle_local_port_in_use", - Category: azdext.LocalErrorCategoryUser, - Suggestion: localPortSuggestion(port), - } - } - return listener.Close() -} - -func localPortSuggestion(port int) string { - return fmt.Sprintf( - "Check containers using this port: docker ps --filter \"publish=%d\" --format \"table {{.Names}}\\t{{.Ports}}\"\n"+ - "Stop the container: docker rm -f \n"+ - "Then rerun: azd ai rle run --port %d\n"+ - "If Docker does not show a container, check the process with: netstat -ano | findstr :%d", - port, - port, - port, - ) -} - -func stopLocalContainer(cmd *cobra.Command, environmentName string) error { - container := localContainerName(environmentName) - return runDocker(cmd, "rm", "-f", container) -} - -func loadLocalRunState(flags *localRunFlags, output io.Writer) (rleState, error) { - state, err := loadRleState() - if err != nil { - if localErr, ok := errors.AsType[*azdext.LocalError](err); !ok || - localErr.Code != "rle_project_not_initialized" { - return rleState{}, err - } - state = defaultRleState(defaultSourceName(flags.source)) - if _, err := fmt.Fprintf(output, "No %s found; using current folder as the RLE source.\n", rleStateFile); err != nil { - return rleState{}, err - } - if err := saveRleState(state); err != nil { - return rleState{}, err - } - if _, err := fmt.Fprintf(output, "Created %s with name %q.\n", rleStateFile, state.Name); err != nil { - return rleState{}, err - } - } - - state.Name = firstNonEmpty(state.Name, defaultSourceName(flags.source)) - return state, nil -} - -func localRuntimeImageForRun(flags *localRunFlags, state rleState) string { - return slug(firstNonEmpty(state.Name, defaultSourceName(flags.source))) + ":local" -} - -func defaultSourceName(source string) string { - source = strings.TrimSpace(source) - if source == "" { - source = "." - } - abs, err := filepath.Abs(source) - if err != nil { - return "rle_env" - } - name := filepath.Base(abs) - if name == "." || name == string(filepath.Separator) || name == "" { - return "rle_env" - } - return slug(name) -} - -func buildLocalRuntimeImage(cmd *cobra.Command, image string, opts dockerBuildOptions) error { - source, dockerfile, cleanup, err := prepareDockerBuild(opts) - if err != nil { - return err - } - if cleanup != nil { - defer cleanup() - } - if _, err := fmt.Fprintf( - cmd.ErrOrStderr(), - "Building local runtime image %s from %s ...\n", - image, - dockerfile, - ); err != nil { - return err - } - if err := runDocker(cmd, "build", "-t", image, "-f", dockerfile, source); err != nil { - return &azdext.LocalError{ - Message: fmt.Sprintf("Failed to build Docker image %q: %v", image, err), - Code: "rle_local_docker_build_failed", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Fix the Dockerfile or generated environment code, then retry.", - } - } - return nil -} - -func watchLocalContainer(cmd *cobra.Command, flags *localRunFlags) error { - last, err := sourceSnapshot(flags.source) - if err != nil { - return err - } - if _, err := fmt.Fprintln(cmd.OutOrStdout(), "Watching for source changes. Press Ctrl+C to stop."); err != nil { - return err - } - - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - select { - case <-cmd.Context().Done(): - return nil - case <-ticker.C: - current, err := sourceSnapshot(flags.source) - if err != nil { - return err - } - if current == last { - continue - } - last = current - if _, err := fmt.Fprintln( - cmd.OutOrStdout(), - "Source change detected; rebuilding local container ...", - ); err != nil { - return err - } - restartFlags := *flags - restartFlags.restart = true - baseUrl, err := ensureLocalContainerEndpoint(cmd, &restartFlags) - if err != nil { - return err - } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Local RLE environment restarted at %s\n", baseUrl); err != nil { - return err - } - } - } -} - -func sourceSnapshot(source string) (string, error) { - source = strings.TrimSpace(source) - if source == "" { - source = "." - } - source, err := filepath.Abs(source) - if err != nil { - return "", err - } - var latest int64 - var count int - err = filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() && shouldSkipWatchDir(entry.Name()) { - return filepath.SkipDir - } - if entry.IsDir() { - return nil - } - info, err := entry.Info() - if err != nil { - return err - } - count++ - if modified := info.ModTime().UnixNano(); modified > latest { - latest = modified - } - return nil - }) - if err != nil { - return "", err - } - return fmt.Sprintf("%d:%d", latest, count), nil -} - -func shouldSkipWatchDir(name string) bool { - switch name { - case ".azd", ".git", ".venv", "__pycache__", "node_modules", "venv": - return true - default: - return false - } -} - -func resolvePort(flags *localRunFlags) int { - if flags.port > 0 { - return flags.port - } - return defaultPort -} - -func localContainerStatus(cmd *cobra.Command, container string) (running bool, exists bool) { - //nolint:gosec // Fixed docker inspect command; container is a generated local name. - process := exec.CommandContext(cmd.Context(), "docker", "inspect", "-f", "{{.State.Running}}", container) - process.Env = os.Environ() - output, err := process.Output() - if err != nil { - return false, false - } - return strings.TrimSpace(string(output)) == "true", true -} - -const localContainerImageLabel = "azd.ai.rle.local-image" - -func runDocker(cmd *cobra.Command, args ...string) error { - //nolint:gosec // Fixed docker command shapes with selected names/tags. - process := exec.CommandContext(cmd.Context(), "docker", args...) - process.Stdout = cmd.OutOrStdout() - process.Stderr = cmd.ErrOrStderr() - process.Env = os.Environ() - return process.Run() -} - -func localContainerName(envName string) string { - return "azd-rle-" + slug(firstNonEmpty(envName, "environment")) -} - -func waitForOpenEnvHealth(baseUrl string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - var lastErr error - client := &http.Client{Timeout: 2 * time.Second} - for time.Now().Before(deadline) { - resp, err := client.Get(strings.TrimRight(baseUrl, "/") + "/health") //nolint:gosec // Local user-selected endpoint. - if err == nil { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return nil - } - lastErr = fmt.Errorf("health returned HTTP %d", resp.StatusCode) - } else { - lastErr = err - } - time.Sleep(time.Second) - } - return &azdext.LocalError{ - Message: fmt.Sprintf("OpenEnv endpoint did not become healthy at %s: %v", baseUrl, lastErr), - Code: "rle_local_container_not_ready", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Check the local container logs or remote sandbox status, then retry.", - } -} - -func callOpenEnv(baseUrl string, operation string, flags *openEnvCallFlags) (string, error) { - method := http.MethodGet - var body io.Reader - if operation == "reset" || operation == "step" { - method = http.MethodPost - requestBody, err := openEnvRequestBody(operation, flags) - if err != nil { - return "", err - } - body = bytes.NewReader(requestBody) - } - - url := strings.TrimRight(baseUrl, "/") + "/" + operation - req, err := http.NewRequest(method, url, body) - if err != nil { - return "", err - } - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - client := openEnvHTTPClient(flags.timeout) - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("call OpenEnv %s %s: %w", method, url, err) - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", &azdext.LocalError{ - Message: fmt.Sprintf("OpenEnv endpoint returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))), - Code: "rle_open_env_call_failed", - Category: azdext.LocalErrorCategoryUser, - } - } - - return prettyJson(data), nil -} - -func openEnvHTTPClient(timeoutSeconds int) *http.Client { - if timeoutSeconds <= 0 { - return &http.Client{} - } - return &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second} -} - -func openEnvRequestBody(operation string, flags *openEnvCallFlags) ([]byte, error) { - if strings.TrimSpace(flags.body) != "" { - return validateJsonObject(flags.body, "body") - } - if operation == "reset" { - return []byte("{}"), nil - } - - action, err := validateJsonObject(flags.action, "action") - if err != nil { - return nil, err - } - var actionValue any - if err := json.Unmarshal(action, &actionValue); err != nil { - return nil, err - } - return json.Marshal(map[string]any{"action": actionValue}) -} - -func validateJsonObject(value string, flagName string) ([]byte, error) { - var decoded map[string]any - data := []byte(value) - if err := json.Unmarshal(data, &decoded); err != nil { - return nil, &azdext.LocalError{ - Message: fmt.Sprintf("--%s must be valid JSON object: %v", flagName, err), - Code: "rle_invalid_json", - Category: azdext.LocalErrorCategoryUser, - Suggestion: jsonFlagSuggestion(flagName), - } - } - return data, nil -} - -func jsonFlagSuggestion(flagName string) string { - if flagName == "body" { - return "Use reset {\"seed\":0} or another JSON object." - } - return "Use step {\"message\":\"hello\"} or another JSON object." -} - -func prettyJson(data []byte) string { - var decoded any - if err := json.Unmarshal(data, &decoded); err != nil { - return string(data) - } - formatted, err := json.MarshalIndent(decoded, "", " ") - if err != nil { - return string(data) - } - return string(formatted) -} 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 index db663c44be9..1090e7291a9 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -19,109 +19,11 @@ import ( "testing" "time" + rleui "azure.ai.rle/internal/ui" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -func TestOpenEnvRequestBodyWrapsAction(t *testing.T) { - body, err := openEnvRequestBody("step", &openEnvCallFlags{action: `{"message":"hello"}`}) - if err != nil { - t.Fatal(err) - } - if string(body) != `{"action":{"message":"hello"}}` { - t.Fatalf("unexpected body: %s", body) - } -} - -func TestOpenEnvRequestBodyUsesRawBody(t *testing.T) { - body, err := openEnvRequestBody("step", &openEnvCallFlags{ - action: `{"message":"hello"}`, - body: `{"action":{"message":"override"},"metadata":{"x":1}}`, - }) - if err != nil { - t.Fatal(err) - } - expected := `{"action":{"message":"override"},"metadata":{"x":1}}` - if string(body) != expected { - t.Fatalf("expected %s, got %s", expected, body) - } -} - -func TestNormalizeOpenEnvOperation(t *testing.T) { - operation, err := normalizeOpenEnvOperation("/STEP") - if err != nil { - t.Fatal(err) - } - if operation != "step" { - t.Fatalf("expected step, got %q", operation) - } - if _, err := normalizeOpenEnvOperation("unknown"); err == nil { - t.Fatal("expected unknown operation to fail") - } -} - -func TestRunOpenEnvShellCallsCommands(t *testing.T) { - var stepBody map[string]any - var resetBody map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/health": - _, _ = w.Write([]byte(`{"ok":true}`)) - case "/reset": - if err := json.NewDecoder(r.Body).Decode(&resetBody); err != nil { - t.Errorf("decode reset body: %v", err) - } - _, _ = w.Write([]byte(`{"reset":true}`)) - case "/step": - if err := json.NewDecoder(r.Body).Decode(&stepBody); err != nil { - t.Errorf("decode step body: %v", err) - } - _, _ = w.Write([]byte(`{"reward":1}`)) - case "/state": - _, _ = w.Write([]byte(`{"state":"ready"}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - input := strings.NewReader("health\nreset {\"seed\":0}\nstep {\"message\":\"hello\"}\nstate\nexit\n") - var output bytes.Buffer - if err := runOpenEnvShell(input, &output, server.URL, 30); err != nil { - t.Fatal(err) - } - - if resetBody["seed"] != float64(0) { - t.Fatalf("expected reset seed body, got %#v", resetBody) - } - action, ok := stepBody["action"].(map[string]any) - if !ok || action["message"] != "hello" { - t.Fatalf("expected wrapped step action, got %#v", stepBody) - } - if !strings.Contains(output.String(), `"reward": 1`) { - t.Fatalf("expected pretty step response in output, got %s", output.String()) - } -} - -func TestRunOpenEnvShellRequiresStepPayload(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/step" { - t.Fatal("step without payload should not call the runtime") - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - input := strings.NewReader("step\nexit\n") - var output bytes.Buffer - if err := runOpenEnvShell(input, &output, server.URL, 30); err != nil { - t.Fatal(err) - } - if !strings.Contains(output.String(), "step requires a JSON action payload") { - t.Fatalf("expected step payload error, got %s", output.String()) - } -} - func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { captureBrowserOpen(t) tempDir := t.TempDir() @@ -414,7 +316,7 @@ func TestLocalContainerNamesUseEnvironmentName(t *testing.T) { } func TestEnsurePortAvailableRejectsBoundPort(t *testing.T) { - listener, err := net.Listen("tcp", ":0") + listener, err := net.Listen("tcp", ":0") //nolint:gosec // test intentionally binds an ephemeral port on all interfaces to verify conflict detection. if err != nil { t.Fatal(err) } @@ -452,7 +354,7 @@ func TestResolvePortDefaultsTo8000WithoutPersistedState(t *testing.T) { func TestLoadLocalRunStateDefaultsToExistingFolderWithoutInit(t *testing.T) { tempDir := filepath.Join(t.TempDir(), "My Env") - if err := os.MkdirAll(tempDir, 0755); err != nil { + if err := os.MkdirAll(tempDir, 0750); err != nil { t.Fatal(err) } t.Chdir(tempDir) @@ -617,36 +519,23 @@ func TestSandboxLeasePendingStatusTreatsAnyConflictAsPending(t *testing.T) { } } -func TestRunOpenEnvShellWithContextReturnsOnCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - reader, writer := io.Pipe() - defer reader.Close() - defer writer.Close() - cancel() - - var output bytes.Buffer - if err := runOpenEnvShellWithContext(ctx, reader, &output, "http://127.0.0.1", 30); err != nil { - t.Fatal(err) - } -} - func captureBrowserOpen(t *testing.T) *string { t.Helper() - old := openBrowserFunc + old := rleui.OpenBrowser openedUrl := "" - openBrowserFunc = func(url string) error { + rleui.OpenBrowser = func(url string) error { openedUrl = url return nil } t.Cleanup(func() { - openBrowserFunc = old + rleui.OpenBrowser = old }) return &openedUrl } func TestResolveDeployStateDefaultsToExistingFolderWithoutInit(t *testing.T) { tempDir := filepath.Join(t.TempDir(), "My Env") - if err := os.MkdirAll(tempDir, 0755); err != nil { + if err := os.MkdirAll(tempDir, 0750); err != nil { t.Fatal(err) } t.Chdir(tempDir) @@ -734,90 +623,9 @@ func TestResolveDeployImageUsesRegistryEvenWhenStateExists(t *testing.T) { } } -func TestResolveDockerBuildFindsRootDockerfile(t *testing.T) { - tempDir := t.TempDir() - if err := os.WriteFile(filepath.Join(tempDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { - t.Fatal(err) - } - - source, dockerfile, cleanup, err := prepareDockerBuild(dockerBuildOptions{source: tempDir}) - if err != nil { - t.Fatal(err) - } - if cleanup != nil { - t.Fatal("expected no cleanup for existing source") - } - if source != tempDir { - t.Fatalf("expected source %q, got %q", tempDir, source) - } - if dockerfile != filepath.Join(tempDir, "Dockerfile") { - t.Fatalf("expected root Dockerfile, got %q", dockerfile) - } -} - -func TestResolveDockerBuildFindsServerDockerfile(t *testing.T) { - tempDir := t.TempDir() - serverDir := filepath.Join(tempDir, "server") - if err := os.MkdirAll(serverDir, 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(serverDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { - t.Fatal(err) - } - - source, dockerfile, cleanup, err := prepareDockerBuild(dockerBuildOptions{source: tempDir}) - if err != nil { - t.Fatal(err) - } - if cleanup != nil { - t.Fatal("expected no cleanup for existing source") - } - if source != tempDir { - t.Fatalf("expected source %q, got %q", tempDir, source) - } - if dockerfile != filepath.Join(serverDir, "Dockerfile") { - t.Fatalf("expected server Dockerfile, got %q", dockerfile) - } -} - -func TestResolveDockerBuildUsesDockerfileOption(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - dockerDir := filepath.Join(tempDir, "docker") - if err := os.MkdirAll(dockerDir, 0755); err != nil { - t.Fatal(err) - } - customPath := filepath.Join(dockerDir, "custom.Dockerfile") - if err := os.WriteFile(customPath, []byte("FROM scratch\n"), 0600); err != nil { - t.Fatal(err) - } - _, dockerfile, cleanup, err := prepareDockerBuild(dockerBuildOptions{ - source: tempDir, - dockerfile: "docker/custom.Dockerfile", - }) - if err != nil { - t.Fatal(err) - } - if cleanup != nil { - t.Fatal("expected no cleanup for existing source") - } - if dockerfile != customPath { - t.Fatalf("expected explicit Dockerfile, got %q", dockerfile) - } -} - -func TestIsAcrImageReference(t *testing.T) { - if !isAcrImageReference("myregistry.azurecr.io/echo_env:latest") { - t.Fatal("expected ACR image reference") - } - if isAcrImageReference("echo_env:latest") { - t.Fatal("did not expect local image tag to be treated as ACR") - } -} - func TestLocalRuntimeImageForRunDefaultsToSourceFolder(t *testing.T) { tempDir := filepath.Join(t.TempDir(), "My Env") - if err := os.MkdirAll(tempDir, 0755); err != nil { + if err := os.MkdirAll(tempDir, 0750); err != nil { t.Fatal(err) } @@ -829,22 +637,3 @@ func TestLocalRuntimeImageForRunDefaultsToSourceFolder(t *testing.T) { t.Fatalf("expected source folder image, got %q", image) } } - -func TestResolveDockerBuildRejectsDockerfileEscapes(t *testing.T) { - tempDir := t.TempDir() - outsideDir := t.TempDir() - if err := os.WriteFile(filepath.Join(outsideDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { - t.Fatal(err) - } - for _, dockerfile := range []string{ - filepath.Join("..", filepath.Base(outsideDir), "Dockerfile"), - filepath.Join(outsideDir, "Dockerfile"), - } { - if _, _, _, err := prepareDockerBuild(dockerBuildOptions{ - source: tempDir, - dockerfile: dockerfile, - }); err == nil { - t.Fatalf("expected Dockerfile path %q to be rejected", dockerfile) - } - } -} 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 513e57987ed..3a35a7f72d4 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 @@ -195,7 +195,7 @@ func TestInitCopiesOpenEnvEchoSampleByDefault(t *testing.T) { } sessionDir := filepath.Join(tempDir, "echo_env") - stateBytes, err := os.ReadFile(filepath.Join(sessionDir, rleStateFile)) + stateBytes, err := os.ReadFile(filepath.Join(sessionDir, rleStateFile)) //nolint:gosec // test reads the state file from its own temporary session directory. if err != nil { t.Fatal(err) } @@ -236,7 +236,7 @@ func TestInitUsesPositionalNameForDefaultSample(t *testing.T) { } sessionDir := filepath.Join(tempDir, "code_rl") - stateBytes, err := os.ReadFile(filepath.Join(sessionDir, rleStateFile)) + stateBytes, err := os.ReadFile(filepath.Join(sessionDir, rleStateFile)) //nolint:gosec // test reads the state file from its own temporary session directory. if err != nil { t.Fatal(err) } @@ -253,12 +253,17 @@ func stubOpenEnvEchoCheckout(t *testing.T) { t.Helper() old := checkoutOpenEnvEchoSampleFunc checkoutOpenEnvEchoSampleFunc = func(name string, dest string, force bool) (string, error) { - sessionDir, err := createRleSessionDir(name, dest, force) - if err != nil { + sessionDir := filepath.Join(dest, name) + if force { + if err := os.RemoveAll(sessionDir); err != nil { + return "", err + } + } + if err := os.MkdirAll(sessionDir, 0750); err != nil { return "", err } serverDir := filepath.Join(sessionDir, "server") - if err := os.MkdirAll(serverDir, 0755); err != nil { + if err := os.MkdirAll(serverDir, 0750); err != nil { return "", err } if err := os.WriteFile(filepath.Join(serverDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go new file mode 100644 index 00000000000..2a77dfa0bc2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go @@ -0,0 +1,391 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "time" + + rleproject "azure.ai.rle/internal/project" + rleui "azure.ai.rle/internal/ui" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type localRunFlags struct { + port int + source string + dockerfile string + watch bool + restart bool + reuseRunning bool +} + +type localRunAction struct { + cmd *cobra.Command + flags *localRunFlags +} + +func newRunCommand() *cobra.Command { + flags := &localRunFlags{ + reuseRunning: true, + } + + cmd := &cobra.Command{ + Use: "run", + Short: "Build and run the local RLE environment container", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return (&localRunAction{cmd: cmd, flags: flags}).Run() + }, + } + + cmd.Flags().IntVar( + &flags.port, + "port", + 0, + "Host port mapped to the local Docker container. Defaults to 8000.", + ) + cmd.Flags().StringVar( + &flags.dockerfile, + "dockerfile", + "", + "Dockerfile path relative to the current folder. Defaults to Dockerfile at the source root or server/Dockerfile.", + ) + cmd.Flags().BoolVar( + &flags.watch, + "watch", + false, + "Watch source files and rebuild/restart the local container when they change.", + ) + return cmd +} + +func (a *localRunAction) Run() error { + ctx, stopSignals := signal.NotifyContext(a.cmd.Context(), os.Interrupt) + defer stopSignals() + + baseUrl, err := ensureLocalContainerEndpoint(a.cmd, a.flags) + if err != nil { + return err + } + state, err := loadLocalRunState(a.flags, a.cmd.OutOrStdout()) + if err != nil { + return err + } + defer func() { + if err := stopLocalContainer(a.cmd, state.Name); err != nil { + _, _ = fmt.Fprintf(a.cmd.ErrOrStderr(), "Warning: failed to stop local container: %v\n", err) + } + }() + + watchDone := make(chan error, 1) + if a.flags.watch { + watchCtx, cancelWatch := context.WithCancel(ctx) + defer cancelWatch() + watchCmd := *a.cmd + watchCmd.SetContext(watchCtx) + go func() { + watchDone <- watchLocalContainer(&watchCmd, a.flags) + }() + } + + webUrl := baseUrl + "/web" + _, err = fmt.Fprintf( + a.cmd.OutOrStdout(), + "Local RLE environment is running at %s\nPlayground UI: %s\n", + baseUrl, + webUrl, + ) + if err != nil { + return err + } + if err := rleui.OpenBrowser(webUrl); err != nil { + _, _ = fmt.Fprintf(a.cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) + } + shellErr := rleproject.RunShellWithContext(ctx, a.cmd.InOrStdin(), a.cmd.OutOrStdout(), baseUrl, 0) + if a.flags.watch { + select { + case err := <-watchDone: + if err != nil && shellErr == nil { + return err + } + default: + } + } + return shellErr +} + +const ( + defaultPort = 8000 + localContainerImageLabel = "azd.ai.rle.local-image" +) + +func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *localRunFlags) (string, error) { + state, err := loadLocalRunState(flags, cmd.OutOrStdout()) + if err != nil { + return "", err + } + port := resolvePort(flags) + if port <= 0 { + return "", &azdext.LocalError{ + Message: "--port must be greater than 0.", + Code: "rle_invalid_local_port", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Pass a valid host port, for example --port 8000.", + } + } + if _, err := exec.LookPath("docker"); err != nil { + return "", &azdext.LocalError{ + Message: "Could not find \"docker\" on PATH.", + Code: "rle_docker_not_found", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Install/start Docker Desktop, then retry the command.", + } + } + + image := localRuntimeImageForRun(flags, state) + container := localContainerName(state.Name) + baseUrl := fmt.Sprintf("http://localhost:%d", port) + + if running, exists := rleproject.ContainerStatus(cmd.Context(), container); exists { + if running && flags.reuseRunning && !flags.restart { + if err := rleproject.WaitForHealth(baseUrl, 30*time.Second); err != nil { + return "", err + } + return baseUrl, nil + } + _ = rleproject.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) + } + if err := ensurePortAvailable(port); err != nil { + return "", err + } + if err := rleproject.BuildRuntimeImage(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), image, rleproject.BuildOptions{ + Source: flags.source, + Dockerfile: flags.dockerfile, + }); err != nil { + return "", err + } + if _, err := fmt.Fprintf( + cmd.ErrOrStderr(), + "Starting local container %s on port %d ...\n", + container, + port, + ); err != nil { + return "", err + } + portMapping := fmt.Sprintf("%d:8000", port) + runArgs := []string{ + "run", "-d", + "--name", container, + "--label", localContainerImageLabel + "=" + image, + "-e", "ENABLE_WEB_INTERFACE=true", + "-p", portMapping, + image, + } + if err := rleproject.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), runArgs...); err != nil { + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Failed to start local Docker container %q: %v", container, err), + Code: "rle_local_docker_run_failed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: localPortSuggestion(port), + } + } + started := true + cleanupStartedContainer := func() { + if started { + _ = rleproject.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) + } + } + + if err := rleproject.WaitForHealth(baseUrl, 30*time.Second); err != nil { + cleanupStartedContainer() + return "", err + } + started = false + return baseUrl, nil +} + +func ensurePortAvailable(port int) error { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf("Port %d is already in use.", port), + Code: "rle_local_port_in_use", + Category: azdext.LocalErrorCategoryUser, + Suggestion: localPortSuggestion(port), + } + } + return listener.Close() +} + +func localPortSuggestion(port int) string { + return fmt.Sprintf( + "Check containers using this port: docker ps --filter \"publish=%d\" --format \"table {{.Names}}\\t{{.Ports}}\"\n"+ + "Stop the container: docker rm -f \n"+ + "Then rerun: azd ai rle run --port %d\n"+ + "If Docker does not show a container, check the process with: netstat -ano | findstr :%d", + port, + port, + port, + ) +} + +func stopLocalContainer(cmd *cobra.Command, environmentName string) error { + container := localContainerName(environmentName) + return rleproject.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) +} + +func loadLocalRunState(flags *localRunFlags, output io.Writer) (rleState, error) { + state, err := loadRleState() + if err != nil { + if localErr, ok := errors.AsType[*azdext.LocalError](err); !ok || + localErr.Code != "rle_project_not_initialized" { + return rleState{}, err + } + state = defaultRleState(defaultSourceName(flags.source)) + if _, err := fmt.Fprintf(output, "No %s found; using current folder as the RLE source.\n", rleStateFile); err != nil { + return rleState{}, err + } + if err := saveRleState(state); err != nil { + return rleState{}, err + } + if _, err := fmt.Fprintf(output, "Created %s with name %q.\n", rleStateFile, state.Name); err != nil { + return rleState{}, err + } + } + + state.Name = firstNonEmpty(state.Name, defaultSourceName(flags.source)) + return state, nil +} + +func localRuntimeImageForRun(flags *localRunFlags, state rleState) string { + return rleproject.Slug(firstNonEmpty(state.Name, defaultSourceName(flags.source))) + ":local" +} + +func defaultSourceName(source string) string { + source = strings.TrimSpace(source) + if source == "" { + source = "." + } + abs, err := filepath.Abs(source) + if err != nil { + return "rle_env" + } + name := filepath.Base(abs) + if name == "." || name == string(filepath.Separator) || name == "" { + return "rle_env" + } + return rleproject.Slug(name) +} + +func watchLocalContainer(cmd *cobra.Command, flags *localRunFlags) error { + last, err := sourceSnapshot(flags.source) + if err != nil { + return err + } + if _, err := fmt.Fprintln(cmd.OutOrStdout(), "Watching for source changes. Press Ctrl+C to stop."); err != nil { + return err + } + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-cmd.Context().Done(): + return nil + case <-ticker.C: + current, err := sourceSnapshot(flags.source) + if err != nil { + return err + } + if current == last { + continue + } + last = current + if _, err := fmt.Fprintln( + cmd.OutOrStdout(), + "Source change detected; rebuilding local container ...", + ); err != nil { + return err + } + restartFlags := *flags + restartFlags.restart = true + baseUrl, err := ensureLocalContainerEndpoint(cmd, &restartFlags) + if err != nil { + return err + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Local RLE environment restarted at %s\n", baseUrl); err != nil { + return err + } + } + } +} + +func sourceSnapshot(source string) (string, error) { + source = strings.TrimSpace(source) + if source == "" { + source = "." + } + source, err := filepath.Abs(source) + if err != nil { + return "", err + } + var latest int64 + var count int + err = filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && shouldSkipWatchDir(entry.Name()) { + return filepath.SkipDir + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + count++ + if modified := info.ModTime().UnixNano(); modified > latest { + latest = modified + } + return nil + }) + if err != nil { + return "", err + } + return fmt.Sprintf("%d:%d", latest, count), nil +} + +func shouldSkipWatchDir(name string) bool { + switch name { + case ".azd", ".git", ".venv", "__pycache__", "node_modules", "venv": + return true + default: + return false + } +} + +func resolvePort(flags *localRunFlags) int { + if flags.port > 0 { + return flags.port + } + return defaultPort +} + +func localContainerName(envName string) string { + return "azd-rle-" + rleproject.Slug(firstNonEmpty(envName, "environment")) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/build.go b/cli/azd/extensions/azure.ai.rle/internal/project/build.go similarity index 75% rename from cli/azd/extensions/azure.ai.rle/internal/cmd/build.go rename to cli/azd/extensions/azure.ai.rle/internal/project/build.go index a869c791d54..6104276c212 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/build.go +++ b/cli/azd/extensions/azure.ai.rle/internal/project/build.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package cmd +package project import ( "fmt" @@ -10,16 +10,15 @@ import ( "strings" "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" ) -type dockerBuildOptions struct { - source string - dockerfile string +type BuildOptions struct { + Source string + Dockerfile string } -func prepareDockerBuild(opts dockerBuildOptions) (source string, dockerfile string, cleanup func(), err error) { - source = strings.TrimSpace(opts.source) +func PrepareDockerBuild(opts BuildOptions) (source string, dockerfile string, cleanup func(), err error) { + source = strings.TrimSpace(opts.Source) if source == "" { source = "." } @@ -28,7 +27,7 @@ func prepareDockerBuild(opts dockerBuildOptions) (source string, dockerfile stri return "", "", nil, err } - dockerfile = strings.TrimSpace(opts.dockerfile) + dockerfile = strings.TrimSpace(opts.Dockerfile) if dockerfile != "" { if !isSafeRelativePath(filepath.Clean(filepath.FromSlash(dockerfile))) { return "", "", cleanup, &azdext.LocalError{ @@ -76,6 +75,11 @@ func prepareDockerBuild(opts dockerBuildOptions) (source string, dockerfile stri return "", "", cleanup, missingDockerfileError(source) } +func IsAcrImageReference(image string) bool { + firstSegment, _, _ := strings.Cut(strings.TrimSpace(image), "/") + return strings.HasSuffix(strings.ToLower(firstSegment), ".azurecr.io") +} + func isPathWithinRoot(root string, path string) bool { relativePath, err := filepath.Rel(root, path) if err != nil { @@ -84,6 +88,12 @@ func isPathWithinRoot(root string, path string) bool { return isSafeRelativePath(filepath.Clean(relativePath)) } +func isSafeRelativePath(path string) bool { + return path != ".." && + !filepath.IsAbs(path) && + !strings.HasPrefix(path, ".."+string(os.PathSeparator)) +} + func fileExists(path string) (bool, error) { info, err := os.Stat(path) if os.IsNotExist(err) { @@ -103,23 +113,3 @@ func missingDockerfileError(source string) error { Suggestion: "Add Dockerfile at the source root or server/Dockerfile, or pass --dockerfile .", } } - -func pushDockerImage(cmd *cobra.Command, image string) error { - if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Pushing image %s ...\n", image); err != nil { - return err - } - if err := runDocker(cmd, "push", image); err != nil { - return &azdext.LocalError{ - Message: fmt.Sprintf("Failed to push Docker image %q: %v", image, err), - Code: "rle_docker_push_failed", - Category: azdext.LocalErrorCategoryUser, - Suggestion: "Run az acr login --name , then retry.", - } - } - return nil -} - -func isAcrImageReference(image string) bool { - firstSegment, _, _ := strings.Cut(strings.TrimSpace(image), "/") - return strings.HasSuffix(strings.ToLower(firstSegment), ".azurecr.io") -} diff --git a/cli/azd/extensions/azure.ai.rle/internal/project/build_test.go b/cli/azd/extensions/azure.ai.rle/internal/project/build_test.go new file mode 100644 index 00000000000..2dc37c61cbe --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/project/build_test.go @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPrepareDockerBuildFindsRootDockerfile(t *testing.T) { + tempDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tempDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + + source, dockerfile, cleanup, err := PrepareDockerBuild(BuildOptions{Source: tempDir}) + if err != nil { + t.Fatal(err) + } + if cleanup != nil { + t.Fatal("expected no cleanup for existing source") + } + if source != tempDir { + t.Fatalf("expected source %q, got %q", tempDir, source) + } + if dockerfile != filepath.Join(tempDir, "Dockerfile") { + t.Fatalf("expected root Dockerfile, got %q", dockerfile) + } +} + +func TestPrepareDockerBuildFindsServerDockerfile(t *testing.T) { + tempDir := t.TempDir() + serverDir := filepath.Join(tempDir, "server") + if err := os.MkdirAll(serverDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(serverDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + + source, dockerfile, cleanup, err := PrepareDockerBuild(BuildOptions{Source: tempDir}) + if err != nil { + t.Fatal(err) + } + if cleanup != nil { + t.Fatal("expected no cleanup for existing source") + } + if source != tempDir { + t.Fatalf("expected source %q, got %q", tempDir, source) + } + if dockerfile != filepath.Join(serverDir, "Dockerfile") { + t.Fatalf("expected server Dockerfile, got %q", dockerfile) + } +} + +func TestPrepareDockerBuildUsesDockerfileOption(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + dockerDir := filepath.Join(tempDir, "docker") + if err := os.MkdirAll(dockerDir, 0750); err != nil { + t.Fatal(err) + } + customPath := filepath.Join(dockerDir, "custom.Dockerfile") + if err := os.WriteFile(customPath, []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + _, dockerfile, cleanup, err := PrepareDockerBuild(BuildOptions{ + Source: tempDir, + Dockerfile: "docker/custom.Dockerfile", + }) + if err != nil { + t.Fatal(err) + } + if cleanup != nil { + t.Fatal("expected no cleanup for existing source") + } + if dockerfile != customPath { + t.Fatalf("expected explicit Dockerfile, got %q", dockerfile) + } +} + +func TestIsAcrImageReference(t *testing.T) { + if !IsAcrImageReference("myregistry.azurecr.io/echo_env:latest") { + t.Fatal("expected ACR image reference") + } + if IsAcrImageReference("echo_env:latest") { + t.Fatal("did not expect local image tag to be treated as ACR") + } +} + +func TestPrepareDockerBuildRejectsDockerfileEscapes(t *testing.T) { + tempDir := t.TempDir() + outsideDir := t.TempDir() + if err := os.WriteFile(filepath.Join(outsideDir, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + for _, dockerfile := range []string{ + filepath.Join("..", filepath.Base(outsideDir), "Dockerfile"), + filepath.Join(outsideDir, "Dockerfile"), + } { + if _, _, _, err := PrepareDockerBuild(BuildOptions{ + Source: tempDir, + Dockerfile: dockerfile, + }); err == nil { + t.Fatalf("expected Dockerfile path %q to be rejected", dockerfile) + } + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/project/docker.go b/cli/azd/extensions/azure.ai.rle/internal/project/docker.go new file mode 100644 index 00000000000..d43d3ca981d --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/project/docker.go @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func BuildRuntimeImage( + ctx context.Context, + stdout io.Writer, + stderr io.Writer, + image string, + opts BuildOptions, +) error { + source, dockerfile, cleanup, err := PrepareDockerBuild(opts) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + if _, err := fmt.Fprintf( + stderr, + "Building local runtime image %s from %s ...\n", + image, + dockerfile, + ); err != nil { + return err + } + if err := RunDocker(ctx, stdout, stderr, "build", "-t", image, "-f", dockerfile, source); err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf("Failed to build Docker image %q: %v", image, err), + Code: "rle_local_docker_build_failed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Fix the Dockerfile or generated environment code, then retry.", + } + } + return nil +} + +func PushImage(ctx context.Context, stdout io.Writer, stderr io.Writer, image string) error { + if _, err := fmt.Fprintf(stderr, "Pushing image %s ...\n", image); err != nil { + return err + } + if err := RunDocker(ctx, stdout, stderr, "push", image); err != nil { + return &azdext.LocalError{ + Message: fmt.Sprintf("Failed to push Docker image %q: %v", image, err), + Code: "rle_docker_push_failed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Run az acr login --name , then retry.", + } + } + return nil +} + +func ContainerStatus(ctx context.Context, container string) (running bool, exists bool) { + //nolint:gosec // Fixed docker inspect command; container is a generated local name. + process := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.State.Running}}", container) + process.Env = os.Environ() + output, err := process.Output() + if err != nil { + return false, false + } + return strings.TrimSpace(string(output)) == "true", true +} + +func RunDocker(ctx context.Context, stdout io.Writer, stderr io.Writer, args ...string) error { + //nolint:gosec // Fixed docker command shapes with selected names/tags. + process := exec.CommandContext(ctx, "docker", args...) + process.Stdout = stdout + process.Stderr = stderr + process.Env = os.Environ() + return process.Run() +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/project/identifiers.go b/cli/azd/extensions/azure.ai.rle/internal/project/identifiers.go new file mode 100644 index 00000000000..3fa7c261a3b --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/project/identifiers.go @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "regexp" + "strings" +) + +var environmentNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + +func ValidateEnvironmentName(name string) (string, error) { + name = strings.TrimSpace(name) + if !environmentNamePattern.MatchString(name) { + return "", fmt.Errorf("invalid environment name %q", name) + } + return name, nil +} + +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/project/runtime.go b/cli/azd/extensions/azure.ai.rle/internal/project/runtime.go new file mode 100644 index 00000000000..f80a5cd31b0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/project/runtime.go @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +type callOptions struct { + timeout int + action string + body string +} + +func RunShellWithContext( + ctx context.Context, + input io.Reader, + output io.Writer, + baseUrl string, + timeout int, +) error { + done := make(chan error, 1) + go func() { + done <- runShell(input, output, baseUrl, timeout) + }() + select { + case err := <-done: + return err + case <-ctx.Done(): + fmt.Fprintln(output) + return nil + } +} + +func normalizeOperation(operation string) (string, error) { + operation = strings.Trim(strings.ToLower(operation), "/") + switch operation { + case "reset", "step", "state", "health", "metadata", "schema": + return operation, nil + default: + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Unknown environment runtime operation %q.", operation), + Code: "rle_unknown_open_env_operation", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use one of: reset, step, state, health, metadata, schema.", + } + } +} + +func runShell(input io.Reader, output io.Writer, baseUrl string, timeout int) error { + fmt.Fprintln(output, "Environment runtime shell. Type help for commands, exit to quit.") + scanner := bufio.NewScanner(input) + for { + fmt.Fprint(output, "rle> ") + if !scanner.Scan() { + fmt.Fprintln(output) + return scanner.Err() + } + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if strings.EqualFold(line, "exit") || strings.EqualFold(line, "quit") { + return nil + } + if strings.EqualFold(line, "help") { + printShellHelp(output) + continue + } + + operation, payload, _ := strings.Cut(line, " ") + operation, err := normalizeOperation(operation) + if err != nil { + fmt.Fprintf(output, "error: %v\n", err) + continue + } + payload = strings.TrimSpace(payload) + flags := &callOptions{timeout: timeout} + switch operation { + case "reset": + flags.body = payload + case "step": + if payload == "" { + fmt.Fprintln(output, "error: step requires a JSON action payload, for example: step {\"message\":\"hello\"}") + continue + } + flags.action = payload + default: + if payload != "" { + fmt.Fprintf(output, "error: %s does not accept a JSON payload\n", operation) + continue + } + } + + response, err := call(baseUrl, operation, flags) + if err != nil { + fmt.Fprintf(output, "error: %v\n", err) + continue + } + fmt.Fprintln(output, response) + } +} + +func printShellHelp(output io.Writer) { + fmt.Fprintln(output, "Commands:") + fmt.Fprintln(output, " health") + fmt.Fprintln(output, " reset [json]") + fmt.Fprintln(output, " step [json-action]") + fmt.Fprintln(output, " state") + fmt.Fprintln(output, " metadata") + fmt.Fprintln(output, " schema") + fmt.Fprintln(output, " exit") +} + +func WaitForHealth(baseUrl string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var lastErr error + client := &http.Client{Timeout: 2 * time.Second} + for time.Now().Before(deadline) { + resp, err := client.Get(strings.TrimRight(baseUrl, "/") + "/health") //nolint:gosec // Local user-selected endpoint. + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + lastErr = fmt.Errorf("health returned HTTP %d", resp.StatusCode) + } else { + lastErr = err + } + time.Sleep(time.Second) + } + return &azdext.LocalError{ + Message: fmt.Sprintf("Environment runtime endpoint did not become healthy at %s: %v", baseUrl, lastErr), + Code: "rle_local_container_not_ready", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Check the local container logs or remote sandbox status, then retry.", + } +} + +func call(baseUrl string, operation string, flags *callOptions) (string, error) { + method := http.MethodGet + var body io.Reader + if operation == "reset" || operation == "step" { + method = http.MethodPost + requestBody, err := requestBody(operation, flags) + if err != nil { + return "", err + } + body = bytes.NewReader(requestBody) + } + + url := strings.TrimRight(baseUrl, "/") + "/" + operation + req, err := http.NewRequest(method, url, body) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := HTTPClient(flags.timeout) + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("call environment runtime %s %s: %w", method, url, err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Environment runtime endpoint returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))), + Code: "rle_open_env_call_failed", + Category: azdext.LocalErrorCategoryUser, + } + } + + return prettyJson(data), nil +} + +func HTTPClient(timeoutSeconds int) *http.Client { + if timeoutSeconds <= 0 { + return &http.Client{} + } + return &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second} +} + +func requestBody(operation string, flags *callOptions) ([]byte, error) { + if strings.TrimSpace(flags.body) != "" { + return validateJsonObject(flags.body, "body") + } + if operation == "reset" { + return []byte("{}"), nil + } + + action, err := validateJsonObject(flags.action, "action") + if err != nil { + return nil, err + } + var actionValue any + if err := json.Unmarshal(action, &actionValue); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"action": actionValue}) +} + +func validateJsonObject(value string, flagName string) ([]byte, error) { + var decoded map[string]any + data := []byte(value) + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf("--%s must be valid JSON object: %v", flagName, err), + Code: "rle_invalid_json", + Category: azdext.LocalErrorCategoryUser, + Suggestion: jsonFlagSuggestion(flagName), + } + } + return data, nil +} + +func jsonFlagSuggestion(flagName string) string { + if flagName == "body" { + return "Use reset {\"seed\":0} or another JSON object." + } + return "Use step {\"message\":\"hello\"} or another JSON object." +} + +func prettyJson(data []byte) string { + var decoded any + if err := json.Unmarshal(data, &decoded); err != nil { + return string(data) + } + formatted, err := json.MarshalIndent(decoded, "", " ") + if err != nil { + return string(data) + } + return string(formatted) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/project/runtime_test.go b/cli/azd/extensions/azure.ai.rle/internal/project/runtime_test.go new file mode 100644 index 00000000000..aaf1d94403a --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/project/runtime_test.go @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRequestBodyWrapsAction(t *testing.T) { + body, err := requestBody("step", &callOptions{action: `{"message":"hello"}`}) + if err != nil { + t.Fatal(err) + } + if string(body) != `{"action":{"message":"hello"}}` { + t.Fatalf("unexpected body: %s", body) + } +} + +func TestRequestBodyUsesRawBody(t *testing.T) { + body, err := requestBody("step", &callOptions{ + action: `{"message":"hello"}`, + body: `{"action":{"message":"override"},"metadata":{"x":1}}`, + }) + if err != nil { + t.Fatal(err) + } + expected := `{"action":{"message":"override"},"metadata":{"x":1}}` + if string(body) != expected { + t.Fatalf("expected %s, got %s", expected, body) + } +} + +func TestNormalizeOperation(t *testing.T) { + operation, err := normalizeOperation("/STEP") + if err != nil { + t.Fatal(err) + } + if operation != "step" { + t.Fatalf("expected step, got %q", operation) + } + if _, err := normalizeOperation("unknown"); err == nil { + t.Fatal("expected unknown operation to fail") + } +} + +func TestRunShellCallsCommands(t *testing.T) { + var stepBody map[string]any + var resetBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/health": + _, _ = w.Write([]byte(`{"ok":true}`)) + case "/reset": + if err := json.NewDecoder(r.Body).Decode(&resetBody); err != nil { + t.Errorf("decode reset body: %v", err) + } + _, _ = w.Write([]byte(`{"reset":true}`)) + case "/step": + if err := json.NewDecoder(r.Body).Decode(&stepBody); err != nil { + t.Errorf("decode step body: %v", err) + } + _, _ = w.Write([]byte(`{"reward":1}`)) + case "/state": + _, _ = w.Write([]byte(`{"state":"ready"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + input := strings.NewReader("health\nreset {\"seed\":0}\nstep {\"message\":\"hello\"}\nstate\nexit\n") + var output bytes.Buffer + if err := runShell(input, &output, server.URL, 30); err != nil { + t.Fatal(err) + } + + if resetBody["seed"] != float64(0) { + t.Fatalf("expected reset seed body, got %#v", resetBody) + } + action, ok := stepBody["action"].(map[string]any) + if !ok || action["message"] != "hello" { + t.Fatalf("expected wrapped step action, got %#v", stepBody) + } + if !strings.Contains(output.String(), `"reward": 1`) { + t.Fatalf("expected pretty step response in output, got %s", output.String()) + } +} + +func TestRunShellRequiresStepPayload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/step" { + t.Fatal("step without payload should not call the runtime") + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + input := strings.NewReader("step\nexit\n") + var output bytes.Buffer + if err := runShell(input, &output, server.URL, 30); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "step requires a JSON action payload") { + t.Fatalf("expected step payload error, got %s", output.String()) + } +} + +func TestRunShellWithContextReturnsOnCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + cancel() + + var output bytes.Buffer + if err := RunShellWithContext(ctx, reader, &output, "http://127.0.0.1", 30); err != nil { + t.Fatal(err) + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go b/cli/azd/extensions/azure.ai.rle/internal/project/scaffold.go similarity index 75% rename from cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go rename to cli/azd/extensions/azure.ai.rle/internal/project/scaffold.go index ae6c3118790..8eea596b194 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold.go +++ b/cli/azd/extensions/azure.ai.rle/internal/project/scaffold.go @@ -1,53 +1,23 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package cmd +package project import ( "fmt" "os" "os/exec" "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 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(), "-") -} - const ( openEnvRepoUrl = "https://github.com/huggingface/OpenEnv.git" openEnvRepoRef = "main" openEnvEchoSamplePath = "envs/echo_env" ) -var checkoutOpenEnvEchoSampleFunc = checkoutOpenEnvEchoSample - func createRleSessionDir(name string, dest string, force bool) (string, error) { sessionDir := filepath.Join(dest, name) if entries, err := os.ReadDir(sessionDir); err == nil && len(entries) > 0 && !force { @@ -65,13 +35,17 @@ func createRleSessionDir(name string, dest string, force bool) (string, error) { return "", err } } - if err := os.MkdirAll(sessionDir, 0755); err != nil { + if err := os.MkdirAll(sessionDir, 0750); err != nil { return "", err } return sessionDir, nil } -func checkoutOpenEnvEchoSample(name string, dest string, force bool) (string, error) { +func CheckoutOpenEnvEchoSample(name string, dest string, force bool) (string, error) { + name, err := ValidateEnvironmentName(name) + if err != nil { + return "", err + } sessionDir, err := createRleSessionDir(name, dest, force) if err != nil { return "", err @@ -106,12 +80,6 @@ func checkoutOpenEnvEchoSample(name string, dest string, force bool) (string, er return sessionDir, nil } -func isSafeRelativePath(path string) bool { - return path != ".." && - !filepath.IsAbs(path) && - !strings.HasPrefix(path, ".."+string(os.PathSeparator)) -} - func runGitCheckout(args ...string) error { if _, err := exec.LookPath("git"); err != nil { return &azdext.LocalError{ @@ -121,7 +89,7 @@ func runGitCheckout(args ...string) error { Suggestion: "Install Git, then retry azd ai rle init.", } } - process := exec.Command("git", args...) + process := exec.Command("git", args...) //nolint:gosec // args are fixed by init's OpenEnv sample checkout flow. process.Env = os.Environ() output, err := process.CombinedOutput() if err != nil { @@ -167,7 +135,7 @@ func copyDirectory(sourceDir string, destDir string) error { } targetPath := filepath.Join(destDir, relativePath) if entry.IsDir() { - return os.MkdirAll(targetPath, 0755) + return os.MkdirAll(targetPath, 0750) } info, err := entry.Info() if err != nil { @@ -177,6 +145,6 @@ func copyDirectory(sourceDir string, destDir string) error { if err != nil { return err } - return os.WriteFile(targetPath, data, info.Mode().Perm()) + return os.WriteFile(targetPath, data, info.Mode().Perm()) //nolint:gosec // target path is derived from walking the trusted source sample and preserves source file mode. }) } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold_test.go b/cli/azd/extensions/azure.ai.rle/internal/project/scaffold_test.go similarity index 70% rename from cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold_test.go rename to cli/azd/extensions/azure.ai.rle/internal/project/scaffold_test.go index 1c28d0c3675..0fc793bca07 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/scaffold_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/project/scaffold_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package cmd +package project import ( "os" @@ -13,7 +13,7 @@ import ( func TestCopyDirectorySkipsGitMetadata(t *testing.T) { sourceDir := t.TempDir() destDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(sourceDir, ".git"), 0755); err != nil { + if err := os.MkdirAll(filepath.Join(sourceDir, ".git"), 0750); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(sourceDir, ".git", "config"), []byte("[remote]\n"), 0600); err != nil { @@ -38,7 +38,7 @@ func TestCopyDirectorySkipsSymlinksAndPreservesFileMode(t *testing.T) { sourceDir := t.TempDir() destDir := t.TempDir() scriptPath := filepath.Join(sourceDir, "run.sh") - if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\n"), 0755); err != nil { + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\n"), 0700); err != nil { //nolint:gosec // test fixture needs an executable bit to verify mode preservation. t.Fatal(err) } if err := os.Symlink(scriptPath, filepath.Join(sourceDir, "linked-run.sh")); err != nil { @@ -52,7 +52,7 @@ func TestCopyDirectorySkipsSymlinksAndPreservesFileMode(t *testing.T) { if err != nil { t.Fatal(err) } - if runtime.GOOS != "windows" && info.Mode().Perm() != 0755 { + if runtime.GOOS != "windows" && info.Mode().Perm() != 0700 { t.Fatalf("expected executable bit to be preserved, got %v", info.Mode().Perm()) } if _, err := os.Stat(filepath.Join(destDir, "linked-run.sh")); !os.IsNotExist(err) { @@ -69,3 +69,18 @@ func TestCopyDirectoryRejectsFileSource(t *testing.T) { t.Fatal("expected file source path to be rejected") } } + +func TestCheckoutOpenEnvEchoSampleRejectsInvalidNameBeforeChangingDestination(t *testing.T) { + destDir := t.TempDir() + sentinel := filepath.Join(destDir, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + + if _, err := CheckoutOpenEnvEchoSample("../bad", destDir, true); err == nil { + t.Fatal("expected invalid environment name to be rejected") + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("expected destination to be unchanged: %v", err) + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/ui/browser.go b/cli/azd/extensions/azure.ai.rle/internal/ui/browser.go new file mode 100644 index 00000000000..40a94a32c22 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/ui/browser.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ui + +import ( + "os/exec" + "runtime" +) + +// OpenBrowser opens a URL in the platform default browser. +var OpenBrowser = openBrowser + +func openBrowser(url string) error { + var command string + var args []string + switch runtime.GOOS { + case "windows": + command = "run" + "dll32" + args = []string{"url.dll,FileProtocolHandler", url} + case "darwin": + command = "open" + args = []string{url} + default: + command = "xdg-open" + args = []string{url} + } + return exec.Command(command, args...).Start() //nolint:gosec // command is selected from fixed platform defaults; url is passed as an argument. +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/ui/playground.go b/cli/azd/extensions/azure.ai.rle/internal/ui/playground.go new file mode 100644 index 00000000000..8f8ebeb62ea --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/ui/playground.go @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ui + +import _ "embed" + +//go:embed remote_playground.html +var RemotePlaygroundHTML string diff --git a/cli/azd/extensions/azure.ai.rle/internal/ui/remote_playground.html b/cli/azd/extensions/azure.ai.rle/internal/ui/remote_playground.html new file mode 100644 index 00000000000..56f1df14793 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/ui/remote_playground.html @@ -0,0 +1,77 @@ + + + + + + RLE Remote Console + + + +
RLE Remote Consolelocal UI proxy
+
+
+

Action

+ + + + + + + +

+

Schema

+
Not loaded.
+
+
+

Output

+
Ready.
+
+
+ + + diff --git a/eng/pipelines/release-ext-azure-ai-rle.yml b/eng/pipelines/release-ext-azure-ai-rle.yml new file mode 100644 index 00000000000..7c5a7d527c2 --- /dev/null +++ b/eng/pipelines/release-ext-azure-ai-rle.yml @@ -0,0 +1,40 @@ +# Continuous deployment trigger +trigger: + branches: + include: + - main + paths: + include: + - go.mod + - cli/azd/extensions/azure.ai.rle + - eng/pipelines/release-azd-extension.yml + - /eng/pipelines/templates/jobs/build-azd-extension.yml + - /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + - /eng/pipelines/templates/variables/image.yml + +pr: + paths: + include: + - cli/azd/extensions/azure.ai.rle + - eng/pipelines/release-ext-azure-ai-rle.yml + - eng/pipelines/release-azd-extension.yml + - eng/pipelines/templates/steps/publish-cli.yml + exclude: + - cli/azd/docs/** + +parameters: + - name: PublishToDevRegistry + displayName: Publish to dev registry + type: boolean + default: false + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extension.yml + parameters: + AzdExtensionId: azure.ai.rle + SanitizedExtensionId: azure-ai-rle + AzdExtensionDirectory: cli/azd/extensions/azure.ai.rle + PublishToDevRegistry: ${{ parameters.PublishToDevRegistry }} From 715a6c91061aba169212d1d6812f91680475fa91 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 8 Jul 2026 01:47:33 +0530 Subject: [PATCH 22/25] fix: lint, cspell failures --- .../workflows/test-ext-azure-ai-agents.yml | 98 +++++++++++++++++++ .../azure.ai.rle/internal/cmd/deploy.go | 24 ++--- .../azure.ai.rle/internal/cmd/init.go | 6 +- .../azure.ai.rle/internal/cmd/invoke.go | 14 +-- .../azure.ai.rle/internal/cmd/invoke_test.go | 12 +-- .../azure.ai.rle/internal/cmd/run.go | 30 +++--- 6 files changed, 140 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/test-ext-azure-ai-agents.yml diff --git a/.github/workflows/test-ext-azure-ai-agents.yml b/.github/workflows/test-ext-azure-ai-agents.yml new file mode 100644 index 00000000000..fd44207227d --- /dev/null +++ b/.github/workflows/test-ext-azure-ai-agents.yml @@ -0,0 +1,98 @@ +name: ext-azure-ai-agents-test + +on: + pull_request: + paths: + - "cli/azd/extensions/azure.ai.agents/**" + - "cli/azd/extensions/azure.ai.inspector/**" + - "cli/azd/test/functional/ai_agents/**" + - ".github/workflows/test-ext-azure-ai-agents.yml" + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: cli/azd/go.mod + + - name: Build azd + working-directory: cli/azd + run: | + go build -tags=record -o azd-record ./ + # Make the record-tagged binary available as "azd" on PATH. + # Required by extension sub-processes (azd x publish) and by + # azidentity.AzureDeveloperCLICredential (exec.LookPath("azd")) in playback. + sudo ln -sf "$GITHUB_WORKSPACE/cli/azd/azd-record" /usr/local/bin/azd + + - name: Install extension toolchain + run: | + # Install the developer extension (provides 'azd x' commands). + # The built-in 'azd' source is auto-registered, so no source-add needed. + azd extension install microsoft.azd.extensions --no-prompt + + - name: Build & install inspector dependency from source + working-directory: cli/azd/extensions/azure.ai.inspector + run: | + # Build azure.ai.inspector from source to ensure PR CI always has a + # compatible version — dependency releases are post-merge, but PRs may + # include inspector changes from this same repo. + pwsh -File ci-build.ps1 -OutputFileName azure-ai-inspector-linux-amd64 -Version $(cat version.txt | tr -d '\r\n') + mkdir -p bin + mv azure-ai-inspector-linux-amd64 bin/ + azd x pack + azd x publish + azd extension install azure.ai.inspector --source local --force --no-prompt + + - name: Build extension (ci-build.ps1) + working-directory: cli/azd/extensions/azure.ai.agents + run: | + # Use the canonical ci-build.ps1 which includes security flags + # (trimpath, pie, cfi, cfg, osusergo) matching release builds. + # -BuildRecordMode produces both a production and a record-tagged binary. + pwsh -File ci-build.ps1 -BuildRecordMode -OutputFileName azure-ai-agents-linux-amd64 -Version "$(cat version.txt | tr -d '\r\n')" + # Move production binary to bin/ where azd x pack expects it. + mkdir -p bin + mv azure-ai-agents-linux-amd64 bin/ + + - name: Pack, publish & install extension (production) + working-directory: cli/azd/extensions/azure.ai.agents + run: | + azd x pack + azd x publish + azd extension install azure.ai.agents --source local --force --no-prompt + + - name: Run Tier 0 tests (offline, production extension) + working-directory: cli/azd + env: + CLI_TEST_AZD_PATH: ${{ github.workspace }}/cli/azd/azd-record + run: | + go test -tags=record -run "Test_AIAgent_(Version|Help|Init_NoPrompt_MissingFlags|Doctor_Help)" \ + ./test/functional/ai_agents/ -v -timeout 5m + + - name: Swap extension to record binary for Tier 1 + run: | + # Overwrite the installed production binary with the record-tagged + # binary so Tier 1 tests can intercept/replay HTTP traffic. + cp "${{ github.workspace }}/cli/azd/extensions/azure.ai.agents/azure-ai-agents-linux-amd64-record" \ + ~/.azd/extensions/azure.ai.agents/azure-ai-agents-linux-amd64 + + - name: Run Tier 1 tests (playback, record extension) + working-directory: cli/azd + env: + AZURE_RECORD_MODE: playback + CLI_TEST_AZD_PATH: ${{ github.workspace }}/cli/azd/azd-record + run: | + go test -tags=record -run "Test_AIAgent_(Init_(NoPrompt_(Defer|WithProject)|NegativeControl_BadCassette)|SampleList_(Recorded|JSON_Recorded))" \ + ./test/functional/ai_agents/ -v -timeout 5m 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 7812e88bb9c..53d865ad41f 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -10,7 +10,7 @@ import ( "os" "strings" - rleproject "azure.ai.rle/internal/project" + "azure.ai.rle/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" @@ -67,7 +67,7 @@ func (a *deployAction) Run() error { if err != nil { return err } - if !rleproject.IsAcrImageReference(image) { + if !project.IsAcrImageReference(image) { return &azdext.LocalError{ Message: fmt.Sprintf("RLE deploy image must be an ACR image reference, got %q.", image), Code: "rle_acr_image_required", @@ -75,20 +75,20 @@ func (a *deployAction) Run() error { Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run deploy again.", } } - if err := rleproject.BuildRuntimeImage(a.cmd.Context(), a.cmd.OutOrStdout(), a.cmd.ErrOrStderr(), image, rleproject.BuildOptions{ + if err := project.BuildRuntimeImage(a.cmd.Context(), a.cmd.OutOrStdout(), a.cmd.ErrOrStderr(), image, project.BuildOptions{ Source: ".", Dockerfile: a.flags.dockerfile, }); err != nil { return err } - if err := rleproject.PushImage(a.cmd.Context(), a.cmd.OutOrStdout(), a.cmd.ErrOrStderr(), image); err != nil { + if err := project.PushImage(a.cmd.Context(), a.cmd.OutOrStdout(), a.cmd.ErrOrStderr(), image); err != nil { return err } - project, err := projectRouteSegment(state) + projectName, err := projectRouteSegment(state) if err != nil { return err } - environmentId := firstNonEmpty(state.EnvironmentId, rleproject.Slug(state.Name)) + environmentId := firstNonEmpty(state.EnvironmentId, project.Slug(state.Name)) client := newRleClient(resolveControlPlaneEndpoint()) request := v1EnvironmentRequest{ Name: state.Name, @@ -112,9 +112,9 @@ func (a *deployAction) Run() error { return err } if state.EnvironmentId == "" { - environment, err = client.createV1Environment(a.cmd.Context(), project, request) + environment, err = client.createV1Environment(a.cmd.Context(), projectName, request) } else { - environment, err = client.updateV1Environment(a.cmd.Context(), project, environmentId, request) + environment, err = client.updateV1Environment(a.cmd.Context(), projectName, 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. @@ -122,12 +122,12 @@ func (a *deployAction) Run() error { a.cmd.OutOrStdout(), "Environment '%s' not found in project '%s'; creating a new one.\n", environmentId, - project, + projectName, ); msgErr != nil { return msgErr } created = true - environment, err = client.createV1Environment(a.cmd.Context(), project, request) + environment, err = client.createV1Environment(a.cmd.Context(), projectName, request) } } if err != nil { @@ -202,11 +202,11 @@ func resolveDeployImage(flags *rleDeployFlags, state rleState) (string, error) { Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run deploy again.", } } - project, err := projectRouteSegment(state) + projectName, err := projectRouteSegment(state) if err != nil { return "", err } - return fmt.Sprintf("%s/%s-%s:latest", registry, rleproject.Slug(project), rleproject.Slug(state.Name)), nil + return fmt.Sprintf("%s/%s-%s:latest", registry, project.Slug(projectName), project.Slug(state.Name)), nil } type environmentOutput struct { 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 1caa0378858..6cd40f88062 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -8,7 +8,7 @@ import ( "os" "strings" - rleproject "azure.ai.rle/internal/project" + "azure.ai.rle/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" @@ -24,7 +24,7 @@ type initAction struct { envNameOverride string } -var checkoutOpenEnvEchoSampleFunc = rleproject.CheckoutOpenEnvEchoSample +var checkoutOpenEnvEchoSampleFunc = project.CheckoutOpenEnvEchoSample func newInitCommand() *cobra.Command { flags := &rleInitFlags{} @@ -63,7 +63,7 @@ func newInitCommand() *cobra.Command { func (a *initAction) Run() error { envName := firstNonEmpty(a.envNameOverride, "echo_env") var err error - envName, err = rleproject.ValidateEnvironmentName(envName) + envName, err = project.ValidateEnvironmentName(envName) if err != nil { return &azdext.LocalError{ Message: err.Error(), 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 738534ebabb..378e80ac0cc 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -15,8 +15,8 @@ import ( "strings" "time" - rleproject "azure.ai.rle/internal/project" - rleui "azure.ai.rle/internal/ui" + "azure.ai.rle/internal/project" + "azure.ai.rle/internal/ui" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" @@ -97,7 +97,7 @@ func (a *remoteInvokeAction) Run() error { ); err != nil { return err } - if err := rleproject.WaitForHealth(sandboxUrl, 60*time.Second); err != nil { + if err := project.WaitForHealth(sandboxUrl, 60*time.Second); err != nil { return err } playgroundUrl, stopPlayground, err := remotePlaygroundUrl(ctx, sandboxUrl) @@ -108,10 +108,10 @@ func (a *remoteInvokeAction) Run() error { if _, err := fmt.Fprintf(a.cmd.OutOrStdout(), "Playground UI: %s\n", playgroundUrl); err != nil { return err } - if err := rleui.OpenBrowser(playgroundUrl); err != nil { + if err := ui.OpenBrowser(playgroundUrl); err != nil { _, _ = fmt.Fprintf(a.cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) } - return rleproject.RunShellWithContext(ctx, a.cmd.InOrStdin(), a.cmd.OutOrStdout(), sandboxUrl, a.flags.timeout) + return project.RunShellWithContext(ctx, a.cmd.InOrStdin(), a.cmd.OutOrStdout(), sandboxUrl, a.flags.timeout) } const ( @@ -354,7 +354,7 @@ func remotePlaygroundHandler(sandboxUrl string) http.Handler { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" || r.URL.Path == "/web" { w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = io.WriteString(w, rleui.RemotePlaygroundHTML) + _, _ = io.WriteString(w, ui.RemotePlaygroundHTML) return } proxyOpenEnvToSandbox(w, r, sandboxUrl) @@ -389,7 +389,7 @@ func proxyOpenEnvToSandbox(w http.ResponseWriter, r *http.Request, sandboxUrl st if contentType := r.Header.Get("Content-Type"); contentType != "" { target.Header.Set("Content-Type", contentType) } - resp, err := rleproject.HTTPClient(60).Do(target) //nolint:gosec // local UI proxy intentionally forwards only fixed OpenEnv operations to the active sandbox. + resp, err := project.HTTPClient(60).Do(target) //nolint:gosec // local UI proxy intentionally forwards only fixed OpenEnv operations to the active sandbox. if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return 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 index 1090e7291a9..675deedfbdf 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -5,7 +5,6 @@ package cmd import ( "bytes" - "context" "encoding/json" "errors" "io" @@ -19,7 +18,7 @@ import ( "testing" "time" - rleui "azure.ai.rle/internal/ui" + "azure.ai.rle/internal/ui" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) @@ -162,8 +161,7 @@ func TestRemotePlaygroundProxyForwardsToSandbox(t *testing.T) { })) defer envServer.Close() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() playgroundUrl, stop, err := remotePlaygroundUrl(ctx, envServer.URL) if err != nil { t.Fatal(err) @@ -521,14 +519,14 @@ func TestSandboxLeasePendingStatusTreatsAnyConflictAsPending(t *testing.T) { func captureBrowserOpen(t *testing.T) *string { t.Helper() - old := rleui.OpenBrowser + old := ui.OpenBrowser openedUrl := "" - rleui.OpenBrowser = func(url string) error { + ui.OpenBrowser = func(url string) error { openedUrl = url return nil } t.Cleanup(func() { - rleui.OpenBrowser = old + ui.OpenBrowser = old }) return &openedUrl } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go index 2a77dfa0bc2..a1942f86ecc 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go @@ -16,8 +16,8 @@ import ( "strings" "time" - rleproject "azure.ai.rle/internal/project" - rleui "azure.ai.rle/internal/ui" + "azure.ai.rle/internal/project" + "azure.ai.rle/internal/ui" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" @@ -111,10 +111,10 @@ func (a *localRunAction) Run() error { if err != nil { return err } - if err := rleui.OpenBrowser(webUrl); err != nil { + if err := ui.OpenBrowser(webUrl); err != nil { _, _ = fmt.Fprintf(a.cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) } - shellErr := rleproject.RunShellWithContext(ctx, a.cmd.InOrStdin(), a.cmd.OutOrStdout(), baseUrl, 0) + shellErr := project.RunShellWithContext(ctx, a.cmd.InOrStdin(), a.cmd.OutOrStdout(), baseUrl, 0) if a.flags.watch { select { case err := <-watchDone: @@ -159,19 +159,19 @@ func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *localRunFlags) (str container := localContainerName(state.Name) baseUrl := fmt.Sprintf("http://localhost:%d", port) - if running, exists := rleproject.ContainerStatus(cmd.Context(), container); exists { + if running, exists := project.ContainerStatus(cmd.Context(), container); exists { if running && flags.reuseRunning && !flags.restart { - if err := rleproject.WaitForHealth(baseUrl, 30*time.Second); err != nil { + if err := project.WaitForHealth(baseUrl, 30*time.Second); err != nil { return "", err } return baseUrl, nil } - _ = rleproject.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) + _ = project.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) } if err := ensurePortAvailable(port); err != nil { return "", err } - if err := rleproject.BuildRuntimeImage(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), image, rleproject.BuildOptions{ + if err := project.BuildRuntimeImage(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), image, project.BuildOptions{ Source: flags.source, Dockerfile: flags.dockerfile, }); err != nil { @@ -194,7 +194,7 @@ func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *localRunFlags) (str "-p", portMapping, image, } - if err := rleproject.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), runArgs...); err != nil { + if err := project.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), runArgs...); err != nil { return "", &azdext.LocalError{ Message: fmt.Sprintf("Failed to start local Docker container %q: %v", container, err), Code: "rle_local_docker_run_failed", @@ -205,11 +205,11 @@ func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *localRunFlags) (str started := true cleanupStartedContainer := func() { if started { - _ = rleproject.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) + _ = project.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) } } - if err := rleproject.WaitForHealth(baseUrl, 30*time.Second); err != nil { + if err := project.WaitForHealth(baseUrl, 30*time.Second); err != nil { cleanupStartedContainer() return "", err } @@ -244,7 +244,7 @@ func localPortSuggestion(port int) string { func stopLocalContainer(cmd *cobra.Command, environmentName string) error { container := localContainerName(environmentName) - return rleproject.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) + return project.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) } func loadLocalRunState(flags *localRunFlags, output io.Writer) (rleState, error) { @@ -271,7 +271,7 @@ func loadLocalRunState(flags *localRunFlags, output io.Writer) (rleState, error) } func localRuntimeImageForRun(flags *localRunFlags, state rleState) string { - return rleproject.Slug(firstNonEmpty(state.Name, defaultSourceName(flags.source))) + ":local" + return project.Slug(firstNonEmpty(state.Name, defaultSourceName(flags.source))) + ":local" } func defaultSourceName(source string) string { @@ -287,7 +287,7 @@ func defaultSourceName(source string) string { if name == "." || name == string(filepath.Separator) || name == "" { return "rle_env" } - return rleproject.Slug(name) + return project.Slug(name) } func watchLocalContainer(cmd *cobra.Command, flags *localRunFlags) error { @@ -387,5 +387,5 @@ func resolvePort(flags *localRunFlags) int { } func localContainerName(envName string) string { - return "azd-rle-" + rleproject.Slug(firstNonEmpty(envName, "environment")) + return "azd-rle-" + project.Slug(firstNonEmpty(envName, "environment")) } From 9d28357c87ca7e29e53a752e927994cf6f390515 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 8 Jul 2026 01:50:44 +0530 Subject: [PATCH 23/25] fix: revert ai agents change --- .../workflows/test-ext-azure-ai-agents.yml | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 .github/workflows/test-ext-azure-ai-agents.yml diff --git a/.github/workflows/test-ext-azure-ai-agents.yml b/.github/workflows/test-ext-azure-ai-agents.yml deleted file mode 100644 index fd44207227d..00000000000 --- a/.github/workflows/test-ext-azure-ai-agents.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: ext-azure-ai-agents-test - -on: - pull_request: - paths: - - "cli/azd/extensions/azure.ai.agents/**" - - "cli/azd/extensions/azure.ai.inspector/**" - - "cli/azd/test/functional/ai_agents/**" - - ".github/workflows/test-ext-azure-ai-agents.yml" - branches: [main] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version-file: cli/azd/go.mod - - - name: Build azd - working-directory: cli/azd - run: | - go build -tags=record -o azd-record ./ - # Make the record-tagged binary available as "azd" on PATH. - # Required by extension sub-processes (azd x publish) and by - # azidentity.AzureDeveloperCLICredential (exec.LookPath("azd")) in playback. - sudo ln -sf "$GITHUB_WORKSPACE/cli/azd/azd-record" /usr/local/bin/azd - - - name: Install extension toolchain - run: | - # Install the developer extension (provides 'azd x' commands). - # The built-in 'azd' source is auto-registered, so no source-add needed. - azd extension install microsoft.azd.extensions --no-prompt - - - name: Build & install inspector dependency from source - working-directory: cli/azd/extensions/azure.ai.inspector - run: | - # Build azure.ai.inspector from source to ensure PR CI always has a - # compatible version — dependency releases are post-merge, but PRs may - # include inspector changes from this same repo. - pwsh -File ci-build.ps1 -OutputFileName azure-ai-inspector-linux-amd64 -Version $(cat version.txt | tr -d '\r\n') - mkdir -p bin - mv azure-ai-inspector-linux-amd64 bin/ - azd x pack - azd x publish - azd extension install azure.ai.inspector --source local --force --no-prompt - - - name: Build extension (ci-build.ps1) - working-directory: cli/azd/extensions/azure.ai.agents - run: | - # Use the canonical ci-build.ps1 which includes security flags - # (trimpath, pie, cfi, cfg, osusergo) matching release builds. - # -BuildRecordMode produces both a production and a record-tagged binary. - pwsh -File ci-build.ps1 -BuildRecordMode -OutputFileName azure-ai-agents-linux-amd64 -Version "$(cat version.txt | tr -d '\r\n')" - # Move production binary to bin/ where azd x pack expects it. - mkdir -p bin - mv azure-ai-agents-linux-amd64 bin/ - - - name: Pack, publish & install extension (production) - working-directory: cli/azd/extensions/azure.ai.agents - run: | - azd x pack - azd x publish - azd extension install azure.ai.agents --source local --force --no-prompt - - - name: Run Tier 0 tests (offline, production extension) - working-directory: cli/azd - env: - CLI_TEST_AZD_PATH: ${{ github.workspace }}/cli/azd/azd-record - run: | - go test -tags=record -run "Test_AIAgent_(Version|Help|Init_NoPrompt_MissingFlags|Doctor_Help)" \ - ./test/functional/ai_agents/ -v -timeout 5m - - - name: Swap extension to record binary for Tier 1 - run: | - # Overwrite the installed production binary with the record-tagged - # binary so Tier 1 tests can intercept/replay HTTP traffic. - cp "${{ github.workspace }}/cli/azd/extensions/azure.ai.agents/azure-ai-agents-linux-amd64-record" \ - ~/.azd/extensions/azure.ai.agents/azure-ai-agents-linux-amd64 - - - name: Run Tier 1 tests (playback, record extension) - working-directory: cli/azd - env: - AZURE_RECORD_MODE: playback - CLI_TEST_AZD_PATH: ${{ github.workspace }}/cli/azd/azd-record - run: | - go test -tags=record -run "Test_AIAgent_(Init_(NoPrompt_(Defer|WithProject)|NegativeControl_BadCassette)|SampleList_(Recorded|JSON_Recorded))" \ - ./test/functional/ai_agents/ -v -timeout 5m From c9650da5c1d83129efe71da6d7e92231aee7e865 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 8 Jul 2026 14:15:00 +0530 Subject: [PATCH 24/25] fix: lint error in agents extension --- .github/workflows/test-ext-azure-ai-agents.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-ext-azure-ai-agents.yml b/.github/workflows/test-ext-azure-ai-agents.yml index fd44207227d..b2d924d763e 100644 --- a/.github/workflows/test-ext-azure-ai-agents.yml +++ b/.github/workflows/test-ext-azure-ai-agents.yml @@ -48,7 +48,7 @@ jobs: # Build azure.ai.inspector from source to ensure PR CI always has a # compatible version — dependency releases are post-merge, but PRs may # include inspector changes from this same repo. - pwsh -File ci-build.ps1 -OutputFileName azure-ai-inspector-linux-amd64 -Version $(cat version.txt | tr -d '\r\n') + pwsh -File ci-build.ps1 -OutputFileName azure-ai-inspector-linux-amd64 -Version "$(cat version.txt | tr -d '\r\n')" mkdir -p bin mv azure-ai-inspector-linux-amd64 bin/ azd x pack From 2452b2a182280905f24292f63bbb2312306306ea Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 8 Jul 2026 17:27:56 +0530 Subject: [PATCH 25/25] fix: user experience --- .../azure.ai.rle/internal/cmd/deploy.go | 28 +++++++++---------- .../azure.ai.rle/internal/cmd/root.go | 2 +- .../azure.ai.rle/internal/cmd/root_test.go | 9 +++++- 3 files changed, 23 insertions(+), 16 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 53d865ad41f..34affa594cc 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -153,13 +153,13 @@ func (a *deployAction) Run() error { return err } body, err := json.MarshalIndent(environmentOutput{ - EnvironmentId: environment.Id, - ProjectId: environment.ProjectId, - Name: environment.Name, - AcrImage: environment.AcrImagePath, - Version: state.EnvironmentVersion, - CreatedAt: environment.CreatedAt, - UpdatedAt: environment.UpdatedAt, + EnvironmentId: environment.Id, + EnvironmentVersion: state.EnvironmentVersion, + Name: environment.Name, + FoundryProjectEndpoint: state.ProjectEndpoint, + AcrImage: environment.AcrImagePath, + CreatedAt: environment.CreatedAt, + UpdatedAt: environment.UpdatedAt, }, "", " ") if err != nil { return err @@ -210,11 +210,11 @@ func resolveDeployImage(flags *rleDeployFlags, state rleState) (string, error) { } type environmentOutput struct { - EnvironmentId string `json:"environmentId"` - ProjectId string `json:"projectId"` - Name string `json:"name"` - AcrImage string `json:"acrImage"` - Version string `json:"version"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + EnvironmentId string `json:"environmentId"` + EnvironmentVersion string `json:"environmentVersion"` + Name string `json:"name"` + FoundryProjectEndpoint string `json:"foundryProjectEndpoint"` + AcrImage string `json:"acrImage"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } 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 2ec014ee434..3e0008569a1 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -40,12 +40,12 @@ func NewRootCommand() *cobra.Command { newInitCommand(), newInvokeCommand(), newRunCommand(), - newVersionCommand(&extCtx.OutputFormat), } for _, command := range userCommands { command.Hidden = !rleCommandsEnabled() rootCmd.AddCommand(command) } + 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 index 3a35a7f72d4..4ec5c78ac5d 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 @@ -25,7 +25,7 @@ func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { func TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { t.Setenv(rleEnableEnvVar, "") rootCmd := NewRootCommand() - for _, commandName := range []string{"deploy", "init", "invoke", "run", "version"} { + for _, commandName := range []string{"deploy", "init", "invoke", "run"} { command, _, err := rootCmd.Find([]string{commandName}) if err != nil { t.Fatalf("expected command %q to be registered: %v", commandName, err) @@ -34,6 +34,13 @@ func TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { t.Fatalf("expected command %q to be hidden when %s is not true", commandName, rleEnableEnvVar) } } + versionCommand, _, err := rootCmd.Find([]string{"version"}) + if err != nil { + t.Fatalf("expected version command to be registered: %v", err) + } + if versionCommand.Hidden { + t.Fatal("expected version command to remain visible when preview lifecycle commands are hidden") + } metadataCommand, _, err := rootCmd.Find([]string{"metadata"}) if err != nil { t.Fatalf("expected metadata command to be registered: %v", err)