From 8e0fb0348315a80f63cb47b3c7204869482448f4 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 14 Jan 2026 22:55:11 +0000 Subject: [PATCH] feat(prebuilt/cloud-sql): Add create backup tool for Cloud SQL (#2141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This pull request adds a new tool, cloud-sql-create-backup, which enables taking a backup on a Cloud SQL instance from the toolbox using the Cloud SQL Admin API. The tool supports optionally supplying a location or description for the backup. Tested: image ## 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) - [x] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/genai-toolbox/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 #2140 --------- Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com> Co-authored-by: Averi Kitsch --- cmd/root.go | 1 + cmd/root_test.go | 6 +- .../connect-ide/cloud_sql_mssql_admin_mcp.md | 2 + .../connect-ide/cloud_sql_mysql_admin_mcp.md | 2 + .../connect-ide/cloud_sql_pg_admin_mcp.md | 2 + docs/en/reference/prebuilt-tools.md | 6 + .../tools/cloudsql/cloudsqlcreatebackup.md | 45 ++++ .../tools/cloud-sql-mssql-admin.yaml | 4 + .../tools/cloud-sql-mysql-admin.yaml | 4 + .../tools/cloud-sql-postgres-admin.yaml | 4 + .../sources/cloudsqladmin/cloud_sql_admin.go | 22 ++ .../cloudsqlcreatebackup.go | 180 ++++++++++++++ .../cloudsqlcreatebackup_test.go | 72 ++++++ .../cloudsql/cloud_sql_create_backup_test.go | 232 ++++++++++++++++++ 14 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 docs/en/resources/tools/cloudsql/cloudsqlcreatebackup.md create mode 100644 internal/tools/cloudsql/cloudsqlcreatebackup/cloudsqlcreatebackup.go create mode 100644 internal/tools/cloudsql/cloudsqlcreatebackup/cloudsqlcreatebackup_test.go create mode 100644 tests/cloudsql/cloud_sql_create_backup_test.go diff --git a/cmd/root.go b/cmd/root.go index a9b41989ac4..5b70bbd2940 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -92,6 +92,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudmonitoring" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcloneinstance" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcreatebackup" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcreatedatabase" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcreateusers" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlgetinstances" diff --git a/cmd/root_test.go b/cmd/root_test.go index 590eb4bd28f..55d28239204 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1493,7 +1493,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "cloud_sql_postgres_admin_tools": tools.ToolsetConfig{ Name: "cloud_sql_postgres_admin_tools", - ToolNames: []string{"create_instance", "get_instance", "list_instances", "create_database", "list_databases", "create_user", "wait_for_operation", "postgres_upgrade_precheck", "clone_instance"}, + ToolNames: []string{"create_instance", "get_instance", "list_instances", "create_database", "list_databases", "create_user", "wait_for_operation", "postgres_upgrade_precheck", "clone_instance", "create_backup"}, }, }, }, @@ -1503,7 +1503,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "cloud_sql_mysql_admin_tools": tools.ToolsetConfig{ Name: "cloud_sql_mysql_admin_tools", - ToolNames: []string{"create_instance", "get_instance", "list_instances", "create_database", "list_databases", "create_user", "wait_for_operation", "clone_instance"}, + ToolNames: []string{"create_instance", "get_instance", "list_instances", "create_database", "list_databases", "create_user", "wait_for_operation", "clone_instance", "create_backup"}, }, }, }, @@ -1513,7 +1513,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "cloud_sql_mssql_admin_tools": tools.ToolsetConfig{ Name: "cloud_sql_mssql_admin_tools", - ToolNames: []string{"create_instance", "get_instance", "list_instances", "create_database", "list_databases", "create_user", "wait_for_operation", "clone_instance"}, + ToolNames: []string{"create_instance", "get_instance", "list_instances", "create_database", "list_databases", "create_user", "wait_for_operation", "clone_instance", "create_backup"}, }, }, }, diff --git a/docs/en/how-to/connect-ide/cloud_sql_mssql_admin_mcp.md b/docs/en/how-to/connect-ide/cloud_sql_mssql_admin_mcp.md index 03abf8422eb..c88e6edfc08 100644 --- a/docs/en/how-to/connect-ide/cloud_sql_mssql_admin_mcp.md +++ b/docs/en/how-to/connect-ide/cloud_sql_mssql_admin_mcp.md @@ -48,6 +48,7 @@ instance, database and users: * `roles/cloudsql.editor`: Provides permissions to manage existing resources. * All `viewer` tools * `create_database` + * `create_backup` * `roles/cloudsql.admin`: Provides full control over all resources. * All `editor` and `viewer` tools * `create_instance` @@ -299,6 +300,7 @@ instances and interacting with your database: * **create_user**: Creates a new user in a Cloud SQL instance. * **wait_for_operation**: Waits for a Cloud SQL operation to complete. * **clone_instance**: Creates a clone of an existing Cloud SQL for SQL Server instance. +* **create_backup**: Creates a backup on a Cloud SQL instance. {{< notice note >}} Prebuilt tools are pre-1.0, so expect some tool changes between versions. LLMs diff --git a/docs/en/how-to/connect-ide/cloud_sql_mysql_admin_mcp.md b/docs/en/how-to/connect-ide/cloud_sql_mysql_admin_mcp.md index 85eb0412134..d68b7562cbd 100644 --- a/docs/en/how-to/connect-ide/cloud_sql_mysql_admin_mcp.md +++ b/docs/en/how-to/connect-ide/cloud_sql_mysql_admin_mcp.md @@ -48,6 +48,7 @@ database and users: * `roles/cloudsql.editor`: Provides permissions to manage existing resources. * All `viewer` tools * `create_database` + * `create_backup` * `roles/cloudsql.admin`: Provides full control over all resources. * All `editor` and `viewer` tools * `create_instance` @@ -299,6 +300,7 @@ instances and interacting with your database: * **create_user**: Creates a new user in a Cloud SQL instance. * **wait_for_operation**: Waits for a Cloud SQL operation to complete. * **clone_instance**: Creates a clone of an existing Cloud SQL for MySQL instance. +* **create_backup**: Creates a backup on a Cloud SQL instance. {{< notice note >}} Prebuilt tools are pre-1.0, so expect some tool changes between versions. LLMs diff --git a/docs/en/how-to/connect-ide/cloud_sql_pg_admin_mcp.md b/docs/en/how-to/connect-ide/cloud_sql_pg_admin_mcp.md index fabeb0d8beb..035daa4a1ab 100644 --- a/docs/en/how-to/connect-ide/cloud_sql_pg_admin_mcp.md +++ b/docs/en/how-to/connect-ide/cloud_sql_pg_admin_mcp.md @@ -48,6 +48,7 @@ instance, database and users: * `roles/cloudsql.editor`: Provides permissions to manage existing resources. * All `viewer` tools * `create_database` + * `create_backup` * `roles/cloudsql.admin`: Provides full control over all resources. * All `editor` and `viewer` tools * `create_instance` @@ -299,6 +300,7 @@ instances and interacting with your database: * **create_user**: Creates a new user in a Cloud SQL instance. * **wait_for_operation**: Waits for a Cloud SQL operation to complete. * **clone_instance**: Creates a clone of an existing Cloud SQL for PostgreSQL instance. +* **create_backup**: Creates a backup on a Cloud SQL instance. {{< notice note >}} Prebuilt tools are pre-1.0, so expect some tool changes between versions. LLMs diff --git a/docs/en/reference/prebuilt-tools.md b/docs/en/reference/prebuilt-tools.md index 061f27b0ff1..c13ebceb3bb 100644 --- a/docs/en/reference/prebuilt-tools.md +++ b/docs/en/reference/prebuilt-tools.md @@ -187,6 +187,7 @@ See [Usage Examples](../reference/cli.md#examples). manage existing resources. * All `viewer` tools * `create_database` + * `create_backup` * **Cloud SQL Admin** (`roles/cloudsql.admin`): Provides full control over all resources. * All `editor` and `viewer` tools @@ -203,6 +204,7 @@ See [Usage Examples](../reference/cli.md#examples). * `create_user`: Creates a new user in a Cloud SQL instance. * `wait_for_operation`: Waits for a Cloud SQL operation to complete. * `clone_instance`: Creates a clone for an existing Cloud SQL for MySQL instance. + * `create_backup`: Creates a backup on a Cloud SQL instance. ## Cloud SQL for PostgreSQL @@ -275,6 +277,7 @@ See [Usage Examples](../reference/cli.md#examples). manage existing resources. * All `viewer` tools * `create_database` + * `create_backup` * **Cloud SQL Admin** (`roles/cloudsql.admin`): Provides full control over all resources. * All `editor` and `viewer` tools @@ -290,6 +293,7 @@ See [Usage Examples](../reference/cli.md#examples). * `create_user`: Creates a new user in a Cloud SQL instance. * `wait_for_operation`: Waits for a Cloud SQL operation to complete. * `clone_instance`: Creates a clone for an existing Cloud SQL for PostgreSQL instance. + * `create_backup`: Creates a backup on a Cloud SQL instance. ## Cloud SQL for SQL Server @@ -336,6 +340,7 @@ See [Usage Examples](../reference/cli.md#examples). manage existing resources. * All `viewer` tools * `create_database` + * `create_backup` * **Cloud SQL Admin** (`roles/cloudsql.admin`): Provides full control over all resources. * All `editor` and `viewer` tools @@ -351,6 +356,7 @@ See [Usage Examples](../reference/cli.md#examples). * `create_user`: Creates a new user in a Cloud SQL instance. * `wait_for_operation`: Waits for a Cloud SQL operation to complete. * `clone_instance`: Creates a clone for an existing Cloud SQL for SQL Server instance. + * `create_backup`: Creates a backup on a Cloud SQL instance. ## Dataplex diff --git a/docs/en/resources/tools/cloudsql/cloudsqlcreatebackup.md b/docs/en/resources/tools/cloudsql/cloudsqlcreatebackup.md new file mode 100644 index 00000000000..751534a0ba3 --- /dev/null +++ b/docs/en/resources/tools/cloudsql/cloudsqlcreatebackup.md @@ -0,0 +1,45 @@ +--- +title: cloud-sql-create-backup +type: docs +weight: 10 +description: "Creates a backup on a Cloud SQL instance." +--- + +The `cloud-sql-create-backup` tool creates an on-demand backup on a Cloud SQL instance using the Cloud SQL Admin API. + +{{< notice info dd>}} +This tool uses a `source` of kind `cloud-sql-admin`. +{{< /notice >}} + +## Examples + +Basic backup creation (current state) + +```yaml +tools: + backup-creation-basic: + kind: cloud-sql-create-backup + source: cloud-sql-admin-source + description: "Creates a backup on the given Cloud SQL instance." +``` +## Reference +### Tool Configuration +| **field** | **type** | **required** | **description** | +| -------------- | :------: | :----------: | ------------------------------------------------------------- | +| kind | string | true | Must be "cloud-sql-create-backup". | +| source | string | true | The name of the `cloud-sql-admin` source to use. | +| description | string | false | A description of the tool. | + +### Tool Inputs + +| **parameter** | **type** | **required** | **description** | +| -------------------------- | :------: | :----------: | ------------------------------------------------------------------------------- | +| project | string | true | The project ID. | +| instance | string | true | The name of the instance to take a backup on. Does not include the project ID. | +| location | string | false | (Optional) Location of the backup run. | +| backup_description | string | false | (Optional) The description of this backup run. | + +## See Also +- [Cloud SQL Admin API documentation](https://cloud.google.com/sql/docs/mysql/admin-api) +- [Toolbox Cloud SQL tools documentation](../cloudsql) +- [Cloud SQL Backup API documentation](https://cloud.google.com/sql/docs/mysql/backup-recovery/backups) \ No newline at end of file diff --git a/internal/prebuiltconfigs/tools/cloud-sql-mssql-admin.yaml b/internal/prebuiltconfigs/tools/cloud-sql-mssql-admin.yaml index 7830ea45cb1..b3b1cbb1919 100644 --- a/internal/prebuiltconfigs/tools/cloud-sql-mssql-admin.yaml +++ b/internal/prebuiltconfigs/tools/cloud-sql-mssql-admin.yaml @@ -43,6 +43,9 @@ tools: clone_instance: kind: cloud-sql-clone-instance source: cloud-sql-admin-source + create_backup: + kind: cloud-sql-create-backup + source: cloud-sql-admin-source toolsets: cloud_sql_mssql_admin_tools: @@ -54,3 +57,4 @@ toolsets: - create_user - wait_for_operation - clone_instance + - create_backup diff --git a/internal/prebuiltconfigs/tools/cloud-sql-mysql-admin.yaml b/internal/prebuiltconfigs/tools/cloud-sql-mysql-admin.yaml index 145f1cbc33a..2b571371962 100644 --- a/internal/prebuiltconfigs/tools/cloud-sql-mysql-admin.yaml +++ b/internal/prebuiltconfigs/tools/cloud-sql-mysql-admin.yaml @@ -43,6 +43,9 @@ tools: clone_instance: kind: cloud-sql-clone-instance source: cloud-sql-admin-source + create_backup: + kind: cloud-sql-create-backup + source: cloud-sql-admin-source toolsets: cloud_sql_mysql_admin_tools: @@ -54,3 +57,4 @@ toolsets: - create_user - wait_for_operation - clone_instance + - create_backup diff --git a/internal/prebuiltconfigs/tools/cloud-sql-postgres-admin.yaml b/internal/prebuiltconfigs/tools/cloud-sql-postgres-admin.yaml index dffac3dc1b8..72d15d69a20 100644 --- a/internal/prebuiltconfigs/tools/cloud-sql-postgres-admin.yaml +++ b/internal/prebuiltconfigs/tools/cloud-sql-postgres-admin.yaml @@ -46,6 +46,9 @@ tools: postgres_upgrade_precheck: kind: postgres-upgrade-precheck source: cloud-sql-admin-source + create_backup: + kind: cloud-sql-create-backup + source: cloud-sql-admin-source toolsets: cloud_sql_postgres_admin_tools: @@ -58,3 +61,4 @@ toolsets: - wait_for_operation - postgres_upgrade_precheck - clone_instance + - create_backup diff --git a/internal/sources/cloudsqladmin/cloud_sql_admin.go b/internal/sources/cloudsqladmin/cloud_sql_admin.go index 7d8929b7822..5da165cbfa8 100644 --- a/internal/sources/cloudsqladmin/cloud_sql_admin.go +++ b/internal/sources/cloudsqladmin/cloud_sql_admin.go @@ -352,6 +352,28 @@ func (s *Source) GetWaitForOperations(ctx context.Context, service *sqladmin.Ser return nil, nil } +func (s *Source) InsertBackupRun(ctx context.Context, project, instance, location, backupDescription, accessToken string) (any, error) { + backupRun := &sqladmin.BackupRun{} + if location != "" { + backupRun.Location = location + } + if backupDescription != "" { + backupRun.Description = backupDescription + } + + service, err := s.GetService(ctx, string(accessToken)) + if err != nil { + return nil, err + } + + resp, err := service.BackupRuns.Insert(project, instance, backupRun).Do() + if err != nil { + return nil, fmt.Errorf("error creating backup: %w", err) + } + + return resp, nil +} + func generateCloudSQLConnectionMessage(ctx context.Context, source *Source, logger log.Logger, opResponse map[string]any, connectionMessageTemplate string) (string, bool) { operationType, ok := opResponse["operationType"].(string) if !ok || operationType != "CREATE_DATABASE" { diff --git a/internal/tools/cloudsql/cloudsqlcreatebackup/cloudsqlcreatebackup.go b/internal/tools/cloudsql/cloudsqlcreatebackup/cloudsqlcreatebackup.go new file mode 100644 index 00000000000..2ad723f0d5a --- /dev/null +++ b/internal/tools/cloudsql/cloudsqlcreatebackup/cloudsqlcreatebackup.go @@ -0,0 +1,180 @@ +// Copyright 2026 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 cloudsqlcreatebackup + +import ( + "context" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/embeddingmodels" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/util/parameters" + "google.golang.org/api/sqladmin/v1" +) + +const kind string = "cloud-sql-create-backup" + +var _ tools.ToolConfig = Config{} + +type compatibleSource interface { + GetDefaultProject() string + GetService(context.Context, string) (*sqladmin.Service, error) + UseClientAuthorization() bool + InsertBackupRun(ctx context.Context, project, instance, location, backupDescription, accessToken string) (any, error) +} + +// Config defines the configuration for the create-backup tool. +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Description string `yaml:"description"` + Source string `yaml:"source" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +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 +} + +// 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("no source named %q configured", cfg.Source) + } + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source %q not compatible", kind, cfg.Source) + } + + project := s.GetDefaultProject() + var projectParam parameters.Parameter + if project != "" { + projectParam = parameters.NewStringParameterWithDefault("project", project, "The GCP project ID. This is pre-configured; do not ask for it unless the user explicitly provides a different one.") + } else { + projectParam = parameters.NewStringParameter("project", "The project ID") + } + + allParameters := parameters.Parameters{ + projectParam, + parameters.NewStringParameter("instance", "Cloud SQL instance ID. This does not include the project ID."), + // Location and backup_description are optional. + parameters.NewStringParameterWithRequired("location", "Location of the backup run.", false), + parameters.NewStringParameterWithRequired("backup_description", "The description of this backup run.", false), + } + paramManifest := allParameters.Manifest() + + description := cfg.Description + if description == "" { + description = "Creates a backup on a Cloud SQL instance." + } + + mcpManifest := tools.GetMcpManifest(cfg.Name, description, cfg.AuthRequired, allParameters, nil) + + return Tool{ + Config: cfg, + AllParams: allParameters, + manifest: tools.Manifest{Description: description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + }, nil +} + +// Tool represents the create-backup tool. +type Tool struct { + Config + AllParams parameters.Parameters `yaml:"allParams"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) ToConfig() tools.ToolConfig { + return t.Config +} + +func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Kind) + if err != nil { + return nil, err + } + paramsMap := params.AsMap() + + project, ok := paramsMap["project"].(string) + if !ok { + return nil, fmt.Errorf("error casting 'project' parameter: %v", paramsMap["project"]) + } + instance, ok := paramsMap["instance"].(string) + if !ok { + return nil, fmt.Errorf("error casting 'instance' parameter: %v", paramsMap["instance"]) + } + + location, _ := paramsMap["location"].(string) + description, _ := paramsMap["backup_description"].(string) + + return source.InsertBackupRun(ctx, project, instance, location, description, string(accessToken)) +} + +// ParseParams parses the parameters for the tool. +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (parameters.ParamValues, error) { + return parameters.ParseParams(t.AllParams, data, claims) +} + +func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return parameters.EmbedParams(ctx, t.AllParams, paramValues, embeddingModelsMap, nil) +} + +// 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(resourceMgr tools.SourceProvider) (bool, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Kind) + if err != nil { + return false, err + } + + return source.UseClientAuthorization(), nil +} + +func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) { + return "Authorization", nil +} diff --git a/internal/tools/cloudsql/cloudsqlcreatebackup/cloudsqlcreatebackup_test.go b/internal/tools/cloudsql/cloudsqlcreatebackup/cloudsqlcreatebackup_test.go new file mode 100644 index 00000000000..f3f46a470df --- /dev/null +++ b/internal/tools/cloudsql/cloudsqlcreatebackup/cloudsqlcreatebackup_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 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 cloudsqlcreatebackup_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" + "github.com/googleapis/genai-toolbox/internal/tools/cloudsql/cloudsqlcreatebackup" +) + +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-backup-tool: + kind: cloud-sql-create-backup + description: a test description + source: a-source + `, + want: server.ToolConfigs{ + "create-backup-tool": cloudsqlcreatebackup.Config{ + Name: "create-backup-tool", + Kind: "cloud-sql-create-backup", + Description: "a test description", + Source: "a-source", + AuthRequired: []string{}, + }, + }, + }, + } + 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) + } + }) + } +} diff --git a/tests/cloudsql/cloud_sql_create_backup_test.go b/tests/cloudsql/cloud_sql_create_backup_test.go new file mode 100644 index 00000000000..d9b7d052649 --- /dev/null +++ b/tests/cloudsql/cloud_sql_create_backup_test.go @@ -0,0 +1,232 @@ +// Copyright 2026 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 cloudsql + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "regexp" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/tests" + "google.golang.org/api/sqladmin/v1" +) + +var ( + createBackupToolKind = "cloud-sql-create-backup" +) + +type createBackupTransport struct { + transport http.RoundTripper + url *url.URL +} + +func (t *createBackupTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.HasPrefix(req.URL.String(), "https://sqladmin.googleapis.com") { + req.URL.Scheme = t.url.Scheme + req.URL.Host = t.url.Host + } + return t.transport.RoundTrip(req) +} + +type mastercreateBackupHandler struct { + t *testing.T +} + +func (h *mastercreateBackupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.UserAgent(), "genai-toolbox/") { + h.t.Errorf("User-Agent header not found") + } + var backupRun sqladmin.BackupRun + if err := json.NewDecoder(r.Body).Decode(&backupRun); err != nil { + h.t.Fatalf("failed to decode request body: %v", err) + } else { + h.t.Logf("Received request body: %+v", backupRun) + } + + var expectedBackupRun sqladmin.BackupRun + var response any + var statusCode int + + switch backupRun.Description { + case "": + expectedBackupRun = sqladmin.BackupRun{} + response = map[string]any{"name": "op1", "status": "PENDING"} + statusCode = http.StatusOK + case "test desc": + expectedBackupRun = sqladmin.BackupRun{Location: "us-central1", Description: "test desc"} + response = map[string]any{"name": "op1", "status": "PENDING"} + statusCode = http.StatusOK + default: + http.Error(w, fmt.Sprintf("unhandled instance name: %s", backupRun.Instance), http.StatusInternalServerError) + return + } + + if diff := cmp.Diff(expectedBackupRun, backupRun); diff != "" { + h.t.Errorf("unexpected request body (-want +got):\n%s", diff) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func TestCreateBackupToolEndpoints(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + handler := &mastercreateBackupHandler{t: t} + server := httptest.NewServer(handler) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse server URL: %v", err) + } + + originalTransport := http.DefaultClient.Transport + if originalTransport == nil { + originalTransport = http.DefaultTransport + } + http.DefaultClient.Transport = &createBackupTransport{ + transport: originalTransport, + url: serverURL, + } + t.Cleanup(func() { + http.DefaultClient.Transport = originalTransport + }) + + var args []string + toolsFile := getCreateBackupToolsConfig() + cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...) + if err != nil { + t.Fatalf("command initialization returned an error: %s", err) + } + defer cleanup() + + waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + 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 + toolName string + body string + want string + expectError bool + errorStatus int + }{ + { + name: "successful backup creation with no optional parameters", + toolName: "create-backup", + body: `{"project": "p1", "instance": "instance-no-optional"}`, + want: `{"name":"op1","status":"PENDING"}`, + }, + { + name: "successful backup creation with optional parameters", + toolName: "create-backup", + body: `{"project": "p1", "instance": "instance-optional", "location": "us-central1", "backup_description": "test desc"}`, + want: `{"name":"op1","status":"PENDING"}`, + }, + { + name: "missing instance name", + toolName: "create-backup", + body: `{"project": "p1", "escription": "invalid"}`, + expectError: true, + errorStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + api := fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/invoke", tc.toolName) + 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() + + if tc.expectError { + if resp.StatusCode != tc.errorStatus { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected status %d but got %d: %s", tc.errorStatus, resp.StatusCode, string(bodyBytes)) + } + return + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + 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.NewDecoder(resp.Body).Decode(&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 !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected result: got %+v, want %+v", got, want) + } + }) + } +} + +func getCreateBackupToolsConfig() map[string]any { + return map[string]any{ + "sources": map[string]any{ + "my-cloud-sql-source": map[string]any{ + "kind": "cloud-sql-admin", + }, + }, + "tools": map[string]any{ + "create-backup": map[string]any{ + "kind": createBackupToolKind, + "source": "my-cloud-sql-source", + }, + }, + } +}