From 611c6b1c839d063c5258c5ab047752e89206ab88 Mon Sep 17 00:00:00 2001 From: anhpg Date: Mon, 26 Oct 2026 02:10:08 +0700 Subject: [PATCH 1/8] update gaps for agentbase cli --- docs/commands/agentbase/access/index.md | 12 + .../access/outbound-auth-managed-3lo-token.md | 108 +++++++ .../access/outbound-auth-managed-get.md | 39 +++ .../access/outbound-auth-managed-list.md | 51 +++ docs/commands/agentbase/deploy/index.md | 2 +- docs/commands/agentbase/deploy/up.md | 11 +- docs/commands/agentbase/gateway/index.md | 3 + .../agentbase/gateway/mcp-connectors-get.md | 44 +++ .../agentbase/gateway/mcp-connectors-list.md | 71 +++++ .../gateway/mcp-server-templates-list.md | 76 +++++ .../commands/agentbase/gateway/target-sync.md | 66 ++++ .../agentbase/gateway/target-tools.md | 40 +++ docs/commands/agentbase/runtime/create.md | 178 ++++++++++- docs/commands/agentbase/runtime/update.md | 165 ++++++++-- go/cmd/agentbase/access.go | 189 ++++++++++- go/cmd/agentbase/catalog.go | 14 +- go/cmd/agentbase/deploy.go | 20 +- go/cmd/agentbase/gateway.go | 285 ++++++++++++++++- go/cmd/agentbase/runtime.go | 294 +++++++++++++++--- go/cmd/agentbase/runtime_test.go | 198 +++++++++++- go/internal/agentbase/deploy/models.go | 3 +- go/internal/agentbase/deploy/models_test.go | 2 +- go/internal/agentbase/gateway/client.go | 87 ++++++ go/internal/agentbase/gateway/client_test.go | 186 +++++++++++ go/internal/agentbase/gateway/models.go | 130 ++++++++ go/internal/agentbase/identity/client.go | 41 +++ go/internal/agentbase/identity/client_test.go | 82 +++++ go/internal/agentbase/identity/models.go | 28 ++ go/internal/agentbase/runtime/client.go | 42 +-- go/internal/agentbase/runtime/client_test.go | 40 +-- go/internal/agentbase/runtime/models.go | 110 +++++-- mkdocs.yml | 8 + 32 files changed, 2432 insertions(+), 193 deletions(-) create mode 100644 docs/commands/agentbase/access/outbound-auth-managed-3lo-token.md create mode 100644 docs/commands/agentbase/access/outbound-auth-managed-get.md create mode 100644 docs/commands/agentbase/access/outbound-auth-managed-list.md create mode 100644 docs/commands/agentbase/gateway/mcp-connectors-get.md create mode 100644 docs/commands/agentbase/gateway/mcp-connectors-list.md create mode 100644 docs/commands/agentbase/gateway/mcp-server-templates-list.md create mode 100644 docs/commands/agentbase/gateway/target-sync.md create mode 100644 docs/commands/agentbase/gateway/target-tools.md diff --git a/docs/commands/agentbase/access/index.md b/docs/commands/agentbase/access/index.md index e266ac2..496b902 100644 --- a/docs/commands/agentbase/access/index.md +++ b/docs/commands/agentbase/access/index.md @@ -51,3 +51,15 @@ grn agentbase access [options] | [outbound-auth oauth2 delete](outbound-auth-oauth2-delete.md) | Delete an OAuth2 provider | | [outbound-auth oauth2 m2m-token](outbound-auth-oauth2-m2m-token.md) | Get an M2M OAuth2 token | | [outbound-auth oauth2 3lo-token](outbound-auth-oauth2-3lo-token.md) | Get a 3-legged OAuth2 token | + +### Outbound Auth — Managed OAuth2 (read-only) + +Platform-managed OAuth2 providers: credentials live server-side and are never +exposed, so the surface is read-only plus 3LO token retrieval. Each provider +has a fixed `callbackUrl` the delegated browser flow returns to. + +| Command | Description | +|---------|-------------| +| [outbound-auth managed list](outbound-auth-managed-list.md) | List platform-managed OAuth2 providers | +| [outbound-auth managed get](outbound-auth-managed-get.md) | Get a platform-managed OAuth2 provider | +| [outbound-auth managed 3lo-token](outbound-auth-managed-3lo-token.md) | Get a 3-legged OAuth2 token (managed provider) | diff --git a/docs/commands/agentbase/access/outbound-auth-managed-3lo-token.md b/docs/commands/agentbase/access/outbound-auth-managed-3lo-token.md new file mode 100644 index 0000000..e9d9098 --- /dev/null +++ b/docs/commands/agentbase/access/outbound-auth-managed-3lo-token.md @@ -0,0 +1,108 @@ +# outbound-auth managed 3lo-token + +Get a 3-legged OAuth2 token via a managed provider. + +## Description + +Retrieve a 3-legged OAuth2 token for an agent identity via a +platform-managed OAuth2 provider (POST +`/api/v1/outbound-auth/managed-oauth2-providers/{providerName}/agent-identities/{agentIdentityName}/tokens/3lo`). +The flow is delegated to the browser: the first call returns an +`authorizationUrl` to open; the provider redirects to the managed provider's +fixed callback, and the token is completed server-side. + +Unlike [outbound-auth oauth2 3lo-token](outbound-auth-oauth2-3lo-token.md), +no client credentials are involved — the managed provider holds them. + +## Synopsis + +```text +grn agentbase access outbound-auth managed 3lo-token + --agent-user-id + --return-url + --scope ... + [--session-id ] + [--custom-parameters ] + [--custom-state ] + [--force-authentication] +``` + +## Arguments + +**``** (string) — managed provider name. Required. + +**``** (string) — agent identity name. Required. + +## Options + +**`--agent-user-id`** (string) + +Agent user ID. Required (or use `--interactive`). + +- Required: Yes +- Default: (empty) + +**`--return-url`** (string) + +Return URL after authorization. Required (or use `--interactive`). + +- Required: Yes +- Default: (empty) + +**`--scope`** (stringArray) + +OAuth2 scope (repeatable). Required (or use `--interactive`). + +- Required: Yes +- Default: (empty) +- Syntax: `--scope repo --scope read:org` + +**`--session-id`** (string) + +Session ID (UUID format). + +- Required: No +- Default: (empty) + +**`--custom-parameters`** (string) + +Custom parameters as a JSON object, e.g. `'{"key1":"value1"}'`. + +- Required: No +- Default: (empty) + +**`--custom-state`** (string) + +Custom state parameter. + +- Required: No +- Default: (empty) + +**`--force-authentication`** (bool) + +Force re-authentication. + +- Required: No +- Default: `false` + +## Global options + +All `grn agentbase` commands accept: + +- `-o, --output json|table|id` — output format (default `table`) +- `-i, --interactive` — prompt for missing required parameters +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` + +## Examples + +```bash +grn agentbase access outbound-auth managed 3lo-token github my-agent \ + --agent-user-id user-123 \ + --return-url https://app.example.com/done \ + --scope repo +``` + +The first call typically returns `authorization_required` with an +authorization URL — open it in a browser; the token is completed server-side +after consent at the provider's fixed callback. diff --git a/docs/commands/agentbase/access/outbound-auth-managed-get.md b/docs/commands/agentbase/access/outbound-auth-managed-get.md new file mode 100644 index 0000000..649d688 --- /dev/null +++ b/docs/commands/agentbase/access/outbound-auth-managed-get.md @@ -0,0 +1,39 @@ +# outbound-auth managed get + +Get a platform-managed OAuth2 provider. + +## Description + +Retrieve a platform-managed OAuth2 provider summary by name (GET +`/api/v1/outbound-auth/managed-oauth2-providers/{name}`). Shows the +provider's id, name, status, and fixed `callbackUrl`; credentials are never +exposed. + +## Synopsis + +```text +grn agentbase access outbound-auth managed get +``` + +## Arguments + +**``** (string) — managed provider name. Required. + +## Options + +This command takes no command-specific options. + +## Global options + +All `grn agentbase` commands accept: + +- `-o, --output json|table|id` — output format (default `table`) +- `-i, --interactive` — prompt for missing required parameters +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` + +## Examples + +```bash +grn agentbase access outbound-auth managed get google +``` diff --git a/docs/commands/agentbase/access/outbound-auth-managed-list.md b/docs/commands/agentbase/access/outbound-auth-managed-list.md new file mode 100644 index 0000000..39501ee --- /dev/null +++ b/docs/commands/agentbase/access/outbound-auth-managed-list.md @@ -0,0 +1,51 @@ +# outbound-auth managed list + +List platform-managed OAuth2 providers. + +## Description + +Retrieve a paginated list of platform-managed OAuth2 providers (GET +`/api/v1/outbound-auth/managed-oauth2-providers`). Managed providers keep +their credentials server-side (never exposed), so the CLI surface is +read-only. Each provider has a fixed `callbackUrl` the delegated browser flow +returns to. + +## Synopsis + +```text +grn agentbase access outbound-auth managed list + [--page ] + [--size ] +``` + +## Options + +**`--page`** (int) + +Page number (1-based). + +- Required: No +- Default: `1` + +**`--size`** (int) + +Page size. + +- Required: No +- Default: `20` + +## Global options + +All `grn agentbase` commands accept: + +- `-o, --output json|table|id` — output format (default `table`) +- `-i, --interactive` — prompt for missing required parameters +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` + +## Examples + +```bash +grn agentbase access outbound-auth managed list +grn agentbase access outbound-auth managed list --size 50 +``` diff --git a/docs/commands/agentbase/deploy/index.md b/docs/commands/agentbase/deploy/index.md index c88486f..9044da1 100644 --- a/docs/commands/agentbase/deploy/index.md +++ b/docs/commands/agentbase/deploy/index.md @@ -57,5 +57,5 @@ runtime: args: [--port, "8080"] env: {LOG_LEVEL: info} flavorId: agent.small - autoscaling: {minReplicas: 1, maxReplicas: 3, cpuUtilization: 70, memoryUtilization: 80} + autoscaling: {maxReplicas: 3, cpuUtilization: 70, memoryUtilization: 80} ``` diff --git a/docs/commands/agentbase/deploy/up.md b/docs/commands/agentbase/deploy/up.md index 7564b43..a437ff6 100644 --- a/docs/commands/agentbase/deploy/up.md +++ b/docs/commands/agentbase/deploy/up.md @@ -25,7 +25,6 @@ grn agentbase deploy up [--command ] [--args ] [--env ] - [--min-replicas ] [--max-replicas ] [--cpu-utilization ] [--memory-utilization ] @@ -101,20 +100,12 @@ Environment variable `KEY=VALUE` (repeatable). - Required: No - Syntax: `--env LOG_LEVEL=info` -**`--min-replicas`** (integer) - -Autoscaling min replicas. - -- Required: No -- Default: `1` - **`--max-replicas`** (integer) -Autoscaling max replicas. +Autoscaling max replicas. The minimum is platform-owned (always 1). - Required: No - Default: `2` -- Constraints: must be greater than or equal to `--min-replicas`. **`--cpu-utilization`** (integer) diff --git a/docs/commands/agentbase/gateway/index.md b/docs/commands/agentbase/gateway/index.md index a849424..1ecf82b 100644 --- a/docs/commands/agentbase/gateway/index.md +++ b/docs/commands/agentbase/gateway/index.md @@ -35,6 +35,9 @@ terminal state. | [inbound-auth](inbound-auth-jwt-idp-app-set.md) | [`jwt idp-app set`](inbound-auth-jwt-idp-app-set.md) · [`jwt idp-app clear`](inbound-auth-jwt-idp-app-clear.md) | | [private-network](private-network-routes-get.md) | [`routes get`](private-network-routes-get.md) · [`routes set`](private-network-routes-set.md) | | [service-account](service-account-repair.md) | [`repair`](service-account-repair.md) | +| [mcp-connectors](mcp-connectors-list.md) | [`list`](mcp-connectors-list.md) · [`get`](mcp-connectors-get.md) | +| [mcp-server-templates](mcp-server-templates-list.md) | [`list`](mcp-server-templates-list.md) | +| [target](target-tools.md) | [`tools`](target-tools.md) · [`sync`](target-sync.md) | ## Recommended workflow for complex specs diff --git a/docs/commands/agentbase/gateway/mcp-connectors-get.md b/docs/commands/agentbase/gateway/mcp-connectors-get.md new file mode 100644 index 0000000..70da9af --- /dev/null +++ b/docs/commands/agentbase/gateway/mcp-connectors-get.md @@ -0,0 +1,44 @@ +# gateway mcp-connectors get + +Get one MCP connector by gateway + target name. + +## Description + +Get a single MCP connector (one target on one caller-owned gateway) by +composite key (GET `/api/v1/mcp-connectors/{gatewayName}/{targetName}`). +Ownership is caller-scoped, so another user's gateway looks like a 404. + +The response shows the connector's `connectUrl`, template type (or `CUSTOM`), +outbound-auth descriptor, and parent gateway context including inbound-auth +mode. + +## Synopsis + +```text +grn agentbase gateway mcp-connectors get +``` + +## Arguments + +**``** (string) — parent gateway name. Required. + +**``** (string) — target name on that gateway. Required. + +## Options + +This command takes no command-specific options. + +## Global options + +All `grn agentbase` commands accept: + +- `-o, --output json|table|id` — output format (default `table`) +- `-i, --interactive` — prompt for missing required parameters +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` + +## Examples + +```bash +grn agentbase gateway mcp-connectors get my-gw github +``` diff --git a/docs/commands/agentbase/gateway/mcp-connectors-list.md b/docs/commands/agentbase/gateway/mcp-connectors-list.md new file mode 100644 index 0000000..e81c6da --- /dev/null +++ b/docs/commands/agentbase/gateway/mcp-connectors-list.md @@ -0,0 +1,71 @@ +# gateway mcp-connectors list + +List MCP connectors (targets) across all gateways. + +## Description + +List every MCP connector (target) attached across your gateways, flattened +into one list (GET `/api/v1/mcp-connectors`). Each item carries a ready-to-use +`connectUrl`, the derived template type (or `CUSTOM`), the upstream +outbound-auth descriptor, and parent-gateway context including inbound-auth +mode. Filter with `--gateway-name` and `--state`. + +This is the flat "connector catalog" view: use it to find a `connectUrl` to +hand to any MCP client without drilling into one gateway. + +## Synopsis + +```text +grn agentbase gateway mcp-connectors list + [--gateway-name ] + [--state ] + [--page ] + [--size ] +``` + +## Options + +**`--gateway-name`** (string) + +Filter by parent gateway name. + +- Required: No +- Default: (empty) + +**`--state`** (string) + +Filter by parent gateway state (e.g. `ACTIVE`). + +- Required: No +- Default: (empty) + +**`--page`** (int) + +Page number (1-based). + +- Required: No +- Default: `1` + +**`--size`** (int) + +Page size (max 100). + +- Required: No +- Default: `50` + +## Global options + +All `grn agentbase` commands accept: + +- `-o, --output json|table|id` — output format (default `table`) +- `-i, --interactive` — prompt for missing required parameters +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` + +## Examples + +```bash +grn agentbase gateway mcp-connectors list +grn agentbase gateway mcp-connectors list --gateway-name my-gw --state ACTIVE +grn agentbase gateway mcp-connectors list -o json --query 'items[0].connectUrl' +``` diff --git a/docs/commands/agentbase/gateway/mcp-server-templates-list.md b/docs/commands/agentbase/gateway/mcp-server-templates-list.md new file mode 100644 index 0000000..3eaaac9 --- /dev/null +++ b/docs/commands/agentbase/gateway/mcp-server-templates-list.md @@ -0,0 +1,76 @@ +# gateway mcp-server-templates list + +List MCP server templates from the catalog. + +## Description + +List enabled MCP server templates from the catalog (GET +`/api/v1/mcp-server-templates`). Each item carries auth options whose +`recommended*` fields pre-fill a target-creation form; edits go through the +gateway update path. `displayName`/`vendor`/`id` are case-insensitive +substring filters that AND together. + +## Synopsis + +```text +grn agentbase gateway mcp-server-templates list + [--display-name ] + [--vendor ] + [--id ] + [--page ] + [--size ] +``` + +## Options + +**`--display-name`** (string) + +Filter by display name (substring, case-insensitive). + +- Required: No +- Default: (empty) + +**`--vendor`** (string) + +Filter by vendor (substring, case-insensitive). + +- Required: No +- Default: (empty) + +**`--id`** (string) + +Filter by template id (substring, case-insensitive). + +- Required: No +- Default: (empty) + +**`--page`** (int) + +Page number (1-based). + +- Required: No +- Default: `1` + +**`--size`** (int) + +Page size (max 100). + +- Required: No +- Default: `50` + +## Global options + +All `grn agentbase` commands accept: + +- `-o, --output json|table|id` — output format (default `table`) +- `-i, --interactive` — prompt for missing required parameters +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` + +## Examples + +```bash +grn agentbase gateway mcp-server-templates list +grn agentbase gateway mcp-server-templates list --vendor github +grn agentbase gateway mcp-server-templates list --display-name slack --vendor slack +``` diff --git a/docs/commands/agentbase/gateway/target-sync.md b/docs/commands/agentbase/gateway/target-sync.md new file mode 100644 index 0000000..35669c1 --- /dev/null +++ b/docs/commands/agentbase/gateway/target-sync.md @@ -0,0 +1,66 @@ +# gateway target sync + +Sync a target's tools/list from its live MCP endpoint. + +## Description + +Fetch the live tools/list from the target's MCP endpoint and persist it (POST +`/api/v1/gateways/{name}/targets/{target}/sync`). + +Non-interactive auth (NONE/2LO, or 3LO with cached consent) returns the tools +directly. Interactive auth (3LO without consent, INBOUND_FORWARD) returns +`status=authorization_required` plus an `authorizeUrl` — open it in a browser; +on completion the gateway finishes tools/list server-side and redirects to +`--return-to` (or a plain done page). + +Use [target tools](target-tools.md) to read the persisted snapshot without +re-syncing. + +## Synopsis + +```text +grn agentbase gateway target sync + [--return-to ] +``` + +## Arguments + +**``** (string) — gateway name. Required. + +**``** (string) — target name on that gateway. Required. + +## Options + +**`--return-to`** (string) + +URL the gateway redirects the browser to after interactive sync (optional). + +- Required: No +- Default: (empty) + +## Global options + +All `grn agentbase` commands accept: + +- `-o, --output json|table|id` — output format (default `table`) +- `-i, --interactive` — prompt for missing required parameters +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` + +## Examples + +```bash +grn agentbase gateway target sync my-gw github +grn agentbase gateway target sync my-gw slack --return-to https://app.example.com/sync-done +``` + +Interactive (3LO) sync — the first call returns an authorize URL on stderr +when consent is required: + +```bash +grn agentbase gateway target sync my-gw github +# Status: authorization_required +# Authorization URL: https://github.com/login/oauth/authorize?... (open in a browser) +# ...after completing consent, re-run: +grn agentbase gateway target sync my-gw github +``` diff --git a/docs/commands/agentbase/gateway/target-tools.md b/docs/commands/agentbase/gateway/target-tools.md new file mode 100644 index 0000000..6e7c6ec --- /dev/null +++ b/docs/commands/agentbase/gateway/target-tools.md @@ -0,0 +1,40 @@ +# gateway target tools + +Show a target's last-synced tools/list. + +## Description + +Get a target's last-synced tools/list (GET +`/api/v1/gateways/{name}/targets/{target}/tools`). This is the persisted +snapshot; refresh it with [target sync](target-sync.md). + +## Synopsis + +```text +grn agentbase gateway target tools +``` + +## Arguments + +**``** (string) — gateway name. Required. + +**``** (string) — target name on that gateway. Required. + +## Options + +This command takes no command-specific options. + +## Global options + +All `grn agentbase` commands accept: + +- `-o, --output json|table|id` — output format (default `table`) +- `-i, --interactive` — prompt for missing required parameters +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` + +## Examples + +```bash +grn agentbase gateway target tools my-gw github +``` diff --git a/docs/commands/agentbase/runtime/create.md b/docs/commands/agentbase/runtime/create.md index ef05935..675aece 100644 --- a/docs/commands/agentbase/runtime/create.md +++ b/docs/commands/agentbase/runtime/create.md @@ -7,8 +7,9 @@ Create a new agent runtime. Create a new agent runtime. By default the runtime is built from flags (the simple path). For environment -variables, private-registry auth, or multi-element command/args, use `--file` -with a template produced by [generate](generate.md). +variables, private-registry auth, network placement (VPC), inbound auth (IAM or +JWT), a CIDR allowlist, an MCP protocol runtime, or multi-element command/args, +use `--file` with a template produced by [generate](generate.md). The runtime is created asynchronously; this command returns as soon as the service accepts the spec (state `CREATING`). Converge with @@ -17,6 +18,9 @@ service accepts the spec (state `CREATING`). Converge with When `--file` is set it is authoritative — every flag below is ignored and the file must contain `name`, `imageUrl`, and `flavorId`. +> **Deprecation:** `--min-replicas` is gone. The platform owns the autoscaling +> floor (minimum replicas is always 1); only `--max-replicas` is configurable. + ## Synopsis ```text @@ -28,13 +32,28 @@ grn agentbase runtime create [--image-auth-enabled] [--image-auth-username ] [--image-auth-password ] + [--image-auth-use-registry-credentials] [--command ]... [--args ]... [--env KEY=VALUE]... - [--min-replicas ] [--max-replicas ] [--cpu-utilization ] [--memory-utilization ] + [--network-mode ] + [--vpc-id ] + [--subnet-id ] + [--route-cidrs ]... + [--allowed-cidrs ]... + [--inbound-mode ] + [--inbound-jwt-source ] + [--inbound-jwt-discovery-url ] + [--inbound-jwt-jwks ] + [--inbound-jwt-audience ]... + [--inbound-jwt-client ]... + [--inbound-jwt-scope ]... + [--inbound-jwt-principal-claim ] + [--protocol ] + [--poc] [--file ] ``` @@ -47,7 +66,7 @@ Runtime name (immutable). Required unless `--file` is set. - Required: Yes - Default: (empty) - Alias: `-n` -- Constraints: sealed (immutable) after creation. +- Constraints: 5–50 chars; sealed (immutable) after creation. **`--image-url`** (string) @@ -93,6 +112,14 @@ Private-registry password (used with `--image-auth-enabled`). - Default: (empty) - Constraints: send-only — never present on responses. Prefer supplying it via `--file` rather than on the command line. +**`--image-auth-use-registry-credentials`** (bool) + +`imageAuth`: let the platform pull the image using its own registry +credentials. Mutually exclusive with username/password auth. + +- Required: No +- Default: `false` + **`--command`** (stringArray) Container entrypoint element (repeatable). @@ -117,21 +144,13 @@ Environment variable `KEY=VALUE` (repeatable). - Default: (empty) - Syntax: `--env LOG_LEVEL=info --env DEBUG=1` -**`--min-replicas`** (int) - -Autoscaling: minimum replicas (1-10). - -- Required: No -- Default: `1` -- Constraints: 1–10; must be ≤ `--max-replicas`. - **`--max-replicas`** (int) -Autoscaling: maximum replicas (1-10). +Autoscaling: maximum replicas (1-10). The minimum is platform-owned (always 1). - Required: No - Default: `2` -- Constraints: 1–10; must be ≥ `--min-replicas`. +- Constraints: 1–10. **`--cpu-utilization`** (int) @@ -149,6 +168,119 @@ Autoscaling: memory scale-up threshold % (10-90). - Default: `70` - Constraints: 10–90. +**`--network-mode`** (string) + +`networkConfig` mode: `PUBLIC` or `VPC` (unset = `PUBLIC`). + +- Required: No +- Default: `PUBLIC` +- Constraints: `VPC` requires `--vpc-id` and `--subnet-id`. + +**`--vpc-id`** (string) + +`networkConfig` VPC id (required with `--network-mode VPC`). + +- Required: Conditional — required when `--network-mode VPC`. +- Default: (empty) + +**`--subnet-id`** (string) + +`networkConfig` subnet id (required with `--network-mode VPC`). + +- Required: Conditional — required when `--network-mode VPC`. +- Default: (empty) + +**`--route-cidrs`** (stringArray) + +`networkConfig` egress route CIDR (repeatable, max 20, VPC only). + +- Required: No +- Default: (empty) +- Syntax: `--route-cidrs 10.0.0.0/16 --route-cidrs 172.16.0.0/12` + +**`--allowed-cidrs`** (stringArray) + +Inbound CIDR allowlist (repeatable, max 20). Applies to the runtime's inbound +traffic on `PUBLIC` networks. + +- Required: No +- Default: (empty) +- Syntax: `--allowed-cidrs 203.0.113.0/24` + +**`--inbound-mode`** (string) + +`inboundAuth` mode: `NONE`, `IAM`, or `JWT` (unset = `NONE`). + +- Required: No +- Default: (empty = `NONE`) +- Constraints: `JWT` requires the `--inbound-jwt-*` flags; cannot be combined + with them when set to `IAM`/`NONE`. + +**`--inbound-jwt-source`** (string) + +`inboundAuth` JWT key source: `DISCOVERY` or `JWKS` (required with +`--inbound-mode JWT`). + +- Required: Conditional — required when `--inbound-mode JWT`. +- Default: (empty) + +**`--inbound-jwt-discovery-url`** (string) + +`inboundAuth` JWT OIDC discovery URL (required with source `DISCOVERY`). + +- Required: Conditional — required when `--inbound-jwt-source DISCOVERY`. +- Default: (empty) + +**`--inbound-jwt-jwks`** (string) + +`inboundAuth` JWT inline key set JSON (required with source `JWKS`, max 32KiB). +Prefer `--file` — the value is a full JSON document. + +- Required: Conditional — required when `--inbound-jwt-source JWKS`. +- Default: (empty) + +**`--inbound-jwt-audience`** (stringArray) + +`inboundAuth` JWT allowed audience (repeatable, max 20). + +- Required: No +- Default: (empty) + +**`--inbound-jwt-client`** (stringArray) + +`inboundAuth` JWT allowed client (repeatable, max 20). + +- Required: No +- Default: (empty) + +**`--inbound-jwt-scope`** (stringArray) + +`inboundAuth` JWT allowed scope (repeatable, max 50). + +- Required: No +- Default: (empty) + +**`--inbound-jwt-principal-claim`** (string) + +`inboundAuth` JWT principal claim (default `sub`). + +- Required: No +- Default: `sub` + +**`--protocol`** (string) + +Runtime protocol: `HTTP` or `MCP` (unset = `HTTP`). + +- Required: No +- Default: (empty = `HTTP`) + +**`--poc`** (bool) + +Mark as a proof-of-concept runtime. Create-only: dropped on update. + +- Required: No +- Default: `false` + **`--file`** (string) Apply a spec file (see [generate](generate.md)); authoritative when set. @@ -163,7 +295,8 @@ All `grn agentbase` commands accept: - `-o, --output json|table|id` — output format (default `table`) - `-i, --interactive` — prompt for missing required parameters -- The shared `grn` global options: `--profile`, `--region`, `--query`, `--endpoint-url`, `--debug` +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` ## Examples @@ -186,3 +319,18 @@ grn agentbase runtime create \ --command python --command -m --command my_agent \ --env LOG_LEVEL=info ``` + +Create an MCP-protocol runtime in a VPC with a CIDR allowlist and JWT inbound +auth (prefer `--file` for a JWKS payload): + +```bash +grn agentbase runtime create \ + --name my-mcp-runtime \ + --image-url registry.example.com/my-mcp:latest \ + --flavor-id agn-flavor-small \ + --protocol MCP \ + --network-mode VPC --vpc-id vpc-123 --subnet-id subnet-456 \ + --allowed-cidrs 10.10.0.0/16 \ + --inbound-mode JWT --inbound-jwt-source DISCOVERY \ + --inbound-jwt-discovery-url https://idp.example.com/.well-known/openid-configuration +``` diff --git a/docs/commands/agentbase/runtime/update.md b/docs/commands/agentbase/runtime/update.md index be78618..2319a5d 100644 --- a/docs/commands/agentbase/runtime/update.md +++ b/docs/commands/agentbase/runtime/update.md @@ -16,6 +16,10 @@ is validated as a full create spec, so `name` must be present (and non-empty) in the file even though it is not sent; the runtime is addressed by the `` positional argument. +> **Notes:** `--min-replicas` is gone (the platform owns the autoscaling floor — +> always 1); only `--max-replicas` is configurable. `--poc` is create-only and +> cannot be set or cleared on update. + ## Synopsis ```text @@ -26,13 +30,27 @@ grn agentbase runtime update [--image-auth-enabled] [--image-auth-username ] [--image-auth-password ] + [--image-auth-use-registry-credentials] [--command ]... [--args ]... [--env KEY=VALUE]... - [--min-replicas ] [--max-replicas ] [--cpu-utilization ] [--memory-utilization ] + [--network-mode ] + [--vpc-id ] + [--subnet-id ] + [--route-cidrs ]... + [--allowed-cidrs ]... + [--inbound-mode ] + [--inbound-jwt-source ] + [--inbound-jwt-discovery-url ] + [--inbound-jwt-jwks ] + [--inbound-jwt-audience ]... + [--inbound-jwt-client ]... + [--inbound-jwt-scope ]... + [--inbound-jwt-principal-claim ] + [--protocol ] [--file ] ``` @@ -82,6 +100,14 @@ Private-registry password (used with `--image-auth-enabled`). - Default: (empty) - Constraints: send-only — never present on responses. Prefer supplying it via `--file` rather than on the command line. +**`--image-auth-use-registry-credentials`** (bool) + +`imageAuth`: let the platform pull the image using its own registry +credentials. Mutually exclusive with username/password auth. + +- Required: No +- Default: `false` + **`--command`** (stringArray) Container entrypoint element (repeatable). @@ -106,21 +132,13 @@ Environment variable `KEY=VALUE` (repeatable). - Default: (empty) - Syntax: `--env LOG_LEVEL=info --env DEBUG=1` -**`--min-replicas`** (int) - -Autoscaling: minimum replicas (1-10). - -- Required: No -- Default: `1` -- Constraints: 1–10; must be ≤ `--max-replicas`. - **`--max-replicas`** (int) -Autoscaling: maximum replicas (1-10). +Autoscaling: maximum replicas (1-10). The minimum is platform-owned (always 1). - Required: No - Default: `2` -- Constraints: 1–10; must be ≥ `--min-replicas`. +- Constraints: 1–10. **`--cpu-utilization`** (int) @@ -138,13 +156,118 @@ Autoscaling: memory scale-up threshold % (10-90). - Default: `70` - Constraints: 10–90. +**`--network-mode`** (string) + +`networkConfig` mode: `PUBLIC` or `VPC` (unset = `PUBLIC`). + +- Required: No +- Default: `PUBLIC` +- Constraints: `VPC` requires `--vpc-id` and `--subnet-id`. + +**`--vpc-id`** (string) + +`networkConfig` VPC id (required with `--network-mode VPC`). + +- Required: Conditional — required when `--network-mode VPC`. +- Default: (empty) + +**`--subnet-id`** (string) + +`networkConfig` subnet id (required with `--network-mode VPC`). + +- Required: Conditional — required when `--network-mode VPC`. +- Default: (empty) + +**`--route-cidrs`** (stringArray) + +`networkConfig` egress route CIDR (repeatable, max 20, VPC only). + +- Required: No +- Default: (empty) +- Syntax: `--route-cidrs 10.0.0.0/16 --route-cidrs 172.16.0.0/12` + +**`--allowed-cidrs`** (stringArray) + +Inbound CIDR allowlist (repeatable, max 20). + +- Required: No +- Default: (empty) +- Syntax: `--allowed-cidrs 203.0.113.0/24` + +**`--inbound-mode`** (string) + +`inboundAuth` mode: `NONE`, `IAM`, or `JWT` (unset = `NONE`). + +- Required: No +- Default: (empty = `NONE`) +- Constraints: `JWT` requires the `--inbound-jwt-*` flags; cannot be combined + with them when set to `IAM`/`NONE`. + +**`--inbound-jwt-source`** (string) + +`inboundAuth` JWT key source: `DISCOVERY` or `JWKS` (required with +`--inbound-mode JWT`). + +- Required: Conditional — required when `--inbound-mode JWT`. +- Default: (empty) + +**`--inbound-jwt-discovery-url`** (string) + +`inboundAuth` JWT OIDC discovery URL (required with source `DISCOVERY`). + +- Required: Conditional — required when `--inbound-jwt-source DISCOVERY`. +- Default: (empty) + +**`--inbound-jwt-jwks`** (string) + +`inboundAuth` JWT inline key set JSON (required with source `JWKS`, max 32KiB). +Prefer `--file` — the value is a full JSON document. + +- Required: Conditional — required when `--inbound-jwt-source JWKS`. +- Default: (empty) + +**`--inbound-jwt-audience`** (stringArray) + +`inboundAuth` JWT allowed audience (repeatable, max 20). + +- Required: No +- Default: (empty) + +**`--inbound-jwt-client`** (stringArray) + +`inboundAuth` JWT allowed client (repeatable, max 20). + +- Required: No +- Default: (empty) + +**`--inbound-jwt-scope`** (stringArray) + +`inboundAuth` JWT allowed scope (repeatable, max 50). + +- Required: No +- Default: (empty) + +**`--inbound-jwt-principal-claim`** (string) + +`inboundAuth` JWT principal claim (default `sub`). + +- Required: No +- Default: `sub` + +**`--protocol`** (string) + +Runtime protocol: `HTTP` or `MCP` (unset = `HTTP`). + +- Required: No +- Default: (empty = `HTTP`) + **`--file`** (string) -Apply a full-spec file (create template minus `name`). +Apply a full-spec file (create template minus `name`); authoritative when set. - Required: No - Default: (empty) -- Constraints: when set, the flags above are ignored. The file is validated as a full create spec, so a non-empty `name` must be present (its value is ignored; the runtime is addressed by the `` argument). +- Constraints: when set, the flags above are ignored; `name` must be present in the file. ## Global options @@ -152,24 +275,24 @@ All `grn agentbase` commands accept: - `-o, --output json|table|id` — output format (default `table`) - `-i, --interactive` — prompt for missing required parameters -- The shared `grn` global options: `--profile`, `--region`, `--query`, `--endpoint-url`, `--debug` +- The shared `grn` global options: `--profile`, `--region`, `--query`, + `--endpoint-url`, `--debug` ## Examples -Update a runtime from an edited spec file, then converge: +Update from a full-spec file (recommended): ```bash grn agentbase runtime generate > rt.yaml -# ...edit rt.yaml (keep name set)... -grn agentbase runtime update agn-rt-abc123 --file rt.yaml -grn agentbase runtime wait agn-rt-abc123 +# ...edit rt.yaml (keep name)... +grn agentbase runtime update --file rt.yaml ``` -Update a runtime's image and autoscaling from flags: +Update from flags: ```bash -grn agentbase runtime update agn-rt-abc123 \ +grn agentbase runtime update \ --image-url registry.example.com/my-agent:v2 \ --flavor-id agn-flavor-small \ - --max-replicas 4 + --env LOG_LEVEL=info ``` diff --git a/go/cmd/agentbase/access.go b/go/cmd/agentbase/access.go index b2d31eb..1daf5c2 100644 --- a/go/cmd/agentbase/access.go +++ b/go/cmd/agentbase/access.go @@ -276,7 +276,9 @@ var workloadDeleteCmd = &cobra.Command{ var outboundAuthCmd = &cobra.Command{ Use: "outbound-auth", Short: "Manage outbound authentication providers", - Long: `Manage static API key, delegated API key, and OAuth2 providers for agent outbound authentication.`, + Long: `Manage static API key, delegated API key, and OAuth2 providers for agent +outbound authentication, plus read-only access to platform-managed OAuth2 +providers.`, } // --- outbound-auth static --- @@ -1035,6 +1037,173 @@ Optional flags: --session-id, --custom-parameters, --custom-state, --force-authe }, } +// --- Managed OAuth2 providers (platform-managed, read-only) --- + +var managedOauth2Cmd = &cobra.Command{ + Use: "managed", + Short: "Platform-managed OAuth2 providers", + Long: `Platform-managed OAuth2 providers: credentials live server-side and are +never exposed, so the CLI surface is read-only (list, get) plus 3LO token +retrieval for an agent identity. Each provider has a fixed callbackUrl the +delegated browser flow returns to.`, +} + +var managedOauth2ListCmd = &cobra.Command{ + Use: "list", + Short: "List managed OAuth2 providers", + Long: `Retrieve a paginated list of platform-managed OAuth2 providers.`, + RunE: func(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + size, _ := cmd.Flags().GetInt("size") + + ctx := context.Background() + client, err := newIdentityClient(ctx, cmd) + if err != nil { + return err + } + resp, err := client.ListManagedOauth2Providers(ctx, page-1, size) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + if len(resp.Content) == 0 { + fmt.Fprintln(cmd.ErrOrStderr(), "No items found.") + return nil + } + rows := make([][]string, len(resp.Content)) + for i, p := range resp.Content { + rows[i] = []string{str(p.ID), str(p.Name), str(p.Status), str(p.CallbackURL)} + } + output.Table([]string{"ID", "Name", "Status", "Callback URL"}, rows) + if resp.Page != nil && resp.TotalPages != nil && resp.TotalElements != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Page %d of %d (%d total items)\n", *resp.Page+1, *resp.TotalPages, *resp.TotalElements) + } + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + if len(resp.Content) > 0 { + output.PrintID(str(resp.Content[0].ID)) + } + } + return nil + }, +} + +var managedOauth2GetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a managed OAuth2 provider", + Long: `Retrieve a platform-managed OAuth2 provider summary by name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newIdentityClient(ctx, cmd) + if err != nil { + return err + } + resp, err := client.GetManagedOauth2Provider(ctx, args[0]) + if err != nil { + return err + } + return output.PrintResource(resp, func() string { return str(resp.Name) }, func() error { + output.Table([]string{"Field", "Value"}, [][]string{ + {"ID", output.StrOrDash(str(resp.ID))}, + {"Name", output.StrOrDash(str(resp.Name))}, + {"Status", output.StrOrDash(str(resp.Status))}, + {"Callback URL", output.StrOrDash(str(resp.CallbackURL))}, + {"Created", formatTime(resp.CreatedAt)}, + {"Updated", formatTime(resp.UpdatedAt)}, + }) + return nil + }) + }, +} + +var managedOauth2ThreeLoTokenCmd = &cobra.Command{ + Use: "3lo-token ", + Short: "Get a 3-legged OAuth2 token (managed provider)", + Long: `Retrieve a 3-legged OAuth2 token for an agent identity via a +platform-managed OAuth2 provider. The flow is delegated to the browser: the +first call returns an authorizationUrl to open; the provider redirects to the +managed provider's fixed callback, and the token is completed server-side. + +Required flags: --agent-user-id, --return-url, --scope. +Optional flags: --session-id, --custom-parameters, --custom-state, --force-authentication.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + agentUserID, _ := cmd.Flags().GetString("agent-user-id") + returnURL, _ := cmd.Flags().GetString("return-url") + scopes, _ := cmd.Flags().GetStringArray("scope") + + var err error + agentUserID, err = cliinput.RequireOrPromptString(agentUserID, "--agent-user-id", "Agent user ID") + if err != nil { + return err + } + returnURL, err = cliinput.RequireOrPromptString(returnURL, "--return-url", "Return URL") + if err != nil { + return err + } + scopes, err = cliinput.RequireOrPromptStringSlice(scopes, "--scope", "OAuth2 scopes") + if err != nil { + return err + } + + ctx := context.Background() + client, err := newIdentityClient(ctx, cmd) + if err != nil { + return err + } + req := &identitypkg.ThreeLoTokenRequest{ + AgentUserID: agentUserID, + ReturnURL: returnURL, + Scopes: jsonslice.Array[string](scopes), + } + if cmd.Flags().Changed("session-id") { + v, _ := cmd.Flags().GetString("session-id") + req.SessionID = &v + } + if cmd.Flags().Changed("custom-state") { + v, _ := cmd.Flags().GetString("custom-state") + req.CustomState = &v + } + if cmd.Flags().Changed("force-authentication") { + v, _ := cmd.Flags().GetBool("force-authentication") + req.ForceAuthentication = &v + } + customParams, _ := cmd.Flags().GetString("custom-parameters") + if customParams != "" { + var params map[string]string + if err := json.Unmarshal([]byte(customParams), ¶ms); err != nil { + return fmt.Errorf("invalid --custom-parameters JSON: %w", err) + } + req.CustomParameters = ¶ms + } + + resp, err := client.GetManaged3LOToken(ctx, args[0], args[1], req) + if err != nil { + return err + } + + switch output.GetFormat() { + case output.FormatTable: + output.Table([]string{"Field", "Value"}, [][]string{ + {"Access Token", output.StrOrDash(str(resp.AccessToken))}, + {"Token Type", output.StrOrDash(str(resp.TokenType))}, + {"Authorization URL", output.StrOrDash(str(resp.AuthorizationURL))}, + {"Session ID", output.StrOrDash(str(resp.SessionID))}, + {"Status", output.StrOrDash(str(resp.Status))}, + }) + case output.FormatJSON: + return output.JSON(resp) + case output.FormatID: + output.PrintID(str(resp.AccessToken)) + } + return nil + }, +} + func init() { AgentbaseCmd.AddCommand(identityCmd) @@ -1137,6 +1306,24 @@ func init() { oauth23LOTokenCmd.Flags().String("custom-state", "", "Custom state parameter") oauth23LOTokenCmd.Flags().Bool("force-authentication", false, "Force re-authentication") oauth2Cmd.AddCommand(oauth23LOTokenCmd) + + // managed + outboundAuthCmd.AddCommand(managedOauth2Cmd) + + managedOauth2ListCmd.Flags().Int("page", 1, "Page number (1-based)") + managedOauth2ListCmd.Flags().Int("size", 20, "Page size") + managedOauth2Cmd.AddCommand(managedOauth2ListCmd) + + managedOauth2Cmd.AddCommand(managedOauth2GetCmd) + + managedOauth2ThreeLoTokenCmd.Flags().String("agent-user-id", "", "Agent user ID (required without --interactive)") + managedOauth2ThreeLoTokenCmd.Flags().String("return-url", "", "Return URL after authorization (required without --interactive)") + managedOauth2ThreeLoTokenCmd.Flags().StringArray("scope", nil, "OAuth2 scope (repeatable, required without --interactive)") + managedOauth2ThreeLoTokenCmd.Flags().String("session-id", "", "Session ID (UUID format)") + managedOauth2ThreeLoTokenCmd.Flags().String("custom-parameters", "", `Custom parameters as a JSON object, e.g. '{"key1":"value1"}'`) + managedOauth2ThreeLoTokenCmd.Flags().String("custom-state", "", "Custom state parameter") + managedOauth2ThreeLoTokenCmd.Flags().Bool("force-authentication", false, "Force re-authentication") + managedOauth2Cmd.AddCommand(managedOauth2ThreeLoTokenCmd) } // str safely dereferences a string pointer, returning an empty string if nil. diff --git a/go/cmd/agentbase/catalog.go b/go/cmd/agentbase/catalog.go index 3887603..f552610 100644 --- a/go/cmd/agentbase/catalog.go +++ b/go/cmd/agentbase/catalog.go @@ -214,20 +214,24 @@ For anything richer — messaging channels (telegram/zalo), the GreenNode model provider, environment variables, or POC flag — write a spec file and apply it with --file (keys are the JSON/camelCase field names). Example: - name: my-bot - versionId: v1 + name: my-bot # lowercase [a-z0-9-], max 50 chars; defaults to the id + versionId: v1 # from 'marketplace openclaw-versions list' flavorId: f1 poc: true greenNodeModelProvider: enabled: true - apiKeyName: my-key + apiKeyName: my-key # optional; platform-managed 'openclaw-service' key when omitted environmentVariables: LOG_LEVEL: info channels: telegram: botToken: tok - dmPolicy: ALLOWLIST - dmAllowedUserIds: ["123"]`, + dmPolicy: allowlist # allowlist | pairing (required per channel) + dmAllowedUserIds: ["123"] + +The create response carries two one-time credentials — the gatewayToken and +the access token embedded in the url (#token=...) — that no later read returns, +so save them from the output.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() if catalogFile != "" { diff --git a/go/cmd/agentbase/deploy.go b/go/cmd/agentbase/deploy.go index 6f1c836..edd6c42 100644 --- a/go/cmd/agentbase/deploy.go +++ b/go/cmd/agentbase/deploy.go @@ -164,7 +164,7 @@ Defaults to YAML (with comments); pass -o json for a JSON skeleton.`, "env": map[string]string{"LOG_LEVEL": "info"}, "flavorId": "agent.small", "autoscaling": map[string]any{ - "minReplicas": 1, "maxReplicas": 3, + "maxReplicas": 3, "cpuUtilization": 70, "memoryUtilization": 80, }, }, @@ -215,7 +215,7 @@ runtime: args: [--port, "8080"] env: {LOG_LEVEL: info} flavorId: agent.small - autoscaling: {minReplicas: 1, maxReplicas: 3, cpuUtilization: 70, memoryUtilization: 80} + autoscaling: {maxReplicas: 3, cpuUtilization: 70, memoryUtilization: 80} ` // --------------------------------------------------------------------------- @@ -410,7 +410,6 @@ func buildRuntimeCreateReq(ctx context.Context, d *deployClients, mfst *deploypk EnvironmentVariables: nonNilMap(rs.Env), FlavorID: rs.FlavorID, Autoscaling: runtimepkg.Autoscaling{ - MinReplicas: rs.Autoscaling.MinReplicas, MaxReplicas: rs.Autoscaling.MaxReplicas, CPUUtilization: rs.Autoscaling.CPUUtilization, MemoryUtilization: rs.Autoscaling.MemoryUtilization, @@ -677,19 +676,21 @@ func buildManifestFromFlags(cmd *cobra.Command) (*deploypkg.Manifest, error) { } mfst.Runtime.ImageAuth = deploypkg.ImageAuthSpec{Auto: true} } - minReplicas, _ := f.GetInt("min-replicas") + // minReplicas was removed from the runtime API — the platform owns the + // floor (always 1). The flag stays (hidden, deprecated) for old scripts. + if f.Changed("min-replicas") { + fmt.Fprintln(os.Stderr, "warning: --min-replicas is deprecated and ignored; the platform owns the replica floor (always 1)") + } maxReplicas, _ := f.GetInt("max-replicas") cpuUtil, _ := f.GetInt("cpu-utilization") memUtil, _ := f.GetInt("memory-utilization") mfst.Runtime.Autoscaling = deploypkg.ManifestAutoscale{ - MinReplicas: minReplicas, MaxReplicas: maxReplicas, CPUUtilization: cpuUtil, MemoryUtilization: memUtil, } - if mfst.Runtime.Autoscaling.MaxReplicas < mfst.Runtime.Autoscaling.MinReplicas { - return nil, fmt.Errorf("--max-replicas (%d) must be >= --min-replicas (%d)", - mfst.Runtime.Autoscaling.MaxReplicas, mfst.Runtime.Autoscaling.MinReplicas) + if mfst.Runtime.Autoscaling.MaxReplicas < 1 { + return nil, fmt.Errorf("--max-replicas (%d) must be >= 1", mfst.Runtime.Autoscaling.MaxReplicas) } if strategies, _ := f.GetStringArray("memory-strategy"); len(strategies) > 0 { mem := &deploypkg.MemorySpec{} @@ -840,7 +841,8 @@ func init() { up.Flags().StringArray("command", nil, "Container entrypoint element (repeatable)") up.Flags().StringArray("args", nil, "Container arg (repeatable)") up.Flags().StringArray("env", nil, "Environment variable KEY=VALUE (repeatable)") - up.Flags().Int("min-replicas", 1, "Autoscaling min replicas") + up.Flags().Int("min-replicas", 1, "Deprecated: ignored; the platform owns the replica floor (always 1)") + _ = up.Flags().MarkDeprecated("min-replicas", "minReplicas is platform-owned (always 1); the flag is ignored") up.Flags().Int("max-replicas", 2, "Autoscaling max replicas") up.Flags().Int("cpu-utilization", 70, "Autoscaling target CPU utilization (10-90)") up.Flags().Int("memory-utilization", 70, "Autoscaling target memory utilization (10-90)") diff --git a/go/cmd/agentbase/gateway.go b/go/cmd/agentbase/gateway.go index dea5cd7..aa5f259 100644 --- a/go/cmd/agentbase/gateway.go +++ b/go/cmd/agentbase/gateway.go @@ -41,7 +41,12 @@ path, generate a template, fill it in, and apply it with --file: grn agentbase gateway create --file gw.yaml grn agentbase gateway wait my-gateway -gateways share the ~/.greennode profile like the rest of agentbase.`, +gateways share the ~/.greennode profile like the rest of agentbase. + +Besides the lifecycle, the group reads the connector catalog: +'mcp-connectors' lists/gets the MCP servers attached across your gateways +(with ready connectUrls), 'mcp-server-templates' lists the template catalog, +and 'target tools'/'target sync' read or refresh a target's tools/list.`, } // newGatewayClient mirrors newIdentityClient: resolve the shared profile + @@ -833,6 +838,30 @@ func init() { // service-account / repair gatewayServiceAccountCmd.AddCommand(gatewayServiceAccountRepairCmd) gatewayCmd.AddCommand(gatewayServiceAccountCmd) + + // mcp-connectors + gatewayMcpConnectorsListCmd.Flags().String("gateway-name", "", "Filter by parent gateway name") + gatewayMcpConnectorsListCmd.Flags().String("state", "", "Filter by parent gateway state (e.g. ACTIVE)") + gatewayMcpConnectorsListCmd.Flags().Int("page", 1, "Page number (1-based)") + gatewayMcpConnectorsListCmd.Flags().Int("size", 50, "Page size (max 100)") + gatewayMcpConnectorsCmd.AddCommand(gatewayMcpConnectorsListCmd) + gatewayMcpConnectorsCmd.AddCommand(gatewayMcpConnectorsGetCmd) + gatewayCmd.AddCommand(gatewayMcpConnectorsCmd) + + // mcp-server-templates + gatewayMcpServerTemplatesListCmd.Flags().String("display-name", "", "Filter by display name (substring)") + gatewayMcpServerTemplatesListCmd.Flags().String("vendor", "", "Filter by vendor (substring)") + gatewayMcpServerTemplatesListCmd.Flags().String("id", "", "Filter by template id (substring)") + gatewayMcpServerTemplatesListCmd.Flags().Int("page", 1, "Page number (1-based)") + gatewayMcpServerTemplatesListCmd.Flags().Int("size", 50, "Page size (max 100)") + gatewayMcpServerTemplatesCmd.AddCommand(gatewayMcpServerTemplatesListCmd) + gatewayCmd.AddCommand(gatewayMcpServerTemplatesCmd) + + // target (tools + sync) + gatewayTargetSyncCmd.Flags().String("return-to", "", "URL the gateway redirects the browser to after interactive sync (optional)") + gatewayTargetCmd.AddCommand(gatewayTargetToolsCmd) + gatewayTargetCmd.AddCommand(gatewayTargetSyncCmd) + gatewayCmd.AddCommand(gatewayTargetCmd) } // gatewayCreateName holds the --name value for create (the other create flags @@ -1251,3 +1280,257 @@ the refreshed gateway.`, return output.PrintResource(gw, func() string { return gw.Name }, func() error { return renderGatewayDetail(gw) }) }, } + +// --------------------------------------------------------------------------- +// mcp-connectors / mcp-server-templates / target (tools + sync) +// --------------------------------------------------------------------------- + +// gatewayMcpConnectorsCmd groups the mcp-connectors read commands (every +// target flattened across the caller's gateways, with ready connect URLs). +var gatewayMcpConnectorsCmd = &cobra.Command{ + Use: "mcp-connectors", + Short: "List MCP connectors (targets across gateways)", +} + +var gatewayMcpConnectorsListCmd = &cobra.Command{ + Use: "list", + Short: "List MCP connectors", + Long: `List every MCP connector (target) attached across your gateways, +flattened into one list (GET /api/v1/mcp-connectors). Each item carries a +ready-to-use connectUrl, the derived template type (or CUSTOM), the upstream +outbound-auth descriptor, and parent-gateway context including inbound-auth +mode. Filter with --gateway-name and --state.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + f := cmd.Flags() + gatewayName, _ := f.GetString("gateway-name") + state, _ := f.GetString("state") + page, _ := f.GetInt("page") + size, _ := f.GetInt("size") + client, err := newGatewayClient(ctx, cmd) + if err != nil { + return err + } + resp, err := client.ListMCPConnectors(ctx, gatewayName, state, page, size) + if err != nil { + return err + } + if output.GetFormat() == output.FormatJSON { + return output.JSON(resp) + } + if len(resp.Items) == 0 { + fmt.Fprintln(os.Stderr, "No MCP connectors found.") + return nil + } + rows := make([][]string, 0, len(resp.Items)) + for i := range resp.Items { + it := resp.Items[i] + rows = append(rows, []string{ + it.Name, it.Type, it.Gateway.Name, it.Gateway.State, + output.StrOrDash(it.Template.DisplayName), + mcpConnectorAuthStr(it.OutboundAuth), + truncate(it.ConnectURL, 60), + }) + } + output.Table([]string{"Name", "Type", "Gateway", "State", "Template", "Outbound Auth", "Connect URL"}, rows) + p := resp.Pagination + fmt.Fprintf(os.Stderr, "Page %d (size %d, %d total)\n", p.Page, p.PageSize, p.TotalItems) + return nil + }, +} + +// mcpConnectorAuthStr renders a connector's outbound-auth descriptor for a +// table cell (e.g. "OAUTH/3LO github" or "NONE"). +func mcpConnectorAuthStr(a gatewaypkg.MCPConnectorOutboundAuthDTO) string { + if a.Type == "" { + return "-" + } + s := a.Type + if a.Flow != "" { + s += "/" + a.Flow + } + if a.Provider != "" { + s += " " + a.Provider + } + return s +} + +var gatewayMcpConnectorsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one MCP connector", + Long: `Get a single MCP connector (one target on one caller-owned gateway) +by composite key (GET /api/v1/mcp-connectors/{gatewayName}/{targetName}). +Ownership is caller-scoped, so another user's gateway looks like a 404.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newGatewayClient(ctx, cmd) + if err != nil { + return err + } + conn, err := client.GetMCPConnector(ctx, args[0], args[1]) + if err != nil { + return err + } + if output.GetFormat() == output.FormatJSON { + return output.JSON(conn) + } + rows := [][]string{ + {"Name", conn.Name}, + {"Type", conn.Type}, + {"Connect URL", conn.ConnectURL}, + {"Template", output.StrOrDash(conn.Template.DisplayName)}, + {"Template ID", output.StrOrDash(conn.Template.ID)}, + {"Gateway", conn.Gateway.Name}, + {"Gateway State", conn.Gateway.State}, + {"Gateway Network", conn.Gateway.NetworkMode}, + {"Gateway Endpoint", conn.Gateway.Endpoint}, + {"Inbound Auth", conn.Gateway.InboundAuth.Mode}, + {"Outbound Auth", mcpConnectorAuthStr(conn.OutboundAuth)}, + {"Outbound Scopes", strings.Join(conn.OutboundAuth.Scopes, ",")}, + {"Created At", output.StrOrDash(conn.CreatedAt)}, + {"Updated At", output.StrOrDash(conn.UpdatedAt)}, + } + output.Table([]string{"Field", "Value"}, rows) + return nil + }, +} + +// gatewayMcpServerTemplatesCmd groups the MCP server template catalog reads. +var gatewayMcpServerTemplatesCmd = &cobra.Command{ + Use: "mcp-server-templates", + Short: "List MCP server templates", +} + +var gatewayMcpServerTemplatesListCmd = &cobra.Command{ + Use: "list", + Short: "List MCP server templates", + Long: `List enabled MCP server templates from the catalog (GET +/api/v1/mcp-server-templates). Each item carries auth options whose +recommended* fields pre-fill a target-creation form; edits go through the +gateway update path. displayName/vendor/id are case-insensitive substring +filters that AND together.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + f := cmd.Flags() + displayName, _ := f.GetString("display-name") + vendor, _ := f.GetString("vendor") + id, _ := f.GetString("id") + page, _ := f.GetInt("page") + size, _ := f.GetInt("size") + client, err := newGatewayClient(ctx, cmd) + if err != nil { + return err + } + resp, err := client.ListMCPServerTemplates(ctx, displayName, vendor, id, page, size) + if err != nil { + return err + } + if output.GetFormat() == output.FormatJSON { + return output.JSON(resp) + } + if len(resp.Items) == 0 { + fmt.Fprintln(os.Stderr, "No MCP server templates found.") + return nil + } + rows := make([][]string, 0, len(resp.Items)) + for i := range resp.Items { + it := resp.Items[i] + rows = append(rows, []string{ + it.ID, it.DisplayName, output.StrOrDash(it.Vendor), + output.StrOrDash(it.AuthVendor), it.Endpoint, + strconv.Itoa(len(it.AuthOptions)), + }) + } + output.Table([]string{"ID", "Display Name", "Vendor", "Auth Vendor", "Endpoint", "Auth Options"}, rows) + p := resp.Pagination + fmt.Fprintf(os.Stderr, "Page %d (size %d, %d total)\n", p.Page, p.PageSize, p.TotalItems) + return nil + }, +} + +// gatewayTargetCmd groups per-target operations (last-synced tools, sync). +var gatewayTargetCmd = &cobra.Command{ + Use: "target", + Short: "Target tools on a gateway", +} + +var gatewayTargetToolsCmd = &cobra.Command{ + Use: "tools ", + Short: "Get a target's last-synced tools", + Long: `Get a target's last-synced tools/list (GET +/api/v1/gateways/{name}/targets/{target}/tools). This is the persisted +snapshot; refresh it with 'target sync'.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + client, err := newGatewayClient(ctx, cmd) + if err != nil { + return err + } + resp, err := client.TargetTools(ctx, args[0], args[1]) + if err != nil { + return err + } + return renderSyncResponse(resp) + }, +} + +var gatewayTargetSyncCmd = &cobra.Command{ + Use: "sync ", + Short: "Sync a target's tools/list", + Long: `Fetch the live tools/list from the target's MCP endpoint and persist +it (POST /api/v1/gateways/{name}/targets/{target}/sync). + +Non-interactive auth (NONE/2LO, or 3LO with cached consent) returns the tools +directly. Interactive auth (3LO without consent, INBOUND_FORWARD) returns +status=authorization_required plus an authorizeUrl — open it in a browser; on +completion the gateway finishes tools/list server-side and redirects to +--return-to (or a plain done page).`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + returnTo, _ := cmd.Flags().GetString("return-to") + client, err := newGatewayClient(ctx, cmd) + if err != nil { + return err + } + resp, err := client.SyncTarget(ctx, args[0], args[1], &gatewaypkg.SyncRequest{ReturnTo: returnTo}) + if err != nil { + return err + } + return renderSyncResponse(resp) + }, +} + +// renderSyncResponse prints a tools/sync response: the status line, then a +// tools table (or the authorizeUrl hint for authorization_required). +func renderSyncResponse(resp *gatewaypkg.SyncResponse) error { + if output.GetFormat() == output.FormatJSON { + return output.JSON(resp) + } + rows := [][]string{ + {"Status", output.StrOrDash(resp.Status)}, + {"Tool Count", strconv.Itoa(resp.ToolCount)}, + {"Last Synced At", output.StrOrDash(resp.LastSyncedAt)}, + {"Sync Session", output.StrOrDash(resp.SyncSessionID)}, + {"Error", output.StrOrDash(resp.Error)}, + } + output.Table([]string{"Field", "Value"}, rows) + if resp.AuthorizeURL != "" { + fmt.Fprintf(os.Stderr, "Authorization required — open this URL in a browser to finish the sync:\n %s\n", resp.AuthorizeURL) + } + if len(resp.Tools) == 0 { + fmt.Fprintln(os.Stderr, "No tools in the synced list.") + return nil + } + trows := make([][]string, 0, len(resp.Tools)) + for i := range resp.Tools { + t := resp.Tools[i] + trows = append(trows, []string{ + t.Name, output.StrOrDash(t.Title), truncate(t.Description, 60), + }) + } + output.Table([]string{"Tool", "Title", "Description"}, trows) + return nil +} diff --git a/go/cmd/agentbase/runtime.go b/go/cmd/agentbase/runtime.go index 191857c..78a33d6 100644 --- a/go/cmd/agentbase/runtime.go +++ b/go/cmd/agentbase/runtime.go @@ -18,8 +18,8 @@ import ( // runtimeCmd groups the agent-runtime lifecycle commands. The agentbase /runtime // endpoint fronts the agent-core-runtime REST API (POST/GET/PATCH/DELETE -// /agent-runtimes — note: bare paths, no /api/v1). An agent runtime is a -// deployable container (image/command/args/env/autoscaling); it converges +// /v1/agent-runtimes). An agent runtime is a deployable container +// (image/command/args/env/network/inbound-auth/autoscaling); it converges // asynchronously (CREATING → ACTIVE), so create/update/delete return immediately // and `wait` polls to a terminal state. // @@ -67,20 +67,27 @@ func newRuntimeClient(ctx context.Context, cmd *cobra.Command) (*runtimepkg.Clie // --------------------------------------------------------------------------- // runtimeSpec holds the mutable spec fields common to create and update. +// networkConfig/inboundAuth/protocol/allowedCidrs are only populated when the +// corresponding flags are set (they are optional server-side; omitting them +// keeps the platform defaults: public network, no inbound auth, HTTP). type runtimeSpec struct { - Description string - ImageURL string - ImageAuth *runtimepkg.ImageAuth - Command []string - Args []string - Env map[string]string - FlavorID string - Autoscaling runtimepkg.Autoscaling + Description string + ImageURL string + ImageAuth *runtimepkg.ImageAuth + Command []string + Args []string + Env map[string]string + Network *runtimepkg.NetworkConfig + AllowedCidrs []string + Inbound *runtimepkg.InboundAuth + Protocol string + FlavorID string + Autoscaling runtimepkg.Autoscaling } -// readRuntimeSpec reads + prompts the mutable spec fields. name is handled by -// the caller (create-only). Required (@NotEmpty) fields: imageUrl, flavorId; -// autoscaling has flag defaults so it is always populated. +// readRuntimeSpec reads + validates the mutable spec fields. name and poc are +// create-only and handled by the caller. Required (@NotEmpty) fields: +// imageUrl, flavorId; autoscaling has flag defaults so it is always populated. func readRuntimeSpec(cmd *cobra.Command) (*runtimeSpec, error) { f := cmd.Flags() @@ -107,19 +114,35 @@ func readRuntimeSpec(cmd *cobra.Command) (*runtimeSpec, error) { return nil, err } - minReplicas, _ := f.GetInt("min-replicas") + // minReplicas was removed from the API — the platform owns the floor (1). + // The flag stays registered (hidden, deprecated) so old scripts only warn. + if f.Changed("min-replicas") { + fmt.Fprintln(os.Stderr, "warning: --min-replicas is deprecated and ignored; the platform owns the replica floor (always 1)") + } maxReplicas, _ := f.GetInt("max-replicas") cpuUtil, _ := f.GetInt("cpu-utilization") memUtil, _ := f.GetInt("memory-utilization") autoscaling := runtimepkg.Autoscaling{ - MinReplicas: minReplicas, MaxReplicas: maxReplicas, CPUUtilization: cpuUtil, MemoryUtilization: memUtil, } - if autoscaling.MaxReplicas < autoscaling.MinReplicas { - return nil, fmt.Errorf("--max-replicas (%d) must be >= --min-replicas (%d)", - autoscaling.MaxReplicas, autoscaling.MinReplicas) + if autoscaling.MaxReplicas < 1 { + return nil, fmt.Errorf("--max-replicas (%d) must be >= 1", autoscaling.MaxReplicas) + } + + network, err := readNetworkConfig(cmd) + if err != nil { + return nil, err + } + allowedCidrs, _ := f.GetStringArray("allowed-cidrs") + inbound, err := readInboundAuth(cmd) + if err != nil { + return nil, err + } + protocol, _ := f.GetString("protocol") + if protocol != "" && protocol != "HTTP" && protocol != "MCP" { + return nil, fmt.Errorf("--protocol must be HTTP or MCP (got %q)", protocol) } imageAuth, err := readImageAuth(cmd) @@ -128,30 +151,131 @@ func readRuntimeSpec(cmd *cobra.Command) (*runtimeSpec, error) { } return &runtimeSpec{ - Description: description, - ImageURL: imageURL, - ImageAuth: imageAuth, - Command: command, - Args: args, - Env: env, - FlavorID: flavorID, - Autoscaling: autoscaling, + Description: description, + ImageURL: imageURL, + ImageAuth: imageAuth, + Command: command, + Args: args, + Env: env, + Network: network, + AllowedCidrs: allowedCidrs, + Inbound: inbound, + Protocol: protocol, + FlavorID: flavorID, + Autoscaling: autoscaling, + }, nil +} + +// readNetworkConfig builds *NetworkConfig only when --network-mode is set. +// PUBLIC is the platform default (endpoints on the public internet); VPC +// attaches endpoints to a VPC/subnet (vpcId and subnetId are then required; +// routeCidrs adds egress routes). +func readNetworkConfig(cmd *cobra.Command) (*runtimepkg.NetworkConfig, error) { + f := cmd.Flags() + mode, _ := f.GetString("network-mode") + if mode == "" { + return nil, nil + } + if mode != "PUBLIC" && mode != "VPC" { + return nil, fmt.Errorf("--network-mode must be PUBLIC or VPC (got %q)", mode) + } + vpcID, _ := f.GetString("vpc-id") + subnetID, _ := f.GetString("subnet-id") + routeCidrs, _ := f.GetStringArray("route-cidrs") + if mode == "VPC" && (vpcID == "" || subnetID == "") { + return nil, fmt.Errorf("--network-mode VPC requires both --vpc-id and --subnet-id") + } + return &runtimepkg.NetworkConfig{ + Mode: mode, + VpcID: vpcID, + SubnetID: subnetID, + RouteCidrs: routeCidrs, + }, nil +} + +// readInboundAuth builds *InboundAuth only when --inbound-mode is set. NONE is +// open, IAM authenticates callers via the platform IAM, JWT validates caller +// JWTs (--inbound-jwt-* then required: DISCOVERY needs --inbound-jwt-discovery-url, +// JWKS needs --inbound-jwt-jwks). +func readInboundAuth(cmd *cobra.Command) (*runtimepkg.InboundAuth, error) { + f := cmd.Flags() + mode, _ := f.GetString("inbound-mode") + if mode == "" { + return nil, nil + } + if mode != "NONE" && mode != "IAM" && mode != "JWT" { + return nil, fmt.Errorf("--inbound-mode must be NONE, IAM or JWT (got %q)", mode) + } + if mode != "JWT" { + for _, name := range []string{"inbound-jwt-source", "inbound-jwt-jwks", "inbound-jwt-discovery-url", "inbound-jwt-audience", "inbound-jwt-client", "inbound-jwt-scope", "inbound-jwt-principal-claim"} { + if f.Changed(name) { + return nil, fmt.Errorf("--%s only applies when --inbound-mode is JWT", name) + } + } + return &runtimepkg.InboundAuth{Mode: mode}, nil + } + source, _ := f.GetString("inbound-jwt-source") + if source == "" { + return nil, fmt.Errorf("--inbound-mode JWT requires --inbound-jwt-source (DISCOVERY or JWKS)") + } + if source != "DISCOVERY" && source != "JWKS" { + return nil, fmt.Errorf("--inbound-jwt-source must be DISCOVERY or JWKS (got %q)", source) + } + jwksRaw, _ := f.GetString("inbound-jwt-jwks") + discoveryURL, _ := f.GetString("inbound-jwt-discovery-url") + switch source { + case "DISCOVERY": + if discoveryURL == "" { + return nil, fmt.Errorf("--inbound-jwt-source DISCOVERY requires --inbound-jwt-discovery-url") + } + case "JWKS": + if jwksRaw == "" { + return nil, fmt.Errorf("--inbound-jwt-source JWKS requires --inbound-jwt-jwks (the key set JSON, max 32KiB)") + } + if !json.Valid([]byte(jwksRaw)) { + return nil, fmt.Errorf("--inbound-jwt-jwks must be a valid JSON key set") + } + if len(jwksRaw) > 32*1024 { + return nil, fmt.Errorf("--inbound-jwt-jwks is %d bytes; the API caps it at 32KiB (prefer DISCOVERY)", len(jwksRaw)) + } + } + audiences, _ := f.GetStringArray("inbound-jwt-audience") + clients, _ := f.GetStringArray("inbound-jwt-client") + scopes, _ := f.GetStringArray("inbound-jwt-scope") + principalClaim, _ := f.GetString("inbound-jwt-principal-claim") + return &runtimepkg.InboundAuth{ + Mode: mode, + JWT: &runtimepkg.InboundAuthJWT{ + Source: source, + JWKS: json.RawMessage(jwksRaw), + DiscoveryURL: discoveryURL, + AllowedAudiences: audiences, + AllowedClients: clients, + AllowedScopes: scopes, + PrincipalClaim: principalClaim, + }, }, nil } // readImageAuth builds *ImageAuth only when --image-auth-enabled is set (or a // username is supplied). username/password are required when auth is enabled. -// The password is send-only (never present on responses); prefer --file for it. +// --image-auth-use-registry-credentials instead lets the platform pull with +// its own credentials (no username/password needed). The password is send-only +// (never present on responses); prefer --file for it. func readImageAuth(cmd *cobra.Command) (*runtimepkg.ImageAuth, error) { f := cmd.Flags() enabled, _ := f.GetBool("image-auth-enabled") + useRegistry, _ := f.GetBool("image-auth-use-registry-credentials") username, _ := f.GetString("image-auth-username") password, _ := f.GetString("image-auth-password") + if useRegistry { + return &runtimepkg.ImageAuth{Enabled: true, UseAgentBaseRegistryCredentials: &useRegistry}, nil + } if !enabled && username == "" { return nil, nil } if username == "" || password == "" { - return nil, fmt.Errorf("--image-auth-enabled requires both --image-auth-username and --image-auth-password (or use --file)") + return nil, fmt.Errorf("--image-auth-enabled requires both --image-auth-username and --image-auth-password (or --image-auth-use-registry-credentials, or use --file)") } return &runtimepkg.ImageAuth{Enabled: enabled, Username: username, Password: password}, nil } @@ -183,8 +307,9 @@ var runtimeCreateCmd = &cobra.Command{ Long: `Create a new agent runtime. By default the runtime is built from flags (the simple path). For environment -variables, private-registry auth, or multi-element command/args, use --file with -a template produced by 'grn agentbase runtime generate'. +variables, private-registry auth, network placement (VPC), inbound auth (IAM or +JWT), a CIDR allowlist, an MCP protocol runtime, or multi-element command/args, +use --file with a template produced by 'grn agentbase runtime generate'. The runtime is created asynchronously; this command returns as soon as the service accepts the spec (state CREATING). Converge with @@ -196,7 +321,7 @@ service accepts the spec (state CREATING). Converge with if err != nil { return fmt.Errorf("read --file: %w", err) } - req, err := loadRuntimeSpec(data) + req, err := loadRuntimeSpec(data, true) if err != nil { return err } @@ -211,17 +336,26 @@ service accepts the spec (state CREATING). Converge with if err != nil { return err } - return createRuntimeAndPrint(ctx, cmd, &runtimepkg.CreateAgentRuntimeRequest{ - Name: name, + req := &runtimepkg.CreateAgentRuntimeRequest{ Description: spec.Description, ImageURL: spec.ImageURL, ImageAuth: spec.ImageAuth, Command: spec.Command, Args: spec.Args, EnvironmentVariables: spec.Env, + NetworkConfig: spec.Network, + AllowedCidrs: spec.AllowedCidrs, + InboundAuth: spec.Inbound, + Protocol: spec.Protocol, FlavorID: spec.FlavorID, Autoscaling: spec.Autoscaling, - }) + } + // poc is create-only: send it only when the flag was explicitly set. + if cmd.Flags().Changed("poc") { + poc, _ := cmd.Flags().GetBool("poc") + req.POC = &poc + } + return createRuntimeAndPrint(ctx, cmd, req) }, } @@ -259,8 +393,12 @@ Defaults to YAML (with comments); pass -o json for a JSON skeleton.`, Command: []string{}, Args: []string{}, EnvironmentVariables: map[string]string{}, + NetworkConfig: &runtimepkg.NetworkConfig{Mode: "PUBLIC"}, + AllowedCidrs: []string{}, + InboundAuth: &runtimepkg.InboundAuth{Mode: "NONE"}, + Protocol: "HTTP", FlavorID: "fill-flavor-id", - Autoscaling: runtimepkg.Autoscaling{MinReplicas: 1, MaxReplicas: 2, CPUUtilization: 70, MemoryUtilization: 70}, + Autoscaling: runtimepkg.Autoscaling{MaxReplicas: 2, CPUUtilization: 70, MemoryUtilization: 70}, } b, err := json.MarshalIndent(example, "", " ") if err != nil { @@ -281,12 +419,14 @@ const runtimeCreateTemplateYAML = `# Agent runtime create spec. # Fill in and apply with: grn agentbase runtime create --file # # Required: name, imageUrl, flavorId, autoscaling (and command/args/env, which -# may be empty lists/map but must be present). name is sealed (immutable). +# may be empty lists/map but must be present). name is sealed (immutable); +# 5-50 chars, [a-zA-Z0-9._-]. name: my-runtime # immutable description: "" imageUrl: registry.example.com/my-agent:latest # imageAuth: # optional private-registry credentials (send-only) # enabled: true +# useAgentBaseRegistryCredentials: false # let the platform pull with its own registry credentials # username: "" # password: "" # prefer keeping this in the file, not on the CLI command: # container entrypoint (list); may be empty @@ -296,9 +436,28 @@ command: # container entrypoint (list); may be empty args: [] # container args (list); may be empty environmentVariables: # map; may be empty LOG_LEVEL: info +# networkConfig: # optional network placement (omit = PUBLIC) +# mode: VPC # PUBLIC | VPC (VPC requires vpcId + subnetId) +# vpcId: "" +# subnetId: "" +# routeCidrs: [] # egress routes (max 20) +# allowedCidrs: # optional inbound CIDR allowlist (max 20) +# - 10.0.0.0/8 +# inboundAuth: # optional inbound authentication (omit = NONE) +# mode: JWT # NONE | IAM | JWT +# jwt: # required when mode is JWT +# source: DISCOVERY # DISCOVERY | JWKS +# discoveryUrl: https://idp.example.com/.well-known/openid-configuration +# # jwks: '{"keys": []}' # inline key set when source is JWKS (max 32KiB) +# allowedAudiences: [] # max 20 +# allowedClients: [] # max 20 +# allowedScopes: [] # max 50 +# principalClaim: sub +protocol: HTTP # HTTP | MCP (omit = HTTP) flavorId: fill-flavor-id -autoscaling: # HPA config (bounds: replicas 1-10, util 10-90) - minReplicas: 1 +poc: false # proof-of-concept runtime (create-only) +autoscaling: # HPA config (bounds: maxReplicas 1-10, util 10-90; + # minReplicas is platform-owned and no longer accepted) maxReplicas: 2 cpuUtilization: 70 memoryUtilization: 70 @@ -398,10 +557,12 @@ For anything beyond the simple path, use --file with the create template if err != nil { return fmt.Errorf("read --file: %w", err) } - createReq, err := loadRuntimeSpec(data) + createReq, err := loadRuntimeSpec(data, false) if err != nil { return err } + // poc is create-only: a spec file carrying poc is accepted for + // convenience but the field is not forwarded on update. return updateRuntimeAndPrint(ctx, cmd, id, &runtimepkg.UpdateAgentRuntimeRequest{ Description: createReq.Description, ImageURL: createReq.ImageURL, @@ -409,6 +570,10 @@ For anything beyond the simple path, use --file with the create template Command: createReq.Command, Args: createReq.Args, EnvironmentVariables: createReq.EnvironmentVariables, + NetworkConfig: createReq.NetworkConfig, + AllowedCidrs: createReq.AllowedCidrs, + InboundAuth: createReq.InboundAuth, + Protocol: createReq.Protocol, FlavorID: createReq.FlavorID, Autoscaling: createReq.Autoscaling, }) @@ -424,6 +589,10 @@ For anything beyond the simple path, use --file with the create template Command: spec.Command, Args: spec.Args, EnvironmentVariables: spec.Env, + NetworkConfig: spec.Network, + AllowedCidrs: spec.AllowedCidrs, + InboundAuth: spec.Inbound, + Protocol: spec.Protocol, FlavorID: spec.FlavorID, Autoscaling: spec.Autoscaling, }) @@ -542,12 +711,18 @@ var ( // --------------------------------------------------------------------------- func renderRuntimeDetail(rt *runtimepkg.AgentRuntime) error { + poc := "" + if rt.POC != nil { + poc = fmt.Sprintf("%t", *rt.POC) + } rows := [][]string{ {"ID", rt.ID}, {"Name", rt.Name}, {"Description", output.StrOrDash(rt.Description)}, {"State", rt.Status}, + {"Display Status", output.StrOrDash(rt.DisplayStatus)}, {"Status Reason", output.StrOrDash(rt.StatusReason)}, + {"PoC", output.StrOrDash(poc)}, {"Created", formatTimeVal(rt.CreatedAt)}, {"Updated", formatTimeVal(rt.UpdatedAt)}, } @@ -562,8 +737,9 @@ func renderRuntimeDetail(rt *runtimepkg.AgentRuntime) error { // loadRuntimeSpec parses a YAML/JSON create spec into CreateAgentRuntimeRequest. // yaml.Unmarshal into a map then json.Unmarshal into the struct so the file's // camelCase keys bind to the struct's json tags (yaml.v3 does not honor json -// tags directly). Used by both create (--file) and update (--file, minus name). -func loadRuntimeSpec(data []byte) (*runtimepkg.CreateAgentRuntimeRequest, error) { +// tags directly). Used by both create (--file, name required) and update +// (--file, name omitted/ignored: the create template minus name works as-is). +func loadRuntimeSpec(data []byte, requireName bool) (*runtimepkg.CreateAgentRuntimeRequest, error) { m, err := yamlToMap(data) if err != nil { return nil, err @@ -576,8 +752,11 @@ func loadRuntimeSpec(data []byte) (*runtimepkg.CreateAgentRuntimeRequest, error) if err := json.Unmarshal(jb, &req); err != nil { return nil, fmt.Errorf("invalid runtime spec: %w", err) } - if req.Name == "" || req.ImageURL == "" || req.FlavorID == "" { - return nil, fmt.Errorf("spec is missing required field(s): name, imageUrl, flavorId") + if req.ImageURL == "" || req.FlavorID == "" { + return nil, fmt.Errorf("spec is missing required field(s): imageUrl, flavorId") + } + if requireName && req.Name == "" { + return nil, fmt.Errorf("spec is missing required field: name") } return &req, nil } @@ -1014,6 +1193,7 @@ func renderEndpointDetail(ep *runtimepkg.AgentRuntimeEndpointDto) error { {"Live Version", fmt.Sprintf("%d", ep.LiveVersion)}, {"Replicas", fmt.Sprintf("%d", ep.CurrentReplicaCount)}, {"URL", output.StrOrDash(ep.URL)}, + {"Outbound IPs", output.StrOrDash(strings.Join(ep.OutboundIps, ", "))}, {"Status", ep.Status}, {"Display Status", ep.DisplayStatus}, {"Created", formatTimeVal(ep.CreatedAt)}, @@ -1103,7 +1283,8 @@ func init() { // create create := runtimeCreateCmd addRuntimeSpecFlags(create) - create.Flags().StringVarP(&runtimeName, "name", "n", "", "Runtime name (required without --interactive, immutable)") + create.Flags().StringVarP(&runtimeName, "name", "n", "", "Runtime name (required without --interactive, immutable; 5-50 chars)") + create.Flags().Bool("poc", false, "Mark as a proof-of-concept runtime (create-only)") create.Flags().StringVar(&runtimeFile, "file", "", "Apply a spec file (see 'generate'); authoritative when set") runtimeCmd.AddCommand(create) @@ -1210,8 +1391,10 @@ func addLogFlags(cmd *cobra.Command) { } // addRuntimeSpecFlags registers the mutable spec flags shared by create and -// update (everything except --name). autoscaling has flag defaults so it is -// always populated without prompting. +// update (everything except --name and --poc, which are create-only). +// autoscaling has flag defaults so it is always populated without prompting. +// The network/inbound flags stay unset by default (platform defaults: public +// network, no inbound auth, HTTP). func addRuntimeSpecFlags(cmd *cobra.Command) { f := cmd.Flags() f.String("description", "", "Description") @@ -1219,14 +1402,31 @@ func addRuntimeSpecFlags(cmd *cobra.Command) { f.Bool("image-auth-enabled", false, "Enable private-registry auth (requires --image-auth-username/password)") f.String("image-auth-username", "", "Private-registry username (with --image-auth-enabled)") f.String("image-auth-password", "", "Private-registry password (with --image-auth-enabled; prefer --file)") + f.Bool("image-auth-use-registry-credentials", false, "imageAuth: let the platform pull using its own registry credentials") f.StringArray("command", nil, "Container entrypoint element (repeatable)") f.StringArray("args", nil, "Container arg (repeatable)") f.StringArray("env", nil, "Environment variable KEY=VALUE (repeatable)") + f.String("network-mode", "", "networkConfig mode: PUBLIC or VPC (unset = PUBLIC)") + f.String("vpc-id", "", "networkConfig vpcId (required with --network-mode VPC)") + f.String("subnet-id", "", "networkConfig subnetId (required with --network-mode VPC)") + f.StringArray("route-cidrs", nil, "networkConfig egress route CIDR (repeatable, max 20)") + f.StringArray("allowed-cidrs", nil, "Inbound CIDR allowlist (repeatable, max 20)") + f.String("inbound-mode", "", "inboundAuth mode: NONE, IAM or JWT (unset = NONE)") + f.String("inbound-jwt-source", "", "inboundAuth JWT key source: DISCOVERY or JWKS (with --inbound-mode JWT)") + f.String("inbound-jwt-jwks", "", "inboundAuth JWT inline key set JSON (with source JWKS, max 32KiB; prefer --file)") + f.String("inbound-jwt-discovery-url", "", "inboundAuth JWT OIDC discovery URL (with source DISCOVERY)") + f.StringArray("inbound-jwt-audience", nil, "inboundAuth JWT allowed audience (repeatable, max 20)") + f.StringArray("inbound-jwt-client", nil, "inboundAuth JWT allowed client (repeatable, max 20)") + f.StringArray("inbound-jwt-scope", nil, "inboundAuth JWT allowed scope (repeatable, max 50)") + f.String("inbound-jwt-principal-claim", "", "inboundAuth JWT principal claim (default sub)") + f.String("protocol", "", "Runtime protocol: HTTP or MCP (unset = HTTP)") f.String("flavor-id", "", "Flavor id (required)") - f.Int("min-replicas", 1, "Autoscaling: minimum replicas (1-10)") + f.Int("min-replicas", 1, "Deprecated: ignored; the platform owns the replica floor (always 1)") f.Int("max-replicas", 2, "Autoscaling: maximum replicas (1-10)") f.Int("cpu-utilization", 70, "Autoscaling: CPU scale-up threshold % (10-90)") f.Int("memory-utilization", 70, "Autoscaling: memory scale-up threshold % (10-90)") + // Hidden + deprecated, not removed, so existing scripts only warn. + _ = f.MarkDeprecated("min-replicas", "minReplicas is platform-owned (always 1); the flag is ignored") } // runtimeName holds the --name value for create. diff --git a/go/cmd/agentbase/runtime_test.go b/go/cmd/agentbase/runtime_test.go index 2d757c4..2b653dc 100644 --- a/go/cmd/agentbase/runtime_test.go +++ b/go/cmd/agentbase/runtime_test.go @@ -34,7 +34,7 @@ func TestRuntimeGenerate_RoundTripsThroughLoadRuntimeSpec(t *testing.T) { t.Fatalf("generate RunE: %v", err) } }) - req, err := loadRuntimeSpec([]byte(template)) + req, err := loadRuntimeSpec([]byte(template), true) if err != nil { t.Fatalf("generated template is not a valid spec: %v", err) } @@ -84,12 +84,11 @@ environmentVariables: FEATURE_X: "true" flavorId: f1 autoscaling: - minReplicas: 1 maxReplicas: 3 cpuUtilization: 65 memoryUtilization: 75 `) - req, err := loadRuntimeSpec(spec) + req, err := loadRuntimeSpec(spec, true) if err != nil { t.Fatalf("loadRuntimeSpec: %v", err) } @@ -108,18 +107,25 @@ autoscaling: if len(req.EnvironmentVariables) != 2 || req.EnvironmentVariables["LOG_LEVEL"] != "info" { t.Errorf("env mismatch: %+v", req.EnvironmentVariables) } - if req.Autoscaling.MinReplicas != 1 || req.Autoscaling.MaxReplicas != 3 || + if req.Autoscaling.MaxReplicas != 3 || req.Autoscaling.CPUUtilization != 65 || req.Autoscaling.MemoryUtilization != 75 { t.Errorf("autoscaling mismatch: %+v", req.Autoscaling) } } -// TestLoadRuntimeSpec_MissingRequiredFails verifies the required-field gate. +// TestLoadRuntimeSpec_MissingRequiredFails verifies the required-field gate: +// imageUrl/flavorId always; name additionally when requireName (create). func TestLoadRuntimeSpec_MissingRequiredFails(t *testing.T) { - _, err := loadRuntimeSpec([]byte(`name: only-name`)) - if err == nil { + if _, err := loadRuntimeSpec([]byte(`name: only-name`), true); err == nil { t.Fatal("expected error for spec missing required fields") } + // update path: name omitted is fine, but imageUrl/flavorId are still required + if _, err := loadRuntimeSpec([]byte(`name: only-name`), false); err == nil { + t.Fatal("expected error for update spec missing imageUrl/flavorId") + } + if _, err := loadRuntimeSpec([]byte("imageUrl: img\nflavorId: f1"), false); err != nil { + t.Fatalf("update spec without name should load: %v", err) + } } // TestParseEnvVars covers the KEY=VALUE flag parser. @@ -189,16 +195,16 @@ func TestReadImageAuth(t *testing.T) { }) } -// TestReadRuntimeSpec_AutoscalingMaxLtMinErrors verifies the max>=min guard. -func TestReadRuntimeSpec_AutoscalingMaxLtMinErrors(t *testing.T) { +// TestReadRuntimeSpec_AutoscalingMaxLtOneErrors verifies the max>=1 guard +// (minReplicas is platform-owned; only the ceiling is configurable). +func TestReadRuntimeSpec_AutoscalingMaxLtOneErrors(t *testing.T) { cliinput.SetInteractive(false) cmd := newSpecCmd(t) mustSet(t, cmd, "image-url", "img") mustSet(t, cmd, "flavor-id", "f") - mustSet(t, cmd, "min-replicas", "3") - mustSet(t, cmd, "max-replicas", "1") // < min + mustSet(t, cmd, "max-replicas", "0") // < 1 if _, err := readRuntimeSpec(cmd); err == nil { - t.Error("expected error when max-replicas < min-replicas") + t.Error("expected error when max-replicas < 1") } } @@ -224,7 +230,7 @@ func TestReadRuntimeSpec_OK(t *testing.T) { if s.Env["K"] != "V" { t.Errorf("env mismatch: %+v", s.Env) } - if s.Autoscaling.MinReplicas != 1 || s.Autoscaling.MaxReplicas != 2 { // flag defaults + if s.Autoscaling.MaxReplicas != 2 { // flag defaults (minReplicas is platform-owned) t.Errorf("autoscaling default mismatch: %+v", s.Autoscaling) } if s.ImageAuth != nil { @@ -235,3 +241,169 @@ func TestReadRuntimeSpec_OK(t *testing.T) { ImageURL: s.ImageURL, FlavorID: s.FlavorID, Autoscaling: s.Autoscaling, } } + +// TestReadNetworkConfig covers the --network-* flag group: unset → nil (the +// platform default PUBLIC), VPC without vpc/subnet → error, VPC happy path. +func TestReadNetworkConfig(t *testing.T) { + t.Run("unset_returns_nil", func(t *testing.T) { + cmd := newSpecCmd(t) + got, err := readNetworkConfig(cmd) + if err != nil || got != nil { + t.Errorf("expected (nil, nil), got (%+v, %v)", got, err) + } + }) + t.Run("vpc_without_ids_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "network-mode", "VPC") + if _, err := readNetworkConfig(cmd); err == nil { + t.Error("expected error when VPC without --vpc-id/--subnet-id") + } + }) + t.Run("vpc_ok", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "network-mode", "VPC") + mustSet(t, cmd, "vpc-id", "vpc-1") + mustSet(t, cmd, "subnet-id", "sub-1") + mustSet(t, cmd, "route-cidrs", "10.0.0.0/8") + got, err := readNetworkConfig(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Mode != "VPC" || got.VpcID != "vpc-1" || got.SubnetID != "sub-1" || len(got.RouteCidrs) != 1 { + t.Errorf("unexpected: %+v", got) + } + }) + t.Run("bad_mode_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "network-mode", "TUNNEL") + if _, err := readNetworkConfig(cmd); err == nil { + t.Error("expected error for bad --network-mode") + } + }) +} + +// TestReadInboundAuth covers the --inbound-* flag group: unset → nil, NONE/IAM +// happy paths, JWT validation matrix (source required; DISCOVERY needs URL; +// JWKS needs valid inline JSON; jwt flags rejected for non-JWT modes). +func TestReadInboundAuth(t *testing.T) { + t.Run("unset_returns_nil", func(t *testing.T) { + cmd := newSpecCmd(t) + got, err := readInboundAuth(cmd) + if err != nil || got != nil { + t.Errorf("expected (nil, nil), got (%+v, %v)", got, err) + } + }) + t.Run("none_ok", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "NONE") + got, err := readInboundAuth(cmd) + if err != nil || got == nil || got.Mode != "NONE" || got.JWT != nil { + t.Errorf("unexpected: (%+v, %v)", got, err) + } + }) + t.Run("jwt_without_source_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "JWT") + if _, err := readInboundAuth(cmd); err == nil { + t.Error("expected error when JWT without --inbound-jwt-source") + } + }) + t.Run("jwt_discovery_without_url_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "JWT") + mustSet(t, cmd, "inbound-jwt-source", "DISCOVERY") + if _, err := readInboundAuth(cmd); err == nil { + t.Error("expected error when DISCOVERY without --inbound-jwt-discovery-url") + } + }) + t.Run("jwt_discovery_ok", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "JWT") + mustSet(t, cmd, "inbound-jwt-source", "DISCOVERY") + mustSet(t, cmd, "inbound-jwt-discovery-url", "https://idp/.well-known/openid-configuration") + mustSet(t, cmd, "inbound-jwt-audience", "aud1") + mustSet(t, cmd, "inbound-jwt-principal-claim", "sub") + got, err := readInboundAuth(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Mode != "JWT" || got.JWT == nil || got.JWT.Source != "DISCOVERY" || + got.JWT.DiscoveryURL == "" || len(got.JWT.AllowedAudiences) != 1 || got.JWT.PrincipalClaim != "sub" { + t.Errorf("unexpected: %+v", got) + } + }) + t.Run("jwt_jwks_invalid_json_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "JWT") + mustSet(t, cmd, "inbound-jwt-source", "JWKS") + mustSet(t, cmd, "inbound-jwt-jwks", "{not-json") + if _, err := readInboundAuth(cmd); err == nil { + t.Error("expected error for invalid JWKS JSON") + } + }) + t.Run("jwt_jwks_ok", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "JWT") + mustSet(t, cmd, "inbound-jwt-source", "JWKS") + mustSet(t, cmd, "inbound-jwt-jwks", `{"keys":[]}`) + got, err := readInboundAuth(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got.JWT.JWKS) != `{"keys":[]}` { + t.Errorf("jwks mismatch: %s", got.JWT.JWKS) + } + }) + t.Run("iam_with_jwt_flags_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "IAM") + mustSet(t, cmd, "inbound-jwt-source", "JWKS") + if _, err := readInboundAuth(cmd); err == nil { + t.Error("expected error when jwt flags set with --inbound-mode IAM") + } + }) +} + +// TestReadRuntimeSpec_NewFieldsAssemble verifies the new spec fields flow +// through readRuntimeSpec into the spec struct (protocol, allowedCidrs, +// network, inbound) and that the deprecated --min-replicas no longer errors. +func TestReadRuntimeSpec_NewFieldsAssemble(t *testing.T) { + cliinput.SetInteractive(false) + cmd := newSpecCmd(t) + mustSet(t, cmd, "image-url", "img:1") + mustSet(t, cmd, "flavor-id", "f1") + mustSet(t, cmd, "protocol", "MCP") + mustSet(t, cmd, "allowed-cidrs", "10.0.0.0/8") + mustSet(t, cmd, "network-mode", "VPC") + mustSet(t, cmd, "vpc-id", "vpc-1") + mustSet(t, cmd, "subnet-id", "sub-1") + mustSet(t, cmd, "inbound-mode", "IAM") + mustSet(t, cmd, "min-replicas", "3") // deprecated: tolerated, ignored + s, err := readRuntimeSpec(cmd) + if err != nil { + t.Fatalf("readRuntimeSpec: %v", err) + } + if s.Protocol != "MCP" || len(s.AllowedCidrs) != 1 || + s.Network == nil || s.Network.Mode != "VPC" || + s.Inbound == nil || s.Inbound.Mode != "IAM" { + t.Errorf("new fields mismatch: %+v", s) + } + // The assembled spec must fit the create request (compile-time shape check). + _ = runtimepkg.CreateAgentRuntimeRequest{ + NetworkConfig: s.Network, AllowedCidrs: s.AllowedCidrs, + InboundAuth: s.Inbound, Protocol: s.Protocol, + } +} + +// TestReadImageAuth_UseRegistryCredentials covers the platform-registry path. +func TestReadImageAuth_UseRegistryCredentials(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "image-auth-use-registry-credentials", "true") + got, err := readImageAuth(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || !got.Enabled || got.UseAgentBaseRegistryCredentials == nil || !*got.UseAgentBaseRegistryCredentials { + t.Errorf("unexpected: %+v", got) + } +} diff --git a/go/internal/agentbase/deploy/models.go b/go/internal/agentbase/deploy/models.go index 29782a0..59754c5 100644 --- a/go/internal/agentbase/deploy/models.go +++ b/go/internal/agentbase/deploy/models.go @@ -65,8 +65,9 @@ type RuntimeSpec struct { } // ManifestAutoscale is the HPA config (bounds enforced server-side). +// minReplicas was removed from the runtime API (the platform owns the floor, +// always 1); the field is tolerated in existing manifests but not forwarded. type ManifestAutoscale struct { - MinReplicas int `json:"minReplicas,omitempty"` MaxReplicas int `json:"maxReplicas,omitempty"` CPUUtilization int `json:"cpuUtilization,omitempty"` MemoryUtilization int `json:"memoryUtilization,omitempty"` diff --git a/go/internal/agentbase/deploy/models_test.go b/go/internal/agentbase/deploy/models_test.go index bc82493..dc7b10a 100644 --- a/go/internal/agentbase/deploy/models_test.go +++ b/go/internal/agentbase/deploy/models_test.go @@ -69,7 +69,7 @@ func TestManifest_Decode(t *testing.T) { "command": ["./agent"], "env": {"LOG_LEVEL": "info"}, "flavorId": "agent.small", - "autoscaling": {"minReplicas": 1, "maxReplicas": 3} + "autoscaling": {"maxReplicas": 3} } }`) var m Manifest diff --git a/go/internal/agentbase/gateway/client.go b/go/internal/agentbase/gateway/client.go index 3f1e7e1..6310d3d 100644 --- a/go/internal/agentbase/gateway/client.go +++ b/go/internal/agentbase/gateway/client.go @@ -216,3 +216,90 @@ func accessLogQueryValues(qy AccessLogQuery, stats bool) url.Values { } return q } + +// ListMCPConnectors returns every MCP connector (target) attached across the +// caller's gateways, flattened into one list. gatewayName and state are +// optional filters; page is 1-based. +func (c *Client) ListMCPConnectors(ctx context.Context, gatewayName, state string, page, pageSize int) (*MCPConnectorListResponse, error) { + q := url.Values{} + if gatewayName != "" { + q.Set("gatewayName", gatewayName) + } + if state != "" { + q.Set("state", state) + } + if page > 0 { + q.Set("page", strconv.Itoa(page)) + } + if pageSize > 0 { + q.Set("pageSize", strconv.Itoa(pageSize)) + } + var out MCPConnectorListResponse + if err := c.http.Get(ctx, "/api/v1/mcp-connectors", q, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetMCPConnector returns a single connector by composite key (gatewayName, +// targetName). Ownership is caller-scoped, so another user's gateway looks +// like a 404. +func (c *Client) GetMCPConnector(ctx context.Context, gatewayName, targetName string) (*MCPConnectorResponse, error) { + var out MCPConnectorResponse + if err := c.http.Get(ctx, fmt.Sprintf("/api/v1/mcp-connectors/%s/%s", gatewayName, targetName), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListMCPServerTemplates returns enabled MCP server templates from the +// catalog. displayName/vendor/id are optional case-insensitive substring +// filters (AND together); page is 1-based. +func (c *Client) ListMCPServerTemplates(ctx context.Context, displayName, vendor, id string, page, pageSize int) (*MCPServerTemplateListResponse, error) { + q := url.Values{} + if displayName != "" { + q.Set("displayName", displayName) + } + if vendor != "" { + q.Set("vendor", vendor) + } + if id != "" { + q.Set("id", id) + } + if page > 0 { + q.Set("page", strconv.Itoa(page)) + } + if pageSize > 0 { + q.Set("pageSize", strconv.Itoa(pageSize)) + } + var out MCPServerTemplateListResponse + if err := c.http.Get(ctx, "/api/v1/mcp-server-templates", q, &out); err != nil { + return nil, err + } + return &out, nil +} + +// TargetTools returns a target's last-synced tools/list (GET). A target that +// has never synced comes back empty. +func (c *Client) TargetTools(ctx context.Context, name, target string) (*SyncResponse, error) { + var out SyncResponse + if err := c.http.Get(ctx, fmt.Sprintf("/api/v1/gateways/%s/targets/%s/tools", name, target), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// SyncTarget fetches the live tools/list from the target's MCP endpoint and +// persists it (POST). req may be nil (no returnTo). Non-interactive auth +// returns the tools directly; interactive auth (3LO without consent, +// INBOUND_FORWARD) returns status=authorization_required + authorizeUrl. +func (c *Client) SyncTarget(ctx context.Context, name, target string, req *SyncRequest) (*SyncResponse, error) { + if req == nil { + req = &SyncRequest{} + } + var out SyncResponse + if err := c.http.Post(ctx, fmt.Sprintf("/api/v1/gateways/%s/targets/%s/sync", name, target), req, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/go/internal/agentbase/gateway/client_test.go b/go/internal/agentbase/gateway/client_test.go index 97bd94f..372c8ba 100644 --- a/go/internal/agentbase/gateway/client_test.go +++ b/go/internal/agentbase/gateway/client_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/greennodehub/greennode-cli/internal/agentbase/client" @@ -462,3 +463,188 @@ func TestRepairServiceAccount(t *testing.T) { t.Errorf("decode: %+v", out) } } + +// TestListMCPConnectors verifies the mcp-connectors list path, the optional +// gatewayName/state filters (sent only when set), and the {items, pagination} +// envelope decode. +func TestListMCPConnectors(t *testing.T) { + var gotGateway, gotState, gotPage, gotPageSize string + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/mcp-connectors" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + q := r.URL.Query() + gotGateway, gotState = q.Get("gatewayName"), q.Get("state") + gotPage, gotPageSize = q.Get("page"), q.Get("pageSize") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(MCPConnectorListResponse{ + Items: []MCPConnectorResponse{{ + Name: "gh", + Type: "GITHUB", + ConnectURL: "https://gw.example/mcp/gh", + Gateway: MCPConnectorGatewayRef{Name: "gw-one", State: "ACTIVE", InboundAuth: MCPConnectorInboundAuthDTO{Mode: "IAM"}}, + OutboundAuth: MCPConnectorOutboundAuthDTO{ + Type: "OAUTH", Flow: "3LO", Provider: "github", Scopes: []string{"repo"}, + }, + }}, + Pagination: Pagination{Page: 1, PageSize: 50, TotalItems: 1, HasMore: false}, + }) + }) + out, err := c.ListMCPConnectors(context.Background(), "gw-one", "ACTIVE", 1, 50) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotGateway != "gw-one" || gotState != "ACTIVE" || gotPage != "1" || gotPageSize != "50" { + t.Errorf("query gateway=%q state=%q page=%q pageSize=%q", gotGateway, gotState, gotPage, gotPageSize) + } + it := out.Items[0] + if it.Name != "gh" || it.Type != "GITHUB" || it.Gateway.Name != "gw-one" || + it.OutboundAuth.Type != "OAUTH" || it.OutboundAuth.Flow != "3LO" || len(it.OutboundAuth.Scopes) != 1 { + t.Errorf("unexpected item: %+v", it) + } +} + +// TestListMCPConnectors_OmitsEmptyFilters verifies unset filters and zero +// paging are not sent. +func TestListMCPConnectors_OmitsEmptyFilters(t *testing.T) { + var gotQuery url.Values + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(MCPConnectorListResponse{}) + }) + if _, err := c.ListMCPConnectors(context.Background(), "", "", 0, 0); err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, k := range []string{"gatewayName", "state", "page", "pageSize"} { + if gotQuery.Get(k) != "" { + t.Errorf("query should omit %s, got %q", k, gotQuery.Get(k)) + } + } +} + +// TestGetMCPConnector verifies the composite-key path. +func TestGetMCPConnector(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/mcp-connectors/gw-one/gh" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(MCPConnectorResponse{Name: "gh", Type: "CUSTOM"}) + }) + out, err := c.GetMCPConnector(context.Background(), "gw-one", "gh") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Name != "gh" || out.Type != "CUSTOM" { + t.Errorf("unexpected: %+v", out) + } +} + +// TestListMCPServerTemplates verifies the template-catalog path, the three +// substring filters, and the envelope decode (authOptions + endpointVariables). +func TestListMCPServerTemplates(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/mcp-server-templates" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + q := r.URL.Query() + if q.Get("displayName") != "GitHub" || q.Get("vendor") != "" || q.Get("id") != "github" { + t.Errorf("unexpected filters: %v", q) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(MCPServerTemplateListResponse{ + Items: []MCPServerTemplateResponse{{ + ID: "github", DisplayName: "GitHub MCP", Vendor: "github", + Endpoint: "https://api.githubcopilot.com/mcp/", + AuthOptions: []MCPServerTemplateAuthOption{{ + Type: "OAUTH", Flow: "3LO", Label: "GitHub OAuth", + RecommendedScopes: []string{"repo"}, + }}, + EndpointVariables: []EndpointVariable{{Key: "tenant", Sample: "acme"}}, + }}, + Pagination: Pagination{Page: 1, PageSize: 50, TotalItems: 1}, + }) + }) + out, err := c.ListMCPServerTemplates(context.Background(), "GitHub", "", "github", 1, 50) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + it := out.Items[0] + if it.ID != "github" || len(it.AuthOptions) != 1 || it.AuthOptions[0].Flow != "3LO" || + len(it.EndpointVariables) != 1 || it.EndpointVariables[0].Key != "tenant" { + t.Errorf("unexpected item: %+v", it) + } +} + +// TestTargetTools verifies the last-synced tools GET. +func TestTargetTools(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/gateways/gw-one/targets/gh/tools" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(SyncResponse{ + Status: "ok", ToolCount: 1, LastSyncedAt: "2026-02-03T00:00:00Z", + Tools: []SyncedTool{{ + Name: "search", Title: "Search", + InputSchema: map[string]interface{}{"type": "object"}, + }}, + }) + }) + out, err := c.TargetTools(context.Background(), "gw-one", "gh") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Status != "ok" || out.ToolCount != 1 || len(out.Tools) != 1 || out.Tools[0].Name != "search" { + t.Errorf("unexpected: %+v", out) + } +} + +// TestSyncTarget verifies the sync POST path, the returnTo body, and the +// authorization_required shape (status + authorizeUrl, no tools). +func TestSyncTarget(t *testing.T) { + var gotBody []byte + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/gateways/gw-one/targets/gh/sync" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(SyncResponse{ + Status: "authorization_required", + AuthorizeURL: "https://gw.example/authorize?session=1", + }) + }) + out, err := c.SyncTarget(context.Background(), "gw-one", "gh", &SyncRequest{ReturnTo: "http://localhost/done"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got SyncRequest + if err := json.Unmarshal(gotBody, &got); err != nil { + t.Fatalf("decode body: %v", err) + } + if got.ReturnTo != "http://localhost/done" { + t.Errorf("returnTo=%q", got.ReturnTo) + } + if out.Status != "authorization_required" || out.AuthorizeURL == "" { + t.Errorf("unexpected: %+v", out) + } +} + +// TestSyncTarget_NilRequest verifies nil req sends an empty body object, not +// a JSON null. +func TestSyncTarget_NilRequest(t *testing.T) { + var gotBody []byte + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(SyncResponse{Status: "ok"}) + }) + if _, err := c.SyncTarget(context.Background(), "gw", "t", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(gotBody) != "{}" { + t.Errorf("body=%q, want {}", gotBody) + } +} diff --git a/go/internal/agentbase/gateway/models.go b/go/internal/agentbase/gateway/models.go index d553eb1..7164978 100644 --- a/go/internal/agentbase/gateway/models.go +++ b/go/internal/agentbase/gateway/models.go @@ -383,3 +383,133 @@ type PrivateRoutesResponse struct { type ReplacePrivateRoutesRequest struct { Routes []string `json:"routes"` } + +// ---------------------------------------------------------------------------- +// MCP connectors / MCP server templates / target tools sync +// (GET /api/v1/mcp-connectors, GET /api/v1/mcp-server-templates, +// GET|POST /api/v1/gateways/{name}/targets/{target}/tools|sync) +// ---------------------------------------------------------------------------- + +// MCPConnectorListResponse is the body of GET /api/v1/mcp-connectors: every +// target flattened across the caller's gateways. +type MCPConnectorListResponse struct { + Items []MCPConnectorResponse `json:"items"` + Pagination Pagination `json:"pagination"` +} + +// MCPConnectorResponse is one MCP connector: a single target on a single +// caller-owned gateway, with its ready-to-use connectUrl. +type MCPConnectorResponse struct { + Name string `json:"name"` + Type string `json:"type"` + ConnectURL string `json:"connectUrl"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Gateway MCPConnectorGatewayRef `json:"gateway"` + Template MCPConnectorTemplateRef `json:"template"` + OutboundAuth MCPConnectorOutboundAuthDTO `json:"outboundAuth"` +} + +// MCPConnectorGatewayRef is the parent-gateway context on a connector. +type MCPConnectorGatewayRef struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Endpoint string `json:"endpoint"` + State string `json:"state"` + NetworkMode string `json:"networkMode"` + UpdatedAt string `json:"updatedAt"` + InboundAuth MCPConnectorInboundAuthDTO `json:"inboundAuth"` +} + +// MCPConnectorInboundAuthDTO is the parent gateway's inbound-auth summary. +type MCPConnectorInboundAuthDTO struct { + Mode string `json:"mode"` + Source string `json:"source"` + DiscoveryURL string `json:"discoveryUrl"` +} + +// MCPConnectorTemplateRef names the catalog template a connector derives from +// (or CUSTOM when it does not). +type MCPConnectorTemplateRef struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` +} + +// MCPConnectorOutboundAuthDTO is the upstream outbound-auth descriptor. +type MCPConnectorOutboundAuthDTO struct { + Type string `json:"type"` + Flow string `json:"flow"` + Provider string `json:"provider"` + Scopes []string `json:"scopes"` +} + +// MCPServerTemplateListResponse is the body of GET /api/v1/mcp-server-templates. +type MCPServerTemplateListResponse struct { + Items []MCPServerTemplateResponse `json:"items"` + Pagination Pagination `json:"pagination"` +} + +// MCPServerTemplateResponse is one enabled template from the MCP server +// catalog. Its authOptions' Recommended* fields are pre-fill hints for a +// target-creation form; edits go through the gateway update path. +type MCPServerTemplateResponse struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Vendor string `json:"vendor"` + AuthVendor string `json:"authVendor"` + Description string `json:"description"` + Endpoint string `json:"endpoint"` + Logo string `json:"logo"` + SortOrder int `json:"sortOrder"` + AuthOptions []MCPServerTemplateAuthOption `json:"authOptions"` + EndpointVariables []EndpointVariable `json:"endpointVariables"` +} + +// MCPServerTemplateAuthOption is one auth wiring a template recommends. +type MCPServerTemplateAuthOption struct { + Type string `json:"type"` + Flow string `json:"flow"` + Label string `json:"label"` + RecommendedScopes []string `json:"recommendedScopes"` + RecommendedHeaderName string `json:"recommendedHeaderName"` + RecommendedHeaderValuePrefix string `json:"recommendedHeaderValuePrefix"` + RecommendedCustomParameters map[string]string `json:"recommendedCustomParameters"` +} + +// EndpointVariable is one URL-path variable a template's endpoint needs. +type EndpointVariable struct { + Key string `json:"key"` + Description string `json:"description"` + Sample string `json:"sample"` +} + +// SyncRequest is the optional body of POST .../targets/{target}/sync. +// returnTo is where the gateway redirects the browser after interactive +// (3LO) tools/list completion; omit it for a plain done page. +type SyncRequest struct { + ReturnTo string `json:"returnTo,omitempty"` +} + +// SyncResponse is the body of both GET .../targets/{target}/tools (the last +// synced list) and POST .../sync. On interactive auth the sync response comes +// back with status=authorization_required plus an authorizeUrl to open in a +// browser; the gateway then finishes tools/list server-side. +type SyncResponse struct { + Status string `json:"status"` + Tools []SyncedTool `json:"tools"` + ToolCount int `json:"toolCount"` + LastSyncedAt string `json:"lastSyncedAt"` + AuthorizeURL string `json:"authorizeUrl"` + SyncSessionID string `json:"syncSessionId"` + Error string `json:"error"` +} + +// SyncedTool is one tool from a target's tools/list. +type SyncedTool struct { + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"inputSchema"` + OutputSchema map[string]interface{} `json:"outputSchema"` +} diff --git a/go/internal/agentbase/identity/client.go b/go/internal/agentbase/identity/client.go index a0a0b2f..6d926a7 100644 --- a/go/internal/agentbase/identity/client.go +++ b/go/internal/agentbase/identity/client.go @@ -228,3 +228,44 @@ func (c *Client) GetDelegatedApiKey(ctx context.Context, providerName, agentIden } return &out, nil } + +// --- Managed OAuth2 providers (platform-managed, read-only) --- + +// ListManagedOauth2Providers returns a paginated list of platform-managed +// OAuth2 providers (0-based page, identity-style). +func (c *Client) ListManagedOauth2Providers(ctx context.Context, page, size int) (*PagedResponseManagedOauth2ProviderSummaryResponse, error) { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("size", strconv.Itoa(size)) + var out PagedResponseManagedOauth2ProviderSummaryResponse + if err := c.http.Get(ctx, "/api/v1/outbound-auth/managed-oauth2-providers", q, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetManagedOauth2Provider retrieves a single managed OAuth2 provider summary +// by name. +func (c *Client) GetManagedOauth2Provider(ctx context.Context, name string) (*ManagedOauth2ProviderSummaryResponse, error) { + var out ManagedOauth2ProviderSummaryResponse + if err := c.http.Get(ctx, fmt.Sprintf("/api/v1/outbound-auth/managed-oauth2-providers/%s", name), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetManaged3LOToken starts or resumes a 3-legged OAuth2 flow through a +// managed provider for an agent identity. The first call typically returns an +// authorizationUrl; the browser flow completes server-side at the provider's +// fixed callback. +func (c *Client) GetManaged3LOToken(ctx context.Context, providerName, agentIdentityName string, req *ThreeLoTokenRequest) (*ThreeLoTokenResponse, error) { + if req == nil { + req = &ThreeLoTokenRequest{} + } + var out ThreeLoTokenResponse + path := fmt.Sprintf("/api/v1/outbound-auth/managed-oauth2-providers/%s/agent-identities/%s/tokens/3lo", providerName, agentIdentityName) + if err := c.http.Post(ctx, path, req, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/go/internal/agentbase/identity/client_test.go b/go/internal/agentbase/identity/client_test.go index f7730b8..be93cc7 100644 --- a/go/internal/agentbase/identity/client_test.go +++ b/go/internal/agentbase/identity/client_test.go @@ -412,3 +412,85 @@ func TestNewClient(t *testing.T) { t.Fatal("expected non-nil client") } } + +func TestListManagedOauth2Providers(t *testing.T) { + var gotPath string + var gotPage, gotSize string + resp := PagedResponseManagedOauth2ProviderSummaryResponse{ + Content: jsonslice.Array[ManagedOauth2ProviderSummaryResponse]{{ + ID: sp("1"), Name: sp("google"), Status: sp("ACTIVE"), + CallbackURL: sp("https://agentbase.api.vngcloud.vn/outbound-auth/callback"), + }}, + } + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotPage, gotSize = r.URL.Query().Get("page"), r.URL.Query().Get("size") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + out, err := c.ListManagedOauth2Providers(context.Background(), 0, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/api/v1/outbound-auth/managed-oauth2-providers" { + t.Errorf("unexpected path: %s", gotPath) + } + if gotPage != "0" || gotSize != "10" { + t.Errorf("query page=%q size=%q, want 0/10", gotPage, gotSize) + } + if len(out.Content) != 1 || out.Content[0].Name == nil || *out.Content[0].Name != "google" { + t.Errorf("unexpected content: %v", out.Content) + } +} + +func TestGetManagedOauth2Provider(t *testing.T) { + var gotPath string + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ManagedOauth2ProviderSummaryResponse{ID: sp("1"), Name: sp("google")}) + }) + out, err := c.GetManagedOauth2Provider(context.Background(), "google") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/api/v1/outbound-auth/managed-oauth2-providers/google" { + t.Errorf("unexpected path: %s", gotPath) + } + if out.Name == nil || *out.Name != "google" { + t.Errorf("unexpected name: %v", out.Name) + } +} + +func TestGetManaged3LOToken(t *testing.T) { + var gotPath string + var gotBody []byte + c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ThreeLoTokenResponse{ + Status: sp("authorization_required"), + AuthorizationURL: sp("https://accounts.google.com/o/oauth2/auth"), + SessionID: sp("sess-1"), + }) + }) + out, err := c.GetManaged3LOToken(context.Background(), "google", "agent-1", + &ThreeLoTokenRequest{AgentUserID: "user-1", ReturnURL: "http://localhost/done", Scopes: jsonslice.Array[string]{"repo"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/api/v1/outbound-auth/managed-oauth2-providers/google/agent-identities/agent-1/tokens/3lo" { + t.Errorf("unexpected path: %s", gotPath) + } + var body ThreeLoTokenRequest + if err := json.Unmarshal(gotBody, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.AgentUserID != "user-1" || body.ReturnURL != "http://localhost/done" || len(body.Scopes) != 1 { + t.Errorf("unexpected body: %+v", body) + } + if out.Status == nil || *out.Status != "authorization_required" || out.AuthorizationURL == nil || *out.AuthorizationURL == "" { + t.Errorf("unexpected response: %+v", out) + } +} diff --git a/go/internal/agentbase/identity/models.go b/go/internal/agentbase/identity/models.go index 3b13130..f95e915 100644 --- a/go/internal/agentbase/identity/models.go +++ b/go/internal/agentbase/identity/models.go @@ -233,3 +233,31 @@ type GetDelegatedApiKeyResponse struct { SessionID *string `json:"sessionId"` Status *string `json:"status"` } + +// --- Managed OAuth2 providers --- + +// ManagedOauth2ProviderSummaryResponse is a platform-managed OAuth2 provider: +// credentials live server-side and are never exposed, so the CLI surface is +// read-only (list/get) plus 3LO token retrieval for an agent identity. +type ManagedOauth2ProviderSummaryResponse struct { + ID *string `json:"id"` + Name *string `json:"name"` + Status *string `json:"status"` + CallbackURL *string `json:"callbackUrl"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` +} + +// PagedResponseManagedOauth2ProviderSummaryResponse is a paginated list of +// managed OAuth2 provider summaries. +type PagedResponseManagedOauth2ProviderSummaryResponse struct { + Content jsonslice.Array[ManagedOauth2ProviderSummaryResponse] `json:"content"` + Page *int `json:"page"` + Size *int `json:"size"` + TotalElements *int64 `json:"totalElements"` + TotalPages *int `json:"totalPages"` + First *bool `json:"first"` + Last *bool `json:"last"` + HasNext *bool `json:"hasNext"` + HasPrevious *bool `json:"hasPrevious"` +} diff --git a/go/internal/agentbase/runtime/client.go b/go/internal/agentbase/runtime/client.go index 201c4de..4b601a3 100644 --- a/go/internal/agentbase/runtime/client.go +++ b/go/internal/agentbase/runtime/client.go @@ -39,7 +39,7 @@ func (c *Client) List(ctx context.Context, page, size int) (*ListAgentRuntimesRe q.Set("size", strconv.Itoa(size)) } var out ListAgentRuntimesResponse - if err := c.http.Get(ctx, "/agent-runtimes", q, &out); err != nil { + if err := c.http.Get(ctx, "/v1/agent-runtimes", q, &out); err != nil { return nil, err } return &out, nil @@ -49,7 +49,7 @@ func (c *Client) List(ctx context.Context, page, size int) (*ListAgentRuntimesRe // (CREATING) state; converge with `wait`. func (c *Client) Create(ctx context.Context, req *CreateAgentRuntimeRequest) (*AgentRuntime, error) { var out AgentRuntime - if err := c.http.Post(ctx, "/agent-runtimes", req, &out); err != nil { + if err := c.http.Post(ctx, "/v1/agent-runtimes", req, &out); err != nil { return nil, err } return &out, nil @@ -59,7 +59,7 @@ func (c *Client) Create(ctx context.Context, req *CreateAgentRuntimeRequest) (*A // name is immutable but not the path key). func (c *Client) Get(ctx context.Context, id string) (*AgentRuntime, error) { var out AgentRuntime - if err := c.http.Get(ctx, fmt.Sprintf("/agent-runtimes/%s", id), nil, &out); err != nil { + if err := c.http.Get(ctx, fmt.Sprintf("/v1/agent-runtimes/%s", id), nil, &out); err != nil { return nil, err } return &out, nil @@ -71,7 +71,7 @@ func (c *Client) Get(ctx context.Context, id string) (*AgentRuntime, error) { // the default endpoint forward. func (c *Client) Update(ctx context.Context, id string, req *UpdateAgentRuntimeRequest) (*AgentRuntime, error) { var out AgentRuntime - if err := c.http.Patch(ctx, fmt.Sprintf("/agent-runtimes/%s", id), nil, req, &out); err != nil { + if err := c.http.Patch(ctx, fmt.Sprintf("/v1/agent-runtimes/%s", id), nil, req, &out); err != nil { return nil, err } return &out, nil @@ -80,7 +80,7 @@ func (c *Client) Update(ctx context.Context, id string, req *UpdateAgentRuntimeR // Delete deletes an agent runtime by id. The service returns 200 with an empty // body; converge with `wait` (status DELETED). func (c *Client) Delete(ctx context.Context, id string) error { - return c.http.Delete(ctx, fmt.Sprintf("/agent-runtimes/%s", id), nil) + return c.http.Delete(ctx, fmt.Sprintf("/v1/agent-runtimes/%s", id), nil) } // pageQuery builds the ?page=&size= query (both omitted when <= 0, falling back @@ -103,7 +103,7 @@ func pageQuery(page, size int) url.Values { // ListEndpoints returns a page of endpoints for a runtime. func (c *Client) ListEndpoints(ctx context.Context, id string, page, size int) (*ListResponseAgentRuntimeEndpointDto, error) { var out ListResponseAgentRuntimeEndpointDto - if err := c.http.Get(ctx, fmt.Sprintf("/agent-runtimes/%s/endpoints", id), pageQuery(page, size), &out); err != nil { + if err := c.http.Get(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints", id), pageQuery(page, size), &out); err != nil { return nil, err } return &out, nil @@ -112,7 +112,7 @@ func (c *Client) ListEndpoints(ctx context.Context, id string, page, size int) ( // CreateEndpoint creates a new endpoint on a runtime. func (c *Client) CreateEndpoint(ctx context.Context, id string, req *AgentRuntimeEndpointCreateRequest) (*AgentRuntimeEndpointDto, error) { var out AgentRuntimeEndpointDto - if err := c.http.Post(ctx, fmt.Sprintf("/agent-runtimes/%s/endpoints", id), req, &out); err != nil { + if err := c.http.Post(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints", id), req, &out); err != nil { return nil, err } return &out, nil @@ -126,7 +126,7 @@ func (c *Client) UpdateEndpoint(ctx context.Context, id, endpointID string, vers q := url.Values{} q.Set("version", strconv.Itoa(version)) var out AgentRuntimeEndpointDto - if err := c.http.Do(ctx, http.MethodPut, fmt.Sprintf("/agent-runtimes/%s/endpoints/%s", id, endpointID), q, nil, &out); err != nil { + if err := c.http.Do(ctx, http.MethodPut, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints/%s", id, endpointID), q, nil, &out); err != nil { return nil, err } return &out, nil @@ -137,7 +137,7 @@ func (c *Client) UpdateEndpoint(ctx context.Context, id, endpointID string, vers // PrintDeletedID when the body is empty. func (c *Client) DeleteEndpoint(ctx context.Context, id, endpointID string) (*AgentRuntimeEndpointDto, error) { var out AgentRuntimeEndpointDto - if err := c.http.Delete(ctx, fmt.Sprintf("/agent-runtimes/%s/endpoints/%s", id, endpointID), &out); err != nil { + if err := c.http.Delete(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints/%s", id, endpointID), &out); err != nil { return nil, err } return &out, nil @@ -145,18 +145,18 @@ func (c *Client) DeleteEndpoint(ctx context.Context, id, endpointID string) (*Ag // StartEndpoint starts an endpoint. No body; 200 OK. func (c *Client) StartEndpoint(ctx context.Context, id, endpointID string) error { - return c.http.Post(ctx, fmt.Sprintf("/agent-runtimes/%s/endpoints/%s/start", id, endpointID), nil, nil) + return c.http.Post(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints/%s/start", id, endpointID), nil, nil) } // StopEndpoint stops an endpoint. No body; 200 OK. func (c *Client) StopEndpoint(ctx context.Context, id, endpointID string) error { - return c.http.Post(ctx, fmt.Sprintf("/agent-runtimes/%s/endpoints/%s/stop", id, endpointID), nil, nil) + return c.http.Post(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints/%s/stop", id, endpointID), nil, nil) } // EndpointLogs searches an endpoint's logs. 200 → LogSearchResult. func (c *Client) EndpointLogs(ctx context.Context, id, endpointID string, req *LogSearchRequest) (*LogSearchResult, error) { var out LogSearchResult - if err := c.http.Post(ctx, fmt.Sprintf("/agent-runtimes/%s/endpoints/%s/logs", id, endpointID), req, &out); err != nil { + if err := c.http.Post(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints/%s/logs", id, endpointID), req, &out); err != nil { return nil, err } return &out, nil @@ -172,7 +172,7 @@ func (c *Client) EndpointMetrics(ctx context.Context, id, endpointID, from, to s q.Set("toTimestamp", to) } var out AgentRuntimeEndpointMetrics - if err := c.http.Get(ctx, fmt.Sprintf("/agent-runtimes/%s/endpoints/%s/metrics", id, endpointID), q, &out); err != nil { + if err := c.http.Get(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints/%s/metrics", id, endpointID), q, &out); err != nil { return nil, err } return &out, nil @@ -181,7 +181,7 @@ func (c *Client) EndpointMetrics(ctx context.Context, id, endpointID, from, to s // EndpointEvents lists kubernetes events for an endpoint. func (c *Client) EndpointEvents(ctx context.Context, id, endpointID string) ([]KubeEventDto, error) { var out []KubeEventDto - if err := c.http.Get(ctx, fmt.Sprintf("/agent-runtimes/%s/endpoints/%s/events", id, endpointID), nil, &out); err != nil { + if err := c.http.Get(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/endpoints/%s/events", id, endpointID), nil, &out); err != nil { return nil, err } return out, nil @@ -195,7 +195,7 @@ func (c *Client) EndpointEvents(ctx context.Context, id, endpointID string) ([]K // LogSearchResult. func (c *Client) Logs(ctx context.Context, id string, req *LogSearchRequest) (*LogSearchResult, error) { var out LogSearchResult - if err := c.http.Post(ctx, fmt.Sprintf("/agent-runtimes/%s/logs", id), req, &out); err != nil { + if err := c.http.Post(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/logs", id), req, &out); err != nil { return nil, err } return &out, nil @@ -205,13 +205,13 @@ func (c *Client) Logs(ctx context.Context, id string, req *LogSearchRequest) (*L // OK. The service also exposes a deprecated PATCH variant; this POST is the // canonical one (the QC PATCH row maps to it). func (c *Client) ResetServiceAccount(ctx context.Context, id string) error { - return c.http.Post(ctx, fmt.Sprintf("/agent-runtimes/%s/reset-service-account", id), nil, nil) + return c.http.Post(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/reset-service-account", id), nil, nil) } // ListVersions returns a page of a runtime's versions (full spec per version). func (c *Client) ListVersions(ctx context.Context, id string, page, size int) (*ListResponseAgentRuntimeVersionDto, error) { var out ListResponseAgentRuntimeVersionDto - if err := c.http.Get(ctx, fmt.Sprintf("/agent-runtimes/%s/versions", id), pageQuery(page, size), &out); err != nil { + if err := c.http.Get(ctx, fmt.Sprintf("/v1/agent-runtimes/%s/versions", id), pageQuery(page, size), &out); err != nil { return nil, err } return &out, nil @@ -221,7 +221,7 @@ func (c *Client) ListVersions(ctx context.Context, id string, page, size int) (* // Sub-resources: tracing (Slice 4) // // The trace endpoints are Google-AIP custom verbs on the agent-runtimes -// collection (/agent-runtimes:get-trace, :search-traces, +// collection (/v1/agent-runtimes:get-trace, :search-traces, // :trace-search-tag-values). They forward arbitrary query params (allParams) to // the tracing backend and return that backend's raw JSON, so the response is // decoded as json.RawMessage and printed verbatim. @@ -237,7 +237,7 @@ func (c *Client) GetTrace(ctx context.Context, traceID string, params url.Values } q.Set("traceId", traceID) var out json.RawMessage - if err := c.http.Get(ctx, "/agent-runtimes:get-trace", q, &out); err != nil { + if err := c.http.Get(ctx, "/v1/agent-runtimes:get-trace", q, &out); err != nil { return nil, err } return out, nil @@ -247,7 +247,7 @@ func (c *Client) GetTrace(ctx context.Context, traceID string, params url.Values // the tracing backend (the --param flag). func (c *Client) SearchTraces(ctx context.Context, params url.Values) (json.RawMessage, error) { var out json.RawMessage - if err := c.http.Get(ctx, "/agent-runtimes:search-traces", params, &out); err != nil { + if err := c.http.Get(ctx, "/v1/agent-runtimes:search-traces", params, &out); err != nil { return nil, err } return out, nil @@ -263,7 +263,7 @@ func (c *Client) TraceTagValues(ctx context.Context, tagKey string, params url.V } q.Set("tagKey", tagKey) var out json.RawMessage - if err := c.http.Get(ctx, "/agent-runtimes:trace-search-tag-values", q, &out); err != nil { + if err := c.http.Get(ctx, "/v1/agent-runtimes:trace-search-tag-values", q, &out); err != nil { return nil, err } return out, nil diff --git a/go/internal/agentbase/runtime/client_test.go b/go/internal/agentbase/runtime/client_test.go index 550725b..49c71c7 100644 --- a/go/internal/agentbase/runtime/client_test.go +++ b/go/internal/agentbase/runtime/client_test.go @@ -36,7 +36,7 @@ func TestList(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } - if r.URL.Path != "/agent-runtimes" { + if r.URL.Path != "/v1/agent-runtimes" { t.Errorf("unexpected path: %s", r.URL.Path) } gotPage = r.URL.Query().Get("page") @@ -81,7 +81,7 @@ func TestList_OmittedPagingNotSent(t *testing.T) { func TestGet(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { // Keyed by id, bare path (no /api/v1). - if r.URL.Path != "/agent-runtimes/abc-123" { + if r.URL.Path != "/v1/agent-runtimes/abc-123" { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -122,7 +122,7 @@ func TestCreate(t *testing.T) { if got.EnvironmentVariables["K"] != "V" { t.Errorf("unexpected env: %+v", got.EnvironmentVariables) } - if got.Autoscaling.MinReplicas != 1 || got.Autoscaling.MaxReplicas != 3 { + if got.Autoscaling.MaxReplicas != 3 { t.Errorf("unexpected autoscaling: %+v", got.Autoscaling) } w.Header().Set("Content-Type", "application/json") @@ -137,7 +137,7 @@ func TestCreate(t *testing.T) { Args: []string{}, EnvironmentVariables: map[string]string{"K": "V"}, ImageAuth: &ImageAuth{Enabled: true, Username: "u", Password: pw}, - Autoscaling: Autoscaling{MinReplicas: 1, MaxReplicas: 3, CPUUtilization: 70, MemoryUtilization: 70}, + Autoscaling: Autoscaling{MaxReplicas: 3, CPUUtilization: 70, MemoryUtilization: 70}, }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -162,7 +162,7 @@ func TestCreate_OmitsNilImageAuth(t *testing.T) { _, _ = c.Create(context.Background(), &CreateAgentRuntimeRequest{ Name: "rt", ImageURL: "img", FlavorID: "f", Command: []string{}, Args: []string{}, EnvironmentVariables: map[string]string{}, - Autoscaling: Autoscaling{MinReplicas: 1, MaxReplicas: 2}, + Autoscaling: Autoscaling{MaxReplicas: 2}, }) var got map[string]json.RawMessage if err := json.Unmarshal(gotBody, &got); err != nil { @@ -182,7 +182,7 @@ func TestUpdate(t *testing.T) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) } - if r.URL.Path != "/agent-runtimes/abc-123" { + if r.URL.Path != "/v1/agent-runtimes/abc-123" { t.Errorf("unexpected path: %s", r.URL.Path) } gotBody, _ = io.ReadAll(r.Body) @@ -196,7 +196,7 @@ func TestUpdate(t *testing.T) { Command: []string{"run"}, Args: []string{}, EnvironmentVariables: map[string]string{"K": "V"}, - Autoscaling: Autoscaling{MinReplicas: 1, MaxReplicas: 2, CPUUtilization: 70, MemoryUtilization: 70}, + Autoscaling: Autoscaling{MaxReplicas: 2, CPUUtilization: 70, MemoryUtilization: 70}, }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -267,7 +267,7 @@ func wantMethodPath(t *testing.T, r *http.Request, method, path string) { func TestListEndpoints(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodGet, "/agent-runtimes/r1/endpoints") + wantMethodPath(t, r, http.MethodGet, "/v1/agent-runtimes/r1/endpoints") if r.URL.Query().Get("page") != "1" || r.URL.Query().Get("size") != "10" { t.Errorf("query: %s", r.URL.Query().Encode()) } @@ -287,7 +287,7 @@ func TestListEndpoints(t *testing.T) { func TestCreateEndpoint(t *testing.T) { var gotBody []byte c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodPost, "/agent-runtimes/r1/endpoints") + wantMethodPath(t, r, http.MethodPost, "/v1/agent-runtimes/r1/endpoints") gotBody, _ = io.ReadAll(r.Body) _ = json.NewEncoder(w).Encode(AgentRuntimeEndpointDto{ID: "e1", Name: "ep"}) }) @@ -309,7 +309,7 @@ func TestCreateEndpoint(t *testing.T) { func TestUpdateEndpoint(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodPut, "/agent-runtimes/r1/endpoints/e1") + wantMethodPath(t, r, http.MethodPut, "/v1/agent-runtimes/r1/endpoints/e1") if got := r.URL.Query().Get("version"); got != "3" { t.Errorf("version: %q", got) } @@ -329,7 +329,7 @@ func TestUpdateEndpoint(t *testing.T) { func TestDeleteEndpoint(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodDelete, "/agent-runtimes/r1/endpoints/e1") + wantMethodPath(t, r, http.MethodDelete, "/v1/agent-runtimes/r1/endpoints/e1") _ = json.NewEncoder(w).Encode(AgentRuntimeEndpointDto{ID: "e1", Status: "DELETED"}) }) out, err := c.DeleteEndpoint(context.Background(), "r1", "e1") @@ -362,7 +362,7 @@ func TestStartStopEndpoint(t *testing.T) { func TestEndpointLogs(t *testing.T) { var gotBody []byte c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodPost, "/agent-runtimes/r1/endpoints/e1/logs") + wantMethodPath(t, r, http.MethodPost, "/v1/agent-runtimes/r1/endpoints/e1/logs") gotBody, _ = io.ReadAll(r.Body) _ = json.NewEncoder(w).Encode(LogSearchResult{TotalCount: 1, Logs: []LogRecord{{Content: "hi"}}}) }) @@ -384,7 +384,7 @@ func TestEndpointLogs(t *testing.T) { func TestEndpointMetrics(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodGet, "/agent-runtimes/r1/endpoints/e1/metrics") + wantMethodPath(t, r, http.MethodGet, "/v1/agent-runtimes/r1/endpoints/e1/metrics") if got := r.URL.Query().Get("fromTimestamp"); got != "t0" { t.Errorf("from: %q", got) } @@ -403,7 +403,7 @@ func TestEndpointMetrics(t *testing.T) { func TestEndpointEvents(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodGet, "/agent-runtimes/r1/endpoints/e1/events") + wantMethodPath(t, r, http.MethodGet, "/v1/agent-runtimes/r1/endpoints/e1/events") _ = json.NewEncoder(w).Encode([]KubeEventDto{{Message: "pulled"}}) }) out, err := c.EndpointEvents(context.Background(), "r1", "e1") @@ -417,7 +417,7 @@ func TestEndpointEvents(t *testing.T) { func TestLogs(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodPost, "/agent-runtimes/r1/logs") + wantMethodPath(t, r, http.MethodPost, "/v1/agent-runtimes/r1/logs") _ = json.NewEncoder(w).Encode(LogSearchResult{TotalCount: 2}) }) out, err := c.Logs(context.Background(), "r1", &LogSearchRequest{Limit: 100}) @@ -431,7 +431,7 @@ func TestLogs(t *testing.T) { func TestResetServiceAccount(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodPost, "/agent-runtimes/r1/reset-service-account") + wantMethodPath(t, r, http.MethodPost, "/v1/agent-runtimes/r1/reset-service-account") w.WriteHeader(http.StatusOK) }) if err := c.ResetServiceAccount(context.Background(), "r1"); err != nil { @@ -441,7 +441,7 @@ func TestResetServiceAccount(t *testing.T) { func TestListVersions(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodGet, "/agent-runtimes/r1/versions") + wantMethodPath(t, r, http.MethodGet, "/v1/agent-runtimes/r1/versions") _ = json.NewEncoder(w).Encode(ListResponseAgentRuntimeVersionDto{ ListData: []AgentRuntimeVersionDto{{Version: 1, ImageURL: "img", FlavorID: "f1"}}, }) @@ -457,7 +457,7 @@ func TestListVersions(t *testing.T) { func TestGetTrace(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodGet, "/agent-runtimes:get-trace") + wantMethodPath(t, r, http.MethodGet, "/v1/agent-runtimes:get-trace") if got := r.URL.Query().Get("traceId"); got != "abc" { t.Errorf("traceId: %q", got) } @@ -479,7 +479,7 @@ func TestGetTrace(t *testing.T) { func TestSearchTraces(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodGet, "/agent-runtimes:search-traces") + wantMethodPath(t, r, http.MethodGet, "/v1/agent-runtimes:search-traces") if got := r.URL.Query().Get("service"); got != "runtime" { t.Errorf("param: %q", got) } @@ -498,7 +498,7 @@ func TestSearchTraces(t *testing.T) { func TestTraceTagValues(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { - wantMethodPath(t, r, http.MethodGet, "/agent-runtimes:trace-search-tag-values") + wantMethodPath(t, r, http.MethodGet, "/v1/agent-runtimes:trace-search-tag-values") if got := r.URL.Query().Get("tagKey"); got != "env" { t.Errorf("tagKey: %q", got) } diff --git a/go/internal/agentbase/runtime/models.go b/go/internal/agentbase/runtime/models.go index 8fe043b..9a53a56 100644 --- a/go/internal/agentbase/runtime/models.go +++ b/go/internal/agentbase/runtime/models.go @@ -1,12 +1,13 @@ // Package runtime is the typed client for the agent-core-runtime service // (`grn agentbase runtime`). The entity model mirrors the service's REST -// contract (POST/GET/PATCH/DELETE /agent-runtimes), which the agentbase +// contract (POST/GET/PATCH/DELETE /v1/agent-runtimes), which the agentbase // /runtime endpoint fronts. // // Note: agent-core-runtime is a Java/Spring Boot service (not Go), but the // wire contract is what matters here. Spring serializes fields by name -// (camelCase); no /api/v1 version prefix; paging uses {listData, page, -// pageSize, totalPage, totalItem} with ?page=&size= (1-based, defaults 1/10). +// (camelCase); all paths are versioned under /v1 (the no-prefix routes are +// legacy aliases); paging uses {listData, page, pageSize, totalPage, +// totalItem} with ?page=&size= (1-based, defaults 1/10). // // Compiled into the default grn binary (the `-tags agentbase` gate was dropped at GA). package runtime @@ -23,16 +24,21 @@ import ( // ImageAuth is the optional private-registry credentials. Password is // send-only: it appears on create/update but is never present on a response // (AgentRuntime has no imageAuth field), so it never round-trips back. +// UseAgentBaseRegistryCredentials (pointer: only sent when set) lets the +// platform pull the image using its own registry credentials instead of +// username/password. type ImageAuth struct { - Enabled bool `json:"enabled"` - Username string `json:"username"` - Password string `json:"password"` + Enabled bool `json:"enabled"` + UseAgentBaseRegistryCredentials *bool `json:"useAgentBaseRegistryCredentials,omitempty"` + Username string `json:"username"` + Password string `json:"password"` } // Autoscaling is the HPA config. Bounds mirror the service's validation: -// replicas 1-10, utilizations 10-90. +// maxReplicas 1-10, utilizations 10-90. minReplicas was removed from the +// contract — the platform owns the floor (always 1), so only maxReplicas and +// the utilization targets are configurable. type Autoscaling struct { - MinReplicas int `json:"minReplicas"` MaxReplicas int `json:"maxReplicas"` CPUUtilization int `json:"cpuUtilization"` MemoryUtilization int `json:"memoryUtilization"` @@ -44,8 +50,44 @@ type Autoscaling struct { // not a merge-patch. // ---------------------------------------------------------------------------- -// CreateAgentRuntimeRequest is the body for POST /agent-runtimes. name is -// required and sealed; the rest is the mutable spec. +// NetworkConfig is the create/update network placement. mode PUBLIC exposes +// the runtime's endpoints on the public internet (default); mode VPC attaches +// them to a VPC/subnet (vpcId and subnetId are then required server-side; +// routeCidrs adds egress routes, max 20). +type NetworkConfig struct { + Mode string `json:"mode"` // PUBLIC | VPC + VpcID string `json:"vpcId,omitempty"` + SubnetID string `json:"subnetId,omitempty"` + RouteCidrs []string `json:"routeCidrs,omitempty"` +} + +// InboundAuthJWT is the JWT validation config (only read when +// InboundAuth.Mode is JWT). source is required: DISCOVERY pulls keys from a +// .well-known OIDC document (discoveryUrl, then required), JWKS inlines the +// key set (jwks, then required, <=32KiB). Allowed* lists cap at 20/20/50. +type InboundAuthJWT struct { + Source string `json:"source"` // DISCOVERY | JWKS + JWKS json.RawMessage `json:"jwks,omitempty"` + DiscoveryURL string `json:"discoveryUrl,omitempty"` + AllowedAudiences []string `json:"allowedAudiences,omitempty"` + AllowedClients []string `json:"allowedClients,omitempty"` + AllowedScopes []string `json:"allowedScopes,omitempty"` + PrincipalClaim string `json:"principalClaim,omitempty"` +} + +// InboundAuth is the inbound authentication mode for a runtime's endpoints. +// mode is required: NONE is open, IAM authenticates via the platform IAM +// (Bearer token), JWT validates caller JWTs per the jwt config (then +// required). +type InboundAuth struct { + Mode string `json:"mode"` // NONE | IAM | JWT + JWT *InboundAuthJWT `json:"jwt,omitempty"` +} + +// CreateAgentRuntimeRequest is the body for POST /v1/agent-runtimes. name is +// required (5-50 chars, [a-zA-Z0-9._-]) and sealed; the rest is the mutable +// spec. poc marks a proof-of-concept runtime (create-only — it cannot be +// changed by update). type CreateAgentRuntimeRequest struct { Name string `json:"name"` Description string `json:"description"` @@ -54,14 +96,19 @@ type CreateAgentRuntimeRequest struct { Command []string `json:"command"` Args []string `json:"args"` EnvironmentVariables map[string]string `json:"environmentVariables"` + NetworkConfig *NetworkConfig `json:"networkConfig,omitempty"` + AllowedCidrs []string `json:"allowedCidrs,omitempty"` + InboundAuth *InboundAuth `json:"inboundAuth,omitempty"` + Protocol string `json:"protocol,omitempty"` // HTTP | MCP FlavorID string `json:"flavorId"` + POC *bool `json:"poc,omitempty"` Autoscaling Autoscaling `json:"autoscaling"` } -// UpdateAgentRuntimeRequest is the body for PATCH /agent-runtimes/{id}. Same as -// create minus name (name is immutable). Every field is required (full-spec -// replacement — updating creates a new version and rolls the default endpoint -// forward), so this is NOT JSON Merge Patch semantics. +// UpdateAgentRuntimeRequest is the body for PATCH /v1/agent-runtimes/{id}. +// Same as create minus name (immutable) and poc (create-only). Every field is +// required (full-spec replacement — updating creates a new version and rolls +// the default endpoint forward), so this is NOT JSON Merge Patch semantics. type UpdateAgentRuntimeRequest struct { Description string `json:"description"` ImageURL string `json:"imageUrl"` @@ -69,6 +116,10 @@ type UpdateAgentRuntimeRequest struct { Command []string `json:"command"` Args []string `json:"args"` EnvironmentVariables map[string]string `json:"environmentVariables"` + NetworkConfig *NetworkConfig `json:"networkConfig,omitempty"` + AllowedCidrs []string `json:"allowedCidrs,omitempty"` + InboundAuth *InboundAuth `json:"inboundAuth,omitempty"` + Protocol string `json:"protocol,omitempty"` // HTTP | MCP FlavorID string `json:"flavorId"` Autoscaling Autoscaling `json:"autoscaling"` } @@ -77,21 +128,25 @@ type UpdateAgentRuntimeRequest struct { // Response types. // ---------------------------------------------------------------------------- -// AgentRuntime is the body returned by get/create and one element of list. -// description/statusReason are declared but only populated transiently by get() -// (statusReason when the IAM service account is unhealthy); list/create/update -// return them empty. Model them anyway for forward compatibility. +// AgentRuntime is the body returned by get/create and one element of list +// (AgentRuntimeDto in the spec). description/statusReason are declared but +// only populated transiently by get() (statusReason when the IAM service +// account is unhealthy); list/create/update return them empty. Model them +// anyway for forward compatibility. poc marks a proof-of-concept runtime +// (create-only). type AgentRuntime struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status"` - StatusReason string `json:"statusReason"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + DisplayStatus string `json:"displayStatus"` + StatusReason string `json:"statusReason"` + POC *bool `json:"poc,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } -// ListAgentRuntimesResponse is the body of GET /agent-runtimes. Distinct from +// ListAgentRuntimesResponse is the body of GET /v1/agent-runtimes. Distinct from // gateway's {items, pagination} and identity's PagedResponse: this service uses // {listData, page, pageSize, totalPage, totalItem} with 1-based page/size query // params (defaults 1/10). @@ -119,6 +174,7 @@ type AgentRuntimeEndpointDto struct { LiveVersion int `json:"liveVersion"` CurrentReplicaCount int `json:"currentReplicaCount"` URL string `json:"url"` + OutboundIps []string `json:"outboundIps"` Status string `json:"status"` DisplayStatus string `json:"displayStatus"` CreatedAt time.Time `json:"createdAt"` @@ -126,7 +182,7 @@ type AgentRuntimeEndpointDto struct { } // AgentRuntimeEndpointCreateRequest is the body for POST -// /agent-runtimes/{id}/endpoints. name is required; version is optional +// /v1/agent-runtimes/{id}/endpoints. name is required; version is optional // (defaults server-side, minimum 1). type AgentRuntimeEndpointCreateRequest struct { Name string `json:"name"` diff --git a/mkdocs.yml b/mkdocs.yml index a4bb5b5..f047b02 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -105,6 +105,9 @@ nav: - outbound-auth oauth2 delete: commands/agentbase/access/outbound-auth-oauth2-delete.md - outbound-auth oauth2 m2m-token: commands/agentbase/access/outbound-auth-oauth2-m2m-token.md - outbound-auth oauth2 3lo-token: commands/agentbase/access/outbound-auth-oauth2-3lo-token.md + - outbound-auth managed list: commands/agentbase/access/outbound-auth-managed-list.md + - outbound-auth managed get: commands/agentbase/access/outbound-auth-managed-get.md + - outbound-auth managed 3lo-token: commands/agentbase/access/outbound-auth-managed-3lo-token.md - Gateway: - Overview: commands/agentbase/gateway/index.md - create: commands/agentbase/gateway/create.md @@ -122,6 +125,11 @@ nav: - private-network routes get: commands/agentbase/gateway/private-network-routes-get.md - private-network routes set: commands/agentbase/gateway/private-network-routes-set.md - service-account repair: commands/agentbase/gateway/service-account-repair.md + - mcp-connectors list: commands/agentbase/gateway/mcp-connectors-list.md + - mcp-connectors get: commands/agentbase/gateway/mcp-connectors-get.md + - mcp-server-templates list: commands/agentbase/gateway/mcp-server-templates-list.md + - target tools: commands/agentbase/gateway/target-tools.md + - target sync: commands/agentbase/gateway/target-sync.md - Runtime: - Overview: commands/agentbase/runtime/index.md - create: commands/agentbase/runtime/create.md From 53214f2d50f66bd6d4a3e74110475097e1784f32 Mon Sep 17 00:00:00 2001 From: anhpg Date: Mon, 26 Oct 2026 16:19:32 +0700 Subject: [PATCH 2/8] fix(agentbase): error on unknown subcommand instead of printing help Every agentbase group command lacked Run/RunE, so cobra treated them as non-runnable: a bare invocation printed help with exit 0 (fine), but an unknown subcommand word ALSO printed help and exited 0 -- a typo like 'grn agentbase runtime delte' looked successful. Add requireSubcommand (bare invocation -> help + exit 0; unknown word -> hard error pointing at --help) and wire it into the 44 group commands living in pure wiring files: access 7, agentbase 1, catalog 4, context 1, cr 5, deploy 1, gateway 12, memory 6, policy 3, tools 4. The remaining 5 (runtime 3, code-interpreter 2) land with their fix commits -- 49 total, 5 more than the audited 44 (5 memory sub-groups + runtimeEndpointCmd). Test: subcommand_test.go unit-tests both paths and walks the whole agentbase tree asserting every group is runnable, so future groups cannot regress silently. The vks command group has the same gap; fixing it is left as a follow-up. Refs #34838. --- go/cmd/agentbase/access.go | 7 +++ go/cmd/agentbase/agentbase.go | 1 + go/cmd/agentbase/catalog.go | 4 ++ go/cmd/agentbase/context.go | 1 + go/cmd/agentbase/cr.go | 5 +++ go/cmd/agentbase/deploy.go | 3 +- go/cmd/agentbase/gateway.go | 12 +++++ go/cmd/agentbase/memory.go | 11 ++--- go/cmd/agentbase/policy.go | 3 ++ go/cmd/agentbase/subcommand.go | 24 ++++++++++ go/cmd/agentbase/subcommand_test.go | 69 +++++++++++++++++++++++++++++ go/cmd/agentbase/tools.go | 4 ++ 12 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 go/cmd/agentbase/subcommand.go create mode 100644 go/cmd/agentbase/subcommand_test.go diff --git a/go/cmd/agentbase/access.go b/go/cmd/agentbase/access.go index 1daf5c2..f83a863 100644 --- a/go/cmd/agentbase/access.go +++ b/go/cmd/agentbase/access.go @@ -29,6 +29,7 @@ Authentication is shared with the rest of the CLI: machine credentials come from login from 'grn login'. Use 'grn logout' to clear a login. There is no separate agentbase login/logout — the profile's auth_mode (user vs machine) selects the token source agentbase uses, exactly like vks/vserver.`, + RunE: requireSubcommand, } // identity whoami / config show were removed: agentbase defers config display to @@ -42,6 +43,7 @@ var workloadCmd = &cobra.Command{ Use: "agent-id", Short: "Manage agent identities", Long: `Create, list, get, update, and delete agent identities used to represent digital identities for agents accessing external services.`, + RunE: requireSubcommand, } var ( @@ -279,6 +281,7 @@ var outboundAuthCmd = &cobra.Command{ Long: `Manage static API key, delegated API key, and OAuth2 providers for agent outbound authentication, plus read-only access to platform-managed OAuth2 providers.`, + RunE: requireSubcommand, } // --- outbound-auth static --- @@ -287,6 +290,7 @@ var staticCmd = &cobra.Command{ Use: "static", Short: "Manage static API key providers", Long: `Create, list, get, update, and delete static API key providers for outbound authentication.`, + RunE: requireSubcommand, } var staticCreateCmd = &cobra.Command{ @@ -493,6 +497,7 @@ var delegatedCmd = &cobra.Command{ Use: "delegated", Short: "Manage delegated API key providers", Long: `Create, list, get, and delete delegated API key providers for user-provided API keys for external services.`, + RunE: requireSubcommand, } var delegatedCreateCmd = &cobra.Command{ @@ -690,6 +695,7 @@ var oauth2Cmd = &cobra.Command{ Use: "oauth2", Short: "Manage OAuth2 providers", Long: `Create, list, get, update, and delete OAuth2 providers, and retrieve M2M and 3-legged OAuth2 tokens.`, + RunE: requireSubcommand, } var oauth2CreateCmd = &cobra.Command{ @@ -1046,6 +1052,7 @@ var managedOauth2Cmd = &cobra.Command{ never exposed, so the CLI surface is read-only (list, get) plus 3LO token retrieval for an agent identity. Each provider has a fixed callbackUrl the delegated browser flow returns to.`, + RunE: requireSubcommand, } var managedOauth2ListCmd = &cobra.Command{ diff --git a/go/cmd/agentbase/agentbase.go b/go/cmd/agentbase/agentbase.go index 00e6053..dd365a1 100644 --- a/go/cmd/agentbase/agentbase.go +++ b/go/cmd/agentbase/agentbase.go @@ -62,6 +62,7 @@ active environment and endpoints.`, cliinput.SetInteractive(interactiveMode) return applyAgentbaseQuery(cmd) }, + RunE: requireSubcommand, } // applyAgentbaseQuery wires grn's global --query flag (JMESPath, defined on diff --git a/go/cmd/agentbase/catalog.go b/go/cmd/agentbase/catalog.go index f552610..96969fe 100644 --- a/go/cmd/agentbase/catalog.go +++ b/go/cmd/agentbase/catalog.go @@ -31,24 +31,28 @@ Two groups: and switch version. Marketplace ops share the ~/.greennode profile and the runtime endpoint.`, + RunE: requireSubcommand, } // catalogFlavorsCmd groups the flavor commands. var catalogFlavorsCmd = &cobra.Command{ Use: "flavors", Short: "Browse compute flavors", + RunE: requireSubcommand, } // catalogOpenClawVersionsCmd groups the openclaw-version commands. var catalogOpenClawVersionsCmd = &cobra.Command{ Use: "openclaw-versions", Short: "Browse OpenClaw versions", + RunE: requireSubcommand, } // catalogOpenClawCmd groups the openclaw lifecycle commands. var catalogOpenClawCmd = &cobra.Command{ Use: "openclaw", Short: "Manage OpenClaws", + RunE: requireSubcommand, } // newCatalogClient mirrors newRuntimeClient (catalog is served by the runtime diff --git a/go/cmd/agentbase/context.go b/go/cmd/agentbase/context.go index 4e2991c..e863770 100644 --- a/go/cmd/agentbase/context.go +++ b/go/cmd/agentbase/context.go @@ -18,6 +18,7 @@ The environment selects the agentbase API endpoints AND the IAM v2 token URL; it is stored as iam_env in the shared ~/.greennode profile (default prod), the same selector vks/vserver use. Switch it with 'grn configure set iam_env ' (machine) or 'grn login --iam-env ' (user).`, + RunE: requireSubcommand, } var contextCurrentCmd = &cobra.Command{ diff --git a/go/cmd/agentbase/cr.go b/go/cmd/agentbase/cr.go index c534d98..5c4fa97 100644 --- a/go/cmd/agentbase/cr.go +++ b/go/cmd/agentbase/cr.go @@ -35,6 +35,7 @@ account used for 'docker login': The robot-account secret is real (it authorizes push/pull): it is MASKED in table output and revealed only with -o json. Use 'reset-secret' to rotate it.`, + RunE: requireSubcommand, } // newCRClient mirrors newPolicyClient: resolve the shared profile + env, select @@ -61,6 +62,7 @@ var crRepositoryCmd = &cobra.Command{ Short: "Show the user's container-registry repository", Long: `Show the user's auto-provisioned repository (name, registry URL, image count, quota). The repository + robot account are created on first access if absent.`, + RunE: requireSubcommand, } var crRepositoryGetCmd = &cobra.Command{ @@ -87,6 +89,7 @@ var crRepositoryGetCmd = &cobra.Command{ var crImageCmd = &cobra.Command{ Use: "image", Short: "List or delete images in the user's repository", + RunE: requireSubcommand, } var crImageListCmd = &cobra.Command{ @@ -160,6 +163,7 @@ var crImageDeleteCmd = &cobra.Command{ var crArtifactCmd = &cobra.Command{ Use: "artifact", Short: "List or delete artifacts (digests) within an image", + RunE: requireSubcommand, } var crArtifactListCmd = &cobra.Command{ @@ -247,6 +251,7 @@ var crRegistryCredentialCmd = &cobra.Command{ registry for push/pull. The secret is MASKED in table output and revealed only with -o json (so you can pipe it into 'docker login'). 'reset-secret' rotates the secret.`, + RunE: requireSubcommand, } var crRegistryCredentialGetCmd = &cobra.Command{ diff --git a/go/cmd/agentbase/deploy.go b/go/cmd/agentbase/deploy.go index edd6c42..129ea44 100644 --- a/go/cmd/agentbase/deploy.go +++ b/go/cmd/agentbase/deploy.go @@ -52,6 +52,7 @@ resolves the pull credentials from your auto-provisioned robot account. runtime to ACTIVE). ` + "`destroy`" + ` deletes runtime + memory; pass --purge to also delete the identity. ` + "`up`" + `/` + "`destroy`" + ` look resources up by name, so no state file is needed.`, + RunE: requireSubcommand, } // --------------------------------------------------------------------------- @@ -164,7 +165,7 @@ Defaults to YAML (with comments); pass -o json for a JSON skeleton.`, "env": map[string]string{"LOG_LEVEL": "info"}, "flavorId": "agent.small", "autoscaling": map[string]any{ - "maxReplicas": 3, + "maxReplicas": 3, "cpuUtilization": 70, "memoryUtilization": 80, }, }, diff --git a/go/cmd/agentbase/gateway.go b/go/cmd/agentbase/gateway.go index aa5f259..c57ddba 100644 --- a/go/cmd/agentbase/gateway.go +++ b/go/cmd/agentbase/gateway.go @@ -47,6 +47,7 @@ Besides the lifecycle, the group reads the connector catalog: 'mcp-connectors' lists/gets the MCP servers attached across your gateways (with ready connectUrls), 'mcp-server-templates' lists the template catalog, and 'target tools'/'target sync' read or refresh a target's tools/list.`, + RunE: requireSubcommand, } // newGatewayClient mirrors newIdentityClient: resolve the shared profile + @@ -878,6 +879,7 @@ var gatewayCreateName string var gatewayFlavorsCmd = &cobra.Command{ Use: "flavors", Short: "Gateway placement flavors", + RunE: requireSubcommand, } var gatewayFlavorsListCmd = &cobra.Command{ @@ -931,6 +933,7 @@ catalog. Filters are optional.`, var gatewayAccessLogsCmd = &cobra.Command{ Use: "access-logs", Short: "Gateway access logs", + RunE: requireSubcommand, } // addAccessLogFilterFlags registers the shared access-log filter flags on a @@ -1095,11 +1098,13 @@ func printCallerBuckets(buckets []gatewaypkg.AccessLogCallerBucket) { var gatewayInboundAuthCmd = &cobra.Command{ Use: "inbound-auth", Short: "Gateway inbound authentication", + RunE: requireSubcommand, } var gatewayInboundAuthJwtCmd = &cobra.Command{ Use: "jwt", Short: "JWT inbound-auth configuration", + RunE: requireSubcommand, } var gatewayInboundAuthJwtIdpAppCmd = &cobra.Command{ @@ -1108,6 +1113,7 @@ var gatewayInboundAuthJwtIdpAppCmd = &cobra.Command{ Long: `Manage the gateway's inbound-auth JWT IdP application credentials (the OAuth2 client/secret the gateway uses to talk to the IdP). The secret stays server-side after it is set.`, + RunE: requireSubcommand, } var gatewayInboundAuthJwtIdpAppSetCmd = &cobra.Command{ @@ -1164,11 +1170,13 @@ var gatewayInboundAuthJwtIdpAppClearCmd = &cobra.Command{ var gatewayPrivateNetworkCmd = &cobra.Command{ Use: "private-network", Short: "PRIVATE-mode gateway private network", + RunE: requireSubcommand, } var gatewayPrivateNetworkRoutesCmd = &cobra.Command{ Use: "routes", Short: "Private-network node routes (CIDRs)", + RunE: requireSubcommand, } var gatewayPrivateNetworkRoutesGetCmd = &cobra.Command{ @@ -1257,6 +1265,7 @@ concurrency; omit to force the replace.`, var gatewayServiceAccountCmd = &cobra.Command{ Use: "service-account", Short: "Gateway IAM service account", + RunE: requireSubcommand, } var gatewayServiceAccountRepairCmd = &cobra.Command{ @@ -1290,6 +1299,7 @@ the refreshed gateway.`, var gatewayMcpConnectorsCmd = &cobra.Command{ Use: "mcp-connectors", Short: "List MCP connectors (targets across gateways)", + RunE: requireSubcommand, } var gatewayMcpConnectorsListCmd = &cobra.Command{ @@ -1400,6 +1410,7 @@ Ownership is caller-scoped, so another user's gateway looks like a 404.`, var gatewayMcpServerTemplatesCmd = &cobra.Command{ Use: "mcp-server-templates", Short: "List MCP server templates", + RunE: requireSubcommand, } var gatewayMcpServerTemplatesListCmd = &cobra.Command{ @@ -1453,6 +1464,7 @@ filters that AND together.`, var gatewayTargetCmd = &cobra.Command{ Use: "target", Short: "Target tools on a gateway", + RunE: requireSubcommand, } var gatewayTargetToolsCmd = &cobra.Command{ diff --git a/go/cmd/agentbase/memory.go b/go/cmd/agentbase/memory.go index 95f4773..6af0743 100644 --- a/go/cmd/agentbase/memory.go +++ b/go/cmd/agentbase/memory.go @@ -41,6 +41,7 @@ template, fill it in, and apply with --file: grn agentbase memory generate > mem.yaml Memories share the ~/.greennode profile like the rest of agentbase.`, + RunE: requireSubcommand, } // newMemoryClient mirrors newRuntimeClient: resolve the shared profile + env, @@ -443,11 +444,11 @@ func loadMemorySpec(data []byte) (*memorypkg.CreateMemoryRequest, error) { // sub-resources (Slice 3): actor / session / event / strategy / record // --------------------------------------------------------------------------- -var memoryActorCmd = &cobra.Command{Use: "actor", Short: "Browse a memory's actors"} -var memorySessionCmd = &cobra.Command{Use: "session", Short: "Browse a memory's sessions"} -var memoryEventCmd = &cobra.Command{Use: "event", Short: "Manage a session's events"} -var memoryStrategyCmd = &cobra.Command{Use: "strategy", Short: "Browse a memory's long-term-memory strategies"} -var memoryRecordCmd = &cobra.Command{Use: "record", Short: "Manage a memory's long-term records"} +var memoryActorCmd = &cobra.Command{Use: "actor", Short: "Browse a memory's actors", RunE: requireSubcommand} +var memorySessionCmd = &cobra.Command{Use: "session", Short: "Browse a memory's sessions", RunE: requireSubcommand} +var memoryEventCmd = &cobra.Command{Use: "event", Short: "Manage a session's events", RunE: requireSubcommand} +var memoryStrategyCmd = &cobra.Command{Use: "strategy", Short: "Browse a memory's long-term-memory strategies", RunE: requireSubcommand} +var memoryRecordCmd = &cobra.Command{Use: "record", Short: "Manage a memory's long-term records", RunE: requireSubcommand} // --- actor list --- diff --git a/go/cmd/agentbase/policy.go b/go/cmd/agentbase/policy.go index 0697604..54a356e 100644 --- a/go/cmd/agentbase/policy.go +++ b/go/cmd/agentbase/policy.go @@ -37,6 +37,7 @@ here as a probe: Policy resources are synchronous (no WAITING_* FSM), so there is no 'wait'. Policy shares the ~/.greennode profile like the rest of agentbase.`, + RunE: requireSubcommand, } // newPolicyClient mirrors newMemoryClient: resolve the shared profile + env, @@ -66,6 +67,7 @@ var policyGroupCmd = &cobra.Command{ A group owns a set of policies and is what a gateway binds via its policyGroupId. Max 20 groups per user; deleting a group cascades to its policies.`, + RunE: requireSubcommand, } var policyGroupFile string @@ -276,6 +278,7 @@ var policyPolicyCmd = &cobra.Command{ Each policy is a permit/forbid rule compiled to Cedar. Max 10 policies per group. The group-id is the first positional argument of each subcommand (it maps to /policy-groups//policies).`, + RunE: requireSubcommand, } var policyFile string diff --git a/go/cmd/agentbase/subcommand.go b/go/cmd/agentbase/subcommand.go new file mode 100644 index 0000000..a13ed73 --- /dev/null +++ b/go/cmd/agentbase/subcommand.go @@ -0,0 +1,24 @@ +package agentbase + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// requireSubcommand is the RunE for group commands — commands that only own +// subcommands ('grn agentbase access', 'grn agentbase runtime', ...). +// +// Without a Run/RunE, cobra treats a non-root group as not runnable: a bare +// invocation prints help and exits 0, and — worse — an unknown subcommand word +// also prints help and exits 0 ('grn agentbase runtime bogus' looked like +// success to scripts and to the e2e runner, which treats exit 0 as PASS). This +// makes groups behave like the root command: bare invocation still prints help +// (exit 0, discoverability), but an unrecognized word is a hard error with the +// same wording cobra uses at the root. +func requireSubcommand(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + return fmt.Errorf("unknown subcommand %q for %q\nRun '%s --help' for usage.", args[0], cmd.CommandPath(), cmd.CommandPath()) +} diff --git a/go/cmd/agentbase/subcommand_test.go b/go/cmd/agentbase/subcommand_test.go new file mode 100644 index 0000000..2c8033e --- /dev/null +++ b/go/cmd/agentbase/subcommand_test.go @@ -0,0 +1,69 @@ +package agentbase + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestRequireSubcommand covers the group-command RunE: bare invocation prints +// help and exits 0 (discoverability), an unknown subcommand word is a hard +// error instead of silently printing help with a success exit code (34838). +func TestRequireSubcommand(t *testing.T) { + cmd := newSubcommandTestGroup(t) + + t.Run("bare_invocation_prints_help", func(t *testing.T) { + var out bytes.Buffer + cmd.SetOut(&out) + if err := requireSubcommand(cmd, nil); err != nil { + t.Fatalf("bare invocation must not error: %v", err) + } + if !strings.Contains(out.String(), "Usage:") { + t.Errorf("expected help output, got: %q", out.String()) + } + }) + + t.Run("unknown_word_errors", func(t *testing.T) { + err := requireSubcommand(cmd, []string{"bogus"}) + if err == nil { + t.Fatal("expected error for unknown subcommand") + } + if !strings.Contains(err.Error(), `unknown subcommand "bogus"`) { + t.Errorf("unexpected error text: %v", err) + } + if !strings.Contains(err.Error(), "--help") { + t.Errorf("error should point at --help: %v", err) + } + }) +} + +// TestGroupCommands_AreRunnable is the 34838 invariant as a tree walk: every +// command that owns subcommands must be runnable (Run/RunE set — wired to +// requireSubcommand), otherwise an unknown subcommand word silently prints +// help and exits 0. Walking the real tree also catches future groups that +// forget the wiring. +func TestGroupCommands_AreRunnable(t *testing.T) { + var walk func(c *cobra.Command, path string) + walk = func(c *cobra.Command, path string) { + if c.HasSubCommands() { + if !c.Runnable() { + t.Errorf("%s: group command without Run/RunE (unknown subcommands would exit 0)", path) + } + for _, child := range c.Commands() { + walk(child, path+" "+child.Name()) + } + } + } + walk(AgentbaseCmd, "agentbase") +} + +// newSubcommandTestGroup builds a throwaway group command with one child, so +// requireSubcommand's help/error paths are exercised on a real cobra command. +func newSubcommandTestGroup(t *testing.T) *cobra.Command { + t.Helper() + group := &cobra.Command{Use: "group", Short: "test group", RunE: requireSubcommand} + group.AddCommand(&cobra.Command{Use: "child", Short: "test child", RunE: func(*cobra.Command, []string) error { return nil }}) + return group +} diff --git a/go/cmd/agentbase/tools.go b/go/cmd/agentbase/tools.go index 247bb64..95c030b 100644 --- a/go/cmd/agentbase/tools.go +++ b/go/cmd/agentbase/tools.go @@ -43,6 +43,7 @@ endpoint awaits go-live. tools commands share the ~/.greennode profile and the built-in tools endpoint (see 'grn agentbase context current').`, + RunE: requireSubcommand, } // newToolsClient builds the tools client for the agent-core-built-in-tools @@ -160,6 +161,7 @@ Create is asynchronous: 201 CREATING → poll with 'wait' until ACTIVE (or FAILED). Sessions attach to a browser (see 'browser session') and expose the CDP automation stream, the live-view stream, and (after the session is deleted) its recording.`, + RunE: requireSubcommand, } var toolsBrowserListCmd = &cobra.Command{ @@ -406,6 +408,7 @@ recording (downloadable only after the session is deleted and archived). --end-user-id is optional (the X-End-User-ID header): the end user on whose behalf the session acts, 1-128 chars of A-Za-z0-9._:@- (validated server-side).`, + RunE: requireSubcommand, } var toolsBrowserSessionListCmd = &cobra.Command{ @@ -643,6 +646,7 @@ var toolsBrowserSessionStreamsCmd = &cobra.Command{ Only the automation stream's status is toggleable (ENABLED|DISABLED); the live-view stream is always on. The update returns no body, so the CLI re-reads the session and shows the resulting streams.`, + RunE: requireSubcommand, } var toolsBrowserSessionStreamsUpdateCmd = &cobra.Command{ From 80febcb09e5791ced38116d67ad3e91db7ac912c Mon Sep 17 00:00:00 2001 From: anhpg Date: Mon, 26 Oct 2026 16:19:46 +0700 Subject: [PATCH 3/8] fix(agentbase): runtime flag-mode create and update match the API contract create: the flag path dropped the name from the request; the server 400'd with "name must not be blank" while everything looked fine client-side. createRequestFromSpec now sends the prompt-resolved name. (Refs #34830) create/update: imageAuth is @NotNull server-side. readImageAuth returned nil for a public image, so the field was omitted from the JSON and the request 400'd ("imageAuth must not be null"). The disabled path now returns {enabled: false}, create --file defaults it the same way, and the generate template ships it uncommented. (Refs #34831) update: the API applies a full-spec replacement, but the CLI only required image-url/flavor-id, so every other field was zeroed on each flag-mode update (env wiped, network dropped, autoscaling reset, auth cleared). Unset flags now carry the current version's field over (a stderr note lists them); a set flag replaces its field wholesale (no element-level merge). imageAuth is the exception: the API never returns the stored password, so username/password auth cannot be carried over and errors instead of silently disabling; update --file without an explicit imageAuth is rejected for the same reason. readRuntimeSpec gains a forUpdate mode: no prompting for image-url/flavor-id (unset = carried over), but explicitly-set-but-empty is rejected. (Refs #34836) vpc-id/subnet-id/route-cidrs without --network-mode were silently ignored; they now error. help: runtime/create/update Long texts and the spec template describe the flag-driven contract. (Part of #34839) runtime/trace/endpoint groups are wired to requireSubcommand. (Part of #34838) models: the version DTO gains the typed ImageAuth/JWKS fields needed to read the current version for carry-over; the update request comment documents the contract. Tests: create-request name, update-mode readRuntimeSpec, dangling network flags, and a carry-over matrix (unset carries incl. network/inbound/JWT/ autoscaling/imageAuth branches, set replaces wholesale, password-auth carry fails safe). --- go/cmd/agentbase/runtime.go | 438 ++++++++++++++++++++---- go/cmd/agentbase/runtime_test.go | 287 +++++++++++++++- go/internal/agentbase/runtime/models.go | 9 +- 3 files changed, 647 insertions(+), 87 deletions(-) diff --git a/go/cmd/agentbase/runtime.go b/go/cmd/agentbase/runtime.go index 78a33d6..872d3f0 100644 --- a/go/cmd/agentbase/runtime.go +++ b/go/cmd/agentbase/runtime.go @@ -35,9 +35,11 @@ environment, autoscaling, and flavor. Runtimes converge asynchronously: create/update return the resource in a CREATING/UPDATING state and reach ACTIVE (or ERROR / SERVICE_ACCOUNT_ERROR). Use 'wait' to block until terminal. -The runtime API addresses resources by id (not name; name is immutable). Update -is a full-spec replacement — every field is required, so for anything beyond the -simple path, generate a template, fill it in, and apply with --file: +The runtime API addresses resources by id (not name; name is immutable). Create +is fully flag-driven (see 'create --help'); update carries fields whose flags +are unset over from the current version (see 'update --help'). For a full spec +from a file — or to keep the image-auth password out of the shell — generate a +template, fill it in, and apply with --file: grn agentbase runtime generate > rt.yaml # ...edit rt.yaml... @@ -45,6 +47,7 @@ simple path, generate a template, fill it in, and apply with --file: grn agentbase runtime wait Runtimes share the ~/.greennode profile like the rest of agentbase.`, + RunE: requireSubcommand, } // newRuntimeClient mirrors newGatewayClient: resolve the shared profile + env, @@ -86,23 +89,40 @@ type runtimeSpec struct { } // readRuntimeSpec reads + validates the mutable spec fields. name and poc are -// create-only and handled by the caller. Required (@NotEmpty) fields: -// imageUrl, flavorId; autoscaling has flag defaults so it is always populated. -func readRuntimeSpec(cmd *cobra.Command) (*runtimeSpec, error) { +// create-only and handled by the caller. On create (forUpdate=false) the +// @NotEmpty fields imageUrl/flavorId are prompted when unset; autoscaling has +// flag defaults so it is always populated. On update (forUpdate=true) unset +// flags are left empty — no prompting — and carried over from the current +// version by buildUpdateRequest; the autoscaling bounds are only checked when +// a scaling flag is actually set (the rest carries over). +func readRuntimeSpec(cmd *cobra.Command, forUpdate bool) (*runtimeSpec, error) { f := cmd.Flags() description, _ := f.GetString("description") imageURL, _ := f.GetString("image-url") - imageURL, err := cliinput.RequireOrPromptString(imageURL, "--image-url", "Container image URL") - if err != nil { - return nil, err + if forUpdate { + // Explicitly-set-but-empty is a user error; unset carries over. + if f.Changed("image-url") && imageURL == "" { + return nil, fmt.Errorf("--image-url cannot be empty") + } + } else { + var err error + if imageURL, err = cliinput.RequireOrPromptString(imageURL, "--image-url", "Container image URL"); err != nil { + return nil, err + } } flavorID, _ := f.GetString("flavor-id") - flavorID, err = cliinput.RequireOrPromptString(flavorID, "--flavor-id", "Flavor id") - if err != nil { - return nil, err + if forUpdate { + if f.Changed("flavor-id") && flavorID == "" { + return nil, fmt.Errorf("--flavor-id cannot be empty") + } + } else { + var err error + if flavorID, err = cliinput.RequireOrPromptString(flavorID, "--flavor-id", "Flavor id"); err != nil { + return nil, err + } } command, _ := f.GetStringArray("command") @@ -127,7 +147,7 @@ func readRuntimeSpec(cmd *cobra.Command) (*runtimeSpec, error) { CPUUtilization: cpuUtil, MemoryUtilization: memUtil, } - if autoscaling.MaxReplicas < 1 { + if autoscaling.MaxReplicas < 1 && (!forUpdate || f.Changed("max-replicas") || f.Changed("cpu-utilization") || f.Changed("memory-utilization")) { return nil, fmt.Errorf("--max-replicas (%d) must be >= 1", autoscaling.MaxReplicas) } @@ -174,6 +194,14 @@ func readNetworkConfig(cmd *cobra.Command) (*runtimepkg.NetworkConfig, error) { f := cmd.Flags() mode, _ := f.GetString("network-mode") if mode == "" { + // A dangling --vpc-id/--subnet-id/--route-cidrs without --network-mode + // used to be silently ignored — refuse it instead (it would otherwise + // read as "leave the network public" while the user meant VPC). + for _, name := range []string{"vpc-id", "subnet-id", "route-cidrs"} { + if f.Changed(name) { + return nil, fmt.Errorf("--%s only applies with --network-mode (PUBLIC or VPC)", name) + } + } return nil, nil } if mode != "PUBLIC" && mode != "VPC" { @@ -257,11 +285,14 @@ func readInboundAuth(cmd *cobra.Command) (*runtimepkg.InboundAuth, error) { }, nil } -// readImageAuth builds *ImageAuth only when --image-auth-enabled is set (or a -// username is supplied). username/password are required when auth is enabled. -// --image-auth-use-registry-credentials instead lets the platform pull with -// its own credentials (no username/password needed). The password is send-only -// (never present on responses); prefer --file for it. +// readImageAuth builds the imageAuth spec from the --image-auth-* flags. The +// API requires imageAuth to be present on create/update (@NotNull): with no +// image-auth flags (a public image) it returns {enabled: false} — a nil here +// used to make the server 400 on "imageAuth must not be null". username/ +// password are required when auth is enabled; --image-auth-use-registry- +// credentials instead lets the platform pull with its own credentials (no +// username/password needed). The password is send-only (never present on +// responses); prefer --file for it. func readImageAuth(cmd *cobra.Command) (*runtimepkg.ImageAuth, error) { f := cmd.Flags() enabled, _ := f.GetBool("image-auth-enabled") @@ -272,7 +303,8 @@ func readImageAuth(cmd *cobra.Command) (*runtimepkg.ImageAuth, error) { return &runtimepkg.ImageAuth{Enabled: true, UseAgentBaseRegistryCredentials: &useRegistry}, nil } if !enabled && username == "" { - return nil, nil + // Public image: the spec must still carry imageAuth {enabled: false}. + return &runtimepkg.ImageAuth{Enabled: false}, nil } if username == "" || password == "" { return nil, fmt.Errorf("--image-auth-enabled requires both --image-auth-username and --image-auth-password (or --image-auth-use-registry-credentials, or use --file)") @@ -306,10 +338,25 @@ var runtimeCreateCmd = &cobra.Command{ Short: "Create a new agent runtime", Long: `Create a new agent runtime. -By default the runtime is built from flags (the simple path). For environment -variables, private-registry auth, network placement (VPC), inbound auth (IAM or -JWT), a CIDR allowlist, an MCP protocol runtime, or multi-element command/args, -use --file with a template produced by 'grn agentbase runtime generate'. +The whole spec is flag-driven. --name (immutable, prompted when unset), +--image-url, --flavor-id, repeatable --command/--args/--env KEY=VALUE, and +autoscaling (--max-replicas, --cpu-utilization, --memory-utilization; unset +sends the defaults 2/70/70). + +Private-registry auth: --image-auth-username/--image-auth-password, or +--image-auth-use-registry-credentials to let the platform pull with its own +credentials. imageAuth is required by the API: with no image-auth flags the +request sends {enabled: false} (public image). + +Network placement: --network-mode VPC with --vpc-id/--subnet-id and optional +repeatable --route-cidrs (unset = PUBLIC). Inbound auth: --inbound-mode +NONE/IAM/JWT (JWT adds --inbound-jwt-source DISCOVERY|JWKS and its flags). +Inbound CIDR allowlist: repeatable --allowed-cidrs. Protocol: --protocol +HTTP|MCP. + +--file applies a full spec instead ('grn agentbase runtime generate' template) +— prefer it to keep the image-auth password (and inline JWKS key sets) out of +the shell history. The runtime is created asynchronously; this command returns as soon as the service accepts the spec (state CREATING). Converge with @@ -325,6 +372,12 @@ service accepts the spec (state CREATING). Converge with if err != nil { return err } + // imageAuth is @NotNull server-side: a spec without it is a + // public image — send {enabled: false} instead of letting the + // server 400 on a missing field. + if req.ImageAuth == nil { + req.ImageAuth = &runtimepkg.ImageAuth{Enabled: false} + } return createRuntimeAndPrint(ctx, cmd, req) } name, _ := cmd.Flags().GetString("name") @@ -332,24 +385,11 @@ service accepts the spec (state CREATING). Converge with if err != nil { return err } - spec, err := readRuntimeSpec(cmd) + spec, err := readRuntimeSpec(cmd, false) if err != nil { return err } - req := &runtimepkg.CreateAgentRuntimeRequest{ - Description: spec.Description, - ImageURL: spec.ImageURL, - ImageAuth: spec.ImageAuth, - Command: spec.Command, - Args: spec.Args, - EnvironmentVariables: spec.Env, - NetworkConfig: spec.Network, - AllowedCidrs: spec.AllowedCidrs, - InboundAuth: spec.Inbound, - Protocol: spec.Protocol, - FlavorID: spec.FlavorID, - Autoscaling: spec.Autoscaling, - } + req := createRequestFromSpec(name, spec) // poc is create-only: send it only when the flag was explicitly set. if cmd.Flags().Changed("poc") { poc, _ := cmd.Flags().GetBool("poc") @@ -359,6 +399,28 @@ service accepts the spec (state CREATING). Converge with }, } +// createRequestFromSpec assembles the create request from the prompt-resolved +// name and the flag-read spec. The name must reach the request — a regression +// once dropped it and the server 400'd with "name must not be blank" while the +// flag path looked fine. Unit-tested in runtime_test.go. +func createRequestFromSpec(name string, spec *runtimeSpec) *runtimepkg.CreateAgentRuntimeRequest { + return &runtimepkg.CreateAgentRuntimeRequest{ + Name: name, + Description: spec.Description, + ImageURL: spec.ImageURL, + ImageAuth: spec.ImageAuth, + Command: spec.Command, + Args: spec.Args, + EnvironmentVariables: spec.Env, + NetworkConfig: spec.Network, + AllowedCidrs: spec.AllowedCidrs, + InboundAuth: spec.Inbound, + Protocol: spec.Protocol, + FlavorID: spec.FlavorID, + Autoscaling: spec.Autoscaling, + } +} + func createRuntimeAndPrint(ctx context.Context, cmd *cobra.Command, req *runtimepkg.CreateAgentRuntimeRequest) error { client, err := newRuntimeClient(ctx, cmd) if err != nil { @@ -418,17 +480,22 @@ Defaults to YAML (with comments); pass -o json for a JSON skeleton.`, const runtimeCreateTemplateYAML = `# Agent runtime create spec. # Fill in and apply with: grn agentbase runtime create --file # -# Required: name, imageUrl, flavorId, autoscaling (and command/args/env, which -# may be empty lists/map but must be present). name is sealed (immutable); -# 5-50 chars, [a-zA-Z0-9._-]. +# Required: name, imageUrl, flavorId, imageAuth, autoscaling (and command/args/ +# env, which may be empty lists/map but must be present). name is sealed +# (immutable); 5-50 chars, [a-zA-Z0-9._-]. name: my-runtime # immutable description: "" imageUrl: registry.example.com/my-agent:latest -# imageAuth: # optional private-registry credentials (send-only) -# enabled: true -# useAgentBaseRegistryCredentials: false # let the platform pull with its own registry credentials -# username: "" -# password: "" # prefer keeping this in the file, not on the CLI +# imageAuth is required by the API. enabled: false (public image) is the +# default body below — for a private registry, enable it and fill username + +# password (send-only; the API never returns the password), or set +# useAgentBaseRegistryCredentials to let the platform pull with its own +# registry credentials. +imageAuth: + enabled: false + # useAgentBaseRegistryCredentials: false + # username: "" + # password: "" # prefer keeping this in the file, not on the CLI command: # container entrypoint (list); may be empty - python - -m @@ -541,17 +608,33 @@ var runtimeGetCmd = &cobra.Command{ var runtimeUpdateCmd = &cobra.Command{ Use: "update ", - Short: "Update an agent runtime (full-spec replacement)", - Long: `Update an agent runtime. Unlike gateway, this is a FULL-SPEC replacement, not a -merge-patch: every field is required server-side (the create spec minus name). -Updating creates a new version and rolls the default endpoint forward. + Short: "Update an agent runtime (new immutable version)", + Long: `Update an agent runtime. Updating creates a new immutable version and rolls +the default endpoint forward. + +The API is a full-spec replacement, but the flag path is merge-like: a field +whose flags are left unset is carried over from the current version (a stderr +note lists the carried fields), and a set flag replaces its field wholesale +(no element-level merge). Set flags only for what you intend to change. + +imageAuth is the exception — the API never returns the stored password, so +username/password image auth cannot be carried over: re-supply +--image-auth-username/--image-auth-password (or --file with a full spec). +Without image auth the update re-sends {enabled: false}. -For anything beyond the simple path, use --file with the create template -('grn agentbase runtime generate') minus the name field.`, +--file applies a full spec instead ('grn agentbase runtime generate' template +minus name) — it must include imageAuth explicitly, since a missing imageAuth +would silently wipe stored credentials. + +The update is asynchronous; converge with 'grn agentbase runtime wait '.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() id := args[0] + client, err := newRuntimeClient(ctx, cmd) + if err != nil { + return err + } if runtimeFile != "" { data, err := os.ReadFile(runtimeFile) if err != nil { @@ -561,9 +644,17 @@ For anything beyond the simple path, use --file with the create template if err != nil { return err } + // imageAuth is required server-side and cannot be defaulted on + // update: a missing imageAuth would silently wipe the stored + // credentials. Make the user state it — {enabled: false} drops + // them deliberately, username/password or + // useAgentBaseRegistryCredentials keeps them. + if createReq.ImageAuth == nil { + return fmt.Errorf("spec is missing required field: imageAuth (the API never returns the stored password, so it cannot be carried over; set imageAuth explicitly: {enabled: false} drops it, username/password or useAgentBaseRegistryCredentials keeps it)") + } // poc is create-only: a spec file carrying poc is accepted for // convenience but the field is not forwarded on update. - return updateRuntimeAndPrint(ctx, cmd, id, &runtimepkg.UpdateAgentRuntimeRequest{ + return updateRuntimeAndPrint(ctx, client, cmd, id, &runtimepkg.UpdateAgentRuntimeRequest{ Description: createReq.Description, ImageURL: createReq.ImageURL, ImageAuth: createReq.ImageAuth, @@ -578,32 +669,29 @@ For anything beyond the simple path, use --file with the create template Autoscaling: createReq.Autoscaling, }) } - spec, err := readRuntimeSpec(cmd) + spec, err := readRuntimeSpec(cmd, true) if err != nil { return err } - return updateRuntimeAndPrint(ctx, cmd, id, &runtimepkg.UpdateAgentRuntimeRequest{ - Description: spec.Description, - ImageURL: spec.ImageURL, - ImageAuth: spec.ImageAuth, - Command: spec.Command, - Args: spec.Args, - EnvironmentVariables: spec.Env, - NetworkConfig: spec.Network, - AllowedCidrs: spec.AllowedCidrs, - InboundAuth: spec.Inbound, - Protocol: spec.Protocol, - FlavorID: spec.FlavorID, - Autoscaling: spec.Autoscaling, - }) + // Full-spec replacement: base the request on the current version and + // let the changed flags override — unset flags used to reset fields to + // the server defaults (inboundAuth NONE, networkConfig PUBLIC, ...) . + cur, err := latestVersion(ctx, client, id) + if err != nil { + return err + } + req, carried, err := buildUpdateRequest(spec, cur, cmd.Flags().Changed) + if err != nil { + return err + } + if len(carried) > 0 { + fmt.Fprintf(os.Stderr, "Carrying over from version %d: %s\n", cur.Version, strings.Join(carried, ", ")) + } + return updateRuntimeAndPrint(ctx, client, cmd, id, req) }, } -func updateRuntimeAndPrint(ctx context.Context, cmd *cobra.Command, id string, req *runtimepkg.UpdateAgentRuntimeRequest) error { - client, err := newRuntimeClient(ctx, cmd) - if err != nil { - return err - } +func updateRuntimeAndPrint(ctx context.Context, client *runtimepkg.Client, cmd *cobra.Command, id string, req *runtimepkg.UpdateAgentRuntimeRequest) error { rt, err := client.Update(ctx, id, req) if err != nil { return err @@ -613,6 +701,207 @@ func updateRuntimeAndPrint(ctx context.Context, cmd *cobra.Command, id string, r return output.PrintResource(rt, func() string { return rt.ID }, func() error { return renderRuntimeDetail(rt) }) } +// imageAuthFlags are the flags that explicitly set the imageAuth spec; any of +// them marks it as user-provided on update (otherwise it is derived from the +// current version). +var imageAuthFlags = []string{"image-auth-enabled", "image-auth-use-registry-credentials", "image-auth-username", "image-auth-password"} + +// networkFlags are the flags that explicitly set networkConfig. +var networkFlags = []string{"network-mode", "vpc-id", "subnet-id", "route-cidrs"} + +// autoscalingFlags are the flags that explicitly set autoscaling. +var autoscalingFlags = []string{"max-replicas", "cpu-utilization", "memory-utilization"} + +// anyChanged reports whether any of the named flags was explicitly set. +func anyChanged(changed func(string) bool, names ...string) bool { + for _, name := range names { + if changed(name) { + return true + } + } + return false +} + +// buildUpdateRequest assembles the update request for the flag path. The API +// is a full-spec replacement, so a field whose flags are unset is carried over +// from the current version (and recorded in the returned carry list for the +// caller's stderr note) instead of silently resetting to the server defaults +// (inboundAuth NONE, networkConfig PUBLIC, empty env/command/args). A changed +// flag replaces its field wholesale (no element-level merge). +// +// imageAuth is the exception: the API never returns the stored password and an +// update with enabled=false wipes the stored credentials, so it cannot be +// carried over blindly — see carriedImageAuth. +func buildUpdateRequest(spec *runtimeSpec, cur *runtimepkg.AgentRuntimeVersionDto, changed func(string) bool) (*runtimepkg.UpdateAgentRuntimeRequest, []string, error) { + req := &runtimepkg.UpdateAgentRuntimeRequest{} + var carried []string + + if changed("description") { + req.Description = spec.Description + } else { + req.Description = cur.Description + carried = append(carried, "description") + } + if changed("image-url") { + req.ImageURL = spec.ImageURL + } else { + req.ImageURL = cur.ImageURL + carried = append(carried, "imageUrl") + } + if changed("command") { + req.Command = spec.Command + } else { + req.Command = cur.Command + carried = append(carried, "command") + } + if changed("args") { + req.Args = spec.Args + } else { + req.Args = cur.Args + carried = append(carried, "args") + } + if changed("env") { + req.EnvironmentVariables = spec.Env + } else { + req.EnvironmentVariables = cur.EnvironmentVariables + carried = append(carried, "environmentVariables") + } + if anyChanged(changed, networkFlags...) { + req.NetworkConfig = spec.Network + } else { + req.NetworkConfig = versionNetworkConfig(cur) + carried = append(carried, "networkConfig") + } + if changed("allowed-cidrs") { + req.AllowedCidrs = spec.AllowedCidrs + } else { + req.AllowedCidrs = cur.AllowedCidrs + carried = append(carried, "allowedCidrs") + } + if changed("inbound-mode") { + req.InboundAuth = spec.Inbound + } else { + req.InboundAuth = versionInboundAuth(cur) + carried = append(carried, "inboundAuth") + } + if changed("protocol") { + req.Protocol = spec.Protocol + } else { + req.Protocol = cur.Protocol + carried = append(carried, "protocol") + } + if changed("flavor-id") { + req.FlavorID = spec.FlavorID + } else { + req.FlavorID = cur.FlavorID + carried = append(carried, "flavorId") + } + if anyChanged(changed, autoscalingFlags...) { + req.Autoscaling = spec.Autoscaling + } else { + req.Autoscaling = cur.Autoscaling + carried = append(carried, "autoscaling") + } + + if anyChanged(changed, imageAuthFlags...) { + req.ImageAuth = spec.ImageAuth + } else { + ia, err := carriedImageAuth(cur) + if err != nil { + return nil, nil, err + } + req.ImageAuth = ia + carried = append(carried, "imageAuth") + } + return req, carried, nil +} + +// carriedImageAuth rebuilds the update's imageAuth from the current version. +// See buildUpdateRequest for why username/password auth cannot be carried over +// (the API never returns the stored password, and defaulting to enabled=false +// would wipe it). +func carriedImageAuth(cur *runtimepkg.AgentRuntimeVersionDto) (*runtimepkg.ImageAuth, error) { + if cur.ImageAuth == nil || !cur.ImageAuth.Enabled { + // imageAuth is required server-side: re-send {enabled: false}. + return &runtimepkg.ImageAuth{Enabled: false}, nil + } + if cur.ImageAuth.UseAgentBaseRegistryCredentials { + // Registry-credentials mode needs no password: carry it verbatim. + useRegistry := true + return &runtimepkg.ImageAuth{ + Enabled: true, + UseAgentBaseRegistryCredentials: &useRegistry, + Username: cur.ImageAuth.Username, + }, nil + } + return nil, fmt.Errorf("current version uses username/password image auth: the API never returns the stored password, so it cannot be carried over; re-supply --image-auth-username/--image-auth-password (or apply a full spec with --file)") +} + +// versionNetworkConfig converts a version's networkConfig entity into the +// update request's NetworkConfig (same shape, distinct types). +func versionNetworkConfig(cur *runtimepkg.AgentRuntimeVersionDto) *runtimepkg.NetworkConfig { + if cur.NetworkConfig == nil { + return nil + } + return &runtimepkg.NetworkConfig{ + Mode: cur.NetworkConfig.Mode, + VpcID: cur.NetworkConfig.VpcID, + SubnetID: cur.NetworkConfig.SubnetID, + RouteCidrs: cur.NetworkConfig.RouteCidrs, + } +} + +// versionInboundAuth converts a version's inboundAuth view into the update +// request's InboundAuth (same shape, distinct types; JWKS is copied raw). +func versionInboundAuth(cur *runtimepkg.AgentRuntimeVersionDto) *runtimepkg.InboundAuth { + if cur.InboundAuth == nil { + return nil + } + ia := &runtimepkg.InboundAuth{Mode: cur.InboundAuth.Mode} + if cur.InboundAuth.JWT != nil { + jwks := cur.InboundAuth.JWT.JWKS + if len(jwks) == 0 { + jwks = nil + } + ia.JWT = &runtimepkg.InboundAuthJWT{ + Source: cur.InboundAuth.JWT.Source, + JWKS: jwks, + DiscoveryURL: cur.InboundAuth.JWT.DiscoveryURL, + AllowedAudiences: cur.InboundAuth.JWT.AllowedAudiences, + AllowedClients: cur.InboundAuth.JWT.AllowedClients, + AllowedScopes: cur.InboundAuth.JWT.AllowedScopes, + PrincipalClaim: cur.InboundAuth.JWT.PrincipalClaim, + } + } + return ia +} + +// latestVersion fetches the runtime's current version spec: the entry with the +// highest version number, scanning every page of the versions list (each +// update appends a new immutable version, so the last one is the live spec). +func latestVersion(ctx context.Context, client *runtimepkg.Client, id string) (*runtimepkg.AgentRuntimeVersionDto, error) { + const size = 100 + var latest *runtimepkg.AgentRuntimeVersionDto + for page := 1; page <= 100; page++ { + resp, err := client.ListVersions(ctx, id, page, size) + if err != nil { + return nil, fmt.Errorf("read current version: %w", err) + } + for i := range resp.ListData { + if latest == nil || resp.ListData[i].Version > latest.Version { + latest = &resp.ListData[i] + } + } + if page >= resp.TotalPage || len(resp.ListData) == 0 { + break + } + } + if latest == nil { + return nil, fmt.Errorf("runtime %s has no versions to carry fields over from", id) + } + return latest, nil +} + // --------------------------------------------------------------------------- // delete // --------------------------------------------------------------------------- @@ -769,7 +1058,7 @@ func loadRuntimeSpec(data []byte, requireName bool) (*runtimepkg.CreateAgentRunt // Sub-resources (Slice 4): endpoints / runtime-level ops / tracing // --------------------------------------------------------------------------- -var runtimeEndpointCmd = &cobra.Command{Use: "endpoint", Short: "Manage a runtime's endpoints"} +var runtimeEndpointCmd = &cobra.Command{Use: "endpoint", Short: "Manage a runtime's endpoints", RunE: requireSubcommand} var runtimeTraceCmd = &cobra.Command{ Use: "trace", Short: "Query the tracing backend (raw passthrough)", @@ -779,6 +1068,7 @@ These are Google-AIP custom verbs that forward arbitrary query params to the tracing backend and return that backend's raw JSON. Use --param key=value (repeatable) to pass query params through verbatim; the response is printed raw (use -o json for clean stdout).`, + RunE: requireSubcommand, } // --- endpoint list/create/update/delete/start/stop --- diff --git a/go/cmd/agentbase/runtime_test.go b/go/cmd/agentbase/runtime_test.go index 2b653dc..4d14bda 100644 --- a/go/cmd/agentbase/runtime_test.go +++ b/go/cmd/agentbase/runtime_test.go @@ -1,6 +1,8 @@ package agentbase import ( + "encoding/json" + "slices" "strings" "testing" @@ -161,14 +163,21 @@ func mustSet(t *testing.T, cmd *cobra.Command, flag, value string) { } } -// TestReadImageAuth covers the three paths: none → nil; enabled+creds → built; -// enabled without password → error. +// TestReadImageAuth covers the three paths: none → {enabled:false} (the API +// requires imageAuth present — nil used to 400 server-side); enabled+creds → +// built; enabled without password → error. func TestReadImageAuth(t *testing.T) { - t.Run("none_returns_nil", func(t *testing.T) { + t.Run("none_returns_disabled_default", func(t *testing.T) { cmd := newSpecCmd(t) got, err := readImageAuth(cmd) - if err != nil || got != nil { - t.Errorf("expected (nil, nil), got (%+v, %v)", got, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || got.Enabled { + t.Errorf("expected &ImageAuth{Enabled:false}, got %+v", got) + } + if got.Username != "" || got.Password != "" || got.UseAgentBaseRegistryCredentials != nil { + t.Errorf("disabled default must carry no credentials: %+v", got) } }) t.Run("enabled_with_creds", func(t *testing.T) { @@ -203,7 +212,7 @@ func TestReadRuntimeSpec_AutoscalingMaxLtOneErrors(t *testing.T) { mustSet(t, cmd, "image-url", "img") mustSet(t, cmd, "flavor-id", "f") mustSet(t, cmd, "max-replicas", "0") // < 1 - if _, err := readRuntimeSpec(cmd); err == nil { + if _, err := readRuntimeSpec(cmd, false); err == nil { t.Error("expected error when max-replicas < 1") } } @@ -217,7 +226,7 @@ func TestReadRuntimeSpec_OK(t *testing.T) { mustSet(t, cmd, "flavor-id", "f1") mustSet(t, cmd, "command", "python") mustSet(t, cmd, "env", "K=V") - s, err := readRuntimeSpec(cmd) + s, err := readRuntimeSpec(cmd, false) if err != nil { t.Fatalf("readRuntimeSpec: %v", err) } @@ -233,8 +242,8 @@ func TestReadRuntimeSpec_OK(t *testing.T) { if s.Autoscaling.MaxReplicas != 2 { // flag defaults (minReplicas is platform-owned) t.Errorf("autoscaling default mismatch: %+v", s.Autoscaling) } - if s.ImageAuth != nil { - t.Errorf("imageAuth should be nil when not enabled: %+v", s.ImageAuth) + if s.ImageAuth == nil || s.ImageAuth.Enabled { + t.Errorf("imageAuth should default to &ImageAuth{Enabled:false}: %+v", s.ImageAuth) } // Ensure the returned spec round-trips into a request struct. _ = runtimepkg.CreateAgentRuntimeRequest{ @@ -379,7 +388,7 @@ func TestReadRuntimeSpec_NewFieldsAssemble(t *testing.T) { mustSet(t, cmd, "subnet-id", "sub-1") mustSet(t, cmd, "inbound-mode", "IAM") mustSet(t, cmd, "min-replicas", "3") // deprecated: tolerated, ignored - s, err := readRuntimeSpec(cmd) + s, err := readRuntimeSpec(cmd, false) if err != nil { t.Fatalf("readRuntimeSpec: %v", err) } @@ -407,3 +416,261 @@ func TestReadImageAuth_UseRegistryCredentials(t *testing.T) { t.Errorf("unexpected: %+v", got) } } + +// TestCreateRequestFromSpec verifies the flag-path create request carries the +// name (34830: the request literal used to omit it, so create-by-flags always +// failed server-side with a name validation error). +func TestCreateRequestFromSpec(t *testing.T) { + spec := &runtimeSpec{ + ImageURL: "img:1", FlavorID: "f1", Command: []string{"python"}, + Env: map[string]string{"K": "V"}, + ImageAuth: &runtimepkg.ImageAuth{Enabled: false}, + Autoscaling: runtimepkg.Autoscaling{MaxReplicas: 2}, + } + req := createRequestFromSpec("my-rt", spec) + if req.Name != "my-rt" { + t.Errorf("name missing from create request: %+v", req) + } + if req.ImageURL != "img:1" || req.FlavorID != "f1" || req.Autoscaling.MaxReplicas != 2 { + t.Errorf("spec fields not mapped: %+v", req) + } + if req.ImageAuth == nil || req.ImageAuth.Enabled { + t.Errorf("imageAuth should be &ImageAuth{Enabled:false}: %+v", req.ImageAuth) + } +} + +// TestReadRuntimeSpec_ForUpdate covers the update mode: no prompting for +// image-url/flavor-id (unset = carry over), explicitly-set-but-empty rejected, +// and the autoscaling max>=1 guard skipped when no autoscaling flag is set +// (the guard runs on the merged request, not the partial spec). +func TestReadRuntimeSpec_ForUpdate(t *testing.T) { + cliinput.SetInteractive(false) + t.Run("unset_image_and_flavor_ok", func(t *testing.T) { + cmd := newSpecCmd(t) + s, err := readRuntimeSpec(cmd, true) + if err != nil { + t.Fatalf("readRuntimeSpec(forUpdate): %v", err) + } + if s.ImageURL != "" || s.FlavorID != "" { + t.Errorf("unset flags must stay empty on update: %+v", s) + } + }) + t.Run("explicitly_empty_image_url_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "image-url", "") + if _, err := readRuntimeSpec(cmd, true); err == nil { + t.Error("expected error for explicitly-set-but-empty --image-url") + } + }) + t.Run("explicitly_empty_flavor_id_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "flavor-id", "") + if _, err := readRuntimeSpec(cmd, true); err == nil { + t.Error("expected error for explicitly-set-but-empty --flavor-id") + } + }) + t.Run("autoscaling_guard_skipped_when_unset", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "image-url", "img") + mustSet(t, cmd, "flavor-id", "f") + if _, err := readRuntimeSpec(cmd, true); err != nil { + t.Errorf("update mode must not require autoscaling flags: %v", err) + } + }) + t.Run("autoscaling_guard_applies_when_set", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "max-replicas", "0") + if _, err := readRuntimeSpec(cmd, true); err == nil { + t.Error("expected error when --max-replicas < 1 is explicitly set") + } + }) +} + +// TestReadNetworkConfig_DanglingFlagsError covers vpc-id/subnet-id/route-cidrs +// set without --network-mode: previously silently ignored, now an error. +func TestReadNetworkConfig_DanglingFlagsError(t *testing.T) { + for _, flag := range []string{"vpc-id", "subnet-id"} { + cmd := newSpecCmd(t) + mustSet(t, cmd, flag, "x") + if _, err := readNetworkConfig(cmd); err == nil { + t.Errorf("expected error for dangling --%s", flag) + } + } + cmd := newSpecCmd(t) + mustSet(t, cmd, "route-cidrs", "10.0.0.0/8") + if _, err := readNetworkConfig(cmd); err == nil { + t.Error("expected error for dangling --route-cidrs") + } +} + +// testVersion is a current-version fixture for buildUpdateRequest: every field +// distinct so carry-over vs override is observable. +func testVersion() *runtimepkg.AgentRuntimeVersionDto { + return &runtimepkg.AgentRuntimeVersionDto{ + Version: 7, + Description: "cur-desc", + ImageURL: "cur-img:7", + Command: []string{"cur-cmd"}, + Args: []string{"cur-arg"}, + EnvironmentVariables: map[string]string{"CUR": "1"}, + NetworkConfig: &runtimepkg.AgentRuntimeNetworkConfigEntity{ + Mode: "VPC", VpcID: "cur-vpc", SubnetID: "cur-sub", RouteCidrs: []string{"10.0.0.0/8"}, + }, + AllowedCidrs: []string{"10.0.0.0/8"}, + InboundAuth: &runtimepkg.AgentRuntimeInboundAuthDto{ + Mode: "JWT", + JWT: &runtimepkg.AgentRuntimeInboundAuthJwtDto{ + Source: "JWKS", JWKS: json.RawMessage(`{"keys": []}`), + AllowedAudiences: []string{"aud"}, PrincipalClaim: "sub", + }, + }, + Protocol: "MCP", + FlavorID: "cur-flavor", + Autoscaling: runtimepkg.Autoscaling{MaxReplicas: 3}, + } +} + +// specFromFlags assembles the update-path spec from a flag command. +func specFromFlags(t *testing.T, cmd *cobra.Command) *runtimeSpec { + t.Helper() + s, err := readRuntimeSpec(cmd, true) + if err != nil { + t.Fatalf("readRuntimeSpec(forUpdate): %v", err) + } + return s +} + +// TestBuildUpdateRequest covers the update merge semantics (34836): unset +// flags carry the current version's field over; changed flags replace their +// field wholesale; imageAuth is derived from the current version with the +// password caveat. +func TestBuildUpdateRequest(t *testing.T) { + t.Run("nothing_set_carries_everything", func(t *testing.T) { + cmd := newSpecCmd(t) + cur := testVersion() + req, carried, err := buildUpdateRequest(specFromFlags(t, cmd), cur, cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.Description != "cur-desc" || req.ImageURL != "cur-img:7" || + req.Command[0] != "cur-cmd" || req.Args[0] != "cur-arg" || + req.EnvironmentVariables["CUR"] != "1" || req.FlavorID != "cur-flavor" || + req.Protocol != "MCP" || req.AllowedCidrs[0] != "10.0.0.0/8" || + req.Autoscaling.MaxReplicas != 3 { + t.Errorf("carry-over mismatch: %+v", req) + } + if req.NetworkConfig == nil || req.NetworkConfig.Mode != "VPC" || req.NetworkConfig.VpcID != "cur-vpc" || + req.NetworkConfig.SubnetID != "cur-sub" || req.NetworkConfig.RouteCidrs[0] != "10.0.0.0/8" { + t.Errorf("networkConfig carry-over mismatch: %+v", req.NetworkConfig) + } + if req.InboundAuth == nil || req.InboundAuth.Mode != "JWT" || req.InboundAuth.JWT == nil || + req.InboundAuth.JWT.Source != "JWKS" || string(req.InboundAuth.JWT.JWKS) != `{"keys": []}` || + req.InboundAuth.JWT.AllowedAudiences[0] != "aud" || req.InboundAuth.JWT.PrincipalClaim != "sub" { + t.Errorf("inboundAuth carry-over mismatch: %+v", req.InboundAuth) + } + for _, f := range []string{"description", "imageUrl", "command", "args", "environmentVariables", + "networkConfig", "allowedCidrs", "inboundAuth", "protocol", "flavorId", "autoscaling", "imageAuth"} { + if !slices.Contains(carried, f) { + t.Errorf("carried list missing %s: %v", f, carried) + } + } + }) + t.Run("changed_flag_replaces_field_wholesale", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "env", "NEW=2") + req, _, err := buildUpdateRequest(specFromFlags(t, cmd), testVersion(), cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.EnvironmentVariables["NEW"] != "2" || len(req.EnvironmentVariables) != 1 { + t.Errorf("env should be replaced wholesale, not merged: %+v", req.EnvironmentVariables) + } + if req.ImageURL != "cur-img:7" { + t.Errorf("unset image-url should carry over: %+v", req) + } + }) + t.Run("network_flag_replaces_network", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "network-mode", "VPC") + mustSet(t, cmd, "vpc-id", "v1") + mustSet(t, cmd, "subnet-id", "s1") + req, _, err := buildUpdateRequest(specFromFlags(t, cmd), testVersion(), cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.NetworkConfig == nil || req.NetworkConfig.VpcID != "v1" || + req.NetworkConfig.SubnetID != "s1" || len(req.NetworkConfig.RouteCidrs) != 0 { + t.Errorf("networkConfig should be the flag spec: %+v", req.NetworkConfig) + } + }) + t.Run("imageauth_password_auth_cannot_carry", func(t *testing.T) { + cur := testVersion() + cur.ImageAuth = &runtimepkg.AgentRuntimeImageAuthDto{Enabled: true, Username: "u"} + cmd := newSpecCmd(t) + _, _, err := buildUpdateRequest(specFromFlags(t, cmd), cur, cmd.Flags().Changed) + if err == nil { + t.Error("expected error carrying username/password image auth") + } + }) + t.Run("imageauth_registry_credentials_carries", func(t *testing.T) { + cur := testVersion() + cur.ImageAuth = &runtimepkg.AgentRuntimeImageAuthDto{Enabled: true, UseAgentBaseRegistryCredentials: true, Username: "u"} + cmd := newSpecCmd(t) + req, _, err := buildUpdateRequest(specFromFlags(t, cmd), cur, cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + ia := req.ImageAuth + if ia == nil || !ia.Enabled || ia.UseAgentBaseRegistryCredentials == nil || !*ia.UseAgentBaseRegistryCredentials || + ia.Username != "u" || ia.Password != "" { + t.Errorf("registry-credentials imageAuth should carry verbatim: %+v", ia) + } + }) + t.Run("imageauth_disabled_carries_as_disabled", func(t *testing.T) { + cur := testVersion() // ImageAuth nil = disabled + cmd := newSpecCmd(t) + req, _, err := buildUpdateRequest(specFromFlags(t, cmd), cur, cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.ImageAuth == nil || req.ImageAuth.Enabled { + t.Errorf("nil imageAuth must carry as {enabled:false}: %+v", req.ImageAuth) + } + }) + t.Run("imageauth_flag_wins", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "image-auth-enabled", "true") + mustSet(t, cmd, "image-auth-username", "nu") + mustSet(t, cmd, "image-auth-password", "np") + req, _, err := buildUpdateRequest(specFromFlags(t, cmd), testVersion(), cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.ImageAuth == nil || !req.ImageAuth.Enabled || req.ImageAuth.Username != "nu" || req.ImageAuth.Password != "np" { + t.Errorf("image-auth flags should replace imageAuth: %+v", req.ImageAuth) + } + }) + t.Run("autoscaling_flag_replaces_autoscaling", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "max-replicas", "5") + req, _, err := buildUpdateRequest(specFromFlags(t, cmd), testVersion(), cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.Autoscaling.MaxReplicas != 5 { + t.Errorf("autoscaling should be the flag spec: %+v", req.Autoscaling) + } + }) +} + +// TestCarriedImageAuth_UnsetVersion returns {enabled:false} (imageAuth is +// required server-side; nil used to 400 the update). +func TestCarriedImageAuth_UnsetVersion(t *testing.T) { + ia, err := carriedImageAuth(&runtimepkg.AgentRuntimeVersionDto{}) + if err != nil { + t.Fatalf("carriedImageAuth: %v", err) + } + if ia == nil || ia.Enabled || ia.Username != "" { + t.Errorf("expected &ImageAuth{Enabled:false}, got %+v", ia) + } +} diff --git a/go/internal/agentbase/runtime/models.go b/go/internal/agentbase/runtime/models.go index 9a53a56..80d34be 100644 --- a/go/internal/agentbase/runtime/models.go +++ b/go/internal/agentbase/runtime/models.go @@ -106,9 +106,12 @@ type CreateAgentRuntimeRequest struct { } // UpdateAgentRuntimeRequest is the body for PATCH /v1/agent-runtimes/{id}. -// Same as create minus name (immutable) and poc (create-only). Every field is -// required (full-spec replacement — updating creates a new version and rolls -// the default endpoint forward), so this is NOT JSON Merge Patch semantics. +// Same as create minus name (immutable) and poc (create-only). imageAuth is +// required server-side; the API never returns the stored password, so the CLI +// cannot blindly echo a version's imageAuth back on update. The server applies +// the body as a full-spec replacement (updating creates a new immutable +// version), so the CLI's update command carries fields whose flags are unset +// over from the current version rather than resetting them to the defaults. type UpdateAgentRuntimeRequest struct { Description string `json:"description"` ImageURL string `json:"imageUrl"` From 864504a77419bb77aa111ed6b88120c01b433b86 Mon Sep 17 00:00:00 2001 From: anhpg Date: Mon, 26 Oct 2026 16:19:55 +0700 Subject: [PATCH 4/8] fix(agentbase): default listFiles directory-path to the session dir The backend rejects a listFiles invocation without directoryPath (400, error -32602 invalid params) while the platform MCP server defaults it to '.'. The CLI passed the empty flag value through, so a bare listFiles failed. Default the flag to '.' (the session working directory) when it is not set; an explicit value still wins. Also wire the code-interpreter group commands to requireSubcommand (part of #34838) and document the default in the invoke help. Refs #34837. --- go/cmd/agentbase/tools_code_interpreter.go | 14 +++++++++++-- go/cmd/agentbase/tools_test.go | 24 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/go/cmd/agentbase/tools_code_interpreter.go b/go/cmd/agentbase/tools_code_interpreter.go index 48cc6b7..bf0fb8a 100644 --- a/go/cmd/agentbase/tools_code_interpreter.go +++ b/go/cmd/agentbase/tools_code_interpreter.go @@ -28,6 +28,7 @@ Create is asynchronous: 201 CREATING → poll with 'wait' until ACTIVE (or FAILED). Sessions attach to a code interpreter and are the unit of execution: 'code-interpreter session invoke' runs a tool (executeCode, executeCommand, listFiles, writeFiles, readFiles, getTask, ...) inside a session.`, + RunE: requireSubcommand, } var toolsCodeInterpreterListCmd = &cobra.Command{ @@ -220,6 +221,7 @@ creation is synchronous (no wait needed). --end-user-id is optional (the X-End-User-ID header): the end user on whose behalf the session acts, 1-128 chars of A-Za-z0-9._:@- (validated server-side).`, + RunE: requireSubcommand, } var toolsCISessionListCmd = &cobra.Command{ @@ -354,6 +356,13 @@ func toolsInvokeToolArgs(cmd *cobra.Command, tool string) (*toolspkg.InvokeArgum } arguments.Command = command case "listFiles": + // The backend REQUIRES directoryPath (it 400s with -32602 when + // omitted), and the platform MCP server defaults it to "." — mirror + // that so a bare listFiles lists the session working directory + // instead of erroring. + if directoryPath == "" { + directoryPath = "." + } arguments.DirectoryPath = directoryPath case "writeFiles": if len(content) == 0 && len(contentB64) == 0 { @@ -411,7 +420,8 @@ Per-tool flags (the tool decides which are required): executeCode --language, --code (both required) executeCommand --command (required) startCommandExecution --command (required; async variant) - listFiles --directory-path (optional) + listFiles --directory-path (optional; defaults to '.', the + session working directory) writeFiles --content path=content (repeatable, at least one; the CLI base64-encodes each content) or --content-b64 path= (pre-encoded) @@ -742,7 +752,7 @@ func init() { toolsCISessionInvokeCmd.Flags().String("language", "", "executeCode: language (required for executeCode)") toolsCISessionInvokeCmd.Flags().String("code", "", "executeCode: code to run (required for executeCode)") toolsCISessionInvokeCmd.Flags().String("command", "", "executeCommand/startCommandExecution: command to run (required)") - toolsCISessionInvokeCmd.Flags().String("directory-path", "", "listFiles: directory to list (optional)") + toolsCISessionInvokeCmd.Flags().String("directory-path", "", "listFiles: directory to list (optional; defaults to '.', the session working directory)") toolsCISessionInvokeCmd.Flags().StringSlice("content", nil, "writeFiles: file to write as path=content (repeatable, split on the first '='; the CLI base64-encodes the content)") toolsCISessionInvokeCmd.Flags().StringSlice("content-b64", nil, "writeFiles: file to write as path= (repeatable, for pre-encoded content)") toolsCISessionInvokeCmd.Flags().StringSlice("path", nil, "readFiles: file path to read (repeatable)") diff --git a/go/cmd/agentbase/tools_test.go b/go/cmd/agentbase/tools_test.go index 2af1c34..118ecf4 100644 --- a/go/cmd/agentbase/tools_test.go +++ b/go/cmd/agentbase/tools_test.go @@ -376,3 +376,27 @@ func TestApplyAgentbaseQuery(t *testing.T) { t.Errorf("empty query must reset, got %q", got) } } + +// TestToolsInvokeToolArgs_ListFiles_DefaultsDirectoryPath: the backend +// requires directoryPath (400 with -32602 when omitted) and the platform MCP +// server defaults it to "." — a bare listFiles must list the session working +// directory instead of erroring (34837). +func TestToolsInvokeToolArgs_ListFiles_DefaultsDirectoryPath(t *testing.T) { + cmd := newInvokeArgsCmd(t, nil) // no --directory-path at all + args, err := toolsInvokeToolArgs(cmd, "listFiles") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if args.DirectoryPath != "." { + t.Errorf("directoryPath: got %q, want \".\"", args.DirectoryPath) + } + + cmd = newInvokeArgsCmd(t, map[string][]string{"directory-path": {"/tmp/work"}}) + args, err = toolsInvokeToolArgs(cmd, "listFiles") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if args.DirectoryPath != "/tmp/work" { + t.Errorf("explicit directoryPath: got %q, want /tmp/work", args.DirectoryPath) + } +} From e4255733979bcb6a411489a44c82a25be8c6fb66 Mon Sep 17 00:00:00 2001 From: anhpg Date: Mon, 26 Oct 2026 16:19:55 +0700 Subject: [PATCH 5/8] docs(agentbase): runtime pages reflect flag-mode semantics Update the runtime command pages for the flag-mode contract: - create is fully flag-driven; --file is for keeping the image-auth password (and inline JWKS) out of the shell history, not a requirement for "advanced" fields - update carries unset fields over from the current version (imageAuth caveat documented: password auth cannot carry, --file must include imageAuth explicitly); --image-url/--flavor-id are no longer required - the update file's name is ignored (it is not validated as required -- imageUrl/flavorId are); imageAuth must be present - generate ships imageAuth enabled:false and doubles as the update spec - listFiles --directory-path defaults to '.' Refs #34839. --- docs/commands/agentbase/runtime/create.md | 12 ++- docs/commands/agentbase/runtime/generate.md | 6 ++ docs/commands/agentbase/runtime/index.md | 10 ++- docs/commands/agentbase/runtime/update.md | 85 ++++++++++++------- .../tools/code-interpreter-session-invoke.md | 4 +- 5 files changed, 78 insertions(+), 39 deletions(-) diff --git a/docs/commands/agentbase/runtime/create.md b/docs/commands/agentbase/runtime/create.md index 675aece..4922125 100644 --- a/docs/commands/agentbase/runtime/create.md +++ b/docs/commands/agentbase/runtime/create.md @@ -6,10 +6,14 @@ Create a new agent runtime. Create a new agent runtime. -By default the runtime is built from flags (the simple path). For environment -variables, private-registry auth, network placement (VPC), inbound auth (IAM or -JWT), a CIDR allowlist, an MCP protocol runtime, or multi-element command/args, -use `--file` with a template produced by [generate](generate.md). +By default the runtime is built from flags: `--name` is prompted for when +missing, `--command`/`--args`/`--env` are repeatable, `--network-mode VPC` +places the runtime in a VPC, `--inbound-mode IAM|JWT` configures inbound auth, +`--allowed-cidrs` restricts inbound CIDRs, and `--protocol MCP` selects the +MCP protocol. `imageAuth` is required by the API: with no `--image-auth-*` +flags the CLI sends `{enabled: false}` (public image). Prefer `--file` with a +template produced by [generate](generate.md) to keep the image-auth password +out of the shell history. The runtime is created asynchronously; this command returns as soon as the service accepts the spec (state `CREATING`). Converge with diff --git a/docs/commands/agentbase/runtime/generate.md b/docs/commands/agentbase/runtime/generate.md index c97bd26..af0b675 100644 --- a/docs/commands/agentbase/runtime/generate.md +++ b/docs/commands/agentbase/runtime/generate.md @@ -7,6 +7,12 @@ Print a runtime create template (YAML or JSON). Print a commented agent-runtime create template to stdout. Save it, fill it in, and apply with `grn agentbase runtime create --file `. +The template ships with `imageAuth: {enabled: false}` — the API requires the +field, and `enabled: false` is the public-image default. For a private +registry, enable it and fill in `username`/`password` (send-only) or set +`useAgentBaseRegistryCredentials`. The same file (minus the create-only `poc`, +keeping `name`) feeds `runtime update --file`. + Defaults to YAML (with comments); pass `-o json` for a JSON skeleton. ## Synopsis diff --git a/docs/commands/agentbase/runtime/index.md b/docs/commands/agentbase/runtime/index.md index 9edc258..dfe36cd 100644 --- a/docs/commands/agentbase/runtime/index.md +++ b/docs/commands/agentbase/runtime/index.md @@ -12,9 +12,11 @@ environment, autoscaling, and flavor. Runtimes converge asynchronously: `CREATING`/`UPDATING`) and reach `ACTIVE` (or `ERROR` / `SERVICE_ACCOUNT_ERROR`). Use [wait](wait.md) to block until terminal. -Runtimes are addressed by `id` (the `name` is immutable after creation). `update` -is a **full-spec replacement** — every field is required (the create spec minus -`name`), not a merge-patch. For anything beyond the simple path, generate a +Runtimes are addressed by `id` (the `name` is immutable after creation). +`create` is fully flag-driven (see [create](create.md)). `update` is +merge-like from the CLI side: fields whose flags are left unset are carried +over from the current version (see [update](update.md)); to apply a full spec +from a file — or keep the image-auth password out of the shell — generate a template with [generate](generate.md), fill it in, and apply with `--file`. ## Available commands @@ -25,7 +27,7 @@ template with [generate](generate.md), fill it in, and apply with `--file`. | [generate](generate.md) | Print a runtime create template (YAML or JSON) | | [list](list.md) | List agent runtimes | | [get](get.md) | Show an agent runtime | -| [update](update.md) | Update an agent runtime (full-spec replacement) | +| [update](update.md) | Update an agent runtime (new immutable version) | | [delete](delete.md) | Delete an agent runtime | | [wait](wait.md) | Wait for an agent runtime to reach a terminal state | diff --git a/docs/commands/agentbase/runtime/update.md b/docs/commands/agentbase/runtime/update.md index 2319a5d..093b2c6 100644 --- a/docs/commands/agentbase/runtime/update.md +++ b/docs/commands/agentbase/runtime/update.md @@ -1,20 +1,31 @@ # runtime update -Update an agent runtime (full-spec replacement). +Update an agent runtime (new immutable version). ## Description -Update an agent runtime. Unlike gateway, this is a FULL-SPEC replacement, not a -merge-patch: every field is required server-side (the create spec minus `name`). -Updating creates a new version and rolls the default endpoint forward. - -For anything beyond the simple path, use `--file` with the create template -([generate](generate.md)) minus the `name` field. - -When `--file` is set it is authoritative — every flag below is ignored. The file -is validated as a full create spec, so `name` must be present (and non-empty) in -the file even though it is not sent; the runtime is addressed by the `` -positional argument. +Update an agent runtime. Updating creates a new immutable version and rolls +the default endpoint forward. + +The API is a full-spec replacement, but the flag path is merge-like: a field +whose flags are left unset is **carried over** from the current version (a +stderr note lists the carried fields), and a set flag replaces its field +wholesale (no element-level merge). Set flags only for what you intend to +change — e.g. a bare `--env K=V` replaces the whole environment map, it does +not add one key. + +`imageAuth` is the exception — the API never returns the stored password, so +username/password image auth cannot be carried over: re-supply +`--image-auth-username`/`--image-auth-password` (or use `--file` with a full +spec). Without image auth the update re-sends `{enabled: false}`. + +When `--file` is set it is authoritative — every flag below is ignored, and +the spec is applied as-is. The file must contain `imageUrl` and `flavorId`; a +`name` in the file is ignored (the runtime is addressed by the `` +positional argument and the name is immutable). The file **must include +`imageAuth` explicitly**: a missing `imageAuth` would silently wipe the stored +credentials, so it is rejected — `{enabled: false}` drops them deliberately, +`username`/`password` or `useAgentBaseRegistryCredentials` keeps them. > **Notes:** `--min-replicas` is gone (the platform owns the autoscaling floor — > always 1); only `--max-replicas` is configurable. `--poc` is create-only and @@ -58,16 +69,18 @@ grn agentbase runtime update **`--image-url`** (string) -Container image URL. +Container image URL. Unset = carried over from the current version; explicitly +set but empty is rejected. -- Required: Yes +- Required: No (carried over from the current version when unset) - Default: (empty) **`--flavor-id`** (string) -Flavor id. +Flavor id. Unset = carried over from the current version; explicitly set but +empty is rejected. -- Required: Yes +- Required: No (carried over from the current version when unset) - Default: (empty) **`--description`** (string) @@ -158,10 +171,12 @@ Autoscaling: memory scale-up threshold % (10-90). **`--network-mode`** (string) -`networkConfig` mode: `PUBLIC` or `VPC` (unset = `PUBLIC`). +`networkConfig` mode: `PUBLIC` or `VPC`. Unset (with `--vpc-id`/`--subnet-id`/ +`--route-cidrs` also unset) = the current version's `networkConfig` is carried +over; setting any network flag replaces the whole `networkConfig`. -- Required: No -- Default: `PUBLIC` +- Required: No (carried over from the current version when unset) +- Default: (empty = carried over) - Constraints: `VPC` requires `--vpc-id` and `--subnet-id`. **`--vpc-id`** (string) @@ -196,10 +211,11 @@ Inbound CIDR allowlist (repeatable, max 20). **`--inbound-mode`** (string) -`inboundAuth` mode: `NONE`, `IAM`, or `JWT` (unset = `NONE`). +`inboundAuth` mode: `NONE`, `IAM`, or `JWT`. Unset = the current version's +`inboundAuth` is carried over. -- Required: No -- Default: (empty = `NONE`) +- Required: No (carried over from the current version when unset) +- Default: (empty = carried over) - Constraints: `JWT` requires the `--inbound-jwt-*` flags; cannot be combined with them when set to `IAM`/`NONE`. @@ -256,10 +272,10 @@ Prefer `--file` — the value is a full JSON document. **`--protocol`** (string) -Runtime protocol: `HTTP` or `MCP` (unset = `HTTP`). +Runtime protocol: `HTTP` or `MCP`. -- Required: No -- Default: (empty = `HTTP`) +- Required: No (carried over from the current version when unset) +- Default: (empty = carried over) **`--file`** (string) @@ -267,7 +283,11 @@ Apply a full-spec file (create template minus `name`); authoritative when set. - Required: No - Default: (empty) -- Constraints: when set, the flags above are ignored; `name` must be present in the file. +- Constraints: when set, the flags above are ignored; `name` must be present + in the file; `imageAuth` must be present in the file (a missing `imageAuth` + would wipe the stored credentials — set `{enabled: false}` to drop them + deliberately, or `username`/`password` / `useAgentBaseRegistryCredentials` + to keep them). ## Global options @@ -280,15 +300,22 @@ All `grn agentbase` commands accept: ## Examples -Update from a full-spec file (recommended): +Change only the image (everything else is carried over from the current +version, listed on stderr): + +```bash +grn agentbase runtime update --image-url registry.example.com/my-agent:v2 +``` + +Update from a full-spec file (recommended for password/JWKS-carrying specs): ```bash grn agentbase runtime generate > rt.yaml -# ...edit rt.yaml (keep name)... +# ...edit rt.yaml (keep name; keep imageAuth)... grn agentbase runtime update --file rt.yaml ``` -Update from flags: +Update several fields from flags: ```bash grn agentbase runtime update \ diff --git a/docs/commands/agentbase/tools/code-interpreter-session-invoke.md b/docs/commands/agentbase/tools/code-interpreter-session-invoke.md index 1560a2a..a25c87c 100644 --- a/docs/commands/agentbase/tools/code-interpreter-session-invoke.md +++ b/docs/commands/agentbase/tools/code-interpreter-session-invoke.md @@ -18,7 +18,7 @@ truth and rejects bad shapes with a 400 listing the accepted values). | `executeCode` | `--language`, `--code` (both required) | | `executeCommand` | `--command` (required) | | `startCommandExecution` | `--command` (required; async variant) | -| `listFiles` | `--directory-path` (optional) | +| `listFiles` | `--directory-path` (optional; defaults to `.`, the session working directory) | | `writeFiles` | `--content path=content` (the CLI base64-encodes it) or `--content-b64 path=` (repeatable, at least one) | | `readFiles` | `--path` (repeatable, at least one) | | `getTask` | `--task-id` (required) | @@ -68,7 +68,7 @@ grn agentbase tools code-interpreter session invoke Date: Tue, 27 Oct 2026 08:18:23 +0700 Subject: [PATCH 6/8] fix(agentbase): treat 404 during gateway/runtime wait as delete-completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A completed delete removes the gateway/runtime record, so the wait poll GET returns HTTP 404 instead of a DELETED state — every delete+wait flow exited 1. Map that 404 onto the DELETED terminal success: gateway wait and runtime wait report the resource as deleted and exit 0 (a 404 before any successful poll returns a minimal {id/name, DELETED} snapshot; after one, the last-seen resource). deploy destroy gets the same success mapping, while deploy up keeps 404 an error (it waits for ACTIVE). Refs #34856 --- docs/commands/agentbase/gateway/wait.md | 8 +- docs/commands/agentbase/runtime/wait.md | 8 +- go/cmd/agentbase/deploy.go | 4 +- go/cmd/agentbase/gateway.go | 70 +++++--- go/cmd/agentbase/runtime.go | 30 +++- go/cmd/agentbase/wait_test.go | 213 ++++++++++++++++++++++++ 6 files changed, 307 insertions(+), 26 deletions(-) create mode 100644 go/cmd/agentbase/wait_test.go diff --git a/docs/commands/agentbase/gateway/wait.md b/docs/commands/agentbase/gateway/wait.md index faddc74..8081e02 100644 --- a/docs/commands/agentbase/gateway/wait.md +++ b/docs/commands/agentbase/gateway/wait.md @@ -19,6 +19,11 @@ stage) and exits non-zero. On timeout it exits non-zero with the last observed state. All non-terminal states (`WAITING_*`, `CREATING`, `UPDATING`, `DELETING`, `*_CLEANING`) keep polling. +A completed delete removes the gateway record, so once the delete finishes the +GET returns HTTP 404 (no longer present) instead of a `DELETED` state. That 404 +counts as the `DELETED` terminal success: `wait` reports the gateway as deleted +and exits 0. + ## Synopsis ```text @@ -71,7 +76,8 @@ grn agentbase gateway create --file gw.yaml grn agentbase gateway wait my-gateway ``` -Wait for a deletion to finish (terminates on `DELETED`), polling every 10 +Wait for a deletion to finish (terminates on `DELETED` — once the record is +gone the API returns 404, which counts as `DELETED`), polling every 10 seconds up to 30 minutes: ```bash diff --git a/docs/commands/agentbase/runtime/wait.md b/docs/commands/agentbase/runtime/wait.md index a812a7f..8b85301 100644 --- a/docs/commands/agentbase/runtime/wait.md +++ b/docs/commands/agentbase/runtime/wait.md @@ -11,6 +11,11 @@ Poll an agent runtime until it reaches a terminal state: `ACTIVE` / `DELETED` Progress lines are written to stderr. On failure or timeout the command returns a non-zero exit status. +A completed delete removes the runtime record, so once the delete finishes the +GET returns HTTP 404 (no longer present) instead of a `DELETED` status. That 404 +counts as the `DELETED` terminal success: `wait` reports the runtime as deleted +and exits 0. + ## Synopsis ```text @@ -60,7 +65,8 @@ Wait with a custom timeout and poll interval: grn agentbase runtime wait agn-rt-abc123 --timeout 30m --interval 10s ``` -Wait for deletion to complete (reaches `DELETED`): +Wait for deletion to complete (once the record is gone the API returns 404, +which counts as `DELETED`): ```bash grn agentbase runtime delete agn-rt-abc123 diff --git a/go/cmd/agentbase/deploy.go b/go/cmd/agentbase/deploy.go index 129ea44..687913f 100644 --- a/go/cmd/agentbase/deploy.go +++ b/go/cmd/agentbase/deploy.go @@ -356,7 +356,7 @@ func deployApplyRuntime(ctx context.Context, d *deployClients, mfst *deploypkg.M } pctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - rt, err = pollRuntimeToTerminal(pctx, rc, rt.ID, interval) + rt, err = pollRuntimeToTerminal(pctx, rc, rt.ID, interval, false) if err != nil { return rt, err } @@ -531,7 +531,7 @@ The runtime deletion is asynchronous, so destroy waits for it to reach DELETED } else { pctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - if _, werr := pollRuntimeToTerminal(pctx, d.runtime(), rt.ID, interval); werr != nil { + if _, werr := pollRuntimeToTerminal(pctx, d.runtime(), rt.ID, interval, true); werr != nil { steps = append(steps, deployStep{"runtime", "error", "deleted but " + werr.Error()}) } else { steps = append(steps, deployStep{"runtime", "deleted", rt.ID}) diff --git a/go/cmd/agentbase/gateway.go b/go/cmd/agentbase/gateway.go index c57ddba..5a696a8 100644 --- a/go/cmd/agentbase/gateway.go +++ b/go/cmd/agentbase/gateway.go @@ -3,7 +3,9 @@ package agentbase import ( "context" "encoding/json" + "errors" "fmt" + "net/http" "os" "strconv" "strings" @@ -13,6 +15,7 @@ import ( "github.com/spf13/pflag" "gopkg.in/yaml.v3" + clientpkg "github.com/greennodehub/greennode-cli/internal/agentbase/client" "github.com/greennodehub/greennode-cli/internal/agentbase/cliinput" gatewaypkg "github.com/greennodehub/greennode-cli/internal/agentbase/gateway" "github.com/greennodehub/greennode-cli/internal/agentbase/output" @@ -561,7 +564,9 @@ var gatewayWaitCmd = &cobra.Command{ Use: "wait ", Short: "Wait for a gateway to reach a terminal state", Long: `Poll a gateway until it reaches a terminal state: ACTIVE / DELETED (success) -or CREATE_ERROR / UPDATE_ERROR / ERROR (failure). Use after create/update/delete.`, +or CREATE_ERROR / UPDATE_ERROR / ERROR (failure). Use after create/update/delete. +A completed delete removes the gateway record, so a 404 (no longer present) +while waiting counts as DELETED.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -575,28 +580,55 @@ or CREATE_ERROR / UPDATE_ERROR / ERROR (failure). Use after create/update/delete return err } name := args[0] - for { - gw, err := client.Get(ctx, name) - if err != nil { - return err - } - if gatewayTerminalFailure[gw.State] { - return fmt.Errorf("gateway %q failed: state=%s %s", name, gw.State, lastErrStr(gw.LastError)) - } - if gatewayTerminalSuccess[gw.State] { - fmt.Fprintf(os.Stderr, "Gateway %q reached state %s.\n", name, gw.State) - return output.PrintResource(gw, func() string { return gw.Name }, func() error { return renderGatewayDetail(gw) }) - } - fmt.Fprintf(os.Stderr, "Gateway %q state: %s ...\n", name, gw.State) - select { - case <-ctx.Done(): - return fmt.Errorf("timed out waiting for gateway %q (last state %s)", name, gw.State) - case <-time.After(interval): - } + gw, err := pollGatewayToTerminal(ctx, client, name, interval) + if err != nil { + return err } + return output.PrintResource(gw, func() string { return gw.Name }, func() error { return renderGatewayDetail(gw) }) }, } +// pollGatewayToTerminal polls a gateway by name until it reaches a terminal +// state (ACTIVE/DELETED = success, *_ERROR = failure) or ctx expires. Progress +// lines go to stderr; on success the caller renders the returned gateway. +// +// A completed delete removes the gateway record, so Get returns 404 instead of +// a DELETED state — a 404 mid-poll counts as the DELETED terminal success +// (documented in `gateway wait --help`). When it arrives before any successful +// poll, a minimal {name, DELETED} snapshot is returned — the record is gone, +// there is nothing better to report. +func pollGatewayToTerminal(ctx context.Context, client *gatewaypkg.Client, name string, interval time.Duration) (*gatewaypkg.GatewayResponse, error) { + var last *gatewaypkg.GatewayResponse + for { + gw, err := client.Get(ctx, name) + if err != nil { + var apiErr *clientpkg.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + fmt.Fprintf(os.Stderr, "Gateway %q deleted (no longer present, HTTP 404).\n", name) + if last == nil { + last = &gatewaypkg.GatewayResponse{Name: name, State: "DELETED"} + } + return last, nil + } + return nil, err + } + last = gw + if gatewayTerminalFailure[gw.State] { + return nil, fmt.Errorf("gateway %q failed: state=%s %s", name, gw.State, lastErrStr(gw.LastError)) + } + if gatewayTerminalSuccess[gw.State] { + fmt.Fprintf(os.Stderr, "Gateway %q reached state %s.\n", name, gw.State) + return gw, nil + } + fmt.Fprintf(os.Stderr, "Gateway %q state: %s ...\n", name, gw.State) + select { + case <-ctx.Done(): + return nil, fmt.Errorf("timed out waiting for gateway %q (last state %s)", name, gw.State) + case <-time.After(interval): + } + } +} + // gatewayTerminalSuccess / gatewayTerminalFailure partition the State enum. // The transient (WAITING_*/CREATING/UPDATING/DELETING/*_CLEANING) states keep // polling; everything else is terminal. diff --git a/go/cmd/agentbase/runtime.go b/go/cmd/agentbase/runtime.go index 872d3f0..2ce58e2 100644 --- a/go/cmd/agentbase/runtime.go +++ b/go/cmd/agentbase/runtime.go @@ -3,7 +3,9 @@ package agentbase import ( "context" "encoding/json" + "errors" "fmt" + "net/http" "net/url" "os" "strings" @@ -11,6 +13,7 @@ import ( "github.com/spf13/cobra" + clientpkg "github.com/greennodehub/greennode-cli/internal/agentbase/client" "github.com/greennodehub/greennode-cli/internal/agentbase/cliinput" "github.com/greennodehub/greennode-cli/internal/agentbase/output" runtimepkg "github.com/greennodehub/greennode-cli/internal/agentbase/runtime" @@ -933,7 +936,9 @@ var runtimeWaitCmd = &cobra.Command{ Use: "wait ", Short: "Wait for an agent runtime to reach a terminal state", Long: `Poll an agent runtime until it reaches a terminal state: ACTIVE / DELETED -(success) or ERROR / SERVICE_ACCOUNT_ERROR (failure). Use after create/update/delete.`, +(success) or ERROR / SERVICE_ACCOUNT_ERROR (failure). Use after create/update/delete. +A completed delete removes the runtime record, so a 404 (no longer present) +while waiting counts as DELETED.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -945,7 +950,7 @@ var runtimeWaitCmd = &cobra.Command{ } pctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - rt, err := pollRuntimeToTerminal(pctx, client, args[0], interval) + rt, err := pollRuntimeToTerminal(pctx, client, args[0], interval, true) if err != nil { return err } @@ -959,12 +964,31 @@ var runtimeWaitCmd = &cobra.Command{ // is one polling loop. Progress lines go to stderr. Returns the last-fetched // runtime and a non-nil error on failure/timeout (the caller decides whether to // render it). -func pollRuntimeToTerminal(ctx context.Context, client *runtimepkg.Client, id string, interval time.Duration) (*runtimepkg.AgentRuntime, error) { +// +// A completed delete removes the runtime record, so Get returns 404 instead of +// a DELETED status. notFoundIsDeleted maps that 404 onto the DELETED terminal +// success: pass true for the delete flows (`runtime wait`, which is documented +// for use after delete, and `deploy destroy`); pass false for `deploy up`, +// which waits for ACTIVE, where a vanishing runtime stays an error. When the +// 404 arrives before any successful poll, the returned runtime is a minimal +// {id, DELETED} snapshot — the record is gone, there is nothing better to +// report. +func pollRuntimeToTerminal(ctx context.Context, client *runtimepkg.Client, id string, interval time.Duration, notFoundIsDeleted bool) (*runtimepkg.AgentRuntime, error) { + var last *runtimepkg.AgentRuntime for { rt, err := client.Get(ctx, id) if err != nil { + var apiErr *clientpkg.APIError + if notFoundIsDeleted && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + fmt.Fprintf(os.Stderr, "Runtime %q deleted (no longer present, HTTP 404).\n", id) + if last == nil { + last = &runtimepkg.AgentRuntime{ID: id, Status: "DELETED"} + } + return last, nil + } return nil, err } + last = rt if runtimeTerminalFailure[rt.Status] { return rt, fmt.Errorf("runtime %q failed: state=%s %s", id, rt.Status, rt.StatusReason) } diff --git a/go/cmd/agentbase/wait_test.go b/go/cmd/agentbase/wait_test.go new file mode 100644 index 0000000..426134a --- /dev/null +++ b/go/cmd/agentbase/wait_test.go @@ -0,0 +1,213 @@ +package agentbase + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + clientpkg "github.com/greennodehub/greennode-cli/internal/agentbase/client" + gatewaypkg "github.com/greennodehub/greennode-cli/internal/agentbase/gateway" + runtimepkg "github.com/greennodehub/greennode-cli/internal/agentbase/runtime" +) + +// fakeWaitTokenProvider hands out a static bearer token so the poll tests can +// hit an httptest server through the real agentbase client stack (the same seam +// runtimepkg/gatewaypkg use in their own package tests). +type fakeWaitTokenProvider struct{} + +func (fakeWaitTokenProvider) GetToken() (string, error) { return "test-token", nil } +func (fakeWaitTokenProvider) RefreshToken() (string, error) { return "test-token", nil } + +// newWaitServer spins up an httptest server backed by handler and closes it +// with the test. +func newWaitServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return srv +} + +// serve404 replies with the error body the API returns once a delete has +// removed the record. +func serve404(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + fmt.Fprintln(w, `{"error":{"code":"not_found","message":"not found"}}`) +} + +// serveJSON marshals v as a 200 response. +func serveJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// --- runtime wait polling --------------------------------------------------- + +// TestPollRuntimeToTerminal_NotFoundCountsAsDeleted covers the fast delete: +// the record is already gone by the first poll, so the wait never sees a +// runtime. With notFoundIsDeleted the 404 maps onto the DELETED terminal +// success and the caller gets a minimal {id, DELETED} snapshot. +func TestPollRuntimeToTerminal_NotFoundCountsAsDeleted(t *testing.T) { + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { serve404(w) }) + client := runtimepkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + rt, err := pollRuntimeToTerminal(context.Background(), client, "rt-1", time.Millisecond, true) + if err != nil { + t.Fatalf("pollRuntimeToTerminal: %v", err) + } + if rt == nil || rt.ID != "rt-1" || rt.Status != "DELETED" { + t.Errorf("rt = %+v, want minimal {id: rt-1, status: DELETED} snapshot", rt) + } +} + +// TestPollRuntimeToTerminal_NotFoundStillErrorsWhenNotWaitingForDelete keeps +// the 404 an error for the ACTIVE flows (deploy up): a runtime vanishing +// mid-converge is not a success there. +func TestPollRuntimeToTerminal_NotFoundStillErrorsWhenNotWaitingForDelete(t *testing.T) { + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { serve404(w) }) + client := runtimepkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + _, err := pollRuntimeToTerminal(context.Background(), client, "rt-1", time.Millisecond, false) + if err == nil { + t.Fatal("expected the 404 to surface as an error when not waiting for a delete") + } + var apiErr *clientpkg.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound { + t.Errorf("err = %v, want *client.APIError with status 404", err) + } +} + +// TestPollRuntimeToTerminal_DeletingThenNotFoundReturnsLastSeen covers the +// slow delete: the runtime is seen in DELETING, then disappears. The 404 counts +// as DELETED and the caller gets the last-seen runtime, not a synthetic one. +func TestPollRuntimeToTerminal_DeletingThenNotFoundReturnsLastSeen(t *testing.T) { + var calls int32 + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&calls, 1) == 1 { + serveJSON(w, map[string]string{"id": "rt-1", "status": "DELETING"}) + return + } + serve404(w) + }) + client := runtimepkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + rt, err := pollRuntimeToTerminal(context.Background(), client, "rt-1", time.Millisecond, true) + if err != nil { + t.Fatalf("pollRuntimeToTerminal: %v", err) + } + if rt == nil || rt.ID != "rt-1" || rt.Status != "DELETING" { + t.Errorf("rt = %+v, want the last-seen DELETING runtime", rt) + } +} + +// TestPollRuntimeToTerminal_ActiveStopsPolling guards the plain success path: +// one ACTIVE response ends the loop without extra polls. +func TestPollRuntimeToTerminal_ActiveStopsPolling(t *testing.T) { + var calls int32 + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + serveJSON(w, map[string]string{"id": "rt-1", "status": "ACTIVE"}) + }) + client := runtimepkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + rt, err := pollRuntimeToTerminal(context.Background(), client, "rt-1", time.Millisecond, true) + if err != nil { + t.Fatalf("pollRuntimeToTerminal: %v", err) + } + if rt.Status != "ACTIVE" { + t.Errorf("status = %q, want ACTIVE", rt.Status) + } + if calls != 1 { + t.Errorf("polls = %d, want 1 (ACTIVE is terminal)", calls) + } +} + +// TestPollRuntimeToTerminal_ErrorStateFails guards the terminal-failure path. +func TestPollRuntimeToTerminal_ErrorStateFails(t *testing.T) { + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + serveJSON(w, map[string]string{"id": "rt-1", "status": "ERROR", "statusReason": "boom"}) + }) + client := runtimepkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + _, err := pollRuntimeToTerminal(context.Background(), client, "rt-1", time.Millisecond, true) + if err == nil || !strings.Contains(err.Error(), "failed") { + t.Errorf("err = %v, want a terminal-failure error", err) + } +} + +// --- gateway wait polling --------------------------------------------------- + +// TestPollGatewayToTerminal_NotFoundCountsAsDeleted covers the fast delete: +// the gateway is already gone by the first poll, so the wait gets a minimal +// {name, DELETED} snapshot and no error. +func TestPollGatewayToTerminal_NotFoundCountsAsDeleted(t *testing.T) { + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { serve404(w) }) + client := gatewaypkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + gw, err := pollGatewayToTerminal(context.Background(), client, "gw-1", time.Millisecond) + if err != nil { + t.Fatalf("pollGatewayToTerminal: %v", err) + } + if gw == nil || gw.Name != "gw-1" || gw.State != "DELETED" { + t.Errorf("gw = %+v, want minimal {name: gw-1, state: DELETED} snapshot", gw) + } +} + +// TestPollGatewayToTerminal_DeletingThenNotFoundReturnsLastSeen covers the +// observed prod flow: the gateway is seen in DELETING, then disappears +// (~25-31s). The 404 counts as DELETED and the caller gets the last-seen +// gateway. +func TestPollGatewayToTerminal_DeletingThenNotFoundReturnsLastSeen(t *testing.T) { + var calls int32 + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&calls, 1) == 1 { + serveJSON(w, map[string]string{"name": "gw-1", "state": "DELETING"}) + return + } + serve404(w) + }) + client := gatewaypkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + gw, err := pollGatewayToTerminal(context.Background(), client, "gw-1", time.Millisecond) + if err != nil { + t.Fatalf("pollGatewayToTerminal: %v", err) + } + if gw == nil || gw.Name != "gw-1" || gw.State != "DELETING" { + t.Errorf("gw = %+v, want the last-seen DELETING gateway", gw) + } +} + +// TestPollGatewayToTerminal_ActiveStopsPolling guards the plain success path. +func TestPollGatewayToTerminal_ActiveStopsPolling(t *testing.T) { + var calls int32 + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + serveJSON(w, map[string]string{"name": "gw-1", "state": "ACTIVE"}) + }) + client := gatewaypkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + gw, err := pollGatewayToTerminal(context.Background(), client, "gw-1", time.Millisecond) + if err != nil { + t.Fatalf("pollGatewayToTerminal: %v", err) + } + if gw.State != "ACTIVE" { + t.Errorf("state = %q, want ACTIVE", gw.State) + } + if calls != 1 { + t.Errorf("polls = %d, want 1 (ACTIVE is terminal)", calls) + } +} + +// TestPollGatewayToTerminal_FailureStateErrors guards the terminal-failure +// path (the *_ERROR states keep failing, 404 or not). +func TestPollGatewayToTerminal_FailureStateErrors(t *testing.T) { + srv := newWaitServer(t, func(w http.ResponseWriter, r *http.Request) { + serveJSON(w, map[string]string{"name": "gw-1", "state": "CREATE_ERROR"}) + }) + client := gatewaypkg.NewClient(srv.URL, fakeWaitTokenProvider{}) + _, err := pollGatewayToTerminal(context.Background(), client, "gw-1", time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "failed") { + t.Errorf("err = %v, want a terminal-failure error", err) + } +} From a5cae8a0ba03b740981f7da1dd84b439e3b2221b Mon Sep 17 00:00:00 2001 From: anhpg Date: Mon, 2 Nov 2026 14:30:51 +0700 Subject: [PATCH 7/8] change default inbound mode to IAM --- docs/commands/agentbase/gateway/create.md | 44 +++--- docs/commands/agentbase/gateway/generate.md | 3 +- docs/commands/agentbase/runtime/create.md | 15 ++- docs/commands/agentbase/runtime/generate.md | 6 +- docs/commands/agentbase/runtime/update.md | 4 + go/cmd/agentbase/gateway.go | 102 +++++++++----- go/cmd/agentbase/gateway_test.go | 142 +++++++++++++++++++- go/cmd/agentbase/runtime.go | 56 +++++--- go/cmd/agentbase/runtime_test.go | 50 +++++-- 9 files changed, 334 insertions(+), 88 deletions(-) diff --git a/docs/commands/agentbase/gateway/create.md b/docs/commands/agentbase/gateway/create.md index 969ed7c..8ef58f8 100644 --- a/docs/commands/agentbase/gateway/create.md +++ b/docs/commands/agentbase/gateway/create.md @@ -10,6 +10,10 @@ By default the gateway is built from flags (the simple path: no inline targets). For targets, outbound auth, JWT inbound auth, or anything nested, use `--file` with a template produced by `grn agentbase gateway generate`. +Inbound auth defaults to `IAM` (callers authenticate via the platform IAM); +pass `--inbound-mode NONE` to open the gateway or `JWT` to validate caller +JWTs. + The gateway is created asynchronously; this command returns as soon as the service accepts the spec (state `WAITING_CREATING`). Converge with `grn agentbase gateway wait `. @@ -17,7 +21,8 @@ service accepts the spec (state `WAITING_CREATING`). Converge with When `--file` is set it is authoritative and the individual flags below are ignored. `--file` (produced by [generate](generate.md)) is the recommended path for complex nested configuration (targets / outboundAuth / inboundAuth / -privateNetwork). +privateNetwork). A spec that omits `inboundAuth` (or its `mode`) is defaulted +to `{mode: IAM}`; a `jwt` block without `mode: JWT` is rejected. ## Synopsis @@ -95,10 +100,13 @@ Replica count. Sealed at create. **`--inbound-mode`** (string) -Inbound (caller) authentication mode. +Inbound (caller) authentication mode. Callers authenticate via the platform +IAM by default; `NONE` opens the gateway, `JWT` validates caller JWTs (the +`--jwt-*` flags below then build the config). -- Required: Yes (or `--interactive`) -- Possible values: `NONE`, `IAM`, `JWT` +- Required: No +- Default: `IAM` +- Possible values: `NONE`, `IAM`, `JWT` (case-insensitive) **`--client-redirect-uri`** (list<string>) @@ -110,52 +118,56 @@ Allowed client redirect URI for DCR/authorize. *(repeatable)* **`--iam-require-owner`** (boolean) IAM mode only: require the caller to own the resource. Only sent when explicitly -set. +set; rejected unless `--inbound-mode` is `IAM` (the default). - Required: No - Default: `false` **`--jwt-source`** (string) -JWT inbound-auth source. Triggers JWT config assembly when set (or when -`--inbound-mode` is `JWT`). +JWT inbound-auth source. Only applies when `--inbound-mode` is `JWT` (error +otherwise); with `JWT` it triggers the JWT config assembly. - Required: No - Possible values: `DISCOVERY`, `JWKS` **`--jwt-discovery-url`** (string) -JWT OIDC discovery URL (used when `--jwt-source` is `DISCOVERY`). +JWT OIDC discovery URL (used when `--jwt-source` is `DISCOVERY`). Only applies +when `--inbound-mode` is `JWT`. - Required: No **`--jwt-jwks`** (string) -JWT inline JWKS document (used when `--jwt-source` is `JWKS`). +JWT inline JWKS document (used when `--jwt-source` is `JWKS`). Only applies +when `--inbound-mode` is `JWT`. - Required: No **`--allowed-audience`** (list<string>) -Allowed JWT audience. *(repeatable)* +Allowed JWT audience. *(repeatable)* Only applies when `--inbound-mode` is +`JWT`. - Required: No **`--allowed-client`** (list<string>) -Allowed JWT client id. *(repeatable)* +Allowed JWT client id. *(repeatable)* Only applies when `--inbound-mode` is +`JWT`. - Required: No **`--allowed-scope`** (list<string>) -Allowed JWT scope. *(repeatable)* +Allowed JWT scope. *(repeatable)* Only applies when `--inbound-mode` is `JWT`. - Required: No **`--principal-claim`** (string) -JWT principal claim. +JWT principal claim. Only applies when `--inbound-mode` is `JWT`. - Required: No - Default: `sub` @@ -227,15 +239,15 @@ All `grn agentbase` commands accept: ## Examples -Create a simple PUBLIC gateway from flags: +Create a simple PUBLIC gateway from flags (inbound auth defaults to `IAM`; +add `--inbound-mode NONE` to open the gateway): ```bash grn agentbase gateway create \ --name my-gateway \ --network-mode PUBLIC \ --flavor-id fill-flavor-id \ - --replicas 1 \ - --inbound-mode NONE + --replicas 1 grn agentbase gateway wait my-gateway ``` diff --git a/docs/commands/agentbase/gateway/generate.md b/docs/commands/agentbase/gateway/generate.md index 7de6e64..0f0f768 100644 --- a/docs/commands/agentbase/gateway/generate.md +++ b/docs/commands/agentbase/gateway/generate.md @@ -11,7 +11,8 @@ Defaults to YAML (with comments); pass `-o json` for a JSON skeleton. The template keys are the JSON (camelCase) field names, so the file round-trips through `create --file` exactly. Required keys: `name`, `networkMode`, -`flavorId`, `replicas`, `inboundAuth.mode`. Sealed at create: `name`, +`flavorId`, `replicas` (`inboundAuth.mode` is optional — omitted it defaults +to `IAM`; the template ships with `mode: IAM`). Sealed at create: `name`, `networkMode`, `flavorId`, `replicas`, and (PRIVATE) `privateNetwork.vpcId`/`subnetId`. diff --git a/docs/commands/agentbase/runtime/create.md b/docs/commands/agentbase/runtime/create.md index 4922125..7b601ac 100644 --- a/docs/commands/agentbase/runtime/create.md +++ b/docs/commands/agentbase/runtime/create.md @@ -8,7 +8,8 @@ Create a new agent runtime. By default the runtime is built from flags: `--name` is prompted for when missing, `--command`/`--args`/`--env` are repeatable, `--network-mode VPC` -places the runtime in a VPC, `--inbound-mode IAM|JWT` configures inbound auth, +places the runtime in a VPC, `--inbound-mode NONE|IAM|JWT` configures inbound +auth (defaults to `IAM` on `PUBLIC` networks; not available in VPC mode), `--allowed-cidrs` restricts inbound CIDRs, and `--protocol MCP` selects the MCP protocol. `imageAuth` is required by the API: with no `--image-auth-*` flags the CLI sends `{enabled: false}` (public image). Prefer `--file` with a @@ -20,7 +21,9 @@ service accepts the spec (state `CREATING`). Converge with `grn agentbase runtime wait `. When `--file` is set it is authoritative — every flag below is ignored and the -file must contain `name`, `imageUrl`, and `flavorId`. +file must contain `name`, `imageUrl`, and `flavorId`. A spec that omits +`inboundAuth` is sent with `inboundAuth: {mode: IAM}` unless the spec puts the +runtime in a VPC (`inboundAuth` is `PUBLIC`-network only). > **Deprecation:** `--min-replicas` is gone. The platform owns the autoscaling > floor (minimum replicas is always 1); only `--max-replicas` is configurable. @@ -213,12 +216,14 @@ traffic on `PUBLIC` networks. **`--inbound-mode`** (string) -`inboundAuth` mode: `NONE`, `IAM`, or `JWT` (unset = `NONE`). +`inboundAuth` mode: `NONE`, `IAM`, or `JWT`. `PUBLIC` networks only — the API +rejects `inboundAuth` on VPC runtimes. Unset = `IAM` on create (callers +authenticate via the platform IAM); pass `NONE` to open the runtime. - Required: No -- Default: (empty = `NONE`) +- Default: `IAM` on create (when the network is `PUBLIC` or unset) - Constraints: `JWT` requires the `--inbound-jwt-*` flags; cannot be combined - with them when set to `IAM`/`NONE`. + with them when set to `IAM`/`NONE`; not available with `--network-mode VPC`. **`--inbound-jwt-source`** (string) diff --git a/docs/commands/agentbase/runtime/generate.md b/docs/commands/agentbase/runtime/generate.md index af0b675..48a21f3 100644 --- a/docs/commands/agentbase/runtime/generate.md +++ b/docs/commands/agentbase/runtime/generate.md @@ -10,8 +10,10 @@ and apply with `grn agentbase runtime create --file `. The template ships with `imageAuth: {enabled: false}` — the API requires the field, and `enabled: false` is the public-image default. For a private registry, enable it and fill in `username`/`password` (send-only) or set -`useAgentBaseRegistryCredentials`. The same file (minus the create-only `poc`, -keeping `name`) feeds `runtime update --file`. +`useAgentBaseRegistryCredentials`. The template also ships `inboundAuth: +{mode: IAM}` — the create-time default on `PUBLIC` networks (the field is not +available in VPC mode). The same file (minus the create-only `poc`, keeping +`name`) feeds `runtime update --file`. Defaults to YAML (with comments); pass `-o json` for a JSON skeleton. diff --git a/docs/commands/agentbase/runtime/update.md b/docs/commands/agentbase/runtime/update.md index 093b2c6..3cc110c 100644 --- a/docs/commands/agentbase/runtime/update.md +++ b/docs/commands/agentbase/runtime/update.md @@ -27,6 +27,10 @@ positional argument and the name is immutable). The file **must include credentials, so it is rejected — `{enabled: false}` drops them deliberately, `username`/`password` or `useAgentBaseRegistryCredentials` keeps them. +Unlike create, update applies **no IAM default** to `inboundAuth`: a spec that +omits it sends the field omitted — include `inboundAuth` explicitly to keep +inbound auth on the new version (`inboundAuth` is `PUBLIC`-network only). + > **Notes:** `--min-replicas` is gone (the platform owns the autoscaling floor — > always 1); only `--max-replicas` is configurable. `--poc` is create-only and > cannot be set or cleared on update. diff --git a/go/cmd/agentbase/gateway.go b/go/cmd/agentbase/gateway.go index 5a696a8..bb6f5e7 100644 --- a/go/cmd/agentbase/gateway.go +++ b/go/cmd/agentbase/gateway.go @@ -86,6 +86,9 @@ By default the gateway is built from flags (the simple path: no inline targets). For targets, outbound auth, JWT inbound auth, or anything nested, use --file with a template produced by 'grn agentbase gateway generate'. +Inbound auth defaults to IAM (callers authenticate via the platform IAM); pass +--inbound-mode NONE to open the gateway or JWT to validate caller JWTs. + The gateway is created asynchronously; this command returns as soon as the service accepts the spec (state WAITING_CREATING). Converge with 'grn agentbase gateway wait '.`, @@ -156,12 +159,13 @@ func buildCreateFromFlags(cmd *cobra.Command) (*gatewaypkg.CreateGatewayRequest, } } + // Inbound auth defaults to IAM (secure by default — the flag default does + // it); the flag only needs to be set to opt out (NONE) or switch to JWT. inboundMode, _ := f.GetString("inbound-mode") - inboundMode, err = cliinput.RequireOrPromptString(inboundMode, "--inbound-mode", "Inbound auth mode (NONE|IAM|JWT)") - if err != nil { - return nil, err + inboundMode = strings.ToUpper(strings.TrimSpace(inboundMode)) + if inboundMode != "NONE" && inboundMode != "IAM" && inboundMode != "JWT" { + return nil, fmt.Errorf("--inbound-mode must be NONE, IAM or JWT (got %q)", inboundMode) } - inboundMode = strings.ToUpper(inboundMode) req := &gatewaypkg.CreateGatewayRequest{ Name: name, @@ -183,11 +187,22 @@ func buildCreateFromFlags(cmd *cobra.Command) (*gatewaypkg.CreateGatewayRequest, req.InboundAuth.ClientRedirectURIs = v } if f.Changed("iam-require-owner") { + if inboundMode != "IAM" { + return nil, fmt.Errorf("--iam-require-owner only applies when --inbound-mode is IAM") + } b, _ := f.GetBool("iam-require-owner") req.InboundAuth.IAMRequireOwner = &b } - if inboundMode == "JWT" || f.Changed("jwt-source") || f.Changed("jwt-discovery-url") || f.Changed("jwt-jwks") { + if inboundMode == "JWT" { req.InboundAuth.JWT = buildJWTFromFlags(cmd) + } else { + // Dangling --jwt-*/--allowed-*/--principal-claim flags would otherwise + // be silently dropped (they only mean anything in JWT mode). + for _, flagName := range []string{"jwt-source", "jwt-discovery-url", "jwt-jwks", "allowed-audience", "allowed-client", "allowed-scope", "principal-claim"} { + if f.Changed(flagName) { + return nil, fmt.Errorf("--%s only applies when --inbound-mode is JWT", flagName) + } + } } // PRIVATE-mode private network (vpcId/subnetId sealed at create). @@ -298,7 +313,7 @@ Defaults to YAML (with comments); pass -o json for a JSON skeleton.`, NetworkMode: "PUBLIC", FlavorID: "fill-flavor-id", Replicas: 1, - InboundAuth: gatewaypkg.InboundAuthRequest{Mode: "NONE"}, + InboundAuth: gatewaypkg.InboundAuthRequest{Mode: "IAM"}, } b, err := json.MarshalIndent(example, "", " ") if err != nil { @@ -318,7 +333,8 @@ Defaults to YAML (with comments); pass -o json for a JSON skeleton.`, const gatewayCreateTemplateYAML = `# Gateway create spec. # Fill in and apply with: grn agentbase gateway create --file # -# Required: name, networkMode, flavorId, replicas, inboundAuth.mode +# Required: name, networkMode, flavorId, replicas (inboundAuth.mode defaults +# to IAM) # Sealed at create (recreate to change): name, networkMode, flavorId, replicas, # and (PRIVATE) privateNetwork.vpcId/subnetId. name: my-gateway # 3-40 chars, [a-z0-9-] @@ -331,7 +347,7 @@ policyGroupId: "" # optional; bind a policy group (FK) # Inbound (caller) authentication. inboundAuth: - mode: NONE # NONE | IAM | JWT (required) + mode: IAM # NONE | IAM | JWT (default IAM) # clientRedirectUris: # DCR/authorize allowlist # - https://app.example.com/callback # iamRequireOwner: true # IAM mode only @@ -761,43 +777,59 @@ func loadCreateSpec(data []byte) (*gatewaypkg.CreateGatewayRequest, error) { return nil, fmt.Errorf("invalid gateway spec: %w", err) } if req.Name == "" || req.NetworkMode == "" || req.FlavorID == "" { - return nil, fmt.Errorf("spec is missing required field(s): name, networkMode, flavorId (and replicas/inboundAuth.mode)") + return nil, fmt.Errorf("spec is missing required field(s): name, networkMode, flavorId (and replicas)") + } + // inboundAuth.mode defaults to IAM (secure by default — the API requires + // the field). A jwt block without a mode is a spec error: the mode must + // say JWT for the jwt config to mean anything. + if req.InboundAuth.Mode == "" { + if req.InboundAuth.JWT != nil { + return nil, fmt.Errorf("spec inboundAuth.jwt requires inboundAuth.mode JWT") + } + req.InboundAuth.Mode = "IAM" } return &req, nil } // --------------------------------------------------------------------------- +// addGatewayCreateFlags registers the gateway create flags (kept as a helper +// so tests can register the identical flag set on a scratch command; the +// --file var is shared with update). +func addGatewayCreateFlags(cmd *cobra.Command) { + f := cmd.Flags() + f.StringVarP(&gatewayCreateName, "name", "n", "", "Gateway name (required without --interactive)") + f.String("display-name", "", "Display name") + f.String("description", "", "Description") + f.String("network-mode", "", "Network mode: PUBLIC | PRIVATE (required, sealed)") + f.String("flavor-id", "", "Flavor id (required, sealed)") + f.Int("replicas", 0, "Replica count 1-10 (required, sealed)") + f.String("inbound-mode", "IAM", "Inbound auth mode: NONE | IAM | JWT (default IAM)") + f.StringArray("client-redirect-uri", nil, "Allowed client redirect URI (repeatable)") + f.Bool("iam-require-owner", false, "IAM mode: require the caller to own the resource") + f.String("jwt-source", "", "JWT source: DISCOVERY | JWKS") + f.String("jwt-discovery-url", "", "JWT OIDC discovery URL (DISCOVERY)") + f.String("jwt-jwks", "", "JWT inline JWKS (JWKS)") + f.StringArray("allowed-audience", nil, "Allowed JWT audience (repeatable)") + f.StringArray("allowed-client", nil, "Allowed JWT client id (repeatable)") + f.StringArray("allowed-scope", nil, "Allowed JWT scope (repeatable)") + f.String("principal-claim", "", "JWT principal claim (default sub)") + f.String("policy-group-id", "", "Policy group id to bind (FK)") + f.String("private-vpc-id", "", "PRIVATE mode: VPC id (sealed)") + f.String("private-subnet-id", "", "PRIVATE mode: subnet id (sealed)") + f.StringArray("private-route", nil, "PRIVATE mode: node route CIDR (repeatable)") + f.Bool("public-endpoint-enabled", false, "PRIVATE mode: expose a public endpoint") + f.StringArray("allowed-cidr", nil, "Inbound client-IP allowlist CIDR (repeatable)") + f.StringArray("host-alias", nil, "/etc/hosts override: ip=host1,host2 (repeatable)") + f.StringVar(&gatewayCreateFile, "file", "", "Apply a spec file (see 'generate'); authoritative when set") +} + func init() { AgentbaseCmd.AddCommand(gatewayCmd) // create - create := gatewayCreateCmd - create.Flags().StringVarP(&gatewayCreateName, "name", "n", "", "Gateway name (required without --interactive)") - create.Flags().String("display-name", "", "Display name") - create.Flags().String("description", "", "Description") - create.Flags().String("network-mode", "", "Network mode: PUBLIC | PRIVATE (required, sealed)") - create.Flags().String("flavor-id", "", "Flavor id (required, sealed)") - create.Flags().Int("replicas", 0, "Replica count 1-10 (required, sealed)") - create.Flags().String("inbound-mode", "", "Inbound auth mode: NONE | IAM | JWT (required)") - create.Flags().StringArray("client-redirect-uri", nil, "Allowed client redirect URI (repeatable)") - create.Flags().Bool("iam-require-owner", false, "IAM mode: require the caller to own the resource") - create.Flags().String("jwt-source", "", "JWT source: DISCOVERY | JWKS") - create.Flags().String("jwt-discovery-url", "", "JWT OIDC discovery URL (DISCOVERY)") - create.Flags().String("jwt-jwks", "", "JWT inline JWKS (JWKS)") - create.Flags().StringArray("allowed-audience", nil, "Allowed JWT audience (repeatable)") - create.Flags().StringArray("allowed-client", nil, "Allowed JWT client id (repeatable)") - create.Flags().StringArray("allowed-scope", nil, "Allowed JWT scope (repeatable)") - create.Flags().String("principal-claim", "", "JWT principal claim (default sub)") - create.Flags().String("policy-group-id", "", "Policy group id to bind (FK)") - create.Flags().String("private-vpc-id", "", "PRIVATE mode: VPC id (sealed)") - create.Flags().String("private-subnet-id", "", "PRIVATE mode: subnet id (sealed)") - create.Flags().StringArray("private-route", nil, "PRIVATE mode: node route CIDR (repeatable)") - create.Flags().Bool("public-endpoint-enabled", false, "PRIVATE mode: expose a public endpoint") - create.Flags().StringArray("allowed-cidr", nil, "Inbound client-IP allowlist CIDR (repeatable)") - create.Flags().StringArray("host-alias", nil, "/etc/hosts override: ip=host1,host2 (repeatable)") - create.Flags().StringVar(&gatewayCreateFile, "file", "", "Apply a spec file (see 'generate'); authoritative when set") - gatewayCmd.AddCommand(create) + addGatewayCreateFlags(gatewayCreateCmd) + gatewayCmd.AddCommand(gatewayCreateCmd) // generate gatewayCmd.AddCommand(gatewayGenerateCmd) diff --git a/go/cmd/agentbase/gateway_test.go b/go/cmd/agentbase/gateway_test.go index 961e866..94f7fe2 100644 --- a/go/cmd/agentbase/gateway_test.go +++ b/go/cmd/agentbase/gateway_test.go @@ -7,6 +7,9 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + + "github.com/greennodehub/greennode-cli/internal/agentbase/cliinput" "github.com/greennodehub/greennode-cli/internal/agentbase/output" ) @@ -79,7 +82,7 @@ func TestGatewayGenerate_RoundTripsThroughLoadCreateSpec(t *testing.T) { if req.Name != "my-gateway" || req.NetworkMode != "PUBLIC" || req.FlavorID != "fill-flavor-id" { t.Errorf("round-trip decoded unexpected spec: %+v", req) } - if req.Replicas != 1 || req.InboundAuth.Mode != "NONE" { + if req.Replicas != 1 || req.InboundAuth.Mode != "IAM" { t.Errorf("round-trip decoded unexpected replicas/inbound: %+v", req) } } @@ -210,6 +213,143 @@ func TestLoadCreateSpec_MissingRequiredFails(t *testing.T) { } } +// TestLoadCreateSpec_InboundDefaultsToIAM verifies the inboundAuth defaulting +// in the --file path: a spec without inboundAuth creates with mode IAM (the +// secure default), while a jwt block without a mode is rejected instead of +// being silently paired with the IAM default. +func TestLoadCreateSpec_InboundDefaultsToIAM(t *testing.T) { + req, err := loadCreateSpec([]byte(` +name: default-inbound-gw +networkMode: PUBLIC +flavorId: f1 +replicas: 1 +`)) + if err != nil { + t.Fatalf("loadCreateSpec: %v", err) + } + if req.InboundAuth.Mode != "IAM" || req.InboundAuth.JWT != nil { + t.Errorf("inboundAuth should default to IAM, got: %+v", req.InboundAuth) + } + + _, err = loadCreateSpec([]byte(` +name: jwt-no-mode-gw +networkMode: PUBLIC +flavorId: f1 +replicas: 1 +inboundAuth: + jwt: + source: DISCOVERY + discoveryUrl: https://idp.example.com/.well-known/openid-configuration +`)) + if err == nil { + t.Fatal("expected error for inboundAuth.jwt without inboundAuth.mode") + } + if !strings.Contains(err.Error(), "inboundAuth.mode JWT") { + t.Errorf("expected inboundAuth.mode error, got: %v", err) + } +} + +// newGatewayCreateCmd builds a fresh command with the create flags, for +// testing buildCreateFromFlags in isolation (flag Changed state must not leak +// between subtests). +func newGatewayCreateCmd(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + addGatewayCreateFlags(cmd) + return cmd +} + +// TestBuildCreateFromFlags_InboundAuth covers the inbound-auth flag group on +// the gateway create path: unset --inbound-mode defaults to IAM (no prompt), +// NONE opts out (case-normalized), bad modes error, and the JWT / IAM-only +// flags are rejected outside their modes. +func TestBuildCreateFromFlags_InboundAuth(t *testing.T) { + cliinput.SetInteractive(false) + setBase := func(t *testing.T, cmd *cobra.Command) { + t.Helper() + mustSet(t, cmd, "name", "gw-1") + mustSet(t, cmd, "network-mode", "PUBLIC") + mustSet(t, cmd, "flavor-id", "f1") + mustSet(t, cmd, "replicas", "1") + } + t.Run("unset_defaults_to_iam", func(t *testing.T) { + cmd := newGatewayCreateCmd(t) + setBase(t, cmd) + req, err := buildCreateFromFlags(cmd) + if err != nil { + t.Fatalf("buildCreateFromFlags: %v", err) + } + if req.InboundAuth.Mode != "IAM" || req.InboundAuth.JWT != nil || req.InboundAuth.IAMRequireOwner != nil { + t.Errorf("expected plain IAM default, got: %+v", req.InboundAuth) + } + }) + t.Run("none_opt_out_normalized", func(t *testing.T) { + cmd := newGatewayCreateCmd(t) + setBase(t, cmd) + mustSet(t, cmd, "inbound-mode", "none") + req, err := buildCreateFromFlags(cmd) + if err != nil { + t.Fatalf("buildCreateFromFlags: %v", err) + } + if req.InboundAuth.Mode != "NONE" { + t.Errorf("expected NONE (normalized), got: %+v", req.InboundAuth) + } + }) + t.Run("bad_mode_errors", func(t *testing.T) { + cmd := newGatewayCreateCmd(t) + setBase(t, cmd) + mustSet(t, cmd, "inbound-mode", "TUNNEL") + if _, err := buildCreateFromFlags(cmd); err == nil { + t.Error("expected error for bad --inbound-mode") + } + }) + t.Run("jwt_flags_with_default_iam_error", func(t *testing.T) { + cmd := newGatewayCreateCmd(t) + setBase(t, cmd) + mustSet(t, cmd, "jwt-source", "JWKS") + _, err := buildCreateFromFlags(cmd) + if err == nil || !strings.Contains(err.Error(), "only applies when --inbound-mode is JWT") { + t.Errorf("expected jwt-flag guard error, got: %v", err) + } + }) + t.Run("iam_require_owner_with_none_errors", func(t *testing.T) { + cmd := newGatewayCreateCmd(t) + setBase(t, cmd) + mustSet(t, cmd, "inbound-mode", "NONE") + mustSet(t, cmd, "iam-require-owner", "true") + if _, err := buildCreateFromFlags(cmd); err == nil { + t.Error("expected error for --iam-require-owner with NONE") + } + }) + t.Run("iam_require_owner_ok", func(t *testing.T) { + cmd := newGatewayCreateCmd(t) + setBase(t, cmd) + mustSet(t, cmd, "iam-require-owner", "true") + req, err := buildCreateFromFlags(cmd) + if err != nil { + t.Fatalf("buildCreateFromFlags: %v", err) + } + if req.InboundAuth.IAMRequireOwner == nil || !*req.InboundAuth.IAMRequireOwner { + t.Errorf("expected IAMRequireOwner=true, got: %+v", req.InboundAuth) + } + }) + t.Run("jwt_ok", func(t *testing.T) { + cmd := newGatewayCreateCmd(t) + setBase(t, cmd) + mustSet(t, cmd, "inbound-mode", "JWT") + mustSet(t, cmd, "jwt-source", "DISCOVERY") + mustSet(t, cmd, "jwt-discovery-url", "https://idp/.well-known/openid-configuration") + req, err := buildCreateFromFlags(cmd) + if err != nil { + t.Fatalf("buildCreateFromFlags: %v", err) + } + if req.InboundAuth.Mode != "JWT" || req.InboundAuth.JWT == nil || + req.InboundAuth.JWT.Source != "DISCOVERY" || req.InboundAuth.JWT.DiscoveryURL == "" { + t.Errorf("unexpected jwt config: %+v", req.InboundAuth) + } + }) +} + // TestParseHostAliases covers the ip=host1,host2 flag parser. func TestParseHostAliases(t *testing.T) { out, err := parseHostAliases([]string{"10.0.0.1=foo.local,bar.local", "10.0.0.2=solo.local"}) diff --git a/go/cmd/agentbase/runtime.go b/go/cmd/agentbase/runtime.go index 2ce58e2..012af51 100644 --- a/go/cmd/agentbase/runtime.go +++ b/go/cmd/agentbase/runtime.go @@ -73,9 +73,12 @@ func newRuntimeClient(ctx context.Context, cmd *cobra.Command) (*runtimepkg.Clie // --------------------------------------------------------------------------- // runtimeSpec holds the mutable spec fields common to create and update. -// networkConfig/inboundAuth/protocol/allowedCidrs are only populated when the -// corresponding flags are set (they are optional server-side; omitting them -// keeps the platform defaults: public network, no inbound auth, HTTP). +// networkConfig/protocol/allowedCidrs are only populated when the corresponding +// flags are set (they are optional server-side; omitting them keeps the +// platform defaults: public network, HTTP). inboundAuth defaults to IAM on +// create (PUBLIC networks only — the API rejects inboundAuth in VPC mode); on +// update an unset --inbound-mode stays nil and the current value is carried +// over by buildUpdateRequest. type runtimeSpec struct { Description string ImageURL string @@ -159,7 +162,7 @@ func readRuntimeSpec(cmd *cobra.Command, forUpdate bool) (*runtimeSpec, error) { return nil, err } allowedCidrs, _ := f.GetStringArray("allowed-cidrs") - inbound, err := readInboundAuth(cmd) + inbound, err := readInboundAuth(cmd, forUpdate, network) if err != nil { return nil, err } @@ -224,15 +227,27 @@ func readNetworkConfig(cmd *cobra.Command) (*runtimepkg.NetworkConfig, error) { }, nil } -// readInboundAuth builds *InboundAuth only when --inbound-mode is set. NONE is +// readInboundAuth builds *InboundAuth from the --inbound-* flag group. NONE is // open, IAM authenticates callers via the platform IAM, JWT validates caller // JWTs (--inbound-jwt-* then required: DISCOVERY needs --inbound-jwt-discovery-url, // JWKS needs --inbound-jwt-jwks). -func readInboundAuth(cmd *cobra.Command) (*runtimepkg.InboundAuth, error) { +// +// Defaulting: on create an unset --inbound-mode defaults to IAM (secure by +// default), but only on PUBLIC networks — the API rejects inboundAuth in VPC +// mode, so a VPC runtime keeps it unset. On update an unset mode stays nil and +// the current version's inboundAuth is carried over by buildUpdateRequest. +func readInboundAuth(cmd *cobra.Command, forUpdate bool, network *runtimepkg.NetworkConfig) (*runtimepkg.InboundAuth, error) { f := cmd.Flags() mode, _ := f.GetString("inbound-mode") if mode == "" { - return nil, nil + if forUpdate { + return nil, nil + } + if network != nil && network.Mode == "VPC" { + // inboundAuth is PUBLIC-only: a VPC runtime must not carry it. + return nil, nil + } + mode = "IAM" } if mode != "NONE" && mode != "IAM" && mode != "JWT" { return nil, fmt.Errorf("--inbound-mode must be NONE, IAM or JWT (got %q)", mode) @@ -353,9 +368,9 @@ request sends {enabled: false} (public image). Network placement: --network-mode VPC with --vpc-id/--subnet-id and optional repeatable --route-cidrs (unset = PUBLIC). Inbound auth: --inbound-mode -NONE/IAM/JWT (JWT adds --inbound-jwt-source DISCOVERY|JWKS and its flags). -Inbound CIDR allowlist: repeatable --allowed-cidrs. Protocol: --protocol -HTTP|MCP. +NONE/IAM/JWT (unset = IAM on PUBLIC networks; not available in VPC mode; JWT +adds --inbound-jwt-source DISCOVERY|JWKS and its flags). Inbound CIDR +allowlist: repeatable --allowed-cidrs. Protocol: --protocol HTTP|MCP. --file applies a full spec instead ('grn agentbase runtime generate' template) — prefer it to keep the image-auth password (and inline JWKS key sets) out of @@ -381,6 +396,13 @@ service accepts the spec (state CREATING). Converge with if req.ImageAuth == nil { req.ImageAuth = &runtimepkg.ImageAuth{Enabled: false} } + // inboundAuth defaults to IAM on create (secure by default), but + // only on PUBLIC networks — the API rejects inboundAuth in VPC + // mode, so a VPC spec stays without it. + vpc := req.NetworkConfig != nil && req.NetworkConfig.Mode == "VPC" + if req.InboundAuth == nil && !vpc { + req.InboundAuth = &runtimepkg.InboundAuth{Mode: "IAM"} + } return createRuntimeAndPrint(ctx, cmd, req) } name, _ := cmd.Flags().GetString("name") @@ -460,7 +482,7 @@ Defaults to YAML (with comments); pass -o json for a JSON skeleton.`, EnvironmentVariables: map[string]string{}, NetworkConfig: &runtimepkg.NetworkConfig{Mode: "PUBLIC"}, AllowedCidrs: []string{}, - InboundAuth: &runtimepkg.InboundAuth{Mode: "NONE"}, + InboundAuth: &runtimepkg.InboundAuth{Mode: "IAM"}, Protocol: "HTTP", FlavorID: "fill-flavor-id", Autoscaling: runtimepkg.Autoscaling{MaxReplicas: 2, CPUUtilization: 70, MemoryUtilization: 70}, @@ -513,8 +535,8 @@ environmentVariables: # map; may be empty # routeCidrs: [] # egress routes (max 20) # allowedCidrs: # optional inbound CIDR allowlist (max 20) # - 10.0.0.0/8 -# inboundAuth: # optional inbound authentication (omit = NONE) -# mode: JWT # NONE | IAM | JWT +# inboundAuth: # inbound authentication (PUBLIC networks only; +# mode: IAM # default on create: IAM. NONE | IAM | JWT) # jwt: # required when mode is JWT # source: DISCOVERY # DISCOVERY | JWKS # discoveryUrl: https://idp.example.com/.well-known/openid-configuration @@ -1707,8 +1729,10 @@ func addLogFlags(cmd *cobra.Command) { // addRuntimeSpecFlags registers the mutable spec flags shared by create and // update (everything except --name and --poc, which are create-only). // autoscaling has flag defaults so it is always populated without prompting. -// The network/inbound flags stay unset by default (platform defaults: public -// network, no inbound auth, HTTP). +// The network flags stay unset by default (platform default: public network). +// inbound-mode stays unset by default: create then defaults inboundAuth to +// IAM (PUBLIC networks only — the API rejects inboundAuth in VPC mode), while +// update carries the current version's inboundAuth over. func addRuntimeSpecFlags(cmd *cobra.Command) { f := cmd.Flags() f.String("description", "", "Description") @@ -1725,7 +1749,7 @@ func addRuntimeSpecFlags(cmd *cobra.Command) { f.String("subnet-id", "", "networkConfig subnetId (required with --network-mode VPC)") f.StringArray("route-cidrs", nil, "networkConfig egress route CIDR (repeatable, max 20)") f.StringArray("allowed-cidrs", nil, "Inbound CIDR allowlist (repeatable, max 20)") - f.String("inbound-mode", "", "inboundAuth mode: NONE, IAM or JWT (unset = NONE)") + f.String("inbound-mode", "", "inboundAuth mode: NONE, IAM or JWT (default IAM on create; carried over on update)") f.String("inbound-jwt-source", "", "inboundAuth JWT key source: DISCOVERY or JWKS (with --inbound-mode JWT)") f.String("inbound-jwt-jwks", "", "inboundAuth JWT inline key set JSON (with source JWKS, max 32KiB; prefer --file)") f.String("inbound-jwt-discovery-url", "", "inboundAuth JWT OIDC discovery URL (with source DISCOVERY)") diff --git a/go/cmd/agentbase/runtime_test.go b/go/cmd/agentbase/runtime_test.go index 4d14bda..fe4a5b9 100644 --- a/go/cmd/agentbase/runtime_test.go +++ b/go/cmd/agentbase/runtime_test.go @@ -245,6 +245,9 @@ func TestReadRuntimeSpec_OK(t *testing.T) { if s.ImageAuth == nil || s.ImageAuth.Enabled { t.Errorf("imageAuth should default to &ImageAuth{Enabled:false}: %+v", s.ImageAuth) } + if s.Inbound == nil || s.Inbound.Mode != "IAM" { + t.Errorf("inboundAuth should default to &InboundAuth{Mode: IAM} on create: %+v", s.Inbound) + } // Ensure the returned spec round-trips into a request struct. _ = runtimepkg.CreateAgentRuntimeRequest{ ImageURL: s.ImageURL, FlavorID: s.FlavorID, Autoscaling: s.Autoscaling, @@ -291,21 +294,44 @@ func TestReadNetworkConfig(t *testing.T) { }) } -// TestReadInboundAuth covers the --inbound-* flag group: unset → nil, NONE/IAM -// happy paths, JWT validation matrix (source required; DISCOVERY needs URL; -// JWKS needs valid inline JSON; jwt flags rejected for non-JWT modes). +// TestReadInboundAuth covers the --inbound-* flag group: unset → IAM on create +// (PUBLIC networks only — the API rejects inboundAuth in VPC mode) but nil on +// update (carried over), NONE/IAM happy paths, JWT validation matrix (source +// required; DISCOVERY needs URL; JWKS needs valid inline JSON; jwt flags +// rejected for non-JWT modes, including the IAM default). func TestReadInboundAuth(t *testing.T) { - t.Run("unset_returns_nil", func(t *testing.T) { + t.Run("unset_create_defaults_to_iam", func(t *testing.T) { + cmd := newSpecCmd(t) + got, err := readInboundAuth(cmd, false, nil) + if err != nil || got == nil || got.Mode != "IAM" || got.JWT != nil { + t.Errorf("expected (&InboundAuth{Mode: IAM}, nil), got (%+v, %v)", got, err) + } + }) + t.Run("unset_create_vpc_stays_nil", func(t *testing.T) { + cmd := newSpecCmd(t) + got, err := readInboundAuth(cmd, false, &runtimepkg.NetworkConfig{Mode: "VPC"}) + if err != nil || got != nil { + t.Errorf("expected (nil, nil), got (%+v, %v)", got, err) + } + }) + t.Run("unset_update_stays_nil", func(t *testing.T) { cmd := newSpecCmd(t) - got, err := readInboundAuth(cmd) + got, err := readInboundAuth(cmd, true, nil) if err != nil || got != nil { t.Errorf("expected (nil, nil), got (%+v, %v)", got, err) } }) + t.Run("unset_with_jwt_flags_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-jwt-source", "JWKS") + if _, err := readInboundAuth(cmd, false, nil); err == nil { + t.Error("expected error when jwt flags set with --inbound-mode unset (defaults to IAM)") + } + }) t.Run("none_ok", func(t *testing.T) { cmd := newSpecCmd(t) mustSet(t, cmd, "inbound-mode", "NONE") - got, err := readInboundAuth(cmd) + got, err := readInboundAuth(cmd, false, nil) if err != nil || got == nil || got.Mode != "NONE" || got.JWT != nil { t.Errorf("unexpected: (%+v, %v)", got, err) } @@ -313,7 +339,7 @@ func TestReadInboundAuth(t *testing.T) { t.Run("jwt_without_source_errors", func(t *testing.T) { cmd := newSpecCmd(t) mustSet(t, cmd, "inbound-mode", "JWT") - if _, err := readInboundAuth(cmd); err == nil { + if _, err := readInboundAuth(cmd, false, nil); err == nil { t.Error("expected error when JWT without --inbound-jwt-source") } }) @@ -321,7 +347,7 @@ func TestReadInboundAuth(t *testing.T) { cmd := newSpecCmd(t) mustSet(t, cmd, "inbound-mode", "JWT") mustSet(t, cmd, "inbound-jwt-source", "DISCOVERY") - if _, err := readInboundAuth(cmd); err == nil { + if _, err := readInboundAuth(cmd, false, nil); err == nil { t.Error("expected error when DISCOVERY without --inbound-jwt-discovery-url") } }) @@ -332,7 +358,7 @@ func TestReadInboundAuth(t *testing.T) { mustSet(t, cmd, "inbound-jwt-discovery-url", "https://idp/.well-known/openid-configuration") mustSet(t, cmd, "inbound-jwt-audience", "aud1") mustSet(t, cmd, "inbound-jwt-principal-claim", "sub") - got, err := readInboundAuth(cmd) + got, err := readInboundAuth(cmd, false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -346,7 +372,7 @@ func TestReadInboundAuth(t *testing.T) { mustSet(t, cmd, "inbound-mode", "JWT") mustSet(t, cmd, "inbound-jwt-source", "JWKS") mustSet(t, cmd, "inbound-jwt-jwks", "{not-json") - if _, err := readInboundAuth(cmd); err == nil { + if _, err := readInboundAuth(cmd, false, nil); err == nil { t.Error("expected error for invalid JWKS JSON") } }) @@ -355,7 +381,7 @@ func TestReadInboundAuth(t *testing.T) { mustSet(t, cmd, "inbound-mode", "JWT") mustSet(t, cmd, "inbound-jwt-source", "JWKS") mustSet(t, cmd, "inbound-jwt-jwks", `{"keys":[]}`) - got, err := readInboundAuth(cmd) + got, err := readInboundAuth(cmd, false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -367,7 +393,7 @@ func TestReadInboundAuth(t *testing.T) { cmd := newSpecCmd(t) mustSet(t, cmd, "inbound-mode", "IAM") mustSet(t, cmd, "inbound-jwt-source", "JWKS") - if _, err := readInboundAuth(cmd); err == nil { + if _, err := readInboundAuth(cmd, false, nil); err == nil { t.Error("expected error when jwt flags set with --inbound-mode IAM") } }) From f3f040e5103187cb674e03fe03b82f8fcfe74407 Mon Sep 17 00:00:00 2001 From: anhpg Date: Tue, 3 Nov 2026 13:17:58 +0700 Subject: [PATCH 8/8] fix default inbound to IAM --- docs/commands/agentbase/runtime/create.md | 7 +- docs/commands/agentbase/runtime/generate.md | 10 +- docs/commands/agentbase/runtime/update.md | 40 ++- go/cmd/agentbase/runtime.go | 143 +++++++++-- go/cmd/agentbase/runtime_test.go | 255 +++++++++++++++++++- 5 files changed, 416 insertions(+), 39 deletions(-) diff --git a/docs/commands/agentbase/runtime/create.md b/docs/commands/agentbase/runtime/create.md index 7b601ac..8de1885 100644 --- a/docs/commands/agentbase/runtime/create.md +++ b/docs/commands/agentbase/runtime/create.md @@ -181,7 +181,8 @@ Autoscaling: memory scale-up threshold % (10-90). - Required: No - Default: `PUBLIC` -- Constraints: `VPC` requires `--vpc-id` and `--subnet-id`. +- Constraints: `VPC` requires `--vpc-id` and `--subnet-id`; explicitly set but + empty is rejected (an empty `$NETWORK_MODE` expansion used to read as unset). **`--vpc-id`** (string) @@ -223,7 +224,9 @@ authenticate via the platform IAM); pass `NONE` to open the runtime. - Required: No - Default: `IAM` on create (when the network is `PUBLIC` or unset) - Constraints: `JWT` requires the `--inbound-jwt-*` flags; cannot be combined - with them when set to `IAM`/`NONE`; not available with `--network-mode VPC`. + with them when set to `IAM`/`NONE`; not available with `--network-mode VPC`; + explicitly set but empty is rejected (an empty `$INBOUND_MODE` expansion + used to read as unset and default to `IAM`). **`--inbound-jwt-source`** (string) diff --git a/docs/commands/agentbase/runtime/generate.md b/docs/commands/agentbase/runtime/generate.md index 48a21f3..b638d14 100644 --- a/docs/commands/agentbase/runtime/generate.md +++ b/docs/commands/agentbase/runtime/generate.md @@ -10,10 +10,12 @@ and apply with `grn agentbase runtime create --file `. The template ships with `imageAuth: {enabled: false}` — the API requires the field, and `enabled: false` is the public-image default. For a private registry, enable it and fill in `username`/`password` (send-only) or set -`useAgentBaseRegistryCredentials`. The template also ships `inboundAuth: -{mode: IAM}` — the create-time default on `PUBLIC` networks (the field is not -available in VPC mode). The same file (minus the create-only `poc`, keeping -`name`) feeds `runtime update --file`. +`useAgentBaseRegistryCredentials`. The template also includes a live +`inboundAuth: {mode: IAM}` block — the create-time default on `PUBLIC` +networks (the field is not available in VPC mode) — so a template-driven +create or update is explicit about inbound auth instead of relying on the +default/carry-over. Drop the block only for VPC specs. The same file (minus +the create-only `poc`, keeping `name`) feeds `runtime update --file`. Defaults to YAML (with comments); pass `-o json` for a JSON skeleton. diff --git a/docs/commands/agentbase/runtime/update.md b/docs/commands/agentbase/runtime/update.md index 3cc110c..fe22e1d 100644 --- a/docs/commands/agentbase/runtime/update.md +++ b/docs/commands/agentbase/runtime/update.md @@ -14,22 +14,34 @@ wholesale (no element-level merge). Set flags only for what you intend to change — e.g. a bare `--env K=V` replaces the whole environment map, it does not add one key. +An unset `--inbound-mode` carries the current version's inbound auth over, +and the carried value follows the target network: moving the runtime to `VPC` +drops it (inbound auth does not apply on a private network; `--inbound-mode` +is an error there), and opening a `VPC` runtime up to `PUBLIC` defaults it to +`IAM` — never silently to `NONE` (say `--inbound-mode NONE` to open it +deliberately). An explicitly-set-but-empty `--inbound-mode` (e.g. an empty +`$INBOUND_MODE` expansion) is rejected instead of being read as unset. + `imageAuth` is the exception — the API never returns the stored password, so username/password image auth cannot be carried over: re-supply `--image-auth-username`/`--image-auth-password` (or use `--file` with a full spec). Without image auth the update re-sends `{enabled: false}`. When `--file` is set it is authoritative — every flag below is ignored, and -the spec is applied as-is. The file must contain `imageUrl` and `flavorId`; a +the spec is applied as-is except for an omitted `inboundAuth` (see below). The +file must contain `imageUrl` and `flavorId`; a `name` in the file is ignored (the runtime is addressed by the `` positional argument and the name is immutable). The file **must include `imageAuth` explicitly**: a missing `imageAuth` would silently wipe the stored credentials, so it is rejected — `{enabled: false}` drops them deliberately, `username`/`password` or `useAgentBaseRegistryCredentials` keeps them. -Unlike create, update applies **no IAM default** to `inboundAuth`: a spec that -omits it sends the field omitted — include `inboundAuth` explicitly to keep -inbound auth on the new version (`inboundAuth` is `PUBLIC`-network only). +A spec that omits `inboundAuth` gets the same treatment as an unset +`--inbound-mode`: the current version's `inboundAuth` is carried over (a +stderr note names the source version), defaulting to `IAM` when there is +nothing to carry (`PUBLIC`-network specs only; drop the `inboundAuth` block +when the spec moves `networkConfig` to `VPC` — `inboundAuth` is +`PUBLIC`-network only, and the CLI rejects a spec that combines the two). > **Notes:** `--min-replicas` is gone (the platform owns the autoscaling floor — > always 1); only `--max-replicas` is configurable. `--poc` is create-only and @@ -181,7 +193,8 @@ over; setting any network flag replaces the whole `networkConfig`. - Required: No (carried over from the current version when unset) - Default: (empty = carried over) -- Constraints: `VPC` requires `--vpc-id` and `--subnet-id`. +- Constraints: `VPC` requires `--vpc-id` and `--subnet-id`; explicitly set but + empty is rejected. **`--vpc-id`** (string) @@ -216,12 +229,14 @@ Inbound CIDR allowlist (repeatable, max 20). **`--inbound-mode`** (string) `inboundAuth` mode: `NONE`, `IAM`, or `JWT`. Unset = the current version's -`inboundAuth` is carried over. +`inboundAuth` is carried over; on a `VPC` target (set or carried) unset drops +it, and when there is nothing to carry it defaults to `IAM`. - Required: No (carried over from the current version when unset) -- Default: (empty = carried over) +- Default: (empty = carried over; `IAM` when nothing to carry) - Constraints: `JWT` requires the `--inbound-jwt-*` flags; cannot be combined - with them when set to `IAM`/`NONE`. + with them when set to `IAM`/`NONE`; explicitly set but empty is rejected; + rejected on a `VPC` target (inbound auth is `PUBLIC`-network only). **`--inbound-jwt-source`** (string) @@ -287,11 +302,14 @@ Apply a full-spec file (create template minus `name`); authoritative when set. - Required: No - Default: (empty) -- Constraints: when set, the flags above are ignored; `name` must be present - in the file; `imageAuth` must be present in the file (a missing `imageAuth` +- Constraints: when set, the flags above are ignored; `name` is optional in + the file (ignored — the runtime is addressed by the `` positional + argument); `imageAuth` must be present in the file (a missing `imageAuth` would wipe the stored credentials — set `{enabled: false}` to drop them deliberately, or `username`/`password` / `useAgentBaseRegistryCredentials` - to keep them). + to keep them); an omitted `inboundAuth` is carried over from the current + version (defaulted to `IAM` when there is nothing to carry), and a spec that + combines `inboundAuth` with a `VPC` `networkConfig` is rejected. ## Global options diff --git a/go/cmd/agentbase/runtime.go b/go/cmd/agentbase/runtime.go index 012af51..3192a47 100644 --- a/go/cmd/agentbase/runtime.go +++ b/go/cmd/agentbase/runtime.go @@ -200,6 +200,13 @@ func readNetworkConfig(cmd *cobra.Command) (*runtimepkg.NetworkConfig, error) { f := cmd.Flags() mode, _ := f.GetString("network-mode") if mode == "" { + if f.Changed("network-mode") { + // Set-but-empty is a scripting hazard (an empty $NETWORK_MODE + // expansion): it read as unset, which on create silently meant + // PUBLIC while the script meant to set a network. Refuse it like + // --image-url/--flavor-id. + return nil, fmt.Errorf("--network-mode cannot be empty (PUBLIC or VPC; unset means PUBLIC on create and carries the current network over on update)") + } // A dangling --vpc-id/--subnet-id/--route-cidrs without --network-mode // used to be silently ignored — refuse it instead (it would otherwise // read as "leave the network public" while the user meant VPC). @@ -236,10 +243,18 @@ func readNetworkConfig(cmd *cobra.Command) (*runtimepkg.NetworkConfig, error) { // default), but only on PUBLIC networks — the API rejects inboundAuth in VPC // mode, so a VPC runtime keeps it unset. On update an unset mode stays nil and // the current version's inboundAuth is carried over by buildUpdateRequest. +// +// A set-but-empty mode is refused on both paths (35388): an empty $INBOUND_MODE +// expansion is a scripting hazard — on update it read as unset and silently +// dropped inboundAuth (the API's full-spec replacement then reset the mode to +// NONE), on create it silently took the IAM default. func readInboundAuth(cmd *cobra.Command, forUpdate bool, network *runtimepkg.NetworkConfig) (*runtimepkg.InboundAuth, error) { f := cmd.Flags() mode, _ := f.GetString("inbound-mode") if mode == "" { + if f.Changed("inbound-mode") { + return nil, fmt.Errorf("--inbound-mode cannot be empty (NONE, IAM or JWT; unset defaults to IAM on create and carries the current mode over on update)") + } if forUpdate { return nil, nil } @@ -535,8 +550,8 @@ environmentVariables: # map; may be empty # routeCidrs: [] # egress routes (max 20) # allowedCidrs: # optional inbound CIDR allowlist (max 20) # - 10.0.0.0/8 -# inboundAuth: # inbound authentication (PUBLIC networks only; -# mode: IAM # default on create: IAM. NONE | IAM | JWT) +inboundAuth: # inbound authentication (PUBLIC networks only; + mode: IAM # default: IAM. NONE | IAM | JWT) # jwt: # required when mode is JWT # source: DISCOVERY # DISCOVERY | JWKS # discoveryUrl: https://idp.example.com/.well-known/openid-configuration @@ -647,9 +662,19 @@ username/password image auth cannot be carried over: re-supply --image-auth-username/--image-auth-password (or --file with a full spec). Without image auth the update re-sends {enabled: false}. +An unset --inbound-mode carries the current version's inbound auth over, and +the carried value follows the target network: moving the runtime to VPC drops +it (inbound auth does not apply on a private network; --inbound-mode is an +error there), and opening a VPC runtime up to PUBLIC defaults it to IAM (never +silently to NONE) — say --inbound-mode NONE to open it deliberately. + --file applies a full spec instead ('grn agentbase runtime generate' template minus name) — it must include imageAuth explicitly, since a missing imageAuth -would silently wipe stored credentials. +would silently wipe stored credentials. An omitted inboundAuth block is not a +reset either: it is carried over from the current version (defaulted to IAM +when there is nothing to carry). Drop the inboundAuth block only when the spec +moves networkConfig to VPC — inboundAuth is PUBLIC-only and the API rejects it +in VPC mode (the CLI errors on that combination). The update is asynchronous; converge with 'grn agentbase runtime wait '.`, Args: cobra.ExactArgs(1), @@ -677,22 +702,28 @@ The update is asynchronous; converge with 'grn agentbase runtime wait '.`, if createReq.ImageAuth == nil { return fmt.Errorf("spec is missing required field: imageAuth (the API never returns the stored password, so it cannot be carried over; set imageAuth explicitly: {enabled: false} drops it, username/password or useAgentBaseRegistryCredentials keeps it)") } + // inboundAuth carry-over (35379): an omitted block used to be + // forwarded as omitted and the API's full-spec replacement reset + // the mode to NONE. Fetch the current version only when there is + // something to resolve — an explicit block or a VPC spec needs + // no round-trip. + var cur *runtimepkg.AgentRuntimeVersionDto + if createReq.InboundAuth == nil && !specTargetsVPC(createReq.NetworkConfig) { + cur, err = latestVersion(ctx, client, id) + if err != nil { + return err + } + } + req, note, err := updateRequestFromFileSpec(createReq, cur) + if err != nil { + return err + } + if note != "" { + fmt.Fprintf(os.Stderr, "Carrying over: %s\n", note) + } // poc is create-only: a spec file carrying poc is accepted for // convenience but the field is not forwarded on update. - return updateRuntimeAndPrint(ctx, client, cmd, id, &runtimepkg.UpdateAgentRuntimeRequest{ - Description: createReq.Description, - ImageURL: createReq.ImageURL, - ImageAuth: createReq.ImageAuth, - Command: createReq.Command, - Args: createReq.Args, - EnvironmentVariables: createReq.EnvironmentVariables, - NetworkConfig: createReq.NetworkConfig, - AllowedCidrs: createReq.AllowedCidrs, - InboundAuth: createReq.InboundAuth, - Protocol: createReq.Protocol, - FlavorID: createReq.FlavorID, - Autoscaling: createReq.Autoscaling, - }) + return updateRuntimeAndPrint(ctx, client, cmd, id, req) } spec, err := readRuntimeSpec(cmd, true) if err != nil { @@ -726,6 +757,56 @@ func updateRuntimeAndPrint(ctx context.Context, client *runtimepkg.Client, cmd * return output.PrintResource(rt, func() string { return rt.ID }, func() error { return renderRuntimeDetail(rt) }) } +// specTargetsVPC reports whether a spec's networkConfig pins the runtime to +// VPC. A nil networkConfig (or PUBLIC) is a public network — including on +// update, where the API's full-spec replacement resets an omitted +// networkConfig to the PUBLIC default rather than keeping the current one. +func specTargetsVPC(network *runtimepkg.NetworkConfig) bool { + return network != nil && network.Mode == "VPC" +} + +// updateRequestFromFileSpec assembles the update request for the --file path. +// The spec is authoritative for the fields it sets; for an inboundAuth it +// omits, it matches the flag path (35379) instead of forwarding the omission: +// the current version's inboundAuth is carried over (cur may be nil when the +// caller determined no round-trip was needed, or when the current version has +// none — then IAM is the default, never a silent NONE), and a VPC spec keeps +// it unset (inboundAuth is PUBLIC-only; an explicit block on a VPC spec is an +// error). Unit-tested in runtime_test.go. +func updateRequestFromFileSpec(createReq *runtimepkg.CreateAgentRuntimeRequest, cur *runtimepkg.AgentRuntimeVersionDto) (*runtimepkg.UpdateAgentRuntimeRequest, string, error) { + vpc := specTargetsVPC(createReq.NetworkConfig) + if createReq.InboundAuth != nil && vpc { + return nil, "", fmt.Errorf("spec sets inboundAuth on a VPC networkConfig: inboundAuth is PUBLIC-only — drop the inboundAuth block (a VPC runtime carries no inbound auth)") + } + req := &runtimepkg.UpdateAgentRuntimeRequest{ + Description: createReq.Description, + ImageURL: createReq.ImageURL, + ImageAuth: createReq.ImageAuth, + Command: createReq.Command, + Args: createReq.Args, + EnvironmentVariables: createReq.EnvironmentVariables, + NetworkConfig: createReq.NetworkConfig, + AllowedCidrs: createReq.AllowedCidrs, + Protocol: createReq.Protocol, + FlavorID: createReq.FlavorID, + Autoscaling: createReq.Autoscaling, + } + var note string + switch { + case createReq.InboundAuth != nil: + req.InboundAuth = createReq.InboundAuth + case vpc: + // A VPC spec must not carry inboundAuth — leave it unset. + case cur != nil && cur.InboundAuth != nil: + req.InboundAuth = versionInboundAuth(cur) + note = fmt.Sprintf("inboundAuth (from version %d)", cur.Version) + default: + req.InboundAuth = &runtimepkg.InboundAuth{Mode: "IAM"} + note = "inboundAuth (defaulted to IAM; current version has none)" + } + return req, note, nil +} + // imageAuthFlags are the flags that explicitly set the imageAuth spec; any of // them marks it as user-provided on update (otherwise it is derived from the // current version). @@ -757,6 +838,12 @@ func anyChanged(changed func(string) bool, names ...string) bool { // imageAuth is the exception: the API never returns the stored password and an // update with enabled=false wipes the stored credentials, so it cannot be // carried over blindly — see carriedImageAuth. +// +// inboundAuth is PUBLIC-only (the API rejects it in VPC mode) and the carried +// value follows the target network: a VPC target drops it (the PUBLIC→VPC +// transition; an explicit --inbound-mode on a VPC target is an error), and a +// PUBLIC target with nothing to carry (a VPC version opening up) defaults to +// IAM like create does instead of silently opening the runtime (35379). func buildUpdateRequest(spec *runtimeSpec, cur *runtimepkg.AgentRuntimeVersionDto, changed func(string) bool) (*runtimepkg.UpdateAgentRuntimeRequest, []string, error) { req := &runtimepkg.UpdateAgentRuntimeRequest{} var carried []string @@ -804,10 +891,28 @@ func buildUpdateRequest(spec *runtimeSpec, cur *runtimepkg.AgentRuntimeVersionDt carried = append(carried, "allowedCidrs") } if changed("inbound-mode") { + if req.NetworkConfig != nil && req.NetworkConfig.Mode == "VPC" { + return nil, nil, fmt.Errorf("--inbound-mode only applies to PUBLIC networks (the update targets VPC; a VPC runtime carries no inbound auth)") + } req.InboundAuth = spec.Inbound } else { - req.InboundAuth = versionInboundAuth(cur) - carried = append(carried, "inboundAuth") + switch { + case req.NetworkConfig != nil && req.NetworkConfig.Mode == "VPC": + // PUBLIC→VPC: inbound auth does not apply on a private network — + // drop the current version's instead of letting the API 400. + if cur.InboundAuth != nil { + carried = append(carried, "inboundAuth (dropped: target network is VPC)") + } + case cur.InboundAuth != nil: + req.InboundAuth = versionInboundAuth(cur) + carried = append(carried, "inboundAuth") + default: + // VPC→PUBLIC (or a version with no inboundAuth at all): nothing to + // carry — default to IAM like create instead of letting the + // omitted field reset the mode to NONE. + req.InboundAuth = &runtimepkg.InboundAuth{Mode: "IAM"} + carried = append(carried, "inboundAuth (defaulted to IAM; current version has none)") + } } if changed("protocol") { req.Protocol = spec.Protocol diff --git a/go/cmd/agentbase/runtime_test.go b/go/cmd/agentbase/runtime_test.go index fe4a5b9..47a12f1 100644 --- a/go/cmd/agentbase/runtime_test.go +++ b/go/cmd/agentbase/runtime_test.go @@ -45,6 +45,11 @@ func TestRuntimeGenerate_RoundTripsThroughLoadRuntimeSpec(t *testing.T) { if req.Name != "my-runtime" || req.ImageURL != "registry.example.com/my-agent:latest" || req.FlavorID != "fill-flavor-id" { t.Errorf("round-trip decoded unexpected spec: %+v", req) } + // 35379: the template ships an explicit inboundAuth block (mode IAM) — it + // must decode too, so a template-driven update never relies on carry-over. + if req.InboundAuth == nil || req.InboundAuth.Mode != "IAM" { + t.Errorf("template inboundAuth should decode to {mode: IAM}: %+v", req.InboundAuth) + } } // TestRuntimeGenerate_PrintsJSONSkeleton verifies -o json prints a JSON skeleton. @@ -264,6 +269,15 @@ func TestReadNetworkConfig(t *testing.T) { t.Errorf("expected (nil, nil), got (%+v, %v)", got, err) } }) + t.Run("set_but_empty_errors", func(t *testing.T) { + // Hardening: an empty $NETWORK_MODE expansion used to read as unset + // (PUBLIC on create) while the script meant to set a network. + cmd := newSpecCmd(t) + mustSet(t, cmd, "network-mode", "") + if _, err := readNetworkConfig(cmd); err == nil { + t.Error("expected error for explicitly-set-but-empty --network-mode") + } + }) t.Run("vpc_without_ids_errors", func(t *testing.T) { cmd := newSpecCmd(t) mustSet(t, cmd, "network-mode", "VPC") @@ -321,6 +335,23 @@ func TestReadInboundAuth(t *testing.T) { t.Errorf("expected (nil, nil), got (%+v, %v)", got, err) } }) + t.Run("set_but_empty_update_errors", func(t *testing.T) { + // 35388: an empty $INBOUND_MODE expansion used to read as unset and + // silently dropped inboundAuth on update (the API's full-spec + // replacement then reset the mode to NONE). + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "") + if _, err := readInboundAuth(cmd, true, nil); err == nil { + t.Error("expected error for explicitly-set-but-empty --inbound-mode on update") + } + }) + t.Run("set_but_empty_create_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "inbound-mode", "") + if _, err := readInboundAuth(cmd, false, nil); err == nil { + t.Error("expected error for explicitly-set-but-empty --inbound-mode on create") + } + }) t.Run("unset_with_jwt_flags_errors", func(t *testing.T) { cmd := newSpecCmd(t) mustSet(t, cmd, "inbound-jwt-source", "JWKS") @@ -539,8 +570,11 @@ func testVersion() *runtimepkg.AgentRuntimeVersionDto { Command: []string{"cur-cmd"}, Args: []string{"cur-arg"}, EnvironmentVariables: map[string]string{"CUR": "1"}, + // PUBLIC + inboundAuth is the only real-world combo that carries one + // (the API rejects inboundAuth in VPC mode, so a VPC version never has + // it). NetworkConfig: &runtimepkg.AgentRuntimeNetworkConfigEntity{ - Mode: "VPC", VpcID: "cur-vpc", SubnetID: "cur-sub", RouteCidrs: []string{"10.0.0.0/8"}, + Mode: "PUBLIC", }, AllowedCidrs: []string{"10.0.0.0/8"}, InboundAuth: &runtimepkg.AgentRuntimeInboundAuthDto{ @@ -556,6 +590,17 @@ func testVersion() *runtimepkg.AgentRuntimeVersionDto { } } +// testVPCVersion is the VPC twin of testVersion: a VPC version never carries +// inboundAuth (PUBLIC-only field), so VPC carry/drop tests use it. +func testVPCVersion() *runtimepkg.AgentRuntimeVersionDto { + cur := testVersion() + cur.NetworkConfig = &runtimepkg.AgentRuntimeNetworkConfigEntity{ + Mode: "VPC", VpcID: "cur-vpc", SubnetID: "cur-sub", RouteCidrs: []string{"10.0.0.0/8"}, + } + cur.InboundAuth = nil + return cur +} + // specFromFlags assembles the update-path spec from a flag command. func specFromFlags(t *testing.T, cmd *cobra.Command) *runtimeSpec { t.Helper() @@ -585,8 +630,7 @@ func TestBuildUpdateRequest(t *testing.T) { req.Autoscaling.MaxReplicas != 3 { t.Errorf("carry-over mismatch: %+v", req) } - if req.NetworkConfig == nil || req.NetworkConfig.Mode != "VPC" || req.NetworkConfig.VpcID != "cur-vpc" || - req.NetworkConfig.SubnetID != "cur-sub" || req.NetworkConfig.RouteCidrs[0] != "10.0.0.0/8" { + if req.NetworkConfig == nil || req.NetworkConfig.Mode != "PUBLIC" || req.NetworkConfig.VpcID != "" { t.Errorf("networkConfig carry-over mismatch: %+v", req.NetworkConfig) } if req.InboundAuth == nil || req.InboundAuth.Mode != "JWT" || req.InboundAuth.JWT == nil || @@ -687,6 +731,91 @@ func TestBuildUpdateRequest(t *testing.T) { t.Errorf("autoscaling should be the flag spec: %+v", req.Autoscaling) } }) + t.Run("carried_none_stays_none", func(t *testing.T) { + // A PUBLIC runtime running open (mode NONE) must not be flipped to IAM + // by a routine update — NONE carries verbatim (35379). + cur := testVersion() + cur.InboundAuth = &runtimepkg.AgentRuntimeInboundAuthDto{Mode: "NONE"} + cmd := newSpecCmd(t) + req, carried, err := buildUpdateRequest(specFromFlags(t, cmd), cur, cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.InboundAuth == nil || req.InboundAuth.Mode != "NONE" { + t.Errorf("NONE should carry verbatim, not default to IAM: %+v", req.InboundAuth) + } + if !slices.Contains(carried, "inboundAuth") { + t.Errorf("carried list missing inboundAuth: %v", carried) + } + }) + t.Run("nothing_set_on_vpc_carries_network_without_inbound", func(t *testing.T) { + // A VPC version never has inboundAuth: an unchanged update carries the + // VPC networkConfig and keeps inboundAuth unset (no IAM default on a + // VPC target). + cmd := newSpecCmd(t) + req, carried, err := buildUpdateRequest(specFromFlags(t, cmd), testVPCVersion(), cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.NetworkConfig == nil || req.NetworkConfig.Mode != "VPC" || req.NetworkConfig.VpcID != "cur-vpc" || + req.NetworkConfig.SubnetID != "cur-sub" || req.NetworkConfig.RouteCidrs[0] != "10.0.0.0/8" { + t.Errorf("VPC networkConfig should carry over: %+v", req.NetworkConfig) + } + if req.InboundAuth != nil { + t.Errorf("a VPC target must not carry or default inboundAuth: %+v", req.InboundAuth) + } + if slices.Contains(carried, "inboundAuth") { + t.Errorf("carried list should not mention inboundAuth: %v", carried) + } + }) + t.Run("vpc_to_public_defaults_inbound_to_iam", func(t *testing.T) { + // Opening a VPC runtime up to PUBLIC: nothing to carry — default to + // IAM like create instead of letting the omitted field reset the mode + // to NONE (35379 / hardening). + cmd := newSpecCmd(t) + mustSet(t, cmd, "network-mode", "PUBLIC") + req, carried, err := buildUpdateRequest(specFromFlags(t, cmd), testVPCVersion(), cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.NetworkConfig == nil || req.NetworkConfig.Mode != "PUBLIC" { + t.Errorf("target network should be PUBLIC: %+v", req.NetworkConfig) + } + if req.InboundAuth == nil || req.InboundAuth.Mode != "IAM" || req.InboundAuth.JWT != nil { + t.Errorf("inboundAuth should default to IAM: %+v", req.InboundAuth) + } + if !slices.Contains(carried, "inboundAuth (defaulted to IAM; current version has none)") { + t.Errorf("carried list should note the IAM default: %v", carried) + } + }) + t.Run("public_to_vpc_drops_carried_inbound", func(t *testing.T) { + // Moving to VPC: inbound auth does not apply on a private network — + // drop the current version's instead of letting the API 400. + cmd := newSpecCmd(t) + mustSet(t, cmd, "network-mode", "VPC") + mustSet(t, cmd, "vpc-id", "v1") + mustSet(t, cmd, "subnet-id", "s1") + req, carried, err := buildUpdateRequest(specFromFlags(t, cmd), testVersion(), cmd.Flags().Changed) + if err != nil { + t.Fatalf("buildUpdateRequest: %v", err) + } + if req.InboundAuth != nil { + t.Errorf("inboundAuth should be dropped on a VPC target: %+v", req.InboundAuth) + } + if !slices.Contains(carried, "inboundAuth (dropped: target network is VPC)") { + t.Errorf("carried list should note the drop: %v", carried) + } + }) + t.Run("explicit_inbound_mode_on_vpc_target_errors", func(t *testing.T) { + cmd := newSpecCmd(t) + mustSet(t, cmd, "network-mode", "VPC") + mustSet(t, cmd, "vpc-id", "v1") + mustSet(t, cmd, "subnet-id", "s1") + mustSet(t, cmd, "inbound-mode", "NONE") + if _, _, err := buildUpdateRequest(specFromFlags(t, cmd), testVersion(), cmd.Flags().Changed); err == nil { + t.Error("expected error for --inbound-mode on a VPC target") + } + }) } // TestCarriedImageAuth_UnsetVersion returns {enabled:false} (imageAuth is @@ -700,3 +829,123 @@ func TestCarriedImageAuth_UnsetVersion(t *testing.T) { t.Errorf("expected &ImageAuth{Enabled:false}, got %+v", ia) } } + +// TestUpdateRequestFromFileSpec covers the --file update path (35379): an +// omitted inboundAuth block is carried over from the current version (or +// defaulted to IAM when there is nothing to carry) instead of being forwarded +// as an omission that the API's full-spec replacement resets to NONE; a VPC +// spec keeps inboundAuth unset and refuses an explicit block. +func TestUpdateRequestFromFileSpec(t *testing.T) { + newSpec := func(mutate func(*runtimepkg.CreateAgentRuntimeRequest)) *runtimepkg.CreateAgentRuntimeRequest { + req := &runtimepkg.CreateAgentRuntimeRequest{ + ImageURL: "img:2", + ImageAuth: &runtimepkg.ImageAuth{Enabled: false}, + FlavorID: "f2", + } + if mutate != nil { + mutate(req) + } + return req + } + t.Run("omitted_inbound_carries_from_current", func(t *testing.T) { + req, note, err := updateRequestFromFileSpec(newSpec(nil), testVersion()) + if err != nil { + t.Fatalf("updateRequestFromFileSpec: %v", err) + } + if req.InboundAuth == nil || req.InboundAuth.Mode != "JWT" || req.InboundAuth.JWT == nil || + req.InboundAuth.JWT.Source != "JWKS" || string(req.InboundAuth.JWT.JWKS) != `{"keys": []}` || + req.InboundAuth.JWT.AllowedAudiences[0] != "aud" || req.InboundAuth.JWT.PrincipalClaim != "sub" { + t.Errorf("inboundAuth should carry the JWT config verbatim: %+v", req.InboundAuth) + } + if !strings.Contains(note, "from version 7") { + t.Errorf("note should name the source version: %q", note) + } + }) + t.Run("omitted_inbound_defaults_to_iam_when_nothing_to_carry", func(t *testing.T) { + cur := testVersion() + cur.InboundAuth = nil + req, note, err := updateRequestFromFileSpec(newSpec(nil), cur) + if err != nil { + t.Fatalf("updateRequestFromFileSpec: %v", err) + } + if req.InboundAuth == nil || req.InboundAuth.Mode != "IAM" || req.InboundAuth.JWT != nil { + t.Errorf("inboundAuth should default to IAM, never NONE: %+v", req.InboundAuth) + } + if !strings.Contains(note, "defaulted to IAM") { + t.Errorf("note should name the IAM default: %q", note) + } + }) + t.Run("explicit_inbound_forwarded_verbatim", func(t *testing.T) { + req, note, err := updateRequestFromFileSpec(newSpec(func(r *runtimepkg.CreateAgentRuntimeRequest) { + r.InboundAuth = &runtimepkg.InboundAuth{Mode: "NONE"} + }), testVersion()) + if err != nil { + t.Fatalf("updateRequestFromFileSpec: %v", err) + } + if req.InboundAuth == nil || req.InboundAuth.Mode != "NONE" { + t.Errorf("explicit inboundAuth should replace the current one: %+v", req.InboundAuth) + } + if note != "" { + t.Errorf("explicit inboundAuth needs no carry note: %q", note) + } + }) + t.Run("vpc_spec_keeps_inbound_unset", func(t *testing.T) { + req, note, err := updateRequestFromFileSpec(newSpec(func(r *runtimepkg.CreateAgentRuntimeRequest) { + r.NetworkConfig = &runtimepkg.NetworkConfig{Mode: "VPC", VpcID: "v1", SubnetID: "s1"} + }), testVersion()) + if err != nil { + t.Fatalf("updateRequestFromFileSpec: %v", err) + } + if req.InboundAuth != nil { + t.Errorf("a VPC spec must not carry or default inboundAuth: %+v", req.InboundAuth) + } + if note != "" { + t.Errorf("a VPC spec needs no carry note: %q", note) + } + }) + t.Run("vpc_spec_on_vpc_current_keeps_inbound_unset_without_current", func(t *testing.T) { + // cur == nil: the caller skips the version round-trip on a VPC spec. + req, note, err := updateRequestFromFileSpec(newSpec(func(r *runtimepkg.CreateAgentRuntimeRequest) { + r.NetworkConfig = &runtimepkg.NetworkConfig{Mode: "VPC", VpcID: "v1", SubnetID: "s1"} + }), nil) + if err != nil { + t.Fatalf("updateRequestFromFileSpec: %v", err) + } + if req.InboundAuth != nil || note != "" { + t.Errorf("VPC spec with no current version: inboundAuth=%+v note=%q", req.InboundAuth, note) + } + }) + t.Run("vpc_spec_with_inbound_errors", func(t *testing.T) { + _, _, err := updateRequestFromFileSpec(newSpec(func(r *runtimepkg.CreateAgentRuntimeRequest) { + r.NetworkConfig = &runtimepkg.NetworkConfig{Mode: "VPC", VpcID: "v1", SubnetID: "s1"} + r.InboundAuth = &runtimepkg.InboundAuth{Mode: "IAM"} + }), nil) + if err == nil { + t.Error("expected error for an inboundAuth block on a VPC spec") + } + }) + t.Run("spec_fields_map_to_request", func(t *testing.T) { + req, _, err := updateRequestFromFileSpec(newSpec(func(r *runtimepkg.CreateAgentRuntimeRequest) { + r.Description = "d" + r.Command = []string{"c"} + r.Args = []string{"a"} + r.EnvironmentVariables = map[string]string{"K": "V"} + r.NetworkConfig = &runtimepkg.NetworkConfig{Mode: "PUBLIC"} + r.AllowedCidrs = []string{"10.0.0.0/8"} + r.Protocol = "MCP" + r.Autoscaling = runtimepkg.Autoscaling{MaxReplicas: 4} + }), nil) + if err != nil { + t.Fatalf("updateRequestFromFileSpec: %v", err) + } + if req.Description != "d" || req.ImageURL != "img:2" || req.Command[0] != "c" || req.Args[0] != "a" || + req.EnvironmentVariables["K"] != "V" || req.AllowedCidrs[0] != "10.0.0.0/8" || + req.Protocol != "MCP" || req.FlavorID != "f2" || req.Autoscaling.MaxReplicas != 4 || + req.ImageAuth == nil || req.ImageAuth.Enabled { + t.Errorf("spec fields not mapped to the update request: %+v", req) + } + if req.InboundAuth == nil || req.InboundAuth.Mode != "IAM" { + t.Errorf("PUBLIC spec with no inbound block should default to IAM: %+v", req.InboundAuth) + } + }) +}