Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ azd rest get https://graph.microsoft.com/v1.0/me
# Azure Resource Graph (KQL) query
azd rest graph "Resources | summarize count() by type"

# Azure Resource Graph query from a file
azd rest graph --query-file resources.kql

# Show the signed-in Azure identity (tenant, app, scopes, expiry)
azd rest whoami

Expand Down
37 changes: 37 additions & 0 deletions cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ azd rest version
| `head` | Execute a HEAD request |
| `options` | Execute an OPTIONS request |
| `scope` | Preview the detected OAuth scope and auth mode for a URL |
| `graph` | Run an Azure Resource Graph query |
| `version` | Display the extension version |

---
Expand Down Expand Up @@ -359,6 +360,42 @@ Service: Azure Resource Manager

---

## `azd rest graph [kql-query]`

Run an Azure Resource Graph query using Kusto Query Language. Authentication, the endpoint, and the default API version are handled for you.

**Usage:**
```bash
azd rest graph [kql-query] [flags]
```

**Examples:**
```bash
# Inline query
azd rest graph "Resources | summarize count() by type"

# Query from a file
azd rest graph --query-file resources.kql

# Scope to one subscription
azd rest graph "Resources | project name, type" --subscription <sub-id> --top 5
```

**Flags:**

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--query-file` | string | "" | Read the KQL query from a file instead of a positional argument |
| `--subscription` | string[] | [] | Subscription ID to scope the query (repeatable) |
| `--management-group` | string[] | [] | Management group ID to scope the query (repeatable) |
| `--top` | int | 0 | Maximum number of rows to return |
| `--skip` | int | 0 | Number of rows to skip |
| `--skip-token` | string | "" | Continuation token from a previous response |

Pass either a positional query or `--query-file`, not both. The file is read as plain text and sent as the Resource Graph `query` field.

---

## Scope Detection

`azd rest` automatically detects the appropriate OAuth scope for Azure services based on the URL hostname. This eliminates the need to manually specify scopes for most Azure API calls.
Expand Down
41 changes: 37 additions & 4 deletions cli/src/internal/cmd/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -68,27 +69,36 @@ func NewGraphCommand() *cobra.Command {
top int
skip int
skipToken string
queryFile string
)

cmd := &cobra.Command{
Use: "graph <kql-query>",
Use: "graph [kql-query]",
Short: "Run an Azure Resource Graph query",
Long: `Run an Azure Resource Graph query using Kusto Query Language (KQL).

The query runs against every subscription you can access unless you narrow it
with --subscription or --management-group. Authentication and the api-version
are handled for you.`,
are handled for you. Pass the query as an argument or read it from a file with
--query-file.`,
Example: ` # Count resources by type
azd rest graph "Resources | summarize count() by type"

# Read a query from a file
azd rest graph --query-file resources.kql

# Scope to specific subscriptions and return the first 5 rows
azd rest graph "Resources | project name, type" --subscription <sub-id> --top 5

# Continue a paged result set
azd rest graph "Resources | project name" --skip-token <token>`,
Args: cobra.ExactArgs(1),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runGraph(cmd, args[0], subscriptions, managementGroups, top, skip, skipToken)
query, err := resolveGraphQuery(args, queryFile)
if err != nil {
return err
}
return runGraph(cmd, query, subscriptions, managementGroups, top, skip, skipToken)
},
}

Expand All @@ -97,10 +107,33 @@ are handled for you.`,
cmd.Flags().IntVar(&top, "top", 0, "Maximum number of rows to return (maps to options.$top)")
cmd.Flags().IntVar(&skip, "skip", 0, "Number of rows to skip (maps to options.$skip)")
cmd.Flags().StringVar(&skipToken, "skip-token", "", "Continuation token from a previous response (maps to options.$skipToken)")
cmd.Flags().StringVar(&queryFile, "query-file", "", "Read the KQL query from a file instead of the positional argument")

return cmd
}

func resolveGraphQuery(args []string, queryFile string) (string, error) {
if queryFile != "" {
if len(args) > 0 {
return "", fmt.Errorf("--query-file cannot be combined with a positional query")
}
data, err := os.ReadFile(queryFile) // #nosec G304 -- User-specified query file path is intentional.
if err != nil {
return "", fmt.Errorf("failed to read --query-file %s: %w", queryFile, err)
}
query := string(data)
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("--query-file %s is empty", queryFile)
}
return query, nil
}

if len(args) == 0 || strings.TrimSpace(args[0]) == "" {
return "", fmt.Errorf("query is required; pass a KQL argument or --query-file")
}
return args[0], nil
}

// runGraph builds the Resource Graph request body and delegates to the request
// service, reusing the same auth, retry, and formatting path as other commands.
func runGraph(cmd *cobra.Command, query string, subscriptions, managementGroups []string, top, skip int, skipToken string) error {
Expand Down
63 changes: 63 additions & 0 deletions cli/src/internal/cmd/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package cmd

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -125,3 +128,63 @@ func TestBuildGraphRequestBodyUsesDollarPrefixedOptionKeys(t *testing.T) {
}
}
}

func TestResolveGraphQueryFromArgument(t *testing.T) {
got, err := resolveGraphQuery([]string{"Resources | count"}, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "Resources | count" {
t.Fatalf("query = %q", got)
}
}

func TestResolveGraphQueryFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "query.kql")
want := "Resources\n| project name, type\n"
if err := os.WriteFile(path, []byte(want), 0o600); err != nil {
t.Fatalf("write query file: %v", err)
}

got, err := resolveGraphQuery(nil, path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != want {
t.Fatalf("query = %q, want %q", got, want)
}
}

func TestResolveGraphQueryRejectsArgumentAndFile(t *testing.T) {
_, err := resolveGraphQuery([]string{"Resources | count"}, "query.kql")
if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
t.Fatalf("expected combined input error, got %v", err)
}
}

func TestResolveGraphQueryRejectsMissingQuery(t *testing.T) {
_, err := resolveGraphQuery(nil, "")
if err == nil || !strings.Contains(err.Error(), "query is required") {
t.Fatalf("expected missing query error, got %v", err)
}
}

func TestResolveGraphQueryRejectsEmptyFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.kql")
if err := os.WriteFile(path, []byte(" \n\t"), 0o600); err != nil {
t.Fatalf("write query file: %v", err)
}

_, err := resolveGraphQuery(nil, path)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Fatalf("expected empty file error, got %v", err)
}
}

func TestResolveGraphQueryReportsMissingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing.kql")
_, err := resolveGraphQuery(nil, path)
if err == nil || !strings.Contains(err.Error(), "failed to read --query-file") {
t.Fatalf("expected missing file error, got %v", err)
}
}
4 changes: 4 additions & 0 deletions cli/src/internal/skills/azd-rest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,14 @@ query runs against every subscription you can access unless you narrow it with

```bash
azd rest graph "Resources | summarize count() by type"

# Query from a file
azd rest graph --query-file resources.kql
```

| Flag | Description |
|------|-------------|
| `--query-file` | Read the KQL query from a file |
| `--subscription` | Subscription ID to scope the query (repeatable) |
| `--management-group` | Management group ID to scope the query (repeatable) |
| `--top` | Maximum number of rows to return |
Expand Down
Loading