From 45adb5ff3c4cf70c653115696a6ceaf34d6f6375 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Tue, 28 Jul 2026 19:49:58 +0530 Subject: [PATCH 01/12] fix: switch to foundry auth and backend urls --- cli/azd/extensions/azure.ai.rle/CHANGELOG.md | 9 + cli/azd/extensions/azure.ai.rle/README.md | 93 +++++--- .../extensions/azure.ai.rle/extension.yaml | 2 +- cli/azd/extensions/azure.ai.rle/go.mod | 4 +- .../azure.ai.rle/internal/cmd/client.go | 121 +++++----- .../azure.ai.rle/internal/cmd/client_test.go | 86 ++++++- .../azure.ai.rle/internal/cmd/deploy.go | 26 +-- .../azure.ai.rle/internal/cmd/invoke.go | 132 +++++------ .../azure.ai.rle/internal/cmd/invoke_test.go | 219 ++++++++++++------ .../azure.ai.rle/internal/project/docker.go | 2 +- cli/azd/extensions/azure.ai.rle/version.txt | 2 +- 11 files changed, 433 insertions(+), 263 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md index 37fa1cafe66..4847dd6a2a9 100644 --- a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md @@ -1,5 +1,14 @@ # Release History +## 0.2.0-preview + +- Use the Foundry project endpoint for project-relative RLE environment and sandbox APIs. +- Authenticate Foundry API requests with Azure credentials from `az login`, `azd auth login`, or another supported development credential. +- Send the required `2025-11-15-preview` Foundry data-plane API version. +- Support versioned environment deployments and sandbox `baseUrl` invocation. +- Wait for asynchronous disk-image conversion before leasing a sandbox and surface conversion failures directly. +- Keep container command output concise while preserving actionable failure diagnostics. + ## 0.1.0-preview - Initial preview scaffold for the RLE extension with `init`, `run`, `invoke`, `deploy`, and `version` commands. \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 37a9824950a..548eba8b73e 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -1,6 +1,6 @@ # Azure AI RLE extension for azd -Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: init, build and run the environment container, test it through a playground UI or shell, and deploy the environment image to the RLE control plane. +Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: init, build and run the environment container, test it through a playground UI or shell, and deploy the environment image to the RLE control plane through your Foundry project endpoint. ## Prerequisites @@ -16,18 +16,21 @@ Verify: ```powershell azd version docker version +az account show ``` -## Install the extension from this checkout +Sign in with Azure CLI before calling the Foundry project APIs: -From `cli\azd\extensions\azure.ai.rle`: +```powershell +az login +``` + +The extension also supports credentials from `azd auth login` and the other development credentials in Azure's default credential chain. + +## Install the extension from the nightly registry ```powershell -azd extension install microsoft.azd.extensions -azd x build -azd x pack -azd x publish -azd extension install azure.ai.rle --source local --force +azd ext install azure.ai.rle -s https://aka.ms/azd/extensions/registry/nightly ``` Verify: @@ -37,38 +40,42 @@ azd ai rle --help azd ai rle version ``` -After code changes, rerun: +`version` is always available. The lifecycle commands are preview-gated; if commands such as `init`, `run`, `deploy`, or `invoke` are hidden, enable the preview flag in your terminal: ```powershell -azd x build -azd x pack -azd x publish -azd extension install azure.ai.rle --source local --force +$env:AZD_AI_RLE_ENABLE = "true" ``` -## Configure the RLE control plane +## Configure the Foundry project endpoint -The extension defaults to the local RLE control plane at `http://localhost:5000`. To target another control plane: +RLE control-plane APIs are called relative to the Foundry project endpoint. APIM maps the project endpoint request to the workspace-scoped RLE service internally, so the extension does not require a separate control-plane endpoint. + +Set the Foundry project endpoint once in the terminal where you run `deploy`: ```powershell -$env:RLE_ENDPOINT = "https://" +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +``` + +For example, RLE environment registration is sent to: + +```text +/fine_tuning/environments?api-version=2025-11-15-preview ``` -Deploy uses a Foundry project endpoint and an ACR image for the registered RLE environment. Set them once: +Deploy also needs an ACR registry endpoint: ```powershell -$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" ``` -## Quickstart - -Discovery for all commands is currently disabled using `AZD_AI_RLE_ENABLE`. To enable: +Authenticate Docker to ACR before deploying: ```powershell -$env:AZD_AI_RLE_ENABLE = "true" +az acr login --name ``` +## Quickstart + ### 1. Initialize an environment session Default echo session: @@ -78,8 +85,7 @@ azd ai rle init cd .\echo_env ``` -The default echo session downloads the Hugging Face `OpenEnv` repo, copies `envs/echo_env` into the session folder, -and writes `.azd-rle.json` with the local environment name. +The default echo session downloads the Hugging Face `OpenEnv` repo, copies `envs/echo_env` into the session folder, and writes `.azd-rle.json` with the local environment name. The copied session does not keep `.git` metadata from the upstream repository. @@ -97,12 +103,9 @@ For an existing source folder, skip `init` and run commands directly from that f azd ai rle run ``` -`run` builds a local Docker image from the current source folder, removes any stale local container for the -same environment name, starts a fresh container, waits for `/health`, opens the playground UI at `/web`, and -keeps an OpenEnv shell attached. When the shell exits or Ctrl+C is received, `run` removes the local container. +`run` builds a local Docker image from the current source folder, removes any stale local container for the same environment name, starts a fresh container, waits for `/health`, opens the playground UI at `/web`, and keeps an OpenEnv shell attached. When the shell exits or Ctrl+C is received, `run` removes the local container. -If `.azd-rle.json` -does not exist, `run` creates it with only the inferred local environment name. +If `.azd-rle.json` does not exist, `run` creates it with only the inferred local environment name. Use a custom host port: @@ -110,8 +113,7 @@ Use a custom host port: azd ai rle run --port 9000 ``` -`run` looks for `Dockerfile` at the source root, then `server\Dockerfile`. If the Dockerfile is elsewhere, -pass it explicitly: +`run` looks for `Dockerfile` at the source root, then `server\Dockerfile`. If the Dockerfile is elsewhere, pass it explicitly: ```powershell azd ai rle run --dockerfile server\Dockerfile @@ -153,8 +155,9 @@ $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" azd ai rle deploy ``` -Deploy reads the Foundry project endpoint from `FOUNDRY_PROJECT_ENDPOINT` and the ACR registry from `AZURE_CONTAINER_REGISTRY_ENDPOINT` in the terminal environment. It derives the project route segment from `/api/projects/`, builds the Docker image as `.azurecr.io/-:latest`, pushes it to ACR, registers that image with the RLE control plane, and saves the project/environment details in `.azd-rle.json`. -The deploy command prints a CLI-friendly summary using `environmentId`, `acrImage`, `version`, `createdAt`, and `updatedAt`. +Deploy reads the Foundry project endpoint from `FOUNDRY_PROJECT_ENDPOINT` and the ACR registry from `AZURE_CONTAINER_REGISTRY_ENDPOINT`. It derives the project route segment from `/api/projects/`, builds the Docker image as `.azurecr.io/-:latest`, pushes it to ACR, registers that image by calling `/fine_tuning/environments`, and saves the project/environment details in `.azd-rle.json`. + +The deploy command prints a CLI-friendly summary using `environmentId`, `foundryProjectEndpoint`, `acrImage`, `environmentVersion`, `createdAt`, and `updatedAt`. If needed, override the Dockerfile path the same way as local run: @@ -164,9 +167,29 @@ azd ai rle deploy --dockerfile server\Dockerfile ### 4. Invoke remotely -Remote invoke uses the deployed environment, leases a sandbox, opens the sandbox `/web` UI when available -(or a local proxy UI otherwise), keeps the shell attached, and releases the sandbox when the shell exits: +Remote invoke uses the deployed environment, leases a sandbox from `/fine_tuning/environments//sandboxes/lease`, opens the sandbox `/web` UI when available (or a local proxy UI otherwise), keeps the shell attached, and releases the sandbox when the shell exits: ```powershell azd ai rle invoke --timeout 60 ``` + +## Build and install from source + +Use this path only when you are developing the extension itself. From `cli\azd\extensions\azure.ai.rle`: + +```powershell +azd extension install microsoft.azd.extensions +azd x build +azd x pack +azd x publish +azd extension install azure.ai.rle --source local --force +``` + +After code changes, rerun: + +```powershell +azd x build +azd x pack +azd x publish +azd extension install azure.ai.rle --source local --force +``` diff --git a/cli/azd/extensions/azure.ai.rle/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml index f5286a7dfba..e8ae9c3dee0 100644 --- a/cli/azd/extensions/azure.ai.rle/extension.yaml +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -11,7 +11,7 @@ tags: - ai - rle usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle [options] -version: 0.1.0-preview +version: 0.2.0-preview examples: - name: init description: Copy the OpenEnv echo sample into a local RLE environment. diff --git a/cli/azd/extensions/azure.ai.rle/go.mod b/cli/azd/extensions/azure.ai.rle/go.mod index 8786092b8d2..5a40d9e65e9 100644 --- a/cli/azd/extensions/azure.ai.rle/go.mod +++ b/cli/azd/extensions/azure.ai.rle/go.mod @@ -3,6 +3,8 @@ module azure.ai.rle 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.25.0 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 @@ -10,8 +12,6 @@ require ( require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go index 4fd2b932c34..7bb60b5ecc2 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -12,36 +12,45 @@ import ( "io" "net/http" "net/url" - "os" "strings" "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/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) const ( - defaultControlPlaneEndpoint = "http://localhost:5000" + environmentCollectionPath = "/fine_tuning/environments" + foundryAPIVersion = "2025-11-15-preview" + foundryTokenScope = "https://ai.azure.com/.default" //nolint:gosec // OAuth scope, not a credential ) type rleClient struct { baseUrl string + credential azcore.TokenCredential httpClient *http.Client } +var createRleClient = newRleClient + type v1EnvironmentRequest struct { Name string `json:"name,omitempty"` AcrImagePath string `json:"acrImagePath"` } type environmentResource struct { - Id string `json:"id"` - ProjectId string `json:"projectId,omitempty"` - Name string `json:"name,omitempty"` - AcrImagePath string `json:"acrImagePath,omitempty"` - Version string `json:"version,omitempty"` - CreatedAt string `json:"createdAtUtc,omitempty"` - UpdatedAt string `json:"updatedAtUtc,omitempty"` - VersionLabel string `json:"versionLabel,omitempty"` + Id string `json:"id"` + ProjectId string `json:"projectId,omitempty"` + Name string `json:"name,omitempty"` + AcrImagePath string `json:"acrImagePath,omitempty"` + Version string `json:"version,omitempty"` + CreatedAt string `json:"createdAtUtc,omitempty"` + UpdatedAt string `json:"updatedAtUtc,omitempty"` + VersionLabel string `json:"versionLabel,omitempty"` + DiskImageConversionStatus string `json:"diskImageConversionStatus,omitempty"` + DiskImageConversionError string `json:"diskImageConversionError,omitempty"` } type sandboxCreateRequest struct { @@ -53,8 +62,7 @@ type sandboxResource struct { ProjectId string `json:"projectId,omitempty"` EnvironmentId string `json:"environmentId,omitempty"` Version string `json:"version,omitempty"` - Url string `json:"url,omitempty"` - Endpoint string `json:"endpoint,omitempty"` + BaseUrl string `json:"baseUrl,omitempty"` Status string `json:"status,omitempty"` Error string `json:"error,omitempty"` CreatedAt string `json:"createdAtUtc,omitempty"` @@ -74,69 +82,55 @@ func serviceError(err error) error { return &azdext.ServiceError{ Message: err.Error(), ServiceName: "rle-control-plane", - Suggestion: fmt.Sprintf( - "Ensure the RLE control plane is running and reachable. Trying at %s; adjust if needed by setting RLE_ENDPOINT=.", - resolveControlPlaneEndpoint(), - ), + Suggestion: fmt.Sprintf("Ensure the Foundry project endpoint in %s is reachable and enabled for RLE.", foundryProjectEndpointEnvVar), } } -// isNotFoundError reports whether err is an RLE control plane error with HTTP 404 status. -func isNotFoundError(err error) bool { - if httpErr, ok := errors.AsType[*rleHTTPError](err); ok { - return httpErr.statusCode == http.StatusNotFound +func newRleClient(endpoint string) (*rleClient, error) { + credential, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return nil, fmt.Errorf("create Azure credential: %w", err) } - return false + + return newRleClientWithCredential(endpoint, credential), nil } -func newRleClient(endpoint string) *rleClient { +func newRleClientWithCredential(endpoint string, credential azcore.TokenCredential) *rleClient { return &rleClient{ - baseUrl: strings.TrimRight(endpoint, "/"), + baseUrl: strings.TrimRight(endpoint, "/"), + credential: credential, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } -func resolveControlPlaneEndpoint() string { - if endpoint := os.Getenv("RLE_ENDPOINT"); endpoint != "" { - return endpoint - } - return defaultControlPlaneEndpoint -} - func (c *rleClient) createV1Environment( ctx context.Context, - project string, request v1EnvironmentRequest, ) (*environmentResource, error) { - path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments", - url.PathEscape(project), - ) - var result environmentResource - if err := c.do(ctx, http.MethodPost, path, request, &result); err != nil { + if err := c.do(ctx, http.MethodPost, environmentCollectionPath, request, &result); err != nil { return nil, err } return &result, nil } -func (c *rleClient) updateV1Environment( +func (c *rleClient) getEnvironmentVersion( ctx context.Context, - project string, - environmentId string, - request v1EnvironmentRequest, + name string, + version string, ) (*environmentResource, error) { path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments/%s", - url.PathEscape(project), - url.PathEscape(environmentId), + "%s/%s/versions/%s", + environmentCollectionPath, + url.PathEscape(name), + url.PathEscape(version), ) var result environmentResource - if err := c.do(ctx, http.MethodPut, path, request, &result); err != nil { + if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { return nil, err } @@ -145,13 +139,12 @@ func (c *rleClient) updateV1Environment( func (c *rleClient) createSandbox( ctx context.Context, - project string, environmentId string, request sandboxCreateRequest, ) (*sandboxResource, error) { path := fmt.Sprintf( - "/rle/v1.0/projects/%s/environments/%s/sandboxes", - url.PathEscape(project), + "%s/%s/sandboxes/lease", + environmentCollectionPath, url.PathEscape(environmentId), ) @@ -165,11 +158,10 @@ func (c *rleClient) createSandbox( func (c *rleClient) getSandbox( ctx context.Context, - project string, environmentId string, sandboxId string, ) (*sandboxResource, error) { - path := sandboxPath(project, environmentId, sandboxId) + path := sandboxPath(environmentId, sandboxId) var result sandboxResource if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { @@ -181,17 +173,16 @@ func (c *rleClient) getSandbox( func (c *rleClient) deleteSandbox( ctx context.Context, - project string, environmentId string, sandboxId string, ) error { - return c.do(ctx, http.MethodDelete, sandboxPath(project, environmentId, sandboxId), nil, nil) + return c.do(ctx, http.MethodDelete, sandboxPath(environmentId, sandboxId)+"/release", nil, nil) } -func sandboxPath(project string, environmentId string, sandboxId string) string { +func sandboxPath(environmentId string, sandboxId string) string { return fmt.Sprintf( - "/rle/v1.0/projects/%s/environments/%s/sandboxes/%s", - url.PathEscape(project), + "%s/%s/sandboxes/%s", + environmentCollectionPath, url.PathEscape(environmentId), url.PathEscape(sandboxId), ) @@ -207,10 +198,28 @@ func (c *rleClient) do(ctx context.Context, method string, path string, body any reader = bytes.NewReader(data) } - req, err := http.NewRequestWithContext(ctx, method, c.baseUrl+path, reader) + requestUrl, err := url.Parse(c.baseUrl + path) + if err != nil { + return fmt.Errorf("create request URL: %w", err) + } + query := requestUrl.Query() + query.Set("api-version", foundryAPIVersion) + requestUrl.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, method, requestUrl.String(), reader) if err != nil { return fmt.Errorf("create request: %w", err) } + if !strings.EqualFold(req.URL.Scheme, "https") { + return errors.New("RLE control-plane authentication requires an HTTPS Foundry project endpoint") + } + token, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{foundryTokenScope}, + }) + if err != nil { + return fmt.Errorf("authenticate to Foundry: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token.Token) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go index f8e468f4c5e..3af3813a304 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go @@ -4,16 +4,86 @@ package cmd import ( + "context" "errors" + "io" + "net/http" "strings" "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-dev/cli/azd/pkg/azdext" ) -func TestServiceErrorSuggestionShowsCurrentEndpoint(t *testing.T) { - t.Setenv("RLE_ENDPOINT", "https://rle.example.test") +type testTokenCredential struct { + scopes []string +} + +func (c *testTokenCredential) GetToken( + _ context.Context, + options policy.TokenRequestOptions, +) (azcore.AccessToken, error) { + c.scopes = options.Scopes + return azcore.AccessToken{Token: "test-token", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func TestFoundryAPIVersionValue(t *testing.T) { + t.Parallel() + + if foundryAPIVersion != "2025-11-15-preview" { + t.Fatalf("expected Foundry API version %q, got %q", "2025-11-15-preview", foundryAPIVersion) + } +} + +func TestRleClientAuthenticatesFoundryRequests(t *testing.T) { + credential := &testTokenCredential{} + client := newRleClientWithCredential( + "https://account.services.ai.azure.com/api/projects/project", + credential, + ) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if got := request.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("expected bearer token, got %q", got) + } + if got := request.URL.Query().Get("api-version"); got != foundryAPIVersion { + t.Fatalf("expected API version %q, got %q", foundryAPIVersion, got) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("{}")), + Header: make(http.Header), + }, nil + }) + + if err := client.do(context.Background(), http.MethodGet, environmentCollectionPath, nil, nil); err != nil { + t.Fatal(err) + } + if len(credential.scopes) != 1 || credential.scopes[0] != foundryTokenScope { + t.Fatalf("expected Foundry token scope %q, got %v", foundryTokenScope, credential.scopes) + } +} + +func TestRleClientRefusesAuthenticationOverHTTP(t *testing.T) { + client := newRleClientWithCredential( + "http://localhost:5000", + &testTokenCredential{}, + ) + + err := client.do(context.Background(), http.MethodGet, environmentCollectionPath, nil, nil) + if err == nil || !strings.Contains(err.Error(), "requires an HTTPS") { + t.Fatalf("expected HTTPS authentication error, got %v", err) + } +} +func TestServiceErrorSuggestionShowsFoundryProjectEndpoint(t *testing.T) { err := serviceError(errors.New("dial tcp failed")) var serviceErr *azdext.ServiceError if !errors.As(err, &serviceErr) { @@ -21,9 +91,9 @@ func TestServiceErrorSuggestionShowsCurrentEndpoint(t *testing.T) { } for _, expected := range []string{ - "Ensure the RLE control plane is running and reachable.", - "Trying at https://rle.example.test;", - "RLE_ENDPOINT=", + "Ensure the Foundry project endpoint", + foundryProjectEndpointEnvVar, + "enabled for RLE", } { if !strings.Contains(serviceErr.Suggestion, expected) { t.Fatalf("expected suggestion to contain %q, got %q", expected, serviceErr.Suggestion) @@ -48,7 +118,7 @@ func TestResolveDeployStateUsesFoundryProjectEndpointEnvironment(t *testing.T) { } } -func TestResolveDeployStateIgnoresSavedProjectEndpointFallback(t *testing.T) { +func TestResolveDeployStateUsesSavedProjectEndpointFallback(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) if err := saveRleState(rleState{ @@ -65,8 +135,8 @@ func TestResolveDeployStateIgnoresSavedProjectEndpointFallback(t *testing.T) { if !initialized { t.Fatal("expected saved state") } - if state.ProjectEndpoint != "" { - t.Fatalf("expected saved project endpoint fallback to be ignored, got %q", state.ProjectEndpoint) + if state.ProjectEndpoint != "https://account.services.ai.azure.com/api/projects/saved-project" { + t.Fatalf("expected saved project endpoint fallback, got %q", state.ProjectEndpoint) } } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go index 34affa594cc..4b8b6f3a413 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go @@ -84,12 +84,10 @@ func (a *deployAction) Run() error { if err := project.PushImage(a.cmd.Context(), a.cmd.OutOrStdout(), a.cmd.ErrOrStderr(), image); err != nil { return err } - projectName, err := projectRouteSegment(state) + client, err := createRleClient(state.ProjectEndpoint) if err != nil { return err } - environmentId := firstNonEmpty(state.EnvironmentId, project.Slug(state.Name)) - client := newRleClient(resolveControlPlaneEndpoint()) request := v1EnvironmentRequest{ Name: state.Name, AcrImagePath: image, @@ -112,23 +110,9 @@ func (a *deployAction) Run() error { return err } if state.EnvironmentId == "" { - environment, err = client.createV1Environment(a.cmd.Context(), projectName, request) + environment, err = client.createV1Environment(a.cmd.Context(), request) } else { - environment, err = client.updateV1Environment(a.cmd.Context(), projectName, environmentId, request) - if isNotFoundError(err) { - // The recorded environment no longer exists in the target project - // (e.g. the project changed or the control plane was reset). Recreate it. - if _, msgErr := fmt.Fprintf( - a.cmd.OutOrStdout(), - "Environment '%s' not found in project '%s'; creating a new one.\n", - environmentId, - projectName, - ); msgErr != nil { - return msgErr - } - created = true - environment, err = client.createV1Environment(a.cmd.Context(), projectName, request) - } + environment, err = client.createV1Environment(a.cmd.Context(), request) } if err != nil { return serviceError(err) @@ -187,7 +171,9 @@ func resolveDeployState(flags *rleDeployFlags) (rleState, bool, error) { if err != nil { return rleState{}, false, err } - state.ProjectEndpoint = projectEndpoint + if projectEndpoint != "" { + state.ProjectEndpoint = projectEndpoint + } return state, initialized, nil } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go index 378e80ac0cc..3d3ac3f7ef3 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -66,7 +66,10 @@ func (a *remoteInvokeAction) Run() error { ctx, stopSignals := signal.NotifyContext(a.cmd.Context(), os.Interrupt) defer stopSignals() - client := newRleClient(resolveControlPlaneEndpoint()) + client, err := createRleClient(state.ProjectEndpoint) + if err != nil { + return err + } if _, err := fmt.Fprintf( a.cmd.OutOrStdout(), "Creating sandbox for environment %s ...\n", @@ -88,12 +91,11 @@ func (a *remoteInvokeAction) Run() error { } }() - sandboxUrl := strings.TrimRight(firstNonEmpty(sandbox.Url, sandbox.Endpoint), "/") + sandboxUrl := strings.TrimRight(sandbox.BaseUrl, "/") if _, err := fmt.Fprintf( a.cmd.OutOrStdout(), - "Sandbox %s ready at %s\n", + "Sandbox %s ready\n", sandbox.Id, - sandboxUrl, ); err != nil { return err } @@ -105,9 +107,6 @@ func (a *remoteInvokeAction) Run() error { return err } defer stopPlayground() - if _, err := fmt.Fprintf(a.cmd.OutOrStdout(), "Playground UI: %s\n", playgroundUrl); err != nil { - return err - } if err := ui.OpenBrowser(playgroundUrl); err != nil { _, _ = fmt.Fprintf(a.cmd.ErrOrStderr(), "Warning: failed to open playground UI: %v\n", err) } @@ -115,16 +114,17 @@ func (a *remoteInvokeAction) Run() error { } const ( - sandboxStatusRunning = "Running" - sandboxStatusFailed = "Failed" - - remoteSandboxLeaseMaxRetries = 10 + sandboxStatusRunning = "Running" + sandboxStatusFailed = "Failed" + diskImageConversionStatusReady = "Ready" + diskImageConversionStatusFailed = "Failed" ) var ( - remoteSandboxCreateTimeout = 300 * time.Second - remoteSandboxPollInterval = 2 * time.Second - remoteImagePollInterval = 5 * time.Second + remoteSandboxCreateTimeout = 300 * time.Second + remoteSandboxPollInterval = 2 * time.Second + remoteImageConversionTimeout = 15 * time.Minute + remoteImagePollInterval = 5 * time.Second ) func leaseRemoteSandbox( @@ -133,7 +133,13 @@ func leaseRemoteSandbox( client *rleClient, state rleState, ) (*sandboxResource, error) { - sandbox, err := createSandboxWhenImageReady(ctx, output, client, state) + if err := waitForEnvironmentImage(ctx, output, client, state); err != nil { + return nil, err + } + + sandbox, err := client.createSandbox(ctx, state.EnvironmentId, sandboxCreateRequest{ + Version: state.EnvironmentVersion, + }) if err != nil { return nil, err } @@ -145,11 +151,7 @@ func leaseRemoteSandbox( Suggestion: "Check the RLE control plane sandbox response, then retry.", } } - project, err := projectRouteSegment(state) - if err != nil { - return nil, err - } - readySandbox, err := waitForRemoteSandbox(ctx, client, project, state.EnvironmentId, sandbox) + readySandbox, err := waitForRemoteSandbox(ctx, client, state.EnvironmentId, sandbox) if err != nil { if releaseErr := releaseRemoteSandbox(client, state, sandbox.Id); releaseErr != nil { return nil, fmt.Errorf("%w; additionally failed to release sandbox %s: %w", err, sandbox.Id, releaseErr) @@ -159,79 +161,73 @@ func leaseRemoteSandbox( return readySandbox, nil } -func createSandboxWhenImageReady( +func waitForEnvironmentImage( ctx context.Context, output io.Writer, client *rleClient, state rleState, -) (*sandboxResource, error) { - deadline := time.Now().Add(remoteSandboxCreateTimeout) - attempt := 0 +) error { + if strings.TrimSpace(state.Name) == "" || strings.TrimSpace(state.EnvironmentVersion) == "" { + return nil + } + + deadline := time.Now().Add(remoteImageConversionTimeout) for { - project, err := projectRouteSegment(state) + environment, err := client.getEnvironmentVersion(ctx, state.Name, state.EnvironmentVersion) if err != nil { - return nil, err - } - sandbox, err := client.createSandbox(ctx, project, state.EnvironmentId, sandboxCreateRequest{ - Version: state.EnvironmentVersion, - }) - if err == nil { - return sandbox, nil + return err } - status, pending := sandboxLeasePendingStatus(err) - if !pending { - return nil, err + switch environment.DiskImageConversionStatus { + case diskImageConversionStatusReady: + return nil + case diskImageConversionStatusFailed: + return &azdext.LocalError{ + Message: fmt.Sprintf( + "Environment '%s' version '%s' disk image conversion failed: %s", + state.Name, + state.EnvironmentVersion, + firstNonEmpty(environment.DiskImageConversionError, "unknown error"), + ), + Code: "rle_disk_image_conversion_failed", + Category: azdext.LocalErrorCategoryUser, + } } - if attempt >= remoteSandboxLeaseMaxRetries || time.Now().After(deadline) { - return nil, &azdext.LocalError{ + + if time.Now().After(deadline) { + return &azdext.LocalError{ Message: fmt.Sprintf( - "Sandbox was not ready for testing after %d retries (last status: %s).", - attempt, - firstNonEmpty(status, "unknown"), + "Environment '%s' version '%s' disk image was not ready after %.0f minutes (last status: %s).", + state.Name, + state.EnvironmentVersion, + remoteImageConversionTimeout.Minutes(), + firstNonEmpty(environment.DiskImageConversionStatus, "unknown"), ), - Code: "rle_sandbox_lease_pending_timeout", + Code: "rle_disk_image_conversion_timeout", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Wait for the RLE control plane to finish preparing the sandbox, then retry invoke.", + Suggestion: "Check the environment disk image conversion status, then retry invoke.", } } - attempt++ if _, msgErr := fmt.Fprintf( output, - "Getting sandbox ready for testing (status: %s); waiting %.0f seconds before retrying (attempt %d of %d) ...\n", - firstNonEmpty(status, "not ready"), + "Preparing environment disk image (status: %s); waiting %.0f seconds ...\n", + firstNonEmpty(environment.DiskImageConversionStatus, "unknown"), remoteImagePollInterval.Seconds(), - attempt, - remoteSandboxLeaseMaxRetries, ); msgErr != nil { - return nil, msgErr + return msgErr } select { case <-ctx.Done(): - return nil, ctx.Err() + return ctx.Err() case <-time.After(remoteImagePollInterval): } } } -func sandboxLeasePendingStatus(err error) (string, bool) { - httpErr, ok := errors.AsType[*rleHTTPError](err) - if !ok || httpErr.statusCode != http.StatusConflict { - return "", false - } - status := strings.TrimSpace(httpErr.body) - if before, after, found := strings.Cut(httpErr.body, "conversion status:"); found { - _ = before - status = strings.TrimSpace(strings.Trim(strings.Split(after, ")")[0], `."}`)) - } - return status, true -} - func waitForRemoteSandbox( ctx context.Context, client *rleClient, - project string, environmentId string, sandbox *sandboxResource, ) (*sandboxResource, error) { @@ -249,7 +245,7 @@ func waitForRemoteSandbox( } } if sandbox.Status == sandboxStatusRunning { - if strings.TrimSpace(firstNonEmpty(sandbox.Url, sandbox.Endpoint)) == "" { + if strings.TrimSpace(sandbox.BaseUrl) == "" { return nil, &azdext.LocalError{ Message: fmt.Sprintf("Sandbox %s is Running but did not report a data-plane URL.", sandbox.Id), Code: "rle_sandbox_url_missing", @@ -279,7 +275,7 @@ func waitForRemoteSandbox( case <-time.After(remoteSandboxPollInterval): } - updated, err := client.getSandbox(ctx, project, environmentId, sandbox.Id) + updated, err := client.getSandbox(ctx, environmentId, sandbox.Id) if err != nil { return nil, err } @@ -290,11 +286,7 @@ func waitForRemoteSandbox( func releaseRemoteSandbox(client *rleClient, state rleState, sandboxId string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - project, err := projectRouteSegment(state) - if err != nil { - return err - } - return client.deleteSandbox(ctx, project, state.EnvironmentId, sandboxId) + return client.deleteSandbox(ctx, state.EnvironmentId, sandboxId) } func remotePlaygroundUrl(ctx context.Context, sandboxUrl string) (string, func(), error) { diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go index 675deedfbdf..84b743d73ec 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -7,10 +7,12 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "net" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strconv" @@ -52,14 +54,17 @@ func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { + case r.Method == http.MethodGet && + r.URL.Path == "/fine_tuning/environments/code_rl/versions/v1": + _, _ = w.Write([]byte(`{"id":"env-1","version":"v1","diskImageConversionStatus":"Ready"}`)) case r.Method == http.MethodPost && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": if err := json.NewDecoder(r.Body).Decode(&sandboxBody); err != nil { t.Fatal(err) } - _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","url":` + strconv.Quote(envServer.URL) + `}`)) + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": deleteCalled = true w.WriteHeader(http.StatusNoContent) default: @@ -67,7 +72,7 @@ func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { } })) defer controlPlane.Close() - t.Setenv("RLE_ENDPOINT", controlPlane.URL) + useTestProjectEndpoint(t, controlPlane.URL) command := newInvokeCommand() command.SetIn(strings.NewReader("health\nexit\n")) @@ -80,9 +85,12 @@ func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { if sandboxBody["version"] != "v1" { t.Fatalf("expected sandbox version, got %#v", sandboxBody) } - if !strings.Contains(output.String(), "Sandbox sandbox-1 ready at "+envServer.URL) { + if !strings.Contains(output.String(), "Sandbox sandbox-1 ready") { t.Fatalf("expected sandbox ready output, got %s", output.String()) } + if strings.Contains(output.String(), envServer.URL) { + t.Fatalf("expected sandbox data-plane URL to remain hidden, got %s", output.String()) + } if !strings.Contains(output.String(), `"status": "healthy"`) { t.Fatalf("expected remote shell health output, got %s", output.String()) } @@ -96,9 +104,10 @@ func TestInvokeRemoteUsesSandboxWebWhenAvailable(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) if err := saveRleState(rleState{ - Name: "code_rl", - ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", - EnvironmentId: "env-1", + Name: "code_rl", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", + EnvironmentId: "env-1", + EnvironmentVersion: "1.0.0", }); err != nil { t.Fatal(err) } @@ -117,19 +126,23 @@ func TestInvokeRemoteUsesSandboxWebWhenAvailable(t *testing.T) { controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == http.MethodGet && + r.URL.Path == "/fine_tuning/environments/code_rl/versions/1.0.0": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"env-1","version":"1.0.0","diskImageConversionStatus":"Ready"}`)) case r.Method == http.MethodPost && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","url":` + strconv.Quote(envServer.URL) + `}`)) + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) } })) defer controlPlane.Close() - t.Setenv("RLE_ENDPOINT", controlPlane.URL) + useTestProjectEndpoint(t, controlPlane.URL) command := newInvokeCommand() command.SetIn(strings.NewReader("exit\n")) @@ -139,8 +152,11 @@ func TestInvokeRemoteUsesSandboxWebWhenAvailable(t *testing.T) { if err := command.Execute(); err != nil { t.Fatal(err) } - if !strings.Contains(output.String(), "Playground UI: "+envServer.URL+"/web") { - t.Fatalf("expected sandbox web URL, got %s", output.String()) + if strings.Contains(output.String(), "Playground UI:") { + t.Fatalf("expected playground URL to remain hidden, got %s", output.String()) + } + if strings.Contains(output.String(), envServer.URL+"/web") { + t.Fatalf("expected playground proxy URL to remain hidden, got %s", output.String()) } if *openedUrl != envServer.URL+"/web" { t.Fatalf("expected browser to open sandbox web URL, got %q", *openedUrl) @@ -191,9 +207,10 @@ func TestInvokeRemotePollsSandboxUntilRunning(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) if err := saveRleState(rleState{ - Name: "code_rl", - ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", - EnvironmentId: "env-1", + Name: "code_rl", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", + EnvironmentId: "env-1", + EnvironmentVersion: "1.0.0", }); err != nil { t.Fatal(err) } @@ -215,24 +232,28 @@ func TestInvokeRemotePollsSandboxUntilRunning(t *testing.T) { getCount := 0 controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == http.MethodGet && + r.URL.Path == "/fine_tuning/environments/code_rl/versions/1.0.0": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"env-1","version":"1.0.0","diskImageConversionStatus":"Ready"}`)) case r.Method == http.MethodPost && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Starting"}`)) case r.Method == http.MethodGet && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1": getCount++ w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","url":` + strconv.Quote(envServer.URL) + `}`)) + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) } })) defer controlPlane.Close() - t.Setenv("RLE_ENDPOINT", controlPlane.URL) + useTestProjectEndpoint(t, controlPlane.URL) command := newInvokeCommand() command.SetIn(strings.NewReader("exit\n")) @@ -245,9 +266,12 @@ func TestInvokeRemotePollsSandboxUntilRunning(t *testing.T) { if getCount != 1 { t.Fatalf("expected one sandbox poll, got %d", getCount) } - if !strings.Contains(output.String(), "Sandbox sandbox-1 ready at "+envServer.URL) { + if !strings.Contains(output.String(), "Sandbox sandbox-1 ready") { t.Fatalf("expected sandbox ready output, got %s", output.String()) } + if strings.Contains(output.String(), envServer.URL) { + t.Fatalf("expected sandbox data-plane URL to remain hidden, got %s", output.String()) + } } func TestInvokeRemoteFailsWhenSandboxFails(t *testing.T) { @@ -265,11 +289,11 @@ func TestInvokeRemoteFailsWhenSandboxFails(t *testing.T) { controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Failed","error":"image pull failed"}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": deleteCalled = true w.WriteHeader(http.StatusNoContent) default: @@ -277,7 +301,7 @@ func TestInvokeRemoteFailsWhenSandboxFails(t *testing.T) { } })) defer controlPlane.Close() - t.Setenv("RLE_ENDPOINT", controlPlane.URL) + useTestProjectEndpoint(t, controlPlane.URL) command := newInvokeCommand() var output bytes.Buffer @@ -390,9 +414,10 @@ func TestInvokeRemoteWaitsForDiskImageConversion(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) if err := saveRleState(rleState{ - Name: "code_rl", - ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", - EnvironmentId: "env-1", + Name: "code_rl", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", + EnvironmentId: "env-1", + EnvironmentVersion: "1.0.0", }); err != nil { t.Fatal(err) } @@ -411,27 +436,33 @@ func TestInvokeRemoteWaitsForDiskImageConversion(t *testing.T) { })) defer envServer.Close() + conversionGetCount := 0 createCount := 0 controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == http.MethodGet && + r.URL.Path == "/fine_tuning/environments/code_rl/versions/1.0.0": + conversionGetCount++ + w.Header().Set("Content-Type", "application/json") + status := "Pending" + if conversionGetCount >= 3 { + status = "Ready" + } + _, _ = fmt.Fprintf(w, `{"id":"env-1","version":"1.0.0","diskImageConversionStatus":%q}`, status) case r.Method == http.MethodPost && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": createCount++ - if createCount < 3 { - http.Error(w, "disk conversion status: Pending", http.StatusConflict) - return - } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","url":` + strconv.Quote(envServer.URL) + `}`)) + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes/sandbox-1": + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) } })) defer controlPlane.Close() - t.Setenv("RLE_ENDPOINT", controlPlane.URL) + useTestProjectEndpoint(t, controlPlane.URL) command := newInvokeCommand() command.SetIn(strings.NewReader("exit\n")) @@ -441,36 +472,45 @@ func TestInvokeRemoteWaitsForDiskImageConversion(t *testing.T) { if err := command.Execute(); err != nil { t.Fatal(err) } - if createCount != 3 { - t.Fatalf("expected sandbox create to retry twice, got %d calls", createCount) + if conversionGetCount != 3 { + t.Fatalf("expected three conversion status checks, got %d", conversionGetCount) } - if !strings.Contains(output.String(), "Getting sandbox ready for testing (status: Pending); waiting") { - t.Fatalf("expected sandbox readiness wait message, got %s", output.String()) + if createCount != 1 { + t.Fatalf("expected one sandbox lease after conversion, got %d", createCount) } - if !strings.Contains(output.String(), "Sandbox sandbox-1 ready at "+envServer.URL) { + if !strings.Contains(output.String(), "Preparing environment disk image (status: Pending); waiting") { + t.Fatalf("expected disk image conversion wait message, got %s", output.String()) + } + if !strings.Contains(output.String(), "Sandbox sandbox-1 ready") { t.Fatalf("expected sandbox ready output, got %s", output.String()) } + if strings.Contains(output.String(), envServer.URL) { + t.Fatalf("expected sandbox data-plane URL to remain hidden, got %s", output.String()) + } } -func TestRemoteInvokeStopsRetryingSandboxLeaseConflicts(t *testing.T) { +func TestRemoteInvokeDoesNotRetrySandboxLeaseConflictsAfterImageReady(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) if err := saveRleState(rleState{ - Name: "code_rl", - ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", - EnvironmentId: "env-1", + Name: "code_rl", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", + EnvironmentId: "env-1", + EnvironmentVersion: "1.0.0", }); err != nil { t.Fatal(err) } - oldPollInterval := remoteImagePollInterval - remoteImagePollInterval = time.Millisecond - defer func() { remoteImagePollInterval = oldPollInterval }() - createCount := 0 controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && + r.URL.Path == "/fine_tuning/environments/code_rl/versions/1.0.0" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"env-1","version":"1.0.0","diskImageConversionStatus":"Ready"}`)) + return + } if r.Method == http.MethodPost && - r.URL.Path == "/rle/v1.0/projects/project-1/environments/env-1/sandboxes" { + r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease" { createCount++ http.Error(w, `{"error":"quota unavailable"}`, http.StatusConflict) return @@ -478,7 +518,7 @@ func TestRemoteInvokeStopsRetryingSandboxLeaseConflicts(t *testing.T) { t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) })) defer controlPlane.Close() - t.Setenv("RLE_ENDPOINT", controlPlane.URL) + useTestProjectEndpoint(t, controlPlane.URL) command := newInvokeCommand() command.SetIn(strings.NewReader("exit\n")) @@ -487,33 +527,43 @@ func TestRemoteInvokeStopsRetryingSandboxLeaseConflicts(t *testing.T) { command.SetErr(&output) err := command.Execute() if err == nil { - t.Fatal("expected sandbox lease retry error") + t.Fatal("expected sandbox lease conflict") } - localErr, ok := errors.AsType[*azdext.LocalError](err) + serviceErr, ok := errors.AsType[*azdext.ServiceError](err) if !ok { - t.Fatalf("expected LocalError, got %T", err) + t.Fatalf("expected ServiceError, got %T", err) } - if localErr.Code != "rle_sandbox_lease_pending_timeout" { - t.Fatalf("expected sandbox lease timeout code, got %q", localErr.Code) + if createCount != 1 { + t.Fatalf("expected one sandbox lease attempt, got %d", createCount) } - if createCount != remoteSandboxLeaseMaxRetries+1 { - t.Fatalf("expected initial attempt plus max retries, got %d calls", createCount) - } - if !strings.Contains(localErr.Message, "Sandbox was not ready for testing") { - t.Fatalf("expected generic sandbox readiness message, got %q", localErr.Message) + if !strings.Contains(serviceErr.Message, "quota unavailable") { + t.Fatalf("expected lease conflict details, got %q", serviceErr.Message) } } -func TestSandboxLeasePendingStatusTreatsAnyConflictAsPending(t *testing.T) { - status, ok := sandboxLeasePendingStatus(&rleHTTPError{ - statusCode: http.StatusConflict, - body: `{"error":"different conflict"}`, +func TestWaitForEnvironmentImageReportsConversionFailure(t *testing.T) { + client := newRleClientWithCredential("https://rle.test", &testTokenCredential{}) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"diskImageConversionStatus":"Failed","diskImageConversionError":"invalid image"}`, + )), + Header: make(http.Header), + }, nil + }) + + err := waitForEnvironmentImage(t.Context(), io.Discard, client, rleState{ + Name: "code_rl", + EnvironmentVersion: "1.0.0", }) + localErr, ok := errors.AsType[*azdext.LocalError](err) if !ok { - t.Fatal("expected conflict to be treated as pending") + t.Fatalf("expected LocalError, got %T", err) } - if !strings.Contains(status, "different conflict") { - t.Fatalf("expected conflict body in status, got %q", status) + if localErr.Code != "rle_disk_image_conversion_failed" || + !strings.Contains(localErr.Message, "invalid image") { + t.Fatalf("expected conversion failure details, got %#v", localErr) } } @@ -531,6 +581,37 @@ func captureBrowserOpen(t *testing.T) *string { return &openedUrl } +func useTestProjectEndpoint(t *testing.T, endpoint string) { + t.Helper() + target, err := url.Parse(endpoint) + if err != nil { + t.Fatal(err) + } + oldCreateRleClient := createRleClient + createRleClient = func(string) (*rleClient, error) { + client := newRleClientWithCredential("https://rle.test", &testTokenCredential{}) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + request = request.Clone(request.Context()) + request.URL.Scheme = target.Scheme + request.URL.Host = target.Host + return http.DefaultTransport.RoundTrip(request) + }) + return client, nil + } + t.Cleanup(func() { + createRleClient = oldCreateRleClient + }) + + state, err := loadRleState() + if err != nil { + t.Fatal(err) + } + state.ProjectEndpoint = endpoint + if err := saveRleState(state); err != nil { + t.Fatal(err) + } +} + func TestResolveDeployStateDefaultsToExistingFolderWithoutInit(t *testing.T) { tempDir := filepath.Join(t.TempDir(), "My Env") if err := os.MkdirAll(tempDir, 0750); err != nil { diff --git a/cli/azd/extensions/azure.ai.rle/internal/project/docker.go b/cli/azd/extensions/azure.ai.rle/internal/project/docker.go index d43d3ca981d..a89ca90199a 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/project/docker.go +++ b/cli/azd/extensions/azure.ai.rle/internal/project/docker.go @@ -56,7 +56,7 @@ func PushImage(ctx context.Context, stdout io.Writer, stderr io.Writer, image st Message: fmt.Sprintf("Failed to push Docker image %q: %v", image, err), Code: "rle_docker_push_failed", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Run az acr login --name , then retry.", + Suggestion: "Run az acr login --name , then retry.", } } return nil diff --git a/cli/azd/extensions/azure.ai.rle/version.txt b/cli/azd/extensions/azure.ai.rle/version.txt index b727e6cbb8a..fb0124368e3 100644 --- a/cli/azd/extensions/azure.ai.rle/version.txt +++ b/cli/azd/extensions/azure.ai.rle/version.txt @@ -1 +1 @@ -0.1.0-preview \ No newline at end of file +0.2.0-preview \ No newline at end of file From 05675171e586ba92f2750df851ae0c0e70fa9346 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 29 Jul 2026 13:21:04 +0530 Subject: [PATCH 02/12] fix(rle): address pull request feedback Validate persisted Foundry endpoints before creating credentials and cover the full project-relative route shape in invoke tests. Correct the release heading, prerequisites, and test contexts. Authored-by: GitHub Copilot CLI v1.0.68 Model: GPT-5.4 (gpt-5.4) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.rle/CHANGELOG.md | 3 +- cli/azd/extensions/azure.ai.rle/README.md | 3 +- .../azure.ai.rle/internal/cmd/client.go | 12 +++++- .../azure.ai.rle/internal/cmd/client_test.go | 15 ++++++- .../azure.ai.rle/internal/cmd/invoke_test.go | 41 +++++++++++-------- 5 files changed, 49 insertions(+), 25 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md index 4847dd6a2a9..a1200e27e27 100644 --- a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md @@ -1,13 +1,12 @@ # Release History -## 0.2.0-preview +## 0.2.0-preview (Unreleased) - Use the Foundry project endpoint for project-relative RLE environment and sandbox APIs. - Authenticate Foundry API requests with Azure credentials from `az login`, `azd auth login`, or another supported development credential. - Send the required `2025-11-15-preview` Foundry data-plane API version. - Support versioned environment deployments and sandbox `baseUrl` invocation. - Wait for asynchronous disk-image conversion before leasing a sandbox and surface conversion failures directly. -- Keep container command output concise while preserving actionable failure diagnostics. ## 0.1.0-preview diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 548eba8b73e..0759b87cfd0 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -7,6 +7,7 @@ Quickstart for the `azd ai rle` preview extension. The extension manages an Open Install: - Azure Developer CLI (`azd`): https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd +- Azure CLI (`az`): https://learn.microsoft.com/cli/azure/install-azure-cli - Docker Desktop: https://www.docker.com/products/docker-desktop/ - Go, if building from source: https://go.dev/doc/install - Git, if building from source: https://git-scm.com/downloads @@ -16,7 +17,7 @@ Verify: ```powershell azd version docker version -az account show +az version ``` Sign in with Azure CLI before calling the Foundry project APIs: diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go index 7bb60b5ecc2..e018cced8fb 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -82,17 +82,25 @@ func serviceError(err error) error { return &azdext.ServiceError{ Message: err.Error(), ServiceName: "rle-control-plane", - Suggestion: fmt.Sprintf("Ensure the Foundry project endpoint in %s is reachable and enabled for RLE.", foundryProjectEndpointEnvVar), + Suggestion: fmt.Sprintf( + "Ensure the Foundry project endpoint in %s is reachable and enabled for RLE.", + foundryProjectEndpointEnvVar, + ), } } func newRleClient(endpoint string) (*rleClient, error) { + normalizedEndpoint, err := normalizeFoundryProjectEndpoint(endpoint) + if err != nil { + return nil, err + } + credential, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { return nil, fmt.Errorf("create Azure credential: %w", err) } - return newRleClientWithCredential(endpoint, credential), nil + return newRleClientWithCredential(normalizedEndpoint, credential), nil } func newRleClientWithCredential(endpoint string, credential azcore.TokenCredential) *rleClient { diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go index 3af3813a304..6c8bbd7917d 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go @@ -63,7 +63,7 @@ func TestRleClientAuthenticatesFoundryRequests(t *testing.T) { }, nil }) - if err := client.do(context.Background(), http.MethodGet, environmentCollectionPath, nil, nil); err != nil { + if err := client.do(t.Context(), http.MethodGet, environmentCollectionPath, nil, nil); err != nil { t.Fatal(err) } if len(credential.scopes) != 1 || credential.scopes[0] != foundryTokenScope { @@ -77,12 +77,23 @@ func TestRleClientRefusesAuthenticationOverHTTP(t *testing.T) { &testTokenCredential{}, ) - err := client.do(context.Background(), http.MethodGet, environmentCollectionPath, nil, nil) + err := client.do(t.Context(), http.MethodGet, environmentCollectionPath, nil, nil) if err == nil || !strings.Contains(err.Error(), "requires an HTTPS") { t.Fatalf("expected HTTPS authentication error, got %v", err) } } +func TestNewRleClientRejectsUntrustedEndpoint(t *testing.T) { + _, err := newRleClient("https://attacker.example/api/projects/project-1") + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) + } + if localErr.Code != "rle_invalid_project_endpoint" { + t.Fatalf("expected invalid endpoint code, got %q", localErr.Code) + } +} + func TestServiceErrorSuggestionShowsFoundryProjectEndpoint(t *testing.T) { err := serviceError(errors.New("dial tcp failed")) var serviceErr *azdext.ServiceError diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go index 84b743d73ec..c1e2b7511e7 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -25,6 +25,8 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) +const testFoundryProjectPath = "/api/projects/project-1" + func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { captureBrowserOpen(t) tempDir := t.TempDir() @@ -55,16 +57,16 @@ func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && - r.URL.Path == "/fine_tuning/environments/code_rl/versions/v1": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/code_rl/versions/v1": _, _ = w.Write([]byte(`{"id":"env-1","version":"v1","diskImageConversionStatus":"Ready"}`)) case r.Method == http.MethodPost && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/lease": if err := json.NewDecoder(r.Body).Decode(&sandboxBody); err != nil { t.Fatal(err) } _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": deleteCalled = true w.WriteHeader(http.StatusNoContent) default: @@ -127,15 +129,15 @@ func TestInvokeRemoteUsesSandboxWebWhenAvailable(t *testing.T) { controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && - r.URL.Path == "/fine_tuning/environments/code_rl/versions/1.0.0": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/code_rl/versions/1.0.0": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"env-1","version":"1.0.0","diskImageConversionStatus":"Ready"}`)) case r.Method == http.MethodPost && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/lease": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) @@ -233,20 +235,20 @@ func TestInvokeRemotePollsSandboxUntilRunning(t *testing.T) { controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && - r.URL.Path == "/fine_tuning/environments/code_rl/versions/1.0.0": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/code_rl/versions/1.0.0": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"env-1","version":"1.0.0","diskImageConversionStatus":"Ready"}`)) case r.Method == http.MethodPost && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/lease": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Starting"}`)) case r.Method == http.MethodGet && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/sandbox-1": getCount++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) @@ -289,11 +291,11 @@ func TestInvokeRemoteFailsWhenSandboxFails(t *testing.T) { controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/lease": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Failed","error":"image pull failed"}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": deleteCalled = true w.WriteHeader(http.StatusNoContent) default: @@ -441,7 +443,7 @@ func TestInvokeRemoteWaitsForDiskImageConversion(t *testing.T) { controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && - r.URL.Path == "/fine_tuning/environments/code_rl/versions/1.0.0": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/code_rl/versions/1.0.0": conversionGetCount++ w.Header().Set("Content-Type", "application/json") status := "Pending" @@ -450,12 +452,12 @@ func TestInvokeRemoteWaitsForDiskImageConversion(t *testing.T) { } _, _ = fmt.Fprintf(w, `{"id":"env-1","version":"1.0.0","diskImageConversionStatus":%q}`, status) case r.Method == http.MethodPost && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/lease": createCount++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) case r.Method == http.MethodDelete && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected sandbox request: %s %s", r.Method, r.URL.Path) @@ -504,13 +506,13 @@ func TestRemoteInvokeDoesNotRetrySandboxLeaseConflictsAfterImageReady(t *testing createCount := 0 controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && - r.URL.Path == "/fine_tuning/environments/code_rl/versions/1.0.0" { + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/code_rl/versions/1.0.0" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"env-1","version":"1.0.0","diskImageConversionStatus":"Ready"}`)) return } if r.Method == http.MethodPost && - r.URL.Path == "/fine_tuning/environments/env-1/sandboxes/lease" { + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/lease" { createCount++ http.Error(w, `{"error":"quota unavailable"}`, http.StatusConflict) return @@ -589,7 +591,10 @@ func useTestProjectEndpoint(t *testing.T, endpoint string) { } oldCreateRleClient := createRleClient createRleClient = func(string) (*rleClient, error) { - client := newRleClientWithCredential("https://rle.test", &testTokenCredential{}) + client := newRleClientWithCredential( + "https://rle.test"+testFoundryProjectPath, + &testTokenCredential{}, + ) client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { request = request.Clone(request.Context()) request.URL.Scheme = target.Scheme From 7c17421f48fa7db97a8026000abc1294f8edbbe5 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Wed, 29 Jul 2026 16:41:47 +0530 Subject: [PATCH 03/12] fix: remove dormant docker param --- cli/azd/extensions/azure.ai.rle/internal/cmd/run.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go index a1942f86ecc..53c8b9dfd61 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/run.go @@ -190,7 +190,6 @@ func ensureLocalContainerEndpoint(cmd *cobra.Command, flags *localRunFlags) (str "run", "-d", "--name", container, "--label", localContainerImageLabel + "=" + image, - "-e", "ENABLE_WEB_INTERFACE=true", "-p", portMapping, image, } From 917853430ae2501969ef50c5d468f92de969445d Mon Sep 17 00:00:00 2001 From: Azure SDK Bot <53356347+azure-sdk@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:27:16 -0400 Subject: [PATCH 04/12] [azure.ai.rle] Dev registry update for 0.2.0-preview (#9351) --- cli/azd/extensions/registry.dev.json | 80 ++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/cli/azd/extensions/registry.dev.json b/cli/azd/extensions/registry.dev.json index 943c24bcd74..ab50b2d5c63 100644 --- a/cli/azd/extensions/registry.dev.json +++ b/cli/azd/extensions/registry.dev.json @@ -246,6 +246,86 @@ "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.1.0-preview/azure-ai-rle-windows-arm64.zip" } } + }, + { + "version": "0.2.0-preview", + "capabilities": [ + "custom-commands", + "metadata" + ], + "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; azd ai rle [options]", + "examples": [ + { + "name": "init", + "description": "Copy the OpenEnv echo sample into a local RLE environment.", + "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; azd ai rle init" + }, + { + "name": "deploy", + "description": "Build, push, and create or update the RLE environment.", + "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; $env:FOUNDRY_PROJECT_ENDPOINT = \"https://.services.ai.azure.com/api/projects/\"; $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = \".azurecr.io\"; azd ai rle deploy" + }, + { + "name": "run", + "description": "Build and run the local RLE environment container.", + "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; azd ai rle run" + }, + { + "name": "invoke", + "description": "Lease a deployed RLE sandbox and open an OpenEnv runtime shell.", + "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; azd ai rle invoke" + } + ], + "artifacts": { + "darwin/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "2b8e9bda909a4be80c4cd932784c95e9f8ce3102ff118b4ddd46ffb852163eed" + }, + "entryPoint": "azure-ai-rle-darwin-amd64", + "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-darwin-amd64.zip" + }, + "darwin/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "3fb58b60ba8aa3f122d619865bb8f9203b1077ea200494ece5ca7f39d7445b8d" + }, + "entryPoint": "azure-ai-rle-darwin-arm64", + "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-darwin-arm64.zip" + }, + "linux/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "daa43b4a3dc3c09956b5929db0f6faba76b7d0acb0772d882e4fa5842759e23d" + }, + "entryPoint": "azure-ai-rle-linux-amd64", + "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-linux-amd64.tar.gz" + }, + "linux/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "c26e2460269ef83da6b7af1d0a8ba17e194708e3bc6be29deeb1e09e36a3852d" + }, + "entryPoint": "azure-ai-rle-linux-arm64", + "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-linux-arm64.tar.gz" + }, + "windows/amd64": { + "checksum": { + "algorithm": "sha256", + "value": "c0d4ee38e3b8a85a922d793e417b608ecbb47d151b6741b0b8839cd7124fc5dd" + }, + "entryPoint": "azure-ai-rle-windows-amd64.exe", + "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-windows-amd64.zip" + }, + "windows/arm64": { + "checksum": { + "algorithm": "sha256", + "value": "ed9fe83b92c20106c2049776335dfab00a676c0306b53ec432fe1f00961fdbf9" + }, + "entryPoint": "azure-ai-rle-windows-arm64.exe", + "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-windows-arm64.zip" + } + } } ], "tags": [ From 34a88930c305420ad436216d7e62a3382012b552 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Thu, 30 Jul 2026 13:16:49 +0530 Subject: [PATCH 05/12] fix: rename deploy to publish, new environment list command --- cli/azd/extensions/azure.ai.rle/CHANGELOG.md | 8 +- cli/azd/extensions/azure.ai.rle/README.md | 36 ++- .../extensions/azure.ai.rle/extension.yaml | 9 +- .../azure.ai.rle/internal/cmd/client.go | 23 ++ .../azure.ai.rle/internal/cmd/client_test.go | 8 +- .../azure.ai.rle/internal/cmd/environment.go | 144 ++++++++++ .../internal/cmd/environment_test.go | 251 ++++++++++++++++++ .../azure.ai.rle/internal/cmd/init.go | 2 +- .../azure.ai.rle/internal/cmd/invoke.go | 4 +- .../azure.ai.rle/internal/cmd/invoke_test.go | 26 +- .../internal/cmd/{deploy.go => publish.go} | 82 ++++-- .../azure.ai.rle/internal/cmd/publish_test.go | 74 ++++++ .../azure.ai.rle/internal/cmd/root.go | 3 +- .../azure.ai.rle/internal/cmd/root_test.go | 47 +++- cli/azd/extensions/azure.ai.rle/version.txt | 2 +- 15 files changed, 648 insertions(+), 71 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go rename cli/azd/extensions/azure.ai.rle/internal/cmd/{deploy.go => publish.go} (70%) create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/publish_test.go diff --git a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md index a1200e27e27..928af284965 100644 --- a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md @@ -1,6 +1,12 @@ # Release History -## 0.2.0-preview (Unreleased) +## 0.2.1-preview (Unreleased) + +- Add `azd ai rle environment list` to list environments in the configured Foundry project. +- Rename `azd ai rle deploy` to `azd ai rle publish` to avoid confusion with the core `azd deploy` command. +- Add `--version-bump` to `azd ai rle publish` so users can choose major, minor, or patch environment versioning. + +## 0.2.0-preview - Use the Foundry project endpoint for project-relative RLE environment and sandbox APIs. - Authenticate Foundry API requests with Azure credentials from `az login`, `azd auth login`, or another supported development credential. diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index 0759b87cfd0..ebc263f03f2 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -1,6 +1,6 @@ # Azure AI RLE extension for azd -Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: init, build and run the environment container, test it through a playground UI or shell, and deploy the environment image to the RLE control plane through your Foundry project endpoint. +Quickstart for the `azd ai rle` preview extension. The extension manages an OpenEnv-style RLE environment lifecycle: init, build and run the environment container, test it through a playground UI or shell, and publish the environment image to the RLE control plane through your Foundry project endpoint. ## Prerequisites @@ -41,7 +41,7 @@ azd ai rle --help azd ai rle version ``` -`version` is always available. The lifecycle commands are preview-gated; if commands such as `init`, `run`, `deploy`, or `invoke` are hidden, enable the preview flag in your terminal: +`version` is always available. The lifecycle commands are preview-gated; if commands such as `init`, `run`, `publish`, `environment`, or `invoke` are hidden, enable the preview flag in your terminal: ```powershell $env:AZD_AI_RLE_ENABLE = "true" @@ -51,7 +51,7 @@ $env:AZD_AI_RLE_ENABLE = "true" RLE control-plane APIs are called relative to the Foundry project endpoint. APIM maps the project endpoint request to the workspace-scoped RLE service internally, so the extension does not require a separate control-plane endpoint. -Set the Foundry project endpoint once in the terminal where you run `deploy`: +Set the Foundry project endpoint once in the terminal where you run `publish`: ```powershell $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" @@ -63,7 +63,7 @@ For example, RLE environment registration is sent to: /fine_tuning/environments?api-version=2025-11-15-preview ``` -Deploy also needs an ACR registry endpoint: +Publish also needs an ACR registry endpoint: ```powershell $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" @@ -148,25 +148,41 @@ Supported shell commands: | `schema` | `GET /schema` | | `exit` / `quit` | Exit shell | -### 3. Deploy/register +### 3. Publish/register ```powershell $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io" -azd ai rle deploy +azd ai rle publish --version-bump major ``` -Deploy reads the Foundry project endpoint from `FOUNDRY_PROJECT_ENDPOINT` and the ACR registry from `AZURE_CONTAINER_REGISTRY_ENDPOINT`. It derives the project route segment from `/api/projects/`, builds the Docker image as `.azurecr.io/-:latest`, pushes it to ACR, registers that image by calling `/fine_tuning/environments`, and saves the project/environment details in `.azd-rle.json`. +Publish reads the Foundry project endpoint from `FOUNDRY_PROJECT_ENDPOINT` and the ACR registry from `AZURE_CONTAINER_REGISTRY_ENDPOINT`. It derives the project route segment from `/api/projects/`, builds the Docker image as `.azurecr.io/-:latest`, pushes it to ACR, registers that image by calling `/fine_tuning/environments`, and saves the project/environment details in `.azd-rle.json`. -The deploy command prints a CLI-friendly summary using `environmentId`, `foundryProjectEndpoint`, `acrImage`, `environmentVersion`, `createdAt`, and `updatedAt`. +Use `--version-bump major` (default), `--version-bump minor`, or `--version-bump patch` to control the environment version that RLE creates. + +The publish command prints a CLI-friendly summary using `environmentId`, `foundryProjectEndpoint`, `acrImage`, `environmentVersion`, `createdAt`, and `updatedAt`. If needed, override the Dockerfile path the same way as local run: ```powershell -azd ai rle deploy --dockerfile server\Dockerfile +azd ai rle publish --dockerfile server\Dockerfile +``` + +### 4. List deployed environments + +List all RLE environments in the configured Foundry project: + +```powershell +azd ai rle environment list +``` + +The command uses `FOUNDRY_PROJECT_ENDPOINT` when it is set. Otherwise, it uses the project endpoint saved in the current folder's `.azd-rle.json`. Use JSON output for scripting: + +```powershell +azd ai rle environment list --output json ``` -### 4. Invoke remotely +### 5. Invoke remotely Remote invoke uses the deployed environment, leases a sandbox from `/fine_tuning/environments//sandboxes/lease`, opens the sandbox `/web` UI when available (or a local proxy UI otherwise), keeps the shell attached, and releases the sandbox when the shell exits: diff --git a/cli/azd/extensions/azure.ai.rle/extension.yaml b/cli/azd/extensions/azure.ai.rle/extension.yaml index e8ae9c3dee0..a2102819ea9 100644 --- a/cli/azd/extensions/azure.ai.rle/extension.yaml +++ b/cli/azd/extensions/azure.ai.rle/extension.yaml @@ -11,17 +11,20 @@ tags: - ai - rle usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle [options] -version: 0.2.0-preview +version: 0.2.1-preview examples: - name: init description: Copy the OpenEnv echo sample into a local RLE environment. usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle init - - name: deploy + - name: publish description: Build, push, and create or update the RLE environment. - usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"; $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io"; azd ai rle deploy + usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"; $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = ".azurecr.io"; azd ai rle publish --version-bump major - name: run description: Build and run the local RLE environment container. usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle run - name: invoke description: Lease a deployed RLE sandbox and open an OpenEnv runtime shell. usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle invoke + - name: environment list + description: List RLE environments in a Foundry project. + usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"; azd ai rle environment list diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go index e018cced8fb..593e83f3f2f 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -38,6 +39,7 @@ var createRleClient = newRleClient type v1EnvironmentRequest struct { Name string `json:"name,omitempty"` AcrImagePath string `json:"acrImagePath"` + VersionBump string `json:"versionBump,omitempty"` } type environmentResource struct { @@ -53,6 +55,10 @@ type environmentResource struct { DiskImageConversionError string `json:"diskImageConversionError,omitempty"` } +type listEnvironmentsResponse struct { + Value []environmentResource `json:"value"` +} + type sandboxCreateRequest struct { Version string `json:"version,omitempty"` } @@ -125,6 +131,23 @@ func (c *rleClient) createV1Environment( return &result, nil } +func (c *rleClient) listEnvironments( + ctx context.Context, + skip int, + top int, +) (*listEnvironmentsResponse, error) { + query := url.Values{} + query.Set("skip", strconv.Itoa(skip)) + query.Set("top", strconv.Itoa(top)) + + var result listEnvironmentsResponse + if err := c.do(ctx, http.MethodGet, environmentCollectionPath+"?"+query.Encode(), nil, &result); err != nil { + return nil, err + } + + return &result, nil +} + func (c *rleClient) getEnvironmentVersion( ctx context.Context, name string, diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go index 6c8bbd7917d..eafbee5c89c 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go @@ -112,12 +112,12 @@ func TestServiceErrorSuggestionShowsFoundryProjectEndpoint(t *testing.T) { } } -func TestResolveDeployStateUsesFoundryProjectEndpointEnvironment(t *testing.T) { +func TestResolvePublishStateUsesFoundryProjectEndpointEnvironment(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) t.Setenv(foundryProjectEndpointEnvVar, "https://ACCOUNT.services.ai.azure.com/api/projects/project-from-env/") - state, initialized, err := resolveDeployState(&rleDeployFlags{}) + state, initialized, err := resolvePublishState(&rlePublishFlags{}) if err != nil { t.Fatal(err) } @@ -129,7 +129,7 @@ func TestResolveDeployStateUsesFoundryProjectEndpointEnvironment(t *testing.T) { } } -func TestResolveDeployStateUsesSavedProjectEndpointFallback(t *testing.T) { +func TestResolvePublishStateUsesSavedProjectEndpointFallback(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) if err := saveRleState(rleState{ @@ -139,7 +139,7 @@ func TestResolveDeployStateUsesSavedProjectEndpointFallback(t *testing.T) { t.Fatal(err) } - state, initialized, err := resolveDeployState(&rleDeployFlags{}) + state, initialized, err := resolvePublishState(&rlePublishFlags{}) if err != nil { t.Fatal(err) } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go new file mode 100644 index 00000000000..e65fc0515e4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +const environmentListPageSize = 200 + +type environmentListAction struct { + cmd *cobra.Command + outputFormat *string +} + +func newEnvironmentCommand(outputFormat *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "environment", + Short: "Manage RLE environments", + Args: cobra.NoArgs, + } + cmd.AddCommand(newEnvironmentListCommand(outputFormat)) + return cmd +} + +func newEnvironmentListCommand(outputFormat *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List RLE environments in the Foundry project", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return (&environmentListAction{cmd: cmd, outputFormat: outputFormat}).Run() + }, + } + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"default", "json"}, + }) + return cmd +} + +func (a *environmentListAction) Run() error { + projectEndpoint, err := resolveEnvironmentListProjectEndpoint() + if err != nil { + return err + } + + client, err := createRleClient(projectEndpoint) + if err != nil { + return err + } + environments, err := listAllEnvironments(a.cmd.Context(), client) + if err != nil { + return serviceError(err) + } + + format, err := azdext.ParseOutputFormat(*a.outputFormat) + if err != nil { + return err + } + output := azdext.NewOutput(azdext.OutputOptions{ + Format: format, + Writer: a.cmd.OutOrStdout(), + ErrWriter: a.cmd.ErrOrStderr(), + }) + if output.IsJSON() { + return output.JSON(environments) + } + if len(environments) == 0 { + output.Message("No RLE environments found in this Foundry project.") + return nil + } + + rows := make([][]string, 0, len(environments)) + for _, environment := range environments { + rows = append(rows, []string{ + environment.Name, + environment.Version, + environment.DiskImageConversionStatus, + environment.Id, + environment.UpdatedAt, + }) + } + output.Message("") + output.Table( + []string{"NAME", "VERSION", "DISK IMAGE", "ENVIRONMENT ID", "UPDATED"}, + rows, + ) + output.Message("") + return nil +} + +func listAllEnvironments(ctx context.Context, client *rleClient) ([]environmentResource, error) { + var environments []environmentResource + for skip := 0; ; skip += environmentListPageSize { + page, err := client.listEnvironments(ctx, skip, environmentListPageSize) + if err != nil { + return nil, err + } + environments = append(environments, page.Value...) + if len(page.Value) < environmentListPageSize { + return environments, nil + } + } +} + +func resolveEnvironmentListProjectEndpoint() (string, error) { + endpoint, err := resolveFoundryProjectEndpoint() + if err != nil { + return "", err + } + if endpoint != "" { + return endpoint, nil + } + + state, stateErr := loadRleState() + if stateErr == nil && strings.TrimSpace(state.ProjectEndpoint) != "" { + return state.ProjectEndpoint, nil + } + if stateErr != nil { + var localErr *azdext.LocalError + if !errors.As(stateErr, &localErr) || localErr.Code != "rle_project_not_initialized" { + return "", stateErr + } + } + + return "", &azdext.LocalError{ + Message: "Foundry project endpoint is required to list RLE environments.", + Code: "rle_project_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: fmt.Sprintf( + "Set %s=https://.services.ai.azure.com/api/projects/, "+ + "or run this command from a deployed RLE environment folder.", + foundryProjectEndpointEnvVar, + ), + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go new file mode 100644 index 00000000000..9c5241cfbb8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func TestEnvironmentListListsProjectEnvironments(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv( + foundryProjectEndpointEnvVar, + "https://account.services.ai.azure.com/api/projects/project-from-env", + ) + + requestCount := 0 + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if r.Method != http.MethodGet || r.URL.Path != testFoundryProjectPath+environmentCollectionPath { + t.Fatalf("unexpected environments request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("api-version"); got != foundryAPIVersion { + t.Fatalf("expected API version %q, got %q", foundryAPIVersion, got) + } + if got := r.URL.Query().Get("skip"); got != "0" { + t.Fatalf("expected skip=0, got %q", got) + } + if got := r.URL.Query().Get("top"); got != strconv.Itoa(environmentListPageSize) { + t.Fatalf("expected top=%d, got %q", environmentListPageSize, got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "value": [ + { + "id": "env-1", + "name": "echo_env", + "version": "1.2.0", + "diskImageConversionStatus": "Ready", + "updatedAtUtc": "2026-07-30T05:00:00Z" + }, + { + "id": "env-2", + "name": "code_rl", + "version": "2.0.0", + "diskImageConversionStatus": "Pending", + "updatedAtUtc": "2026-07-30T06:00:00Z" + } + ], + "count": 2 + }`)) + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + outputFormat := "default" + command := newEnvironmentListCommand(&outputFormat) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + + if requestCount != 1 { + t.Fatalf("expected one list request, got %d", requestCount) + } + for _, expected := range []string{ + "NAME", + "VERSION", + "DISK IMAGE", + "ENVIRONMENT ID", + "echo_env", + "1.2.0", + "env-1", + "code_rl", + "Pending", + } { + if !strings.Contains(output.String(), expected) { + t.Fatalf("expected output to contain %q, got %s", expected, output.String()) + } + } + if !strings.HasPrefix(output.String(), "\n") || !strings.HasSuffix(output.String(), "\n\n") { + t.Fatalf("expected blank lines around table, got %q", output.String()) + } +} + +func TestEnvironmentListSupportsJSONOutput(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/saved-project", + }); err != nil { + t.Fatal(err) + } + + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "value": [{"id":"env-1","name":"echo_env","version":"1.0.0"}], + "count": 1 + }`)) + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + outputFormat := "json" + command := newEnvironmentListCommand(&outputFormat) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + + var result []environmentResource + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatalf("expected JSON output, got %s: %v", output.String(), err) + } + if len(result) != 1 || result[0].Id != "env-1" || result[0].Name != "echo_env" { + t.Fatalf("unexpected environments JSON: %#v", result) + } +} + +func TestEnvironmentListReportsEmptyProject(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv( + foundryProjectEndpointEnvVar, + "https://account.services.ai.azure.com/api/projects/project-from-env", + ) + + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"value":[],"count":0}`)) + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + outputFormat := "default" + command := newEnvironmentListCommand(&outputFormat) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "No RLE environments found") { + t.Fatalf("expected empty project message, got %s", output.String()) + } +} + +func TestEnvironmentListRequiresProjectEndpoint(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv(foundryProjectEndpointEnvVar, "") + + outputFormat := "default" + command := newEnvironmentListCommand(&outputFormat) + err := command.Execute() + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T: %v", err, err) + } + if localErr.Code != "rle_project_required" { + t.Fatalf("expected project required code, got %q", localErr.Code) + } + if !strings.Contains(localErr.Suggestion, foundryProjectEndpointEnvVar) { + t.Fatalf("expected endpoint suggestion, got %q", localErr.Suggestion) + } +} + +func TestListAllEnvironmentsPaginates(t *testing.T) { + requestCount := 0 + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + skip, err := strconv.Atoi(r.URL.Query().Get("skip")) + if err != nil { + t.Fatal(err) + } + requestCount++ + pageSize := environmentListPageSize + if skip > 0 { + pageSize = 1 + } + value := make([]environmentResource, pageSize) + for i := range value { + value[i] = environmentResource{ + Id: fmt.Sprintf("env-%d", skip+i), + Name: fmt.Sprintf("environment-%d", skip+i), + } + } + if err := json.NewEncoder(w).Encode(listEnvironmentsResponse{Value: value}); err != nil { + t.Fatal(err) + } + })) + defer controlPlane.Close() + + client := testRleClientForServer(t, controlPlane.URL) + environments, err := listAllEnvironments(t.Context(), client) + if err != nil { + t.Fatal(err) + } + if requestCount != 2 { + t.Fatalf("expected two pages, got %d", requestCount) + } + if len(environments) != environmentListPageSize+1 { + t.Fatalf("expected %d environments, got %d", environmentListPageSize+1, len(environments)) + } +} + +func stubRleClientEndpoint(t *testing.T, endpoint string) { + t.Helper() + oldCreateRleClient := createRleClient + createRleClient = func(string) (*rleClient, error) { + return testRleClientForServer(t, endpoint), nil + } + t.Cleanup(func() { + createRleClient = oldCreateRleClient + }) +} + +func testRleClientForServer(t *testing.T, endpoint string) *rleClient { + t.Helper() + target, err := url.Parse(endpoint) + if err != nil { + t.Fatal(err) + } + client := newRleClientWithCredential( + "https://rle.test"+testFoundryProjectPath, + &testTokenCredential{}, + ) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + request = request.Clone(request.Context()) + request.URL.Scheme = target.Scheme + request.URL.Host = target.Host + return http.DefaultTransport.RoundTrip(request) + }) + return client +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go index 6cd40f88062..19b95b32bff 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/init.go @@ -91,7 +91,7 @@ func (a *initAction) Run() error { " azd ai rle run\n"+ " $env:FOUNDRY_PROJECT_ENDPOINT = \"https://.services.ai.azure.com/api/projects/\"\n"+ " $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = \".azurecr.io\"\n"+ - " azd ai rle deploy\n", + " azd ai rle publish\n", displayDir, displayDir, ) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go index 3d3ac3f7ef3..0da0f53caaa 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -401,7 +401,7 @@ func requireDeployedEnvironment(state rleState) error { Message: "Foundry project endpoint is required for remote invoke.", Code: "rle_project_required", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Run azd ai rle deploy first with FOUNDRY_PROJECT_ENDPOINT set.", + Suggestion: "Run azd ai rle publish first with FOUNDRY_PROJECT_ENDPOINT set.", } } if strings.TrimSpace(state.EnvironmentId) == "" { @@ -409,7 +409,7 @@ func requireDeployedEnvironment(state rleState) error { Message: "RLE environment has not been deployed.", Code: "rle_environment_not_deployed", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Run azd ai rle deploy from this environment folder first.", + Suggestion: "Run azd ai rle publish from this environment folder first.", } } return nil diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go index c1e2b7511e7..f038d7a7023 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -617,7 +617,7 @@ func useTestProjectEndpoint(t *testing.T, endpoint string) { } } -func TestResolveDeployStateDefaultsToExistingFolderWithoutInit(t *testing.T) { +func TestResolvePublishStateDefaultsToExistingFolderWithoutInit(t *testing.T) { tempDir := filepath.Join(t.TempDir(), "My Env") if err := os.MkdirAll(tempDir, 0750); err != nil { t.Fatal(err) @@ -625,7 +625,7 @@ func TestResolveDeployStateDefaultsToExistingFolderWithoutInit(t *testing.T) { t.Chdir(tempDir) t.Setenv(foundryProjectEndpointEnvVar, "https://account.services.ai.azure.com/api/projects/project-1") - state, initialized, err := resolveDeployState(&rleDeployFlags{}) + state, initialized, err := resolvePublishState(&rlePublishFlags{}) if err != nil { t.Fatal(err) } @@ -640,12 +640,12 @@ func TestResolveDeployStateDefaultsToExistingFolderWithoutInit(t *testing.T) { } } -func TestResolveDeployStateDoesNotPersistDockerfileFlag(t *testing.T) { +func TestResolvePublishStateDoesNotPersistDockerfileFlag(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) t.Setenv(foundryProjectEndpointEnvVar, "https://account.services.ai.azure.com/api/projects/project-1") - state, initialized, err := resolveDeployState(&rleDeployFlags{ + state, initialized, err := resolvePublishState(&rlePublishFlags{ dockerfile: "server/Dockerfile", }) if err != nil { @@ -659,11 +659,11 @@ func TestResolveDeployStateDoesNotPersistDockerfileFlag(t *testing.T) { } } -func TestResolveDeployImageUsesTerminalAcrRegistryEnvironment(t *testing.T) { +func TestResolvePublishImageUsesTerminalAcrRegistryEnvironment(t *testing.T) { t.Setenv("AZURE_CONTAINER_REGISTRY_ENDPOINT", "example.azurecr.io") - image, err := resolveDeployImage( - &rleDeployFlags{}, + image, err := resolvePublishImage( + &rlePublishFlags{}, rleState{Name: "My Env", ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/Project 1"}, ) if err != nil { @@ -674,9 +674,9 @@ func TestResolveDeployImageUsesTerminalAcrRegistryEnvironment(t *testing.T) { } } -func TestResolveDeployImageRequiresAcrRegistry(t *testing.T) { - _, err := resolveDeployImage( - &rleDeployFlags{}, +func TestResolvePublishImageRequiresAcrRegistry(t *testing.T) { + _, err := resolvePublishImage( + &rlePublishFlags{}, rleState{Name: "my-env", ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1"}, ) localErr, ok := errors.AsType[*azdext.LocalError](err) @@ -688,11 +688,11 @@ func TestResolveDeployImageRequiresAcrRegistry(t *testing.T) { } } -func TestResolveDeployImageUsesRegistryEvenWhenStateExists(t *testing.T) { +func TestResolvePublishImageUsesRegistryEvenWhenStateExists(t *testing.T) { t.Setenv("AZURE_CONTAINER_REGISTRY_ENDPOINT", "example.azurecr.io") - image, err := resolveDeployImage( - &rleDeployFlags{}, + image, err := resolvePublishImage( + &rlePublishFlags{}, rleState{ Name: "my-env", ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/publish.go similarity index 70% rename from cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go rename to cli/azd/extensions/azure.ai.rle/internal/cmd/publish.go index 4b8b6f3a413..5e11eacd725 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/publish.go @@ -16,34 +16,47 @@ import ( "github.com/spf13/cobra" ) -type rleDeployFlags struct { - dockerfile string +type rlePublishFlags struct { + dockerfile string + versionBump string } -type deployAction struct { +type publishAction struct { cmd *cobra.Command - flags *rleDeployFlags + flags *rlePublishFlags } -func newDeployCommand() *cobra.Command { - flags := &rleDeployFlags{} +func newPublishCommand() *cobra.Command { + flags := &rlePublishFlags{} + flags.versionBump = "major" cmd := &cobra.Command{ - Use: "deploy", - Short: "Create or update the RLE environment", + Use: "publish", + Short: "Build, push, and create or update the RLE environment", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return (&deployAction{cmd: cmd, flags: flags}).Run() + return (&publishAction{cmd: cmd, flags: flags}).Run() }, } cmd.Flags().StringVar(&flags.dockerfile, "dockerfile", "", "Dockerfile path relative to the current folder. Defaults to Dockerfile at the source root or server/Dockerfile.") + cmd.Flags().StringVar( + &flags.versionBump, + "version-bump", + flags.versionBump, + "Version bump to apply when creating or updating the environment: major, minor, or patch.", + ) return cmd } -func (a *deployAction) Run() error { - state, initialized, err := resolveDeployState(a.flags) +func (a *publishAction) Run() error { + versionBump, err := normalizeVersionBumpFlag(a.flags.versionBump) + if err != nil { + return err + } + + state, initialized, err := resolvePublishState(a.flags) if err != nil { return err } @@ -56,23 +69,23 @@ func (a *deployAction) Run() error { if state.ProjectEndpoint == "" { return &azdext.LocalError{ - Message: "Foundry project endpoint is required for deploy.", + Message: "Foundry project endpoint is required for publish.", Code: "rle_project_required", Category: azdext.LocalErrorCategoryUser, Suggestion: fmt.Sprintf("Set %s=https://.services.ai.azure.com/api/projects/.", foundryProjectEndpointEnvVar), } } - image, err := resolveDeployImage(a.flags, state) + image, err := resolvePublishImage(a.flags, state) if err != nil { return err } if !project.IsAcrImageReference(image) { return &azdext.LocalError{ - Message: fmt.Sprintf("RLE deploy image must be an ACR image reference, got %q.", image), + Message: fmt.Sprintf("RLE publish image must be an ACR image reference, got %q.", image), Code: "rle_acr_image_required", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run deploy again.", + Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run publish again.", } } if err := project.BuildRuntimeImage(a.cmd.Context(), a.cmd.OutOrStdout(), a.cmd.ErrOrStderr(), image, project.BuildOptions{ @@ -88,10 +101,7 @@ func (a *deployAction) Run() error { if err != nil { return err } - request := v1EnvironmentRequest{ - Name: state.Name, - AcrImagePath: image, - } + request := buildEnvironmentCreateRequest(state.Name, image, versionBump) var environment *environmentResource created := state.EnvironmentId == "" @@ -154,7 +164,33 @@ func (a *deployAction) Run() error { return nil } -func resolveDeployState(flags *rleDeployFlags) (rleState, bool, error) { +func normalizeVersionBumpFlag(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "major": + return "Major", nil + case "minor": + return "Minor", nil + case "patch": + return "Patch", nil + default: + return "", &azdext.LocalError{ + Message: fmt.Sprintf("Invalid version bump %q.", value), + Code: "rle_invalid_version_bump", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Use --version-bump major, --version-bump minor, or --version-bump patch.", + } + } +} + +func buildEnvironmentCreateRequest(name string, image string, versionBump string) v1EnvironmentRequest { + return v1EnvironmentRequest{ + Name: name, + AcrImagePath: image, + VersionBump: versionBump, + } +} + +func resolvePublishState(flags *rlePublishFlags) (rleState, bool, error) { state, err := loadRleState() initialized := err == nil if err != nil { @@ -178,14 +214,14 @@ func resolveDeployState(flags *rleDeployFlags) (rleState, bool, error) { return state, initialized, nil } -func resolveDeployImage(flags *rleDeployFlags, state rleState) (string, error) { +func resolvePublishImage(flags *rlePublishFlags, state rleState) (string, error) { registry := strings.Trim(strings.TrimSpace(os.Getenv("AZURE_CONTAINER_REGISTRY_ENDPOINT")), "/") if registry == "" { return "", &azdext.LocalError{ - Message: "ACR registry is required for deploy.", + Message: "ACR registry is required for publish.", Code: "rle_acr_registry_required", Category: azdext.LocalErrorCategoryUser, - Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run deploy again.", + Suggestion: "Set AZURE_CONTAINER_REGISTRY_ENDPOINT=.azurecr.io, then run publish again.", } } projectName, err := projectRouteSegment(state) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/publish_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/publish_test.go new file mode 100644 index 00000000000..ddd419b35a9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/publish_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "errors" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func TestNormalizeVersionBumpFlag(t *testing.T) { + cases := []struct { + name string + value string + expected string + }{ + {name: "default major", value: "major", expected: "Major"}, + {name: "minor", value: "minor", expected: "Minor"}, + {name: "patch", value: "patch", expected: "Patch"}, + {name: "trimmed uppercase", value: " MAJOR ", expected: "Major"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizeVersionBumpFlag(tc.value) + if err != nil { + t.Fatal(err) + } + if got != tc.expected { + t.Fatalf("expected %q, got %q", tc.expected, got) + } + }) + } +} + +func TestNormalizeVersionBumpFlagRejectsInvalidValue(t *testing.T) { + _, err := normalizeVersionBumpFlag("gold") + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) + } + if localErr.Code != "rle_invalid_version_bump" { + t.Fatalf("expected invalid version bump code, got %q", localErr.Code) + } +} + +func TestPublishRejectsInvalidVersionBumpBeforeResolvingState(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + command := newPublishCommand() + command.SetArgs([]string{"--version-bump", "gold"}) + command.SetOut(&bytes.Buffer{}) + command.SetErr(&bytes.Buffer{}) + + err := command.Execute() + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T", err) + } + if localErr.Code != "rle_invalid_version_bump" { + t.Fatalf("expected invalid version bump code, got %q", localErr.Code) + } +} + +func TestBuildEnvironmentCreateRequestIncludesVersionBump(t *testing.T) { + request := buildEnvironmentCreateRequest("echo_env", "example.azurecr.io/echo_env:latest", "Patch") + if request.VersionBump != "Patch" { + t.Fatalf("expected version bump to be included, got %#v", request) + } +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go index 3e0008569a1..f857961c0bf 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -36,9 +36,10 @@ func NewRootCommand() *cobra.Command { }) userCommands := []*cobra.Command{ - newDeployCommand(), + newEnvironmentCommand(&extCtx.OutputFormat), newInitCommand(), newInvokeCommand(), + newPublishCommand(), newRunCommand(), } for _, command := range userCommands { diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go index 4ec5c78ac5d..7c16eaad4ae 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go @@ -15,17 +15,27 @@ import ( func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { rootCmd := NewRootCommand() - for _, commandName := range []string{"deploy", "init", "invoke", "run", "version", "metadata"} { + for _, commandName := range []string{"environment", "init", "invoke", "publish", "run", "version", "metadata"} { if command, _, err := rootCmd.Find([]string{commandName}); err != nil || command.Name() != commandName { t.Fatalf("expected command %q to be registered", commandName) } } + listCommand, _, err := rootCmd.Find([]string{"environment", "list"}) + if err != nil || listCommand.Name() != "list" { + t.Fatal("expected environment list command to be registered") + } + if command, _, err := rootCmd.Find([]string{"environments"}); err == nil && command.Name() == "environments" { + t.Fatal("expected no environments alias") + } + if command, _, err := rootCmd.Find([]string{"deploy"}); err == nil && command.Name() == "deploy" { + t.Fatal("expected no deploy alias") + } } func TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { t.Setenv(rleEnableEnvVar, "") rootCmd := NewRootCommand() - for _, commandName := range []string{"deploy", "init", "invoke", "run"} { + for _, commandName := range []string{"environment", "init", "invoke", "publish", "run"} { command, _, err := rootCmd.Find([]string{commandName}) if err != nil { t.Fatalf("expected command %q to be registered: %v", commandName, err) @@ -51,7 +61,7 @@ func TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { t.Setenv(rleEnableEnvVar, "true") rootCmd = NewRootCommand() - for _, commandName := range []string{"deploy", "init", "invoke", "run", "version"} { + for _, commandName := range []string{"environment", "init", "invoke", "publish", "run", "version"} { command, _, err := rootCmd.Find([]string{commandName}) if err != nil { t.Fatalf("expected command %q to be registered: %v", commandName, err) @@ -62,26 +72,31 @@ func TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { } } -func TestDeployExposesStandaloneFlags(t *testing.T) { +func TestPublishExposesStandaloneFlags(t *testing.T) { rootCmd := NewRootCommand() - command, _, err := rootCmd.Find([]string{"deploy"}) + command, _, err := rootCmd.Find([]string{"publish"}) if err != nil { - t.Fatalf("expected deploy command to be registered: %v", err) + t.Fatalf("expected publish command to be registered: %v", err) } if flag := command.Flags().Lookup("project-endpoint"); flag != nil { - t.Fatal("expected deploy not to expose --project-endpoint") + t.Fatal("expected publish not to expose --project-endpoint") } if flag := command.Flags().Lookup("project-id"); flag != nil { - t.Fatal("expected deploy not to expose --project-id") + t.Fatal("expected publish not to expose --project-id") } if flag := command.Flags().Lookup("image"); flag != nil { - t.Fatal("expected deploy not to expose --image") + t.Fatal("expected publish not to expose --image") } if flag := command.Flags().Lookup("dockerfile"); flag == nil { - t.Fatal("expected deploy to expose --dockerfile") + t.Fatal("expected publish to expose --dockerfile") + } + if flag := command.Flags().Lookup("version-bump"); flag == nil { + t.Fatal("expected publish to expose --version-bump") + } else if got := flag.DefValue; got != "major" { + t.Fatalf("expected --version-bump default to be major, got %q", got) } if flag := command.Flags().Lookup("name"); flag != nil { - t.Fatal("expected deploy not to expose --name") + t.Fatal("expected publish not to expose --name") } } @@ -166,11 +181,19 @@ func TestLifecycleFlagsAlignWithHostedAgentConventions(t *testing.T) { func TestLifecycleCommandsRejectPositionalArguments(t *testing.T) { rootCmd := NewRootCommand() - for _, commandName := range []string{"deploy", "invoke", "run"} { + for _, commandName := range []string{"publish", "invoke", "run"} { command, _, err := rootCmd.Find([]string{commandName}) if err != nil { t.Fatalf("expected command %q to be registered: %v", commandName, err) } + + environmentListCommand, _, err := rootCmd.Find([]string{"environment", "list"}) + if err != nil { + t.Fatalf("expected environment list command to be registered: %v", err) + } + if err := environmentListCommand.Args(environmentListCommand, []string{"unexpected"}); err == nil { + t.Fatal("expected environment list to reject positional arguments") + } if err := command.Args(command, []string{"unexpected"}); err == nil { t.Fatalf("expected command %q to reject positional arguments", commandName) } diff --git a/cli/azd/extensions/azure.ai.rle/version.txt b/cli/azd/extensions/azure.ai.rle/version.txt index fb0124368e3..e6ff235da1d 100644 --- a/cli/azd/extensions/azure.ai.rle/version.txt +++ b/cli/azd/extensions/azure.ai.rle/version.txt @@ -1 +1 @@ -0.2.0-preview \ No newline at end of file +0.2.1-preview \ No newline at end of file From c9c140df19bfe7f541a8df4ab15ace508aea859c Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Thu, 30 Jul 2026 13:29:16 +0530 Subject: [PATCH 06/12] fix: restore registry.dev.json from main branch --- cli/azd/extensions/registry.dev.json | 80 ---------------------------- 1 file changed, 80 deletions(-) diff --git a/cli/azd/extensions/registry.dev.json b/cli/azd/extensions/registry.dev.json index ab50b2d5c63..943c24bcd74 100644 --- a/cli/azd/extensions/registry.dev.json +++ b/cli/azd/extensions/registry.dev.json @@ -246,86 +246,6 @@ "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.1.0-preview/azure-ai-rle-windows-arm64.zip" } } - }, - { - "version": "0.2.0-preview", - "capabilities": [ - "custom-commands", - "metadata" - ], - "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; azd ai rle [options]", - "examples": [ - { - "name": "init", - "description": "Copy the OpenEnv echo sample into a local RLE environment.", - "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; azd ai rle init" - }, - { - "name": "deploy", - "description": "Build, push, and create or update the RLE environment.", - "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; $env:FOUNDRY_PROJECT_ENDPOINT = \"https://.services.ai.azure.com/api/projects/\"; $env:AZURE_CONTAINER_REGISTRY_ENDPOINT = \".azurecr.io\"; azd ai rle deploy" - }, - { - "name": "run", - "description": "Build and run the local RLE environment container.", - "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; azd ai rle run" - }, - { - "name": "invoke", - "description": "Lease a deployed RLE sandbox and open an OpenEnv runtime shell.", - "usage": "$env:AZD_AI_RLE_ENABLE = \"true\"; azd ai rle invoke" - } - ], - "artifacts": { - "darwin/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "2b8e9bda909a4be80c4cd932784c95e9f8ce3102ff118b4ddd46ffb852163eed" - }, - "entryPoint": "azure-ai-rle-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-darwin-amd64.zip" - }, - "darwin/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "3fb58b60ba8aa3f122d619865bb8f9203b1077ea200494ece5ca7f39d7445b8d" - }, - "entryPoint": "azure-ai-rle-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-darwin-arm64.zip" - }, - "linux/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "daa43b4a3dc3c09956b5929db0f6faba76b7d0acb0772d882e4fa5842759e23d" - }, - "entryPoint": "azure-ai-rle-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-linux-amd64.tar.gz" - }, - "linux/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "c26e2460269ef83da6b7af1d0a8ba17e194708e3bc6be29deeb1e09e36a3852d" - }, - "entryPoint": "azure-ai-rle-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-linux-arm64.tar.gz" - }, - "windows/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "c0d4ee38e3b8a85a922d793e417b608ecbb47d151b6741b0b8839cd7124fc5dd" - }, - "entryPoint": "azure-ai-rle-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-windows-amd64.zip" - }, - "windows/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "ed9fe83b92c20106c2049776335dfab00a676c0306b53ec432fe1f00961fdbf9" - }, - "entryPoint": "azure-ai-rle-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-rle_0.2.0-preview/azure-ai-rle-windows-arm64.zip" - } - } } ], "tags": [ From 11c2b3c43e45cc834d715d4505bda5762f6d0522 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Mon, 3 Aug 2026 01:28:50 +0530 Subject: [PATCH 07/12] fix: address automated review comments --- cli/azd/extensions/azure.ai.rle/CHANGELOG.md | 2 +- .../azure.ai.rle/internal/cmd/client_test.go | 4 ++-- .../azure.ai.rle/internal/cmd/environment.go | 9 +++++-- .../internal/cmd/environment_test.go | 22 +++++++++++++++++ .../azure.ai.rle/internal/cmd/invoke_test.go | 24 +------------------ .../azure.ai.rle/internal/cmd/publish.go | 15 +++++------- 6 files changed, 39 insertions(+), 37 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md index 928af284965..66de87078ac 100644 --- a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md @@ -6,7 +6,7 @@ - Rename `azd ai rle deploy` to `azd ai rle publish` to avoid confusion with the core `azd deploy` command. - Add `--version-bump` to `azd ai rle publish` so users can choose major, minor, or patch environment versioning. -## 0.2.0-preview +## 0.2.0-preview (2026-07-29) - Use the Foundry project endpoint for project-relative RLE environment and sandbox APIs. - Authenticate Foundry API requests with Azure credentials from `az login`, `azd auth login`, or another supported development credential. diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go index eafbee5c89c..96e1d59981c 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client_test.go @@ -117,7 +117,7 @@ func TestResolvePublishStateUsesFoundryProjectEndpointEnvironment(t *testing.T) t.Chdir(tempDir) t.Setenv(foundryProjectEndpointEnvVar, "https://ACCOUNT.services.ai.azure.com/api/projects/project-from-env/") - state, initialized, err := resolvePublishState(&rlePublishFlags{}) + state, initialized, err := resolvePublishState() if err != nil { t.Fatal(err) } @@ -139,7 +139,7 @@ func TestResolvePublishStateUsesSavedProjectEndpointFallback(t *testing.T) { t.Fatal(err) } - state, initialized, err := resolvePublishState(&rlePublishFlags{}) + state, initialized, err := resolvePublishState() if err != nil { t.Fatal(err) } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go index e65fc0515e4..1781db45144 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go @@ -13,7 +13,10 @@ import ( "github.com/spf13/cobra" ) -const environmentListPageSize = 200 +const ( + environmentListPageSize = 200 + environmentListMaxPages = 100 +) type environmentListAction struct { cmd *cobra.Command @@ -99,7 +102,8 @@ func (a *environmentListAction) Run() error { func listAllEnvironments(ctx context.Context, client *rleClient) ([]environmentResource, error) { var environments []environmentResource - for skip := 0; ; skip += environmentListPageSize { + for pageNumber := 0; pageNumber < environmentListMaxPages; pageNumber++ { + skip := pageNumber * environmentListPageSize page, err := client.listEnvironments(ctx, skip, environmentListPageSize) if err != nil { return nil, err @@ -109,6 +113,7 @@ func listAllEnvironments(ctx context.Context, client *rleClient) ([]environmentR return environments, nil } } + return nil, fmt.Errorf("environment list exceeded the %d-item safety limit", environmentListPageSize*environmentListMaxPages) } func resolveEnvironmentListProjectEndpoint() (string, error) { diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go index 9c5241cfbb8..b13064b8397 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go @@ -189,6 +189,7 @@ func TestListAllEnvironmentsPaginates(t *testing.T) { if err != nil { t.Fatal(err) } + requestCount++ pageSize := environmentListPageSize if skip > 0 { @@ -220,6 +221,27 @@ func TestListAllEnvironmentsPaginates(t *testing.T) { } } +func TestListAllEnvironmentsStopsAtSafetyLimit(t *testing.T) { + requestCount := 0 + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + value := make([]environmentResource, environmentListPageSize) + if err := json.NewEncoder(w).Encode(listEnvironmentsResponse{Value: value}); err != nil { + t.Fatal(err) + } + })) + defer controlPlane.Close() + + client := testRleClientForServer(t, controlPlane.URL) + _, err := listAllEnvironments(t.Context(), client) + if err == nil || !strings.Contains(err.Error(), "safety limit") { + t.Fatalf("expected safety-limit error, got %v", err) + } + if requestCount != environmentListMaxPages { + t.Fatalf("expected %d pages, got %d", environmentListMaxPages, requestCount) + } +} + func stubRleClientEndpoint(t *testing.T, endpoint string) { t.Helper() oldCreateRleClient := createRleClient diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go index f038d7a7023..ccc83ef78db 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -625,7 +625,7 @@ func TestResolvePublishStateDefaultsToExistingFolderWithoutInit(t *testing.T) { t.Chdir(tempDir) t.Setenv(foundryProjectEndpointEnvVar, "https://account.services.ai.azure.com/api/projects/project-1") - state, initialized, err := resolvePublishState(&rlePublishFlags{}) + state, initialized, err := resolvePublishState() if err != nil { t.Fatal(err) } @@ -640,30 +640,10 @@ func TestResolvePublishStateDefaultsToExistingFolderWithoutInit(t *testing.T) { } } -func TestResolvePublishStateDoesNotPersistDockerfileFlag(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - t.Setenv(foundryProjectEndpointEnvVar, "https://account.services.ai.azure.com/api/projects/project-1") - - state, initialized, err := resolvePublishState(&rlePublishFlags{ - dockerfile: "server/Dockerfile", - }) - if err != nil { - t.Fatal(err) - } - if initialized { - t.Fatal("expected no saved state") - } - if state.Name != filepath.Base(tempDir) { - t.Fatalf("expected source folder name, got %q", state.Name) - } -} - func TestResolvePublishImageUsesTerminalAcrRegistryEnvironment(t *testing.T) { t.Setenv("AZURE_CONTAINER_REGISTRY_ENDPOINT", "example.azurecr.io") image, err := resolvePublishImage( - &rlePublishFlags{}, rleState{Name: "My Env", ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/Project 1"}, ) if err != nil { @@ -676,7 +656,6 @@ func TestResolvePublishImageUsesTerminalAcrRegistryEnvironment(t *testing.T) { func TestResolvePublishImageRequiresAcrRegistry(t *testing.T) { _, err := resolvePublishImage( - &rlePublishFlags{}, rleState{Name: "my-env", ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1"}, ) localErr, ok := errors.AsType[*azdext.LocalError](err) @@ -692,7 +671,6 @@ func TestResolvePublishImageUsesRegistryEvenWhenStateExists(t *testing.T) { t.Setenv("AZURE_CONTAINER_REGISTRY_ENDPOINT", "example.azurecr.io") image, err := resolvePublishImage( - &rlePublishFlags{}, rleState{ Name: "my-env", ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/project-1", diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/publish.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/publish.go index 5e11eacd725..60e13f228a7 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/publish.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/publish.go @@ -56,7 +56,7 @@ func (a *publishAction) Run() error { return err } - state, initialized, err := resolvePublishState(a.flags) + state, initialized, err := resolvePublishState() if err != nil { return err } @@ -76,7 +76,7 @@ func (a *publishAction) Run() error { } } - image, err := resolvePublishImage(a.flags, state) + image, err := resolvePublishImage(state) if err != nil { return err } @@ -119,14 +119,11 @@ func (a *publishAction) Run() error { ); err != nil { return err } - if state.EnvironmentId == "" { - environment, err = client.createV1Environment(a.cmd.Context(), request) - } else { - environment, err = client.createV1Environment(a.cmd.Context(), request) - } + environment, err = client.createV1Environment(a.cmd.Context(), request) if err != nil { return serviceError(err) } + state.Name = environment.Name state.EnvironmentId = environment.Id state.EnvironmentVersion = environment.Version if err := saveRleState(state); err != nil { @@ -190,7 +187,7 @@ func buildEnvironmentCreateRequest(name string, image string, versionBump string } } -func resolvePublishState(flags *rlePublishFlags) (rleState, bool, error) { +func resolvePublishState() (rleState, bool, error) { state, err := loadRleState() initialized := err == nil if err != nil { @@ -214,7 +211,7 @@ func resolvePublishState(flags *rlePublishFlags) (rleState, bool, error) { return state, initialized, nil } -func resolvePublishImage(flags *rlePublishFlags, state rleState) (string, error) { +func resolvePublishImage(state rleState) (string, error) { registry := strings.Trim(strings.TrimSpace(os.Getenv("AZURE_CONTAINER_REGISTRY_ENDPOINT")), "/") if registry == "" { return "", &azdext.LocalError{ From f44cb3de229f07f441fd301736ef024defc6cb71 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Mon, 3 Aug 2026 01:35:59 +0530 Subject: [PATCH 08/12] fix: lint error --- cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go index 1781db45144..d6bd8a99547 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go @@ -102,7 +102,7 @@ func (a *environmentListAction) Run() error { func listAllEnvironments(ctx context.Context, client *rleClient) ([]environmentResource, error) { var environments []environmentResource - for pageNumber := 0; pageNumber < environmentListMaxPages; pageNumber++ { + for pageNumber := range environmentListMaxPages { skip := pageNumber * environmentListPageSize page, err := client.listEnvironments(ctx, skip, environmentListPageSize) if err != nil { From 9925401f0af2afd65b8654ed718580699976ba11 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Mon, 3 Aug 2026 17:44:49 +0530 Subject: [PATCH 09/12] fix: command updates --- cli/azd/extensions/azure.ai.rle/CHANGELOG.md | 9 +- cli/azd/extensions/azure.ai.rle/README.md | 37 +++- .../extensions/azure.ai.rle/extension.yaml | 11 +- .../azure.ai.rle/internal/cmd/client.go | 22 ++ .../internal/cmd/environment_lookup.go | 53 +++++ .../azure.ai.rle/internal/cmd/invoke.go | 128 +++++++++-- .../azure.ai.rle/internal/cmd/invoke_test.go | 200 ++++++++++++++++++ .../internal/cmd/{environment.go => list.go} | 18 +- .../cmd/{environment_test.go => list_test.go} | 88 +++++++- .../internal/cmd/project_endpoint.go | 4 +- .../azure.ai.rle/internal/cmd/root.go | 3 +- .../azure.ai.rle/internal/cmd/root_test.go | 52 +++-- .../azure.ai.rle/internal/cmd/show.go | 176 +++++++++++++++ cli/azd/extensions/azure.ai.rle/version.txt | 2 +- 14 files changed, 737 insertions(+), 66 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/environment_lookup.go rename cli/azd/extensions/azure.ai.rle/internal/cmd/{environment.go => list.go} (87%) rename cli/azd/extensions/azure.ai.rle/internal/cmd/{environment_test.go => list_test.go} (73%) create mode 100644 cli/azd/extensions/azure.ai.rle/internal/cmd/show.go diff --git a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md index 66de87078ac..2ba8ccdbf6d 100644 --- a/cli/azd/extensions/azure.ai.rle/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.rle/CHANGELOG.md @@ -1,13 +1,12 @@ # Release History -## 0.2.1-preview (Unreleased) +## 0.3.0-preview (Unreleased) -- Add `azd ai rle environment list` to list environments in the configured Foundry project. +- Add `azd ai rle list` to list environments in the configured Foundry project. +- Add `azd ai rle show ` to inspect an environment's full details and version history. +- Allow `azd ai rle invoke ` to invoke an existing project environment without local source or state, with optional `--version` selection. - Rename `azd ai rle deploy` to `azd ai rle publish` to avoid confusion with the core `azd deploy` command. - Add `--version-bump` to `azd ai rle publish` so users can choose major, minor, or patch environment versioning. - -## 0.2.0-preview (2026-07-29) - - Use the Foundry project endpoint for project-relative RLE environment and sandbox APIs. - Authenticate Foundry API requests with Azure credentials from `az login`, `azd auth login`, or another supported development credential. - Send the required `2025-11-15-preview` Foundry data-plane API version. diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index ebc263f03f2..a0a281448ae 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -41,7 +41,7 @@ azd ai rle --help azd ai rle version ``` -`version` is always available. The lifecycle commands are preview-gated; if commands such as `init`, `run`, `publish`, `environment`, or `invoke` are hidden, enable the preview flag in your terminal: +`version` is always available. The lifecycle commands are preview-gated; if commands such as `init`, `run`, `publish`, `list`, `show`, or `invoke` are hidden, enable the preview flag in your terminal: ```powershell $env:AZD_AI_RLE_ENABLE = "true" @@ -173,16 +173,30 @@ azd ai rle publish --dockerfile server\Dockerfile List all RLE environments in the configured Foundry project: ```powershell -azd ai rle environment list +azd ai rle list ``` The command uses `FOUNDRY_PROJECT_ENDPOINT` when it is set. Otherwise, it uses the project endpoint saved in the current folder's `.azd-rle.json`. Use JSON output for scripting: ```powershell -azd ai rle environment list --output json +azd ai rle list --output json ``` -### 5. Invoke remotely +### 5. Show environment details + +Show the full details for a specific environment, including version history: + +```powershell +azd ai rle show code_rl +``` + +If you run the command from a published environment folder, omit the name to inspect the local environment record: + +```powershell +azd ai rle show +``` + +### 6. Invoke remotely Remote invoke uses the deployed environment, leases a sandbox from `/fine_tuning/environments//sandboxes/lease`, opens the sandbox `/web` UI when available (or a local proxy UI otherwise), keeps the shell attached, and releases the sandbox when the shell exits: @@ -190,6 +204,21 @@ Remote invoke uses the deployed environment, leases a sandbox from ` [options] -version: 0.2.1-preview +version: 0.3.0-preview examples: - name: init description: Copy the OpenEnv echo sample into a local RLE environment. @@ -24,7 +24,10 @@ examples: usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle run - name: invoke description: Lease a deployed RLE sandbox and open an OpenEnv runtime shell. - usage: $env:AZD_AI_RLE_ENABLE = "true"; azd ai rle invoke - - name: environment list + usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"; azd ai rle invoke [--version ] + - name: list description: List RLE environments in a Foundry project. - usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"; azd ai rle environment list + usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"; azd ai rle list + - name: show + description: Show the details and version history for an RLE environment. + usage: $env:AZD_AI_RLE_ENABLE = "true"; $env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/"; azd ai rle show diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go index 593e83f3f2f..2d91f436f06 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/client.go @@ -59,6 +59,14 @@ type listEnvironmentsResponse struct { Value []environmentResource `json:"value"` } +type environmentVersionResource struct { + EnvironmentId string `json:"environmentId"` + ProjectId string `json:"projectId,omitempty"` + Version string `json:"version,omitempty"` + AcrImagePath string `json:"acrImagePath,omitempty"` + CreatedAt string `json:"createdAtUtc,omitempty"` +} + type sandboxCreateRequest struct { Version string `json:"version,omitempty"` } @@ -168,6 +176,20 @@ func (c *rleClient) getEnvironmentVersion( return &result, nil } +func (c *rleClient) listEnvironmentVersions( + ctx context.Context, + name string, +) ([]environmentVersionResource, error) { + path := fmt.Sprintf("%s/%s/versions", environmentCollectionPath, url.PathEscape(name)) + + var result []environmentVersionResource + if err := c.do(ctx, http.MethodGet, path, nil, &result); err != nil { + return nil, err + } + + return result, nil +} + func (c *rleClient) createSandbox( ctx context.Context, environmentId string, diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_lookup.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_lookup.go new file mode 100644 index 00000000000..fd13a60da3b --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_lookup.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func resolveLatestEnvironmentByName( + ctx context.Context, + client *rleClient, + environmentName string, +) (*environmentResource, error) { + environments, err := listAllEnvironments(ctx, client) + if err != nil { + return nil, err + } + + for _, environment := range environments { + if environment.Name == environmentName { + return &environment, nil + } + } + + return nil, &azdext.LocalError{ + Message: fmt.Sprintf("RLE environment %q was not found in this Foundry project.", environmentName), + Code: "rle_environment_not_found", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Run azd ai rle list to see the available environments.", + } +} + +func requireReadyEnvironment(environment *environmentResource, environmentName string) error { + if environment.DiskImageConversionStatus != diskImageConversionStatusReady { + return &azdext.LocalError{ + Message: fmt.Sprintf( + "Environment %q disk image status is %q, expected %q.", + environmentName, + environment.DiskImageConversionStatus, + diskImageConversionStatusReady, + ), + Code: "rle_disk_image_not_ready", + Category: azdext.LocalErrorCategoryUser, + Suggestion: fmt.Sprintf( + "Run azd ai rle show %s to inspect the environment details and version history.", + environmentName, + ), + } + } + + return nil +} diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go index 0da0f53caaa..045ee7c034b 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -24,11 +24,13 @@ import ( type remoteInvokeFlags struct { timeout int + version string } type remoteInvokeAction struct { - cmd *cobra.Command - flags *remoteInvokeFlags + cmd *cobra.Command + flags *remoteInvokeFlags + environmentName string } func newInvokeCommand() *cobra.Command { @@ -37,11 +39,25 @@ func newInvokeCommand() *cobra.Command { } cmd := &cobra.Command{ - Use: "invoke", + Use: "invoke [environment-name]", Short: "Open a remote OpenEnv runtime shell", - Args: cobra.NoArgs, + Long: `Open a remote OpenEnv runtime shell. + +With no environment name, invoke uses the environment saved in .azd-rle.json. +To invoke an existing environment without local source or state, provide its name +and set FOUNDRY_PROJECT_ENDPOINT. Use --version to select a specific published +version; otherwise, the latest version returned by the project is used.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return (&remoteInvokeAction{cmd: cmd, flags: flags}).Run() + environmentName := "" + if len(args) == 1 { + environmentName = args[0] + } + return (&remoteInvokeAction{ + cmd: cmd, + flags: flags, + environmentName: environmentName, + }).Run() }, } @@ -51,34 +67,34 @@ func newInvokeCommand() *cobra.Command { flags.timeout, "Per-command OpenEnv request timeout in seconds (0 for no timeout).", ) + cmd.Flags().StringVar( + &flags.version, + "version", + "", + "Published environment version to invoke.", + ) return cmd } func (a *remoteInvokeAction) Run() error { - state, err := loadRleState() + state, client, err := a.resolveTarget() if err != nil { return err } - if err := requireDeployedEnvironment(state); err != nil { - return err - } ctx, stopSignals := signal.NotifyContext(a.cmd.Context(), os.Interrupt) defer stopSignals() - client, err := createRleClient(state.ProjectEndpoint) - if err != nil { - return err - } if _, err := fmt.Fprintf( a.cmd.OutOrStdout(), - "Creating sandbox for environment %s ...\n", - state.EnvironmentId, + "Creating sandbox for environment %s version %s ...\n", + state.Name, + state.EnvironmentVersion, ); err != nil { return err } - sandbox, err := leaseRemoteSandbox(ctx, a.cmd.OutOrStdout(), client, state) + sandbox, err := leaseRemoteSandbox(ctx, a.cmd.OutOrStdout(), client, state, a.environmentName == "") if err != nil { if _, ok := errors.AsType[*azdext.LocalError](err); ok { return err @@ -113,6 +129,79 @@ func (a *remoteInvokeAction) Run() error { return project.RunShellWithContext(ctx, a.cmd.InOrStdin(), a.cmd.OutOrStdout(), sandboxUrl, a.flags.timeout) } +func (a *remoteInvokeAction) resolveTarget() (rleState, *rleClient, error) { + requestedVersion := strings.TrimSpace(a.flags.version) + if a.cmd.Flags().Changed("version") && requestedVersion == "" { + return rleState{}, nil, &azdext.LocalError{ + Message: "--version requires a non-empty environment version.", + Code: "rle_environment_version_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Provide a semantic version, for example --version 2.1.0.", + } + } + if strings.TrimSpace(a.environmentName) == "" && requestedVersion != "" { + return rleState{}, nil, &azdext.LocalError{ + Message: "--version requires an environment name.", + Code: "rle_environment_name_required", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Run azd ai rle invoke --version .", + } + } + + if strings.TrimSpace(a.environmentName) == "" { + state, err := loadRleState() + if err != nil { + return rleState{}, nil, err + } + if err := requireDeployedEnvironment(state); err != nil { + return rleState{}, nil, err + } + client, err := createRleClient(state.ProjectEndpoint) + return state, client, err + } + + environmentName := strings.TrimSpace(a.environmentName) + projectEndpoint, err := resolveEnvironmentListProjectEndpoint() + if err != nil { + return rleState{}, nil, err + } + client, err := createRleClient(projectEndpoint) + if err != nil { + return rleState{}, nil, err + } + environment, err := resolveLatestEnvironmentByName(a.cmd.Context(), client, environmentName) + if err != nil { + return rleState{}, nil, err + } + + if requestedVersion == "" { + if err := requireReadyEnvironment(environment, environmentName); err != nil { + return rleState{}, nil, err + } + return rleState{ + Name: environment.Name, + ProjectEndpoint: projectEndpoint, + EnvironmentId: environment.Id, + EnvironmentVersion: environment.Version, + }, client, nil + } + + versionedEnvironment, err := client.getEnvironmentVersion(a.cmd.Context(), environmentName, requestedVersion) + if err != nil { + return rleState{}, nil, err + } + if err := requireReadyEnvironment(versionedEnvironment, environmentName); err != nil { + return rleState{}, nil, err + } + return rleState{ + Name: versionedEnvironment.Name, + ProjectEndpoint: projectEndpoint, + EnvironmentId: versionedEnvironment.Id, + EnvironmentVersion: versionedEnvironment.Version, + }, client, nil + +} + const ( sandboxStatusRunning = "Running" sandboxStatusFailed = "Failed" @@ -132,9 +221,12 @@ func leaseRemoteSandbox( output io.Writer, client *rleClient, state rleState, + waitForImage bool, ) (*sandboxResource, error) { - if err := waitForEnvironmentImage(ctx, output, client, state); err != nil { - return nil, err + if waitForImage { + if err := waitForEnvironmentImage(ctx, output, client, state); err != nil { + return nil, err + } } sandbox, err := client.createSandbox(ctx, state.EnvironmentId, sandboxCreateRequest{ diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go index ccc83ef78db..73a3b981f4f 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -101,6 +101,206 @@ func TestInvokeRemoteCreatesSandboxAndRunsShell(t *testing.T) { } } +func TestInvokeRemoteByNameUsesLatestListedVersionWithoutLocalState(t *testing.T) { + captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv( + foundryProjectEndpointEnvVar, + "https://account.services.ai.azure.com/api/projects/project-1", + ) + + envServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + _, _ = w.Write([]byte(`{"status":"healthy"}`)) + case "/web": + _, _ = w.Write([]byte("environment")) + default: + http.NotFound(w, r) + } + })) + defer envServer.Close() + + var sandboxBody sandboxCreateRequest + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && + r.URL.Path == testFoundryProjectPath+environmentCollectionPath: + _, _ = w.Write([]byte(`{ + "value": [{"id":"env-2","name":"code_rl","version":"2.0.0","diskImageConversionStatus":"Ready"}] + }`)) + case r.Method == http.MethodGet && + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/code_rl/versions/2.0.0": + _, _ = w.Write([]byte(`{"id":"env-2","name":"code_rl","version":"2.0.0","diskImageConversionStatus":"Ready"}`)) + case r.Method == http.MethodPost && + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-2/sandboxes/lease": + if err := json.NewDecoder(r.Body).Decode(&sandboxBody); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) + case r.Method == http.MethodDelete && + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-2/sandboxes/sandbox-1/release": + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + command := newInvokeCommand() + command.SetArgs([]string{"code_rl"}) + command.SetIn(strings.NewReader("exit\n")) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if sandboxBody.Version != "2.0.0" { + t.Fatalf("expected latest listed version, got %q", sandboxBody.Version) + } + if !strings.Contains(output.String(), "Creating sandbox for environment code_rl version 2.0.0") { + t.Fatalf("expected resolved environment output, got %s", output.String()) + } + if _, err := os.Stat(stateFilePath(".")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected cloud-only invoke not to create local state, got %v", err) + } +} + +func TestInvokeRemoteByNameFailsWhenLatestDiskImageIsNotReady(t *testing.T) { + captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv( + foundryProjectEndpointEnvVar, + "https://account.services.ai.azure.com/api/projects/project-1", + ) + + leaseCalled := false + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && + r.URL.Path == testFoundryProjectPath+environmentCollectionPath: + _, _ = w.Write([]byte(`{ + "value": [{"id":"env-2","name":"code_rl","version":"2.0.0","diskImageConversionStatus":"Pending"}] + }`)) + case r.Method == http.MethodPost && + strings.Contains(r.URL.Path, "/sandboxes/lease"): + leaseCalled = true + t.Fatalf("expected invoke to stop before leasing a sandbox") + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + command := newInvokeCommand() + command.SetArgs([]string{"code_rl"}) + command.SetIn(strings.NewReader("exit\n")) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + err := command.Execute() + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected LocalError, got %T: %v", err, err) + } + if localErr.Code != "rle_disk_image_not_ready" { + t.Fatalf("expected disk image not ready code, got %q", localErr.Code) + } + if localErr.Message != `Environment "code_rl" disk image status is "Pending", expected "Ready".` { + t.Fatalf("unexpected disk image error message: %q", localErr.Message) + } + if localErr.Suggestion != "Run azd ai rle show code_rl to inspect the environment details and version history." { + t.Fatalf("unexpected disk image error suggestion: %q", localErr.Suggestion) + } + if leaseCalled { + t.Fatal("expected invoke not to lease sandbox when disk image is not ready") + } +} + +func TestInvokeRemoteByNameUsesExplicitVersion(t *testing.T) { + captureBrowserOpen(t) + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv( + foundryProjectEndpointEnvVar, + "https://account.services.ai.azure.com/api/projects/project-1", + ) + + envServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + _, _ = w.Write([]byte(`{"status":"healthy"}`)) + case "/web": + _, _ = w.Write([]byte("environment")) + default: + http.NotFound(w, r) + } + })) + defer envServer.Close() + + var sandboxBody sandboxCreateRequest + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && + r.URL.Path == testFoundryProjectPath+environmentCollectionPath: + _, _ = w.Write([]byte(`{ + "value": [{"id":"env-2","name":"code_rl","version":"2.0.0","diskImageConversionStatus":"Ready"}] + }`)) + case r.Method == http.MethodGet && + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/code_rl/versions/1.0.0": + _, _ = w.Write([]byte(`{"id":"env-1","name":"code_rl","version":"1.0.0","diskImageConversionStatus":"Ready"}`)) + case r.Method == http.MethodPost && + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/lease": + if err := json.NewDecoder(r.Body).Decode(&sandboxBody); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"id":"sandbox-1","status":"Running","baseUrl":` + strconv.Quote(envServer.URL) + `}`)) + case r.Method == http.MethodDelete && + r.URL.Path == testFoundryProjectPath+"/fine_tuning/environments/env-1/sandboxes/sandbox-1/release": + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + command := newInvokeCommand() + command.SetArgs([]string{"code_rl", "--version", "1.0.0"}) + command.SetIn(strings.NewReader("exit\n")) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if sandboxBody.Version != "1.0.0" { + t.Fatalf("expected explicit version, got %q", sandboxBody.Version) + } +} + +func TestInvokeRemoteRejectsVersionWithoutEnvironmentName(t *testing.T) { + command := newInvokeCommand() + command.SetArgs([]string{"--version", "1.0.0"}) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + err := command.Execute() + var localErr *azdext.LocalError + if !errors.As(err, &localErr) { + t.Fatalf("expected local error, got %v", err) + } + if localErr.Code != "rle_environment_name_required" { + t.Fatalf("expected environment-name-required error, got %q", localErr.Code) + } +} + func TestInvokeRemoteUsesSandboxWebWhenAvailable(t *testing.T) { openedUrl := captureBrowserOpen(t) tempDir := t.TempDir() diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go similarity index 87% rename from cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go rename to cli/azd/extensions/azure.ai.rle/internal/cmd/list.go index d6bd8a99547..4f683952e3f 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go @@ -18,28 +18,18 @@ const ( environmentListMaxPages = 100 ) -type environmentListAction struct { +type listAction struct { cmd *cobra.Command outputFormat *string } -func newEnvironmentCommand(outputFormat *string) *cobra.Command { - cmd := &cobra.Command{ - Use: "environment", - Short: "Manage RLE environments", - Args: cobra.NoArgs, - } - cmd.AddCommand(newEnvironmentListCommand(outputFormat)) - return cmd -} - -func newEnvironmentListCommand(outputFormat *string) *cobra.Command { +func newListCommand(outputFormat *string) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List RLE environments in the Foundry project", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return (&environmentListAction{cmd: cmd, outputFormat: outputFormat}).Run() + return (&listAction{cmd: cmd, outputFormat: outputFormat}).Run() }, } azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ @@ -49,7 +39,7 @@ func newEnvironmentListCommand(outputFormat *string) *cobra.Command { return cmd } -func (a *environmentListAction) Run() error { +func (a *listAction) Run() error { projectEndpoint, err := resolveEnvironmentListProjectEndpoint() if err != nil { return err diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go similarity index 73% rename from cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go rename to cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go index b13064b8397..10c4b0881e8 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go @@ -66,7 +66,7 @@ func TestEnvironmentListListsProjectEnvironments(t *testing.T) { stubRleClientEndpoint(t, controlPlane.URL) outputFormat := "default" - command := newEnvironmentListCommand(&outputFormat) + command := newListCommand(&outputFormat) var output bytes.Buffer command.SetOut(&output) command.SetErr(&output) @@ -117,7 +117,7 @@ func TestEnvironmentListSupportsJSONOutput(t *testing.T) { stubRleClientEndpoint(t, controlPlane.URL) outputFormat := "json" - command := newEnvironmentListCommand(&outputFormat) + command := newListCommand(&outputFormat) var output bytes.Buffer command.SetOut(&output) command.SetErr(&output) @@ -150,7 +150,7 @@ func TestEnvironmentListReportsEmptyProject(t *testing.T) { stubRleClientEndpoint(t, controlPlane.URL) outputFormat := "default" - command := newEnvironmentListCommand(&outputFormat) + command := newListCommand(&outputFormat) var output bytes.Buffer command.SetOut(&output) command.SetErr(&output) @@ -168,7 +168,7 @@ func TestEnvironmentListRequiresProjectEndpoint(t *testing.T) { t.Setenv(foundryProjectEndpointEnvVar, "") outputFormat := "default" - command := newEnvironmentListCommand(&outputFormat) + command := newListCommand(&outputFormat) err := command.Execute() localErr, ok := errors.AsType[*azdext.LocalError](err) if !ok { @@ -242,6 +242,86 @@ func TestListAllEnvironmentsStopsAtSafetyLimit(t *testing.T) { } } +func TestShowDisplaysEnvironmentHistory(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "echo_env", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/saved-project", + EnvironmentId: "env-1", + EnvironmentVersion: "1.2.0", + }); err != nil { + t.Fatal(err) + } + + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == testFoundryProjectPath+environmentCollectionPath+"/echo_env/versions/1.2.0": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"env-1", + "name":"echo_env", + "version":"1.2.0", + "diskImageConversionStatus":"Ready", + "updatedAtUtc":"2026-07-30T05:00:00Z" + }`)) + case r.Method == http.MethodGet && r.URL.Path == testFoundryProjectPath+environmentCollectionPath+"/echo_env/versions": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"environmentId":"env-1","version":"1.0.0","createdAtUtc":"2026-07-28T05:00:00Z","acrImagePath":"registry/echo:1.0.0"}, + {"environmentId":"env-1","version":"1.2.0","createdAtUtc":"2026-07-30T05:00:00Z","acrImagePath":"registry/echo:1.2.0"} + ]`)) + case r.Method == http.MethodGet && r.URL.Path == testFoundryProjectPath+environmentCollectionPath+"/echo_env/versions/1.0.0": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"env-1", + "name":"echo_env", + "version":"1.0.0", + "diskImageConversionStatus":"Failed", + "updatedAtUtc":"2026-07-28T06:00:00Z" + }`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + outputFormat := "default" + command := newShowCommand(&outputFormat) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + + for _, expected := range []string{ + "NAME", + "VERSION", + "DISK IMAGE", + "ENVIRONMENT ID", + "UPDATED", + "ACR IMAGE", + "echo_env", + "1.2.0", + "Ready", + "registry/echo:1.2.0", + "1.0.0", + "Failed", + "registry/echo:1.0.0", + } { + if !strings.Contains(output.String(), expected) { + t.Fatalf("expected output to contain %q, got %s", expected, output.String()) + } + } + for _, unexpected := range []string{"CREATED", "FIELD", "VALUE", "Version history:"} { + if strings.Contains(output.String(), unexpected) { + t.Fatalf("expected one consolidated table without %q, got %s", unexpected, output.String()) + } + } +} + func stubRleClientEndpoint(t *testing.T, endpoint string) { t.Helper() oldCreateRleClient := createRleClient diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/project_endpoint.go index 4ec63e8b291..0ad7bf115c3 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/project_endpoint.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/project_endpoint.go @@ -12,9 +12,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -const ( - foundryProjectEndpointEnvVar = "FOUNDRY_PROJECT_ENDPOINT" -) +const foundryProjectEndpointEnvVar = "FOUNDRY_PROJECT_ENDPOINT" func resolveFoundryProjectEndpoint() (string, error) { if endpoint := strings.TrimSpace(os.Getenv(foundryProjectEndpointEnvVar)); endpoint != "" { diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go index f857961c0bf..a650ca91f66 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root.go @@ -36,7 +36,8 @@ func NewRootCommand() *cobra.Command { }) userCommands := []*cobra.Command{ - newEnvironmentCommand(&extCtx.OutputFormat), + newListCommand(&extCtx.OutputFormat), + newShowCommand(&extCtx.OutputFormat), newInitCommand(), newInvokeCommand(), newPublishCommand(), diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go index 7c16eaad4ae..af64722f5a4 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/root_test.go @@ -15,15 +15,11 @@ import ( func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { rootCmd := NewRootCommand() - for _, commandName := range []string{"environment", "init", "invoke", "publish", "run", "version", "metadata"} { + for _, commandName := range []string{"list", "show", "init", "invoke", "publish", "run", "version", "metadata"} { if command, _, err := rootCmd.Find([]string{commandName}); err != nil || command.Name() != commandName { t.Fatalf("expected command %q to be registered", commandName) } } - listCommand, _, err := rootCmd.Find([]string{"environment", "list"}) - if err != nil || listCommand.Name() != "list" { - t.Fatal("expected environment list command to be registered") - } if command, _, err := rootCmd.Find([]string{"environments"}); err == nil && command.Name() == "environments" { t.Fatal("expected no environments alias") } @@ -35,7 +31,7 @@ func TestNewRootCommandIncludesExpectedCommands(t *testing.T) { func TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { t.Setenv(rleEnableEnvVar, "") rootCmd := NewRootCommand() - for _, commandName := range []string{"environment", "init", "invoke", "publish", "run"} { + for _, commandName := range []string{"list", "show", "init", "invoke", "publish", "run"} { command, _, err := rootCmd.Find([]string{commandName}) if err != nil { t.Fatalf("expected command %q to be registered: %v", commandName, err) @@ -61,7 +57,7 @@ func TestRleUserCommandsHiddenUnlessEnabled(t *testing.T) { t.Setenv(rleEnableEnvVar, "true") rootCmd = NewRootCommand() - for _, commandName := range []string{"environment", "init", "invoke", "publish", "run", "version"} { + for _, commandName := range []string{"list", "show", "init", "invoke", "publish", "run", "version"} { command, _, err := rootCmd.Find([]string{commandName}) if err != nil { t.Fatalf("expected command %q to be registered: %v", commandName, err) @@ -155,6 +151,9 @@ func TestLifecycleFlagsAlignWithHostedAgentConventions(t *testing.T) { if flag := invokeCommand.Flags().Lookup("timeout"); flag == nil { t.Fatal("expected invoke to expose --timeout") } + if flag := invokeCommand.Flags().Lookup("version"); flag == nil { + t.Fatal("expected invoke to expose --version") + } if flag := invokeCommand.Flags().Lookup("local"); flag != nil { t.Fatal("expected invoke not to expose --local") } @@ -181,24 +180,53 @@ func TestLifecycleFlagsAlignWithHostedAgentConventions(t *testing.T) { func TestLifecycleCommandsRejectPositionalArguments(t *testing.T) { rootCmd := NewRootCommand() - for _, commandName := range []string{"publish", "invoke", "run"} { + for _, commandName := range []string{"publish", "run"} { command, _, err := rootCmd.Find([]string{commandName}) if err != nil { t.Fatalf("expected command %q to be registered: %v", commandName, err) } - environmentListCommand, _, err := rootCmd.Find([]string{"environment", "list"}) + listCommand, _, err := rootCmd.Find([]string{"list"}) + if err != nil { + t.Fatalf("expected list command to be registered: %v", err) + } + if err := listCommand.Args(listCommand, []string{"unexpected"}); err == nil { + t.Fatal("expected list to reject positional arguments") + } + showCommand, _, err := rootCmd.Find([]string{"show"}) if err != nil { - t.Fatalf("expected environment list command to be registered: %v", err) + t.Fatalf("expected show command to be registered: %v", err) } - if err := environmentListCommand.Args(environmentListCommand, []string{"unexpected"}); err == nil { - t.Fatal("expected environment list to reject positional arguments") + if err := showCommand.Args(showCommand, []string{"unexpected", "extra"}); err == nil { + t.Fatal("expected show to reject multiple positional arguments") } if err := command.Args(command, []string{"unexpected"}); err == nil { t.Fatalf("expected command %q to reject positional arguments", commandName) } } + invokeCommand, _, err := rootCmd.Find([]string{"invoke"}) + if err != nil { + t.Fatalf("expected invoke command to be registered: %v", err) + } + if err := invokeCommand.Args(invokeCommand, []string{"code_rl"}); err != nil { + t.Fatalf("expected invoke to accept one environment name: %v", err) + } + if err := invokeCommand.Args(invokeCommand, []string{"one", "two"}); err == nil { + t.Fatal("expected invoke to reject multiple environment names") + } + + showCommand, _, err := rootCmd.Find([]string{"show"}) + if err != nil { + t.Fatalf("expected show command to be registered: %v", err) + } + if err := showCommand.Args(showCommand, []string{"code_rl"}); err != nil { + t.Fatalf("expected show to accept one environment name: %v", err) + } + if err := showCommand.Args(showCommand, []string{"one", "two"}); err == nil { + t.Fatal("expected show to reject multiple environment names") + } + initCommand, _, err := rootCmd.Find([]string{"init"}) if err != nil { t.Fatalf("expected init command to be registered: %v", err) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go new file mode 100644 index 00000000000..7b7ded2dbba --- /dev/null +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "context" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type showAction struct { + cmd *cobra.Command + outputFormat *string + environmentName string +} + +type showResult struct { + Environment environmentResource `json:"environment"` + Versions []environmentResource `json:"versions"` +} + +func newShowCommand(outputFormat *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "show [environment-name]", + Short: "Show RLE environment details", + Long: `Show RLE environment details. + +With no environment name, show uses the environment saved in .azd-rle.json. +When an environment name is provided, the command resolves it from the Foundry +project and includes its full version history.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + environmentName := "" + if len(args) == 1 { + environmentName = args[0] + } + return (&showAction{ + cmd: cmd, + outputFormat: outputFormat, + environmentName: environmentName, + }).Run() + }, + } + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"default", "json"}, + }) + return cmd +} + +func (a *showAction) Run() error { + format, err := azdext.ParseOutputFormat(*a.outputFormat) + if err != nil { + return err + } + output := azdext.NewOutput(azdext.OutputOptions{ + Format: format, + Writer: a.cmd.OutOrStdout(), + ErrWriter: a.cmd.ErrOrStderr(), + }) + + result, err := a.resolveTarget() + if err != nil { + return err + } + + if output.IsJSON() { + return output.JSON(result) + } + + if len(result.Versions) == 0 { + output.Message("No version history found.") + return nil + } + + rows := make([][]string, 0, len(result.Versions)) + for _, version := range result.Versions { + rows = append(rows, []string{ + version.Name, + version.Version, + version.DiskImageConversionStatus, + version.Id, + version.UpdatedAt, + version.AcrImagePath, + }) + } + output.Message("") + output.Table( + []string{"NAME", "VERSION", "DISK IMAGE", "ENVIRONMENT ID", "UPDATED", "ACR IMAGE"}, + rows, + ) + output.Message("") + return nil +} + +func (a *showAction) resolveTarget() (showResult, error) { + if strings.TrimSpace(a.environmentName) == "" { + state, err := loadRleState() + if err != nil { + return showResult{}, err + } + if err := requireDeployedEnvironment(state); err != nil { + return showResult{}, err + } + + client, err := createRleClient(state.ProjectEndpoint) + if err != nil { + return showResult{}, err + } + + environment, err := client.getEnvironmentVersion(a.cmd.Context(), state.Name, state.EnvironmentVersion) + if err != nil { + return showResult{}, serviceError(err) + } + versions, err := resolveEnvironmentVersions(a.cmd.Context(), client, environment) + if err != nil { + return showResult{}, serviceError(err) + } + return showResult{Environment: *environment, Versions: versions}, nil + } + + projectEndpoint, err := resolveEnvironmentListProjectEndpoint() + if err != nil { + return showResult{}, err + } + client, err := createRleClient(projectEndpoint) + if err != nil { + return showResult{}, err + } + environment, err := resolveLatestEnvironmentByName(a.cmd.Context(), client, strings.TrimSpace(a.environmentName)) + if err != nil { + return showResult{}, err + } + versions, err := resolveEnvironmentVersions(a.cmd.Context(), client, environment) + if err != nil { + return showResult{}, serviceError(err) + } + return showResult{Environment: *environment, Versions: versions}, nil +} + +func resolveEnvironmentVersions( + ctx context.Context, + client *rleClient, + current *environmentResource, +) ([]environmentResource, error) { + history, err := client.listEnvironmentVersions(ctx, current.Name) + if err != nil { + return nil, err + } + if len(history) == 0 { + return []environmentResource{*current}, nil + } + + versions := make([]environmentResource, 0, len(history)) + for _, summary := range history { + var version environmentResource + if summary.EnvironmentId == current.Id && summary.Version == current.Version { + version = *current + } else { + resolved, err := client.getEnvironmentVersion(ctx, current.Name, summary.Version) + if err != nil { + return nil, err + } + version = *resolved + } + + version.Id = firstNonEmpty(version.Id, summary.EnvironmentId) + version.Name = firstNonEmpty(version.Name, current.Name) + version.Version = firstNonEmpty(version.Version, summary.Version) + version.AcrImagePath = firstNonEmpty(version.AcrImagePath, summary.AcrImagePath) + version.CreatedAt = firstNonEmpty(version.CreatedAt, summary.CreatedAt) + versions = append(versions, version) + } + + return versions, nil +} diff --git a/cli/azd/extensions/azure.ai.rle/version.txt b/cli/azd/extensions/azure.ai.rle/version.txt index e6ff235da1d..44ca810d872 100644 --- a/cli/azd/extensions/azure.ai.rle/version.txt +++ b/cli/azd/extensions/azure.ai.rle/version.txt @@ -1 +1 @@ -0.2.1-preview \ No newline at end of file +0.3.0-preview \ No newline at end of file From 53c7a20c2ea368bddac13ef3a19a9d99e90394c2 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Mon, 3 Aug 2026 20:05:54 +0530 Subject: [PATCH 10/12] fix: address review comments --- cli/azd/extensions/azure.ai.rle/README.md | 4 +- .../azure.ai.rle/internal/cmd/invoke.go | 2 +- .../azure.ai.rle/internal/cmd/invoke_test.go | 67 +++++++++++ .../azure.ai.rle/internal/cmd/list.go | 10 +- .../azure.ai.rle/internal/cmd/list_test.go | 108 ++++++++++++++++-- .../azure.ai.rle/internal/cmd/show.go | 41 +++---- 6 files changed, 190 insertions(+), 42 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/README.md b/cli/azd/extensions/azure.ai.rle/README.md index a0a281448ae..e6c93db47e5 100644 --- a/cli/azd/extensions/azure.ai.rle/README.md +++ b/cli/azd/extensions/azure.ai.rle/README.md @@ -190,7 +190,9 @@ Show the full details for a specific environment, including version history: azd ai rle show code_rl ``` -If you run the command from a published environment folder, omit the name to inspect the local environment record: +When run from a published environment folder, the environment name and Foundry +project endpoint can come from `.azd-rle.json`. Environment details and version +history are still retrieved from the Foundry APIs: ```powershell azd ai rle show diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go index 045ee7c034b..5838a7f29e7 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke.go @@ -188,7 +188,7 @@ func (a *remoteInvokeAction) resolveTarget() (rleState, *rleClient, error) { versionedEnvironment, err := client.getEnvironmentVersion(a.cmd.Context(), environmentName, requestedVersion) if err != nil { - return rleState{}, nil, err + return rleState{}, nil, serviceError(err) } if err := requireReadyEnvironment(versionedEnvironment, environmentName); err != nil { return rleState{}, nil, err diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go index 73a3b981f4f..4c82bcf55f9 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/invoke_test.go @@ -285,6 +285,73 @@ func TestInvokeRemoteByNameUsesExplicitVersion(t *testing.T) { } } +func TestInvokeRemoteByNameClassifiesListFailuresAsServiceErrors(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv( + foundryProjectEndpointEnvVar, + "https://account.services.ai.azure.com/api/projects/project-1", + ) + + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + command := newInvokeCommand() + command.SetArgs([]string{"code_rl"}) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + err := command.Execute() + serviceErr, ok := errors.AsType[*azdext.ServiceError](err) + if !ok { + t.Fatalf("expected ServiceError, got %T: %v", err, err) + } + if serviceErr.ServiceName != "rle-control-plane" { + t.Fatalf("expected rle-control-plane service, got %q", serviceErr.ServiceName) + } +} + +func TestInvokeRemoteByNameClassifiesVersionFailuresAsServiceErrors(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv( + foundryProjectEndpointEnvVar, + "https://account.services.ai.azure.com/api/projects/project-1", + ) + + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == testFoundryProjectPath+environmentCollectionPath: + _, _ = w.Write([]byte(`{ + "value": [{"id":"env-2","name":"code_rl","version":"2.0.0","diskImageConversionStatus":"Ready"}] + }`)) + case r.Method == http.MethodGet && + r.URL.Path == testFoundryProjectPath+environmentCollectionPath+"/code_rl/versions/1.0.0": + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + stubRleClientEndpoint(t, controlPlane.URL) + + command := newInvokeCommand() + command.SetArgs([]string{"code_rl", "--version", "1.0.0"}) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + err := command.Execute() + serviceErr, ok := errors.AsType[*azdext.ServiceError](err) + if !ok { + t.Fatalf("expected ServiceError, got %T: %v", err, err) + } + if serviceErr.ServiceName != "rle-control-plane" { + t.Fatalf("expected rle-control-plane service, got %q", serviceErr.ServiceName) + } +} + func TestInvokeRemoteRejectsVersionWithoutEnvironmentName(t *testing.T) { command := newInvokeCommand() command.SetArgs([]string{"--version", "1.0.0"}) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go index 4f683952e3f..c1814939757 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/list.go @@ -51,7 +51,7 @@ func (a *listAction) Run() error { } environments, err := listAllEnvironments(a.cmd.Context(), client) if err != nil { - return serviceError(err) + return err } format, err := azdext.ParseOutputFormat(*a.outputFormat) @@ -96,14 +96,18 @@ func listAllEnvironments(ctx context.Context, client *rleClient) ([]environmentR skip := pageNumber * environmentListPageSize page, err := client.listEnvironments(ctx, skip, environmentListPageSize) if err != nil { - return nil, err + return nil, serviceError(err) } environments = append(environments, page.Value...) if len(page.Value) < environmentListPageSize { return environments, nil } } - return nil, fmt.Errorf("environment list exceeded the %d-item safety limit", environmentListPageSize*environmentListMaxPages) + return nil, &azdext.LocalError{ + Message: fmt.Sprintf("Environment list exceeded the %d-item safety limit.", environmentListPageSize*environmentListMaxPages), + Code: "rle_environment_list_safety_limit", + Category: azdext.LocalErrorCategoryInternal, + } } func resolveEnvironmentListProjectEndpoint() (string, error) { diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go index 10c4b0881e8..056b9c6d4c8 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go @@ -234,28 +234,59 @@ func TestListAllEnvironmentsStopsAtSafetyLimit(t *testing.T) { client := testRleClientForServer(t, controlPlane.URL) _, err := listAllEnvironments(t.Context(), client) - if err == nil || !strings.Contains(err.Error(), "safety limit") { - t.Fatalf("expected safety-limit error, got %v", err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected safety-limit LocalError, got %T: %v", err, err) + } + if localErr.Code != "rle_environment_list_safety_limit" { + t.Fatalf("expected safety-limit code, got %q", localErr.Code) + } + if localErr.Category != azdext.LocalErrorCategoryInternal { + t.Fatalf("expected internal error category, got %q", localErr.Category) } if requestCount != environmentListMaxPages { t.Fatalf("expected %d pages, got %d", environmentListMaxPages, requestCount) } } +func TestListAllEnvironmentsClassifiesRequestFailuresAsServiceErrors(t *testing.T) { + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + })) + defer controlPlane.Close() + + client := testRleClientForServer(t, controlPlane.URL) + _, err := listAllEnvironments(t.Context(), client) + var serviceErr *azdext.ServiceError + if !errors.As(err, &serviceErr) { + t.Fatalf("expected ServiceError, got %T: %v", err, err) + } + if serviceErr.ServiceName != "rle-control-plane" { + t.Fatalf("expected rle-control-plane service, got %q", serviceErr.ServiceName) + } +} + func TestShowDisplaysEnvironmentHistory(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) - if err := saveRleState(rleState{ - Name: "echo_env", - ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/saved-project", - EnvironmentId: "env-1", - EnvironmentVersion: "1.2.0", - }); err != nil { - t.Fatal(err) - } + t.Setenv( + foundryProjectEndpointEnvVar, + "https://account.services.ai.azure.com/api/projects/saved-project", + ) controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == http.MethodGet && r.URL.Path == testFoundryProjectPath+environmentCollectionPath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "value": [{ + "id":"env-1", + "name":"echo_env", + "version":"1.2.0", + "diskImageConversionStatus":"Ready", + "updatedAtUtc":"2026-07-30T05:00:00Z" + }] + }`)) case r.Method == http.MethodGet && r.URL.Path == testFoundryProjectPath+environmentCollectionPath+"/echo_env/versions/1.2.0": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ @@ -289,6 +320,7 @@ func TestShowDisplaysEnvironmentHistory(t *testing.T) { outputFormat := "default" command := newShowCommand(&outputFormat) + command.SetArgs([]string{"echo_env"}) var output bytes.Buffer command.SetOut(&output) command.SetErr(&output) @@ -322,6 +354,62 @@ func TestShowDisplaysEnvironmentHistory(t *testing.T) { } } +func TestShowUsesEnvironmentNameAndProjectEndpointFromState(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := saveRleState(rleState{ + Name: "echo_env", + ProjectEndpoint: "https://account.services.ai.azure.com/api/projects/saved-project", + }); err != nil { + t.Fatal(err) + } + + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == testFoundryProjectPath+environmentCollectionPath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "value": [{ + "id":"env-1", + "name":"echo_env", + "version":"1.2.0", + "diskImageConversionStatus":"Ready" + }] + }`)) + case r.Method == http.MethodGet && r.URL.Path == testFoundryProjectPath+environmentCollectionPath+"/echo_env/versions": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer controlPlane.Close() + oldCreateRleClient := createRleClient + var resolvedProjectEndpoint string + createRleClient = func(projectEndpoint string) (*rleClient, error) { + resolvedProjectEndpoint = projectEndpoint + return testRleClientForServer(t, controlPlane.URL), nil + } + t.Cleanup(func() { + createRleClient = oldCreateRleClient + }) + + outputFormat := "default" + command := newShowCommand(&outputFormat) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "echo_env") || !strings.Contains(output.String(), "1.2.0") { + t.Fatalf("expected API environment resolved from saved name, got %s", output.String()) + } + if resolvedProjectEndpoint != "https://account.services.ai.azure.com/api/projects/saved-project" { + t.Fatalf("expected saved project endpoint, got %q", resolvedProjectEndpoint) + } +} + func stubRleClientEndpoint(t *testing.T, endpoint string) { t.Helper() oldCreateRleClient := createRleClient diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go index 7b7ded2dbba..902a119e812 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go @@ -25,9 +25,9 @@ func newShowCommand(outputFormat *string) *cobra.Command { Short: "Show RLE environment details", Long: `Show RLE environment details. -With no environment name, show uses the environment saved in .azd-rle.json. -When an environment name is provided, the command resolves it from the Foundry -project and includes its full version history.`, +The command resolves the environment from the Foundry project and includes its +full version history. With no environment name, it uses the name saved in +.azd-rle.json.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { environmentName := "" @@ -68,11 +68,6 @@ func (a *showAction) Run() error { return output.JSON(result) } - if len(result.Versions) == 0 { - output.Message("No version history found.") - return nil - } - rows := make([][]string, 0, len(result.Versions)) for _, version := range result.Versions { rows = append(rows, []string{ @@ -94,29 +89,21 @@ func (a *showAction) Run() error { } func (a *showAction) resolveTarget() (showResult, error) { - if strings.TrimSpace(a.environmentName) == "" { + environmentName := strings.TrimSpace(a.environmentName) + if environmentName == "" { state, err := loadRleState() if err != nil { return showResult{}, err } - if err := requireDeployedEnvironment(state); err != nil { - return showResult{}, err - } - - client, err := createRleClient(state.ProjectEndpoint) - if err != nil { - return showResult{}, err - } - - environment, err := client.getEnvironmentVersion(a.cmd.Context(), state.Name, state.EnvironmentVersion) - if err != nil { - return showResult{}, serviceError(err) - } - versions, err := resolveEnvironmentVersions(a.cmd.Context(), client, environment) - if err != nil { - return showResult{}, serviceError(err) + environmentName = strings.TrimSpace(state.Name) + if environmentName == "" { + return showResult{}, &azdext.LocalError{ + Message: "The saved RLE environment does not include a name.", + Code: "rle_environment_name_missing", + Category: azdext.LocalErrorCategoryUser, + Suggestion: "Provide an environment name: azd ai rle show .", + } } - return showResult{Environment: *environment, Versions: versions}, nil } projectEndpoint, err := resolveEnvironmentListProjectEndpoint() @@ -127,7 +114,7 @@ func (a *showAction) resolveTarget() (showResult, error) { if err != nil { return showResult{}, err } - environment, err := resolveLatestEnvironmentByName(a.cmd.Context(), client, strings.TrimSpace(a.environmentName)) + environment, err := resolveLatestEnvironmentByName(a.cmd.Context(), client, environmentName) if err != nil { return showResult{}, err } From b5ace78c34970d22ef7ce83a662fe64a9b95c019 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Mon, 3 Aug 2026 20:22:23 +0530 Subject: [PATCH 11/12] fix. simplify show command output --- .../azure.ai.rle/internal/cmd/list_test.go | 22 +++++++++++++------ .../azure.ai.rle/internal/cmd/show.go | 4 +--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go index 056b9c6d4c8..7879ff3b7d8 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/list_test.go @@ -329,25 +329,30 @@ func TestShowDisplaysEnvironmentHistory(t *testing.T) { } for _, expected := range []string{ - "NAME", "VERSION", "DISK IMAGE", "ENVIRONMENT ID", "UPDATED", - "ACR IMAGE", - "echo_env", "1.2.0", "Ready", - "registry/echo:1.2.0", "1.0.0", "Failed", - "registry/echo:1.0.0", } { if !strings.Contains(output.String(), expected) { t.Fatalf("expected output to contain %q, got %s", expected, output.String()) } } - for _, unexpected := range []string{"CREATED", "FIELD", "VALUE", "Version history:"} { + for _, unexpected := range []string{ + "NAME", + "ACR IMAGE", + "echo_env", + "registry/echo:1.2.0", + "registry/echo:1.0.0", + "CREATED", + "FIELD", + "VALUE", + "Version history:", + } { if strings.Contains(output.String(), unexpected) { t.Fatalf("expected one consolidated table without %q, got %s", unexpected, output.String()) } @@ -402,9 +407,12 @@ func TestShowUsesEnvironmentNameAndProjectEndpointFromState(t *testing.T) { if err := command.Execute(); err != nil { t.Fatal(err) } - if !strings.Contains(output.String(), "echo_env") || !strings.Contains(output.String(), "1.2.0") { + if !strings.Contains(output.String(), "1.2.0") { t.Fatalf("expected API environment resolved from saved name, got %s", output.String()) } + if strings.Contains(output.String(), "echo_env") { + t.Fatalf("expected environment name to be omitted from the version table, got %s", output.String()) + } if resolvedProjectEndpoint != "https://account.services.ai.azure.com/api/projects/saved-project" { t.Fatalf("expected saved project endpoint, got %q", resolvedProjectEndpoint) } diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go index 902a119e812..4f2184ba43e 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go @@ -71,17 +71,15 @@ func (a *showAction) Run() error { rows := make([][]string, 0, len(result.Versions)) for _, version := range result.Versions { rows = append(rows, []string{ - version.Name, version.Version, version.DiskImageConversionStatus, version.Id, version.UpdatedAt, - version.AcrImagePath, }) } output.Message("") output.Table( - []string{"NAME", "VERSION", "DISK IMAGE", "ENVIRONMENT ID", "UPDATED", "ACR IMAGE"}, + []string{"VERSION", "DISK IMAGE", "ENVIRONMENT ID", "UPDATED"}, rows, ) output.Message("") From 3f1dea7c119d9600c3e1b514fb0b367c66ee2ba8 Mon Sep 17 00:00:00 2001 From: Farhan Nawaz Date: Tue, 4 Aug 2026 01:25:45 +0530 Subject: [PATCH 12/12] fix: copyright headers --- .../extensions/azure.ai.rle/internal/cmd/environment_lookup.go | 3 +++ cli/azd/extensions/azure.ai.rle/internal/cmd/show.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_lookup.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_lookup.go index fd13a60da3b..1503fe56ce9 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_lookup.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/environment_lookup.go @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + package cmd import ( diff --git a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go index 4f2184ba43e..0e44cdfc878 100644 --- a/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.rle/internal/cmd/show.go @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + package cmd import (