From 252fc3091af10d25d8d7af7e047b5ac87a5dd041 Mon Sep 17 00:00:00 2001 From: Pranjul Kalsi Date: Thu, 29 Jan 2026 04:01:25 +0530 Subject: [PATCH] feat(sources/cloud-logging-admin): add source, tools, integration test and docs (#2137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR adds cloud logging admin source, tools, integration test and docs. 1. Source is implemented in a manner consistent with the BigQuery source. Supports ADC, OAuth and impersonate Service Account. 2. Total of 3 tools have been implemented - `cloud-logging-admin-list-log-names` - `cloud-logging-admin-list-resource-types` - `cloud-logging-admin-query-logs` 3. docs added for resource and tools. 4. Supporting integration test is added with updated ci Note for reviewers: 1. Integration test runs on cloud, will require `LOGADMIN_PROJECT` env variable, the test creates logs in the project using the `logging` client and then verifies working of the tools using the `logadmin` client. 2. Moved `cache.go` from the BigQuery source to `sources/cache.go` due to shared utility. Regarding Tools: 1. `cloud-logging-admin-list-log-names` uses `client.Logs()` instead of `client.Entries()`, as the latter is resource heavy and the tradeoff was not being able to apply any filters, tool has an optional parameter `limit` which defaults to 200. 2. `cloud-logging-admin-list-resource-types` uses `client.ResourceDescriptors(ctx)`, aim of the tool is to enable the agent become aware of the the resources present and utilise this information in writing filters. 3. `cloud-logging-admin-query-logs` tool enables search and read logs from Google Cloud. Parameters: `filter` (optional): A text string to search for specific logs. `newestFirst` (optional): A simple true/false switch for ordering. `startTime ` (optional): The start date and time to search from (e.g., 2025-12-09T00:00:00Z). Defaults to 30 days ago if not set. `endTime` (optional): The end date and time to search up to. Defaults to "now". `verbose` (optional): If set to true, Shows all available details for each log entry else shows only the main info (timestamp, message, severity). `limit` (optional): The maximum number of log entries to return (default is 200). Looking forward to the feedback here, as `verbose` is simply implemented to save context tokens, any alternative suggestion here is also welcomed. Simple tools.yaml ``` sources: my-logging-admin: kind: cloud-logging-admin project: useClientOAuth: false tools: list_resource_types: kind: cloud-logging-admin-list-resource-types source: my-logging-admin description: List the types of resource that are indexed by Cloud Logging. list_log_names: kind: cloud-logging-admin-list-log-names source: my-logging-admin description: List log names matching a filter criteria. query_logs: kind: cloud-logging-admin-query-logs source: my-logging-admin description: query logs ``` ## PR Checklist - [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) - [ ] Make sure to add `!` if this involve a breaking change 🛠️ Fixes #1772 @anubhav756 @averikitsch Thanks for the guidance and feedback on the implementation plan. --------- Co-authored-by: Yuan Teoh Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com> --- .ci/integration.cloudbuild.yaml | 19 + cmd/root.go | 4 + .../resources/sources/cloud-logging-admin.md | 71 +++ .../cloud-logging-admin-list-log-names.md | 39 ++ ...cloud-logging-admin-list-resource-types.md | 34 ++ .../cloud-logging-admin-query-logs.md | 44 ++ go.mod | 1 + go.sum | 4 +- internal/sources/bigquery/bigquery.go | 12 +- internal/sources/{bigquery => }/cache.go | 2 +- .../cloudloggingadmin/cloud_logging_admin.go | 439 ++++++++++++++++++ .../cloud_logging_admin_test.go | 137 ++++++ .../cloudloggingadminlistlognames.go | 155 +++++++ .../cloudloggingadminlistlognames_test.go | 122 +++++ .../cloudloggingadminlistresourcetypes.go | 142 ++++++ ...cloudloggingadminlistresourcetypes_test.go | 122 +++++ .../cloudloggingadminquerylogs.go | 215 +++++++++ .../cloudloggingadminquerylogs_test.go | 122 +++++ .../cloud_logging_admin_integration_test.go | 339 ++++++++++++++ 19 files changed, 2014 insertions(+), 9 deletions(-) create mode 100644 docs/en/resources/sources/cloud-logging-admin.md create mode 100644 docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-list-log-names.md create mode 100644 docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-list-resource-types.md create mode 100644 docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-query-logs.md rename internal/sources/{bigquery => }/cache.go (99%) create mode 100644 internal/sources/cloudloggingadmin/cloud_logging_admin.go create mode 100644 internal/sources/cloudloggingadmin/cloud_logging_admin_test.go create mode 100644 internal/tools/cloudloggingadmin/cloudloggingadminlistlognames/cloudloggingadminlistlognames.go create mode 100644 internal/tools/cloudloggingadmin/cloudloggingadminlistlognames/cloudloggingadminlistlognames_test.go create mode 100644 internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes/cloudloggingadminlistresourcetypes.go create mode 100644 internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes/cloudloggingadminlistresourcetypes_test.go create mode 100644 internal/tools/cloudloggingadmin/cloudloggingadminquerylogs/cloudloggingadminquerylogs.go create mode 100644 internal/tools/cloudloggingadmin/cloudloggingadminquerylogs/cloudloggingadminquerylogs_test.go create mode 100644 tests/cloudloggingadmin/cloud_logging_admin_integration_test.go diff --git a/.ci/integration.cloudbuild.yaml b/.ci/integration.cloudbuild.yaml index 604be42499..76d0151c2b 100644 --- a/.ci/integration.cloudbuild.yaml +++ b/.ci/integration.cloudbuild.yaml @@ -295,6 +295,25 @@ steps: cloudhealthcare \ cloudhealthcare + - id: "cloud-logging-admin" + name: golang:1 + waitFor: ["compile-test-binary"] + entrypoint: /bin/bash + env: + - "GOPATH=/gopath" + - "LOGADMIN_PROJECT=$PROJECT_ID" + secretEnv: ["CLIENT_ID"] + volumes: + - name: "go" + path: "/gopath" + args: + - -c + - | + .ci/test_with_coverage.sh \ + "Cloud Logging Admin" \ + cloudloggingadmin \ + cloudloggingadmin + - id: "postgres" name: golang:1 waitFor: ["compile-test-binary"] diff --git a/cmd/root.go b/cmd/root.go index 8e685434b6..8812d59cde 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -91,6 +91,9 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicominstances" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomseries" _ "github.com/googleapis/genai-toolbox/internal/tools/cloudhealthcare/cloudhealthcaresearchdicomstudies" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudloggingadmin/cloudloggingadminlistlognames" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes" + _ "github.com/googleapis/genai-toolbox/internal/tools/cloudloggingadmin/cloudloggingadminquerylogs" _ "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" @@ -244,6 +247,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/sources/clickhouse" _ "github.com/googleapis/genai-toolbox/internal/sources/cloudgda" _ "github.com/googleapis/genai-toolbox/internal/sources/cloudhealthcare" + _ "github.com/googleapis/genai-toolbox/internal/sources/cloudloggingadmin" _ "github.com/googleapis/genai-toolbox/internal/sources/cloudmonitoring" _ "github.com/googleapis/genai-toolbox/internal/sources/cloudsqladmin" _ "github.com/googleapis/genai-toolbox/internal/sources/cloudsqlmssql" diff --git a/docs/en/resources/sources/cloud-logging-admin.md b/docs/en/resources/sources/cloud-logging-admin.md new file mode 100644 index 0000000000..199352ef6e --- /dev/null +++ b/docs/en/resources/sources/cloud-logging-admin.md @@ -0,0 +1,71 @@ +--- +title: "Cloud Logging Admin" +type: docs +weight: 1 +description: > + The Cloud Logging Admin source enables tools to interact with the Cloud Logging API, allowing for the retrieval of log names, monitored resource types, and the querying of log data. +--- + +## About + +The Cloud Logging Admin source provides a client to interact with the [Google +Cloud Logging API](https://cloud.google.com/logging/docs). This allows tools to list log names, monitored resource types, and query log entries. + +Authentication can be handled in two ways: + +1. **Application Default Credentials (ADC):** By default, the source uses ADC + to authenticate with the API. +2. **Client-side OAuth:** If `useClientOAuth` is set to `true`, the source will + expect an OAuth 2.0 access token to be provided by the client (e.g., a web + browser) for each request. + +## Available Tools + +- [`cloud-logging-admin-list-log-names`](../tools/cloudloggingadmin/cloud-logging-admin-list-log-names.md) + Lists the log names in the project. + +- [`cloud-logging-admin-list-resource-types`](../tools/cloudloggingadmin/cloud-logging-admin-list-resource-types.md) + Lists the monitored resource types. + +- [`cloud-logging-admin-query-logs`](../tools/cloudloggingadmin/cloud-logging-admin-query-logs.md) + Queries log entries. + +## Example + +Initialize a Cloud Logging Admin source that uses ADC: + +```yaml +kind: sources +name: my-cloud-logging +type: cloud-logging-admin +project: my-project-id +``` + +Initialize a Cloud Logging Admin source that uses client-side OAuth: + +```yaml +kind: sources +name: my-oauth-cloud-logging +type: cloud-logging-admin +project: my-project-id +useClientOAuth: true +``` + +Initialize a Cloud Logging Admin source that uses service account impersonation: + +```yaml +kind: sources +name: my-impersonated-cloud-logging +type: cloud-logging-admin +project: my-project-id +impersonateServiceAccount: "my-service-account@my-project.iam.gserviceaccount.com" +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-----------------------------|:--------:|:------------:|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| type | string | true | Must be "cloud-logging-admin". | +| project | string | true | ID of the GCP project. | +| useClientOAuth | boolean | false | If true, the source will use client-side OAuth for authorization. Otherwise, it will use Application Default Credentials. Defaults to `false`. Cannot be used with `impersonateServiceAccount`. | +| impersonateServiceAccount | string | false | The service account to impersonate for API calls. Cannot be used with `useClientOAuth`. | diff --git a/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-list-log-names.md b/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-list-log-names.md new file mode 100644 index 0000000000..17e30d050d --- /dev/null +++ b/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-list-log-names.md @@ -0,0 +1,39 @@ +--- +title: "cloud-logging-admin-list-log-names" +type: docs +description: > + A "cloud-logging-admin-list-log-names" tool lists the log names in the project. +aliases: +- /resources/tools/cloud-logging-admin-list-log-names +--- + +## About + +The `cloud-logging-admin-list-log-names` tool lists the log names available in the Google Cloud project. +It's compatible with the following sources: + +- [cloud-logging-admin](../../sources/cloud-logging-admin.md) + +## Example + +```yaml +kind: tools +name: list_log_names +type: cloud-logging-admin-list-log-names +source: my-cloud-logging +description: Lists all log names in the project. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "cloud-logging-admin-list-log-names". | +| source | string | true | Name of the cloud-logging-admin source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **parameter** | **type** | **required** | **description** | +|:--------------|:--------:|:------------:|:----------------| +| limit | integer | false | Maximum number of log entries to return (default: 200). | diff --git a/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-list-resource-types.md b/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-list-resource-types.md new file mode 100644 index 0000000000..1bbac35c4b --- /dev/null +++ b/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-list-resource-types.md @@ -0,0 +1,34 @@ +--- +title: "cloud-logging-admin-list-resource-types" +type: docs +description: > + A "cloud-logging-admin-list-resource-types" tool lists the monitored resource types. +aliases: +- /resources/tools/cloud-logging-admin-list-resource-types +--- + +## About + +The `cloud-logging-admin-list-resource-types` tool lists the monitored resource types available in Google Cloud Logging. +It's compatible with the following sources: + +- [cloud-logging-admin](../../sources/cloud-logging-admin.md) + +## Example + +```yaml +kind: tools +name: list_resource_types +type: cloud-logging-admin-list-resource-types +source: my-cloud-logging +description: Lists monitored resource types. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "cloud-logging-admin-list-resource-types".| +| source | string | true | Name of the cloud-logging-admin source. | +| description | string | true | Description of the tool that is passed to the LLM. | + diff --git a/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-query-logs.md b/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-query-logs.md new file mode 100644 index 0000000000..7a9b8f97d5 --- /dev/null +++ b/docs/en/resources/tools/cloudloggingadmin/cloud-logging-admin-query-logs.md @@ -0,0 +1,44 @@ +--- +title: "cloud-logging-admin-query-logs" +type: docs +description: > + A "cloud-logging-admin-query-logs" tool queries log entries. +aliases: +- /resources/tools/cloud-logging-admin-query-logs +--- + +## About + +The `cloud-logging-admin-query-logs` tool allows you to query log entries from Google Cloud Logging using the advanced logs filter syntax. +It's compatible with the following sources: + +- [cloud-logging-admin](../../sources/cloud-logging-admin.md) + +## Example + +```yaml +kind: tools +name: query_logs +type: cloud-logging-admin-query-logs +source: my-cloud-logging +description: Queries log entries from Cloud Logging. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "cloud-logging-admin-query-logs". | +| source | string | true | Name of the cloud-logging-admin source. | +| description | string | true | Description of the tool that is passed to the LLM. | + +### Parameters + +| **parameter** | **type** | **required** | **description** | +|:--------------|:--------:|:------------:|:----------------| +| filter | string | false | Cloud Logging filter query. Common fields: resource.type, resource.labels.*, logName, severity, textPayload, jsonPayload.*, protoPayload.*, labels.*, httpRequest.*. Operators: =, !=, <, <=, >, >=, :, =~, AND, OR, NOT. | +| newestFirst | boolean | false | Set to true for newest logs first. Defaults to oldest first. | +| startTime | string | false | Start time in RFC3339 format (e.g., 2025-12-09T00:00:00Z). Defaults to 30 days ago. | +| endTime | string | false | End time in RFC3339 format (e.g., 2025-12-09T23:59:59Z). Defaults to now. | +| verbose | boolean | false | Include additional fields (insertId, trace, spanId, httpRequest, labels, operation, sourceLocation). Defaults to false. | +| limit | integer | false | Maximum number of log entries to return. Default: `200`. | diff --git a/go.mod b/go.mod index 2254089cee..f592eb67d2 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( cloud.google.com/go/dataproc/v2 v2.15.0 cloud.google.com/go/firestore v1.20.0 cloud.google.com/go/geminidataanalytics v0.3.0 + cloud.google.com/go/logging v1.13.1 cloud.google.com/go/longrunning v0.7.0 cloud.google.com/go/spanner v1.86.1 github.com/ClickHouse/clickhouse-go/v2 v2.40.3 diff --git a/go.sum b/go.sum index 791f5b2457..65e485efe4 100644 --- a/go.sum +++ b/go.sum @@ -370,8 +370,8 @@ cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6 cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo= cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw= cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= -cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= -cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE= cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= diff --git a/internal/sources/bigquery/bigquery.go b/internal/sources/bigquery/bigquery.go index 527c4e0c3b..edd63111ab 100644 --- a/internal/sources/bigquery/bigquery.go +++ b/internal/sources/bigquery/bigquery.go @@ -236,9 +236,9 @@ func setupClientCaching(s *Source, baseCreator BigqueryClientCreator) { } // Initialize caches - s.bqClientCache = NewCache(onBqEvict) - s.bqRestCache = NewCache(nil) - s.dataplexCache = NewCache(onDataplexEvict) + s.bqClientCache = sources.NewCache(onBqEvict) + s.bqRestCache = sources.NewCache(nil) + s.dataplexCache = sources.NewCache(onDataplexEvict) // Create the caching wrapper for the client creator s.ClientCreator = func(tokenString string, wantRestService bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error) { @@ -289,9 +289,9 @@ type Source struct { Session *Session // Caches for OAuth clients - bqClientCache *Cache - bqRestCache *Cache - dataplexCache *Cache + bqClientCache *sources.Cache + bqRestCache *sources.Cache + dataplexCache *sources.Cache } type Session struct { diff --git a/internal/sources/bigquery/cache.go b/internal/sources/cache.go similarity index 99% rename from internal/sources/bigquery/cache.go rename to internal/sources/cache.go index 947c82238f..80e6a2f890 100644 --- a/internal/sources/bigquery/cache.go +++ b/internal/sources/cache.go @@ -11,7 +11,7 @@ // 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 bigquery +package sources import ( "sync" diff --git a/internal/sources/cloudloggingadmin/cloud_logging_admin.go b/internal/sources/cloudloggingadmin/cloud_logging_admin.go new file mode 100644 index 0000000000..289914a144 --- /dev/null +++ b/internal/sources/cloudloggingadmin/cloud_logging_admin.go @@ -0,0 +1,439 @@ +// 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 cloudloggingadmin + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + "cloud.google.com/go/logging" + "cloud.google.com/go/logging/logadmin" + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/util" + "go.opentelemetry.io/otel/trace" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/impersonate" + "google.golang.org/api/iterator" + "google.golang.org/api/option" +) + +const SourceType string = "cloud-logging-admin" + +var _ sources.SourceConfig = Config{} + +func init() { + if !sources.Register(SourceType, newConfig) { + panic(fmt.Sprintf("source type %q already registered", SourceType)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (sources.SourceConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Type string `yaml:"type" validate:"required"` + Project string `yaml:"project" validate:"required"` + UseClientOAuth bool `yaml:"useClientOAuth"` + ImpersonateServiceAccount string `yaml:"impersonateServiceAccount"` +} + +func (r Config) SourceConfigType() string { + return SourceType +} + +func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.Source, error) { + + if r.UseClientOAuth && r.ImpersonateServiceAccount != "" { + return nil, fmt.Errorf("useClientOAuth cannot be used with impersonateServiceAccount") + } + + var client *logadmin.Client + var tokenSource oauth2.TokenSource + var clientCreator LogAdminClientCreator + var err error + + s := &Source{ + Config: r, + Client: client, + TokenSource: tokenSource, + ClientCreator: clientCreator, + } + + if r.UseClientOAuth { + // use client OAuth + baseClientCreator, err := newLogAdminClientCreator(ctx, tracer, r.Project, r.Name) + if err != nil { + return nil, fmt.Errorf("error constructing client creator: %w", err) + } + setupClientCaching(s, baseClientCreator) + } else { + client, tokenSource, err = initLogAdminConnection(ctx, tracer, r.Name, r.Project, r.ImpersonateServiceAccount) + if err != nil { + return nil, fmt.Errorf("error creating client from ADC %w", err) + } + s.Client = client + s.TokenSource = tokenSource + } + return s, nil +} + +var _ sources.Source = &Source{} + +type LogAdminClientCreator func(tokenString string) (*logadmin.Client, error) + +type Source struct { + Config + Client *logadmin.Client + TokenSource oauth2.TokenSource + ClientCreator LogAdminClientCreator + + // Caches for OAuth clients + logadminClientCache *sources.Cache +} + +func (s *Source) SourceType() string { + // Returns logadmin source type + return SourceType +} + +func (s *Source) ToConfig() sources.SourceConfig { + return s.Config +} + +func (s *Source) UseClientAuthorization() bool { + return s.UseClientOAuth +} + +func (s *Source) LogAdminClient() *logadmin.Client { + return s.Client +} + +func (s *Source) LogAdminTokenSource() oauth2.TokenSource { + return s.TokenSource +} + +func (s *Source) LogAdminClientCreator() LogAdminClientCreator { + return s.ClientCreator +} + +func (s *Source) GetProject() string { + return s.Project +} + +// getClient returns the appropriate client based on authentication mode +func (s *Source) getClient(accessToken string) (*logadmin.Client, error) { + if s.UseClientOAuth { + if s.ClientCreator == nil { + return nil, fmt.Errorf("client creator is not initialized") + } + return s.ClientCreator(accessToken) + } + if s.Client == nil { + return nil, fmt.Errorf("source client is not initialized") + } + return s.Client, nil +} + +// ListLogNames lists all log names in the project +func (s *Source) ListLogNames(ctx context.Context, limit int, accessToken string) ([]string, error) { + client, err := s.getClient(accessToken) + if err != nil { + return nil, err + } + + it := client.Logs(ctx) + var logNames []string + for len(logNames) < limit { + logName, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, err + } + logNames = append(logNames, logName) + } + return logNames, nil +} + +// ListResourceTypes lists all resource types in the project +func (s *Source) ListResourceTypes(ctx context.Context, accessToken string) ([]string, error) { + client, err := s.getClient(accessToken) + if err != nil { + return nil, err + } + + it := client.ResourceDescriptors(ctx) + var types []string + for { + desc, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("failed to list resource descriptors: %w", err) + } + types = append(types, desc.Type) + } + slices.Sort(types) + return types, nil +} + +// QueryLogsParams contains the parameters for querying logs +type QueryLogsParams struct { + Filter string + NewestFirst bool + StartTime string + EndTime string + Verbose bool + Limit int +} + +// QueryLogs queries log entries based on the provided parameters +func (s *Source) QueryLogs(ctx context.Context, params QueryLogsParams, accessToken string) ([]map[string]any, error) { + client, err := s.getClient(accessToken) + if err != nil { + return nil, err + } + + // Build filter + var filterParts []string + if params.Filter != "" { + filterParts = append(filterParts, params.Filter) + } + + // Add timestamp filter + startTime := params.StartTime + if startTime != "" { + filterParts = append(filterParts, fmt.Sprintf(`timestamp>="%s"`, startTime)) + } + + if params.EndTime != "" { + filterParts = append(filterParts, fmt.Sprintf(`timestamp<="%s"`, params.EndTime)) + } + + combinedFilter := strings.Join(filterParts, " AND ") + + // Add opts + opts := []logadmin.EntriesOption{ + logadmin.Filter(combinedFilter), + } + + // Set order + if params.NewestFirst { + opts = append(opts, logadmin.NewestFirst()) + } + + // Set up iterator + it := client.Entries(ctx, opts...) + + var results []map[string]any + for len(results) < params.Limit { + entry, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("failed to iterate entries: %w", err) + } + + result := map[string]any{ + "logName": entry.LogName, + "timestamp": entry.Timestamp.Format(time.RFC3339), + "severity": entry.Severity.String(), + "resource": map[string]any{ + "type": entry.Resource.Type, + "labels": entry.Resource.Labels, + }, + } + + if entry.Payload != nil { + result["payload"] = entry.Payload + } + + if params.Verbose { + result["insertId"] = entry.InsertID + + if len(entry.Labels) > 0 { + result["labels"] = entry.Labels + } + + if entry.HTTPRequest != nil { + httpRequestMap := map[string]any{ + "status": entry.HTTPRequest.Status, + "latency": entry.HTTPRequest.Latency.String(), + "remoteIp": entry.HTTPRequest.RemoteIP, + } + if req := entry.HTTPRequest.Request; req != nil { + httpRequestMap["requestMethod"] = req.Method + httpRequestMap["requestUrl"] = req.URL.String() + httpRequestMap["userAgent"] = req.UserAgent() + } + result["httpRequest"] = httpRequestMap + } + + if entry.Trace != "" { + result["trace"] = entry.Trace + } + + if entry.SpanID != "" { + result["spanId"] = entry.SpanID + } + + if entry.Operation != nil { + result["operation"] = map[string]any{ + "id": entry.Operation.Id, + "producer": entry.Operation.Producer, + "first": entry.Operation.First, + "last": entry.Operation.Last, + } + } + + if entry.SourceLocation != nil { + result["sourceLocation"] = map[string]any{ + "file": entry.SourceLocation.File, + "line": entry.SourceLocation.Line, + "function": entry.SourceLocation.Function, + } + } + } + results = append(results, result) + } + return results, nil +} + +func setupClientCaching(s *Source, baseCreator LogAdminClientCreator) { + onEvict := func(key string, value interface{}) { + if client, ok := value.(*logadmin.Client); ok && client != nil { + client.Close() + } + } + + s.logadminClientCache = sources.NewCache(onEvict) + + s.ClientCreator = func(tokenString string) (*logadmin.Client, error) { + if val, found := s.logadminClientCache.Get(tokenString); found { + return val.(*logadmin.Client), nil + } + + client, err := baseCreator(tokenString) + if err != nil { + return nil, err + } + s.logadminClientCache.Set(tokenString, client) + return client, nil + } +} + +func initLogAdminConnection( + ctx context.Context, + tracer trace.Tracer, + name string, + project string, + impersonateServiceAccount string, +) (*logadmin.Client, oauth2.TokenSource, error) { + ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceType, name) + defer span.End() + + userAgent, err := util.UserAgentFromContext(ctx) + if err != nil { + return nil, nil, err + } + + var tokenSource oauth2.TokenSource + var opts []option.ClientOption + + if impersonateServiceAccount != "" { + // Create impersonated credentials token source with cloud-platform scope + // This broader scope is needed for tools like conversational analytics + cloudPlatformTokenSource, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{ + TargetPrincipal: impersonateServiceAccount, + Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + }) + + if err != nil { + return nil, nil, fmt.Errorf("failed to create impersonated credentials for %q: %w", impersonateServiceAccount, err) + } + + tokenSource = cloudPlatformTokenSource + opts = []option.ClientOption{ + option.WithUserAgent(userAgent), + option.WithTokenSource(cloudPlatformTokenSource), + } + } else { + // Use default credentials + cred, err := google.FindDefaultCredentials(ctx, logging.AdminScope) + if err != nil { + return nil, nil, fmt.Errorf("failed to find default Google Cloud credentials with scope %q: %w", logging.AdminScope, err) + } + tokenSource = cred.TokenSource + opts = []option.ClientOption{ + option.WithUserAgent(userAgent), + option.WithCredentials(cred), + } + } + + client, err := logadmin.NewClient(ctx, project, opts...) + if err != nil { + return nil, nil, fmt.Errorf("failed to create Cloud Logging Admin client for project %q: %w", project, err) + } + return client, tokenSource, nil +} + +func initLogAdminConnectionWithOAuthToken( + ctx context.Context, + tracer trace.Tracer, + project, name, userAgent, tokenString string, +) (*logadmin.Client, error) { + ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceType, name) + defer span.End() + + token := &oauth2.Token{ + AccessToken: string(tokenString), + } + ts := oauth2.StaticTokenSource(token) + + // Initialize the logadmin client with tokenSource + client, err := logadmin.NewClient(ctx, project, option.WithUserAgent(userAgent), option.WithTokenSource(ts)) + if err != nil { + return nil, fmt.Errorf("failed to create logadmin client for project %q: %w", project, err) + } + return client, nil +} + +func newLogAdminClientCreator( + ctx context.Context, + tracer trace.Tracer, + project, name string, +) (LogAdminClientCreator, error) { + userAgent, err := util.UserAgentFromContext(ctx) + if err != nil { + return nil, err + } + + return func(tokenString string) (*logadmin.Client, error) { + return initLogAdminConnectionWithOAuthToken(ctx, tracer, project, name, userAgent, tokenString) + }, nil +} diff --git a/internal/sources/cloudloggingadmin/cloud_logging_admin_test.go b/internal/sources/cloudloggingadmin/cloud_logging_admin_test.go new file mode 100644 index 0000000000..3792237659 --- /dev/null +++ b/internal/sources/cloudloggingadmin/cloud_logging_admin_test.go @@ -0,0 +1,137 @@ +// 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 cloudloggingadmin_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources/cloudloggingadmin" + "github.com/googleapis/genai-toolbox/internal/testutils" +) + +func TestParseFromYamlCloudLoggingAdmin(t *testing.T) { + tcs := []struct { + desc string + in string + want server.SourceConfigs + }{ + { + desc: "basic example", + in: ` + kind: sources + name: my-instance + type: cloud-logging-admin + project: my-project + `, + want: server.SourceConfigs{ + "my-instance": cloudloggingadmin.Config{ + Name: "my-instance", + Type: cloudloggingadmin.SourceType, + Project: "my-project", + }, + }, + }, + { + desc: "with client oauth", + in: ` + kind: sources + name: my-instance + type: cloud-logging-admin + project: my-project + useClientOAuth: true + `, + want: server.SourceConfigs{ + "my-instance": cloudloggingadmin.Config{ + Name: "my-instance", + Type: cloudloggingadmin.SourceType, + Project: "my-project", + UseClientOAuth: true, + }, + }, + }, + { + desc: "with service account impersonation", + in: ` + kind: sources + name: my-instance + type: cloud-logging-admin + project: my-project + impersonateServiceAccount: service-account@my-project.iam.gserviceaccount.com + `, + want: server.SourceConfigs{ + "my-instance": cloudloggingadmin.Config{ + Name: "my-instance", + Type: cloudloggingadmin.SourceType, + Project: "my-project", + ImpersonateServiceAccount: "service-account@my-project.iam.gserviceaccount.com", + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got, _, _, _, _, _, err := server.UnmarshalResourceConfig(context.Background(), testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if !cmp.Equal(tc.want, got) { + t.Fatalf("incorrect parse: want %v, got %v", tc.want, got) + } + }) + } +} + +func TestFailParseFromYaml(t *testing.T) { + tcs := []struct { + desc string + in string + err string + }{ + { + desc: "extra field", + in: ` + kind: sources + name: my-instance + type: cloud-logging-admin + project: my-project + foo: bar + `, + err: "error unmarshaling sources: unable to parse source \"my-instance\" as \"cloud-logging-admin\": [1:1] unknown field \"foo\"\n> 1 | foo: bar\n ^\n 2 | name: my-instance\n 3 | project: my-project\n 4 | type: cloud-logging-admin", + }, + { + desc: "missing required field", + in: ` + kind: sources + name: my-instance + type: cloud-logging-admin + `, + err: "error unmarshaling sources: unable to parse source \"my-instance\" as \"cloud-logging-admin\": Key: 'Config.Project' Error:Field validation for 'Project' failed on the 'required' tag", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(context.Background(), testutils.FormatYaml(tc.in)) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if errStr != tc.err { + t.Fatalf("unexpected error: got %q, want %q", errStr, tc.err) + } + }) + } +} diff --git a/internal/tools/cloudloggingadmin/cloudloggingadminlistlognames/cloudloggingadminlistlognames.go b/internal/tools/cloudloggingadmin/cloudloggingadminlistlognames/cloudloggingadminlistlognames.go new file mode 100644 index 0000000000..063fbba334 --- /dev/null +++ b/internal/tools/cloudloggingadmin/cloudloggingadminlistlognames/cloudloggingadminlistlognames.go @@ -0,0 +1,155 @@ +// 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 cloudloggingadminlistlognames + +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" +) + +const resourceType string = "cloud-logging-admin-list-log-names" + +const defaultLimit int = 200 + +func init() { + if !tools.Register(resourceType, newConfig) { + panic(fmt.Sprintf("tool type %q already registered", resourceType)) + } +} + +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 +} + +type compatibleSource interface { + UseClientAuthorization() bool + ListLogNames(ctx context.Context, limit int, accessToken string) ([]string, error) +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Type string `yaml:"type" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigType() string { + return resourceType +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + limitDescription := fmt.Sprintf("Maximum number of log entries to return. Default: %d.", defaultLimit) + params := parameters.Parameters{ + parameters.NewIntParameterWithRequired("limit", limitDescription, false), + } + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, nil) + + t := Tool{ + Config: cfg, + manifest: tools.Manifest{Description: cfg.Description, Parameters: params.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + Parameters: params, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Config + manifest tools.Manifest + mcpManifest tools.McpManifest + Parameters parameters.Parameters `yaml:"parameters"` +} + +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.Type) + if err != nil { + return nil, err + } + + limit := defaultLimit + paramsMap := params.AsMap() + if val, ok := paramsMap["limit"].(int); ok && val > 0 { + limit = val + } else if ok && val < 0 { + return nil, fmt.Errorf("limit must be greater than or equal to 1") + } + + tokenString := "" + if source.UseClientAuthorization() { + tokenString, err = accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("failed to parse access token: %w", err) + } + } + + return source.ListLogNames(ctx, limit, tokenString) +} + +func (t Tool) ParseParams(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) { + return parameters.ParseParams(t.Parameters, data, claimsMap) +} + +func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return paramValues, nil +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization(resourceMgr tools.SourceProvider) (bool, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return false, err + } + return source.UseClientAuthorization(), nil +} + +func (t Tool) ToConfig() tools.ToolConfig { + return t.Config +} + +func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) { + return "Authorization", nil +} + +func (t Tool) GetParameters() parameters.Parameters { + return t.Parameters +} diff --git a/internal/tools/cloudloggingadmin/cloudloggingadminlistlognames/cloudloggingadminlistlognames_test.go b/internal/tools/cloudloggingadmin/cloudloggingadminlistlognames/cloudloggingadminlistlognames_test.go new file mode 100644 index 0000000000..e2b8887d9c --- /dev/null +++ b/internal/tools/cloudloggingadmin/cloudloggingadminlistlognames/cloudloggingadminlistlognames_test.go @@ -0,0 +1,122 @@ +// 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 cloudloggingadminlistlognames_test + +import ( + "context" + "strings" + "testing" + + "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/cloudloggingadmin/cloudloggingadminlistlognames" +) + +func TestParseFromYaml(t *testing.T) { + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-list-log-names + source: my-logging-admin-source + description: list log names + authRequired: + - my-google-auth-service + `, + want: server.ToolConfigs{ + "example_tool": cloudloggingadminlistlognames.Config{ + Name: "example_tool", + Type: "cloud-logging-admin-list-log-names", + Source: "my-logging-admin-source", + Description: "list log names", + AuthRequired: []string{"my-google-auth-service"}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, got, _, _, err := server.UnmarshalResourceConfig(context.Background(), testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} + +func TestFailParseFromYaml(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + err string + }{ + { + desc: "Invalid type", + in: ` + kind: tools + name: example_tool + type: invalid-type + source: my-instance + description: some description + `, + err: `unknown tool type: "invalid-type"`, + }, + { + desc: "missing source", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-list-log-names + description: some description + `, + err: `Key: 'Config.Source' Error:Field validation for 'Source' failed on the 'required' tag`, + }, + { + desc: "missing description", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-list-log-names + source: my-instance + `, + err: `Key: 'Config.Description' Error:Field validation for 'Description' failed on the 'required' tag`, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if !strings.Contains(errStr, tc.err) { + t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err) + } + }) + } +} diff --git a/internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes/cloudloggingadminlistresourcetypes.go b/internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes/cloudloggingadminlistresourcetypes.go new file mode 100644 index 0000000000..1326bf037c --- /dev/null +++ b/internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes/cloudloggingadminlistresourcetypes.go @@ -0,0 +1,142 @@ +// 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 cloudloggingadminlistresourcetypes + +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" +) + +const resourceType string = "cloud-logging-admin-list-resource-types" + +func init() { + if !tools.Register(resourceType, newConfig) { + panic(fmt.Sprintf("tool type %q already registered", resourceType)) + } +} + +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 +} + +type compatibleSource interface { + UseClientAuthorization() bool + ListResourceTypes(ctx context.Context, accessToken string) ([]string, error) +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Type string `yaml:"type" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigType() string { + return resourceType +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // No parameters for this tool + var params parameters.Parameters + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, nil) + + t := Tool{ + Config: cfg, + Parameters: params, + manifest: tools.Manifest{Description: cfg.Description, AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Config + Parameters parameters.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +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.Type) + if err != nil { + return nil, err + } + + tokenString := "" + if source.UseClientAuthorization() { + tokenString, err = accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("failed to parse access token: %w", err) + } + } + + return source.ListResourceTypes(ctx, tokenString) +} + +func (t Tool) ParseParams(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) { + return parameters.ParamValues{}, nil +} + +func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return paramValues, nil +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization(resourceMgr tools.SourceProvider) (bool, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return false, err + } + return source.UseClientAuthorization(), nil +} + +func (t Tool) ToConfig() tools.ToolConfig { + return t.Config +} + +func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) { + return "Authorization", nil +} + +func (t Tool) GetParameters() parameters.Parameters { + return t.Parameters +} diff --git a/internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes/cloudloggingadminlistresourcetypes_test.go b/internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes/cloudloggingadminlistresourcetypes_test.go new file mode 100644 index 0000000000..731fc924d8 --- /dev/null +++ b/internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes/cloudloggingadminlistresourcetypes_test.go @@ -0,0 +1,122 @@ +// 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 cloudloggingadminlistresourcetypes_test + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + cloudloggingadminlistresourcetypes "github.com/googleapis/genai-toolbox/internal/tools/cloudloggingadmin/cloudloggingadminlistresourcetypes" +) + +func TestParseFromYaml(t *testing.T) { + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-list-resource-types + source: my-logging-admin-source + description: list resource types + authRequired: + - my-google-auth-service + `, + want: server.ToolConfigs{ + "example_tool": cloudloggingadminlistresourcetypes.Config{ + Name: "example_tool", + Type: "cloud-logging-admin-list-resource-types", + Source: "my-logging-admin-source", + Description: "list resource types", + AuthRequired: []string{"my-google-auth-service"}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, got, _, _, err := server.UnmarshalResourceConfig(context.Background(), testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} + +func TestFailParseFromYaml(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + err string + }{ + { + desc: "Invalid type", + in: ` + kind: tools + name: example_tool + type: invalid-type + source: my-instance + description: some description + `, + err: `unknown tool type: "invalid-type"`, + }, + { + desc: "missing source", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-list-resource-types + description: some description + `, + err: `Key: 'Config.Source' Error:Field validation for 'Source' failed on the 'required' tag`, + }, + { + desc: "missing description", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-list-resource-types + source: my-instance + `, + err: `Key: 'Config.Description' Error:Field validation for 'Description' failed on the 'required' tag`, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if !strings.Contains(errStr, tc.err) { + t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err) + } + }) + } +} diff --git a/internal/tools/cloudloggingadmin/cloudloggingadminquerylogs/cloudloggingadminquerylogs.go b/internal/tools/cloudloggingadmin/cloudloggingadminquerylogs/cloudloggingadminquerylogs.go new file mode 100644 index 0000000000..ab62ef3510 --- /dev/null +++ b/internal/tools/cloudloggingadmin/cloudloggingadminquerylogs/cloudloggingadminquerylogs.go @@ -0,0 +1,215 @@ +// 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 cloudloggingadminquerylogs + +import ( + "context" + "fmt" + "time" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/embeddingmodels" + "github.com/googleapis/genai-toolbox/internal/sources" + cla "github.com/googleapis/genai-toolbox/internal/sources/cloudloggingadmin" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/util/parameters" +) + +const ( + resourceType string = "cloud-logging-admin-query-logs" + + defaultLimit int = 200 + defaultStartTimeOffsetDays int = 30 +) + +func init() { + if !tools.Register(resourceType, newConfig) { + panic(fmt.Sprintf("tool type %q already registered", resourceType)) + } +} + +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 +} + +type compatibleSource interface { + UseClientAuthorization() bool + QueryLogs(ctx context.Context, params cla.QueryLogsParams, accessToken string) ([]map[string]any, error) +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Type string `yaml:"type" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigType() string { + return resourceType +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + startTimeDescription := fmt.Sprintf("Start time in RFC3339 format (e.g., 2025-12-09T00:00:00Z). Defaults to %d days ago.", defaultStartTimeOffsetDays) + limitDescription := fmt.Sprintf("Maximum number of log entries to return. Default: %d.", defaultLimit) + params := parameters.Parameters{ + parameters.NewStringParameterWithRequired( + "filter", + "Cloud Logging filter query. Common fields: resource.type, resource.labels.*, logName, severity, textPayload, jsonPayload.*, protoPayload.*, labels.*, httpRequest.*. Operators: =, !=, <, <=, >, >=, :, =~, AND, OR, NOT.", + false, + ), + parameters.NewBooleanParameterWithRequired("newestFirst", "Set to true for newest logs first. Defaults to oldest first.", false), + parameters.NewStringParameterWithRequired("startTime", startTimeDescription, false), + parameters.NewStringParameterWithRequired("endTime", "End time in RFC3339 format (e.g., 2025-12-09T23:59:59Z). Defaults to now.", false), + parameters.NewBooleanParameterWithRequired("verbose", "Include additional fields (insertId, trace, spanId, httpRequest, labels, operation, sourceLocation). Defaults to false.", false), + parameters.NewIntParameterWithRequired("limit", limitDescription, false), + } + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, nil) + + t := Tool{ + Config: cfg, + manifest: tools.Manifest{Description: cfg.Description, Parameters: params.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + Parameters: params, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Config + Parameters parameters.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +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.Type) + if err != nil { + return nil, err + } + + // Parse parameters + limit := defaultLimit + paramsMap := params.AsMap() + newestFirst, _ := paramsMap["newestFirst"].(bool) + + // Check and set limit + if val, ok := paramsMap["limit"].(int); ok && val > 0 { + limit = val + } else if ok && val < 0 { + return nil, fmt.Errorf("limit must be greater than or equal to 1") + } + + // Check for verbosity of output + verbose, _ := paramsMap["verbose"].(bool) + + // Build filter + var filter string + if f, ok := paramsMap["filter"].(string); ok { + if len(f) == 0 { + return nil, fmt.Errorf("filter cannot be empty if provided") + } + filter = f + } + + // Parse start time + var startTime string + if val, ok := paramsMap["startTime"].(string); ok && val != "" { + if _, err := time.Parse(time.RFC3339, val); err != nil { + return nil, fmt.Errorf("startTime must be in RFC3339 format (e.g., 2025-12-09T00:00:00Z): %w", err) + } + startTime = val + } else { + startTime = time.Now().AddDate(0, 0, -defaultStartTimeOffsetDays).Format(time.RFC3339) + } + + // Parse end time + var endTime string + if val, ok := paramsMap["endTime"].(string); ok && val != "" { + if _, err := time.Parse(time.RFC3339, val); err != nil { + return nil, fmt.Errorf("endTime must be in RFC3339 format (e.g., 2025-12-09T23:59:59Z): %w", err) + } + endTime = val + } + + tokenString := "" + if source.UseClientAuthorization() { + tokenString, err = accessToken.ParseBearerToken() + if err != nil { + return nil, fmt.Errorf("failed to parse access token: %w", err) + } + } + + queryParams := cla.QueryLogsParams{ + Filter: filter, + NewestFirst: newestFirst, + StartTime: startTime, + EndTime: endTime, + Verbose: verbose, + Limit: limit, + } + + return source.QueryLogs(ctx, queryParams, tokenString) +} + +func (t Tool) ParseParams(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) { + return parameters.ParseParams(t.Parameters, data, claimsMap) +} + +func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return paramValues, nil +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization(resourceMgr tools.SourceProvider) (bool, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return false, err + } + return source.UseClientAuthorization(), nil +} + +func (t Tool) ToConfig() tools.ToolConfig { + return t.Config +} + +func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) { + return "Authorization", nil +} + +func (t Tool) GetParameters() parameters.Parameters { + return t.Parameters +} diff --git a/internal/tools/cloudloggingadmin/cloudloggingadminquerylogs/cloudloggingadminquerylogs_test.go b/internal/tools/cloudloggingadmin/cloudloggingadminquerylogs/cloudloggingadminquerylogs_test.go new file mode 100644 index 0000000000..c01368ccc7 --- /dev/null +++ b/internal/tools/cloudloggingadmin/cloudloggingadminquerylogs/cloudloggingadminquerylogs_test.go @@ -0,0 +1,122 @@ +// 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 cloudloggingadminquerylogs_test + +import ( + "context" + "strings" + "testing" + + "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/cloudloggingadmin/cloudloggingadminquerylogs" +) + +func TestParseFromYaml(t *testing.T) { + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-query-logs + source: my-logging-admin-source + description: query logs + authRequired: + - my-google-auth-service + `, + want: server.ToolConfigs{ + "example_tool": cloudloggingadminquerylogs.Config{ + Name: "example_tool", + Type: "cloud-logging-admin-query-logs", + Source: "my-logging-admin-source", + Description: "query logs", + AuthRequired: []string{"my-google-auth-service"}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, got, _, _, err := server.UnmarshalResourceConfig(context.Background(), testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} + +func TestFailParseFromYaml(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + err string + }{ + { + desc: "Invalid type", + in: ` + kind: tools + name: example_tool + type: invalid-type + source: my-instance + description: some description + `, + err: `unknown tool type: "invalid-type"`, + }, + { + desc: "missing source", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-query-logs + description: some description + `, + err: `Key: 'Config.Source' Error:Field validation for 'Source' failed on the 'required' tag`, + }, + { + desc: "missing description", + in: ` + kind: tools + name: example_tool + type: cloud-logging-admin-query-logs + source: my-instance + `, + err: `Key: 'Config.Description' Error:Field validation for 'Description' failed on the 'required' tag`, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if !strings.Contains(errStr, tc.err) { + t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err) + } + }) + } +} diff --git a/tests/cloudloggingadmin/cloud_logging_admin_integration_test.go b/tests/cloudloggingadmin/cloud_logging_admin_integration_test.go new file mode 100644 index 0000000000..92cbb8fe32 --- /dev/null +++ b/tests/cloudloggingadmin/cloud_logging_admin_integration_test.go @@ -0,0 +1,339 @@ +// 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. + +// To run these tests, set the following environment variables: +// LOGADMIN_PROJECT: Google Cloud project ID. +package cloudloggingadmin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "strings" + "testing" + "time" + + "cloud.google.com/go/logging" + "cloud.google.com/go/logging/logadmin" + "github.com/google/uuid" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/tests" + "golang.org/x/oauth2/google" + "google.golang.org/api/option" +) + +var ( + LogAdminSourceType = "cloud-logging-admin" + LogAdminProject = os.Getenv("LOGADMIN_PROJECT") +) + +func getLogAdminVars(t *testing.T) map[string]any { + switch "" { + case LogAdminProject: + t.Fatal("'LOGADMIN_PROJECT' not set") + } + + return map[string]any{ + "type": LogAdminSourceType, + "project": LogAdminProject, + } +} + +// Copied over from cloud_logging_admin.go +func initLogAdminConnection(project string) (*logadmin.Client, error) { + ctx := context.Background() + cred, err := google.FindDefaultCredentials(ctx, logging.AdminScope) + if err != nil { + return nil, fmt.Errorf("failed to find default Google Cloud credentials with scope %q: %w", logging.AdminScope, err) + } + client, err := logadmin.NewClient(ctx, project, option.WithCredentials(cred)) + if err != nil { + return nil, fmt.Errorf("failed to create Cloud Logging Admin client for project %q: %w", project, err) + } + return client, nil +} + +// This client will be used to add logs to the project +func initLogConnection(project string) (*logging.Client, error) { + ctx := context.Background() + cred, err := google.FindDefaultCredentials(ctx, logging.WriteScope) + if err != nil { + return nil, fmt.Errorf("failed to find default Google Cloud credentials with scope %q: %w", logging.WriteScope, err) + } + client, err := logging.NewClient(ctx, project, option.WithCredentials(cred)) + if err != nil { + return nil, fmt.Errorf("failed to create Cloud Logging client for project %q: %w", project, err) + } + return client, nil +} + +func TestLogAdminToolEndpoints(t *testing.T) { + sourceConfig := getLogAdminVars(t) + ctx, cancel := context.WithTimeout(context.Background(), 7*time.Minute) + defer cancel() + + var args []string + + _, err := initLogAdminConnection(LogAdminProject) + if err != nil { + t.Fatalf("unable to connect to logs: %s", err) + } + + loggingClient, err := initLogConnection(LogAdminProject) + if err != nil { + t.Fatalf("unable to connect to logging: %s", err) + } + defer loggingClient.Close() + + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "") + logName := fmt.Sprintf("toolbox-integration-test-%s", testUUID) + + // set up test logs and wait for logs to be injested. + setupTestLogs(t, loggingClient, logName) + t.Logf("Waiting 15 seconds for log ingestion...") + time.Sleep(15 * time.Second) + + // Delete test logs once test is over + defer teardownTestLogs(t, ctx, LogAdminProject, logName) + + toolsFile := getCloudLoggingAdminToolsConfig(sourceConfig) + cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...) + if err != nil { + t.Fatalf("command initialization returned an error: %s", err) + } + defer cleanup() + + waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Second) + defer waitCancel() + 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) + } + + runListLogNamesTest(t, logName) + runAuthListLogNamesTest(t, logName) + runListResourceTypesTest(t) + runQueryLogsTest(t, logName) + runQueryLogsErrorTest(t) +} + +func setupTestLogs(t *testing.T, client *logging.Client, logName string) { + logger := client.Logger(logName) + logger.Log(logging.Entry{ + Payload: map[string]string{"test_id": logName, "message": "test entry 1"}, + Severity: logging.Info, + Labels: map[string]string{"env": "test", "run_id": "1"}, + }) + + logger.Log(logging.Entry{ + Payload: map[string]string{"test_id": logName, "message": "test entry 2"}, + Severity: logging.Warning, + }) + + logger.Log(logging.Entry{ + Payload: map[string]string{"test_id": logName, "message": "test entry 3"}, + Severity: logging.Error, + }) + if err := logger.Flush(); err != nil { + t.Fatalf("failed to flush logs: %v", err) + } +} + +func teardownTestLogs(t *testing.T, ctx context.Context, projectID, logName string) { + adminClient, err := logadmin.NewClient(ctx, projectID) + if err != nil { + t.Errorf("failed to create admin client for cleanup: %v", err) + return + } + defer adminClient.Close() + + if err := adminClient.DeleteLog(ctx, logName); err != nil { + t.Logf("failed to delete test log %s: %v", logName, err) + } +} + +func getCloudLoggingAdminToolsConfig(sourceConfig map[string]any) map[string]any { + return map[string]any{ + "sources": map[string]any{ + "my-logging-instance": sourceConfig, + }, + "authServices": map[string]any{ + "my-google-auth": map[string]any{ + "type": "google", + "clientId": tests.ClientId, + }, + }, + "tools": map[string]any{ + "list-log-names": map[string]any{ + "type": "cloud-logging-admin-list-log-names", + "source": "my-logging-instance", + "description": "Lists log names in the project", + }, + "list-resource-types": map[string]any{ + "type": "cloud-logging-admin-list-resource-types", + "source": "my-logging-instance", + "description": "Lists monitored resource types", + }, + "query-logs": map[string]any{ + "type": "cloud-logging-admin-query-logs", + "source": "my-logging-instance", + "description": "Queries log entries", + }, + "auth-list-log-names": map[string]any{ + "type": "cloud-logging-admin-list-log-names", + "source": "my-logging-instance", + "authRequired": []string{"my-google-auth"}, + "description": "Lists log names with authentication", + }, + }, + } +} + +func runListLogNamesTest(t *testing.T, expectedLogName string) { + t.Run("list-log-names", func(t *testing.T) { + resp, respBody := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/list-log-names/invoke", bytes.NewBuffer([]byte(`{}`)), nil) + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var body map[string]interface{} + if err := json.Unmarshal(respBody, &body); err != nil { + t.Fatalf("error parsing response body") + } + + result, ok := body["result"].(string) + if !ok { + t.Fatalf("expected result to be string") + } + + if !strings.Contains(result, expectedLogName) { + t.Errorf("expected log name %s not found in result: %s", expectedLogName, result) + } + }) +} + +func runListResourceTypesTest(t *testing.T) { + t.Run("list-resource-types", func(t *testing.T) { + resp, respBody := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/list-resource-types/invoke", bytes.NewBuffer([]byte(`{}`)), nil) + + if resp.StatusCode != 200 { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var body map[string]interface{} + if err := json.Unmarshal(respBody, &body); err != nil { + t.Fatalf("error parsing response body") + } + + result, ok := body["result"].(string) + if !ok { + t.Fatalf("expected result to be string") + } + + expectedTypes := []string{"global", "gce_instance", "gcs_bucket", "project"} + for _, resourceType := range expectedTypes { + if !strings.Contains(result, resourceType) { + t.Errorf("expected '%s' resource type in result, but it was missing", resourceType) + } + } + }) +} + +func runQueryLogsTest(t *testing.T, logName string) { + baseFilter := fmt.Sprintf(`logName="projects/%s/logs/%s"`, LogAdminProject, logName) + + t.Run("query-logs-simple", func(t *testing.T) { + requestBody := fmt.Sprintf(`{"filter": %q, "limit": 10}`, baseFilter) + result := invokeQueryTool(t, requestBody) + + if !strings.Contains(result, "test entry") { + t.Errorf("expected test entries in result: %s", result) + } + }) + + t.Run("query-logs-newest-first", func(t *testing.T) { + requestBody := fmt.Sprintf(`{"filter": %q, "limit": 10, "newestFirst": true}`, baseFilter) + result := invokeQueryTool(t, requestBody) + + idx3 := strings.Index(result, "test entry 3") + idx1 := strings.Index(result, "test entry 1") + + if idx3 == -1 || idx1 == -1 { + t.Fatalf("missing expected entries in result: %s", result) + } + + if idx3 > idx1 { + t.Errorf("expected entry 3 to appear before entry 1 with newestFirst=true, but got: ...%s... then ...%s...", "test entry 3", "test entry 1") + } + }) + + t.Run("query-logs-verbose", func(t *testing.T) { + requestBody := fmt.Sprintf(`{"filter": %q, "limit": 10, "verbose": true}`, baseFilter) + result := invokeQueryTool(t, requestBody) + + if !strings.Contains(result, `"labels":`) { + t.Errorf("expected 'labels' field in verbose output, got: %s", result) + } + if !strings.Contains(result, `"env":"test"`) && !strings.Contains(result, `"env": "test"`) { + t.Errorf("expected label 'env: test' in verbose output, got: %s", result) + } + }) +} + +func invokeQueryTool(t *testing.T, requestBody string) string { + t.Helper() + resp, respBody := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/query-logs/invoke", bytes.NewBuffer([]byte(requestBody)), nil) + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var body map[string]interface{} + if err := json.Unmarshal(respBody, &body); err != nil { + t.Fatalf("error parsing response body") + } + + result, ok := body["result"].(string) + if !ok { + t.Fatalf("expected result to be string") + } + return result +} + +func runAuthListLogNamesTest(t *testing.T, expectedLogName string) { + t.Run("auth-list-log-names", func(t *testing.T) { + resp, _ := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/auth-list-log-names/invoke", bytes.NewBuffer([]byte(`{}`)), nil) + if resp.StatusCode != 401 { + t.Fatalf("expected status 401 (Unauthorized), got %d", resp.StatusCode) + } + }) +} + +func runQueryLogsErrorTest(t *testing.T) { + t.Run("query-logs-error", func(t *testing.T) { + requestBody := `{"filter": "INVALID_FILTER_SYNTAX :::", "limit": 10}` + resp, _ := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/api/tool/query-logs/invoke", bytes.NewBuffer([]byte(requestBody)), nil) + if resp.StatusCode == 200 { + t.Errorf("expected error status code, got 200 OK") + } + }) +}