mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-02-04 12:15:09 -05:00
## 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: <Add 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 <yuanteoh@google.com>
Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
340 lines
11 KiB
Go
340 lines
11 KiB
Go
// 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")
|
|
}
|
|
})
|
|
}
|