mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-01-11 16:38:15 -05:00
feat(tools/alloydb-create-cluster): Add custom tool kind for AlloyDB create cluster (#1331)
## Description
---
This pull request introduces a new custom tool kind
`alloydb-create-cluster` that creates a new AlloyDB cluster.
### Example Configuration
```yaml
tools:
create_cluster:
kind: alloydb-create-cluster
source: my-alloydb-admin-source
description: Use this tool to create a new AlloyDB cluster in a given project and location.
```
### Example Request
```
curl -X POST http://127.0.0.1:5000/api/tool/create_cluster/invoke \
-H "Content-Type: application/json" \
-d '{
"project": "example-project",
"cluster": "my-alloydb-cluster",
"password": "my-password",
"location": "us-central1",
"network": "default",
"user": "postgres"
}'
```
## PR Checklist
---
> Thank you for opening a Pull Request! Before submitting your PR, there
are a
> few things you can do to make sure it goes smoothly:
- [x] Make sure you reviewed
[CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md)
- [ ] Make sure to open an issue as a
[bug/issue](https://github.com/googleapis/langchain-google-alloydb-pg-python/issues/new/choose)
before writing your code! That way we can discuss the change, evaluate
designs, and agree on the general idea
- [x] Ensure the tests and linter pass
- [x] Code coverage does not decrease (if any source code was changed)
- [x] Appropriate docs were updated (if necessary)
- [x] Make sure to add `!` if this involve a breaking change
🛠️ Fixes #<issue_number_goes_here>
This commit is contained in:
@@ -42,6 +42,7 @@ import (
|
||||
"github.com/googleapis/genai-toolbox/internal/util"
|
||||
|
||||
// Import tool packages for side effect of registration
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/alloydb/alloydbcreatecluster"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/alloydb/alloydbcreateuser"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/alloydb/alloydbgetcluster"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/alloydb/alloydbgetinstance"
|
||||
|
||||
47
docs/en/resources/tools/alloydb/alloydb-create-cluster.md
Normal file
47
docs/en/resources/tools/alloydb/alloydb-create-cluster.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "alloydb-create-cluster"
|
||||
type: docs
|
||||
weight: 1
|
||||
description: >
|
||||
The "alloydb-create-cluster" tool creates a new AlloyDB for PostgreSQL cluster in a specified project and location.
|
||||
aliases:
|
||||
- /resources/tools/alloydb-create-cluster
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
The `alloydb-create-cluster` tool creates a new AlloyDB for PostgreSQL cluster in a specified project and location. It is compatible with [alloydb-admin](../../sources/alloydb-admin.md) source.
|
||||
This tool provisions a cluster with a **private IP address** within the specified VPC network.
|
||||
|
||||
**Permissions & APIs Required:**
|
||||
Before using, ensure the following on your GCP project:
|
||||
1. The [AlloyDB API](https://console.cloud.google.com/apis/library/alloydb.googleapis.com) is enabled.
|
||||
2. The user or service account executing the tool has the following IAM roles:
|
||||
- `roles/alloydb.admin`: To create and manage the AlloyDB cluster.
|
||||
|
||||
The tool takes the following input parameters:
|
||||
|
||||
| Parameter | Type | Description | Required |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `project` | string | The GCP project ID where the cluster will be created. | Yes |
|
||||
| `location` | string | The GCP location where the cluster will be created. Default: `us-central1`. If quota is exhausted then use other regions. | No |
|
||||
| `cluster` | string | A unique identifier for the new AlloyDB cluster. | Yes |
|
||||
| `password` | string | A secure password for the initial user. | Yes |
|
||||
| `network` | string | The name of the VPC network to connect the cluster to. Default: `default`. | No |
|
||||
| `user` | string | The name for the initial superuser. Default: `postgres`. | No |
|
||||
|
||||
## Example
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
create_cluster:
|
||||
kind: alloydb-create-cluster
|
||||
source: alloydb-admin-source
|
||||
description: Use this tool to create a new AlloyDB cluster in a given project and location.
|
||||
```
|
||||
## Reference
|
||||
| **field** | **type** | **required** | **description** |
|
||||
|-------------|:------------------------------------------:|:------------:|--------------------------------------------------------------------------------------------------|
|
||||
| kind | string | true | Must be alloydb-create-cluster. | |
|
||||
| source | string | true | The name of an `alloydb-admin` source. |
|
||||
| description | string | false | Description of the tool that is passed to the agent. |
|
||||
@@ -23,47 +23,9 @@ sources:
|
||||
kind: alloydb-admin
|
||||
tools:
|
||||
create_cluster:
|
||||
kind: http
|
||||
source: alloydb-api-source
|
||||
method: POST
|
||||
path: /v1/projects/{{.projectId}}/locations/{{.locationId}}/clusters
|
||||
description: "Create a new AlloyDB cluster. This is a long-running operation, but the API call returns quickly. This will return operation id to be used by get operations tool. Take all parameters from user in one go."
|
||||
pathParams:
|
||||
- name: projectId
|
||||
type: string
|
||||
description: "The dynamic path parameter for project id provided by user."
|
||||
- name: locationId
|
||||
type: string
|
||||
description: "The dynamic path parameter for location. The default value is us-central1. If quota is exhausted then use other regions."
|
||||
default: us-central1
|
||||
queryParams:
|
||||
- name: clusterId
|
||||
type: string
|
||||
description: "A unique ID for the AlloyDB cluster."
|
||||
requestBody: |
|
||||
{
|
||||
"networkConfig": {
|
||||
"network": "projects/{{.project}}/global/networks/{{.network}}"
|
||||
},
|
||||
"initialUser": {
|
||||
"password": "{{.password}}",
|
||||
"user": "{{.user}}"
|
||||
}
|
||||
}
|
||||
bodyParams:
|
||||
- name: project
|
||||
type: string
|
||||
description: "The dynamic path parameter for project id."
|
||||
- name: network
|
||||
type: string
|
||||
description: "The name of the VPC network to connect the cluster to (e.g., 'default')."
|
||||
default: default
|
||||
- name: password
|
||||
type: string
|
||||
description: "A secure password for the initial 'postgres' user or the custom user provided."
|
||||
- name: user
|
||||
type: string
|
||||
description: "The name for the initial superuser. If not provided, it defaults to 'postgres'. The initial database will always be named 'postgres'."
|
||||
kind: alloydb-create-cluster
|
||||
source: alloydb-admin-source
|
||||
description: "Create a new AlloyDB cluster. This is a long-running operation, but the API call returns quickly. This will return operation id to be used by get operations tool. Take all parameters from user in one go."
|
||||
wait_for_operation:
|
||||
kind: alloydb-wait-for-operation
|
||||
description: "This will poll on operations API until the operation is done. For checking operation status we need projectId, locationID and operationId. Once instance is created give follow up steps on how to use the variables to bring data plane MCP server up in local and remote setup."
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package alloydbcreatecluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/googleapis/genai-toolbox/internal/sources"
|
||||
alloydbadmin "github.com/googleapis/genai-toolbox/internal/sources/alloydbadmin"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools"
|
||||
"google.golang.org/api/alloydb/v1"
|
||||
)
|
||||
|
||||
const kind string = "alloydb-create-cluster"
|
||||
|
||||
func init() {
|
||||
if !tools.Register(kind, newConfig) {
|
||||
panic(fmt.Sprintf("tool kind %q already registered", kind))
|
||||
}
|
||||
}
|
||||
|
||||
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
|
||||
actual := Config{Name: name}
|
||||
if err := decoder.DecodeContext(ctx, &actual); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return actual, nil
|
||||
}
|
||||
|
||||
// Configuration for the create-cluster tool.
|
||||
type Config struct {
|
||||
Name string `yaml:"name" validate:"required"`
|
||||
Kind string `yaml:"kind" validate:"required"`
|
||||
Source string `yaml:"source" validate:"required"`
|
||||
Description string `yaml:"description"`
|
||||
AuthRequired []string `yaml:"authRequired"`
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ tools.ToolConfig = Config{}
|
||||
|
||||
// ToolConfigKind returns the kind of the tool.
|
||||
func (cfg Config) ToolConfigKind() string {
|
||||
return kind
|
||||
}
|
||||
|
||||
// Initialize initializes the tool from the configuration.
|
||||
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
|
||||
rawS, ok := srcs[cfg.Source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source %q not found", cfg.Source)
|
||||
}
|
||||
|
||||
s, ok := rawS.(*alloydbadmin.Source)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid source for %q tool: source kind must be `alloydb-admin`", kind)
|
||||
}
|
||||
|
||||
allParameters := tools.Parameters{
|
||||
tools.NewStringParameter("project", "The GCP project ID."),
|
||||
tools.NewStringParameterWithDefault("location", "us-central1", "The location to create the cluster in. The default value is us-central1. If quota is exhausted then use other regions."),
|
||||
tools.NewStringParameter("cluster", "A unique ID for the AlloyDB cluster."),
|
||||
tools.NewStringParameter("password", "A secure password for the initial user."),
|
||||
tools.NewStringParameterWithDefault("network", "default", "The name of the VPC network to connect the cluster to (e.g., 'default')."),
|
||||
tools.NewStringParameterWithDefault("user", "postgres", "The name for the initial superuser. Defaults to 'postgres' if not provided."),
|
||||
}
|
||||
paramManifest := allParameters.Manifest()
|
||||
|
||||
inputSchema := allParameters.McpManifest()
|
||||
inputSchema.Required = []string{"project", "cluster", "password"}
|
||||
|
||||
description := cfg.Description
|
||||
if description == "" {
|
||||
description = "Creates a new AlloyDB cluster. This is a long-running operation, but the API call returns quickly. This will return operation id to be used by get operations tool."
|
||||
}
|
||||
|
||||
mcpManifest := tools.McpManifest{
|
||||
Name: cfg.Name,
|
||||
Description: description,
|
||||
InputSchema: inputSchema,
|
||||
}
|
||||
|
||||
return Tool{
|
||||
Name: cfg.Name,
|
||||
Kind: kind,
|
||||
Source: s,
|
||||
AllParams: allParameters,
|
||||
manifest: tools.Manifest{Description: description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired},
|
||||
mcpManifest: mcpManifest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Tool represents the create-cluster tool.
|
||||
type Tool struct {
|
||||
Name string `yaml:"name"`
|
||||
Kind string `yaml:"kind"`
|
||||
Description string `yaml:"description"`
|
||||
|
||||
Source *alloydbadmin.Source
|
||||
AllParams tools.Parameters `yaml:"allParams"`
|
||||
|
||||
manifest tools.Manifest
|
||||
mcpManifest tools.McpManifest
|
||||
}
|
||||
|
||||
// Invoke executes the tool's logic.
|
||||
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
|
||||
paramsMap := params.AsMap()
|
||||
project, ok := paramsMap["project"].(string)
|
||||
if !ok || project == "" {
|
||||
return nil, fmt.Errorf("invalid or missing 'project' parameter; expected a non-empty string")
|
||||
}
|
||||
|
||||
location, ok := paramsMap["location"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid 'location' parameter; expected a string")
|
||||
}
|
||||
|
||||
clusterID, ok := paramsMap["cluster"].(string)
|
||||
if !ok || clusterID == "" {
|
||||
return nil, fmt.Errorf("invalid or missing 'cluster' parameter; expected a non-empty string")
|
||||
}
|
||||
|
||||
password, ok := paramsMap["password"].(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("invalid or missing 'password' parameter; expected a non-empty string")
|
||||
}
|
||||
|
||||
network, ok := paramsMap["network"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid 'network' parameter; expected a string")
|
||||
}
|
||||
|
||||
user, ok := paramsMap["user"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid 'user' parameter; expected a string")
|
||||
}
|
||||
|
||||
service, err := t.Source.GetService(ctx, string(accessToken))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urlString := fmt.Sprintf("projects/%s/locations/%s", project, location)
|
||||
|
||||
// Build the request body using the type-safe Cluster struct.
|
||||
clusterBody := &alloydb.Cluster{
|
||||
NetworkConfig: &alloydb.NetworkConfig{
|
||||
Network: fmt.Sprintf("projects/%s/global/networks/%s", project, network),
|
||||
},
|
||||
InitialUser: &alloydb.UserPassword{
|
||||
User: user,
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
|
||||
// The Create API returns a long-running operation.
|
||||
resp, err := service.Projects.Locations.Clusters.Create(urlString, clusterBody).ClusterId(clusterID).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating AlloyDB cluster: %w", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ParseParams parses the parameters for the tool.
|
||||
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
|
||||
return tools.ParseParams(t.AllParams, data, claims)
|
||||
}
|
||||
|
||||
// Manifest returns the tool's manifest.
|
||||
func (t Tool) Manifest() tools.Manifest {
|
||||
return t.manifest
|
||||
}
|
||||
|
||||
// McpManifest returns the tool's MCP manifest.
|
||||
func (t Tool) McpManifest() tools.McpManifest {
|
||||
return t.mcpManifest
|
||||
}
|
||||
|
||||
// Authorized checks if the tool is authorized.
|
||||
func (t Tool) Authorized(verifiedAuthServices []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t Tool) RequiresClientAuthorization() bool {
|
||||
return t.Source.UseClientAuthorization()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package alloydbcreatecluster_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/googleapis/genai-toolbox/internal/server"
|
||||
"github.com/googleapis/genai-toolbox/internal/testutils"
|
||||
alloydbcreatecluster "github.com/googleapis/genai-toolbox/internal/tools/alloydb/alloydbcreatecluster"
|
||||
)
|
||||
|
||||
func TestParseFromYaml(t *testing.T) {
|
||||
ctx, err := testutils.ContextWithNewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
tcs := []struct {
|
||||
desc string
|
||||
in string
|
||||
want server.ToolConfigs
|
||||
}{
|
||||
{
|
||||
desc: "basic example",
|
||||
in: `
|
||||
tools:
|
||||
create-my-cluster:
|
||||
kind: alloydb-create-cluster
|
||||
source: my-alloydb-admin-source
|
||||
description: some description
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"create-my-cluster": alloydbcreatecluster.Config{
|
||||
Name: "create-my-cluster",
|
||||
Kind: "alloydb-create-cluster",
|
||||
Source: "my-alloydb-admin-source",
|
||||
Description: "some description",
|
||||
AuthRequired: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "with auth required",
|
||||
in: `
|
||||
tools:
|
||||
create-my-cluster-auth:
|
||||
kind: alloydb-create-cluster
|
||||
source: my-alloydb-admin-source
|
||||
description: some description
|
||||
authRequired:
|
||||
- my-google-auth-service
|
||||
- other-auth-service
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"create-my-cluster-auth": alloydbcreatecluster.Config{
|
||||
Name: "create-my-cluster-auth",
|
||||
Kind: "alloydb-create-cluster",
|
||||
Source: "my-alloydb-admin-source",
|
||||
Description: "some description",
|
||||
AuthRequired: []string{"my-google-auth-service", "other-auth-service"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := struct {
|
||||
Tools server.ToolConfigs `yaml:"tools"`
|
||||
}{}
|
||||
// Parse contents
|
||||
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to unmarshal: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
|
||||
t.Fatalf("incorrect parse: diff %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
AlloyDBCreateUserToolKind = "alloydb-create-user"
|
||||
AlloyDBProject = os.Getenv("ALLOYDB_PROJECT")
|
||||
AlloyDBLocation = os.Getenv("ALLOYDB_REGION")
|
||||
AlloyDBCluster = os.Getenv("ALLOYDB_CLUSTER")
|
||||
AlloyDBInstance = os.Getenv("ALLOYDB_INSTANCE")
|
||||
AlloyDBUser = os.Getenv("ALLOYDB_POSTGRES_USER")
|
||||
AlloyDBCreateClusterToolKind = "alloydb-create-cluster"
|
||||
AlloyDBCreateUserToolKind = "alloydb-create-user"
|
||||
AlloyDBProject = os.Getenv("ALLOYDB_PROJECT")
|
||||
AlloyDBLocation = os.Getenv("ALLOYDB_REGION")
|
||||
AlloyDBCluster = os.Getenv("ALLOYDB_CLUSTER")
|
||||
AlloyDBInstance = os.Getenv("ALLOYDB_INSTANCE")
|
||||
AlloyDBUser = os.Getenv("ALLOYDB_POSTGRES_USER")
|
||||
)
|
||||
|
||||
func getAlloyDBVars(t *testing.T) map[string]string {
|
||||
@@ -67,7 +68,7 @@ func getAlloyDBVars(t *testing.T) map[string]string {
|
||||
"location": AlloyDBLocation,
|
||||
"cluster": AlloyDBCluster,
|
||||
"instance": AlloyDBInstance,
|
||||
"user": AlloyDBUser,
|
||||
"user": AlloyDBUser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +76,10 @@ func getAlloyDBToolsConfig() map[string]any {
|
||||
return map[string]any{
|
||||
"sources": map[string]any{
|
||||
"alloydb-admin-source": map[string]any{
|
||||
"kind": "alloydb-admin",
|
||||
"kind": "alloydb-admin",
|
||||
},
|
||||
},
|
||||
"tools" : map[string]any{
|
||||
"tools": map[string]any{
|
||||
// Tool for RunAlloyDBToolGetTest
|
||||
"my-simple-tool": map[string]any{
|
||||
"kind": "alloydb-list-clusters",
|
||||
@@ -224,10 +225,10 @@ func runAlloyDBMCPToolCallMethod(t *testing.T, vars map[string]string) {
|
||||
}
|
||||
|
||||
invokeTcs := []struct {
|
||||
name string
|
||||
requestBody jsonrpc.JSONRPCRequest
|
||||
wantContains string
|
||||
isErr bool
|
||||
name string
|
||||
requestBody jsonrpc.JSONRPCRequest
|
||||
wantContains string
|
||||
isErr bool
|
||||
}{
|
||||
{
|
||||
name: "MCP Invoke my-param-tool",
|
||||
@@ -273,8 +274,8 @@ func runAlloyDBMCPToolCallMethod(t *testing.T, vars map[string]string) {
|
||||
"arguments": map[string]any{},
|
||||
},
|
||||
},
|
||||
wantContains: `tool with name \"non-existent-tool\" does not exist`,
|
||||
isErr: true,
|
||||
wantContains: `tool with name \"non-existent-tool\" does not exist`,
|
||||
isErr: true,
|
||||
},
|
||||
{
|
||||
name: "MCP Invoke tool without required parameters",
|
||||
@@ -301,8 +302,8 @@ func runAlloyDBMCPToolCallMethod(t *testing.T, vars map[string]string) {
|
||||
"arguments": map[string]any{},
|
||||
},
|
||||
},
|
||||
wantContains: `tool with name \"my-auth-required-tool\" does not exist`,
|
||||
isErr: true,
|
||||
wantContains: `tool with name \"my-auth-required-tool\" does not exist`,
|
||||
isErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -434,26 +435,26 @@ func runAlloyDBListClustersTest(t *testing.T, vars map[string]string) {
|
||||
|
||||
if tc.wantStatusCode == http.StatusOK {
|
||||
var body ToolResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("error parsing outer response body: %v", err)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("error parsing outer response body: %v", err)
|
||||
}
|
||||
|
||||
var clustersData ListClustersResponse
|
||||
if err := json.Unmarshal([]byte(body.Result), &clustersData); err != nil {
|
||||
t.Fatalf("error parsing nested result JSON: %v", err)
|
||||
}
|
||||
var clustersData ListClustersResponse
|
||||
if err := json.Unmarshal([]byte(body.Result), &clustersData); err != nil {
|
||||
t.Fatalf("error parsing nested result JSON: %v", err)
|
||||
}
|
||||
|
||||
var got []string
|
||||
for _, cluster := range clustersData.Clusters {
|
||||
got = append(got, cluster.Name)
|
||||
}
|
||||
var got []string
|
||||
for _, cluster := range clustersData.Clusters {
|
||||
got = append(got, cluster.Name)
|
||||
}
|
||||
|
||||
sort.Strings(got)
|
||||
sort.Strings(tc.want)
|
||||
sort.Strings(got)
|
||||
sort.Strings(tc.want)
|
||||
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("cluster list mismatch:\n got: %v\nwant: %v", got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("cluster list mismatch:\n got: %v\nwant: %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -524,7 +525,6 @@ func runAlloyDBListUsersTest(t *testing.T, vars map[string]string) {
|
||||
t.Fatalf("unable to create request: %s", err)
|
||||
}
|
||||
req.Header.Add("Content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to send request: %s", err)
|
||||
@@ -1004,6 +1004,18 @@ func (h *mockAlloyDBHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var statusCode int
|
||||
|
||||
switch id {
|
||||
case "c1-success":
|
||||
response = `{
|
||||
"name": "projects/p1/locations/l1/operations/mock-operation-success",
|
||||
"metadata": {
|
||||
"verb": "create",
|
||||
"target": "projects/p1/locations/l1/clusters/c1-success"
|
||||
}
|
||||
}`
|
||||
statusCode = http.StatusOK
|
||||
case "c2-api-failure":
|
||||
response = `{"error":{"message":"internal api error"}}`
|
||||
statusCode = http.StatusInternalServerError
|
||||
case "u1-iam-success":
|
||||
response = `{
|
||||
"databaseRoles": ["alloydbiamuser", "alloydbsuperuser"],
|
||||
@@ -1057,6 +1069,116 @@ func setupTestServer(t *testing.T, idParam string) func() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlloyDBCreateCluster(t *testing.T) {
|
||||
cleanup := setupTestServer(t, "clusterId")
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var args []string
|
||||
toolsFile := getAlloyDBCreateToolsConfig()
|
||||
cmd, cleanupCmd, err := tests.StartCmd(ctx, toolsFile, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("command initialization returned an error: %v", err)
|
||||
}
|
||||
defer cleanupCmd()
|
||||
|
||||
waitCtx, cancelWait := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancelWait()
|
||||
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
|
||||
if err != nil {
|
||||
t.Logf("toolbox command logs: \n%s", out)
|
||||
t.Fatalf("toolbox didn't start successfully: %s", err)
|
||||
}
|
||||
|
||||
tcs := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
wantStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "successful creation",
|
||||
body: `{"project": "p1", "location": "l1", "cluster": "c1-success", "password": "p1"}`,
|
||||
want: `{"name":"projects/p1/locations/l1/operations/mock-operation-success", "metadata": {"verb": "create", "target": "projects/p1/locations/l1/clusters/c1-success"}}`,
|
||||
wantStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "api failure",
|
||||
body: `{"project": "p1", "location": "l1", "cluster": "c2-api-failure", "password": "p1"}`,
|
||||
want: "internal api error",
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing project",
|
||||
body: `{"location": "l1", "cluster": "c1", "password": "p1"}`,
|
||||
want: `parameter \"project\" is required`,
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing cluster",
|
||||
body: `{"project": "p1", "location": "l1", "password": "p1"}`,
|
||||
want: `parameter \"cluster\" is required`,
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing password",
|
||||
body: `{"project": "p1", "location": "l1", "cluster": "c1"}`,
|
||||
want: `parameter \"password\" is required`,
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api := "http://127.0.0.1:5000/api/tool/alloydb-create-cluster/invoke"
|
||||
req, err := http.NewRequest(http.MethodPost, api, bytes.NewBufferString(tc.body))
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create request: %s", err)
|
||||
}
|
||||
req.Header.Add("Content-type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to send request: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if tc.wantStatusCode != http.StatusOK {
|
||||
if tc.want != "" && !bytes.Contains(bodyBytes, []byte(tc.want)) {
|
||||
t.Fatalf("expected error response to contain %q, but got: %s", tc.want, string(bodyBytes))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Result string `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
var got, want map[string]any
|
||||
if err := json.Unmarshal([]byte(result.Result), &got); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
|
||||
t.Fatalf("failed to unmarshal want: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("unexpected result (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlloyDBCreateUser(t *testing.T) {
|
||||
cleanup := setupTestServer(t, "userId")
|
||||
defer cleanup()
|
||||
@@ -1214,6 +1336,11 @@ func getAlloyDBCreateToolsConfig() map[string]any {
|
||||
},
|
||||
},
|
||||
"tools": map[string]any{
|
||||
"alloydb-create-cluster": map[string]any{
|
||||
"kind": "alloydb-create-cluster",
|
||||
"description": "create cluster",
|
||||
"source": "my-alloydb-source",
|
||||
},
|
||||
"alloydb-create-user": map[string]any{
|
||||
"kind": "alloydb-create-user",
|
||||
"description": "create user",
|
||||
|
||||
Reference in New Issue
Block a user