From 3bc408c56651c1c399344491af59e7771dcbf6f3 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:41:46 -0700 Subject: [PATCH] Add graph query file support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 ++ cli/docs/cli-reference.md | 37 +++++++++++++ cli/src/internal/cmd/graph.go | 41 +++++++++++++-- cli/src/internal/cmd/graph_test.go | 63 +++++++++++++++++++++++ cli/src/internal/skills/azd-rest/SKILL.md | 4 ++ 5 files changed, 144 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c2cac09..433f501 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index b7a5f27..291120a 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -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 | --- @@ -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 --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. diff --git a/cli/src/internal/cmd/graph.go b/cli/src/internal/cmd/graph.go index 177815b..b3119bc 100644 --- a/cli/src/internal/cmd/graph.go +++ b/cli/src/internal/cmd/graph.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "os" "strings" "github.com/spf13/cobra" @@ -68,27 +69,36 @@ func NewGraphCommand() *cobra.Command { top int skip int skipToken string + queryFile string ) cmd := &cobra.Command{ - Use: "graph ", + 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 --top 5 # Continue a paged result set azd rest graph "Resources | project name" --skip-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) }, } @@ -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 { diff --git a/cli/src/internal/cmd/graph_test.go b/cli/src/internal/cmd/graph_test.go index e81719c..2cc975d 100644 --- a/cli/src/internal/cmd/graph_test.go +++ b/cli/src/internal/cmd/graph_test.go @@ -2,6 +2,9 @@ package cmd import ( "encoding/json" + "os" + "path/filepath" + "strings" "testing" ) @@ -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) + } +} diff --git a/cli/src/internal/skills/azd-rest/SKILL.md b/cli/src/internal/skills/azd-rest/SKILL.md index edbafe3..3d4ff19 100644 --- a/cli/src/internal/skills/azd-rest/SKILL.md +++ b/cli/src/internal/skills/azd-rest/SKILL.md @@ -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 |