Files
genai-toolbox/tests/cloudloggingadmin/cloud_logging_admin_integration_test.go
Pranjul Kalsi 252fc3091a feat(sources/cloud-logging-admin): add source, tools, integration test and docs (#2137)
## 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>
2026-01-28 14:31:25 -08:00

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")
}
})
}