diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index caba167c4fe..83de0131737 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,6 +18,7 @@ /cli/azd/extensions/azure.ai.inspector/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @anchenyi @XiaofuHuang /cli/azd/extensions/azure.ai.models/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft /cli/azd/extensions/azure.ai.projects/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 +/cli/azd/extensions/azure.ai.rle/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 /cli/azd/extensions/azure.ai.routines/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 /cli/azd/extensions/azure.ai.skills/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 /cli/azd/extensions/azure.ai.toolboxes/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 diff --git a/.github/workflows/lint-ext-azure-ai-rle.yml b/.github/workflows/lint-ext-azure-ai-rle.yml new file mode 100644 index 00000000000..382cab44e70 --- /dev/null +++ b/.github/workflows/lint-ext-azure-ai-rle.yml @@ -0,0 +1,22 @@ +name: ext-azure-ai-rle-ci + +on: + pull_request: + paths: + - "cli/azd/extensions/azure.ai.rle/**" + - ".github/workflows/lint-ext-azure-ai-rle.yml" + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + lint: + uses: ./.github/workflows/lint-go.yml + with: + working-directory: cli/azd/extensions/azure.ai.rle 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 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..37fa1cafe66 --- /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 `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 new file mode 100644 index 00000000000..37a9824950a --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -0,0 +1,172 @@ +# Azure AI RLE extension for azd + +Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: init, 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 + +Install: + +- Azure Developer CLI (`azd`): https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd +- Docker Desktop: https://www.docker.com/products/docker-desktop/ +- Go, if building from source: https://go.dev/doc/install +- Git, if building from source: https://git-scm.com/downloads + +Verify: + +```powershell +azd version +docker version +``` + +## Install the extension from this checkout + +From `cli\azd\extensions\azure.ai.rle`: + +```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 +``` + +Verify: + +```powershell +azd ai rle --help +azd ai rle version +``` + +After code changes, rerun: + +```powershell +azd x build +azd x pack +azd x publish +azd extension install azure.ai.rle --source local --force +``` + +## Configure the RLE 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 a Foundry project endpoint and an ACR image for the registered RLE environment. Set them once: + +```powershell +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" +``` + +## Quickstart + +Discovery for all commands is currently disabled using `AZD_AI_RLE_ENABLE`. To enable: + +```powershell +$env:AZD_AI_RLE_ENABLE = "true" +``` + +### 1. Initialize an environment session + +Default echo session: + +```powershell +azd ai rle init +cd .\echo_env +``` + +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. + +Name the copied echo session: + +```powershell +azd ai rle init code_rl +``` + +For an existing source folder, skip `init` and run commands directly from that folder. + +### 2. Run locally + +```powershell +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. + +If `.azd-rle.json` +does not exist, `run` creates it with only the inferred local environment name. + +Use a custom host port: + +```powershell +azd ai rle run --port 9000 +``` + +`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 +``` + +Rebuild automatically while editing local source: + +```powershell +azd ai rle run --watch +``` + +The shell supports the standard OpenEnv commands: + +```text +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 | + +### 3. Deploy/register + +```powershell +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" +azd ai rle deploy +``` + +Deploy reads the Foundry project endpoint from `FOUNDRY_PROJECT_ENDPOINT` and the ACR registry from `AZURE_CONTAINER_REGISTRY_ENDPOINT` in the terminal environment. It derives the project route segment from `/api/projects/`, 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: + +```powershell +azd ai rle deploy --dockerfile server\Dockerfile +``` + +### 4. Invoke remotely + +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: + +```powershell +azd ai rle invoke --timeout 60 +``` 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..76e75af8cb0 --- /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).ToUniversalTime().ToString("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/ci-build.ps1 b/cli/azd/extensions/azure.ai.rle/ci-build.ps1 new file mode 100644 index 00000000000..b06bbd64689 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/ci-build.ps1 @@ -0,0 +1,80 @@ +param( + [string] $Version = (Get-Content "$PSScriptRoot/version.txt"), + [string] $SourceVersion = (git rev-parse HEAD), + [switch] $CodeCoverageEnabled, + [switch] $BuildRecordMode, + [string] $MSYS2Shell, # path to msys2_shell.cmd + [string] $OutputFileName +) + +$PSNativeCommandArgumentPassing = 'Legacy' + +go clean +if ($LASTEXITCODE) { + Write-Host "Error running go clean" + exit $LASTEXITCODE +} + +$buildFlags = @( + "-trimpath", + "-buildmode=pie" +) + +if ($CodeCoverageEnabled) { + $buildFlags += "-cover" +} + +$buildFlags += @( + "-tags=cfi,cfg,osusergo", + "-ldflags=-s -w -X azure.ai.rle/internal/cmd.Version=$Version -X azure.ai.rle/internal/cmd.Commit=$SourceVersion -X azure.ai.rle/internal/cmd.BuildDate=$(Get-Date -Format o) ", + "-o=$OutputFileName" +) + +function PrintFlags() { + foreach ($buildFlag in $buildFlags) { + Write-Host " $buildFlag" + } +} + +$oldGOEXPERIMENT = $env:GOEXPERIMENT +$env:GOEXPERIMENT = "loopvar" + +try { + Write-Host "Running: go build" + PrintFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build" + exit $LASTEXITCODE + } + + if ($BuildRecordMode) { + # Modify build tags to include record + $recordTagPatched = $false + for ($i = 0; $i -lt $buildFlags.Length; $i++) { + if ($buildFlags[$i].StartsWith("-tags=")) { + $buildFlags[$i] += ",record" + $recordTagPatched = $true + } + } + if (-not $recordTagPatched) { + $buildFlags += "-tags=record" + } + $recordOutput = "-o=$OutputFileName-record" + if ($IsWindows) { $recordOutput += ".exe" } + $buildFlags += $recordOutput + + Write-Host "Running: go build (record)" + PrintFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build (record)" + exit $LASTEXITCODE + } + } + + Write-Host "go build succeeded" +} +finally { + $env:GOEXPERIMENT = $oldGOEXPERIMENT +} diff --git a/cli/azd/extensions/azure.ai.rle/ci-test.ps1 b/cli/azd/extensions/azure.ai.rle/ci-test.ps1 new file mode 100644 index 00000000000..347e1b6e107 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/ci-test.ps1 @@ -0,0 +1,25 @@ +$gopath = go env GOPATH +$gotestsumBinary = "gotestsum" +if ($IsWindows) { + $gotestsumBinary += ".exe" +} +$gotestsum = Join-Path $gopath "bin" $gotestsumBinary + +Write-Host "Running unit tests..." + +if (Test-Path $gotestsum) { + & $gotestsum --format testname -- ./... -count=1 +} else { + Write-Host "gotestsum not found, using go test..." -ForegroundColor Yellow + go test ./... -v -count=1 +} + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "Tests failed with exit code: $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "All tests passed!" -ForegroundColor Green +exit 0 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..f5286a7dfba --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -0,0 +1,27 @@ +# 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: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle [options] +version: 0.1.0-preview +examples: + - name: 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: Build, push, and create or update the RLE environment. + usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"; $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io"; azd ai rle deploy + - name: run + description: Build and run the local RLE environment container. + usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle run + - name: invoke + 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 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/banner.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go new file mode 100644 index 00000000000..4a364d423b4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/banner.go @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "io" + "strings" + + "github.com/fatih/color" +) + +const bannerArt = `███████╗ ██████╗ ██╗ ██╗███╗ ██╗██████╗ ██████╗ ██╗ ██╗ +██╔════╝██╔═══██╗██║ ██║████╗ ██║██╔══██╗██╔══██╗╚██╗ ██╔╝ +█████╗ ██║ ██║██║ ██║██╔██╗ ██║██║ ██║██████╔╝ ╚████╔╝ +██╔══╝ ██║ ██║██║ ██║██║╚██╗██║██║ ██║██╔══██╗ ╚██╔╝ +██║ ╚██████╔╝╚██████╔╝██║ ╚████║██████╔╝██║ ██║ ██║ +╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝ ╚═╝` + +func printBanner(w io.Writer) { + purple := color.RGB(109, 53, 255).Add(color.Bold) + fmt.Fprintln(w) + + for line := range strings.SplitSeq(bannerArt, "\n") { + purple.Fprintln(w, line) //nolint:gosec // Banner output errors are non-critical. + } + + fmt.Fprintf(w, "v%s\n\n", Version) //nolint:gosec // Banner output errors are non-critical. +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go new file mode 100644 index 00000000000..4fd2b932c34 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + defaultControlPlaneEndpoint = "http://localhost:5000" +) + +type rleClient struct { + baseUrl string + httpClient *http.Client +} + +type v1EnvironmentRequest struct { + Name string `json:"name,omitempty"` + AcrImagePath string `json:"acrImagePath"` +} + +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"` + CreatedAt string `json:"createdAtUtc,omitempty"` + UpdatedAt string `json:"updatedAtUtc,omitempty"` + 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"` + CreatedAt string `json:"createdAtUtc,omitempty"` + UpdatedAt string `json:"updatedAtUtc,omitempty"` +} + +type rleHTTPError struct { + statusCode int + body string +} + +func (e *rleHTTPError) Error() string { + return fmt.Sprintf("RLE control plane returned HTTP %d: %s", e.statusCode, strings.TrimSpace(e.body)) +} + +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. Trying at %s; adjust if needed by setting RLE_ENDPOINT=.", + resolveControlPlaneEndpoint(), + ), + } +} + +// isNotFoundError reports whether err is an RLE control plane error with HTTP 404 status. +func isNotFoundError(err error) bool { + if httpErr, ok := errors.AsType[*rleHTTPError](err); ok { + return httpErr.statusCode == http.StatusNotFound + } + return false +} + +func newRleClient(endpoint string) *rleClient { + return &rleClient{ + baseUrl: strings.TrimRight(endpoint, "/"), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func resolveControlPlaneEndpoint() string { + if endpoint := os.Getenv("RLE_ENDPOINT"); endpoint != "" { + return endpoint + } + return defaultControlPlaneEndpoint +} + +func (c *rleClient) createV1Environment( + ctx context.Context, + project string, + request v1EnvironmentRequest, +) (*environmentResource, error) { + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments", + url.PathEscape(project), + ) + + var result environmentResource + if err := c.do(ctx, http.MethodPost, path, request, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) updateV1Environment( + ctx context.Context, + project string, + environmentId string, + request v1EnvironmentRequest, +) (*environmentResource, error) { + path := fmt.Sprintf( + "/rle/v1.0/projects/%s/environments/%s", + url.PathEscape(project), + url.PathEscape(environmentId), + ) + + var result environmentResource + if err := c.do(ctx, http.MethodPut, path, request, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (c *rleClient) 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 { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + reader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseUrl+path, reader) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("call RLE control plane %s: %w", c.baseUrl, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read RLE response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &rleHTTPError{statusCode: resp.StatusCode, body: string(respBody)} + } + + if target == nil || len(respBody) == 0 { + return nil + } + if err := json.Unmarshal(respBody, target); err != nil { + return fmt.Errorf("decode RLE response: %w", err) + } + + return nil +} 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..f8e468f4c5e --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go @@ -0,0 +1,94 @@ +// 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")) + var serviceErr *azdext.ServiceError + if !errors.As(err, &serviceErr) { + 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) + } + } +} + +func TestResolveDeployStateUsesFoundryProjectEndpointEnvironment(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv(foundryProjectEndpointEnvVar, "https://ACCOUNT.services.ai.azure.com/api/projects/project-from-env/") + + state, initialized, err := resolveDeployState(&rleDeployFlags{}) + if err != nil { + t.Fatal(err) + } + if initialized { + t.Fatal("expected no saved state") + } + if state.ProjectEndpoint != "https://account.services.ai.azure.com/api/projects/project-from-env" { + t.Fatalf("expected normalized project endpoint, got %q", state.ProjectEndpoint) + } +} + +func TestResolveDeployStateIgnoresSavedProjectEndpointFallback(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "saved-env", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/saved-project", + }); err != nil { + t.Fatal(err) + } + + state, initialized, err := resolveDeployState(&rleDeployFlags{}) + if err != nil { + t.Fatal(err) + } + if !initialized { + t.Fatal("expected saved state") + } + if state.ProjectEndpoint != "" { + t.Fatalf("expected saved project endpoint fallback to be ignored, got %q", state.ProjectEndpoint) + } +} + +func TestProjectNameFromFoundryEndpoint(t *testing.T) { + projectName, err := projectNameFromFoundryEndpoint( + "https://account.services.ai.azure.com/api/projects/my-project", + ) + if err != nil { + t.Fatal(err) + } + if projectName != "my-project" { + t.Fatalf("expected project name from endpoint, got %q", projectName) + } +} + +func TestProjectEndpointRequiresProjectPath(t *testing.T) { + _, err := normalizeFoundryProjectEndpoint("https://account.services.ai.azure.com/api/not-projects/my-project") + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) + } + if localErr.Code != "rle_invalid_project_endpoint" { + t.Fatalf("expected invalid endpoint code, got %q", localErr.Code) + } +} 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..34affa594cc --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "azure.ai.rle/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type rleDeployFlags struct { + dockerfile string +} + +type deployAction struct { + cmd *cobra.Command + flags *rleDeployFlags +} + +func newDeployCommand() *cobra.Command { + flags := &rleDeployFlags{} + + cmd := &cobra.Command{ + Use: "deploy", + Short: "Create or update the RLE environment", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return (&deployAction{cmd: cmd, flags: flags}).Run() + }, + } + + 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 (a *deployAction) Run() error { + state, initialized, err := resolveDeployState(a.flags) + if err != nil { + return err + } + if !initialized { + if _, err := fmt.Fprintf(a.cmd.OutOrStdout(), "No %s found; using current folder as the RLE source.\n", + rleStateFile); err != nil { + return err + } + } + + if state.ProjectEndpoint == "" { + return &azdext.LocalError{ + Message: "Foundry project endpoint is required for deploy.", + Code: "rle_project_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: fmt.Sprintf("Set %s=https://.services.ai.azure.com/api/projects/.", foundryProjectEndpointEnvVar), + } + } + + image, err := resolveDeployImage(a.flags, state) + if err != nil { + return err + } + 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", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run deploy again.", + } + } + 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 := project.PushImage(a.cmd.Context(), a.cmd.OutOrStdout(), a.cmd.ErrOrStderr(), image); err != nil { + return err + } + projectName, err := projectRouteSegment(state) + if err != nil { + return err + } + environmentId := firstNonEmpty(state.EnvironmentId, project.Slug(state.Name)) + client := newRleClient(resolveControlPlaneEndpoint()) + request := v1EnvironmentRequest{ + Name: state.Name, + AcrImagePath: image, + } + + var environment *environmentResource + created := state.EnvironmentId == "" + action := "Creating" + if !created { + action = "Updating" + } + + if _, err := fmt.Fprintf( + a.cmd.OutOrStdout(), + "%s environment '%s' (image=%s) ...\n", + action, + state.Name, + image, + ); err != nil { + return err + } + if state.EnvironmentId == "" { + environment, err = client.createV1Environment(a.cmd.Context(), projectName, request) + } else { + 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. + if _, msgErr := fmt.Fprintf( + a.cmd.OutOrStdout(), + "Environment '%s' not found in project '%s'; creating a new one.\n", + environmentId, + projectName, + ); msgErr != nil { + return msgErr + } + created = true + environment, err = client.createV1Environment(a.cmd.Context(), projectName, request) + } + } + if err != nil { + return serviceError(err) + } + state.EnvironmentId = environment.Id + state.EnvironmentVersion = environment.Version + if err := saveRleState(state); err != nil { + return err + } + + label := "Created" + if !created { + label = "Updated" + } + if _, err := fmt.Fprintf( + a.cmd.OutOrStdout(), + "\n%s environment '%s' (%s).\n", + label, + state.Name, + state.EnvironmentId, + ); err != nil { + return err + } + body, err := json.MarshalIndent(environmentOutput{ + 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 + } + if _, err := fmt.Fprintln(a.cmd.OutOrStdout(), string(body)); err != nil { + return err + } + return nil +} + +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(".")) + + projectEndpoint, err := resolveFoundryProjectEndpoint() + if err != nil { + return rleState{}, false, err + } + state.ProjectEndpoint = projectEndpoint + + 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.", + } + } + projectName, err := projectRouteSegment(state) + if err != nil { + return "", err + } + return fmt.Sprintf("%s/%s-%s:latest", registry, project.Slug(projectName), project.Slug(state.Name)), nil +} + +type environmentOutput struct { + 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/init.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go new file mode 100644 index 00000000000..6cd40f88062 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "os" + "strings" + + "azure.ai.rle/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type rleInitFlags struct { + force bool +} + +type initAction struct { + cmd *cobra.Command + flags *rleInitFlags + envNameOverride string +} + +var checkoutOpenEnvEchoSampleFunc = project.CheckoutOpenEnvEchoSample + +func newInitCommand() *cobra.Command { + flags := &rleInitFlags{} + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize a local RLE environment", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + envNameOverride := "" + if len(args) == 1 { + envNameOverride = args[0] + } + return (&initAction{cmd: cmd, flags: flags, envNameOverride: envNameOverride}).Run() + }, + } + + cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + var help strings.Builder + help.WriteString("Initialize a local RLE environment\n") + help.WriteString("Usage:\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") + if cmd.InheritedFlags().HasAvailableFlags() { + help.WriteString("Global Flags:\n") + help.WriteString(cmd.InheritedFlags().FlagUsages()) + } + _, _ = fmt.Fprint(cmd.OutOrStdout(), help.String()) + }) + cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite generated files in an existing non-empty session directory") + return cmd +} + +func (a *initAction) Run() error { + envName := firstNonEmpty(a.envNameOverride, "echo_env") + var err error + envName, err = project.ValidateEnvironmentName(envName) + 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 := checkoutOpenEnvEchoSampleFunc(envName, ".", a.flags.force) + if err != nil { + return err + } + + if err := saveRleStateIn(sessionDir, defaultRleState(envName)); err != nil { + return err + } + + displayDir := "." + string(os.PathSeparator) + sessionDir + _, err = fmt.Fprintf( + a.cmd.OutOrStdout(), + "Created OpenEnv-style environment at: %s\n"+ + "Next steps:\n"+ + " cd \"%s\"\n"+ + " azd ai rle run\n"+ + " $env:FOUNDRY_PROJECT_ENDPOINT = \"https://.services.ai.azure.com/api/projects/\"\n"+ + " $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = \".azurecr.io\"\n"+ + " azd ai rle deploy\n", + displayDir, + displayDir, + ) + return err +} 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..378e80ac0cc --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -0,0 +1,424 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "strings" + "time" + + "azure.ai.rle/internal/project" + "azure.ai.rle/internal/ui" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type remoteInvokeFlags struct { + timeout int +} + +type remoteInvokeAction struct { + cmd *cobra.Command + flags *remoteInvokeFlags +} + +func newInvokeCommand() *cobra.Command { + flags := &remoteInvokeFlags{ + timeout: 30, + } + + cmd := &cobra.Command{ + Use: "invoke", + Short: "Open a remote OpenEnv runtime shell", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return (&remoteInvokeAction{cmd: cmd, flags: flags}).Run() + }, + } + + cmd.Flags().IntVar( + &flags.timeout, + "timeout", + flags.timeout, + "Per-command OpenEnv request timeout in seconds (0 for no timeout).", + ) + return cmd +} + +func (a *remoteInvokeAction) Run() error { + state, err := loadRleState() + if err != nil { + return err + } + if err := requireDeployedEnvironment(state); err != nil { + return err + } + + ctx, stopSignals := signal.NotifyContext(a.cmd.Context(), os.Interrupt) + defer stopSignals() + + client := newRleClient(resolveControlPlaneEndpoint()) + if _, err := fmt.Fprintf( + a.cmd.OutOrStdout(), + "Creating sandbox for environment %s ...\n", + state.EnvironmentId, + ); err != nil { + return err + } + + sandbox, err := leaseRemoteSandbox(ctx, a.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(a.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( + a.cmd.OutOrStdout(), + "Sandbox %s ready at %s\n", + sandbox.Id, + sandboxUrl, + ); err != nil { + return err + } + if err := project.WaitForHealth(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(a.cmd.OutOrStdout(), "Playground UI: %s\n", playgroundUrl); err != nil { + return err + } + if err := ui.OpenBrowser(playgroundUrl); err != nil { + _, _ = fmt.Fprintf(a.cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) + } + return project.RunShellWithContext(ctx, a.cmd.InOrStdin(), a.cmd.OutOrStdout(), sandboxUrl, a.flags.timeout) +} + +const ( + sandboxStatusRunning = "Running" + sandboxStatusFailed = "Failed" + + remoteSandboxLeaseMaxRetries = 10 +) + +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.", + } + } + project, err := projectRouteSegment(state) + if err != nil { + return nil, err + } + readySandbox, err := waitForRemoteSandbox(ctx, client, 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: %w", 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 { + project, err := projectRouteSegment(state) + if err != nil { + return nil, err + } + sandbox, err := client.createSandbox(ctx, project, state.EnvironmentId, sandboxCreateRequest{ + Version: state.EnvironmentVersion, + }) + if err == nil { + return sandbox, nil + } + + status, pending := sandboxLeasePendingStatus(err) + if !pending { + return nil, err + } + if attempt >= remoteSandboxLeaseMaxRetries || time.Now().After(deadline) { + return nil, &azdext.LocalError{ + Message: fmt.Sprintf( + "Sandbox was not ready for testing after %d retries (last status: %s).", + attempt, + firstNonEmpty(status, "unknown"), + ), + Code: "rle_sandbox_lease_pending_timeout", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Wait for the RLE control plane to finish preparing the sandbox, then retry invoke.", + } + } + + attempt++ + if _, msgErr := fmt.Fprintf( + output, + "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 + } + 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() + project, err := projectRouteSegment(state) + if err != nil { + return err + } + return client.deleteSandbox(ctx, 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, "/")), + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 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.WithoutCancel(ctx), 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, ui.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) //nolint:gosec // sandboxUrl is the active RLE sandbox URL; operation is restricted above. + 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 := 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 + } + 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) +} + +func requireDeployedEnvironment(state rleState) error { + if strings.TrimSpace(state.ProjectEndpoint) == "" { + return &azdext.LocalError{ + Message: "Foundry project endpoint is required for remote invoke.", + Code: "rle_project_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Run azd ai rle deploy first with FOUNDRY_PROJECT_ENDPOINT set.", + } + } + 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 +} 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..675deedfbdf --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -0,0 +1,637 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "azure.ai.rle/internal/ui" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { + captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "code_rl", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/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", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/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 := t.Context() + 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", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/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", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/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{ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/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 TestEnsurePortAvailableRejectsBoundPort(t *testing.T) { + 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) + } + 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 TestResolvePortDefaultsTo8000WithoutPersistedState(t *testing.T) { + if port := resolvePort(&localRunFlags{}); port != defaultPort { + t.Fatalf("expected default port %d, got %d", defaultPort, port) + } + if port := resolvePort(&localRunFlags{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, 0750); err != nil { + t.Fatal(err) + } + t.Chdir(tempDir) + + var output bytes.Buffer + state, err := loadLocalRunState(&localRunFlags{source: "."}, &output) + if err != nil { + t.Fatal(err) + } + if state.Name != "my-env" { + t.Fatalf("expected source-folder name, got %q", state.Name) + } + image := localRuntimeImageForRun(&localRunFlags{source: "."}, state) + if image != "my-env:local" { + t.Fatalf("expected default local image, got %q", image) + } + if !strings.Contains(output.String(), "No .azd-rle.json found; using current folder as the RLE source.") { + t.Fatalf("expected missing state transparency message, got %q", output.String()) + } + var saved rleState + data, err := os.ReadFile(stateFilePath(".")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatal(err) + } + if saved != (rleState{Name: "my-env"}) { + t.Fatalf("expected saved state with only name, got %#v", saved) + } +} + +func TestInvokeRemoteWaitsForDiskImageConversion(t *testing.T) { + captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "code_rl", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", + EnvironmentId: "env-1", + }); 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(), "Getting sandbox ready for testing (status: Pending); waiting") { + t.Fatalf("expected sandbox readiness 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 TestRemoteInvokeStopsRetryingSandboxLeaseConflicts(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "code_rl", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", + EnvironmentId: "env-1", + }); err != nil { + t.Fatal(err) + } + + oldPollInterval := remoteImagePollInterval + remoteImagePollInterval = time.Millisecond + defer func() { remoteImagePollInterval = oldPollInterval }() + + createCount := 0 + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && + r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes" { + createCount++ + http.Error(w, `{"error":"quota unavailable"}`, http.StatusConflict) + return + } + 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) + err := command.Execute() + if err == nil { + t.Fatal("expected sandbox lease retry error") + } + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) + } + if localErr.Code != "rle_sandbox_lease_pending_timeout" { + t.Fatalf("expected sandbox lease timeout code, got %q", localErr.Code) + } + if createCount != remoteSandboxLeaseMaxRetries+1 { + t.Fatalf("expected initial attempt plus max retries, got %d calls", createCount) + } + if !strings.Contains(localErr.Message, "Sandbox was not ready for testing") { + t.Fatalf("expected generic sandbox readiness message, got %q", localErr.Message) + } +} + +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 captureBrowserOpen(t *testing.T) *string { + t.Helper() + old := ui.OpenBrowser + openedUrl := "" + ui.OpenBrowser = func(url string) error { + openedUrl = url + return nil + } + t.Cleanup(func() { + ui.OpenBrowser = old + }) + return &openedUrl +} + +func TestResolveDeployStateDefaultsToExistingFolderWithoutInit(t *testing.T) { + tempDir := filepath.Join(t.TempDir(), "My Env") + if err := os.MkdirAll(tempDir, 0750); err != nil { + t.Fatal(err) + } + t.Chdir(tempDir) + t.Setenv(foundryProjectEndpointEnvVar, "https://account.services.ai.azure.com/api/projects/project-1") + + state, initialized, err := resolveDeployState(&rleDeployFlags{}) + 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.ProjectEndpoint != "https://account.services.ai.azure.com/api/projects/project-1" { + t.Fatalf("expected saved project endpoint, got %q", state.ProjectEndpoint) + } +} + +func TestResolveDeployStateDoesNotPersistDockerfileFlag(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv(foundryProjectEndpointEnvVar, "https://account.services.ai.azure.com/api/projects/project-1") + + state, initialized, err := resolveDeployState(&rleDeployFlags{ + dockerfile: "server/Dockerfile", + }) + 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) + } +} + +func TestResolveDeployImageUsesTerminalAcrRegistryEnvironment(t *testing.T) { + t.Setenv("AZURE_CONTAINER_REGISTRY_ENDPOINT", "example.azurecr.io") + + image, err := resolveDeployImage( + &rleDeployFlags{}, + rleState{Name: "My Env", ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/Project 1"}, + ) + if err != nil { + t.Fatal(err) + } + if image != "example.azurecr.io/project-1-my-env:latest" { + t.Fatalf("expected derived ACR image, got %q", image) + } +} + +func TestResolveDeployImageRequiresAcrRegistry(t *testing.T) { + _, err := resolveDeployImage( + &rleDeployFlags{}, + rleState{Name: "my-env", ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/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", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", + EnvironmentId: "env-1", + }, + ) + if err != nil { + t.Fatal(err) + } + if image != "example.azurecr.io/project-1-my-env:latest" { + t.Fatalf("expected registry-derived image, got %q", image) + } +} + +func TestLocalRuntimeImageForRunDefaultsToSourceFolder(t *testing.T) { + tempDir := filepath.Join(t.TempDir(), "My Env") + if err := os.MkdirAll(tempDir, 0750); err != nil { + t.Fatal(err) + } + + image := localRuntimeImageForRun( + &localRunFlags{source: tempDir}, + rleState{Name: defaultSourceName(tempDir)}, + ) + if image != "my-env:local" { + t.Fatalf("expected source folder image, got %q", image) + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/metadata.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/metadata.go new file mode 100644 index 00000000000..2c38b67413d --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/metadata.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newMetadataCommand(rootCmd *cobra.Command) *cobra.Command { + return azdext.NewMetadataCommand("1.0", "azure.ai.rle", func() *cobra.Command { + return rootCmd + }) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/project_endpoint.go new file mode 100644 index 00000000000..4ec63e8b291 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/project_endpoint.go @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "net/url" + "os" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + foundryProjectEndpointEnvVar = "FOUNDRY_PROJECT_ENDPOINT" +) + +func resolveFoundryProjectEndpoint() (string, error) { + if endpoint := strings.TrimSpace(os.Getenv(foundryProjectEndpointEnvVar)); endpoint != "" { + return normalizeFoundryProjectEndpoint(endpoint) + } + + return "", nil +} + +func normalizeFoundryProjectEndpoint(raw string) (string, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "", invalidProjectEndpointError(fmt.Sprintf("invalid Foundry project endpoint: %v", err)) + } + if !strings.EqualFold(u.Scheme, "https") { + return "", invalidProjectEndpointError("Foundry project endpoint must use https") + } + if u.Hostname() == "" || !strings.HasSuffix(strings.ToLower(u.Hostname()), ".services.ai.azure.com") { + return "", invalidProjectEndpointError("Foundry project endpoint host must end with .services.ai.azure.com") + } + if _, err := projectNameFromFoundryEndpoint(u.String()); err != nil { + return "", err + } + + u.Scheme = "https" + u.Host = strings.ToLower(u.Host) + u.Path = strings.TrimRight(u.Path, "/") + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} + +func projectNameFromFoundryEndpoint(endpoint string) (string, error) { + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return "", invalidProjectEndpointError(fmt.Sprintf("invalid Foundry project endpoint: %v", err)) + } + + parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/") + if len(parts) < 3 || parts[0] != "api" || parts[1] != "projects" || parts[2] == "" { + return "", invalidProjectEndpointError( + "Foundry project endpoint path must be /api/projects/", + ) + } + + projectName, err := url.PathUnescape(parts[2]) + if err != nil { + return "", invalidProjectEndpointError(fmt.Sprintf("invalid Foundry project name: %v", err)) + } + if strings.TrimSpace(projectName) == "" { + return "", invalidProjectEndpointError("Foundry project name must not be empty") + } + return projectName, nil +} + +func projectRouteSegment(state rleState) (string, error) { + return projectNameFromFoundryEndpoint(state.ProjectEndpoint) +} + +func invalidProjectEndpointError(message string) error { + return &azdext.LocalError{ + Message: message, + Code: "rle_invalid_project_endpoint", + Category: azdext.LocalErrorCategoryUser, + Suggestion: fmt.Sprintf( + "Set %s=https://.services.ai.azure.com/api/projects/.", + foundryProjectEndpointEnvVar, + ), + } +} 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..3e0008569a1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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", + Use: "rle [options]", + Short: fmt.Sprintf("Manage RLE resources from your terminal. %s", color.YellowString("(Preview)")), + }) + + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + + defaultHelp := rootCmd.HelpFunc() + rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if cmd == rootCmd { + printBanner(cmd.OutOrStdout()) + } + defaultHelp(cmd, args) + }) + + userCommands := []*cobra.Command{ + newDeployCommand(), + newInitCommand(), + newInvokeCommand(), + newRunCommand(), + } + for _, command := range userCommands { + command.Hidden = !rleCommandsEnabled() + rootCmd.AddCommand(command) + } + rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) + 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 new file mode 100644 index 00000000000..4ec5c78ac5d --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { + rootCmd := NewRootCommand() + + 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 TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { + t.Setenv(rleEnableEnvVar, "") + 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 !command.Hidden { + 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) + } + 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 TestDeployExposesStandaloneFlags(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-endpoint"); flag != nil { + t.Fatal("expected deploy not to expose --project-endpoint") + } + if flag := command.Flags().Lookup("project-id"); flag != nil { + t.Fatal("expected deploy not 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) { + 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 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"}) + 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") + } + 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 { + 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 not to expose --local") + } + 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 flag := invokeCommand.Flags().Lookup("port"); flag != nil { + t.Fatal("expected invoke not to expose --port") + } + 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", "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) + } + } + + 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 TestInitCopiesOpenEnvEchoSampleByDefault(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + stubOpenEnvEchoCheckout(t) + + 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") + 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) + } + 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 _, err := os.Stat(filepath.Join(sessionDir, "server", "Dockerfile")); err != nil { + t.Fatalf("expected copied OpenEnv server Dockerfile: %v", err) + } + 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(), sessionDir) { + t.Fatalf("expected init output not to use absolute cd path, got %s", output.String()) + } + expectedCd := `cd "` + "." + string(os.PathSeparator) + "echo_env" + `"` + if !strings.Contains(output.String(), expectedCd) { + t.Fatalf("expected init output to quote relative cd path, got %s", output.String()) + } +} + +func TestInitUsesPositionalNameForDefaultSample(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + stubOpenEnvEchoCheckout(t) + + command := newInitCommand() + command.SetArgs([]string{"code_rl"}) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + + sessionDir := filepath.Join(tempDir, "code_rl") + 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) + } + 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 := 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, 0750); 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/run.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go new file mode 100644 index 00000000000..a1942f86ecc --- /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" + + "azure.ai.rle/internal/project" + "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 := ui.OpenBrowser(webUrl); err != nil { + _, _ = fmt.Fprintf(a.cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) + } + shellErr := project.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 := project.ContainerStatus(cmd.Context(), container); exists { + if running && flags.reuseRunning && !flags.restart { + if err := project.WaitForHealth(baseUrl, 30*time.Second); err != nil { + return "", err + } + return baseUrl, nil + } + _ = project.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) + } + if err := ensurePortAvailable(port); err != nil { + return "", err + } + if err := project.BuildRuntimeImage(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), image, project.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 := 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", + Category: azdext.LocalErrorCategoryUser, + Suggestion: localPortSuggestion(port), + } + } + started := true + cleanupStartedContainer := func() { + if started { + _ = project.RunDocker(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), "rm", "-f", container) + } + } + + if err := project.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 project.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 project.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 project.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-" + project.Slug(firstNonEmpty(envName, "environment")) +} 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..eb15f927d76 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/state.go @@ -0,0 +1,67 @@ +// 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" +) + +type rleState struct { + Name string `json:"name"` + ProjectEndpoint string `json:"projectEndpoint,omitempty"` + EnvironmentId string `json:"environmentId,omitempty"` + EnvironmentVersion string `json:"environmentVersion,omitempty"` +} + +func defaultRleState(name string) rleState { + return rleState{ + Name: name, + } +} + +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 + } + return state, nil +} + +func saveRleState(state rleState) error { + return saveRleStateIn(".", state) +} + +func saveRleStateIn(dir string, state rleState) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(stateFilePath(dir), append(data, '\n'), 0600) +} + +func stateFilePath(dir string) string { + return filepath.Join(dir, rleStateFile) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/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 "" +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/version.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/version.go new file mode 100644 index 00000000000..80b2e338d90 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/version.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +var ( + // Version is populated at build time. + Version = "dev" + // Commit is populated at build time. + Commit = "none" + // BuildDate is populated at build time. + BuildDate = "unknown" +) + +func newVersionCommand(outputFormat *string) *cobra.Command { + return azdext.NewVersionCommand("azure.ai.rle", Version, outputFormat) +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/project/build.go b/cli/azd/extensions/azure.ai.rle/internal/project/build.go new file mode 100644 index 00000000000..6104276c212 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/project/build.go @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +type BuildOptions struct { + Source string + Dockerfile string +} + +func PrepareDockerBuild(opts BuildOptions) (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 = 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 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 { + return false + } + 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) { + 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 .", + } +} 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/project/scaffold.go b/cli/azd/extensions/azure.ai.rle/internal/project/scaffold.go new file mode 100644 index 00000000000..8eea596b194 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/project/scaffold.go @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package project + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + openEnvRepoUrl = "https://github.com/huggingface/OpenEnv.git" + openEnvRepoRef = "main" + openEnvEchoSamplePath = "envs/echo_env" +) + +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{ + 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 + } + if force { + if err := os.RemoveAll(sessionDir); err != nil { + return "", err + } + } + if err := os.MkdirAll(sessionDir, 0750); err != nil { + return "", err + } + return sessionDir, nil +} + +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 + } + tempDir, err := os.MkdirTemp("", "azd-rle-open-env-*") + 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 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...) //nolint:gosec // args are fixed by init's OpenEnv sample checkout flow. + 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_open_env_checkout_failed", + Category: azdext.LocalErrorCategoryUser, + Suggestion: strings.TrimSpace(string(output)), + } + } + return nil +} + +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.", + } + } + 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, 0750) + } + 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()) //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/project/scaffold_test.go b/cli/azd/extensions/azure.ai.rle/internal/project/scaffold_test.go new file mode 100644 index 00000000000..0fc793bca07 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/project/scaffold_test.go @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +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"), 0750); 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"), 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 { + 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() != 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) { + 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") + } +} + +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/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 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 }}