diff --git a/.github/workflows/lint-ext-azure-ai-dataset.yml b/.github/workflows/lint-ext-azure-ai-dataset.yml new file mode 100644 index 00000000000..b0b4cfd13bd --- /dev/null +++ b/.github/workflows/lint-ext-azure-ai-dataset.yml @@ -0,0 +1,22 @@ +name: ext-azure-ai-dataset-ci + +on: + pull_request: + paths: + - "cli/azd/extensions/azure.ai.dataset/**" + - ".github/workflows/lint-ext-azure-ai-dataset.yml" + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write # required by reusable workflow lint-go.yml + +jobs: + lint: + uses: ./.github/workflows/lint-go.yml + with: + working-directory: cli/azd/extensions/azure.ai.dataset diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 988fa646c88..e1e9c998479 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -493,6 +493,9 @@ overrides: words: - forbidigo - Logf + - filename: extensions/azure.ai.dataset/README.md + words: + - CODEOWNERS - filename: .golangci.yaml words: - forbidigo diff --git a/cli/azd/extensions/azure.ai.dataset/.gitignore b/cli/azd/extensions/azure.ai.dataset/.gitignore new file mode 100644 index 00000000000..f0c9e6ba140 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/.gitignore @@ -0,0 +1,5 @@ +# Test report written by ci-test.ps1 for the pipeline to publish. +junitTestReport.xml + +# Debug log written when --debug or AZD_EXT_DEBUG is set. +azd-ai-dataset-*.log diff --git a/cli/azd/extensions/azure.ai.dataset/.golangci.yaml b/cli/azd/extensions/azure.ai.dataset/.golangci.yaml new file mode 100644 index 00000000000..9777522d023 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/.golangci.yaml @@ -0,0 +1,21 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + gosec: + excludes: + - G204 # Subprocess launched with variable (bicep build invoked in tests) + - G304 # Potential file inclusion via variable + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/azure.ai.dataset/CHANGELOG.md b/cli/azd/extensions/azure.ai.dataset/CHANGELOG.md new file mode 100644 index 00000000000..6f0261c8696 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/CHANGELOG.md @@ -0,0 +1,19 @@ +# Release History + +## 1.0.0-beta.17 (2026-08-20) + +First release of the Foundry datasets extension. + +### Features Added + +- Register and manage Foundry datasets from the terminal: `create`, `update`, + `list`, `show`, `delete`, and `versions list`. +- Publishes a local `.jsonl` file or folder as a versioned dataset, picking the + next version from what the project already carries. `--version` publishes at + exactly that version instead, on `create` and `update` alike. +- Validates before sending: a malformed row, an empty dataset, a name the + service will not take, and a folder that could mean more than one dataset are + each refused locally. +- Reads dataset content back, whether the service hands out a blob URI or the + container holding it. +- `-o json` on every command, and `--no-prompt` for unattended use. diff --git a/cli/azd/extensions/azure.ai.dataset/README.md b/cli/azd/extensions/azure.ai.dataset/README.md new file mode 100644 index 00000000000..d3bf7eb83ff --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/README.md @@ -0,0 +1,76 @@ +# Foundry datasets (Beta) + +Register and version Foundry datasets from your terminal. + +```console +$ azd extension install azure.ai.dataset +$ azd ai dataset --help +``` + +A dataset is a general Foundry asset: evaluation needs one, and so do +fine-tuning and other scenarios. That is why these commands live here rather +than inside `azure.ai.evaluations`. + +## Commands + +| Command | What it does | +|---|---| +| `azd ai dataset create --from-file ` | Register a dataset, publishing its first version | +| `azd ai dataset update --from-file ` | Publish a further version | +| `azd ai dataset list` | List the project's datasets | +| `azd ai dataset show ` | Show one dataset | +| `azd ai dataset delete ` | Delete a dataset version | +| `azd ai dataset versions list ` | List a dataset's versions | + +`--version` names the version to publish, on `create` and `update` alike. Omit +it and the next version after the latest registered one is published; a version +the service already holds is refused rather than stepped past, because a version +you named is one you meant. + +## Generating a dataset + +Generation is `azd ai eval generate`, in `azure.ai.evaluations`, and stays +there: it writes the `datasets:` entry in `evals/azure.eval.yaml`, which is that +extension's file. Splitting the two would leave a generated dataset registered +with the service but absent from the configuration, so `azd up` would not +reconcile it and no eval could name it. + +Once a file exists, `create` registers it here. + +## Project endpoint + +Every command resolves the Foundry project endpoint in this order: + +1. `--project-endpoint` +2. `FOUNDRY_PROJECT_ENDPOINT` in the active azd environment, then + `AZURE_AI_PROJECT_ENDPOINT` there +3. `extensions.ai-agents.project.context.endpoint` in azd's global config, + which `azure.ai.agents` writes and this extension only reads +4. `FOUNDRY_PROJECT_ENDPOINT` in the host environment, then + `AZURE_AI_PROJECT_ENDPOINT` + +Level 3 is worth knowing about: it is machine-wide rather than per-project, so +a project context left behind by `azd ai agent` somewhere else takes precedence +over the variable exported in this shell. `--debug` prints which level answered. + +## Building + +```console +$ go build ./... +$ go test ./... +``` + +## TODO before release + +Both are files the azd extensions team owns, so they are not changed here: + +- [ ] **`cli/azd/extensions/registry.json`** — add the `azure.ai.dataset` entry. + Until it exists `azd extension install azure.ai.dataset` cannot resolve, so + the extension is only reachable through `azd x pack` + `azd x publish` into + the local source registry. +- [ ] **`.github/CODEOWNERS`** — add `/cli/azd/extensions/azure.ai.dataset/`. + Every sibling Foundry extension has an entry; without one, PRs here get no + reviewer routing. +- [ ] **`microsoft.foundry/extension.yaml`** — add the dependency, but only + after the registry entry lands. Declaring a dependency that cannot resolve + breaks installing the bundle. diff --git a/cli/azd/extensions/azure.ai.dataset/build.ps1 b/cli/azd/extensions/azure.ai.dataset/build.ps1 new file mode 100644 index 00000000000..d7b66d013b4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/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" + ) +} + +$VERSION_PATH = "azureaidataset/internal/version" + +# 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 '$VERSION_PATH.Version=$env:EXTENSION_VERSION' -X '$VERSION_PATH.Commit=$COMMIT' -X '$VERSION_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." diff --git a/cli/azd/extensions/azure.ai.dataset/build.sh b/cli/azd/extensions/azure.ai.dataset/build.sh new file mode 100644 index 00000000000..2c2da86b4ba --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/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 + +VERSION_PATH="azureaidataset/internal/version" + +# 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 '$VERSION_PATH.Version=$EXTENSION_VERSION' -X '$VERSION_PATH.Commit=$COMMIT' -X '$VERSION_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." diff --git a/cli/azd/extensions/azure.ai.dataset/ci-build.ps1 b/cli/azd/extensions/azure.ai.dataset/ci-build.ps1 new file mode 100644 index 00000000000..ef3cfce3ea3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/ci-build.ps1 @@ -0,0 +1,116 @@ +param( + [string] $Version = (Get-Content "$PSScriptRoot/version.txt"), + [string] $SourceVersion = (git rev-parse HEAD), + [switch] $CodeCoverageEnabled, + # Accepted because the shared CI template always passes it. This extension + # has no record/playback mode, so there is no second binary to produce. + [switch] $BuildRecordMode, + [string] $MSYS2Shell, # path to msys2_shell.cmd + [string] $OutputFileName +) +$PSNativeCommandArgumentPassing = 'Legacy' + +# Remove any previously built binaries. +go clean + +if ($LASTEXITCODE) { + Write-Host "Error running go clean" + exit $LASTEXITCODE +} + +# Run `go help build` for detail on these flags. +$buildFlags = @( + # Remove file system paths from the binary. Recorded file names become a + # module path@version, or a plain import path for the standard library. + "-trimpath", + + # Position Independent Executable, for memory-corruption hardening across + # platforms. On Windows this enables ASLR and sets DYNAMICBASE and + # HIGH-ENTROPY-VA in the PE header. + "-buildmode=pie" +) + +if ($CodeCoverageEnabled) { + $buildFlags += "-cover" +} + +# cfi: Control Flow Integrity, cfg: Control Flow Guard, +# osusergo: use the pure Go user lookup. +$tagsFlag = "-tags=cfi,cfg,osusergo" + +# -s: omit the symbol table, -w: omit DWARF, -X: set a variable at link time. +# The path has to match this module, azureaidataset: the linker discards -X for +# a symbol that does not exist, so a stale one leaves the binary reporting dev. +$ldFlag = "-ldflags=-s -w " + + "-X 'azureaidataset/internal/version.Version=$Version' " + + "-X 'azureaidataset/internal/version.Commit=$SourceVersion' " + + "-X 'azureaidataset/internal/version.BuildDate=$(Get-Date -Format o)' " + +if ($IsWindows) { + Write-Host "Building for Windows" +} +elseif ($IsLinux) { + Write-Host "Building for linux" + + # Disable cgo for the x64 Linux build. This also links statically, which + # widens compatibility with older Linux distributions. + if ($env:GOARCH -ne "arm64") { + $env:CGO_ENABLED = "0" + } +} +elseif ($IsMacOS) { + Write-Host "Building for macOS" +} + +$outputFlag = "-o=$OutputFileName" + +$buildFlags += @( + $tagsFlag, + $ldFlag, + $outputFlag +) + +function PrintFlags() { + param( + [string] $flags + ) + + # Format the flags so they can be pasted straight into pwsh. + $i = 0 + foreach ($buildFlag in $buildFlags) { + # Quote values so characters such as ',' survive a repaste. Not needed + # for the direct invocation below. + $argWithValue = $buildFlag.Split('=', 2) + if ($argWithValue.Length -eq 2 -and !$argWithValue[1].StartsWith("`"")) { + $buildFlag = "$($argWithValue[0])=`"$($argWithValue[1])`"" + } + + if ($i -eq $buildFlags.Length - 1) { + Write-Host " $buildFlag" + } + else { + Write-Host " $buildFlag ``" + } + $i++ + } +} + +$oldGOEXPERIMENT = $env:GOEXPERIMENT +# Opt into per-iteration loop variables, which is what most readers expect and +# what the Go team intends to make the default. +$env:GOEXPERIMENT = "loopvar" + +try { + Write-Host "Running: go build ``" + PrintFlags -flags $buildFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build" + exit $LASTEXITCODE + } + + Write-Host "go build succeeded" +} +finally { + $env:GOEXPERIMENT = $oldGOEXPERIMENT +} diff --git a/cli/azd/extensions/azure.ai.dataset/ci-test.ps1 b/cli/azd/extensions/azure.ai.dataset/ci-test.ps1 new file mode 100644 index 00000000000..bd8100f5ad2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/ci-test.ps1 @@ -0,0 +1,61 @@ +# Runs the unit tests and writes a JUnit report. +# +# The pipeline publishes **/junitTestReport.xml from the extension directory, +# so the report has to be written under that name for results to show up in the +# build. gotestsum produces it; the go test fallback does not, so the fallback +# only runs when gotestsum is unavailable. +# +# The live integration tests are excluded: they carry the `live` build tag, so +# an untagged run does not compile them, and they additionally require +# AZURE_AI_DATASET_E2E_LIVE and a project endpoint. They are still type-checked +# below, so a change that breaks them cannot reach main unnoticed. +# +# TODO before the first release: PR CI runs this script on windows, linux and +# darwin amd64, so the untagged tests are covered on all three. The live and +# hero suites are only type-checked, never executed, and both have only ever +# run on Windows by hand. Run them once on linux, where they assume a path +# separator and shell out to `azd` and to a proxy address. + +$gopath = go env GOPATH +$gotestsumBinary = "gotestsum" +# $IsWindows only exists on PowerShell 6 and later. On Windows PowerShell 5.1 it +# is undefined, so the suffix was never appended, the binary was never found, +# and the run silently fell back to `go test` with no JUnit report. +if ($env:OS -eq "Windows_NT") { + $gotestsumBinary += ".exe" +} +# Windows PowerShell 5.1 takes a single child path, so the three-argument form +# fails outright there. Nesting is what every version accepts. +$gotestsum = Join-Path (Join-Path $gopath "bin") $gotestsumBinary + +Write-Host "Running unit tests..." + +if (Test-Path $gotestsum) { + & $gotestsum --format testname --junitfile junitTestReport.xml -- ./... -count=1 +} else { + Write-Host "gotestsum not found; falling back to go test (no JUnit report)." -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 +} + +# The tagged suites are never run here, so without this nothing compiles them +# and a change that breaks one reaches main silently. Type-checking needs no +# credentials, so it costs a few seconds and runs everywhere the tests do. +Write-Host "" +Write-Host "Type-checking the live and hero suites..." +go vet -tags live,hero ./... + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "The tagged test suites do not compile: $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "All tests passed!" -ForegroundColor Green +exit 0 diff --git a/cli/azd/extensions/azure.ai.dataset/cspell.yaml b/cli/azd/extensions/azure.ai.dataset/cspell.yaml new file mode 100644 index 00000000000..7cc0d33a702 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/cspell.yaml @@ -0,0 +1,34 @@ +import: ../../.vscode/cspell.yaml +words: + # Go module and package names + - azureaidataset + - azureaieval + - evalcore + - exterrors + - httptest + - projectctx + - urlsafe + - creack + # Possessives cspell does not inflect on its own + - projectctx's + - CLI's + # Service identifiers and API fields + - evalrun + - lookback + - AOAI + # Built-in evaluator names + - ifeval + - groundedness + # Repository names + - foundrysdk + # Terms + - inlines + - negotiables + - parseable + - retargeted + - subsetting + - undeployed + - undoable + - unpassed + - unscored + - Unparseable diff --git a/cli/azd/extensions/azure.ai.dataset/extension.yaml b/cli/azd/extensions/azure.ai.dataset/extension.yaml new file mode 100644 index 00000000000..d57cf3a1d2a --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/extension.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=../extension.schema.json +id: azure.ai.dataset +namespace: ai.dataset +displayName: Foundry datasets (Beta) +description: Register and version Foundry datasets from your terminal. (Beta) +usage: azd ai dataset [options] +# NOTE: Make sure version.txt is in sync with this version. +version: 1.0.0-beta.17 +requiredAzdVersion: ">=1.27.1" +language: go +capabilities: + - custom-commands + - metadata +examples: + - name: create + description: Register a dataset from a local file. + usage: azd ai dataset create support-regression --from-file ./data/golden.jsonl + - name: versions list + description: List the versions of a dataset. + usage: azd ai dataset versions list support-regression +tags: + - ai + - foundry + - dataset diff --git a/cli/azd/extensions/azure.ai.dataset/go.mod b/cli/azd/extensions/azure.ai.dataset/go.mod new file mode 100644 index 00000000000..73a606c861b --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/go.mod @@ -0,0 +1,105 @@ +module azureaidataset + +go 1.26.4 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 + github.com/azure/azure-dev/cli/azd v1.28.0 + github.com/fatih/color v1.18.0 + github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 + google.golang.org/grpc v1.82.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 // 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/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.53.0 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // 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.dataset/go.sum b/cli/azd/extensions/azure.ai.dataset/go.sum new file mode 100644 index 00000000000..3919ed14417 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/go.sum @@ -0,0 +1,316 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +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/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI= +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.28.0 h1:mqqyV85m7A1XfWJFjV/Ut0QoIEImFeF++1Ruq/cRp0s= +github.com/azure/azure-dev/cli/azd v1.28.0/go.mod h1:Ge7QaU9PoJM7i6J0xArDoQCf2tUn6O7OIKkoItxFTA8= +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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +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-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +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.dataset/internal/cmd/apiversions.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/apiversions.go new file mode 100644 index 00000000000..16ad4a772c4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/apiversions.go @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +// API versions used by the Foundry data plane. +const ( + // ProjectEndpointAPIVersion covers datasets on the project endpoint. + ProjectEndpointAPIVersion = "2025-11-15-preview" + + // DataGenerationAPIVersion covers dataset generation jobs. + DataGenerationAPIVersion = "v1" +) diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/artifacts.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/artifacts.go new file mode 100644 index 00000000000..63a66c36439 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/artifacts.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "azureaidataset/internal/messages" + +// envKeyDatasetVersion records the version resolved at the last publish. The +// eval extension writes the same key; nothing reads it yet, so it exists for +// the user's own scripts and for `azd env get-values`. +const envKeyDatasetVersion = "EVAL_DATASET_VERSION" + +// checkAssetExistence enforces the one difference between create and update. +// +// absenceCertain separates "the service says this name is unknown" from "nothing +// came back", and only the former refuses an update. The version listing is +// eventually consistent, so an update issued moments after a create reads an +// empty listing for a dataset that plainly exists. Refusing there strands the +// caller behind an error whose advice -- run `create` -- would fail too, because +// create sees the same listing catch up and reports the name already taken. +// Letting an unprovable absence through publishes a version, which is what was +// asked for either way: the upload does not care whether the name was new. +func checkAssetExistence(verb, kind, name string, exists, absenceCertain bool) error { + switch { + case verb == "create" && exists: + return messages.AssetAlreadyExists(kind, name) + case verb == "update" && !exists && absenceCertain: + return messages.AssetDoesNotExist(kind, name) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/asset_name_parity_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/asset_name_parity_test.go new file mode 100644 index 00000000000..71be20dabbe --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/asset_name_parity_test.go @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Every verb that takes a refuses an invalid one locally. +// +// The sibling extension carries a second copy of these commands, and the guard +// reached its dataset verbs but not its evaluator ones. Walking the tree rather +// than listing the verbs is the point: a verb added later is covered without +// anyone remembering to add it here. +func TestEveryNamedAssetVerbRefusesAnInvalidName(t *testing.T) { + const badName = "has space" + + checked := 0 + walk(t, NewRootCommand(), nil, func(path string, cmd *cobra.Command) { + if cmd.RunE == nil || !strings.Contains(cmd.Use, "") { + return + } + + checked++ + // The guard has to come first: it runs before the client is built, so + // a mistyped name costs neither a round trip nor an azd connection. + err := cmd.RunE(cmd, []string{badName}) + require.Errorf(t, err, "%s accepted %q", path, badName) + assert.Containsf(t, err.Error(), "is invalid", + "%s refused %q, but not by naming the name", path, badName) + }) + + assert.GreaterOrEqual(t, checked, 4, + "create, update, show, delete and versions list all take a name") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/context.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/context.go new file mode 100644 index 00000000000..382ccef6bf4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/context.go @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "log" + "strings" + + "azureaidataset/internal/foundry/projectctx" + "azureaidataset/internal/messages" + "azureaidataset/internal/pkg/dataset_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// projectEndpointEnvKey is the azd environment key holding the Foundry project +// endpoint the data-plane clients target. +const projectEndpointEnvKey = "FOUNDRY_PROJECT_ENDPOINT" + +// datasetContext carries everything the commands need to reach the data plane. +type datasetContext struct { + azdClient *azdext.AzdClient + endpoint string + envName string + cred azcore.TokenCredential + + datasetClient *dataset_api.DatasetClient +} + +// newDatasetContext resolves the project endpoint and builds the data-plane +// clients. The resolution order is projectctx's, so that every Foundry +// extension answers the same question the same way: +// +// 1. --project-endpoint +// 2. the active azd environment (FOUNDRY_PROJECT_ENDPOINT, then AZURE_AI_PROJECT_ENDPOINT) +// 3. global config: extensions.ai-agents.project.context.endpoint +// 4. the host environment variables of the same two names +// 5. otherwise an error naming how to set one +func newDatasetContext(ctx context.Context, endpointFlag string) (*datasetContext, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return nil, messages.ConnectingToAzd(err) + } + + dc := &datasetContext{azdClient: azdClient} + + // The environment name is resolved regardless of where the endpoint came + // from: it is what cached version numbers are read from and written to. + _, envName := lookupEndpointFromAzd(ctx, azdClient) + dc.envName = envName + + resolved, err := projectctx.Resolve(ctx, projectctx.ResolveOpts{FlagValue: endpointFlag}) + if err != nil { + // The caller only defers Close on a context it was handed, so every + // path that abandons this one has to close it here. + dc.Close() + return nil, err + } + dc.endpoint = strings.TrimSuffix(resolved.Endpoint, "/") + log.Printf("[endpoint] resolved from %s", resolved.Source) + + cred, err := newAzdTokenCredential() + if err != nil { + dc.Close() + return nil, err + } + dc.cred = cred + + dc.datasetClient = dataset_api.NewDatasetClient(dc.endpoint, cred) + + return dc, nil +} + +// newAzdTokenCredential returns the azd credential already wrapped in its +// retry. Handing back the wrapper rather than the raw credential is what keeps +// the retry wired: in the sibling extension an earlier version assigned the +// wrapper to the context and then built its clients from the unwrapped one, so +// nothing retried and four tests still passed. +func newAzdTokenCredential() (azcore.TokenCredential, error) { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return nil, messages.CreatingCredential(err) + } + return azdTokenRetry{inner: cred}, nil +} + +// azdTokenRetry retries a failed token request once. azidentity gives the azd +// subprocess a fixed 10 second timeout and discards its stderr, so an azd that +// overruns surfaces as "exit status 1" with no cause; the next call usually +// finds a warm token. Without this a slow token turns into a failed command. +type azdTokenRetry struct{ inner azcore.TokenCredential } + +func (c azdTokenRetry) GetToken( + ctx context.Context, + opts policy.TokenRequestOptions, +) (azcore.AccessToken, error) { + tok, err := c.inner.GetToken(ctx, opts) + if err == nil || ctx.Err() != nil { + return tok, err + } + log.Printf("[auth] token request failed (%v); retrying once", err) + return c.inner.GetToken(ctx, opts) +} + +// azdEnvironmentName is the environment this invocation acts on: the one +// -e/--environment named, or azd's current one when it named none. +// +// Answered here rather than at each reader. -e was parsed by the SDK and then +// discarded, so `azd ai dataset create -e staging` read its endpoint out of the +// default environment and wrote its version back there, and `-e a-name-azd- +// rejects` was accepted in silence. +// +// Empty means there is no environment to act on, which is ordinary: these +// commands work standalone against the data plane. +func azdEnvironmentName(ctx context.Context, azdClient *azdext.AzdClient) string { + if name := projectctx.SelectedEnvironment(ctx); name != "" { + return name + } + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || envResp.GetEnvironment() == nil { + return "" + } + return envResp.Environment.Name +} + +// lookupEndpointFromAzd reads the endpoint from that environment, returning +// empty strings when there is none. +func lookupEndpointFromAzd(ctx context.Context, azdClient *azdext.AzdClient) (endpoint, envName string) { + envName = azdEnvironmentName(ctx, azdClient) + if envName == "" { + return "", "" + } + val, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: projectEndpointEnvKey, + }) + if err != nil || val == nil || val.Value == "" { + return "", envName + } + return val.Value, envName +} + +// errNoAzdEnvironment reports that there is no azd environment to persist into. +// +// These commands work standalone against the data plane, so running outside a +// project is ordinary rather than a problem worth reporting. +var errNoAzdEnvironment = messages.ErrNoAzdEnvironment + +// setEnvValue persists a value into the active azd environment. +func (dc *datasetContext) setEnvValue(ctx context.Context, key, value string) error { + if dc.envName == "" { + envResp, err := dc.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || envResp == nil || envResp.Environment == nil { + return messages.NoAzdEnvironmentToWrite(key) + } + dc.envName = envResp.Environment.Name + } + _, err := dc.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: dc.envName, + Key: key, + Value: value, + }) + if err != nil { + return messages.WritingEnvValue(key, err) + } + return nil +} + +func (dc *datasetContext) Close() { + if dc.azdClient != nil { + dc.azdClient.Close() + } +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/context_retry_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/context_retry_test.go new file mode 100644 index 00000000000..49b57cd005a --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/context_retry_test.go @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// countingCred fails its first failUntil calls, then succeeds. +type countingCred struct { + calls int + failFor int + lastOpts policy.TokenRequestOptions +} + +func (c *countingCred) GetToken( + _ context.Context, opts policy.TokenRequestOptions, +) (azcore.AccessToken, error) { + c.calls++ + c.lastOpts = opts + if c.calls <= c.failFor { + return azcore.AccessToken{}, errors.New("AzureDeveloperCLICredential: exit status 1") + } + return azcore.AccessToken{Token: "token"}, nil +} + +// The tests below construct azdTokenRetry directly, so they all pass even if +// nothing wires it in. This one guards the wiring: an earlier version assigned +// the wrapper to the context and built both clients from the raw credential, +// which made the retry dead code. +func TestNewAzdTokenCredentialReturnsTheRetryingCredential(t *testing.T) { + cred, err := newAzdTokenCredential() + require.NoError(t, err) + _, wrapped := cred.(azdTokenRetry) + assert.True(t, wrapped, "clients must be built from the retrying credential, not the raw one") +} + +// The failure this retries carries no cause: azidentity kills the azd +// subprocess at a fixed 10s and discards its stderr. Retrying is the only way +// to tell a slow token from a broken login. +func TestAzdTokenRetryRecoversFromOneFailure(t *testing.T) { + inner := &countingCred{failFor: 1} + + tok, err := azdTokenRetry{inner: inner}.GetToken( + t.Context(), policy.TokenRequestOptions{Scopes: []string{"scope"}}) + + require.NoError(t, err, "a token that succeeds on the second attempt must not fail the command") + assert.Equal(t, "token", tok.Token) + assert.Equal(t, 2, inner.calls, "exactly one retry") + assert.Equal(t, []string{"scope"}, inner.lastOpts.Scopes, "the retry keeps the caller's scopes") +} + +func TestAzdTokenRetryDoesNotRetryASuccess(t *testing.T) { + inner := &countingCred{} + _, err := azdTokenRetry{inner: inner}.GetToken(t.Context(), policy.TokenRequestOptions{}) + require.NoError(t, err) + assert.Equal(t, 1, inner.calls, "a working token costs one call") +} + +// Retrying must not paper over a genuine failure, and must stop at one. +func TestAzdTokenRetryGivesUpAfterTheSecondFailure(t *testing.T) { + inner := &countingCred{failFor: 99} + _, err := azdTokenRetry{inner: inner}.GetToken(t.Context(), policy.TokenRequestOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "exit status 1", "the original cause survives") + assert.Equal(t, 2, inner.calls, "no more than one retry") +} + +// A cancelled context is the user pressing Ctrl+C or a deadline expiring; +// retrying there would just fail again more slowly. +func TestAzdTokenRetryDoesNotRetryACancelledContext(t *testing.T) { + inner := &countingCred{failFor: 99} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := azdTokenRetry{inner: inner}.GetToken(ctx, policy.TokenRequestOptions{}) + require.Error(t, err) + assert.Equal(t, 1, inner.calls, "a cancelled context is not retried") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset.go new file mode 100644 index 00000000000..1cffc183f05 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset.go @@ -0,0 +1,424 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "azureaidataset/internal/messages" + "azureaidataset/internal/pkg/dataset_api" + + "github.com/spf13/cobra" +) + +// firstDatasetVersions are the versions a dataset's first publish can carry, +// probed when the version listing has not caught up yet. +// +// The service assigns nothing; the client picks. This CLI's first publish is +// NextVersion(""), so probing a hardcoded "1" never found a dataset this CLI +// had just created -- which is the one case the probe exists for. "1" is still +// probed because a generation job, the SDK or the portal can register one. +var firstDatasetVersions = []string{dataset_api.NextVersion(""), "1"} + +// newDatasetCreateCommand builds `dataset create `, which registers a +// dataset that does not exist yet. +func newDatasetCreateCommand() *cobra.Command { + return newDatasetWriteCommand("create", "Register a dataset, publishing its first version.") +} + +// datasetPresence answers whether the dataset is already registered, and +// whether a "no" can be trusted. +// +// The version listing lags a publish, so a create followed straight by an +// update was told the dataset it had just made does not exist. A point read of +// the versions a first publish can carry usually settles that, catching up +// sooner than the listing. +// +// Absence is only certain when the listing itself answered 404. An empty 200 +// does not prove it: latestRegisteredVersion documents that an unknown dataset +// and a listing that has not caught up are indistinguishable. +// +// A read that failed for any other reason proves nothing at all, and is +// returned. Treating a 403 or a timeout as "not there" let `create` go on to +// publish a further version of a dataset that already existed -- the one thing +// separating create from update, decided by an error nobody looked at. +func datasetPresence( + ctx context.Context, + client *dataset_api.DatasetClient, + name string, +) (exists, absenceCertain bool, err error) { + existing, listErr := client.ListDatasetVersions(ctx, name, ProjectEndpointAPIVersion) + if listErr != nil && !dataset_api.IsNotFound(listErr) { + return false, false, messages.CheckingDataset(name, listErr) + } + if listErr == nil && existing != nil && len(existing.Value) > 0 { + return true, false, nil + } + + for _, v := range firstDatasetVersions { + _, getErr := client.GetDataset(ctx, name, v, ProjectEndpointAPIVersion) + if getErr == nil { + return true, false, nil + } + if !dataset_api.IsNotFound(getErr) { + return false, false, messages.CheckingDataset(name, getErr) + } + } + return false, dataset_api.IsNotFound(listErr), nil +} + +// newDatasetUpdateCommand builds `dataset update `, which publishes a +// further version of one that does. +func newDatasetUpdateCommand() *cobra.Command { + return newDatasetWriteCommand("update", "Publish a new version of a dataset.") +} + +// newDatasetWriteCommand builds create and update. Both run the same upload, +// and the existence check is the only thing that separates them: a version is +// brought into being by startPendingUpload, which neither knows nor cares +// whether the name was already in use. +func newDatasetWriteCommand(verb, short string) *cobra.Command { + var ( + fromFile string + version string + endpointFlg string + ) + + cmd := &cobra.Command{ + Use: verb + " ", + Short: short, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if !validAssetName(name) { + return messages.InvalidDatasetName(name) + } + if fromFile == "" { + return requireFlag("from-file") + } + + localDir, err := datasetUploadSource(fromFile) + if err != nil { + return err + } + + ctx := cmd.Context() + ec, err := newDatasetContext(ctx, endpointFlg) + if err != nil { + return err + } + defer ec.Close() + + exists, absenceCertain, err := datasetPresence(ctx, ec.datasetClient, name) + if err != nil { + return err + } + if err := checkAssetExistence( + verb, "dataset", name, exists, absenceCertain, + ); err != nil { + return err + } + + // A declared version is the version to publish, never one to count + // from, so it is written exactly as given. Only an omitted version is + // derived, and only that path walks past a conflict: a version the + // author named and the service already holds is theirs to resolve, + // and stepping past it would publish one they did not ask for. + var ds *dataset_api.Dataset + if version != "" { + ds, err = ec.datasetClient.UploadVersion( + ctx, name, version, localDir, ProjectEndpointAPIVersion, + ) + } else { + ds, err = ec.datasetClient.UploadNextVersion( + ctx, name, "", localDir, ProjectEndpointAPIVersion, + ) + } + if err != nil { + return messages.RegisteringDataset(name, err) + } + + if err := ec.setEnvValue(ctx, envKeyDatasetVersion, ds.Version); err != nil { + // Persisting is a convenience, so this never fails the command. + // It goes to stdout because azd does not surface an extension's + // stderr under `azd up`, which is where a deploy would lose it. + // Skipped outside a project, where having nowhere to persist is + // expected rather than notable. + if !errors.Is(err, errNoAzdEnvironment) && !isJSON(cmd) { + fmt.Fprint(cmd.OutOrStdout(), messages.Warning(err)) + } + } + + if isJSON(cmd) { + return emitJSON(cmd.OutOrStdout(), ds) + } + fmt.Fprint(cmd.OutOrStdout(), messages.DatasetRegistered(ds.Name, ds.Version)) + return nil + }, + } + + cmd.Flags().StringVar(&fromFile, "from-file", "", + "Path to a .jsonl file, or a directory containing one.") + cmd.Flags().StringVar(&version, "version", "", + "Version to publish. Omit to publish the next version after the latest registered.") + cmd.Flags().StringVar(&endpointFlg, "project-endpoint", "", "Foundry project endpoint.") + return cmd +} + +// datasetUploadSource resolves what was named into the path the upload reads. +// +// A named file is returned as itself. Returning its directory would upload +// whichever .jsonl sorts first, so pointing --from-file at one dataset in a +// folder holding several would register a different one under that name. +// +// A directory is resolved to the single .jsonl inside it, which is what the +// flag offers. Several is not that, and picking one would be a guess. +func datasetUploadSource(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", messages.ReadingFromFile(path, err) + } + if !info.IsDir() { + if !strings.EqualFold(filepath.Ext(path), ".jsonl") { + return "", messages.FromFileMustBeJSONL(path) + } + return path, nil + } + + entries, err := os.ReadDir(path) + if err != nil { + return "", messages.ReadingFromFile(path, err) + } + var found []string + for _, e := range entries { + if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".jsonl") { + found = append(found, e.Name()) + } + } + switch len(found) { + case 0: + return "", messages.FromFileDirectoryHasNoJSONL(path) + case 1: + return filepath.Join(path, found[0]), nil + default: + return "", messages.FromFileDirectoryIsAmbiguous(path, found) + } +} + +func newDatasetListCommand() *cobra.Command { + var endpointFlg string + + cmd := &cobra.Command{ + Use: "list", + Short: "List the project's datasets.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + ec, err := newDatasetContext(ctx, endpointFlg) + if err != nil { + return err + } + defer ec.Close() + + list, err := ec.datasetClient.ListDatasets(ctx, ProjectEndpointAPIVersion) + if err != nil { + return messages.ListingDatasets(err) + } + return renderDatasets(cmd, list, messages.NoDatasets()) + }, + } + + cmd.Flags().StringVar(&endpointFlg, "project-endpoint", "", "Foundry project endpoint.") + return cmd +} + +// newDatasetVersionsCommand groups the version listing, so that `list` means +// the assets rather than the history of one of them. +func newDatasetVersionsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "versions", + Short: "Inspect the versions of one dataset.", + } + cmd.AddCommand(newDatasetVersionsListCommand()) + return cmd +} + +func newDatasetVersionsListCommand() *cobra.Command { + var endpointFlg string + + cmd := &cobra.Command{ + Use: "list ", + Short: "List the versions of a dataset.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if !validAssetName(name) { + return messages.InvalidDatasetName(name) + } + + ctx := cmd.Context() + ec, err := newDatasetContext(ctx, endpointFlg) + if err != nil { + return err + } + defer ec.Close() + + list, err := ec.datasetClient.ListDatasetVersions(ctx, name, ProjectEndpointAPIVersion) + if err != nil { + return messages.ListingDatasetVersions(name, err) + } + // An unknown name lists nothing and succeeds; it is not an error. + // `-o json` callers range over the array, and a delete is checked + // for idempotence by listing what is left. The empty sentence names + // the dataset, though: the project may hold plenty of others, so + // "No datasets found." would be answering a different question. + return renderDatasets(cmd, list, messages.NoDatasetVersions(name)) + }, + } + + cmd.Flags().StringVar(&endpointFlg, "project-endpoint", "", "Foundry project endpoint.") + return cmd +} + +// renderDatasets prints a dataset list, or, when it is empty, whatever the +// caller says an empty result means: listing every dataset and listing one +// name's versions come to the same renderer but not to the same sentence. +func renderDatasets(cmd *cobra.Command, list *dataset_api.DatasetList, whenEmpty string) error { + // JSON is decided before emptiness: a caller piping this into a parser needs + // an empty array, not the sentence a human would read. + if list == nil { + list = &dataset_api.DatasetList{} + } + if isJSON(cmd) { + return emitJSONList(cmd.OutOrStdout(), list.Value) + } + rows := make([][]string, 0, len(list.Value)) + for _, d := range list.Value { + rows = append(rows, []string{d.Name, d.Version, d.Type}) + } + if len(rows) == 0 { + fmt.Fprint(cmd.OutOrStdout(), whenEmpty) + return nil + } + // TYPE, not FORMAT: format is a field this API accepts on upload and never + // returns, so the column it filled was empty for every dataset ever listed. + return emitTable(cmd.OutOrStdout(), []string{"NAME", "VERSION", "TYPE"}, rows) +} + +func newDatasetShowCommand() *cobra.Command { + var ( + version string + endpointFlg string + ) + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a dataset version.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if !validAssetName(name) { + return messages.InvalidDatasetName(name) + } + + ctx := cmd.Context() + ec, err := newDatasetContext(ctx, endpointFlg) + if err != nil { + return err + } + defer ec.Close() + + if version == "" { + list, err := ec.datasetClient.ListDatasetVersions(ctx, name, ProjectEndpointAPIVersion) + if err != nil { + return messages.ResolvingLatestDatasetVersion(name, err) + } + // The service answers an unknown name with an empty list rather + // than a 404, so this is what "no such dataset" looks like. A + // dataset cannot exist with no versions. + if len(list.Value) == 0 { + return messages.DatasetNotFound(name) + } + version = dataset_api.LatestVersion(list.Value) + } + + ds, err := ec.datasetClient.GetDataset(ctx, name, version, ProjectEndpointAPIVersion) + if err != nil { + if dataset_api.IsNotFound(err) { + return messages.DatasetVersionNotFoundWithHint(name, version) + } + return messages.ReadingDatasetVersion(name, version, err) + } + + if isJSON(cmd) { + return emitJSON(cmd.OutOrStdout(), ds) + } + return emitDetail(cmd.OutOrStdout(), []field{ + {"Name", ds.Name}, + {"Version", ds.Version}, + {"Type", ds.Type}, + {"URI", ds.ResolvedBlobURI()}, + }) + }, + } + + cmd.Flags().StringVar(&version, "version", "", "Version to show. Omit for the latest.") + cmd.Flags().StringVar(&endpointFlg, "project-endpoint", "", "Foundry project endpoint.") + return cmd +} + +func newDatasetDeleteCommand() *cobra.Command { + var ( + version string + endpointFlg string + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a dataset version.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if !validAssetName(name) { + return messages.InvalidDatasetName(name) + } + if version == "" { + return requireFlag("version") + } + + ctx := cmd.Context() + ec, err := newDatasetContext(ctx, endpointFlg) + if err != nil { + return err + } + defer ec.Close() + + if err := ec.datasetClient.DeleteDatasetVersion( + ctx, name, version, ProjectEndpointAPIVersion, + ); err != nil { + if dataset_api.IsNotFound(err) { + return messages.DatasetVersionNotFound(name, version) + } + return messages.DeletingDatasetVersion(name, version, err) + } + + if isJSON(cmd) { + return emitJSON(cmd.OutOrStdout(), map[string]string{ + "name": name, "version": version, "status": "deleted", + }) + } + fmt.Fprint(cmd.OutOrStdout(), messages.DatasetDeleted(name, version)) + return nil + }, + } + + cmd.Flags().StringVar(&version, "version", "", "Version to delete.") + cmd.Flags().StringVar(&endpointFlg, "project-endpoint", "", "Foundry project endpoint.") + return cmd +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset_presence_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset_presence_test.go new file mode 100644 index 00000000000..2cb8e1972b3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset_presence_test.go @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "azureaidataset/internal/pkg/dataset_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// presenceServer stands up a fake project endpoint and records every path the +// presence probe asks for, so a test can assert what was tried as well as what +// was concluded. +type presenceServer struct { + mu sync.Mutex + paths []string +} + +// requested returns the paths seen so far, in order. +func (s *presenceServer) requested() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.paths...) +} + +// newPresenceClient wires a DatasetClient to a server that answers the version +// listing with listStatus/listBody, and answers a point read of a dataset +// version with whatever found reports for that version. +// +// Retries are off: a test that means "the service said 404" should cost one +// request, not a backoff schedule. +func newPresenceClient( + t *testing.T, + listStatus int, + listBody string, + found map[string]bool, +) (*dataset_api.DatasetClient, *presenceServer) { + t.Helper() + + rec := &presenceServer{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.paths = append(rec.paths, r.URL.Path) + rec.mu.Unlock() + + // Assertions inside a handler run on the server's goroutine, where a + // Fatalf would leave the client hanging on a response never written. + assert.Equal(t, http.MethodGet, r.Method) + + switch { + case strings.HasSuffix(r.URL.Path, "/versions"): + w.WriteHeader(listStatus) + _, _ = w.Write([]byte(listBody)) + default: + version := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + if found[version] { + w.WriteHeader(http.StatusOK) + // A fixed body: datasetPresence reads only whether the point + // read succeeded, and echoing the request path back would make + // this a taint sink for no benefit. + _, _ = w.Write([]byte(`{"name":"ds"}`)) + return + } + http.Error(w, `{"error":{"code":"NotFound"}}`, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + client := dataset_api.NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + return client, rec +} + +// TestPresenceTrustsANonEmptyVersionListing is the ordinary case: the listing +// answered, so no point read is needed. +func TestPresenceTrustsANonEmptyVersionListing(t *testing.T) { + client, rec := newPresenceClient(t, + http.StatusOK, `{"value":[{"name":"ds","version":"1.0"}]}`, nil) + + exists, absenceCertain, err := datasetPresence(t.Context(), client, "ds") + require.NoError(t, err) + + require.True(t, exists) + require.False(t, absenceCertain) + require.Equal(t, []string{"/datasets/ds/versions"}, rec.requested(), + "a listing that answered should settle it without a point read") +} + +// TestPresenceProbesPastAListingThatHasNotCaughtUp covers the bug the probe +// exists for: a create publishes 1.0, the listing still reports nothing, and +// the update that follows must not be told the dataset is missing. +func TestPresenceProbesPastAListingThatHasNotCaughtUp(t *testing.T) { + client, rec := newPresenceClient(t, + http.StatusOK, `{"value":[]}`, map[string]bool{"1.0": true}) + + exists, absenceCertain, err := datasetPresence(t.Context(), client, "ds") + require.NoError(t, err) + + require.True(t, exists, "the point read found the version the listing had not") + require.False(t, absenceCertain) + require.Equal(t, []string{"/datasets/ds/versions", "/datasets/ds/versions/1.0"}, + rec.requested()) + require.NoError(t, checkAssetExistence("update", "dataset", "ds", exists, absenceCertain)) + require.Error(t, checkAssetExistence("create", "dataset", "ds", exists, absenceCertain), + "create must still refuse a name the probe found") +} + +// TestPresenceProbesTheVersionSomethingElseRegistered covers a dataset created +// by the portal, the SDK or a generation job, which numbers its first version +// "1" rather than the "1.0" this CLI publishes. +func TestPresenceProbesTheVersionSomethingElseRegistered(t *testing.T) { + client, rec := newPresenceClient(t, + http.StatusOK, `{"value":[]}`, map[string]bool{"1": true}) + + exists, absenceCertain, err := datasetPresence(t.Context(), client, "ds") + require.NoError(t, err) + + require.True(t, exists) + require.False(t, absenceCertain) + require.Equal(t, + []string{"/datasets/ds/versions", "/datasets/ds/versions/1.0", "/datasets/ds/versions/1"}, + rec.requested(), + "both first-publish versions should be probed before giving up") +} + +// TestPresenceWillNotCallAnEmptyListingProofOfAbsence is the guard that keeps +// `update` working against a service whose listing lags. An empty 200 is not a +// 404, so the gate must let the update through rather than refuse it. +func TestPresenceWillNotCallAnEmptyListingProofOfAbsence(t *testing.T) { + client, _ := newPresenceClient(t, http.StatusOK, `{"value":[]}`, nil) + + exists, absenceCertain, err := datasetPresence(t.Context(), client, "ds") + require.NoError(t, err) + + require.False(t, exists) + require.False(t, absenceCertain, + "an empty listing does not distinguish an unknown dataset from a stale one") + require.NoError(t, checkAssetExistence("update", "dataset", "ds", exists, absenceCertain), + "update must proceed when absence is unproven") +} + +// TestPresenceTreatsA404ListingAsProofOfAbsence is the other half: a service +// that actually said "no such dataset" should stop an update before it uploads. +func TestPresenceTreatsA404ListingAsProofOfAbsence(t *testing.T) { + client, _ := newPresenceClient(t, + http.StatusNotFound, `{"error":{"code":"NotFound"}}`, nil) + + exists, absenceCertain, err := datasetPresence(t.Context(), client, "ds") + require.NoError(t, err) + + require.False(t, exists) + require.True(t, absenceCertain) + + gateErr := checkAssetExistence("update", "dataset", "ds", exists, absenceCertain) + require.Error(t, gateErr) + require.Contains(t, gateErr.Error(), `dataset "ds" does not exist`) + require.NoError(t, checkAssetExistence("create", "dataset", "ds", exists, absenceCertain), + "create is exactly what a proven-absent name should allow") +} + +// A read that failed proves nothing. Answering "not there" let `create` publish +// a further version of a dataset that already existed -- the one thing +// separating create from update, decided by an error nobody looked at. +func TestPresenceReportsAListingThatFailedRatherThanGuessing(t *testing.T) { + for _, status := range []int{ + http.StatusForbidden, + http.StatusUnauthorized, + http.StatusTooManyRequests, + http.StatusInternalServerError, + } { + t.Run(http.StatusText(status), func(t *testing.T) { + client, _ := newPresenceClient(t, status, `{"error":{"code":"Nope"}}`, nil) + + exists, absenceCertain, err := datasetPresence(t.Context(), client, "ds") + + require.Error(t, err, "a failed listing is not an answer about existence") + require.False(t, exists) + require.False(t, absenceCertain) + require.Contains(t, err.Error(), "ds") + }) + } +} + +// The same rule on the point read: only a 404 means "not this version". +func TestPresenceReportsAPointReadThatFailedRatherThanGuessing(t *testing.T) { + rec := &presenceServer{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.paths = append(rec.paths, r.URL.Path) + rec.mu.Unlock() + + if strings.HasSuffix(r.URL.Path, "/versions") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"value":[]}`)) + return + } + http.Error(w, `{"error":{"code":"Forbidden"}}`, http.StatusForbidden) + })) + t.Cleanup(srv.Close) + + client := dataset_api.NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + exists, absenceCertain, err := datasetPresence(t.Context(), client, "ds") + + require.Error(t, err) + require.False(t, exists) + require.False(t, absenceCertain) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset_version_probe_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset_version_probe_test.go new file mode 100644 index 00000000000..fb152cc0b57 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/dataset_version_probe_test.go @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaidataset/internal/pkg/dataset_api" + + "github.com/stretchr/testify/assert" +) + +// The existence probe exists for one case: `create` then immediately `update`, +// where the version listing has not caught up. It probed a hardcoded "1" while +// this CLI's first publish is NextVersion(""), which is "1.0" -- so for the +// case it was written for it read a version that never existed and the fallback +// was inert. Deriving it keeps the two in step if the base ever moves. +func TestFirstDatasetVersionsCoverWhatThisCLIPublishes(t *testing.T) { + assert.Contains(t, firstDatasetVersions, dataset_api.NextVersion(""), + "the probe has to look for the version a create actually writes") + assert.Contains(t, firstDatasetVersions, "1", + "a generation job, the SDK or the portal can register a plain 1") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/debug.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/debug.go new file mode 100644 index 00000000000..44ab7081ef0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/debug.go @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "strconv" + "time" + + azcorelog "github.com/Azure/azure-sdk-for-go/sdk/azcore/log" + "github.com/spf13/pflag" +) + +// setupDebugLogging silences the standard logger unless debug mode is on. +// +// The data-plane clients trace every request through log.Printf, which Go +// writes to stderr by default. Without this the CLI interleaves raw HTTP traces +// with its own output on every command. +// +// The returned function puts the logger back and closes the file. Callers run +// for the length of the process and let the OS close it, so it is returned for +// tests and for any caller that wants to stop logging early. +func setupDebugLogging(flags *pflag.FlagSet) func() { + if !isDebug(flags) { + log.SetOutput(io.Discard) + azcorelog.SetListener(nil) + return func() {} + } + + // Written outside the working directory: that is the user's repository, the + // scaffolded .gitignore does not cover this name, and a routine `git add -A` + // committed one. + // + // The name is picked by CreateTemp rather than built from the date alone. + // The temp directory is shared on Linux, and at a predictable path another + // user can leave a file of their own -- readable, to collect HTTP traces + // that carry request headers, or a symbolic link, to have them written to a + // file of their choosing. CreateTemp finds an unused name and creates it + // 0600 in one step, so neither is reachable. The date stays in the name + // because it is what makes a directory of these readable, and the full path + // is echoed below. + logFile, err := os.CreateTemp("", fmt.Sprintf("azd-ai-dataset-%s-*.log", time.Now().Format("2006-01-02"))) + + var w io.Writer + var closeFile func() + if err != nil { + w = os.Stderr + closeFile = func() {} + } else { + w = logFile + closeFile = func() { _ = logFile.Close() } + // A log nobody can find is not a log. Debugging was asked for + // explicitly, so naming the file costs nothing. + fmt.Fprintf(os.Stderr, "Debug log: %s\n", filepath.ToSlash(logFile.Name())) + } + + log.SetOutput(w) + azcorelog.SetListener(func(event azcorelog.Event, msg string) { + fmt.Fprintf(w, "[%s] %s: %s\n", time.Now().Format(time.RFC3339), event, msg) + }) + + return func() { + log.SetOutput(io.Discard) + azcorelog.SetListener(nil) + closeFile() + } +} + +// isDebug reports whether --debug or AZD_EXT_DEBUG is set. +func isDebug(flags *pflag.FlagSet) bool { + if debugFlag, err := flags.GetBool("debug"); err == nil && debugFlag { + return true + } + debug, _ := strconv.ParseBool(os.Getenv("AZD_EXT_DEBUG")) + return debug +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/helpers_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/helpers_test.go new file mode 100644 index 00000000000..65f1d9b6817 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/helpers_test.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The one difference between create and update, and the only thing stopping a +// create from silently publishing version 2 of someone else's dataset. +func TestCheckAssetExistence(t *testing.T) { + assert.NoError(t, checkAssetExistence("create", "dataset", "x", false, true)) + assert.NoError(t, checkAssetExistence("update", "dataset", "x", true, false)) + + err := checkAssetExistence("create", "dataset", "x", true, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "update", "the error has to name the verb that works") + + err = checkAssetExistence("update", "dataset", "x", false, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "create") +} + +// The deadlock this gate used to create: an update issued moments after a +// create reads an empty listing, and refusing there sends the caller to +// `create`, which fails in turn once the listing catches up. An absence the +// service never confirmed has to publish instead. +func TestCheckAssetExistenceLetsAnUnprovableAbsenceThrough(t *testing.T) { + assert.NoError(t, checkAssetExistence("update", "dataset", "x", false, false)) +} + +// A mistyped --from-file is the common way to get here, and the syscall that +// discovered it says nothing to the person who mistyped it. +func TestDatasetUploadSourceOnAMissingPath(t *testing.T) { + _, err := datasetUploadSource(filepath.Join(t.TempDir(), "nope.jsonl")) + require.Error(t, err) + + assert.Contains(t, err.Error(), "does not exist") + assert.NotContains(t, err.Error(), "GetFileAttributesEx") + assert.NotContains(t, err.Error(), "stat ") +} + +// Pointing at one dataset in a folder holding several must upload that one. +func TestDatasetUploadSourceKeepsTheNamedFile(t *testing.T) { + dir := t.TempDir() + chosen := filepath.Join(dir, "zebra.jsonl") + require.NoError(t, os.WriteFile(chosen, []byte("{\"pick\":\"me\"}\n"), 0o600)) + // Sorts first, so a directory scan would take it instead. + require.NoError(t, os.WriteFile( + filepath.Join(dir, "alpha.jsonl"), []byte("{\"pick\":\"not me\"}\n"), 0o600)) + + resolved, err := datasetUploadSource(chosen) + require.NoError(t, err) + assert.Equal(t, chosen, resolved) + + // The directory on its own is a guess between the two, so it is refused + // rather than resolved to whichever sorts first. + _, err = datasetUploadSource(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "zebra.jsonl") + assert.Contains(t, err.Error(), "alpha.jsonl") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/manifest_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/manifest_test.go new file mode 100644 index 00000000000..e569f3bbf17 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/manifest_test.go @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The build stamps the binary from version.txt while the registry publishes +// what extension.yaml says, so a drift ships a binary that misreports its own +// version. Bumping one and forgetting the other is the easy mistake, and it +// happened here: extension.yaml went to beta.4 while version.txt stayed on +// beta.3. The sibling eval extension already had this check, which is how the +// drift was noticed there; this extension had nothing and stayed quiet. +// +// The version line is read rather than parsed as YAML: this module has no +// direct YAML dependency, and a test is a poor reason to add one. +func TestManifestVersionMatchesVersionFile(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "extension.yaml")) + require.NoError(t, err, "reading extension.yaml") + + var declared string + for line := range strings.SplitSeq(string(raw), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimRight(line, "\r"), "version:"); ok { + declared = strings.TrimSpace(rest) + break + } + } + require.NotEmpty(t, declared, "extension.yaml must declare a version") + + stamped, err := os.ReadFile(filepath.Join("..", "..", "version.txt")) + require.NoError(t, err, "reading version.txt") + + require.Equal(t, strings.TrimSpace(string(stamped)), declared, + "version.txt and extension.yaml must agree") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/names.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/names.go new file mode 100644 index 00000000000..38fc0cd5796 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/names.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "regexp" + +// assetNamePattern is what the service accepts for a dataset name. Its own +// refusal is a 400 carrying four levels of nested JSON, and the sentence that +// matters is at the bottom of it. +var assetNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +const assetNameMaxLength = 255 + +// validAssetName reports whether the service will accept this name, so a +// mistyped one is refused before a round trip rather than after. +func validAssetName(name string) bool { + if name == "" || len(name) > assetNameMaxLength { + return false + } + return assetNamePattern.MatchString(name) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/names_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/names_test.go new file mode 100644 index 00000000000..5aac5a7c2c2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/names_test.go @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// The service's own refusal is a 400 carrying four levels of nested JSON, and +// the sentence that matters sits at the bottom of it. Worse, the upload has +// already happened by then. +func TestValidAssetName(t *testing.T) { + cases := []struct { + name string + want bool + why string + }{ + {"support-golden", true, "dashes are allowed"}, + {"support_golden", true, "underscores are allowed"}, + {"golden123", true, "digits are allowed"}, + {"a", true, "one character is enough"}, + {"bugbash space 035200", false, "spaces are what a developer types first"}, + {"golden.jsonl", false, "dots read like a filename but are refused"}, + {"golden/v2", false, "a slash would change which resource is addressed"}, + {"golden%20", false, "an escape sequence typed by hand is not a name"}, + {"", false, "an empty name addresses the collection, not a dataset"}, + {"caf\u00e9-golden", false, "the service is alphanumeric ASCII only"}, + {strings.Repeat("a", 255), true, "255 is the documented limit"}, + {strings.Repeat("a", 256), false, "256 is over it"}, + } + + for _, c := range cases { + assert.Equalf(t, c.want, validAssetName(c.name), "%s: %s", c.name, c.why) + } +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/output.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/output.go new file mode 100644 index 00000000000..82f8ffcc84c --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/output.go @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + + "azureaidataset/internal/messages" + + "github.com/spf13/cobra" +) + +const outputJSON = "json" + +// outputFormat reads the inherited -o/--output flag. +func outputFormat(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + v, err := cmd.Flags().GetString("output") + if err != nil { + return "" + } + return strings.ToLower(v) +} + +// isJSON reports whether the command should emit machine-readable output. +func isJSON(cmd *cobra.Command) bool { + return outputFormat(cmd) == outputJSON +} + +// emitJSON writes v as indented JSON. +func emitJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// emitJSONList writes items as a JSON array. +// +// List commands emit a bare array rather than the envelope the service replied +// with. The envelopes disagree with each other — the OpenAI-shaped APIs wrap +// results in `data`, the ARM-shaped ones in `value` — so passing them through +// would make a caller's parsing depend on which service happens to back a given +// command. They also carry paging fields that this extension does not follow, +// which would suggest there is more to fetch when there is not. +// +// A nil slice encodes as `null`, so it is normalized to an empty array: a +// caller iterating the result should see no elements, not a type error. +func emitJSONList[T any](w io.Writer, items []T) error { + if items == nil { + items = []T{} + } + return emitJSON(w, items) +} + +// emitTable writes a list view: uppercase headers over a rule, tab-aligned. +// +// The rule is what separates the header from the data at a glance, and it is +// what `azure.ai.skills` prints, so a reader moving between the Foundry +// extensions sees one table. +func emitTable(w io.Writer, headers []string, rows [][]string) error { + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + if _, err := fmt.Fprintln(tw, strings.Join(headers, "\t")); err != nil { + return err + } + rule := make([]string, len(headers)) + for i, h := range headers { + rule[i] = strings.Repeat("-", len(h)) + } + if _, err := fmt.Fprintln(tw, strings.Join(rule, "\t")); err != nil { + return err + } + for _, row := range rows { + if _, err := fmt.Fprintln(tw, strings.Join(row, "\t")); err != nil { + return err + } + } + return tw.Flush() +} + +// field is one row of a detail view. +type field struct { + Key string // Title Case, per the azd style guide + Value string +} + +// emitDetail writes a two-column key/value view, the shape `show` uses. +// +// Empty values are dropped rather than printed blank: a detail view is read to +// learn what a thing is, and a column of empty keys says only that the writer +// did not know which fields this kind has. +func emitDetail(w io.Writer, fields []field) error { + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + for _, f := range fields { + if f.Value == "" { + continue + } + if _, err := fmt.Fprintf(tw, "%s\t%s\n", f.Key, f.Value); err != nil { + return err + } + } + return tw.Flush() +} + +// requireFlag returns an error naming a flag the command needs and has no way +// to settle for itself. +func requireFlag(name string) error { + return messages.FlagRequired(name) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/output_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/output_test.go new file mode 100644 index 00000000000..0a9ab475681 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/output_test.go @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "azureaidataset/internal/messages" + "azureaidataset/internal/pkg/dataset_api" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// commandWithOutput builds a command carrying the -o flag the azd SDK root +// supplies at runtime, on the same flag set the production code reads. +func commandWithOutput(t *testing.T, value string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "x"} + cmd.Flags().StringP("output", "o", "", "") + require.NoError(t, cmd.Flags().Set("output", value)) + cmd.SetOut(&bytes.Buffer{}) + return cmd +} + +// The flag selects machine-readable output, and a caller who types JSON in +// caps means the same thing as one who does not. +func TestOutputFormatAndIsJSON(t *testing.T) { + assert.True(t, isJSON(commandWithOutput(t, "json"))) + assert.True(t, isJSON(commandWithOutput(t, "JSON")), "the format is matched without regard to case") + assert.False(t, isJSON(commandWithOutput(t, "table"))) + assert.False(t, isJSON(commandWithOutput(t, ""))) + + assert.False(t, isJSON(nil), "a command with no flags is not JSON output") + assert.Empty(t, outputFormat(nil)) + + // A command that never declared -o must not panic on being asked. + assert.Empty(t, outputFormat(&cobra.Command{Use: "bare"})) +} + +// A nil slice encodes as null, which a caller iterating the result reads as a +// type error rather than as an empty list. +func TestEmitJSONListNormalizesNil(t *testing.T) { + var buf bytes.Buffer + var none []dataset_api.Dataset + require.NoError(t, emitJSONList(&buf, none)) + assert.Equal(t, "[]", strings.TrimSpace(buf.String())) + + buf.Reset() + require.NoError(t, emitJSONList(&buf, []dataset_api.Dataset{{Name: "a", Version: "1.0"}})) + var round []map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &round)) + require.Len(t, round, 1) + assert.Equal(t, "a", round[0]["name"]) +} + +// The list emits a bare array, not the envelope the service replied with: the +// envelopes disagree with each other and carry paging this extension does not +// follow. +func TestEmitJSONListDropsTheEnvelope(t *testing.T) { + cmd := commandWithOutput(t, "json") + var buf bytes.Buffer + cmd.SetOut(&buf) + + require.NoError(t, renderDatasets(cmd, &dataset_api.DatasetList{ + Value: []dataset_api.Dataset{{Name: "a", Version: "1.0"}}, + NextLink: "https://example/page2", + }, messages.NoDatasets())) + + assert.True(t, strings.HasPrefix(strings.TrimSpace(buf.String()), "["), + "a list answers with an array") + assert.NotContains(t, buf.String(), "nextLink", + "paging this extension does not follow must not suggest there is more to fetch") +} + +// A list view is uppercase headers over a rule. The rule is what separates the +// header from the data at a glance, and it is what the sibling extensions print. +func TestRenderDatasetsTable(t *testing.T) { + cmd := commandWithOutput(t, "") + var buf bytes.Buffer + cmd.SetOut(&buf) + + require.NoError(t, renderDatasets(cmd, &dataset_api.DatasetList{Value: []dataset_api.Dataset{ + {Name: "golden", Version: "2.0", Type: "uri_file"}, + {Name: "smoke", Version: "1.0", Type: "uri_file"}, + }}, messages.NoDatasets())) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.Len(t, lines, 4, "a header, its rule, and one line per dataset") + assert.Contains(t, lines[0], "NAME") + assert.Contains(t, lines[0], "VERSION") + // The API accepts format on upload and never returns it, so a FORMAT column + // was blank for every dataset. Type is what the service actually sends. + assert.Contains(t, lines[0], "TYPE") + assert.True(t, strings.HasPrefix(strings.TrimSpace(lines[1]), "----"), + "the rule under the header is the convention, got %q", lines[1]) + assert.Contains(t, lines[2], "golden") + assert.Contains(t, lines[2], "uri_file") + assert.Contains(t, lines[3], "smoke") +} + +// An empty project has to say so. A bare header over nothing reads as output +// that got cut off. +func TestRenderDatasetsSaysWhenThereAreNone(t *testing.T) { + cmd := commandWithOutput(t, "") + var buf bytes.Buffer + cmd.SetOut(&buf) + + require.NoError(t, renderDatasets(cmd, &dataset_api.DatasetList{}, messages.NoDatasets())) + assert.Contains(t, buf.String(), "No datasets found.") + assert.NotContains(t, buf.String(), "NAME") +} + +// Listing one name's versions and listing every dataset share a renderer but +// not a question. "No datasets found." in a project holding a dozen datasets +// reads as though the lookup failed rather than as an answer about that name. +func TestRenderDatasetsSaysWhichNameHasNoVersions(t *testing.T) { + cmd := commandWithOutput(t, "") + var buf bytes.Buffer + cmd.SetOut(&buf) + + require.NoError(t, renderDatasets(cmd, &dataset_api.DatasetList{}, + messages.NoDatasetVersions("golden"))) + + assert.Contains(t, buf.String(), `No versions of dataset "golden"`) + assert.NotContains(t, buf.String(), "No datasets found.") + assert.NotContains(t, buf.String(), "NAME") +} + +// The empty result is still a success with an empty array: a delete is checked +// for idempotence by listing what is left, and `-o json` callers range over it. +func TestVersionsListEmptyIsStillAnEmptyJSONArray(t *testing.T) { + cmd := commandWithOutput(t, "json") + var buf bytes.Buffer + cmd.SetOut(&buf) + + require.NoError(t, renderDatasets(cmd, &dataset_api.DatasetList{}, + messages.NoDatasetVersions("golden"))) + + assert.Equal(t, "[]", strings.TrimSpace(buf.String()), + "the sentence is for a reader; a parser still gets an array") +} + +// A detail view is Title Case key/value, the shape `show` uses, and a blank +// value is dropped rather than printed as an empty column. +func TestEmitDetail(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, emitDetail(&buf, []field{ + {"Name", "golden"}, + {"Version", "2.0"}, + {"Description", ""}, + {"Format", "jsonl"}, + })) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.Len(t, lines, 3, "the empty Description is dropped") + assert.True(t, strings.HasPrefix(lines[0], "Name")) + assert.Contains(t, lines[0], "golden") + assert.NotContains(t, buf.String(), "Description") +} + +// `show` returns one thing, so the spec's output conventions make it a detail +// view rather than the raw JSON it would otherwise be easiest to print. +func TestShowUsesADetailView(t *testing.T) { + body, err := os.ReadFile(filepath.Join(".", "dataset.go")) + require.NoError(t, err) + assert.Contains(t, string(body), "emitDetail", + "dataset show returns one thing, so dataset.go renders it as a detail view") +} + +// The message has to name the flag that would have supplied the value, and +// nothing else: none of these commands prompts, so blaming --no-prompt named a +// flag the caller had not passed and implied that dropping it would make the +// command ask. +func TestRequireFlag(t *testing.T) { + err := requireFlag("name") + require.Error(t, err) + assert.Contains(t, err.Error(), "--name") + assert.NotContains(t, err.Error(), "--no-prompt") +} + +// --from-file takes either the file or the directory holding it, because both +// are what a caller has to hand. Anything else is worth refusing by name. +func TestDatasetUploadSource(t *testing.T) { + dir := t.TempDir() + rows := filepath.Join(dir, "rows.jsonl") + require.NoError(t, os.WriteFile(rows, []byte("{\"query\":\"q\"}\n"), 0o600)) + + got, err := datasetUploadSource(rows) + require.NoError(t, err) + assert.Equal(t, rows, got, + "a named file is uploaded, not whichever .jsonl its directory sorts first") + + got, err = datasetUploadSource(dir) + require.NoError(t, err) + assert.Equal(t, rows, got, + "a directory resolves to the one .jsonl in it, named rather than scanned later") + + notJSONL := filepath.Join(dir, "rows.csv") + require.NoError(t, os.WriteFile(notJSONL, []byte("a,b\n"), 0o600)) + _, err = datasetUploadSource(notJSONL) + require.Error(t, err) + assert.Contains(t, err.Error(), ".jsonl") + + _, err = datasetUploadSource(filepath.Join(dir, "missing.jsonl")) + require.Error(t, err) + assert.Contains(t, err.Error(), "--from-file") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/root.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/root.go new file mode 100644 index 00000000000..73888d9ebc8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/root.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "azureaidataset/internal/foundry/projectctx" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// NewRootCommand builds the `azd ai dataset` command tree. +func NewRootCommand() *cobra.Command { + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "dataset", + Use: "dataset [options]", + Short: fmt.Sprintf( + "Register and version Foundry datasets from your terminal. %s", + color.YellowString("(Beta)"), + ), + }) + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions.DisableDefaultCmd = true + + // The data-plane clients trace requests through the standard logger, which + // Go writes to stderr, so it has to be silenced unless debug was asked for. + // The SDK's own hook is chained rather than replaced, and cobra ignores + // PersistentPreRun entirely once PersistentPreRunE is set. + sdkPreRun := rootCmd.PersistentPreRunE + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if sdkPreRun != nil { + if err := sdkPreRun(cmd, args); err != nil { + return err + } + } + // -e/--environment is parsed by the SDK into extCtx and then has to be + // acted on. Discarding extCtx left the flag accepted and ignored: + // `azd ai dataset create -e staging` read the endpoint out of the + // default environment and wrote its version back there, and even a name + // azd itself rejects was accepted in silence. Set here rather than at + // each reader, so there is one answer to which environment this + // invocation is about. + cmd.SetContext(projectctx.WithSelectedEnvironment(cmd.Context(), extCtx.Environment)) + if err := projectctx.VerifySelectedEnvironment(cmd.Context()); err != nil { + return err + } + setupDebugLogging(cmd.Flags()) + return nil + } + + // Generation stays with `azure.ai.evaluations`: it writes the `datasets:` + // entry in evals/eval.yaml, which is that extension's file. + rootCmd.AddCommand( + newDatasetCreateCommand(), + newDatasetUpdateCommand(), + newDatasetListCommand(), + newDatasetShowCommand(), + newDatasetDeleteCommand(), + newDatasetVersionsCommand(), + ) + + // The manifest declares the `metadata` capability, which azd uses to + // discover this extension's command tree. Without the command registered, + // that discovery fails with "unknown command". + rootCmd.AddCommand(azdext.NewMetadataCommand("1.0", "azure.ai.dataset", func() *cobra.Command { + return rootCmd + })) + + return rootCmd +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/surface_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/surface_test.go new file mode 100644 index 00000000000..79e634dd382 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/surface_test.go @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// walk visits every command in the tree, skipping the ones azd contributes. +func walk(t *testing.T, cmd *cobra.Command, path []string, visit func(string, *cobra.Command)) { + t.Helper() + for _, child := range cmd.Commands() { + name := strings.Fields(child.Use)[0] + switch name { + case "help", "completion", "listen", "metadata": + continue + } + full := append(append([]string{}, path...), name) + visit(strings.Join(full, " "), child) + walk(t, child, full, visit) + } +} + +// The command tree is the spec's `azd ai dataset` table. The CRUD groups moved +// here from azure.ai.evaluations; `generate` deliberately did not, because it +// writes the `datasets:` entry in evals/eval.yaml, which that extension owns. +func TestCommandTreeMatchesTheSpec(t *testing.T) { + want := []string{ + "create", + "delete", + "list", + "show", + "update", + "versions", + "versions list", + } + + var got []string + walk(t, NewRootCommand(), nil, func(path string, _ *cobra.Command) { + got = append(got, path) + }) + + assert.ElementsMatch(t, want, got, + "the command tree changed; update the spec's command table with it") +} + +// Flag names are shared vocabulary across the Foundry extensions. A command +// that invents its own spelling for something the others already name is the +// kind of difference nobody notices until a user types the one they learned +// somewhere else. +func TestFlagVocabularyIsShared(t *testing.T) { + forbidden := map[string]string{ + "--out-file": "--output-file", + "--out-dir": "--output-dir", + "--file": "--from-file", + "--out": "--output-file", + "--dir": "--output-dir", + } + + walk(t, NewRootCommand(), nil, func(path string, cmd *cobra.Command) { + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if want, bad := forbidden["--"+f.Name]; bad { + t.Errorf("%s declares --%s; use %s", path, f.Name, want) + } + }) + }) +} + +// `-o json` and `--no-prompt` come from the azd extension SDK's root command, +// so every command inherits them — until one declares a flag by the same name, +// which silently shadows the global. +func TestNoCommandShadowsAGlobalFlag(t *testing.T) { + global := []string{"output", "no-prompt", "environment", "cwd", "debug"} + + walk(t, NewRootCommand(), nil, func(path string, cmd *cobra.Command) { + for _, name := range global { + assert.Nilf(t, cmd.LocalFlags().Lookup(name), + "%s declares its own --%s, which shadows the global one", path, name) + } + }) +} + +// Every command here reaches the service, so the shared Foundry resolver has to +// be reachable from all of them. +func TestServiceCommandsTakeProjectEndpoint(t *testing.T) { + walk(t, NewRootCommand(), nil, func(path string, cmd *cobra.Command) { + if cmd.RunE == nil { + return + } + assert.NotNil(t, cmd.Flags().Lookup("project-endpoint"), + "%s reaches the service, so it must accept --project-endpoint", path) + }) +} + +// Generation stays with `azure.ai.evaluations`, so nothing here may grow a +// `--from`: a second generate would be a second place for the catalog write to +// go missing. +func TestNoGenerationCommandLandsHere(t *testing.T) { + walk(t, NewRootCommand(), nil, func(path string, cmd *cobra.Command) { + assert.Nilf(t, cmd.Flags().Lookup("from"), + "%s offers --from; generation belongs to azure.ai.evaluations", path) + assert.NotEqualf(t, "generate", cmd.Name(), + "%s is a generation command; it belongs to azure.ai.evaluations", path) + }) +} + +// Messages that tell a user what to run next have to name a command that +// exists. +// +// In the extension these commands moved from, three suggestions pointed at +// `azd ai dataset ...` while that namespace was served by nobody, and the +// check there matched on the wrong prefix so none of them failed. This +// extension's namespace is `ai.dataset`; anything it suggests under +// `azd ai dataset` has to resolve here, and a suggestion under another +// namespace is one it cannot make. +func TestSuggestedCommandsExist(t *testing.T) { + pattern := regexp.MustCompile("azd ai ([a-z][a-z0-9-]*(?: [a-z][a-z0-9-]*)*)") + + err := filepath.WalkDir("../..", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + body, err := os.ReadFile(path) //nolint:gosec // walking this package's own source + if err != nil { + return err + } + for line := range strings.SplitSeq(string(body), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + for _, m := range pattern.FindAllStringSubmatch(line, -1) { + words := strings.Fields(m[1]) + if len(words) == 0 { + continue + } + // A suggestion pointing at a sibling extension is that + // extension's contract, not this one's, and cannot be resolved + // from here. Listed rather than wildcarded so a typo in a + // namespace still fails. + if siblingNamespaces[words[0]] { + continue + } + assert.Equalf(t, "dataset", words[0], + "%s suggests `azd ai %s`, which is neither this extension's "+ + "namespace nor a sibling it knows about", path, m[1]) + words = words[1:] + + // Trim trailing prose: "job show" is a command, "job show and + // then" is a sentence that begins with one. + for len(words) > 0 { + if resolved, _, e := NewRootCommand().Find(words); e == nil { + if strings.Fields(resolved.Use)[0] == words[len(words)-1] { + break + } + } + words = words[:len(words)-1] + } + assert.NotEmptyf(t, words, + "%s suggests `azd ai %s`, which is not a command", path, m[1]) + } + } + return nil + }) + require.NoError(t, err) +} + +// A suggestion is only as good as its flags. TestSuggestedCommandsExist stops +// at the command, so `azd ai dataset create --file ` passed it +// while `--file` did not exist: the real flag is `--from-file`, and a reader +// following the advice got "unknown flag". Checking the command without its +// flags checks the easy half. +func TestSuggestedFlagsExist(t *testing.T) { + // A suggestion is inside backticks, so the flags belonging to it end where + // the quoting does and prose afterwards is not mistaken for one. + quoted := regexp.MustCompile("`azd ai ([^`]*)`") + + err := filepath.WalkDir("../..", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + body, err := os.ReadFile(path) //nolint:gosec // walking this package's own source + if err != nil { + return err + } + for line := range strings.SplitSeq(string(body), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + for _, m := range quoted.FindAllStringSubmatch(line, -1) { + fields := strings.Fields(m[1]) + if len(fields) == 0 || fields[0] != "dataset" { + continue // a sibling's contract, checked by the test above + } + + // The command is the leading run of plain words; a placeholder + // like or a %q verb ends it. + var words, flags []string + for _, f := range fields[1:] { + switch { + case strings.HasPrefix(f, "--"): + flags = append(flags, strings.TrimPrefix(strings.SplitN(f, "=", 2)[0], "--")) + case len(flags) == 0 && regexp.MustCompile(`^[a-z][a-z0-9-]*$`).MatchString(f): + words = append(words, f) + } + } + if len(flags) == 0 { + continue + } + + // NewRootCommand() is the `dataset` command itself, so the + // namespace word is not part of the path to look up. + resolved, _, e := NewRootCommand().Find(words) + if !assert.NoErrorf(t, e, "%s suggests `azd ai %s`, which is not a command", path, m[1]) { + continue + } + for _, name := range flags { + assert.NotNilf(t, resolved.Flags().Lookup(name), + "%s suggests `azd ai %s`, but `%s` has no --%s flag", + path, m[1], resolved.CommandPath(), name) + } + } + } + return nil + }) + require.NoError(t, err) +} + +// A message pointing at `azd ai eval dataset ...` is almost always the copy// these commands came from rather than a deliberate cross-extension pointer. +// `dataset` is this extension's own namespace, so telling a user to run the +// eval extension's version of a command it serves itself sends them somewhere +// they may not have installed. +// +// Nothing here may suggest one any more: `generate` was the only command with a +// reason to, and it stayed with azure.ai.evaluations. +func TestNoStaleEvalDatasetSuggestions(t *testing.T) { + allowed := map[string]bool{} + + err := filepath.WalkDir("../..", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + body, err := os.ReadFile(path) //nolint:gosec // walking this package's own source + if err != nil { + return err + } + pattern := regexp.MustCompile(`azd ai eval dataset [a-z][a-z0-9-]*`) + for i, line := range strings.Split(string(body), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + for _, m := range pattern.FindAllString(line, -1) { + assert.Truef(t, allowed[m], + "%s:%d suggests `%s`; this extension serves that command as "+ + "`azd ai dataset ...`", path, i+1, m) + } + } + return nil + }) + require.NoError(t, err) +} + +// siblingNamespaces are the other Foundry extensions this one points users at. +var siblingNamespaces = map[string]bool{ + "project": true, // `azd ai project set` owns the shared endpoint context +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/cmd/table_test.go b/cli/azd/extensions/azure.ai.dataset/internal/cmd/table_test.go new file mode 100644 index 00000000000..92b18ee230a --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/cmd/table_test.go @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A list view is uppercase headers over a rule, per the spec's output +// conventions and the sibling extension it cites. The rule is what separates +// the header from the data at a glance, and every `list` command was printing +// the header straight onto the first row. +func TestEmitTableWritesTheRule(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, emitTable(&buf, + []string{"NAME", "VERSION"}, + [][]string{{"support-regression", "3"}, {"nightly", "1"}})) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.Len(t, lines, 4, "a header, its rule, and one line per row") + + assert.Contains(t, lines[0], "NAME") + assert.Contains(t, lines[0], "VERSION") + + // Dashes as wide as the header they sit under, which is what makes the + // rule line up once tabwriter has padded the columns. + assert.Contains(t, lines[1], strings.Repeat("-", len("NAME"))) + assert.Contains(t, lines[1], strings.Repeat("-", len("VERSION"))) + assert.Empty(t, strings.Trim(lines[1], "- "), + "the rule carries nothing but dashes and padding") + + assert.Contains(t, lines[2], "support-regression") + assert.Contains(t, lines[3], "nightly") +} + +// The columns line up: the rule is padded to the same widths as the header, so +// a wide value in the first row does not leave the rule short. +func TestEmitTableRuleAlignsWithTheHeader(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, emitTable(&buf, + []string{"NAME", "STATUS"}, + [][]string{{"a-very-much-longer-value-than-the-header", "completed"}})) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + require.Len(t, lines, 3) + + // tabwriter pads every line in a column to the same width, so the header + // and its rule start their second column at the same offset. + assert.Equal(t, + strings.Index(lines[0], "STATUS"), + strings.Index(lines[1], "------"), + "the rule has to sit under the header it belongs to") +} + +// A listing with nothing in it still prints the header and rule: a caller +// seeing no output cannot tell an empty list from a command that failed to +// render. +func TestEmitTableWithNoRows(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, emitTable(&buf, []string{"NAME", "VERSION"}, nil)) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + assert.Len(t, lines, 2) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.dataset/internal/exterrors/codes.go new file mode 100644 index 00000000000..82eeeb06311 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/exterrors/codes.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +// The codes azd renders alongside an error's category and suggestion. +// +// Only the ones this extension actually raises are listed. This file used to +// carry the toolbox and skill vocabulary it was copied from -- 34 codes and 9 +// operation names for resources this extension has no concept of -- which +// offered anyone looking for the right code a menu belonging to a different +// product. + +// Error codes for user cancellation. +const ( + CodeCancelled = "cancelled" +) + +// Error codes for validation failures (user input, manifests, flags). +const ( + CodeInvalidParameter = "invalid_parameter" +) + +// Error codes for dependency failures (missing resources, services, env values). +const ( + CodeMissingProjectEndpoint = "missing_project_endpoint" +) + +// Error codes for auth failures. +const ( + CodeLoginExpired = "login_expired" + CodeAuthFailed = "auth_failed" +) diff --git a/cli/azd/extensions/azure.ai.dataset/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.dataset/internal/exterrors/errors.go new file mode 100644 index 00000000000..a89390b20cf --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/exterrors/errors.go @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package exterrors provides structured error helpers for the azure.ai.dataset +// extension. +// +// Use plain Go errors until the current code can confidently choose a final +// category, code, and suggestion. At that point, create a structured error with +// one of the helpers in this package or with [ServiceFromAzure] for Azure SDK +// failures. +// +// Once an error is structured, usually return it unchanged. Avoid wrapping a +// structured error with [fmt.Errorf] and %w for extra context: azd serializes +// the structured error's own message and metadata, not the outer wrapper text. +package exterrors + +import ( + "context" + "errors" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// --------------------------------------------------------------------------- +// Structured error factories +// --------------------------------------------------------------------------- + +// Validation returns a validation [azdext.LocalError] for user input / flag errors. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a dependency [azdext.LocalError] for missing resources or services. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +} + +// Auth returns an auth [azdext.LocalError] for authentication/authorization failures. +func Auth(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryAuth, + Suggestion: suggestion, + } +} + +// User returns a user-action [azdext.LocalError] (e.g. cancellation). No suggestion. +func User(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryUser, + } +} + +// Internal returns an internal [azdext.LocalError] for unexpected extension failures. +func Internal(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryInternal, + } +} + +// Cancelled returns a user cancellation error. +func Cancelled(message string) error { + return User(CodeCancelled, message) +} + +// --------------------------------------------------------------------------- +// Azure error converters +// --------------------------------------------------------------------------- + +// ServiceFromAzure wraps an [azcore.ResponseError] into an [azdext.ServiceError] +// with operation context. If the error is not an azcore.ResponseError, it +// returns a generic internal [azdext.LocalError]. +func ServiceFromAzure(err error, operation string) error { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + serviceName := "" + if respErr.RawResponse != nil && respErr.RawResponse.Request != nil { + serviceName = respErr.RawResponse.Request.Host + } + code := respErr.ErrorCode + if code == "" { + code = fmt.Sprintf("%d", respErr.StatusCode) + } + return &azdext.ServiceError{ + Message: fmt.Sprintf("%s: %s", operation, respErr.Error()), + ErrorCode: fmt.Sprintf("%s.%s", operation, code), + StatusCode: respErr.StatusCode, + ServiceName: serviceName, + } + } + if IsCancellation(err) { + return Cancelled(fmt.Sprintf("%s was cancelled", operation)) + } + return Internal(operation, fmt.Sprintf("%s: %s", operation, err.Error())) +} + +// FromPrompt wraps a gRPC error from an azd host Prompt call into a structured +// error. Auth errors (Unauthenticated) are classified as Auth errors with a +// re-auth suggestion; cancellations as User cancellations; other errors are +// returned wrapped with the provided context message. +func FromPrompt(err error, contextMsg string) error { + if err == nil { + return nil + } + + if IsCancellation(err) { + return Cancelled(contextMsg) + } + + st, ok := status.FromError(err) + if ok && st.Code() == codes.Unauthenticated { + return Auth( + CodeAuthFailed, + fmt.Sprintf("%s: %s", contextMsg, st.Message()), + "run `azd auth login` to authenticate", + ) + } + + return fmt.Errorf("%s: %w", contextMsg, err) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// IsCancellation reports whether err represents user cancellation +// ([context.Canceled] or gRPC [codes.Canceled]). +func IsCancellation(err error) bool { + if errors.Is(err, context.Canceled) { + return true + } + if st, ok := status.FromError(err); ok && st.Code() == codes.Canceled { + return true + } + return false +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/env_source_test.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/env_source_test.go new file mode 100644 index 00000000000..1be5ee0deb2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/env_source_test.go @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectctx + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// fakeEnv answers the two reads with whatever the case under test needs. +type fakeEnv struct { + current *azdext.EnvironmentResponse + currentErr error + values map[string]string + valueErr map[string]error + asked []string +} + +func (f *fakeEnv) GetCurrent( + context.Context, *azdext.EmptyRequest, ...grpc.CallOption, +) (*azdext.EnvironmentResponse, error) { + return f.current, f.currentErr +} + +func (f *fakeEnv) GetValue( + _ context.Context, req *azdext.GetEnvRequest, _ ...grpc.CallOption, +) (*azdext.KeyValueResponse, error) { + f.asked = append(f.asked, req.Key) + if err, ok := f.valueErr[req.Key]; ok { + return nil, err + } + return &azdext.KeyValueResponse{Key: req.Key, Value: f.values[req.Key]}, nil +} + +func envNamed(name string) *azdext.EnvironmentResponse { + return &azdext.EnvironmentResponse{Environment: &azdext.Environment{Name: name}} +} + +// An answer of "nothing here" leaves the cascade free to carry on; a failure to +// answer has to stop it, because carrying on resolves to a lower-priority +// endpoint that can belong to a different project. +// +// This is the rule that has regressed twice while every test passed, because +// the only seam was the whole function. +func TestReadEnvHostedSource_TellsAbsenceApartFromFailure(t *testing.T) { + cases := []struct { + name string + env *fakeEnv + wantValue string + wantName string + wantErr string + }{ + { + name: "no environment selected", + env: &fakeEnv{currentErr: status.Error(codes.Unknown, + "default environment not found")}, + }, + { + name: "outside a project altogether", + env: &fakeEnv{currentErr: status.Error(codes.Unknown, + "no project exists; to create a new project, run `azd init`")}, + }, + { + name: "the environment named in config is gone", + env: &fakeEnv{currentErr: status.Error(codes.Unknown, "'dev': environment not found")}, + }, + { + name: "no daemon", + env: &fakeEnv{currentErr: status.Error(codes.Unavailable, "connection refused")}, + }, + { + name: "the login has expired", + env: &fakeEnv{currentErr: status.Error(codes.Unauthenticated, "expired")}, + wantErr: "expired", + }, + { + name: "the daemon broke while looking", + env: &fakeEnv{currentErr: status.Error(codes.Unknown, + "loading project state: permission denied")}, + wantErr: "loading project state", + }, + { + name: "the foundry key answers", + env: &fakeEnv{current: envNamed("dev"), values: map[string]string{foundryEnvKey: "https://a"}}, + wantValue: "https://a", + wantName: "dev", + }, + { + // The key `azd ai agent init` and `azd add` persist, read only + // when the newer one has nothing. + name: "the older key answers when the newer one is empty", + env: &fakeEnv{ + current: envNamed("dev"), + values: map[string]string{foundryEnvKey: "", azureAiEnvKey: "https://b"}, + }, + wantValue: "https://b", + wantName: "dev", + }, + { + name: "neither key is set", + env: &fakeEnv{current: envNamed("dev")}, + }, + { + // A key that is simply absent must not stop the second one being + // tried, nor the levels below. + name: "the first key is absent", + env: &fakeEnv{ + current: envNamed("dev"), + values: map[string]string{azureAiEnvKey: "https://b"}, + valueErr: map[string]error{foundryEnvKey: status.Error(codes.NotFound, "no such key")}, + }, + wantValue: "https://b", + wantName: "dev", + }, + { + name: "reading a key failed", + env: &fakeEnv{ + current: envNamed("dev"), + valueErr: map[string]error{foundryEnvKey: status.Error(codes.Internal, "boom")}, + }, + wantErr: "boom", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + value, name, err := readEnvHostedSource(context.Background(), tc.env) + + if tc.wantErr != "" { + require.Error(t, err, "a failure to answer must stop the cascade") + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err, "an answer of nothing must let the cascade carry on") + assert.Equal(t, tc.wantValue, value) + assert.Equal(t, tc.wantName, name) + }) + } +} + +// The newer key wins, so a project carrying both does not silently prefer the +// one an older command wrote. +func TestReadEnvHostedSource_PrefersTheNewerKey(t *testing.T) { + env := &fakeEnv{ + current: envNamed("dev"), + values: map[string]string{foundryEnvKey: "https://new", azureAiEnvKey: "https://old"}, + } + + value, _, err := readEnvHostedSource(context.Background(), env) + + require.NoError(t, err) + assert.Equal(t, "https://new", value) + assert.Equal(t, []string{foundryEnvKey}, env.asked, + "the older key is not even read once the newer one answers") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/hosted_absence_test.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/hosted_absence_test.go new file mode 100644 index 00000000000..7220ab1496f --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/hosted_absence_test.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectctx + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// azd answers "no default environment" and "no such environment" with plain Go +// errors. Its interceptor only rewrites errors carrying a suggestion or an auth +// failure, so everything else reaches the client as Unknown. Reading Unknown as +// a failure would stop a project with no environment selected from ever +// reaching the global config or the host variable. +func TestUnansweredHostedSourcesLetTheCascadeCarryOn(t *testing.T) { + for name, err := range map[string]error{ + "no daemon at all": status.Error(codes.Unavailable, "connection refused"), + "nothing under that key": status.Error(codes.NotFound, "key not found"), + "no default environment": status.Error(codes.Unknown, "default environment not found"), + "no such environment": status.Error(codes.Unknown, "'dev': environment not found"), + // The atomic commands are meant to work standalone against the data + // plane with FOUNDRY_PROJECT_ENDPOINT exported, so running outside a + // project has to reach the host variable rather than stop here. + "outside a project": status.Error(codes.Unknown, + "no project exists; to create a new project, run `azd init`"), + "wrapped in context": fmt.Errorf("reading the environment: %w", + status.Error(codes.Unknown, "default environment not found")), + } { + t.Run(name, func(t *testing.T) { + assert.True(t, hostedSourceAbsent(err), + "this is absence, so levels 3 and 4 still have to be consulted") + }) + } +} + +// A daemon that refused, or one that broke, is not a daemon with nothing to +// say. Falling through here would resolve to a lower-priority endpoint that can +// belong to a different project, and nothing would have said so. +func TestAFailureToAnswerIsReportedRatherThanSkipped(t *testing.T) { + for name, err := range map[string]error{ + "the login has expired": status.Error(codes.Unauthenticated, "the login has expired"), + "not allowed": status.Error(codes.PermissionDenied, "forbidden"), + "the user hit ctrl-c": status.Error(codes.Canceled, "context canceled"), + "the read timed out": status.Error(codes.DeadlineExceeded, "deadline exceeded"), + "the daemon broke": status.Error(codes.Internal, "internal error"), + "the daemon is full": status.Error(codes.ResourceExhausted, "quota exceeded"), + "the answer was corrupt": status.Error(codes.DataLoss, "data loss"), + // A bare error carries no status at all, so it never travelled the wire + // as an absence the daemon reported. + "not a status at all": errors.New("something local went wrong"), + // Unknown is not absence on its own. azd passes any error carrying no + // suggestion and no auth failure through untouched, so a failure to + // load project state or the environment manager arrives under the same + // code as "no default environment". + "project state would not load": status.Error(codes.Unknown, + "loading project state: open azure.yaml: permission denied"), + "the environment manager broke": status.Error(codes.Unknown, + "creating environment manager: no such host"), + // The message is the only evidence, so it is matched whole. A failure + // whose prose happens to mention one must not read as an absence. + "a failure that mentions an environment": status.Error(codes.Unknown, + "listing deployments: the environment not found in the subscription cache"), + // status.FromError flattens a wrapper's own prose into the message it + // reports, so a wrapper worded like an absence must not decide this. + "a failure wrapped in absence-sounding prose": fmt.Errorf( + "default environment not found in the cache: %w", + status.Error(codes.Unknown, "loading project state: permission denied")), + "wrapped expiry": fmt.Errorf("reading the environment: %w", + status.Error(codes.Unauthenticated, "expired")), + "nested twice over": fmt.Errorf("outer: %w", + fmt.Errorf("inner: %w", status.Error(codes.PermissionDenied, "no"))), + } { + t.Run(name, func(t *testing.T) { + assert.False(t, hostedSourceAbsent(err), + "a failure to answer has to surface, not resolve to a different project") + }) + } +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/resolver.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/resolver.go new file mode 100644 index 00000000000..99827dc9160 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/resolver.go @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectctx + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ReadAzdHostedSourcesFunc is a package-level seam so tests can stub the +// daemon-backed lookup without spinning up a real azd gRPC server. +var ReadAzdHostedSourcesFunc = readAzdHostedSources + +// readAzdHostedSources dials the azd daemon (if reachable) and reads both the +// active environment's project endpoint and the global-config project context +// in a single client lifetime. The active-env read prefers +// FOUNDRY_PROJECT_ENDPOINT and falls back to AZURE_AI_PROJECT_ENDPOINT (the key +// `azd ai agent init` / `azd add` persist). Errors talking to the daemon are +// returned only for non-Unavailable cases on the config read — Unavailable is +// treated as "no daemon" and the caller falls through to subsequent levels. +func readAzdHostedSources(ctx context.Context) (AzdHostedSources, error) { + var out AzdHostedSources + + azdClient, err := azdext.NewAzdClient() + if err != nil { + // No azd client at all => no hosted sources, not an error. + return out, nil + } + defer azdClient.Close() + + envValue, envName, envErr := readEnvHostedSource(ctx, azdClient.Environment()) + if envErr != nil { + return out, envErr + } + out.EnvValue, out.EnvName = envValue, envName + + state, found, cfgErr := getProjectContext(ctx, azdClient) + if cfgErr != nil { + // The same rule the environment reads use. Today the config service can + // only fail here by being unreachable, but stating it differently in + // one of three places is how the three come to disagree. + if !hostedSourceAbsent(cfgErr) { + return out, cfgErr + } + } else { + out.CfgState = state + out.CfgFound = found + } + + return out, nil +} + +// envSource is the slice of azd's environment service this file reads. +// +// Narrowed to an interface so the classification below can be tested. The rule +// it applies -- carry on when the daemon answered "nothing", stop when it +// failed to answer -- has regressed twice while every test passed, because the +// only seam was the whole function. +type envSource interface { + GetCurrent(context.Context, *azdext.EmptyRequest, ...grpc.CallOption) (*azdext.EnvironmentResponse, error) + GetValue(context.Context, *azdext.GetEnvRequest, ...grpc.CallOption) (*azdext.KeyValueResponse, error) +} + +// readEnvHostedSource reads the active environment's project endpoint. +// +// Returns an empty value and no error when there is nothing to read: no +// environment selected, no project at all, or neither key set. An error means +// the daemon failed to answer, which the caller must not read as absence -- +// falling through would resolve to a lower-priority endpoint that can belong to +// a different project. +// +// The environment is the one -e/--environment named, when it named one. Asking +// azd for the current environment instead is how `azd -e staging` came to read +// the endpoint out of the default environment and write its ids back there. +func readEnvHostedSource(ctx context.Context, env envSource) (value, name string, err error) { + selected := SelectedEnvironment(ctx) + name = selected + if name == "" { + envResp, envErr := env.GetCurrent(ctx, &azdext.EmptyRequest{}) + if envErr != nil { + if !hostedSourceAbsent(envErr) { + return "", "", envErr + } + return "", "", nil + } + if envResp.GetEnvironment() == nil { + return "", "", nil + } + name = envResp.Environment.Name + } + + for _, key := range []string{foundryEnvKey, azureAiEnvKey} { + envVal, valErr := env.GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: name, + Key: key, + }) + if valErr != nil { + // A name the caller typed and azd does not have is a mistake to + // report, not an absence to step over. Falling through would run + // the command against a lower-priority endpoint -- possibly another + // project -- and then write its ids into an environment that does + // not exist, which azd accepts only far enough to warn about. + if selected != "" && noSuchEnvironment(valErr) { + return "", "", ErrNoSuchEnvironment(name) + } + if !hostedSourceAbsent(valErr) { + return "", "", valErr + } + continue + } + if envVal.GetValue() != "" { + return envVal.Value, name, nil + } + } + // The name is reported only alongside a value: it says where the endpoint + // came from, and there is no endpoint here. + return "", "", nil +} + +// noSuchEnvironment is azd's answer for a named environment it does not have, +// as distinct from the other absences: there being no default, or no project. +func noSuchEnvironment(err error) bool { + st, ok := azdext.GRPCStatusFromError(err) + if !ok || st.Code() != codes.Unknown { + return false + } + return strings.HasSuffix(st.Message(), "': "+azdNoSuchEnvironment) +} + +// ErrNoSuchEnvironment reports a -e/--environment naming something azd does not +// have. Built here rather than in either extension's messages package, so this +// file stays free of module-local imports and identical in both. +func ErrNoSuchEnvironment(name string) error { + return fmt.Errorf( + "azd environment %q does not exist; run `azd env list` to see the ones that do", + name, + ) +} + +// selectedEnvKey carries the environment -e/--environment named. +type selectedEnvKey struct{} + +// WithSelectedEnvironment records the environment the caller named, so every +// azd read and write in this invocation acts on that one rather than on azd's +// default. +// +// It travels on the context because the answer is fixed for the whole +// invocation and is needed several layers below the flag -- including here, in +// the cascade both extensions share, which has no cobra command to ask. +func WithSelectedEnvironment(ctx context.Context, name string) context.Context { + if name == "" { + return ctx + } + return context.WithValue(ctx, selectedEnvKey{}, name) +} + +// SelectedEnvironment is the name -e/--environment gave, or empty when it gave +// none and azd's default is what to act on. +func SelectedEnvironment(ctx context.Context) string { + name, _ := ctx.Value(selectedEnvKey{}).(string) + return name +} + +// envLookup is the one call needed to confirm a named environment exists. +type envLookup interface { + Get( + context.Context, *azdext.GetEnvironmentRequest, ...grpc.CallOption, + ) (*azdext.EnvironmentResponse, error) +} + +// VerifySelectedEnvironment refuses a -e/--environment azd does not have. +// +// Checked here rather than as a side effect of reading the endpoint, because +// the endpoint may not be read at all: --project-endpoint answers at level 1 +// and the cascade never runs, so `run start -e typo --project-endpoint ...` was +// accepted while the same command without the flag was refused. The name +// decides which environment every id, version and fingerprint is read from and +// written to, whichever level supplied the endpoint. +func VerifySelectedEnvironment(ctx context.Context) error { + name := SelectedEnvironment(ctx) + if name == "" { + return nil + } + client, err := azdext.NewAzdClient() + if err != nil { + // Nothing to ask: the extension is running outside azd. + return nil + } + defer client.Close() + + return verifyEnvironment(ctx, client.Environment(), name) +} + +// verifyEnvironment is the rule on its own, so it can be tested without a +// daemon. +// +// Only azd saying it has no such environment is an answer. Any other failure is +// not one, and is left to the commands that actually need azd to report, rather +// than turning a hiccup into "your environment does not exist". +func verifyEnvironment(ctx context.Context, env envLookup, name string) error { + _, err := env.Get(ctx, &azdext.GetEnvironmentRequest{Name: name}) + if err == nil { + return nil + } + if noSuchEnvironment(err) || containsGRPCCode(err, codes.NotFound) { + return ErrNoSuchEnvironment(name) + } + return nil +} + +// azd's absence sentinels, as they reach us. +// +// `pkg/environment` and `pkg/environment/azdcontext` declare these with +// errors.New, and the daemon's error-wrapping interceptor passes an error +// carrying no suggestion and no auth failure through untouched, so all three +// arrive as Unknown -- the same code a failure to load project state arrives +// under. The message is the only thing left to tell them apart. +// +// Matched whole rather than by substring. The message is the only evidence +// there is, so a failure whose prose happens to mention an environment must not +// read as one of these. The default-environment and no-project sentinels arrive +// on their own; the named-environment one arrives from the data store as +// `'': environment not found`. +// +// Matched rather than imported: taking a dependency on the environment manager +// for three strings costs more than it settles, and a rename fails closed. The +// command would report the daemon error instead of resolving quietly to a +// lower-priority endpoint, which is the direction to fail in. +const ( + azdNoDefaultEnvironment = "default environment not found" + azdNoSuchEnvironment = "environment not found" + azdNoProject = "no project exists; to create a new project, run `azd init`" +) + +// HostedSourceAbsent reports whether an error from the azd daemon is an answer +// of "nothing here" rather than a failure to answer. +// +// Exported because more than the cascade has to ask it. Deriving the set of +// azd's absences a second time elsewhere is how a sentinel comes to be handled +// in one place and missed in another, which has happened three times. +func HostedSourceAbsent(err error) bool { + return hostedSourceAbsent(err) +} + +// DaemonUnreachable reports the one absence that is not an answer about +// anything: there was nobody to ask. +// +// The cascade carries on regardless -- an unreachable daemon has no endpoint to +// offer, so the next level should be consulted. A caller reporting *why* a +// value is missing has to tell it apart, or a gRPC hiccup ends up phrased as a +// fact about the project. +func DaemonUnreachable(err error) bool { + return containsGRPCCode(err, codes.Unavailable) +} + +// hostedSourceAbsent reports whether an error from the azd daemon leaves the +// cascade free to carry on to the next level. +// +// Unavailable is no daemon at all. NotFound is a daemon with nothing under that +// name -- kept as a guard, though azd's environment service does not use it +// today. Unknown is the one that is not obvious: azd answers the ordinary +// absences with plain Go errors that reach us with no status, and without +// letting those through, a project with no environment selected -- or a command +// run outside a project at all, which the atomic commands are meant to support +// -- could never reach the global config or the host variable. It is admitted +// only for the three messages above, because Unknown is equally what a failure +// to load project state arrives as. +// +// Everything else is a failure to answer rather than an answer of "nothing": +// an expired login, a denial, a cancellation, or a server fault. Falling +// through on any of those would resolve quietly to a lower-priority endpoint +// that can belong to a different project. +func hostedSourceAbsent(err error) bool { + if containsGRPCCode(err, codes.Unavailable) || containsGRPCCode(err, codes.NotFound) { + return true + } + // The status the daemon sent, not the flattened text: status.FromError + // replaces a wrapped error's message with the whole of err.Error(), so the + // wrapper's own prose would take part in the comparison below. + st, ok := azdext.GRPCStatusFromError(err) + if !ok || st.Code() != codes.Unknown { + return false + } + msg := st.Message() + return msg == azdNoDefaultEnvironment || + msg == azdNoProject || + strings.HasSuffix(msg, "': "+azdNoSuchEnvironment) +} + +// containsGRPCCode walks the error chain looking for a gRPC status with the +// specified code. fmt.Errorf("%w", ...) wraps errors without forwarding the +// GRPCStatus() method, so we must unwrap manually. +// +// Note: only follows errors.Unwrap chains; errors.Join multi-wraps are not traversed. +func containsGRPCCode(err error, code codes.Code) bool { + for ; err != nil; err = errors.Unwrap(err) { + if st, ok := status.FromError(err); ok && st.Code() == code { + return true + } + } + return false +} + +// Resolve resolves a Foundry project endpoint using the 5-level cascade: +// +// 1. --project-endpoint flag +// 2. Active azd env value (FOUNDRY_PROJECT_ENDPOINT, then AZURE_AI_PROJECT_ENDPOINT) +// 3. Global config: extensions.ai-agents.project.context.endpoint (read-only; +// owned by azure.ai.agents) +// 4. Host environment variable (FOUNDRY_PROJECT_ENDPOINT, then AZURE_AI_PROJECT_ENDPOINT) +// 5. Structured error with actionable suggestion +// +// Invalid values at any level produce a hard validation error (no silent fallback). +func Resolve(ctx context.Context, opts ResolveOpts) (*Resolved, error) { + // Level 1: explicit flag. + if opts.FlagValue != "" { + normalized, _, err := Validate(opts.FlagValue) + if err != nil { + return nil, err + } + return &Resolved{Endpoint: normalized, Source: SourceFlag}, nil + } + + // Levels 2 + 3: azd-hosted sources (active env, then global config). + sources, err := ReadAzdHostedSourcesFunc(ctx) + if err != nil { + return nil, err + } + + // Level 2: active azd environment's FOUNDRY_PROJECT_ENDPOINT (with the + // AZURE_AI_PROJECT_ENDPOINT fallback applied in readAzdHostedSources). + if sources.EnvValue != "" { + normalized, _, err := Validate(sources.EnvValue) + if err != nil { + return nil, err + } + return &Resolved{ + Endpoint: normalized, + Source: SourceAzdEnv, + AzdEnvName: sources.EnvName, + }, nil + } + + // Level 3: global config (~/.azd/config.json). + if sources.CfgFound && sources.CfgState.Endpoint != "" { + normalized, _, err := Validate(sources.CfgState.Endpoint) + if err != nil { + return nil, err + } + return &Resolved{ + Endpoint: normalized, + Source: SourceGlobalConfig, + SetAt: sources.CfgState.SetAt, + }, nil + } + + // Level 4: host environment variable (FOUNDRY_PROJECT_ENDPOINT, then the + // AZURE_AI_PROJECT_ENDPOINT fallback). + for _, key := range []string{foundryEnvKey, azureAiEnvKey} { + envVal := os.Getenv(key) + if envVal == "" { + continue + } + normalized, _, err := Validate(envVal) + if err != nil { + return nil, err + } + return &Resolved{Endpoint: normalized, Source: SourceFoundryEnv}, nil + } + + // Level 5: structured error. + return nil, NoEndpointError() +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/resolver_test.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/resolver_test.go new file mode 100644 index 00000000000..f21bbbd60fe --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/resolver_test.go @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectctx + +import ( + "context" + "errors" + "testing" + + "azureaidataset/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// withHostedSources installs a stub for ReadAzdHostedSourcesFunc for the +// duration of the test and restores the production value on cleanup. Tests +// using this MUST NOT run in parallel because the seam is a package-level var. +func withHostedSources(t *testing.T, sources AzdHostedSources, err error) { + t.Helper() + orig := ReadAzdHostedSourcesFunc + ReadAzdHostedSourcesFunc = func(context.Context) (AzdHostedSources, error) { + return sources, err + } + t.Cleanup(func() { ReadAzdHostedSourcesFunc = orig }) +} + +// isolateFromAzdDaemon installs an empty hosted-sources stub and clears +// AZD_SERVER so any code path that bypasses the seam cannot reach a real +// daemon. After calling this, the resolver only sees the flag and the +// FOUNDRY_PROJECT_ENDPOINT / AZURE_AI_PROJECT_ENDPOINT host env vars. +func isolateFromAzdDaemon(t *testing.T) { + t.Helper() + t.Setenv("AZD_SERVER", "") + withHostedSources(t, AzdHostedSources{}, nil) +} + +func TestResolve_FlagWins(t *testing.T) { + // Even with FOUNDRY_PROJECT_ENDPOINT and azd-hosted sources set, the flag wins. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/env-proj") + withHostedSources(t, AzdHostedSources{ + EnvValue: "https://azdenv.services.ai.azure.com/api/projects/p", + EnvName: "dev", + }, nil) + + result, err := Resolve(t.Context(), ResolveOpts{ + FlagValue: "https://flag.services.ai.azure.com/api/projects/flag-proj", + }) + require.NoError(t, err) + assert.Equal(t, "https://flag.services.ai.azure.com/api/projects/flag-proj", result.Endpoint) + assert.Equal(t, SourceFlag, result.Source) +} + +func TestResolve_AzdEnvWinsOverConfigAndFoundryEnv(t *testing.T) { + // EnvValue here stands in for whichever active-env key readAzdHostedSources + // resolved (FOUNDRY_PROJECT_ENDPOINT, or the AZURE_AI_PROJECT_ENDPOINT + // fallback); either way level 2 wins over global config and the host env. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + withHostedSources(t, AzdHostedSources{ + EnvValue: " HTTPS://Azdenv.Services.AI.Azure.com/api/projects/p/ ", + EnvName: "dev", + CfgState: State{ + Endpoint: "https://cfg.services.ai.azure.com/api/projects/p", + SetAt: "2025-01-01T00:00:00Z", + }, + CfgFound: true, + }, nil) + + result, err := Resolve(t.Context(), ResolveOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://azdenv.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceAzdEnv, result.Source) + assert.Equal(t, "dev", result.AzdEnvName) +} + +func TestResolve_AzdEnvInvalidIsHardError(t *testing.T) { + // Level 2 invalid values are hard errors (no silent fallback to lower levels). + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + withHostedSources(t, AzdHostedSources{ + EnvValue: "http://not-https.services.ai.azure.com/api/projects/p", + EnvName: "dev", + }, nil) + + _, err := Resolve(t.Context(), ResolveOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolve_GlobalConfigWinsOverFoundryEnv(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + withHostedSources(t, AzdHostedSources{ + CfgState: State{ + Endpoint: " HTTPS://Cfg.Services.AI.Azure.com/api/projects/p/ ", + SetAt: "2025-01-02T03:04:05Z", + }, + CfgFound: true, + }, nil) + + result, err := Resolve(t.Context(), ResolveOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://cfg.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceGlobalConfig, result.Source) + assert.Equal(t, "2025-01-02T03:04:05Z", result.SetAt) +} + +func TestResolve_GlobalConfigInvalidIsHardError(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + withHostedSources(t, AzdHostedSources{ + CfgState: State{ + Endpoint: "http://not-https.services.ai.azure.com/api/projects/p", + SetAt: "2025-01-02T03:04:05Z", + }, + CfgFound: true, + }, nil) + + _, err := Resolve(t.Context(), ResolveOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolve_HostedSourcesErrorPropagates(t *testing.T) { + // Non-recoverable errors from the hosted-source lookup must be surfaced + // and must not silently fall through to level 4. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + sentinel := errors.New("boom") + withHostedSources(t, AzdHostedSources{}, sentinel) + + _, err := Resolve(t.Context(), ResolveOpts{}) + require.ErrorIs(t, err, sentinel) +} + +func TestResolve_FoundryEnvFallback(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/env-proj") + + result, err := Resolve(t.Context(), ResolveOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://env.services.ai.azure.com/api/projects/env-proj", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} + +func TestResolve_AzureAiHostEnvFallback(t *testing.T) { + // When FOUNDRY_PROJECT_ENDPOINT is unset, the resolver falls back to the + // AZURE_AI_PROJECT_ENDPOINT host env var (the key azd ai agent init / azd + // add persist). See https://github.com/Azure/azure-dev/issues/8688. + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "https://azureai.services.ai.azure.com/api/projects/p") + + result, err := Resolve(t.Context(), ResolveOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://azureai.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} + +func TestResolve_FoundryHostEnvWinsOverAzureAi(t *testing.T) { + // With both host env vars set, FOUNDRY_PROJECT_ENDPOINT takes precedence. + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/f") + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "https://azureai.services.ai.azure.com/api/projects/a") + + result, err := Resolve(t.Context(), ResolveOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://foundry.services.ai.azure.com/api/projects/f", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} + +func TestResolve_FoundryEnvNormalized(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", " https://X.SERVICES.AI.AZURE.COM/api/projects/p/ ") + + result, err := Resolve(t.Context(), ResolveOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://x.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} + +func TestResolve_InvalidFlagRejected(t *testing.T) { + isolateFromAzdDaemon(t) + + _, err := Resolve(t.Context(), ResolveOpts{ + FlagValue: "http://not-https.services.ai.azure.com/api/projects/p", + }) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolve_InvalidFoundryEnvRejected(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "http://bad.services.ai.azure.com/api/projects/p") + + _, err := Resolve(t.Context(), ResolveOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolve_InvalidAzureAiHostEnvRejected(t *testing.T) { + // An invalid AZURE_AI_PROJECT_ENDPOINT fallback is a hard error, not a + // silent skip to level 5. + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "http://not-https.services.ai.azure.com/api/projects/p") + + _, err := Resolve(t.Context(), ResolveOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolve_NothingResolvable(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "") + + _, err := Resolve(t.Context(), ResolveOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Equal(t, exterrors.CodeMissingProjectEndpoint, localErr.Code) + assert.Equal(t, azdext.LocalErrorCategoryDependency, localErr.Category) +} + +func TestResolve_CfgFoundButEndpointEmptyFallsThrough(t *testing.T) { + // CfgFound=true with Endpoint="" must not short-circuit; the resolver + // should continue to level 4 (FOUNDRY_PROJECT_ENDPOINT). + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/p") + withHostedSources(t, AzdHostedSources{ + CfgState: State{Endpoint: "", SetAt: "2025-01-01T00:00:00Z"}, + CfgFound: true, + }, nil) + + result, err := Resolve(t.Context(), ResolveOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://env.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} + +func TestContainsGRPCCode_NonGRPCErrorReturnsFalse(t *testing.T) { + t.Parallel() + assert.False(t, containsGRPCCode(errors.New("plain"), 0)) + assert.False(t, containsGRPCCode(nil, 0)) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/selected_env_test.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/selected_env_test.go new file mode 100644 index 00000000000..57704a20507 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/selected_env_test.go @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectctx + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// -e/--environment is parsed by the SDK and then has to be acted on. It was +// discarded, so `azd ai eval create -e staging` read its endpoint out of the +// default environment and wrote its ids back there -- and `-e a-name-azd- +// rejects` was accepted in silence, because nothing ever asked azd about it. +// +// These tests pin that the named environment is the one read, and that azd is +// not asked which environment is current when a name was given: asking can only +// produce a second, disagreeing answer. + +// perEnv answers GetValue per environment, which is what tells "read staging" +// apart from "read whatever azd calls current". +type perEnv struct { + values map[string]map[string]string + current string + currentCalls int + askedEnvs []string +} + +func (f *perEnv) GetCurrent( + context.Context, *azdext.EmptyRequest, ...grpc.CallOption, +) (*azdext.EnvironmentResponse, error) { + f.currentCalls++ + return &azdext.EnvironmentResponse{ + Environment: &azdext.Environment{Name: f.current}, + }, nil +} + +func (f *perEnv) GetValue( + _ context.Context, req *azdext.GetEnvRequest, _ ...grpc.CallOption, +) (*azdext.KeyValueResponse, error) { + f.askedEnvs = append(f.askedEnvs, req.EnvName) + return &azdext.KeyValueResponse{ + Key: req.Key, + Value: f.values[req.EnvName][req.Key], + }, nil +} + +func twoEnvironments() *perEnv { + return &perEnv{ + values: map[string]map[string]string{ + "default": {foundryEnvKey: "https://from-default/"}, + "staging": {foundryEnvKey: "https://from-staging/"}, + }, + current: "default", + } +} + +func TestSelectedEnvironmentIsTheOneRead(t *testing.T) { + fake := twoEnvironments() + + ctx := WithSelectedEnvironment(context.Background(), "staging") + value, name, err := readEnvHostedSource(ctx, fake) + + require.NoError(t, err) + assert.Equal(t, "https://from-staging/", value) + assert.Equal(t, "staging", name) + assert.Zero(t, fake.currentCalls, + "a named environment is the answer; asking azd for the current one can only disagree") + assert.NotContains(t, fake.askedEnvs, "default") +} + +func TestWithoutSelectionAzdsCurrentEnvironmentIsRead(t *testing.T) { + fake := twoEnvironments() + + value, name, err := readEnvHostedSource(context.Background(), fake) + + require.NoError(t, err) + assert.Equal(t, "https://from-default/", value) + assert.Equal(t, "default", name) + assert.Equal(t, 1, fake.currentCalls) +} + +// A named environment holding no endpoint reports none. Falling back to the +// default's is the bug, restated. +func TestSelectedEnvironmentWithNoEndpointDoesNotFallBack(t *testing.T) { + fake := twoEnvironments() + fake.values["staging"] = map[string]string{} + + ctx := WithSelectedEnvironment(context.Background(), "staging") + value, _, err := readEnvHostedSource(ctx, fake) + + require.NoError(t, err) + assert.Empty(t, value, "staging has no endpoint; the default's is not an answer") + assert.Zero(t, fake.currentCalls) + assert.NotContains(t, fake.askedEnvs, "default") +} + +// An empty name is "none given", not "the environment called empty string". +func TestWithSelectedEnvironmentIgnoresAnEmptyName(t *testing.T) { + assert.Empty(t, SelectedEnvironment(WithSelectedEnvironment(context.Background(), ""))) + assert.Equal(t, "staging", + SelectedEnvironment(WithSelectedEnvironment(context.Background(), "staging"))) + assert.Empty(t, SelectedEnvironment(context.Background())) +} + +// failingEnv answers GetValue with a fixed error, which is how azd reports an +// environment it does not have. +type failingEnv struct { + err error + currentCalls int +} + +func (f *failingEnv) GetCurrent( + context.Context, *azdext.EmptyRequest, ...grpc.CallOption, +) (*azdext.EnvironmentResponse, error) { + f.currentCalls++ + return &azdext.EnvironmentResponse{ + Environment: &azdext.Environment{Name: "default"}, + }, nil +} + +func (f *failingEnv) GetValue( + context.Context, *azdext.GetEnvRequest, ...grpc.CallOption, +) (*azdext.KeyValueResponse, error) { + return nil, f.err +} + +// A name the caller typed and azd does not have is a mistake to report, not an +// absence to step over. Stepping over it runs the command against a +// lower-priority endpoint, which can belong to another project, and then writes +// its ids into an environment that does not exist. +func TestATypoedEnvironmentNameIsReportedNotSteppedOver(t *testing.T) { + fake := &failingEnv{ + err: status.Error(codes.Unknown, "'does-not-exist': environment not found"), + } + + ctx := WithSelectedEnvironment(context.Background(), "does-not-exist") + _, _, err := readEnvHostedSource(ctx, fake) + + require.Error(t, err, "a named environment azd does not have must stop the cascade") + assert.Contains(t, err.Error(), "does-not-exist") +} + +// The same answer without a name given is ordinary absence: there is simply no +// endpoint in the current environment, and the cascade carries on. +func TestTheSameAnswerWithoutANameIsStillAbsence(t *testing.T) { + fake := &failingEnv{ + err: status.Error(codes.Unknown, "'default': environment not found"), + } + + value, name, err := readEnvHostedSource(context.Background(), fake) + + require.NoError(t, err, "without -e this is absence, and the cascade continues") + assert.Empty(t, value) + assert.Empty(t, name) +} + +// A named environment that exists but cannot be read for some other reason is +// a failure, and must not be reported as a missing environment either. +func TestANamedEnvironmentThatFailsDifferentlyStillFails(t *testing.T) { + fake := &failingEnv{err: status.Error(codes.Internal, "the store is on fire")} + + ctx := WithSelectedEnvironment(context.Background(), "staging") + _, _, err := readEnvHostedSource(ctx, fake) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "does not exist", + "a broken read is not a missing environment") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/store.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/store.go new file mode 100644 index 00000000000..6bb3663fed9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/store.go @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectctx + +import ( + "context" + + "azureaidataset/internal/messages" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// projectContextConfigPath is the read-only UserConfig path for the persisted +// project context owned by azure.ai.agents. The toolboxes extension reads this +// key but never writes it (§ 6 of the design spec). +const projectContextConfigPath = "extensions.ai-agents.project.context" + +// getProjectContext reads the persisted project context from global config. +// Returns (state, true, nil) when present, (zero, false, nil) when absent. +func getProjectContext( + ctx context.Context, azdClient *azdext.AzdClient, +) (State, bool, error) { + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return State{}, false, messages.ProjectContextClient(err) + } + + var state State + found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state) + if err != nil { + return State{}, false, messages.ProjectContextRead(err) + } + + if !found || state.Endpoint == "" { + return State{}, false, nil + } + + return state, true, nil +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/types.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/types.go new file mode 100644 index 00000000000..93bf43f3780 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/types.go @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package projectctx encapsulates the Foundry project endpoint cascade and +// validation shared by every Foundry-extension command tree. +// +// This is the toolboxes-extension copy of the agent_context.go / project_endpoint.go / +// project_context_store.go logic in azure.ai.agents (see § 3.2 of the toolbox +// design spec). Semantics match the agents original verbatim; identifiers are +// exported because they cross the package boundary in this layout. +package projectctx + +const ( + // foundryEnvKey is the canonical project-endpoint key. It is read both from + // the active azd environment (level 2) and as a host environment variable + // (level 4). + foundryEnvKey = "FOUNDRY_PROJECT_ENDPOINT" + // azureAiEnvKey is the legacy/sibling project-endpoint key written by + // `azd ai agent init` and `azd add` (Bicep output). It is read as a fallback + // after foundryEnvKey at both the active-azd-env and host-env levels so the + // hosted-agent + toolbox workflow resolves without an extra manual step. + // See https://github.com/Azure/azure-dev/issues/8688. + azureAiEnvKey = "AZURE_AI_PROJECT_ENDPOINT" +) + +// EndpointSource identifies where a resolved project endpoint came from. +type EndpointSource string + +const ( + // SourceFlag means the endpoint came from the --project-endpoint flag. + SourceFlag EndpointSource = "flag" + // SourceAzdEnv means the endpoint came from the active azd environment's + // FOUNDRY_PROJECT_ENDPOINT (or, as a fallback, AZURE_AI_PROJECT_ENDPOINT) value. + SourceAzdEnv EndpointSource = "azdEnv" + // SourceGlobalConfig means the endpoint came from ~/.azd/config.json + // (extensions.ai-agents.project.context.endpoint — owned by azure.ai.agents + // and shared read-only with sibling extensions). + SourceGlobalConfig EndpointSource = "globalConfig" + // SourceFoundryEnv means the endpoint came from the FOUNDRY_PROJECT_ENDPOINT + // (or, as a fallback, AZURE_AI_PROJECT_ENDPOINT) host environment variable. + SourceFoundryEnv EndpointSource = "foundryEnv" +) + +// ResolveOpts controls the 5-level endpoint resolution cascade. +type ResolveOpts struct { + // FlagValue is the value of the --project-endpoint flag (level 1). + // Empty means the flag was not provided. + FlagValue string +} + +// Resolved holds the result of Resolve. +type Resolved struct { + Endpoint string + Source EndpointSource + AzdEnvName string + SetAt string // RFC3339 timestamp; only meaningful when Source == SourceGlobalConfig +} + +// AzdHostedSources holds the values the resolver reads from azd-managed +// sources (active env + ~/.azd/config.json). Returned as a single struct so +// tests can stub the whole lookup via ReadAzdHostedSourcesFunc. +type AzdHostedSources struct { + // EnvValue is the active-azd-env project endpoint: FOUNDRY_PROJECT_ENDPOINT + // if set, otherwise AZURE_AI_PROJECT_ENDPOINT, otherwise "" (not set / no + // active env / no azd client available). + EnvValue string + // EnvName is the active azd env name. Only meaningful when EnvValue != "". + EnvName string + // CfgState is the project context persisted in global config. + CfgState State + // CfgFound indicates whether a non-empty endpoint was found in global config. + CfgFound bool +} + +// State is the JSON shape stored at extensions.ai-agents.project.context in +// ~/.azd/config.json. This key is owned by azure.ai.agents; the toolboxes +// extension reads it but never writes it. +type State struct { + Endpoint string `json:"endpoint"` + SetAt string `json:"setAt"` +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/validator.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/validator.go new file mode 100644 index 00000000000..b7bb6a72c29 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/validator.go @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectctx + +import ( + "fmt" + "net/url" + "strings" + + "azureaidataset/internal/messages" +) + +// foundryHostSuffixes is the authoritative list of accepted Foundry host suffixes. +var foundryHostSuffixes = []string{ + ".services.ai.azure.com", +} + +// projectEndpointPathPrefix is the expected path prefix for Foundry project endpoints. +const projectEndpointPathPrefix = "/api/projects/" + +// isFoundryHost reports whether the hostname ends with a recognized Foundry suffix. +func isFoundryHost(hostname string) bool { + h := strings.ToLower(hostname) + for _, suffix := range foundryHostSuffixes { + if strings.HasSuffix(h, suffix) { + return true + } + } + return false +} + +// Validate validates and normalizes a Foundry project endpoint URL. +// +// The URL must be an absolute https:// URL whose host ends with a recognized +// Foundry suffix. Whitespace is trimmed, trailing slashes are stripped, and +// the result is returned in normalized form. +// +// The second return value is true when the path does not look like +// /api/projects/ — callers may use this as a non-fatal warning. +func Validate(raw string) (normalized string, pathWarning bool, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false, messages.EndpointEmpty() + } + + u, parseErr := url.Parse(raw) + if parseErr != nil { + return "", false, messages.EndpointUnparseable(parseErr) + } + + if !strings.EqualFold(u.Scheme, "https") { + return "", false, messages.EndpointNotHTTPS() + } + + host := u.Hostname() + if host == "" || !isFoundryHost(host) { + return "", false, messages.EndpointNotFoundryHost(host, foundryHostSuffixes[0]) + } + + if u.Port() != "" { + return "", false, messages.EndpointHasPort(u.Host) + } + + // Normalize: lowercase host, strip trailing slash. + path := strings.TrimRight(u.EscapedPath(), "/") + normalized = fmt.Sprintf("https://%s%s", strings.ToLower(host), path) + + // Warn when the path does not look like /api/projects/. + if !strings.HasPrefix(path, projectEndpointPathPrefix) || + strings.TrimPrefix(path, projectEndpointPathPrefix) == "" { + pathWarning = true + } + + return normalized, pathWarning, nil +} + +// NoEndpointError returns the structured dependency error used when no project +// endpoint could be resolved from any source. +func NoEndpointError() error { + return messages.NoEndpoint() +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/verify_env_test.go b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/verify_env_test.go new file mode 100644 index 00000000000..81b866a9020 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/foundry/projectctx/verify_env_test.go @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectctx + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// lookupStub answers the one call that confirms an environment exists. +type lookupStub struct { + err error + asked []string +} + +func (l *lookupStub) Get( + _ context.Context, req *azdext.GetEnvironmentRequest, _ ...grpc.CallOption, +) (*azdext.EnvironmentResponse, error) { + l.asked = append(l.asked, req.Name) + if l.err != nil { + return nil, l.err + } + return &azdext.EnvironmentResponse{ + Environment: &azdext.Environment{Name: req.Name}, + }, nil +} + +// The named-environment check used to live inside the endpoint cascade, which +// --project-endpoint skips entirely: `run start -e typo --project-endpoint ...` +// was accepted while the same command without the flag was refused. The name +// decides where every id and version is read from and written to, whichever +// level supplied the endpoint, so the check does not belong to any level. +func TestAnEnvironmentAzdDoesNotHaveIsRefused(t *testing.T) { + stub := &lookupStub{ + err: status.Error(codes.Unknown, "'typo': environment not found"), + } + + err := verifyEnvironment(context.Background(), stub, "typo") + + require.Error(t, err) + assert.Contains(t, err.Error(), "typo") + assert.Equal(t, []string{"typo"}, stub.asked) +} + +func TestAnEnvironmentAzdHasIsAccepted(t *testing.T) { + stub := &lookupStub{} + + require.NoError(t, verifyEnvironment(context.Background(), stub, "staging")) + assert.Equal(t, []string{"staging"}, stub.asked) +} + +// A daemon that could not answer has not said the environment is missing. +// Refusing there would turn a hiccup into "your environment does not exist"; +// the commands that need azd report their own failures. +func TestAFailureThatIsNotAnAnswerDoesNotRefuse(t *testing.T) { + for _, err := range []error{ + status.Error(codes.Internal, "the store is on fire"), + status.Error(codes.Unavailable, "no daemon"), + status.Error(codes.Unknown, "no project exists; to create a new project, run `azd init`"), + } { + stub := &lookupStub{err: err} + assert.NoError(t, verifyEnvironment(context.Background(), stub, "staging"), + "unexpected refusal for %v", err) + } +} + +// Nothing named means azd's default, which needs no confirming. +func TestNoSelectionAsksNothing(t *testing.T) { + require.NoError(t, VerifySelectedEnvironment(context.Background())) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/messages/credential_failure_test.go b/cli/azd/extensions/azure.ai.dataset/internal/messages/credential_failure_test.go new file mode 100644 index 00000000000..5e31898de78 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/messages/credential_failure_test.go @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package messages + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// RequestFailed rewrites a credential failure into "run `azd auth login`". That +// is the right answer for a token that could not be minted and the wrong answer +// for anything else, so what counts as one has to be narrow. +func TestRequestFailedOnlyClaimsAuthForRealCredentialFailures(t *testing.T) { + t.Run("a credential failure is rewritten", func(t *testing.T) { + // What AzureDeveloperCLICredential returns when `azd auth token` exits + // non-zero: the shape seen live as "exit status 1". + err := RequestFailed(errors.New( + "AzureDeveloperCLICredential: exit status 1")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "azd auth login") + }) + + // The regression this test exists for. "failed to acquire a token" used to + // be matched anywhere in the text, so an unrelated failure that happened to + // contain the phrase was reported as an expired login. + t.Run("an unrelated error keeping that phrase is left alone", func(t *testing.T) { + err := RequestFailed(errors.New( + "the pool failed to acquire a token bucket lease")) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "azd auth login", + "a lease is not a login") + assert.Contains(t, err.Error(), "token bucket lease", + "and the original failure still has to be readable") + }) + + t.Run("an ordinary transport failure is passed through", func(t *testing.T) { + err := RequestFailed(errors.New("connection reset by peer")) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "azd auth login") + assert.Contains(t, err.Error(), "connection reset by peer") + }) + + // Matching the SDK's type rather than its wording means a reworded message + // still classifies, and a lookalike string does not. + t.Run("the SDK's own type classifies whatever it says", func(t *testing.T) { + var typed error = &azidentity.AuthenticationFailedError{} + err := RequestFailed(fmt.Errorf("getting a token: %w", typed)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "azd auth login") + }) + + t.Run("nil stays nil-ish", func(t *testing.T) { + assert.False(t, isCredentialFailure(nil)) + }) +} + +// A credential that never ran is a different problem from one that ran and was +// refused, and `azd auth login` is not the answer to it -- you cannot log in +// with a tool that is not on PATH. +func TestRequestFailedSeparatesAnUnrunnableCredentialFromAnExpiredLogin(t *testing.T) { + for _, text := range []string{ + "AzureDeveloperCLICredential: executable not found on path", + "AzureDeveloperCLICredential: 'azd' is not recognized as an internal or external command", + } { + err := RequestFailed(errors.New(text)) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "azd auth login", + "cannot log in with a tool that will not run: %s", text) + assert.Contains(t, err.Error(), "could not be run") + } + + // The expired-login case must still say what fixes it. + err := RequestFailed(errors.New("AzureDeveloperCLICredential: exit status 1")) + require.Error(t, err) + assert.Contains(t, err.Error(), "azd auth login") +} + +// A 401 or 403 is the service refusing a token it did read, which is a +// different fix from a token that was never minted. +func TestServiceRefusedOnlyRewritesUnauthorized(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + err := ServiceRefused(status, errors.New("nope")) + require.Error(t, err) + assert.Contains(t, err.Error(), "azd auth login", "status %d", status) + } + + err := ServiceRefused(http.StatusInternalServerError, errors.New("boom")) + require.Error(t, err) + assert.NotContains(t, err.Error(), "azd auth login", + "a 500 is not something a fresh login fixes") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/messages/messages.go b/cli/azd/extensions/azure.ai.dataset/internal/messages/messages.go new file mode 100644 index 00000000000..a4b6a8ac768 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/messages/messages.go @@ -0,0 +1,623 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package messages holds every string this extension shows a user. +// +// One file, so the whole voice of the CLI can be reviewed in one sitting and a +// wording change never has to be hunted through the command tree. The only +// extension package it imports is exterrors, which holds no wording of its own, +// so every other package can use this one. +// +// Conventions, so the set stays consistent: +// +// - Errors state what went wrong and, where there is one, the way out. +// Lowercase, no trailing period: azd renders them after "ERROR: ". +// - A name the user chose is quoted with %q; an identifier the service +// assigned is not, because it is already unmistakable. +// - Progress and success lines are sentences with a capital and no period. +// - A printed line carries its own newlines, so a call site is a bare Fprint. +// - Nothing here decides *whether* to print. That stays at the call site. +package messages + +import ( + "errors" + "fmt" + "io/fs" + "net/http" + "path/filepath" + "strings" + + "azureaidataset/internal/exterrors" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// --------------------------------------------------------------------------- +// Datasets +// --------------------------------------------------------------------------- + +// AssetAlreadyExists reports `create` asked of a name already in use. +func AssetAlreadyExists(kind, name string) error { + return fmt.Errorf("%s %q already exists: use `update` to publish a new version", kind, name) +} + +// AssetDoesNotExist reports `update` asked of a name nobody registered. +func AssetDoesNotExist(kind, name string) error { + return fmt.Errorf("%s %q does not exist: use `create` to register it", kind, name) +} + +// ReadingFromFile reports a --from-file that would not stat. +// +// A path that is simply absent is reported as absent: the wrapped error is a +// syscall name that says nothing to the person who mistyped it. +func ReadingFromFile(path string, err error) error { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("--from-file %q does not exist", filepath.ToSlash(path)) + } + return fmt.Errorf("reading --from-file %q: %w", filepath.ToSlash(path), err) +} + +// FromFileMustBeJSONL reports a --from-file that is not a dataset. +func FromFileMustBeJSONL(path string) error { + return fmt.Errorf( + "--from-file must be a .jsonl file or a directory containing one, got %q", + filepath.ToSlash(path)) +} + +// FromFileDirectoryHasNoJSONL reports a directory with nothing to upload. +func FromFileDirectoryHasNoJSONL(dir string) error { + return fmt.Errorf("no .jsonl file in %q; --from-file needs one to upload", filepath.ToSlash(dir)) +} + +// FromFileDirectoryIsAmbiguous refuses to guess which dataset was meant. +func FromFileDirectoryIsAmbiguous(dir string, names []string) error { + return fmt.Errorf( + "%q holds %d .jsonl files (%s); name the one to upload with --from-file", + filepath.ToSlash(dir), len(names), strings.Join(names, ", ")) +} + +// DatasetNotFound reports a name that is not a dataset in this project. +func DatasetNotFound(name string) error { + return fmt.Errorf( + "no dataset %q in this project; `azd ai dataset list` shows the ones there are", + name) +} + +// InvalidDatasetName reports a name the service will not accept. +func InvalidDatasetName(name string) error { + return fmt.Errorf( + "dataset name %q is invalid: use letters, digits, dashes and underscores, "+ + "up to 255 characters", name) +} + +// ReadingDatasetDirectory reports the upload scan failing to read the directory. +func ReadingDatasetDirectory(err error) error { + return fmt.Errorf("reading directory: %w", err) +} + +// DatasetFileHasNoRows reports an empty dataset file, refused before upload. +func DatasetFileHasNoRows(name string) error { + return fmt.Errorf( + "dataset file %q has no rows, so there would be nothing to evaluate", name) +} + +// JSONLRowInvalid reports a row that is not JSON, named by line. +// +// Refused before upload: the service stores the file whatever is in it, so a +// garbage row registers successfully and only fails much later, in the run that +// scores it, against a line number nobody has any more. +func JSONLRowInvalid(name string, line int, err error) error { + return fmt.Errorf("dataset file %q line %d is not valid JSON: %w", name, line, err) +} + +// JSONLRowEmpty reports a row that parses but carries no fields. +func JSONLRowEmpty(name string, line int) error { + return fmt.Errorf("dataset file %q line %d is an empty object, so it has nothing to score", name, line) +} + +// NoJSONLInDirectory reports an upload directory holding no dataset. +func NoJSONLInDirectory(dir string) error { + return fmt.Errorf("no .jsonl file found in %s", filepath.ToSlash(dir)) +} + +// ReadingDatasetFromDir reports the upload failing to gather the local rows. +func ReadingDatasetFromDir(dir string, err error) error { + return fmt.Errorf("reading dataset from %s: %w", dir, err) +} + +// StartingPendingUpload reports the service refusing to open an upload. +func StartingPendingUpload(err error) error { + return fmt.Errorf("starting pending upload: %w", err) +} + +// NoUploadURI reports an accepted upload the service gave nowhere to write to. +func NoUploadURI() error { + return errors.New("no upload SAS URI returned from startPendingUpload") +} + +// NoBlobURI reports an accepted upload the service gave no way to finalize. +// +// Separate from NoUploadURI because they are different fields of the same +// response: the SAS says where to write, the blob URI says what to register, +// and a response can carry one without the other. +func NoBlobURI() error { + return errors.New("no blob URI returned from startPendingUpload, so there is nothing to register the upload as") +} + +// UploadingBlob reports the dataset content failing to upload. +func UploadingBlob(err error) error { + return fmt.Errorf("uploading blob: %w", err) +} + +// RegisteringDataset reports the service refusing to publish the dataset. +func RegisteringDataset(dataset string, err error) error { + return fmt.Errorf("registering dataset %q: %w", dataset, err) +} + +// DatasetRegistered confirms a published dataset version. +func DatasetRegistered(dataset, version string) string { + return fmt.Sprintf("Registered dataset %s version %s\n", dataset, version) +} + +// ListingDatasets reports a failure to list the project's datasets. +func ListingDatasets(err error) error { + return fmt.Errorf("listing datasets: %w", err) +} + +// ListingDatasetVersions reports a failure to list one dataset's versions. +func ListingDatasetVersions(dataset string, err error) error { + return fmt.Errorf("listing versions of dataset %q: %w", dataset, err) +} + +// NoDatasets reports a project with no datasets to list. +func NoDatasets() string { + return "No datasets found.\n" +} + +// NoDatasetVersions reports a name nothing is published under. +// +// Listing a name that does not exist is not an error — a delete is checked for +// idempotence this way — so this has to read as an answer about that name +// rather than as a report about the project, which holds other datasets. +func NoDatasetVersions(dataset string) string { + return fmt.Sprintf("No versions of dataset %q. Publish one with "+ + "`azd ai dataset create %s --from-file `.\n", dataset, dataset) +} + +// ResolvingLatestDatasetVersion reports a failure to find what "latest" means. +func ResolvingLatestDatasetVersion(dataset string, err error) error { + return fmt.Errorf("resolving the latest version of %q: %w", dataset, err) +} + +// DatasetHasNoVersions reports a dataset nothing was ever published under. +func DatasetHasNoVersions(dataset string) error { + return fmt.Errorf("dataset %q has no versions", dataset) +} + +// DatasetVersionNotFoundWithHint reports a dataset version the project does not +// hold, to a reader who may have meant a different one. +// +// Kept apart from DatasetVersionNotFound because the listing only helps someone +// looking for a version; a delete already named the one it meant. +func DatasetVersionNotFoundWithHint(dataset, version string) error { + return fmt.Errorf( + "no dataset %q at version %q in this project; "+ + "`azd ai dataset versions list %s` shows the ones there are", dataset, version, dataset) +} + +// ReadingDatasetVersion reports one version of a dataset failing to read. +func ReadingDatasetVersion(dataset, version string, err error) error { + return fmt.Errorf("reading dataset %q version %q: %w", dataset, version, err) +} + +// CheckingDataset reports the read that decides whether a name is already +// taken. It is worth its own message because that read is what separates +// `create` from `update`, and a failure answered as "not there" turns a create +// into a silent update. +func CheckingDataset(dataset string, err error) error { + return fmt.Errorf( + "checking whether dataset %q already exists: %w", dataset, err) +} + +// DatasetVersionNotFound reports a dataset version there is nothing to delete at. +func DatasetVersionNotFound(dataset, version string) error { + return fmt.Errorf("no dataset %q at version %q in this project", dataset, version) +} + +// DeletingDatasetVersion reports the service refusing the delete. +func DeletingDatasetVersion(dataset, version string, err error) error { + return fmt.Errorf("deleting dataset %q version %q: %w", dataset, version, err) +} + +// DatasetDeleted confirms a deleted dataset version. +func DatasetDeleted(dataset, version string) string { + return fmt.Sprintf("Deleted dataset %s version %s\n", dataset, version) +} + +// ReadingDownloadCredentials reports the service refusing to hand out a read URI. +func ReadingDownloadCredentials(dataset string, err error) error { + return fmt.Errorf("reading download credentials for %q: %w", dataset, err) +} + +// NoDownloadURI reports a dataset the service gave nowhere to read from. +func NoDownloadURI(dataset string) error { + return fmt.Errorf("no download URI returned for dataset %q", dataset) +} + +// ListingDatasetContent reports a failure to list what a dataset version holds. +func ListingDatasetContent(dataset string, err error) error { + return fmt.Errorf("listing the content of dataset %q: %w", dataset, err) +} + +// DatasetHasNoFile reports a dataset version with nothing to download. +func DatasetHasNoFile(dataset string) error { + return fmt.Errorf("dataset %q holds no downloadable file", dataset) +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +// ConnectingToAzd reports the azd daemon being unreachable. +func ConnectingToAzd(err error) error { + return fmt.Errorf("connecting to azd: %w", err) +} + +// CreatingCredential reports the Azure credential failing to build. +func CreatingCredential(err error) error { + return fmt.Errorf("creating Azure credential: %w", err) +} + +// ErrNoAzdEnvironment reports that there is no azd environment to persist into. +// +// These commands work standalone against the data plane, so running outside a +// project is ordinary rather than a problem worth reporting. +var ErrNoAzdEnvironment = errors.New("no active azd environment") + +// NoAzdEnvironmentToWrite reports a value with nowhere to be remembered. +func NoAzdEnvironmentToWrite(key string) error { + return fmt.Errorf("%w to write %s into", ErrNoAzdEnvironment, key) +} + +// WritingEnvValue reports the azd environment refusing a write. +func WritingEnvValue(key string, err error) error { + return fmt.Errorf("writing %s to the azd environment: %w", key, err) +} + +// EndpointEmpty reports a project endpoint given as blank. +func EndpointEmpty() error { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must not be empty", + "provide a Foundry project endpoint URL "+ + "(e.g. https://.services.ai.azure.com/api/projects/)", + ) +} + +// EndpointUnparseable reports a project endpoint that is not a URL. +func EndpointUnparseable(err error) error { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid project endpoint URL: %v", err), + "provide a valid https:// Foundry project endpoint URL", + ) +} + +// EndpointNotHTTPS reports a project endpoint on the wrong scheme. +func EndpointNotHTTPS() error { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must use https", + "provide an https:// URL", + ) +} + +// EndpointNotFoundryHost reports a project endpoint pointing somewhere else. +func EndpointNotFoundryHost(host, suffix string) error { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf( + "project endpoint host %q is not a recognized Foundry host (*%s)", + host, suffix, + ), + "the host must end with "+suffix, + ) +} + +// EndpointHasPort reports a project endpoint carrying an explicit port. +func EndpointHasPort(host string) error { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("project endpoint host %q must not include a port", host), + "remove the explicit port from the URL", + ) +} + +// NoEndpoint reports a project endpoint that no source could supply. +func NoEndpoint() error { + return exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "no Foundry project endpoint resolved", + "persist a workspace default with `azd ai project set `, "+ + "or set FOUNDRY_PROJECT_ENDPOINT (or AZURE_AI_PROJECT_ENDPOINT) "+ + "in the active azd environment, "+ + "or export FOUNDRY_PROJECT_ENDPOINT (or AZURE_AI_PROJECT_ENDPOINT) in your shell", + ) +} + +// ProjectContextClient reports the config helper failing to build. +func ProjectContextClient(err error) error { + return fmt.Errorf("getProjectContext: %w", err) +} + +// ProjectContextRead reports the persisted project context failing to read. +func ProjectContextRead(err error) error { + return fmt.Errorf("getProjectContext: failed to read config: %w", err) +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +// Progress markers from the azd style guide, so the extension's lines sit +// alongside core's without a second vocabulary. +const ( + DoneMark = "(✓) Done:" // finished successfully + SkippedMark = "(-) Skipped:" // intentionally not done, not a failure + FailedMark = "(x) Failed:" // the step did not complete +) + +// Warning reports a problem that is not worth failing the command over. +func Warning(err error) string { + return fmt.Sprintf("warning: %v\n", err) +} + +// FlagRequired reports a value the command needs and cannot settle itself. +// +// It used to add "(running with --no-prompt)", which was untrue at every call +// site: none of them prompts, so the parenthetical named a flag the caller had +// not passed and implied that dropping it would make the command ask. +func FlagRequired(name string) error { + return fmt.Errorf("--%s is required", name) +} + +// ReadingPath reports a file or directory that could not be read. +func ReadingPath(path string, err error) error { + return fmt.Errorf("reading %s: %w", path, err) +} + +// --------------------------------------------------------------------------- +// Talking to the service +// --------------------------------------------------------------------------- + +// InvalidEndpointURL reports a client built on an endpoint that will not parse. +func InvalidEndpointURL(err error) error { + return fmt.Errorf("invalid endpoint URL: %w", err) +} + +// InvalidRequestPath reports a request path that will not parse. +func InvalidRequestPath(path string, err error) error { + return fmt.Errorf("invalid request path %q: %w", path, err) +} + +// InvalidNextLink reports a pagination link the service sent that will not parse. +func InvalidNextLink(link string, err error) error { + return fmt.Errorf("invalid nextLink %q: %w", link, err) +} + +// NextLinkOffOrigin reports a pagination link pointing somewhere other than the +// project endpoint. Following it would send the caller's token to that host. +func NextLinkOffOrigin(origin string) error { + return fmt.Errorf("refusing to follow nextLink to %s: it is not the project endpoint", origin) +} + +// CreatingRequest reports a request that could not be built. +func CreatingRequest(err error) error { + return fmt.Errorf("failed to create request: %w", err) +} + +// MarshalingRequest reports a request body that would not serialize. +func MarshalingRequest(err error) error { + return fmt.Errorf("failed to marshal request: %w", err) +} + +// SettingRequestBody reports a request body that would not attach. +func SettingRequestBody(err error) error { + return fmt.Errorf("failed to set request body: %w", err) +} + +// RequestFailed reports a request that never reached an answer. +// +// A credential that cannot mint a token fails here rather than as a 401, and +// the SDK's own text for it names neither azd nor the way out. isCredentialFailure +// decides which is which; see it for how. +// +// The hint is in the message as well as the suggestion because the suggestion +// is not rendered on every surface, and it offers a retry first: this call +// shells out to `azd auth token`, which has been seen to fail transiently +// against a login that was perfectly valid -- measured once at over 70 seconds, +// long enough to lose to a deadline. +func RequestFailed(err error) error { + if isCredentialUnavailable(err) { + // Not an expired login, and `azd auth login` cannot be run to fix it. + return exterrors.Auth( + exterrors.CodeAuthFailed, + fmt.Sprintf( + "could not get a token for the Foundry project because azd itself "+ + "could not be run: %v", err), + "check that `azd` is installed and on PATH") + } + if isCredentialFailure(err) { + return exterrors.Auth( + exterrors.CodeLoginExpired, + fmt.Sprintf( + "could not get a token for the Foundry project: %v. "+ + "Try again; if it keeps failing, run `azd auth login`", err), + "try the command again, then `azd auth login` if it keeps failing") + } + return fmt.Errorf("HTTP request failed: %w", err) +} + +// isCredentialUnavailable reports the credential never having run at all, as +// opposed to running and being refused. +// +// azidentity's credentialUnavailableError is unexported, so this matches the +// two messages it carries for that case. Worth separating because the answer +// to both is not `azd auth login` -- you cannot log in with a tool that is not +// on PATH. +func isCredentialUnavailable(err error) bool { + if err == nil { + return false + } + text := err.Error() + return strings.Contains(text, "executable not found on path") || + strings.Contains(text, "is not recognized") +} + +// ServiceRefused turns an unauthorized answer into one that says what to do. +// Every other status is left as the service reported it. +func ServiceRefused(status int, err error) error { + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return exterrors.Auth( + exterrors.CodeAuthFailed, + fmt.Sprintf( + "the Foundry project refused the request (HTTP %d): %v. "+ + "Run `azd auth login`, and check you have access to this project", + status, err), + "run `azd auth login`, and check you have access to this project") + } + return err +} + +// isCredentialFailure reports whether the request failed because no token could +// be minted, rather than for any of the other reasons a request fails. +// +// Decided on the SDK's own error types. This used to also match the phrase +// "failed to acquire a token" anywhere in the text, which any error is free to +// contain -- a service that could not acquire a token bucket lease was told its +// login had expired and to run `azd auth login`. +// +// The credential names stay as a fallback because credentialUnavailableError is +// unexported: a credential that never ran can only be recognized by the name it +// puts in its own message. +func isCredentialFailure(err error) bool { + if err == nil { + return false + } + + var authFailed *azidentity.AuthenticationFailedError + var authRequired *azidentity.AuthenticationRequiredError + if errors.As(err, &authFailed) || errors.As(err, &authRequired) { + return true + } + + text := err.Error() + for _, credential := range []string{ + "AzureDeveloperCLICredential", + "DefaultAzureCredential", + } { + if strings.Contains(text, credential) { + return true + } + } + return false +} + +// ReadingResponseBody reports a response that could not be read. +func ReadingResponseBody(err error) error { + return fmt.Errorf("failed to read response body: %w", err) +} + +// ParsingResponse reports a response that could not be parsed. +func ParsingResponse(err error) error { + return fmt.Errorf("failed to parse response: %w", err) +} + +// InvalidContainerURI reports a storage URI the service handed back unusable. +func InvalidContainerURI(err error) error { + return fmt.Errorf("invalid container SAS URI: %w", err) +} + +// CreatingUploadRequest reports the blob upload request failing to build. +func CreatingUploadRequest(err error) error { + return fmt.Errorf("failed to create upload request: %w", err) +} + +// UploadingBlobFailed reports the blob upload never reaching an answer. +func UploadingBlobFailed(err error) error { + return fmt.Errorf("failed to upload blob: %w", err) +} + +// BlobUploadStatus reports storage refusing the upload. +func BlobUploadStatus(status int, body string) error { + return fmt.Errorf("blob upload failed with status %d: %s", status, body) +} + +// CreatingDownloadRequest reports the dataset download request failing to build. +func CreatingDownloadRequest(err error) error { + return fmt.Errorf("failed to create download request: %w", err) +} + +// DownloadingDatasetBlob reports the dataset download never reaching an answer. +func DownloadingDatasetBlob(err error) error { + return fmt.Errorf("failed to download dataset from blob: %w", err) +} + +// BlobDownloadStatus reports storage refusing the download. +func BlobDownloadStatus(status int) error { + return fmt.Errorf("blob download failed with status %d", status) +} + +// ReadingDatasetContent reports a downloaded dataset that could not be read. +func ReadingDatasetContent(err error) error { + return fmt.Errorf("failed to read dataset content: %w", err) +} + +// CreatingListRequest reports the container listing request failing to build. +func CreatingListRequest(err error) error { + return fmt.Errorf("failed to create list request: %w", err) +} + +// ListingContainerBlobs reports the container listing never reaching an answer. +func ListingContainerBlobs(err error) error { + return fmt.Errorf("failed to list container blobs: %w", err) +} + +// ContainerListStatus reports storage refusing the listing. +func ContainerListStatus(status int) error { + return fmt.Errorf("container list failed with status %d", status) +} + +// ReadingListResponse reports a container listing that could not be read. +func ReadingListResponse(err error) error { + return fmt.Errorf("failed to read list response: %w", err) +} + +// CreatingBlobDownloadRequest reports the blob download request failing to build. +func CreatingBlobDownloadRequest(err error) error { + return fmt.Errorf("failed to create blob download request: %w", err) +} + +// DownloadingBlob reports one blob's download never reaching an answer. +func DownloadingBlob(err error) error { + return fmt.Errorf("failed to download blob: %w", err) +} + +// BlobDownloadStatusFor reports storage refusing one named blob. +func BlobDownloadStatusFor(status int, blobName string) error { + return fmt.Errorf("blob download failed with status %d for %s", status, blobName) +} + +// ReadingBlobContent reports a downloaded blob that could not be read. +func ReadingBlobContent(err error) error { + return fmt.Errorf("failed to read blob content: %w", err) +} + +// ListingTruncated reports a page walk that stopped before the end. +// +// Worth saying out loud rather than logging: a short listing is indistinguishable +// from a complete one, and log goes to io.Discard unless --debug. +func ListingTruncated(pages int) error { + return fmt.Errorf( + "stopped reading the listing after %d pages, so it may be incomplete", pages) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/blob_pages_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/blob_pages_test.go new file mode 100644 index 00000000000..3a1d76a66e2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/blob_pages_test.go @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func blobPage(marker string, names ...string) string { + var body strings.Builder + body.WriteString(``) + for _, n := range names { + fmt.Fprintf(&body, `%s`, n) + } + body.WriteString(`` + marker + ``) + return body.String() +} + +// DownloadDatasetContent falls back to listing the container and taking the +// first .jsonl by name, so a container answered one page at a time could report +// no file, or a different one, depending on where the page happened to end. +func TestListContainerBlobsFollowsTheMarker(t *testing.T) { + var markers []string + + client, srv := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + m := r.URL.Query().Get("marker") + markers = append(markers, m) + w.Header().Set("Content-Type", "application/xml") + switch m { + case "": + fmt.Fprint(w, blobPage("m1", "a.jsonl", "b.jsonl")) + case "m1": + fmt.Fprint(w, blobPage("m2", "c.jsonl")) + default: + fmt.Fprint(w, blobPage("", "d.jsonl")) + } + }) + + names, err := client.ListContainerBlobs(t.Context(), srv.URL+"/container?sig=redacted") + + require.NoError(t, err) + assert.Equal(t, []string{"a.jsonl", "b.jsonl", "c.jsonl", "d.jsonl"}, names) + assert.Equal(t, []string{"", "m1", "m2"}, markers, + "each request has to carry the marker the previous page returned") +} + +// An empty NextMarker is the last page, which is what this did before it could +// see the marker at all. +func TestListContainerBlobsStopsWithoutAMarker(t *testing.T) { + calls := 0 + + client, srv := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, blobPage("", "only.jsonl")) + }) + + names, err := client.ListContainerBlobs(t.Context(), srv.URL+"/container") + + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Equal(t, []string{"only.jsonl"}, names) +} + +// A marker that repeats itself would otherwise spin until the page bound. +func TestListContainerBlobsStopsOnARepeatedMarker(t *testing.T) { + calls := 0 + + client, srv := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, blobPage("stuck", "same.jsonl")) + }) + + _, err := client.ListContainerBlobs(t.Context(), srv.URL+"/container") + + require.NoError(t, err) + assert.Equal(t, 2, calls, "the first request, then the marker once") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/bom_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/bom_test.go new file mode 100644 index 00000000000..0e5ba00e150 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/bom_test.go @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A BOM uploaded as-is becomes part of the first row's first key, so every +// consumer of the dataset sees one malformed record — and nothing fails until +// something tries to read that row. +func TestReadFirstJSONLFile_StripsTheByteOrderMark(t *testing.T) { + dir := t.TempDir() + body := append([]byte{0xEF, 0xBB, 0xBF}, []byte("{\"query\":\"q\"}\n")...) + require.NoError(t, os.WriteFile(filepath.Join(dir, "d.jsonl"), body, 0o600)) + + content, err := ReadFirstJSONLFile(dir) + + require.NoError(t, err) + assert.True(t, strings.HasPrefix(content, `{"query"`), + "the first row has to start with its own first key, got %q", content) +} + +func TestReadFirstJSONLFile_LeavesOrdinaryContentAlone(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "d.jsonl"), []byte("{\"query\":\"q\"}\n"), 0o600)) + + content, err := ReadFirstJSONLFile(dir) + + require.NoError(t, err) + assert.Equal(t, "{\"query\":\"q\"}\n", content) +} + +// A file holding nothing but a BOM is still empty, and registering an empty +// dataset only fails later at the run that scores it. +func TestReadFirstJSONLFile_BOMOnlyFileIsStillEmpty(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "d.jsonl"), []byte{0xEF, 0xBB, 0xBF}, 0o600)) + + _, err := ReadFirstJSONLFile(dir) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no rows") +} + +// One .jsonl per dataset in one folder is the ordinary layout. Scanning the +// directory instead of reading the named file registers the rows of whichever +// sorts first under the other one's name. +func TestReadFirstJSONLFile_ReadsTheNamedFileNotItsNeighbour(t *testing.T) { + dir := t.TempDir() + named := filepath.Join(dir, "zebra.jsonl") + require.NoError(t, os.WriteFile(named, []byte("{\"pick\":\"me\"}\n"), 0o600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "alpha.jsonl"), []byte("{\"pick\":\"not me\"}\n"), 0o600)) + + content, err := ReadFirstJSONLFile(named) + require.NoError(t, err) + assert.Contains(t, content, `"me"`) + assert.NotContains(t, content, "not me") + + // A directory still scans, which is what --from-file means. + content, err = ReadFirstJSONLFile(dir) + require.NoError(t, err) + assert.Contains(t, content, "not me", "the directory form takes the first .jsonl") +} + +// The BOM and empty-file guards have to apply to the named-file path too. +func TestReadFirstJSONLFile_NamedFileGetsTheSameGuards(t *testing.T) { + dir := t.TempDir() + + withBOM := filepath.Join(dir, "bom.jsonl") + body := append([]byte{0xEF, 0xBB, 0xBF}, []byte("{\"query\":\"q\"}\n")...) + require.NoError(t, os.WriteFile(withBOM, body, 0o600)) + + content, err := ReadFirstJSONLFile(withBOM) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(content, `{"query"`), "got %q", content) + + empty := filepath.Join(dir, "empty.jsonl") + require.NoError(t, os.WriteFile(empty, []byte(" \n"), 0o600)) + + _, err = ReadFirstJSONLFile(empty) + require.Error(t, err) + assert.Contains(t, err.Error(), "no rows") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/construction_redaction_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/construction_redaction_test.go new file mode 100644 index 00000000000..f4059a61779 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/construction_redaction_test.go @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "context" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sasSecret is the signature a storage SAS carries in its query string. +const constructionSASSecret = "SIGNATUREVALUETHATMUSTNOTAPPEAR" + +// A URL the parser refuses, still carrying a SAS. Building a request from it +// fails before any transport error can happen, which is the path the redaction +// work missed: Do's failures were wrapped and NewRequest's were not. +func malformedSASURL() string { + return "https://acct.blob.core.windows.net/c/d.jsonl\x7f?sig=" + constructionSASSecret +} + +func constructionClient(t *testing.T) *DatasetClient { + t.Helper() + pipeline := runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, + &policy.ClientOptions{Retry: policy.RetryOptions{MaxRetries: -1}}) + return NewDatasetClientFromPipeline("https://example.invalid", pipeline) +} + +// The premise: the raw error names the URL, so returning it unwrapped hands the +// signature to whoever reads the message or the debug log. +func TestRequestConstructionErrorsDoNotCarryTheSAS(t *testing.T) { + raw := malformedSASURL() + require.Contains(t, raw, constructionSASSecret, "the fixture has to carry a secret to leak") + + client := constructionClient(t) + ctx := context.Background() + + cases := []struct { + name string + call func() error + }{ + {"DownloadDataset", func() error { _, err := client.DownloadDataset(ctx, raw); return err }}, + {"DownloadBlob", func() error { _, err := client.DownloadBlob(ctx, raw, "d.jsonl"); return err }}, + {"UploadBlob", func() error { return client.UploadBlob(ctx, raw, "d.jsonl", []byte("{}")) }}, + {"ListContainerBlobs", func() error { _, err := client.ListContainerBlobs(ctx, raw); return err }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.call() + + require.Error(t, err, "a URL the parser refuses has to fail") + assert.NotContains(t, err.Error(), constructionSASSecret, + "the signature reached the caller through %s", tc.name) + assert.NotContains(t, strings.ToLower(err.Error()), "sig=", + "even the parameter name should not survive") + }) + } +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/download_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/download_test.go new file mode 100644 index 00000000000..9ac6f21e92b --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/download_test.go @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// A dataset's URI points at either the blob or the container holding it, +// depending on how it was created, and nothing in the payload says which: +// isSingleFile is true either way. Uploaded datasets end in the file name; +// generated ones end in the container. Downloading a container returns 409. +func TestLooksLikeBlobURI(t *testing.T) { + uploaded := "https://acct.blob.core.windows.net:443/container-guid/azd-smoke-golden.jsonl" + generated := "https://acct.blob.core.windows.net/asayedahme-420d0b21-956c-513b-bb18-f60bfbf5e724" + + require.True(t, looksLikeBlobURI(uploaded), "an uploaded dataset names its file") + require.False(t, looksLikeBlobURI(generated), "a generated dataset names its container") +} + +// A SAS token on the URI must not change the answer. +func TestLooksLikeBlobURIIgnoresQuery(t *testing.T) { + require.True(t, looksLikeBlobURI( + "https://acct.blob.core.windows.net/c/data.jsonl?sv=2021&sig=abc")) + require.False(t, looksLikeBlobURI( + "https://acct.blob.core.windows.net/c?sv=2021&sig=abc")) + require.False(t, looksLikeBlobURI("https://acct.blob.core.windows.net/c/")) +} + +// An evaluation dataset is JSONL, so that is preferred when a container holds +// more than one file. +func TestPickDatasetBlobPrefersJSONL(t *testing.T) { + require.Equal(t, "data.jsonl", + pickDatasetBlob([]string{"_meta.json", "data.jsonl", "readme.txt"})) + require.Equal(t, "data.JSONL", + pickDatasetBlob([]string{"data.JSONL"}), "the extension match is case-insensitive") +} + +// With nothing recognisable, any real file beats returning nothing. +func TestPickDatasetBlobFallsBackToAnyFile(t *testing.T) { + require.Equal(t, "data.csv", pickDatasetBlob([]string{"data.csv"})) + require.Empty(t, pickDatasetBlob([]string{"folder/"})) + require.Empty(t, pickDatasetBlob(nil)) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/download_wire_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/download_wire_test.go new file mode 100644 index 00000000000..822ad65c627 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/download_wire_test.go @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAPIVersion = "2025-11-15-preview" + +// blobListing is the shape Azure Blob Storage answers a container list with. +func blobListing(names ...string) string { + var b strings.Builder + b.WriteString(``) + for _, n := range names { + b.WriteString("" + n + "") + } + b.WriteString(``) + return b.String() +} + +// storageServer stands in for both the dataset API and blob storage, recording +// what each leg of a download was asked for. +type storageServer struct { + mu sync.Mutex + + // credential is the sasUri handed back for a download, relative to the + // server's own address. + uriPath string + // blobs maps a container-relative blob name to its content. + blobs map[string]string + // directBlobStatus is the status a direct GET of uriPath answers. + directBlobStatus int + + gotListQuery url.Values + gotBlobPaths []string + gotAPIVer []string +} + +func (s *storageServer) start(t *testing.T) (*DatasetClient, *httptest.Server) { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + if v := r.URL.Query().Get("api-version"); v != "" { + s.gotAPIVer = append(s.gotAPIVer, v) + } + + switch { + case strings.HasSuffix(r.URL.Path, "/credentials"): + w.Header().Set("Content-Type", "application/json") + // assert, not require: this runs on the server's goroutine, and + // FailNow there aborts mid-response and fails whichever test is + // running instead. + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "blobReferenceForConsumption": map[string]any{ + "credential": map[string]any{"sasUri": srv.URL + s.uriPath + "?sig=secret"}, + }, + })) + + case r.URL.Query().Get("comp") == "list": + s.gotListQuery = r.URL.Query() + names := make([]string, 0, len(s.blobs)) + for n := range s.blobs { + names = append(names, n) + } + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(blobListing(names...))) + + // A direct read of the credential URI itself, keyed under "". + case r.URL.Path == s.uriPath: + s.gotBlobPaths = append(s.gotBlobPaths, r.URL.Path) + if s.directBlobStatus != 0 { + w.WriteHeader(s.directBlobStatus) + return + } + _, _ = w.Write([]byte(s.blobs[""])) + + default: + s.gotBlobPaths = append(s.gotBlobPaths, r.URL.Path) + body, ok := s.blobs[strings.TrimPrefix(r.URL.Path, s.uriPath+"/")] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(body)) + } + })) + t.Cleanup(srv.Close) + + client := NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + return client, srv +} + +// A dataset that was uploaded names its own file, so it reads in one hop and +// the container must never be listed. +func TestDownloadDatasetContentReadsABlobURIDirectly(t *testing.T) { + server := &storageServer{ + uriPath: "/c/rows.jsonl", + blobs: map[string]string{"": `{"query":"direct"}`}, + } + client, _ := server.start(t) + + data, err := client.DownloadDatasetContent(context.Background(), "ds", "1.0", testAPIVersion) + require.NoError(t, err) + assert.Equal(t, `{"query":"direct"}`, string(data)) + assert.Nil(t, server.gotListQuery, "a blob URI needs no container listing") +} + +// A generated dataset names the container it was written into, and nothing in +// the payload says so: isSingleFile is true either way. Reading the container +// directly returns a 409, so the blob inside has to be found first. +func TestDownloadDatasetContentListsAContainerURI(t *testing.T) { + server := &storageServer{ + uriPath: "/generated-container", + blobs: map[string]string{ + "_meta.json": `{"ignored":true}`, + "data.jsonl": `{"query":"from the container"}`, + }, + } + client, _ := server.start(t) + + data, err := client.DownloadDatasetContent(context.Background(), "ds", "1.0", testAPIVersion) + require.NoError(t, err) + assert.Equal(t, `{"query":"from the container"}`, string(data), + "the JSONL is chosen over the metadata sitting beside it") + require.NotNil(t, server.gotListQuery) + assert.Equal(t, "container", server.gotListQuery.Get("restype")) + assert.Equal(t, "secret", server.gotListQuery.Get("sig"), + "the listing must keep the SAS token, or storage answers 403") +} + +// A URI can name a file and still be a container — the extension is a guess, +// not a fact. When the direct read fails the listing is the fallback, so the +// download succeeds rather than surfacing the first status. +func TestDownloadDatasetContentFallsBackWhenTheBlobReadFails(t *testing.T) { + server := &storageServer{ + uriPath: "/c/looks.jsonl", + directBlobStatus: http.StatusConflict, + blobs: map[string]string{"real.jsonl": `{"query":"found by listing"}`}, + } + client, _ := server.start(t) + + data, err := client.DownloadDatasetContent(context.Background(), "ds", "1.0", testAPIVersion) + require.NoError(t, err, "a 409 on the direct read is the container case, not a failure") + assert.Equal(t, `{"query":"found by listing"}`, string(data)) + assert.NotNil(t, server.gotListQuery) +} + +// An empty container is a dataset with nothing to read, and saying so beats +// returning empty content that looks like a dataset with no rows. +func TestDownloadDatasetContentReportsAnEmptyContainer(t *testing.T) { + server := &storageServer{uriPath: "/empty", blobs: map[string]string{}} + client, _ := server.start(t) + + _, err := client.DownloadDatasetContent(context.Background(), "ds", "1.0", testAPIVersion) + require.Error(t, err) + assert.Contains(t, err.Error(), "no downloadable file") +} + +// The URI carries no SAS of its own, so a credential that resolves to nothing +// has to be reported here rather than as an unauthorized read later. +func TestDownloadDatasetContentRequiresADownloadURI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + + client := NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + _, err := client.DownloadDatasetContent(context.Background(), "ds", "1.0", testAPIVersion) + require.Error(t, err) + assert.Contains(t, err.Error(), "no download URI") +} + +// The blob name is appended to the container path, and the SAS token stays on +// the query where storage expects it. +func TestDownloadBlobKeepsTheSASToken(t *testing.T) { + var gotPath, gotSig string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotSig = r.URL.Path, r.URL.Query().Get("sig") + _, _ = w.Write([]byte("rows")) + })) + t.Cleanup(srv.Close) + + client := NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + data, err := client.DownloadBlob(context.Background(), srv.URL+"/container?sig=secret", "data.jsonl") + require.NoError(t, err) + assert.Equal(t, "rows", string(data)) + assert.Equal(t, "/container/data.jsonl", gotPath) + assert.Equal(t, "secret", gotSig) +} + +// A storage failure has to name the blob, since the container holds several +// and the status alone does not say which one was refused. +func TestDownloadBlobReportsTheStatusAndName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + t.Cleanup(srv.Close) + + client := NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + _, err := client.DownloadBlob(context.Background(), srv.URL+"/c", "data.jsonl") + require.Error(t, err) + assert.Contains(t, err.Error(), "403") + assert.Contains(t, err.Error(), "data.jsonl") +} + +// A malformed URI is the caller's mistake, and it is worth catching before a +// request goes out against a half-parsed address. +func TestBlobOperationsRejectAnUnparseableURI(t *testing.T) { + client := NewDatasetClientFromPipeline( + "https://example", runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + _, err := client.DownloadBlob(context.Background(), "://nope", "x.jsonl") + require.Error(t, err) + + _, err = client.ListContainerBlobs(context.Background(), "://nope") + require.Error(t, err) + + err = client.UploadBlob(context.Background(), "://nope", "x.jsonl", []byte("{}")) + require.Error(t, err) +} + +// Storage answers a listing in XML, and a shape that does not parse yields no +// names rather than a panic. +func TestParseBlobNames(t *testing.T) { + assert.Equal(t, []string{"a.jsonl", "b.json"}, + parseBlobNames(blobListing("a.jsonl", "b.json"))) + assert.Empty(t, parseBlobNames(blobListing())) + assert.Empty(t, parseBlobNames("not xml at all"), + "an unreadable listing is an empty one, not a crash") + assert.Empty(t, parseBlobNames( + ``), + "a nameless blob cannot be downloaded, so it is not offered") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/jsonl_rows_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/jsonl_rows_test.go new file mode 100644 index 00000000000..809dfc8f4b0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/jsonl_rows_test.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The service stores whatever bytes it is given, so a malformed row registers a +// version that looks healthy and only fails in the run that reads it, against a +// line number nobody has any more. It is refused here instead. +func TestJSONLContentRefusesAMalformedRow(t *testing.T) { + good := "{\"query\":\"a\"}\n{\"query\":\"b\"}\n" + _, err := jsonlContent("ds.jsonl", []byte(good)) + require.NoError(t, err) + + _, err = jsonlContent("ds.jsonl", []byte("{\"query\":\"a\"}\nnot json\n{\"query\":\"c\"}\n")) + require.Error(t, err) + assert.Contains(t, err.Error(), "line 2", "the failing row is named by line") + assert.Contains(t, err.Error(), "ds.jsonl") + + _, err = jsonlContent("ds.jsonl", []byte("{\"query\":\"a\"}\n{}\n")) + require.Error(t, err) + assert.Contains(t, err.Error(), "line 2") + + // A blank line between records is formatting, not a row. + _, err = jsonlContent("ds.jsonl", []byte("{\"query\":\"a\"}\n\n{\"query\":\"b\"}\n")) + assert.NoError(t, err) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/list.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/list.go new file mode 100644 index 00000000000..ab2b389b1d9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/list.go @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// DatasetList is the paged response returned when listing datasets or the +// versions of one dataset. +type DatasetList struct { + Value []Dataset `json:"value"` + NextLink string `json:"nextLink,omitempty"` +} + +// ListDatasets returns the datasets registered on the project. +func (c *DatasetClient) ListDatasets(ctx context.Context, apiVersion string) (*DatasetList, error) { + first, err := doRequestTyped[DatasetList](c, ctx, http.MethodGet, pathDatasets, nil, nil, apiVersion) + if err != nil { + return nil, err + } + return c.followPages(ctx, first) +} + +// ListDatasetVersions returns every version of a single dataset. +func (c *DatasetClient) ListDatasetVersions( + ctx context.Context, + name string, + apiVersion string, +) (*DatasetList, error) { + path := fmt.Sprintf("%s/%s/versions", pathDatasets, url.PathEscape(name)) + first, err := doRequestTyped[DatasetList](c, ctx, http.MethodGet, path, nil, nil, apiVersion) + if err != nil { + return nil, err + } + return c.followPages(ctx, first) +} + +// DeleteDatasetVersion removes a single dataset version. +func (c *DatasetClient) DeleteDatasetVersion( + ctx context.Context, + name string, + version string, + apiVersion string, +) error { + path := fmt.Sprintf( + "%s/%s/versions/%s", + pathDatasets, url.PathEscape(name), url.PathEscape(version), + ) + _, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil, apiVersion) + return err +} + +// VersionOrder returns a sortable value for a version string, matching the +// decimal convention NextVersion produces ("1.0", "2.0"). Unparseable versions +// sort lowest. +func VersionOrder(version string) float64 { + v := strings.TrimSpace(version) + if v == "" { + return -1 + } + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + // Fall back to trailing digits, e.g. "v3" -> 3. + i := len(v) + for i > 0 && v[i-1] >= '0' && v[i-1] <= '9' { + i-- + } + if i == len(v) { + return -1 + } + if n, err := strconv.Atoi(v[i:]); err == nil { + return float64(n) + } + return -1 +} + +// VersionGreater reports whether a is a strictly newer version than b. +// +// Both must be orderable; when either is not, the answer is false so an +// unparseable version never triggers a drift failure on its own. +func VersionGreater(a, b string) bool { + orderA, orderB := VersionOrder(a), VersionOrder(b) + if orderA < 0 || orderB < 0 { + return false + } + return orderA > orderB +} + +// LatestVersion returns the highest version in the list, falling back to the +// last entry when none of the versions can be ordered. +func LatestVersion(datasets []Dataset) string { + best := "" + // VersionOrder returns -1 for anything it cannot order, so the sentinel has + // to be -1 rather than lower: below it, the first version it cannot order + // becomes the running best and the fallback below never runs. + bestOrder := -1.0 + for _, d := range datasets { + if o := VersionOrder(d.Version); o > bestOrder { + bestOrder, best = o, d.Version + } + } + if best == "" && len(datasets) > 0 { + return datasets[len(datasets)-1].Version + } + return best +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/models.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/models.go new file mode 100644 index 00000000000..63f84bdff95 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/models.go @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "bufio" + "bytes" + "encoding/json" + "math" + "os" + "path/filepath" + "strconv" + "strings" + + "azureaidataset/internal/messages" +) + +// CreateDatasetRequest is the request body for creating (uploading) a dataset. +type CreateDatasetRequest struct { + Name string `json:"name"` + Version string `json:"version"` + Format string `json:"format"` + Content string `json:"content"` +} + +// Dataset is the response for dataset operations. +// +// The field spelling is not consistent across the surface: the live +// project-endpoint GET returns camelCase (dataUri, isSingleFile), while other +// paths have used snake_case (data_uri, blob_uri, content_uri). Both spellings +// are accepted here because binding only one silently yields an empty URI, +// which then fails much later at download time. +type Dataset struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type,omitempty"` + Format string `json:"format,omitempty"` + + // camelCase spellings (project endpoint). + DataURICamel string `json:"dataUri,omitempty"` + BlobURICamel string `json:"blobUri,omitempty"` + ContentURICamel string `json:"contentUri,omitempty"` + IsSingleFile bool `json:"isSingleFile,omitempty"` + ConnectionName string `json:"connectionName,omitempty"` + + // snake_case spellings. + BlobURI string `json:"blob_uri,omitempty"` + DataURI string `json:"data_uri,omitempty"` + ContentURI string `json:"content_uri,omitempty"` +} + +// ResolvedBlobURI returns the first URI the service supplied, across both +// spellings. An empty result means the dataset carries no downloadable URI and +// the caller must fetch a credential instead. +func (d *Dataset) ResolvedBlobURI() string { + for _, candidate := range []string{ + d.BlobURI, d.BlobURICamel, + d.DataURI, d.DataURICamel, + d.ContentURI, d.ContentURICamel, + } { + if candidate != "" { + return candidate + } + } + return "" +} + +// DatasetCredential is the response for dataset credential (SAS token) requests. +// The API returns a nested structure with blobReference and blobReferenceForConsumption. +type DatasetCredential struct { + // Flat fields (legacy format). + BlobURI string `json:"blob_uri,omitempty"` + SAS string `json:"sas,omitempty"` + SASUri string `json:"sas_uri,omitempty"` + + // Nested fields (current API format). + BlobReference *BlobReference `json:"blobReference,omitempty"` + BlobReferenceConsumption *BlobReference `json:"blobReferenceForConsumption,omitempty"` +} + +// BlobReference represents a blob storage reference with credentials. +type BlobReference struct { + BlobURI string `json:"blobUri,omitempty"` + StorageAccountARM string `json:"storageAccountArmId,omitempty"` + Credential *BlobCredential `json:"credential,omitempty"` +} + +// BlobCredential holds SAS credential details for blob access. +type BlobCredential struct { + Type string `json:"type,omitempty"` + SASUri string `json:"sasUri,omitempty"` + SASPath string `json:"sas,omitempty"` +} + +// ResolvedDownloadURI returns the URL to download the dataset. +// Prefers blobReferenceForConsumption.credential.sasUri (current API), +// then blobReference.credential.sasUri, then flat sas_uri, then blob_uri + sas. +func (c *DatasetCredential) ResolvedDownloadURI() string { + // Current API format: nested blob references. + if c.BlobReferenceConsumption != nil && c.BlobReferenceConsumption.Credential != nil { + if uri := c.BlobReferenceConsumption.Credential.SASUri; uri != "" { + return uri + } + } + if c.BlobReference != nil && c.BlobReference.Credential != nil { + if uri := c.BlobReference.Credential.SASUri; uri != "" { + return uri + } + } + // Legacy flat format. + if c.SASUri != "" { + return c.SASUri + } + if c.BlobURI != "" && c.SAS != "" { + return c.BlobURI + "?" + c.SAS + } + return c.BlobURI +} + +// PendingUploadResponse is returned by the startPendingUpload endpoint. +// It contains a SAS URI for uploading blob data and the blob container URI. +type PendingUploadResponse struct { + BlobReference *BlobReference `json:"blobReference,omitempty"` + BlobReferenceConsumption *BlobReference `json:"blobReferenceForConsumption,omitempty"` + PendingUploadID *string `json:"pendingUploadId,omitempty"` + PendingUploadType string `json:"pendingUploadType,omitempty"` + Version string `json:"version,omitempty"` +} + +// ResolvedUploadURI returns the SAS URI for uploading blobs. +func (p *PendingUploadResponse) ResolvedUploadURI() string { + if p.BlobReference != nil && p.BlobReference.Credential != nil { + if uri := p.BlobReference.Credential.SASUri; uri != "" { + return uri + } + } + return "" +} + +// ResolvedBlobURI returns the blob container URI (without SAS) for the finalize request. +func (p *PendingUploadResponse) ResolvedBlobURI() string { + if p.BlobReference != nil { + return p.BlobReference.BlobURI + } + return "" +} + +// FinalizeDatasetRequest is the request body for finalizing a dataset version +// after blob upload. +type FinalizeDatasetRequest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Type string `json:"type"` + IsReference bool `json:"isReference"` + DataURI string `json:"dataUri"` +} + +// NextVersion computes the next dataset version string. +// +// Rules: +// 1. Empty → "1.0" +// 2. Parsable as a decimal number → increment by 1, format as "N.0" +// 3. Ends with trailing digits → increment the trailing numeric part +// 4. Otherwise → append ".1" +func NextVersion(current string) string { + current = strings.TrimSpace(current) + if current == "" { + return "1.0" + } + + // Try parsing as a decimal number (e.g. "1", "1.0", "2.0"). + if f, err := strconv.ParseFloat(current, 64); err == nil { + return strconv.FormatFloat(math.Floor(f)+1, 'f', 1, 64) + } + + // Find trailing digits and increment them. + i := len(current) - 1 + for i >= 0 && current[i] >= '0' && current[i] <= '9' { + i-- + } + if i < len(current)-1 { + prefix := current[:i+1] + n, err := strconv.Atoi(current[i+1:]) + if err == nil { + return prefix + strconv.Itoa(n+1) + } + } + + return current + ".1" +} + +// utf8BOM is what Windows editors and PowerShell's Set-Content write ahead of +// otherwise valid UTF-8. +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +// ReadFirstJSONLFile reads the rows to upload from a .jsonl file, or from the +// first .jsonl in a directory. +// +// A file path is read as itself. Resolving it to its directory and scanning +// would upload whichever .jsonl sorts first, so pointing at one dataset in a +// folder holding several would register a different one under that name. +func ReadFirstJSONLFile(path string) (string, error) { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + data, err := os.ReadFile(path) //nolint:gosec // local artifact path + if err != nil { + return "", messages.ReadingPath(path, err) + } + return jsonlContent(filepath.Base(path), data) + } + + dir := path + entries, err := os.ReadDir(dir) + if err != nil { + return "", messages.ReadingDatasetDirectory(err) + } + for _, e := range entries { + if e.IsDir() { + continue + } + if strings.EqualFold(filepath.Ext(e.Name()), ".jsonl") { + data, err := os.ReadFile(filepath.Join(dir, e.Name())) //nolint:gosec // local artifact path + if err != nil { + return "", messages.ReadingPath(e.Name(), err) + } + return jsonlContent(e.Name(), data) + } + } + return "", messages.NoJSONLInDirectory(dir) +} + +// jsonlContent prepares one file's bytes for upload. +func jsonlContent(name string, data []byte) (string, error) { + // Windows editors write a BOM. Uploaded as-is it becomes part of the first + // row's first key, so every consumer of the dataset sees one malformed + // record. + data = bytes.TrimPrefix(data, utf8BOM) + // Refused here rather than uploaded: registering an empty dataset succeeds, + // and the failure surfaces at the run that scores it. + if strings.TrimSpace(string(data)) == "" { + return "", messages.DatasetFileHasNoRows(name) + } + if err := validateJSONLRows(name, data); err != nil { + return "", err + } + return string(data), nil +} + +// validateJSONLRows refuses a file the service would happily store. +// +// Upload does not parse the rows, so one malformed line registers a version +// that looks healthy and only fails in the run that reads it. +func validateJSONLRows(name string, data []byte) error { + scanner := bufio.NewScanner(bytes.NewReader(data)) + // A row carrying a whole conversation runs well past the 64KB default. + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + + for line := 1; scanner.Scan(); line++ { + text := strings.TrimSpace(scanner.Text()) + if text == "" { + continue + } + var row map[string]any + if err := json.Unmarshal([]byte(text), &row); err != nil { + return messages.JSONLRowInvalid(name, line, err) + } + if len(row) == 0 { + return messages.JSONLRowEmpty(name, line) + } + } + return scanner.Err() +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/operations.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/operations.go new file mode 100644 index 00000000000..c2ea3d4037b --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/operations.go @@ -0,0 +1,711 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "path" + "strings" + "time" + + "azureaidataset/internal/messages" + "azureaidataset/internal/urlsafe" + "azureaidataset/internal/version" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +// API path prefix for dataset endpoints. +const pathDatasets = "/datasets" + +// DatasetClient provides methods for dataset upload, download, and metadata retrieval. +type DatasetClient struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewDatasetClient creates a new DatasetClient. +func NewDatasetClient(endpoint string, cred azcore.TokenCredential) *DatasetClient { + userAgent := fmt.Sprintf("azd-ext-azure-ai-dataset/%s", version.Version) + + clientOptions := &policy.ClientOptions{ + Logging: policy.LogOptions{ + AllowedHeaders: []string{"X-Ms-Correlation-Request-Id", "X-Request-Id"}, + IncludeBody: false, + }, + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy(cred, []string{"https://ai.azure.com/.default"}, nil), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-datasets", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &DatasetClient{ + endpoint: endpoint, + pipeline: pipeline, + } +} + +// NewDatasetClientFromPipeline creates a DatasetClient with a pre-built pipeline. +// This is intended for tests that need to bypass auth policies. +func NewDatasetClientFromPipeline(endpoint string, pipeline runtime.Pipeline) *DatasetClient { + return &DatasetClient{ + endpoint: endpoint, + pipeline: pipeline, + } +} + +// CreateDataset registers a dataset with inline content (upload). +func (c *DatasetClient) CreateDataset( + ctx context.Context, + request *CreateDatasetRequest, + apiVersion string, +) (*Dataset, error) { + return doRequestTyped[Dataset](c, ctx, http.MethodPost, pathDatasets, nil, request, apiVersion) +} + +// UploadNextVersion registers the next version of a dataset, discovering the +// current one from the service when currentVersion is empty. +// +// Prefer this over UploadNewVersion. That function derives the next version +// from whatever it is handed, so an empty value restarts at 1.0 and the +// service rejects the pending upload with a 409 +// TemporaryDataReferencesForExistingAsset as soon as 1.0 exists. Callers +// almost always mean "the version after whatever is registered", which is what +// this does. +// +// The version listing is eventually consistent — it returns nothing for a +// second or two after a version is created — so an empty listing cannot be +// trusted to mean the dataset is new. A conflict is therefore treated as a +// stale read: the listing is re-read, and when it is still behind, the version +// just refused is taken as proof that it exists and the next one is tried. +// Trusting the listing alone left a second upload issued moments after the +// first reporting a 409 to the user for a publish that should simply have +// added a version. +func (c *DatasetClient) UploadNextVersion( + ctx context.Context, + name string, + currentVersion string, + localDir string, + apiVersion string, +) (*Dataset, error) { + if currentVersion == "" { + latest, err := c.latestRegisteredVersion(ctx, name, apiVersion) + if err != nil { + return nil, err + } + currentVersion = latest + } + + var err error + for range versionConflictAttempts { + var ds *Dataset + ds, err = c.UploadNewVersion(ctx, name, currentVersion, localDir, apiVersion) + if err == nil || !IsVersionConflict(err) { + return ds, err + } + + // The version derived from currentVersion is taken, so it exists + // whatever the listing says. Prefer the listing when it has caught up + // and moved further ahead; otherwise step past what was just refused. + refused := NextVersion(currentVersion) + currentVersion = refused + // A listing failure is not fatal here: the refused version is already a + // correct next step, so only a listing that has moved further ahead + // changes the outcome. + latest, listErr := c.latestRegisteredVersion(ctx, name, apiVersion) + if listErr == nil && versionAtLeast(latest, refused) { + currentVersion = latest + } + } + return nil, err +} + +// versionConflictAttempts bounds the walk past versions the listing has not +// caught up with. Each attempt is one refused pending upload, so this is short. +const versionConflictAttempts = 4 + +// versionAtLeast reports whether a is a version at or beyond b. +func versionAtLeast(a, b string) bool { + if a == "" { + return false + } + return LatestVersion([]Dataset{{Version: a}, {Version: b}}) == a +} + +// latestRegisteredVersion returns the newest registered version. A dataset the +// service does not know, and a listing that has not caught up, both report an +// empty version and no error. Every other failure is returned: treating a 403 +// or a timeout as "no versions" would restart an existing dataset at 1.0. +func (c *DatasetClient) latestRegisteredVersion( + ctx context.Context, + name string, + apiVersion string, +) (string, error) { + list, err := c.ListDatasetVersions(ctx, name, apiVersion) + if err != nil { + if IsNotFound(err) { + return "", nil + } + return "", err + } + if list == nil || len(list.Value) == 0 { + return "", nil + } + return LatestVersion(list.Value), nil +} + +// isVersionConflict reports whether the service refused the upload because the +// target version already exists. +func IsVersionConflict(err error) bool { + respErr, ok := errors.AsType[*azcore.ResponseError](err) + if !ok { + return false + } + return respErr.StatusCode == http.StatusConflict +} + +// IsNotFound reports whether the service answered 404. +// +// A failure part-way through a page walk is refused before the status is read: +// the first page answered, so the dataset exists, and reading that 404 as +// absence restarts an existing dataset at 1.0. +func IsNotFound(err error) bool { + if _, walking := errors.AsType[pageWalkError](err); walking { + return false + } + respErr, ok := errors.AsType[*azcore.ResponseError](err) + if !ok { + return false + } + return respErr.StatusCode == http.StatusNotFound +} + +// UploadNewVersion reads the first JSONL file from localDir, computes the next +// version from currentVersion, and uploads it as a new dataset version using +// the 3-step pending upload flow: +// 1. startPendingUpload → get SAS URI +// 2. Upload blob to SAS URI +// 3. Finalize dataset version with dataUri +func (c *DatasetClient) UploadNewVersion( + ctx context.Context, + name string, + currentVersion string, + localDir string, + apiVersion string, +) (*Dataset, error) { + return c.UploadVersion(ctx, name, NextVersion(currentVersion), localDir, apiVersion) +} + +// UploadVersion publishes the dataset at exactly this version. +// +// Separate from UploadNewVersion because its parameter is the version to +// count from, not the one to write: passing "1.0" there publishes 2.0. An +// author who declares a version means that version. +func (c *DatasetClient) UploadVersion( + ctx context.Context, + name string, + version string, + localDir string, + apiVersion string, +) (*Dataset, error) { + content, err := ReadFirstJSONLFile(localDir) + if err != nil { + return nil, messages.ReadingDatasetFromDir(localDir, err) + } + + newVersion := version + + // Step 1: Start pending upload to get a SAS URI. + pending, err := c.StartPendingUpload(ctx, name, newVersion, apiVersion) + if err != nil { + return nil, messages.StartingPendingUpload(err) + } + + uploadURI := pending.ResolvedUploadURI() + if uploadURI == "" { + return nil, messages.NoUploadURI() + } + // Checked here rather than at step 3, because the two come from different + // fields of the same response and only one of them is needed to write. A + // response carrying the SAS and no blobUri uploaded the bytes and then + // finalized against "/name.jsonl", leaving a blob nothing points at and a + // publish that failed for a reason the message did not name. + blobURI := pending.ResolvedBlobURI() + if blobURI == "" { + return nil, messages.NoBlobURI() + } + + // Step 2: Upload the JSONL file to blob storage. + blobName := name + ".jsonl" + if err := c.UploadBlob(ctx, uploadURI, blobName, []byte(content)); err != nil { + return nil, messages.UploadingBlob(err) + } + + // Step 3: Finalize the dataset version with the full blob URI. + dataURI := strings.TrimSuffix(blobURI, "/") + "/" + blobName + return c.FinalizeDatasetVersion(ctx, name, newVersion, dataURI, apiVersion) +} + +// StartPendingUpload initiates a pending upload for a dataset version. +// Returns the SAS URI and blob reference for uploading data. +func (c *DatasetClient) StartPendingUpload( + ctx context.Context, + name string, + version string, + apiVersion string, +) (*PendingUploadResponse, error) { + path := fmt.Sprintf( + "%s/%s/versions/%s/startPendingUpload", + pathDatasets, url.PathEscape(name), url.PathEscape(version), + ) + return doRequestTyped[PendingUploadResponse](c, ctx, http.MethodPost, path, nil, json.RawMessage(`{}`), apiVersion) +} + +// blobHTTPClient is the client used for direct blob calls. +// +// Bounded: these bypass the SDK pipeline, so nothing else stops a hung storage +// endpoint from holding the command open until someone kills it. Generous, so +// a large dataset over a slow link still finishes. +var blobHTTPClient = &http.Client{Timeout: 10 * time.Minute} + +// UploadBlob uploads data to a container SAS URI as a block blob. +func (c *DatasetClient) UploadBlob(ctx context.Context, containerSASUri, blobName string, data []byte) error { + u, err := url.Parse(containerSASUri) + if err != nil { + return messages.InvalidContainerURI(urlsafe.Error(err)) + } + + // Append blob name to the container path. + u.Path = strings.TrimSuffix(u.Path, "/") + "/" + blobName + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), bytes.NewReader(data)) + if err != nil { + return messages.CreatingUploadRequest(urlsafe.Error(err)) + } + req.Header.Set("x-ms-blob-type", "BlockBlob") + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := blobHTTPClient.Do(req) + if err != nil { + return messages.UploadingBlobFailed(urlsafe.Error(err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return messages.BlobUploadStatus(resp.StatusCode, string(body)) + } + + return nil +} + +// FinalizeDatasetVersion completes the dataset version after blob upload +// by sending the metadata (name, version, dataUri) to the API. +func (c *DatasetClient) FinalizeDatasetVersion( + ctx context.Context, + name string, + version string, + dataURI string, + apiVersion string, +) (*Dataset, error) { + path := fmt.Sprintf("%s/%s/versions/%s", pathDatasets, url.PathEscape(name), url.PathEscape(version)) + request := &FinalizeDatasetRequest{ + Name: name, + Version: version, + Type: "uri_file", + DataURI: dataURI, + } + return doRequestTyped[Dataset](c, ctx, http.MethodPut, path, nil, request, apiVersion) +} + +// GetDataset retrieves metadata for a dataset by name and version. +func (c *DatasetClient) GetDataset( + ctx context.Context, + name string, + version string, + apiVersion string, +) (*Dataset, error) { + path := fmt.Sprintf("%s/%s/versions/%s", pathDatasets, url.PathEscape(name), url.PathEscape(version)) + return doRequestTyped[Dataset](c, ctx, http.MethodGet, path, nil, nil, apiVersion) +} + +// GetDatasetCredential retrieves a SAS credential for downloading a dataset from blob storage. +func (c *DatasetClient) GetDatasetCredential( + ctx context.Context, + name string, + version string, + apiVersion string, +) (*DatasetCredential, error) { + path := fmt.Sprintf( + "%s/%s/versions/%s/credentials", + pathDatasets, url.PathEscape(name), url.PathEscape(version), + ) + return doRequestTyped[DatasetCredential](c, ctx, http.MethodPost, path, nil, nil, apiVersion) +} + +// DownloadDatasetContent fetches a dataset version's content, whether its URI +// names a blob or a container. +// +// The two differ by origin, not by any field: a dataset uploaded through +// startPendingUpload gets a URI ending in the file name, while one produced by +// a generation job gets the container it was written into, with isSingleFile +// true either way. Downloading the container directly returns a 409, so the +// blob inside has to be found first. +// +// A credential is always fetched, because the URI on the dataset carries no +// SAS token and an unauthenticated read fails. +func (c *DatasetClient) DownloadDatasetContent( + ctx context.Context, + name string, + version string, + apiVersion string, +) ([]byte, error) { + cred, err := c.GetDatasetCredential(ctx, name, version, apiVersion) + if err != nil { + return nil, messages.ReadingDownloadCredentials(name, err) + } + + sasURI := cred.ResolvedDownloadURI() + if sasURI == "" { + return nil, messages.NoDownloadURI(name) + } + + // A URI whose last path segment carries a file extension is the blob + // itself; anything else is the container holding it. + if looksLikeBlobURI(sasURI) { + data, err := c.DownloadDataset(ctx, sasURI) + if err == nil { + return data, nil + } + log.Printf("[dataset_api] direct download failed (%v); treating the URI as a container", err) + } + + names, err := c.ListContainerBlobs(ctx, sasURI) + if err != nil { + return nil, messages.ListingDatasetContent(name, err) + } + blobName := pickDatasetBlob(names) + if blobName == "" { + return nil, messages.DatasetHasNoFile(name) + } + return c.DownloadBlob(ctx, sasURI, blobName) +} + +// looksLikeBlobURI reports whether the URI's final segment names a file. +func looksLikeBlobURI(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + last := path.Base(strings.TrimSuffix(u.Path, "/")) + return path.Ext(last) != "" +} + +// pickDatasetBlob chooses the file to read from a container, preferring JSONL +// since that is what an evaluation dataset is. +func pickDatasetBlob(names []string) string { + for _, n := range names { + if strings.EqualFold(path.Ext(n), ".jsonl") { + return n + } + } + for _, n := range names { + if n != "" && !strings.HasSuffix(n, "/") { + return n + } + } + return "" +} + +// DownloadDataset downloads dataset content from blob storage using a SAS-authenticated URL. +// Returns the raw content as bytes. The downloadURL should be the full URL with SAS token +// (e.g., from DatasetCredential.ResolvedDownloadURI()). +func (c *DatasetClient) DownloadDataset(ctx context.Context, downloadURL string) ([]byte, error) { + req, err := runtime.NewRequest(ctx, http.MethodGet, downloadURL) + if err != nil { + return nil, messages.CreatingDownloadRequest(urlsafe.Error(err)) + } + + // Use a plain HTTP client for blob downloads — the SAS token in the URL provides + // authentication, and Azure SDK pipeline policies (bearer token, correlation ID) + // should not be sent to Azure Blob Storage endpoints. + resp, err := blobHTTPClient.Do(req.Raw()) + if err != nil { + return nil, messages.DownloadingDatasetBlob(urlsafe.Error(err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, messages.BlobDownloadStatus(resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, messages.ReadingDatasetContent(err) + } + + log.Printf("[dataset_api] downloaded %d bytes", len(data)) + return data, nil +} + +// ListContainerBlobs lists blobs in a container using a container-level SAS URI. +// The containerSASUri should include the SAS token (e.g., from credential.sasUri with sr=c). +// Returns a list of blob names found in the container. +func (c *DatasetClient) ListContainerBlobs(ctx context.Context, containerSASUri string) ([]string, error) { + // Parse the container URI and append list query parameters. + u, err := url.Parse(containerSASUri) + if err != nil { + return nil, messages.InvalidContainerURI(urlsafe.Error(err)) + } + + // The Blob service answers one page and a NextMarker. Only the marker value + // comes from the service -- the URL is the one built here -- so this walk + // carries none of the risk that following a body-supplied link would. + var names []string + marker := "" + for range maxListPages { + page := *u + q := page.Query() + q.Set("restype", "container") // cspell:ignore restype — Azure Storage API query parameter + q.Set("comp", "list") + if marker != "" { + q.Set("marker", marker) + } + page.RawQuery = q.Encode() + + log.Printf("[dataset_api] listing blobs: %s", urlsafe.URL(&page)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, page.String(), nil) + if err != nil { + return nil, messages.CreatingListRequest(urlsafe.Error(err)) + } + + pageNames, next, err := c.readBlobPage(req) + if err != nil { + return nil, err + } + names = append(names, pageNames...) + if next == "" || next == marker { + break + } + marker = next + } + + log.Printf("[dataset_api] found %d blobs in container", len(names)) + return names, nil +} + +// readBlobPage performs one container listing request. +func (c *DatasetClient) readBlobPage(req *http.Request) ([]string, string, error) { + //nolint:gosec // the URI is the SAS the dataset service issued for this dataset, not caller input + resp, err := blobHTTPClient.Do(req) + if err != nil { + return nil, "", messages.ListingContainerBlobs(urlsafe.Error(err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, "", messages.ContainerListStatus(resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", messages.ReadingListResponse(err) + } + names, next := parseBlobPage(string(body)) + return names, next, nil +} + +// DownloadBlob downloads a single blob from a container using the container SAS URI +// and the blob name. Returns the blob content as bytes. +func (c *DatasetClient) DownloadBlob(ctx context.Context, containerSASUri, blobName string) ([]byte, error) { + u, err := url.Parse(containerSASUri) + if err != nil { + return nil, messages.InvalidContainerURI(urlsafe.Error(err)) + } + + // Append blob name to the container path. + u.Path = strings.TrimSuffix(u.Path, "/") + "/" + blobName + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, messages.CreatingBlobDownloadRequest(urlsafe.Error(err)) + } + + resp, err := blobHTTPClient.Do(req) + if err != nil { + return nil, messages.DownloadingBlob(urlsafe.Error(err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, messages.BlobDownloadStatusFor(resp.StatusCode, blobName) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, messages.ReadingBlobContent(err) + } + + log.Printf("[dataset_api] downloaded blob %s (%d bytes)", blobName, len(data)) + return data, nil +} + +// parseBlobNames extracts blob names from the Azure Blob Storage XML list response +// using proper XML parsing against the EnumerationResults schema. +func parseBlobNames(xmlBody string) []string { + names, _ := parseBlobPage(xmlBody) + return names +} + +// parseBlobPage extracts one page of blob names and the marker that continues +// the listing. An empty marker means this was the last page. +func parseBlobPage(xmlBody string) ([]string, string) { + type blob struct { + Name string `xml:"Name"` + } + type blobs struct { + Blob []blob `xml:"Blob"` + } + type enumerationResults struct { + Blobs blobs `xml:"Blobs"` + NextMarker string `xml:"NextMarker"` + } + + var result enumerationResults + if err := xml.Unmarshal([]byte(xmlBody), &result); err != nil { + return nil, "" + } + + names := make([]string, 0, len(result.Blobs.Blob)) + for _, b := range result.Blobs.Blob { + if b.Name != "" { + names = append(names, b.Name) + } + } + return names, result.NextMarker +} + +// doRequest performs an HTTP request against the dataset API and returns the raw response body. +func (c *DatasetClient) doRequest( + ctx context.Context, + method string, + path string, + query map[string]string, + body any, + apiVersion string, +) ([]byte, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, messages.InvalidEndpointURL(err) + } + + // Callers escape the name and version they interpolate, so the path is set + // as the raw one. Assigning it to u.Path re-escapes the percent signs, and + // a dataset named "my dataset" then addresses one named "my%20dataset". + escapedPath := u.EscapedPath() + path + decodedPath, err := url.PathUnescape(escapedPath) + if err != nil { + return nil, messages.InvalidRequestPath(escapedPath, err) + } + u.Path, u.RawPath = decodedPath, escapedPath + + q := u.Query() + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + for k, v := range query { + q.Set(k, v) + } + u.RawQuery = q.Encode() + + req, err := runtime.NewRequest(ctx, method, u.String()) + if err != nil { + return nil, messages.CreatingRequest(err) + } + + log.Printf("[dataset_api] %s %s", method, urlsafe.URL(u)) + + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return nil, messages.MarshalingRequest(err) + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, messages.SettingRequestBody(err) + } + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, messages.RequestFailed(err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, messages.ReadingResponseBody(err) + } + + log.Printf("[dataset_api] response status: %d", resp.StatusCode) + + // 204 belongs here for the same reason it does in eval_api: a delete that + // removed the version answers No Content, and rejecting that reports every + // successful delete as an error. + if !runtime.HasStatusCode(resp, + http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent) { + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + return nil, messages.ServiceRefused(resp.StatusCode, runtime.NewResponseError(resp)) + } + + return respBody, nil +} + +// doRequestTyped performs an HTTP request and unmarshals the response into T. +func doRequestTyped[T any]( + c *DatasetClient, + ctx context.Context, + method string, + path string, + query map[string]string, + body any, + apiVersion string, +) (*T, error) { + respBody, err := c.doRequest(ctx, method, path, query, body, apiVersion) + if err != nil { + return nil, err + } + + if len(respBody) == 0 { + return new(T), nil + } + + var result T + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, messages.ParsingResponse(err) + } + + return &result, nil +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/operations_wire_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/operations_wire_test.go new file mode 100644 index 00000000000..00f4bb8ff19 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/operations_wire_test.go @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeCredential satisfies the constructor without reaching for a real token. +type fakeCredential struct{} + +func (fakeCredential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "fake", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +// recordedCall is one request the client made, as the service saw it. +type recordedCall struct { + method string + path string + rawPath string + apiVersion string +} + +// recordingDatasetClient answers every request with body and status, recording +// what was asked. Retries are off so a deliberate failure is one call. +func recordingDatasetClient(t *testing.T, status int, body string) (*DatasetClient, *[]recordedCall) { + t.Helper() + calls := &[]recordedCall{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *calls = append(*calls, recordedCall{ + method: r.Method, + path: r.URL.Path, + rawPath: r.URL.EscapedPath(), + apiVersion: r.URL.Query().Get("api-version"), + }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != "" { + _, _ = w.Write([]byte(body)) + } + })) + t.Cleanup(srv.Close) + + pipeline := runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, + &policy.ClientOptions{Retry: policy.RetryOptions{MaxRetries: -1}}) + return NewDatasetClientFromPipeline(srv.URL, pipeline), calls +} + +// The paths are the service contract, and a wrong one costs a round trip to +// find out. Each is pinned against the shape the API documents. +func TestDatasetOperationPaths(t *testing.T) { + cases := []struct { + name string + call func(c *DatasetClient) error + wantMethod string + wantPath string + }{ + { + name: "list", + call: func(c *DatasetClient) error { _, err := c.ListDatasets(t.Context(), testAPIVersion); return err }, + wantMethod: http.MethodGet, + wantPath: "/datasets", + }, + { + name: "list versions", + call: func(c *DatasetClient) error { + _, err := c.ListDatasetVersions(t.Context(), "ds", testAPIVersion) + return err + }, + wantMethod: http.MethodGet, + wantPath: "/datasets/ds/versions", + }, + { + name: "get", + call: func(c *DatasetClient) error { + _, err := c.GetDataset(t.Context(), "ds", "1.0", testAPIVersion) + return err + }, + wantMethod: http.MethodGet, + wantPath: "/datasets/ds/versions/1.0", + }, + { + name: "credential", + call: func(c *DatasetClient) error { + _, err := c.GetDatasetCredential(t.Context(), "ds", "1.0", testAPIVersion) + return err + }, + wantMethod: http.MethodPost, + wantPath: "/datasets/ds/versions/1.0/credentials", + }, + { + name: "start pending upload", + call: func(c *DatasetClient) error { + _, err := c.StartPendingUpload(t.Context(), "ds", "1.0", testAPIVersion) + return err + }, + wantMethod: http.MethodPost, + wantPath: "/datasets/ds/versions/1.0/startPendingUpload", + }, + { + name: "finalize", + call: func(c *DatasetClient) error { + _, err := c.FinalizeDatasetVersion(t.Context(), "ds", "1.0", "https://x/y.jsonl", testAPIVersion) + return err + }, + wantMethod: http.MethodPut, + wantPath: "/datasets/ds/versions/1.0", + }, + { + name: "create", + call: func(c *DatasetClient) error { + _, err := c.CreateDataset(t.Context(), &CreateDatasetRequest{Name: "ds"}, testAPIVersion) + return err + }, + wantMethod: http.MethodPost, + wantPath: "/datasets", + }, + { + name: "delete", + call: func(c *DatasetClient) error { return c.DeleteDatasetVersion(t.Context(), "ds", "1.0", testAPIVersion) }, + wantMethod: http.MethodDelete, + wantPath: "/datasets/ds/versions/1.0", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client, calls := recordingDatasetClient(t, http.StatusOK, `{"name":"ds","version":"1.0","value":[]}`) + require.NoError(t, tc.call(client)) + require.Len(t, *calls, 1) + assert.Equal(t, tc.wantMethod, (*calls)[0].method) + assert.Equal(t, tc.wantPath, (*calls)[0].path) + assert.Equal(t, testAPIVersion, (*calls)[0].apiVersion, + "the service rejects a request that names no api-version") + }) + } +} + +// A name is caller-supplied and a version can be anything the author wrote, so +// both are escaped rather than pasted into the path. +func TestDatasetPathsEscapeNameAndVersion(t *testing.T) { + client, calls := recordingDatasetClient(t, http.StatusOK, `{}`) + _, err := client.GetDataset(t.Context(), "my dataset/v", "1.0 beta", testAPIVersion) + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, "/datasets/my%20dataset%2Fv/versions/1.0%20beta", (*calls)[0].rawPath, + "an unescaped slash would address a different resource entirely") +} + +// A delete answers 204 with nothing in it, which must not read as a failure to +// parse a body that was never promised. +func TestDeleteDatasetVersionAcceptsNoContent(t *testing.T) { + client, calls := recordingDatasetClient(t, http.StatusNoContent, "") + require.NoError(t, client.DeleteDatasetVersion(t.Context(), "ds", "1.0", testAPIVersion)) + assert.Len(t, *calls, 1) +} + +// The listing arrives wrapped in a value envelope; reading it flat yields an +// empty list rather than an error, which looks like a project with no datasets. +func TestListDatasetsReadsTheValueEnvelope(t *testing.T) { + client, _ := recordingDatasetClient(t, http.StatusOK, + `{"value":[{"name":"a","version":"1.0"},{"name":"b","version":"2.0"}]}`) + + list, err := client.ListDatasets(t.Context(), testAPIVersion) + require.NoError(t, err) + require.Len(t, list.Value, 2) + assert.Equal(t, "a", list.Value[0].Name) + assert.Equal(t, "2.0", list.Value[1].Version) +} + +// A failure has to surface as one, since the caller otherwise proceeds with a +// zero-valued dataset and fails somewhere further away. +func TestDatasetOperationsSurfaceServiceFailures(t *testing.T) { + client, _ := recordingDatasetClient(t, http.StatusNotFound, `{"error":{"code":"NotFound"}}`) + + _, err := client.GetDataset(t.Context(), "missing", "1.0", testAPIVersion) + require.Error(t, err) + + err = client.DeleteDatasetVersion(t.Context(), "missing", "1.0", testAPIVersion) + require.Error(t, err) + + _, err = client.ListDatasets(t.Context(), testAPIVersion) + require.Error(t, err) +} + +// The constructor has to build a usable client — it wires the auth policies +// the live service needs, and nothing else exercises that path. +func TestNewDatasetClient(t *testing.T) { + client := NewDatasetClient("https://example.services.ai.azure.com/api/projects/p", fakeCredential{}) + require.NotNil(t, client) + assert.Equal(t, "https://example.services.ai.azure.com/api/projects/p", client.endpoint) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/page_walk_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/page_walk_test.go new file mode 100644 index 00000000000..251214b7382 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/page_walk_test.go @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Refusing IsNotFound must not cost the rest of the error's identity. +// +// The first version of this guard dropped Unwrap altogether, which did stop a +// later-page 404 reading as absence but also made a cancelled walk stop looking +// like a cancellation to everything upstream. +func TestAPageWalkFailureKeepsItsCause(t *testing.T) { + wrapped := pageWalkError{cause: context.Canceled} + + assert.True(t, errors.Is(wrapped, context.Canceled), + "a walk cancelled part-way through is still a cancellation") + assert.False(t, IsNotFound(wrapped), + "the first page answered, so the dataset is not missing") + assert.Contains(t, wrapped.Error(), "later page", + "and the message says which part of the listing failed") +} + +// A 404 on the first page means the service does not know this dataset. A 404 +// on a later page means the continuation failed -- the first page already +// proved the dataset exists. Reading the second as the first answered "no +// versions, no error", which restarts an existing dataset at 1.0. +func TestALaterPageFailingIsNotAbsence(t *testing.T) { + // The nextLink is built from the server's own URL rather than echoed back + // from the request: reflecting r.Host into a response body is a taint sink, + // and gosec is right to refuse it even in a test. + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + http.Error(w, `{"error":{"code":"NotFound"}}`, http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte( + `{"value":[{"name":"ds","version":"3.0"}],"nextLink":"` + + srv.URL + `/datasets/ds/versions?page=2"}`)) + })) + t.Cleanup(srv.Close) + + client := NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + _, err := client.ListDatasetVersions(t.Context(), "ds", "2025-11-15-preview") + require.Error(t, err) + assert.False(t, IsNotFound(err), + "the first page proved the dataset exists; a later 404 is the walk failing") + + version, err := client.latestRegisteredVersion(t.Context(), "ds", "2025-11-15-preview") + require.Error(t, err, "a failed walk must not answer with a version") + assert.Empty(t, version) + assert.Contains(t, strings.ToLower(err.Error()), "page") +} + +// The first page answering 404 is still absence, which is what lets a create +// know the name is free. +func TestAFirstPage404IsStillAbsence(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"code":"NotFound"}}`, http.StatusNotFound) + })) + t.Cleanup(srv.Close) + + client := NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + _, err := client.ListDatasetVersions(t.Context(), "ds", "2025-11-15-preview") + require.Error(t, err) + assert.True(t, IsNotFound(err)) + + version, err := client.latestRegisteredVersion(t.Context(), "ds", "2025-11-15-preview") + require.NoError(t, err, "an unknown dataset has no versions and that is not a failure") + assert.Empty(t, version) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/pages.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/pages.go new file mode 100644 index 00000000000..6724b3045b8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/pages.go @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + + "azureaidataset/internal/messages" + "azureaidataset/internal/urlsafe" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" +) + +// maxListPages bounds page following so a service that keeps handing back a +// nextLink cannot spin forever. +const maxListPages = 100 + +// pageWalkError marks a failure that happened after the first page. +// +// The first page answered, so the dataset exists; a 404 on a later page is the +// continuation failing, not the dataset being unknown. IsNotFound refuses this +// wrapper for that reason -- but the cause is still reachable, so a cancelled +// context or an auth failure part-way through a walk classifies as itself +// rather than as an unreadable listing. +type pageWalkError struct{ cause error } + +func (e pageWalkError) Error() string { + return "reading a later page of the listing: " + e.cause.Error() +} + +func (e pageWalkError) Unwrap() error { return e.cause } + +// followPages walks nextLink until the service stops sending one, returning a +// single list holding every page. Without this, a project with more than one +// page lists incompletely and a latest-version check can decide from a stale +// first page. +func (c *DatasetClient) followPages(ctx context.Context, first *DatasetList) (*DatasetList, error) { + if first == nil { + return nil, nil + } + + // Copied rather than aliased: appending to first.Value could write into the + // caller's backing array when it has spare capacity. + out := &DatasetList{Value: append([]Dataset(nil), first.Value...)} + seen := map[string]bool{} + for next := first.NextLink; next != ""; { + if seen[next] || len(seen) >= maxListPages { + // A repeated or endless link is the service misbehaving, not a reason + // to fail the command -- but the list is short and, said through log, + // nobody would know: log goes to io.Discard unless --debug. + fmt.Fprint(os.Stderr, messages.Warning(messages.ListingTruncated(len(seen)))) + break + } + seen[next] = true + + body, err := c.doRequestGetURL(ctx, next) + if err != nil { + return nil, pageWalkError{cause: err} + } + var page DatasetList + // A page that answers 200 with no body ends the walk; unmarshaling it + // would throw away every page already collected. + if len(body) > 0 { + if err := json.Unmarshal(body, &page); err != nil { + return nil, messages.ParsingResponse(err) + } + } + out.Value = append(out.Value, page.Value...) + next = page.NextLink + } + return out, nil +} + +// sameOrigin reports whether two URLs share a scheme and host. +func sameOrigin(a, b *url.URL) bool { + return strings.EqualFold(a.Scheme, b.Scheme) && strings.EqualFold(a.Host, b.Host) +} + +// doRequestGetURL issues a GET against an absolute URL the service supplied, +// such as a nextLink. The URL is refused unless it shares the endpoint's +// origin: the pipeline attaches the caller's token, so a link pointing +// elsewhere would hand that token to another host. +func (c *DatasetClient) doRequestGetURL(ctx context.Context, rawURL string) ([]byte, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, messages.InvalidNextLink(rawURL, err) + } + base, err := url.Parse(c.endpoint) + if err != nil { + return nil, messages.InvalidEndpointURL(err) + } + + // A nextLink is allowed to be relative. Resolving it against the endpoint + // first keeps the origin check meaningful instead of rejecting a legitimate + // relative link for having no scheme or host of its own. + u := base.ResolveReference(parsed) + if !sameOrigin(u, base) { + return nil, messages.NextLinkOffOrigin(u.Scheme + "://" + u.Host) + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, u.String()) + if err != nil { + return nil, messages.CreatingRequest(err) + } + + log.Printf("[dataset_api] GET %s", urlsafe.URL(u)) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, messages.RequestFailed(err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, messages.ReadingResponseBody(err) + } + if !runtime.HasStatusCode(resp, http.StatusOK) { + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + return nil, messages.ServiceRefused(resp.StatusCode, runtime.NewResponseError(resp)) + } + return respBody, nil +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_edge_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_edge_test.go new file mode 100644 index 00000000000..80c44108c21 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_edge_test.go @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "fmt" + "net/http" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A nextLink is allowed to be relative. Rejecting one for having no scheme or +// host of its own would fail a legitimate listing, so it is resolved against +// the endpoint before the origin check runs. +func TestListDatasetsFollowsARelativeNextLink(t *testing.T) { + c, _ := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "" { + fmt.Fprint(w, `{"value":[{"name":"one"}],"nextLink":"/datasets?page=2"}`) + return + } + fmt.Fprint(w, `{"value":[{"name":"two"}]}`) + }) + + list, err := c.ListDatasets(t.Context(), testAPIVersion) + require.NoError(t, err, "a relative nextLink must be followed, not refused") + require.NotNil(t, list) + require.Len(t, list.Value, 2) + assert.Equal(t, "two", list.Value[1].Name) +} + +// An empty 200 ends the walk. Unmarshaling it would fail and throw away every +// page already collected. +func TestListDatasetsKeepsEarlierPagesWhenAPageComesBackEmpty(t *testing.T) { + var base string + client, srv := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "" { + fmt.Fprintf(w, `{"value":[{"name":"kept"}],"nextLink":%q}`, base+"/datasets?page=2") + return + } + w.WriteHeader(http.StatusOK) // no body + }) + base = srv.URL + + list, err := client.ListDatasets(t.Context(), testAPIVersion) + require.NoError(t, err, "an empty page ends the walk rather than failing it") + require.NotNil(t, list) + require.Len(t, list.Value, 1) + assert.Equal(t, "kept", list.Value[0].Name) +} + +// followPages must not append into the first page's backing array, which the +// caller still owns. +func TestFollowPagesDoesNotWriteIntoTheCallersSlice(t *testing.T) { + backing := make([]Dataset, 1, 4) + backing[0] = Dataset{Name: "first"} + first := &DatasetList{Value: backing} + + var hits int32 + var base string + client, srv := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + fmt.Fprint(w, `{"value":[{"name":"second"}]}`) + }) + base = srv.URL + first.NextLink = base + "/datasets?page=2" + + out, err := client.followPages(t.Context(), first) + require.NoError(t, err) + require.Len(t, out.Value, 2) + assert.Equal(t, "first", backing[0].Name) + assert.Equal(t, 1, len(first.Value), "the caller's slice keeps its own length") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_origin_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_origin_test.go new file mode 100644 index 00000000000..2f83b3679c0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_origin_test.go @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Resolving a relative nextLink against the endpoint is what makes a +// protocol-relative link dangerous: "//host/path" has no scheme of its own, so +// it inherits the endpoint's and resolves to a different host entirely. The +// pipeline attaches the caller's token, so that host must never be contacted. +func TestListDatasetsRefusesAProtocolRelativeNextLink(t *testing.T) { + var elsewhereHits int32 + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&elsewhereHits, 1) + fmt.Fprint(w, `{"value":[{"name":"leaked"}]}`) + })) + t.Cleanup(elsewhere.Close) + + // elsewhere.URL is http://127.0.0.1:PORT; strip the scheme to make it + // protocol-relative, which is the form that inherits ours. + hostOnly := elsewhere.URL[len("http:"):] + + client, _ := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"value":[{"name":"one"}],"nextLink":%q}`, hostOnly+"/datasets") + }) + + _, err := client.ListDatasets(t.Context(), testAPIVersion) + + require.Error(t, err, "a protocol-relative link to another host must be refused") + assert.Zero(t, atomic.LoadInt32(&elsewhereHits), + "the other host must never receive a request carrying our token") +} + +// A guard that only remembers the previous link lets a two-hop cycle through. +// This one alternates A and B forever, so it hangs rather than merely running +// long if the walk does not remember every link it has followed. +func TestListDatasetsStopsOnATwoHopCycle(t *testing.T) { + var base string + var hits int32 + + client, srv := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + next := "a" + if r.URL.Query().Get("page") == "a" { + next = "b" + } + fmt.Fprintf(w, `{"value":[{"name":"loop"}],"nextLink":%q}`, base+"/datasets?page="+next) + }) + base = srv.URL + + list, err := client.ListDatasets(t.Context(), testAPIVersion) + require.NoError(t, err, "a cycle ends the walk rather than failing the command") + require.NotNil(t, list) + assert.LessOrEqual(t, atomic.LoadInt32(&hits), int32(4), + "A to B to A must terminate, not alternate forever") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_test.go new file mode 100644 index 00000000000..2365eff26b2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/paging_test.go @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func pagingClient(t *testing.T, h http.HandlerFunc) (*DatasetClient, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)), srv +} + +// A project with more datasets than fit in one page must list completely; +// stopping at page one silently hides datasets and lets a latest-version check +// decide from a stale prefix. +func TestListDatasetsFollowsNextLinkAcrossPages(t *testing.T) { + var srvURL string + c, srv := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "": + fmt.Fprintf(w, `{"value":[{"name":"one"}],"nextLink":%q}`, srvURL+"/datasets?page=2") + case "2": + fmt.Fprintf(w, `{"value":[{"name":"two"}],"nextLink":%q}`, srvURL+"/datasets?page=3") + default: + fmt.Fprint(w, `{"value":[{"name":"three"}]}`) + } + }) + srvURL = srv.URL + + list, err := c.ListDatasets(t.Context(), testAPIVersion) + require.NoError(t, err) + require.NotNil(t, list) + + var names []string + for _, d := range list.Value { + names = append(names, d.Name) + } + assert.Equal(t, []string{"one", "two", "three"}, names, "every page contributes") +} + +// nextLink is service-supplied and the pipeline attaches the caller's token, so +// a link off the project's origin must be refused rather than followed. +func TestListDatasetsRefusesANextLinkOffTheEndpointOrigin(t *testing.T) { + var elsewhereHits int32 + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&elsewhereHits, 1) + fmt.Fprint(w, `{"value":[{"name":"leaked"}]}`) + })) + t.Cleanup(elsewhere.Close) + + c, _ := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"value":[{"name":"one"}],"nextLink":%q}`, elsewhere.URL+"/datasets") + }) + + _, err := c.ListDatasets(t.Context(), testAPIVersion) + + require.Error(t, err, "an off-origin nextLink must fail the call, not be followed") + assert.Contains(t, err.Error(), "not the project endpoint") + assert.Zero(t, atomic.LoadInt32(&elsewhereHits), "the other host must never be contacted") +} + +// A service that keeps returning the same link must not spin forever. +func TestListDatasetsStopsOnARepeatedNextLink(t *testing.T) { + var srvURL string + var hits int32 + c, srv := pagingClient(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + fmt.Fprintf(w, `{"value":[{"name":"loop"}],"nextLink":%q}`, srvURL+"/datasets?page=same") + }) + srvURL = srv.URL + + list, err := c.ListDatasets(t.Context(), testAPIVersion) + require.NoError(t, err) + require.NotNil(t, list) + assert.LessOrEqual(t, atomic.LoadInt32(&hits), int32(3), "the repeated link is followed at most once") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/path_escaping_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/path_escaping_test.go new file mode 100644 index 00000000000..242cdc11180 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/path_escaping_test.go @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A separator inside a name must not let the request address a route other than +// the one the caller asked for. +// +// Worth pinning because the request builder round-trips the whole path through +// PathUnescape on its way out, and an escape undone there is an escape that +// never happened. It also documents that the traversal this appears to perform +// in an error message is a rendering artefact: azcore prints the DECODED path, +// so a 404 reads as `/datasets/ds-../../etc/passwd/versions` while the bytes on +// the wire are escaped and address the dataset collection correctly. +func TestNameSeparatorsStayEscapedOnTheWire(t *testing.T) { + client, calls := recordingDatasetClient(t, 200, `{"value":[]}`) + + _, err := client.ListDatasetVersions(context.Background(), "ds-../../etc/passwd", "2025-01-01") + require.NoError(t, err) + require.Len(t, *calls, 1) + + got := (*calls)[0] + + assert.Equal(t, "/datasets/ds-..%2F..%2Fetc%2Fpasswd/versions", got.rawPath, + "the separators inside the name stay escaped, so the name is one path segment") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/upload_uris_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/upload_uris_test.go new file mode 100644 index 00000000000..e8562a461ff --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/upload_uris_test.go @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// uploadServer answers startPendingUpload with the given body and records every +// path it is asked for, so what the upload did can be read back. +func pendingUploadServer(t *testing.T, pendingBody string) (*DatasetClient, *[]string) { + t.Helper() + + var asked []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + asked = append(asked, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/startPendingUpload") { + _, _ = w.Write([]byte(pendingBody)) + return + } + // assert, not require: this runs on the server's goroutine. + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"name": "golden", "version": "1"})) + })) + t.Cleanup(srv.Close) + + pipeline := runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, + &policy.ClientOptions{Retry: policy.RetryOptions{MaxRetries: -1}}) + return NewDatasetClientFromPipeline(srv.URL, pipeline), &asked +} + +// datasetDir writes one .jsonl for UploadVersion to read. +func oneJSONLDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "golden.jsonl"), []byte("{\"query\":\"hi\"}\n"), 0o600)) + return dir +} + +// The SAS says where to write and the blob URI says what to register; they are +// different fields of one response. Only the SAS was checked, so a response +// carrying it without the other uploaded the bytes and then finalized against +// "/golden.jsonl" -- a blob nothing points at, and a publish that failed for a +// reason the message did not name. +func TestAnUploadWithNowhereToRegisterItIsRefusedBeforeTheBlobIsWritten(t *testing.T) { + client, asked := pendingUploadServer(t, `{"blobReference":{"credential":{"sasUri":"https://blob.example.invalid/c?sig=s"}}}`) + + _, err := client.UploadVersion(context.Background(), "golden", "1", oneJSONLDir(t), "2024-01-01") + + require.Error(t, err) + assert.Contains(t, err.Error(), "blob URI") + assert.Contains(t, err.Error(), "startPendingUpload") + + joined := strings.Join(*asked, " ") + assert.Contains(t, joined, "startPendingUpload") + assert.NotContains(t, joined, "versions/1?", + "nothing may be finalized when there is nothing to finalize against") +} + +// The missing-SAS case keeps its own message, because the two are not the same +// problem and the remedies differ. +func TestAnUploadWithNowhereToWriteKeepsItsOwnMessage(t *testing.T) { + client, _ := pendingUploadServer(t, `{"blobReference":{"blobUri":"https://blob.example.invalid/c"}}`) + + _, err := client.UploadVersion(context.Background(), "golden", "1", oneJSONLDir(t), "2024-01-01") + + require.Error(t, err) + assert.Contains(t, err.Error(), "upload SAS URI") +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/upload_version_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/upload_version_test.go new file mode 100644 index 00000000000..e66ecb39457 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/upload_version_test.go @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// uploadServer answers the three-step publish, refusing any version in taken +// and reporting whatever the listing is told to report. +type uploadServer struct { + mu sync.Mutex + taken map[string]bool + listing []string + attempts []string +} + +func (s *uploadServer) handler(t *testing.T, base func() string) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasSuffix(r.URL.Path, "/startPendingUpload"): + version := strings.Split(r.URL.Path, "/versions/")[1] + version = strings.TrimSuffix(version, "/startPendingUpload") + s.attempts = append(s.attempts, version) + if s.taken[version] { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":{"code":"Conflict"}}`)) + return + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "blobReference": map[string]any{ + "blobUri": base() + "/c", + "storageAccountArmId": "id", + "credential": map[string]any{"sasUri": base() + "/c?sig=x"}, + }, + })) + + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/versions"): + values := []map[string]any{} + for _, v := range s.listing { + values = append(values, map[string]any{"name": "ds", "version": v}) + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"value": values})) + + case r.Method == http.MethodPut: + version := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + s.taken[version] = true + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "name": "ds", "version": version, + })) + + default: + // The blob PUT. + w.WriteHeader(http.StatusCreated) + } + } +} + +// The version listing lags a publish, so a second upload can be told the +// dataset is new and restart at a version that already exists. Trusting the +// listing alone surfaced that 409 to the user for a publish that should simply +// have added a version. +func TestUploadNextVersionWalksPastAStaleListing(t *testing.T) { + server := &uploadServer{taken: map[string]bool{"1.0": true}} + // The listing has not caught up: it still reports nothing at all. + httpServer := func() *httptest.Server { + var s *httptest.Server + s = httptest.NewServer(server.handler(t, func() string { return s.URL })) + return s + }() + t.Cleanup(httpServer.Close) + + client := NewDatasetClientFromPipeline( + httpServer.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "rows.jsonl"), []byte("{\"query\":\"q\"}\n"), 0o600)) + + ds, err := client.UploadNextVersion(context.Background(), "ds", "", dir, "2025-11-15-preview") + require.NoError(t, err, "a stale listing must not surface as a conflict") + assert.Equal(t, "2.0", ds.Version) + assert.Equal(t, []string{"1.0", "2.0"}, server.attempts, + "the version just refused is proof it exists, so the next one is tried") +} + +// A declared version is the version published, not one to count from. +// +// `--version` used to reach the incrementing path, so `--version 7.0` published +// 8.0 while `version: 7.0` in configuration published 7.0 -- one word, two +// answers, decided by where it was written. +func TestUploadVersionPublishesTheVersionDeclared(t *testing.T) { + server := &uploadServer{taken: map[string]bool{}, listing: []string{"1.0", "2.0"}} + httpServer := func() *httptest.Server { + var s *httptest.Server + s = httptest.NewServer(server.handler(t, func() string { return s.URL })) + return s + }() + t.Cleanup(httpServer.Close) + + client := NewDatasetClientFromPipeline( + httpServer.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "rows.jsonl"), []byte("{\"query\":\"q\"}\n"), 0o600)) + + ds, err := client.UploadVersion(context.Background(), "ds", "7.0", dir, "2025-11-15-preview") + require.NoError(t, err) + assert.Equal(t, "7.0", ds.Version, "the version asked for is the version written") + assert.Equal(t, []string{"7.0"}, server.attempts, + "a declared version is published as given, not counted from") +} + +// A declared version the service already holds is refused rather than stepped +// past. +// +// The conflict walk exists because the listing lags behind a publish, which +// makes it right for a version the CLI derived. Applying it to one an author +// named would publish a version they did not ask for, and report success. +func TestUploadVersionDoesNotWalkPastAConflict(t *testing.T) { + server := &uploadServer{taken: map[string]bool{"7.0": true}, listing: []string{"7.0"}} + httpServer := func() *httptest.Server { + var s *httptest.Server + s = httptest.NewServer(server.handler(t, func() string { return s.URL })) + return s + }() + t.Cleanup(httpServer.Close) + + client := NewDatasetClientFromPipeline( + httpServer.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "rows.jsonl"), []byte("{\"query\":\"q\"}\n"), 0o600)) + + _, err := client.UploadVersion(context.Background(), "ds", "7.0", dir, "2025-11-15-preview") + require.Error(t, err, "the version the author named is taken, and that is theirs to resolve") + assert.True(t, IsVersionConflict(err), "the refusal has to read as a conflict") + assert.Equal(t, []string{"7.0"}, server.attempts, + "nothing beyond the declared version is attempted") +} + +// When the listing has caught up and is further ahead than the refused +// version, it is the better answer: it skips versions somebody else published. +func TestUploadNextVersionPrefersACaughtUpListing(t *testing.T) { + server := &uploadServer{ + taken: map[string]bool{"1.0": true, "2.0": true, "3.0": true}, + listing: []string{"1.0", "2.0", "3.0"}, + } + httpServer := func() *httptest.Server { + var s *httptest.Server + s = httptest.NewServer(server.handler(t, func() string { return s.URL })) + return s + }() + t.Cleanup(httpServer.Close) + + client := NewDatasetClientFromPipeline( + httpServer.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "rows.jsonl"), []byte("{\"query\":\"q\"}\n"), 0o600)) + + ds, err := client.UploadNextVersion(context.Background(), "ds", "", dir, "2025-11-15-preview") + require.NoError(t, err) + assert.Equal(t, "4.0", ds.Version) +} + +// A service that refuses everything must end in the conflict rather than +// looping: an unbounded walk would hammer the service on a real failure. +func TestUploadNextVersionGivesUpBounded(t *testing.T) { + server := &uploadServer{taken: map[string]bool{}} + for _, v := range []string{"1.0", "2.0", "3.0", "4.0", "5.0", "6.0"} { + server.taken[v] = true + } + httpServer := func() *httptest.Server { + var s *httptest.Server + s = httptest.NewServer(server.handler(t, func() string { return s.URL })) + return s + }() + t.Cleanup(httpServer.Close) + + client := NewDatasetClientFromPipeline( + httpServer.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "rows.jsonl"), []byte("{\"query\":\"q\"}\n"), 0o600)) + + _, err := client.UploadNextVersion(context.Background(), "ds", "", dir, "2025-11-15-preview") + require.Error(t, err) + assert.True(t, IsVersionConflict(err)) + assert.Len(t, server.attempts, versionConflictAttempts) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/uri_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/uri_test.go new file mode 100644 index 00000000000..e6cc46c2e3d --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/uri_test.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The service spells these fields inconsistently, and a URI read from the +// wrong spelling comes back empty rather than wrong — which is how the dataset +// URI went unbound the first time. +func TestDatasetResolvedBlobURI_AcceptsEitherSpelling(t *testing.T) { + cases := map[string]string{ + `{"dataUri":"https://x/y.jsonl"}`: "https://x/y.jsonl", + `{"data_uri":"https://x/y.jsonl"}`: "https://x/y.jsonl", + `{"blobUri":"https://x/b.jsonl"}`: "https://x/b.jsonl", + `{"contentUri":"https://x/c.jsonl"}`: "https://x/c.jsonl", + } + for body, want := range cases { + var ds Dataset + require.NoError(t, json.Unmarshal([]byte(body), &ds), body) + assert.Equal(t, want, ds.ResolvedBlobURI(), body) + } + + var none Dataset + require.NoError(t, json.Unmarshal([]byte(`{"name":"x"}`), &none)) + assert.Empty(t, none.ResolvedBlobURI(), + "no URI means the caller has to fetch a credential, not that the dataset is unreadable") +} + +// An upload needs the SAS-bearing URI to write to and the plain one to +// finalize with. Confusing them fails at different stages, so both are read +// from their own place. +func TestPendingUploadURIs(t *testing.T) { + var p PendingUploadResponse + require.NoError(t, json.Unmarshal([]byte(`{ + "blobReference": { + "blobUri": "https://acct.blob.core.windows.net/container", + "credential": { "sasUri": "https://acct.blob.core.windows.net/container?sig=abc" } + } + }`), &p)) + + assert.Equal(t, "https://acct.blob.core.windows.net/container?sig=abc", p.ResolvedUploadURI(), + "the upload target carries the SAS") + assert.Equal(t, "https://acct.blob.core.windows.net/container", p.ResolvedBlobURI(), + "the finalize URI does not") + + var empty PendingUploadResponse + assert.Empty(t, empty.ResolvedUploadURI()) + assert.Empty(t, empty.ResolvedBlobURI()) +} + +// Credentials arrive in two shapes and the consumption one takes precedence, +// because that is the one scoped for reading. +func TestCredentialResolvedDownloadURI(t *testing.T) { + var c DatasetCredential + require.NoError(t, json.Unmarshal([]byte(`{ + "blobReferenceForConsumption": { "credential": { "sasUri": "https://acct/read?sig=r" } }, + "blobReference": { "credential": { "sasUri": "https://acct/write?sig=w" } } + }`), &c)) + assert.Equal(t, "https://acct/read?sig=r", c.ResolvedDownloadURI()) + + var legacy DatasetCredential + require.NoError(t, json.Unmarshal([]byte(`{"sas_uri":"https://acct/legacy?sig=l"}`), &legacy)) + assert.Equal(t, "https://acct/legacy?sig=l", legacy.ResolvedDownloadURI(), + "the flat spelling is still honoured") + + var none DatasetCredential + assert.Empty(t, none.ResolvedDownloadURI()) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/version_selection_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/version_selection_test.go new file mode 100644 index 00000000000..b3edf7f0b25 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/version_selection_test.go @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// datasetDir returns a directory holding one uploadable dataset file, so the +// upload path is exercised rather than short-circuiting on an empty folder. +func datasetDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "data.jsonl"), []byte(`{"query":"hi"}`+"\n"), 0o600)) + return dir +} + +// An empty version listing is how a brand-new dataset looks, so a listing that +// failed must never be mistaken for one. Restarting at 1.0 against a dataset +// that already has versions is the damaging case: the upload either collides or +// publishes over the wrong version. +func TestUploadNextVersionRefusesToStartOverWhenTheListingFails(t *testing.T) { + var mu sync.Mutex + var paths []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + + if strings.HasSuffix(r.URL.Path, "/versions") { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":"AuthorizationFailed"}}`)) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + + c := NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + _, err := c.UploadNextVersion(t.Context(), "ds", "", datasetDir(t), testAPIVersion) + + require.Error(t, err, "a refused listing must surface, not read as a new dataset") + + mu.Lock() + defer mu.Unlock() + for _, p := range paths { + assert.NotContains(t, p, "startPendingUpload", + "no upload may be attempted once the version listing failed") + } +} + +// A 404 is the service saying the dataset does not exist, which genuinely means +// "no versions yet" and must stay distinguishable from a failure. +func TestUploadNextVersionTreatsAnUnknownDatasetAsVersionless(t *testing.T) { + var mu sync.Mutex + var startedVersion string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/versions"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"code":"ResourceNotFound"}}`)) + case strings.HasSuffix(r.URL.Path, "/startPendingUpload"): + mu.Lock() + v := strings.TrimSuffix(r.URL.Path, "/startPendingUpload") + startedVersion = v[strings.LastIndex(v, "/")+1:] + mu.Unlock() + w.WriteHeader(http.StatusInternalServerError) // stop the flow here; the version is the point + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + } + })) + t.Cleanup(srv.Close) + + c := NewDatasetClientFromPipeline( + srv.URL, runtime.NewPipeline("test", "v1", runtime.PipelineOptions{}, nil)) + + _, _ = c.UploadNextVersion(t.Context(), "ds", "", datasetDir(t), testAPIVersion) + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, "1.0", startedVersion, "an unknown dataset still starts at 1.0") +} + +// LatestVersion documents a fallback to the last entry when nothing can be +// ordered. That fallback only runs if an unorderable version never becomes the +// running best. +func TestLatestVersionFallsBackToTheLastEntryWhenNoneAreOrderable(t *testing.T) { + got := LatestVersion([]Dataset{{Version: "alpha"}, {Version: "beta"}, {Version: "gamma"}}) + assert.Equal(t, "gamma", got, "with nothing orderable the service's last entry wins") +} + +func TestLatestVersionPrefersAnOrderableVersionOverAnUnorderableOne(t *testing.T) { + assert.Equal(t, "2.0", LatestVersion([]Dataset{{Version: "alpha"}, {Version: "2.0"}})) + assert.Equal(t, "2.0", LatestVersion([]Dataset{{Version: "2.0"}, {Version: "alpha"}})) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/version_test.go b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/version_test.go new file mode 100644 index 00000000000..052a63ae504 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/pkg/dataset_api/version_test.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package dataset_api + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Drift detection compares the version on the service with the one recorded at +// the last deploy, so the ordering has to be numeric rather than lexical: +// "10.0" is newer than "9.0" even though it sorts earlier as a string. +func TestVersionGreater(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"2.0", "1.0", true}, + {"1.0", "2.0", false}, + {"1.0", "1.0", false}, + {"10.0", "9.0", true}, + {"9.0", "10.0", false}, + {"v3", "v2", true}, + } + + for _, tc := range cases { + require.Equal(t, tc.want, VersionGreater(tc.a, tc.b), + "VersionGreater(%q, %q)", tc.a, tc.b) + } +} + +// An unorderable version must never trigger a drift failure on its own: the +// deploy would be blocked with no way for the author to reason about it. +func TestVersionGreaterIgnoresUnorderable(t *testing.T) { + require.False(t, VersionGreater("draft", "1.0")) + require.False(t, VersionGreater("1.0", "draft")) + require.False(t, VersionGreater("", "1.0")) + require.False(t, VersionGreater("1.0", "")) +} + +// The two upload entry points read their version argument differently, and the +// difference is the whole point: UploadNewVersion counts from it, UploadVersion +// writes it. Passing "1.0" to the counting one publishes 2.0, which is not what +// an author who wrote version: "1.0" asked for. +func TestNextVersionCountsFromTheArgument(t *testing.T) { + if got := NextVersion("1.0"); got != "2.0" { + t.Fatalf("NextVersion(1.0) = %q, want 2.0", got) + } + if got := NextVersion("1"); got != "2.0" { + t.Fatalf("NextVersion(1) = %q, want 2.0", got) + } + // An unknown current version starts the sequence rather than guessing. + if got := NextVersion(""); got != "1.0" { + t.Fatalf("NextVersion(empty) = %q, want 1.0", got) + } +} + +func TestLatestVersionOrdersNumerically(t *testing.T) { + got := LatestVersion([]Dataset{{Version: "1.0"}, {Version: "10.0"}, {Version: "2.0"}}) + if got != "10.0" { + t.Fatalf("LatestVersion = %q, want 10.0 (numeric, not lexical)", got) + } + if LatestVersion(nil) != "" { + t.Fatal("LatestVersion(nil) should be empty") + } +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/urlsafe/urlsafe.go b/cli/azd/extensions/azure.ai.dataset/internal/urlsafe/urlsafe.go new file mode 100644 index 00000000000..a0e038bd819 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/urlsafe/urlsafe.go @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package urlsafe renders URLs for logs and errors without their credentials. +// +// It exists because url.URL.Redacted looks like the safe choice and is not: it +// masks a userinfo password only, and leaves the query string untouched. A +// storage SAS carries its credential in the query as sig, so logging a SAS URI +// with Redacted writes a live credential to disk. +package urlsafe + +import ( + "errors" + "net/url" +) + +// URL renders a URL with its query and fragment removed, keeping the scheme, +// host and path so the log still says where the request went. +func URL(u *url.URL) string { + if u == nil { + return "" + } + safe := *u + safe.RawQuery = "" + safe.Fragment = "" + return safe.Redacted() +} + +// Error rebuilds a *url.Error without its request URL. http.Client.Do embeds +// the full URL in the error text, so a DNS, TLS, timeout or cancellation +// failure on a SAS-backed request would otherwise show the credential to the +// user. The original error is left unmodified. +func Error(err error) error { + urlError, ok := errors.AsType[*url.Error](err) + if !ok { + return err + } + safe := "" + if u, parseErr := url.Parse(urlError.URL); parseErr == nil { + safe = URL(u) + } + return &url.Error{Op: urlError.Op, URL: safe, Err: urlError.Err} +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/urlsafe/urlsafe_test.go b/cli/azd/extensions/azure.ai.dataset/internal/urlsafe/urlsafe_test.go new file mode 100644 index 00000000000..12e9cb12c72 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/urlsafe/urlsafe_test.go @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package urlsafe + +import ( + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sasSecret = "REDACT_ME_SECRET" + +// These tests pin the premise as well as the behavior: url.URL.Redacted is the +// call that looks correct and leaks, so if someone reaches for it again the +// first assertion explains why they should not. +func TestURLDropsTheSASSignature(t *testing.T) { + raw := "https://acct.blob.core.windows.net/c/rows.jsonl?sv=2021-08-06&sig=" + sasSecret + u, err := url.Parse(raw) + require.NoError(t, err) + + assert.Contains(t, u.Redacted(), sasSecret, + "guards the premise: Redacted() alone leaks the signature") + + safe := URL(u) + assert.NotContains(t, safe, sasSecret, "the SAS signature must never reach a log") + assert.NotContains(t, safe, "sig=") + assert.Equal(t, "https://acct.blob.core.windows.net/c/rows.jsonl", safe, + "scheme, host and path stay, so the log still says where the request went") + assert.Equal(t, raw, u.String(), "the caller's URL is untouched and still usable") +} + +func TestURLHandlesNil(t *testing.T) { + assert.Equal(t, "", URL(nil)) +} + +func TestErrorStripsTheSASFromTransportFailures(t *testing.T) { + inner := errors.New("dial tcp: lookup failed") + original := &url.Error{ + Op: "Get", + URL: "https://acct.blob.core.windows.net/c/rows.jsonl?sig=" + sasSecret, + Err: inner, + } + + got := Error(original) + + assert.NotContains(t, got.Error(), sasSecret, + "a transport failure must not show the SAS to the user") + assert.Contains(t, got.Error(), "acct.blob.core.windows.net", + "the host stays so the message still says where it failed") + assert.ErrorIs(t, got, inner, "the cause stays unwrappable") + assert.Contains(t, original.URL, sasSecret, "the original error is not mutated") +} + +func TestErrorLeavesOtherErrorsAlone(t *testing.T) { + plain := errors.New("some other failure") + assert.Same(t, plain, Error(plain)) + assert.Nil(t, Error(nil)) +} diff --git a/cli/azd/extensions/azure.ai.dataset/internal/version/version.go b/cli/azd/extensions/azure.ai.dataset/internal/version/version.go new file mode 100644 index 00000000000..e7279d11fba --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/version/version.go @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package version + +var ( + // Populated at build time. + Version = "dev" + Commit = "none" + BuildDate = "unknown" +) diff --git a/cli/azd/extensions/azure.ai.dataset/internal/version/version_test.go b/cli/azd/extensions/azure.ai.dataset/internal/version/version_test.go new file mode 100644 index 00000000000..1b8a73323f8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/internal/version/version_test.go @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package version + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The build scripts set these variables with the linker's -X flag, and -X is +// discarded without complaint when the symbol it names does not exist. These +// scripts began as copies of the eval extension's and kept its module path, so +// every release binary reported "dev" in its User-Agent and no service-side log +// could tell which build a caller was running. Nothing failed; it just never +// worked. +// +// Comparing against go.mod rather than a literal keeps this honest if the +// module is ever renamed. +func TestBuildScriptsStampThisModule(t *testing.T) { + root := filepath.Join("..", "..") + + goMod, err := os.ReadFile(filepath.Join(root, "go.mod")) + require.NoError(t, err) + + var module string + for line := range strings.SplitSeq(string(goMod), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "module "); ok { + module = strings.TrimSpace(rest) + break + } + } + require.NotEmpty(t, module, "go.mod has to declare a module") + + want := module + "/internal/version" + for _, script := range []string{"build.ps1", "build.sh", "ci-build.ps1"} { + body, err := os.ReadFile(filepath.Join(root, script)) + require.NoError(t, err) + + text := string(body) + require.Contains(t, text, want, + "%s stamps a module path that is not this one, so -X is silently dropped", script) + + for _, sibling := range []string{"azureaieval/internal/version"} { + require.NotContains(t, text, sibling, + "%s still names a sibling extension's version package", script) + } + } +} diff --git a/cli/azd/extensions/azure.ai.dataset/main.go b/cli/azd/extensions/azure.ai.dataset/main.go new file mode 100644 index 00000000000..9f66d1148be --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/main.go @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package main + +import ( + "azureaidataset/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.dataset/tests/cli/dataset_test.go b/cli/azd/extensions/azure.ai.dataset/tests/cli/dataset_test.go new file mode 100644 index 00000000000..9ea3ec3fe38 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/tests/cli/dataset_test.go @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build live + +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// datasetSummary is the shape a script consumes from `-o json`. +type datasetSummary struct { + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + DataURI string `json:"dataUri"` +} + +const oneRow = `{"query":"reset my password","response":"Use Forgot Password."}` + "\n" + +// latestVersion reads back what the service assigned. Versions are "1.0", not +// "1", so a cleanup that guesses deletes nothing and leaves the project dirty. +func latestVersion(t *testing.T, name string) string { + t.Helper() + var ds datasetSummary + requireSuccess(t, run(t, "show", name, "-o", "json")).JSON(t, &ds) + require.NotEmpty(t, ds.Version) + return ds.Version +} + +// removeDataset deletes every version it can see, so nothing outlives the test. +func removeDataset(t *testing.T, name string) { + t.Helper() + var versions []datasetSummary + res := run(t, "versions", "list", name, "-o", "json") + if res.ExitCode != 0 { + return + } + if err := json.Unmarshal([]byte(res.Stdout), &versions); err != nil { + return + } + for _, v := range versions { + run(t, "delete", name, "--version", v.Version) + } +} + +// registerDataset publishes a dataset and removes it when the test ends. +func registerDataset(t *testing.T, rows string) (string, string) { + t.Helper() + + name := uniqueName("azdcli-ds") + file := writeRows(t, rows) + + r := requireSuccess(t, run(t, "create", name, "--from-file", file)) + require.Contains(t, r.Combined(), name) + + t.Cleanup(func() { removeDataset(t, name) }) + return name, file +} + +// The round trip the extension exists for: register, list, read back. +func TestCLIDatasetLifecycle(t *testing.T) { + name, file := registerDataset(t, oneRow) + + t.Run("show returns the registered version", func(t *testing.T) { + var ds datasetSummary + requireSuccess(t, run(t, "show", name, "-o", "json")).JSON(t, &ds) + + require.Equal(t, name, ds.Name) + require.NotEmpty(t, ds.Version) + require.NotEmpty(t, ds.DataURI, "without a URI nothing can read the rows back") + }) + + t.Run("it appears in the listing", func(t *testing.T) { + var all []datasetSummary + requireSuccess(t, run(t, "list", "-o", "json")).JSON(t, &all) + + found := false + for _, d := range all { + if d.Name == name { + found = true + } + } + require.True(t, found, "a registered dataset must appear in the listing") + }) + + t.Run("update publishes a further version", func(t *testing.T) { + first := latestVersion(t, name) + requireSuccess(t, run(t, "update", name, "--from-file", file)) + + second := latestVersion(t, name) + require.NotEqual(t, first, second, + "update must advance the version rather than overwrite") + + var versions []datasetSummary + requireSuccess(t, run(t, "versions", "list", name, "-o", "json")).JSON(t, &versions) + require.GreaterOrEqual(t, len(versions), 2) + }) + + t.Run("the table names its columns", func(t *testing.T) { + r := requireSuccess(t, run(t, "list")) + for _, header := range []string{"NAME", "VERSION", "TYPE"} { + require.Containsf(t, r.Stdout, header, "the listing lost its %s column", header) + } + }) +} + +// Every list has to be a bare array, or a caller's parsing depends on which +// service envelope happened to come back. +func TestCLIJSONListsAreBareArrays(t *testing.T) { + for _, args := range [][]string{{"list", "-o", "json"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + out := requireSuccess(t, run(t, args...)).Stdout + require.True(t, strings.HasPrefix(strings.TrimSpace(out), "["), + "a list must emit an array, got:\n%s", out) + }) + } +} + +// A name the service will reject is worth refusing locally: the service answers +// with a 400 wrapped in several levels of JSON, which says nothing useful. +func TestCLIInvalidNameIsRefusedLocally(t *testing.T) { + file := writeRows(t, oneRow) + + r := requireFailure(t, run(t, "create", "bad name", "--from-file", file)) + + require.Contains(t, r.Combined(), "invalid") + require.NotContains(t, r.Combined(), "RESPONSE 400", + "the refusal must come before the request") +} + +// Pointing at one dataset in a folder holding several must register that one. +// Scanning the directory would upload whichever .jsonl sorts first. +func TestCLIFromFileUploadsTheNamedFile(t *testing.T) { + dir := t.TempDir() + chosen := filepath.Join(dir, "zebra.jsonl") + require.NoError(t, os.WriteFile(chosen, []byte(oneRow), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "alpha.jsonl"), + []byte(`{"query":"wrong","response":"wrong"}`+"\n"), 0o600)) + + name := uniqueName("azdcli-named") + requireSuccess(t, run(t, "create", name, "--from-file", chosen)) + t.Cleanup(func() { removeDataset(t, name) }) + + var ds datasetSummary + requireSuccess(t, run(t, "show", name, "-o", "json")).JSON(t, &ds) + require.NotEmpty(t, ds.DataURI) +} + +// A Windows editor writes a BOM by default. Uploaded as-is it becomes part of +// the first row's first key, and nothing fails until something reads that row. +func TestCLIByteOrderMarkIsAccepted(t *testing.T) { + path := filepath.Join(t.TempDir(), "bom.jsonl") + require.NoError(t, os.WriteFile(path, + append([]byte{0xEF, 0xBB, 0xBF}, []byte(oneRow)...), 0o600)) + + name := uniqueName("azdcli-bom") + requireSuccess(t, run(t, "create", name, "--from-file", path)) + t.Cleanup(func() { removeDataset(t, name) }) +} + +// Refused before upload: registering an empty dataset succeeds, and the +// failure then surfaces at whatever tries to read it. +func TestCLIEmptyDatasetIsRefused(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.jsonl") + require.NoError(t, os.WriteFile(path, []byte(" \n"), 0o600)) + + r := requireFailure(t, run(t, "create", uniqueName("azdcli-empty"), "--from-file", path)) + + require.Contains(t, r.Combined(), "no rows") +} + +// A mistyped path is the common way to get here, and the syscall that +// discovered it says nothing to the person who mistyped it. +func TestCLIMissingFileNamesThePath(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope.jsonl") + + r := requireFailure(t, run(t, "create", uniqueName("azdcli-missing"), "--from-file", missing)) + + require.Contains(t, r.Combined(), "does not exist") + require.NotContains(t, r.Combined(), "GetFileAttributesEx") +} + +func TestCLIUnknownDatasetIsBrief(t *testing.T) { + r := requireFailure(t, run(t, "show", "azdcli-no-such-dataset")) + + require.Contains(t, r.Combined(), "no dataset") + require.NotContains(t, r.Combined(), "RESPONSE 404", + "a missing name does not need the whole HTTP body to explain it") +} + +// A list is a filter, not a lookup, so an unknown name lists nothing and +// succeeds. `show` is the lookup and still refuses. The eval extension answers +// both the same way, and a caller moving between the two should not have to +// learn which one errors. +func TestCLIVersionsListOfAnUnknownNameSucceeds(t *testing.T) { + r := requireSuccess(t, run(t, "versions", "list", "azdcli-no-such-dataset", "-o", "json")) + + var versions []map[string]any + r.JSON(t, &versions) + require.Empty(t, versions, "an unknown name lists nothing rather than failing") +} + +// Succeeding quietly is right for a parser and wrong for a reader. The project +// holds other datasets, so "No datasets found." answers a question nobody +// asked; the line has to be about the name that was typed. +func TestCLIVersionsListOfAnUnknownNameNamesIt(t *testing.T) { + r := requireSuccess(t, run(t, "versions", "list", "azdcli-no-such-dataset")) + + require.Contains(t, r.Stdout, `No versions of dataset "azdcli-no-such-dataset"`) + require.NotContains(t, r.Stdout, "No datasets found.", + "the project has datasets; this name just has no versions") +} + +// Required arguments must end the process rather than wait on a terminal +// nobody is watching. +func TestCLIRequiredValuesFailInsteadOfHanging(t *testing.T) { + cases := [][]string{ + {"create", uniqueName("azdcli-noflag"), "--no-prompt"}, + {"show"}, + {"delete", "some-name"}, + } + + for _, args := range cases { + t.Run(strings.Join(args, " "), func(t *testing.T) { + r := requireFailure(t, run(t, args...)) + require.NotEmpty(t, strings.TrimSpace(r.Combined()), + "a refusal has to say what it could not resolve") + }) + } +} + +// Deleting something that is not registered is how a cleanup script ends, so +// it is not an error. +func TestCLIDeleteIsIdempotent(t *testing.T) { + requireSuccess(t, run(t, "delete", "azdcli-never-registered", "--version", "1")) +} diff --git a/cli/azd/extensions/azure.ai.dataset/tests/cli/harness_test.go b/cli/azd/extensions/azure.ai.dataset/tests/cli/harness_test.go new file mode 100644 index 00000000000..42b6d859db1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/tests/cli/harness_test.go @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build live + +// Package cli drives the built binary as a subprocess. +// +// The unit tests cover the client layer and the helpers. What they cannot +// cover is the surface a user touches: flag parsing, exit codes, the rendered +// tables, and whether `-o json` emits something a script can consume. Those +// only exist once main has wired the command tree, so these run the binary. +// +// go test -tags live -v ./tests/cli/... +// +// Required: +// +// AZURE_AI_DATASET_E2E_LIVE=1 +// FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var ( + binaryPath string + endpoint string +) + +// TestMain builds the extension once so every test runs the same binary a user +// would, rather than an in-process command tree that skips main's wiring. +func TestMain(m *testing.M) { + if os.Getenv("AZURE_AI_DATASET_E2E_LIVE") != "1" { + fmt.Fprintln(os.Stderr, "set AZURE_AI_DATASET_E2E_LIVE=1 to run the CLI tests") + os.Exit(0) + } + + endpoint = strings.TrimSuffix(os.Getenv("FOUNDRY_PROJECT_ENDPOINT"), "/") + if endpoint == "" { + fmt.Fprintln(os.Stderr, "FOUNDRY_PROJECT_ENDPOINT is required") + os.Exit(1) + } + + dir, err := os.MkdirTemp("", "azddataset-cli") + if err != nil { + fmt.Fprintf(os.Stderr, "creating a temp dir: %v\n", err) + os.Exit(1) + } + + binaryPath = filepath.Join(dir, "azddataset"+exeSuffix()) + build := exec.Command("go", "build", "-o", binaryPath, ".") + build.Dir = "../.." + if out, err := build.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "building the extension: %v\n%s\n", err, out) + os.RemoveAll(dir) + os.Exit(1) + } + + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +func exeSuffix() string { + if os.PathSeparator == '\\' { + return ".exe" + } + return "" +} + +// result is one invocation of the binary. +type result struct { + Args []string + Stdout string + Stderr string + ExitCode int +} + +// Combined is stdout and stderr together, for assertions that do not care +// which stream carried the message. +func (r result) Combined() string { return r.Stdout + r.Stderr } + +// JSON decodes stdout, failing the test when the command did not emit +// something a script could consume. +func (r result) JSON(t *testing.T, into any) { + t.Helper() + require.NoError(t, json.Unmarshal([]byte(r.Stdout), into), + "-o json must emit parseable JSON on stdout; got:\n%s", r.Stdout) +} + +// credentialFlake is azd's token helper failing under rapid sequential calls. +// +// Every invocation here is a fresh process, so each one shells out to azd for +// a token, and azd intermittently exits non-zero doing it. Retrying is safe +// because no request was made, and the alternative is a suite that fails on a +// different test each run for a reason unrelated to the code. +const credentialFlake = "AzureDeveloperCLICredential: exit status 1" + +func run(t *testing.T, args ...string) result { + t.Helper() + + res := invoke(t, args...) + for attempt := 0; attempt < 2 && strings.Contains(res.Combined(), credentialFlake); attempt++ { + t.Logf("azd credential flaked; retrying `%s`", strings.Join(args, " ")) + time.Sleep(2 * time.Second) + res = invoke(t, args...) + } + require.NotContains(t, res.Combined(), credentialFlake, + "azd could not produce a token after retries; run `azd auth login` and try again") + return res +} + +func invoke(t *testing.T, args ...string) result { + t.Helper() + + full := append([]string{}, args...) + if !hasFlag(args, "--project-endpoint") && !hasFlag(args, "--help") { + full = append(full, "--project-endpoint", endpoint) + } + + cmd := exec.Command(binaryPath, full...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + code := 0 + if exitErr, ok := err.(*exec.ExitError); ok { + code = exitErr.ExitCode() + } else if err != nil { + t.Fatalf("could not run %v: %v", full, err) + } + + res := result{Args: full, Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: code} + t.Logf("$ azd ai dataset %s -> exit %d", strings.Join(args, " "), res.ExitCode) + return res +} + +func hasFlag(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + return false +} + +// requireSuccess fails with the command's own output, which is what a user +// would have seen. +func requireSuccess(t *testing.T, r result) result { + t.Helper() + require.Equalf(t, 0, r.ExitCode, + "expected `%s` to succeed\nstdout:\n%s\nstderr:\n%s", + strings.Join(r.Args, " "), r.Stdout, r.Stderr) + return r +} + +// requireFailure asserts a non-zero exit, so a command that silently succeeds +// where it should refuse is caught. +func requireFailure(t *testing.T, r result) result { + t.Helper() + require.NotEqualf(t, 0, r.ExitCode, + "expected `%s` to fail\nstdout:\n%s\nstderr:\n%s", + strings.Join(r.Args, " "), r.Stdout, r.Stderr) + return r +} + +func uniqueName(prefix string) string { + return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) +} + +// writeRows puts a .jsonl in its own directory and returns the file path. +func writeRows(t *testing.T, rows string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "rows.jsonl") + require.NoError(t, os.WriteFile(path, []byte(rows), 0o600)) + return path +} diff --git a/cli/azd/extensions/azure.ai.dataset/version.txt b/cli/azd/extensions/azure.ai.dataset/version.txt new file mode 100644 index 00000000000..196d4e05fee --- /dev/null +++ b/cli/azd/extensions/azure.ai.dataset/version.txt @@ -0,0 +1 @@ +1.0.0-beta.17 diff --git a/eng/pipelines/release-ext-azure-ai-dataset.yml b/eng/pipelines/release-ext-azure-ai-dataset.yml new file mode 100644 index 00000000000..dbcebc827c1 --- /dev/null +++ b/eng/pipelines/release-ext-azure-ai-dataset.yml @@ -0,0 +1,45 @@ +# Continuous deployment trigger +trigger: + branches: + include: + - main + paths: + include: + - cli/azd/extensions/azure.ai.dataset + - /eng/pipelines/templates/stages/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.dataset + - eng/pipelines/release-ext-azure-ai-dataset.yml + - /eng/pipelines/templates/stages/release-azd-extension.yml + - eng/pipelines/templates/steps/publish-cli.yml + exclude: + - cli/azd/docs/** + +parameters: + - name: PublishToRegistry + displayName: Publish to registry + type: string + # Scheduled (nightly) runs override this in the shared templates; the runtime + # parameter default must be a literal because it renders before variables exist. + default: stable + values: + - stable + - dev + - nightly + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extension.yml + parameters: + AzdExtensionId: azure.ai.dataset + SanitizedExtensionId: azure-ai-dataset + AzdExtensionDirectory: cli/azd/extensions/azure.ai.dataset + PublishToRegistry: ${{ parameters.PublishToRegistry }}