Skip to content

Add GCE and GKE integration tests for the default OTel config - #759

Open
MarcoDN wants to merge 1 commit into
aws:mainfrom
MarcoDN:gcp-integration-tests
Open

MarcoDN wants to merge 1 commit into
aws:mainfrom
MarcoDN:gcp-integration-tests

Conversation

@MarcoDN

@MarcoDN MarcoDN commented Sep 10, 2026

Copy link
Copy Markdown

Description of the issue

The agent supports running on GCP hosts (GCE and GKE) with the default OTel config, authenticating to AWS via web-identity federation — but there is no integration test coverage proving the credential chains and end-to-end telemetry delivery (metrics, logs, traces) to CloudWatch from either environment.

Description of changes

Adds two new test suites, each provisioned by terraform (companion workflow jobs to follow in aws/amazon-cloudwatch-agent):

GCE (terraform/gcp/gce, test/gcp/gce)

  • Provisions a GCE instance with a per-run service account, uploads the runner-built .deb over SSH, and starts the agent via amazon-cloudwatch-agent-ctl -a set-env + -a fetch-config -m auto -s -c default:otel.
  • The agent authenticates via the GCP web-identity chain (AssumeRoleWithWebIdentity). Google is a built-in AWS web-identity provider, so the per-run role trusts accounts.google.com directly — no IAM OIDC provider resource — pinning all three Google condition keys: :aud and :sub (the service account's unique ID) and :oaud (the requested audience).
  • The Go test runs on the instance and validates metrics (label-filtered), logs (asserting the derived {host.id}/{service.name} stream, so routing is covered rather than just delivery), and traces (the exact generated trace IDs in aws/spans via Logs Insights).

GKE (terraform/gcp/gke, test/gcp/gke)

  • Provisions a zonal GKE cluster and registers its OIDC issuer as an AWS IAM OIDC provider. The issuer URL is constructed — GKE serves it at a deterministic container.googleapis.com/v1/.../clusters/... path that the terraform resource does not export as an attribute.
  • Deploys the agent as a DaemonSet from the integration-test ECR image with a projected service-account token (audience sts.amazonaws.com); a load-generator Job pushes OTLP metrics/logs/traces to the agent over hostNetwork.
  • The Go test runs on the runner and validates all three signals in CloudWatch. Metric validation scopes on a per-run test_id datapoint attribute and asserts cloud.platform=gcp_kubernetes_engine, so it isolates the run and covers resource detection rather than echoing back an injected attribute.
  • Registers the new GCE and GKE compute types in environment/computetype, adds -gkeClusterName to the environment metadata, and adds terraform/gcp/README.md documenting the one-time project/account setup (APIs, CI service-account roles, CI authentication, GitHub configuration, Transaction Search).

License

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Tests

Both modules verified with complete local terraform apply runs — the apply runs the suite via provisioners, so its exit code is the test verdict:

  • GCE: TestGCE PASS in 362.5s — Metrics 0.55s / Logs 0.67s / Traces 151.27s, all generated trace IDs found in aws/spans on the first attempt.
  • GKE: apply fully green — 15 resources created, TestGKE Metrics/Logs/Traces all PASS on the first complete run of the module.
  • Failure propagation confirmed in an earlier run: a failing subtest exits nonzero through the provisioner and fails the apply.
  • gofmt and go vet -tags integration clean on the new packages; terraform validate and terraform fmt -check clean on both stacks.

Worth noting

  • us-east-2 is load-bearing, not arbitrary. aws/spans is only populated where the X-Ray trace segment destination is CloudWatch Logs, which is a per-region setting; us-west-2 deliberately keeps the XRay destination for the App Signals suite that validates through the X-Ray query APIs.
  • The GCE/GKE asymmetry is intentional. GCE sets CWAGENT_ROLE_ARN (the translator only wires the web-identity token file when role_arn is non-empty) and carries an inline policy of validation reads because its test runs on the instance under that role. GKE omits CWAGENT_ROLE_ARN so sigv4auth falls through to the default credential chain and the projected token, and its role has no inline policy because the test runs on the runner under the runner's credentials.
  • The IAM shapes differ by provider design. GCE federates the built-in accounts.google.com principal with per-run conditions; GKE registers a per-run IAM OIDC provider for the cluster issuer.

@MarcoDN
MarcoDN requested a review from a team as a code owner September 10, 2026 14:24
@MarcoDN
MarcoDN marked this pull request as draft September 11, 2026 15:42
Comment thread test/gcp/gce/payloads_test.go Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is almost entirely the same as https://github.com/aws/amazon-cloudwatch-agent-test/blob/main/test/azure/vm/payloads_test.go. Can we move it into a utility under test/otel_collect/otlpvalidation or something like that instead of replicating the entire file and update both the Azure VM and GCE tests to use it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also practically identical to the AKS otlp_load_generator.sh aside from the aks/gke prefix. It already renders via templatefile(), so could we move that one to a shared location and add one prefix template variable for those? The same prefix is used for validation so consider using the lowercase computeType for the prefix for both generation and validation.

Comment thread test/gcp/gce/gce_test.go
Comment on lines +121 to +284
// validateLogs confirms the OTLP log record landed in the default:otel log group on the stream the
// agent's log routing is expected to derive for this host.
func validateLogs() status.TestResult {
testResult := status.TestResult{Name: "GCE_Logs", Status: status.FAILED}

// The agent routes OTLP logs to {host.id}/{service.name}, so assert that exact stream: it makes the
// check prove log routing rather than just delivery, and keeps cost flat as the shared group
// accumulates a stream per VM. Retries because the stream and events both lag.
logStream := fmt.Sprintf("%s/%s", env.InstanceId, serviceName)
// Clean up only on success: the group is shared by every VM run, so drop this run's stream but never
// the group. On failure the stream is left in place as evidence for whoever debugs the run.
defer func() {
if testResult.Status == status.SUCCESSFUL {
awsservice.DeleteLogStream(otlpLogGroup, logStream)
}
}()
marker := fmt.Sprintf("gce_otlp_log_%s", env.InstanceId)
const maxRetries = 4
const retryInterval = 30 * time.Second
for attempt := 1; attempt <= maxRetries; attempt++ {
since := time.Now().Add(-loadWindow - time.Minute)
until := time.Now()
log.Printf("[GCE_Logs] attempt %d: checking %s/%s", attempt, otlpLogGroup, logStream)
err := awsservice.ValidateLogs(
otlpLogGroup, logStream, &since, &until,
awsservice.AssertLogsNotEmpty(),
awsservice.AssertPerLog(awsservice.AssertLogContainsSubstring(marker)),
)
if err == nil {
testResult.Status = status.SUCCESSFUL
return testResult
}
testResult.Reason = err
if attempt < maxRetries {
log.Printf("[GCE_Logs] %v — retrying in %v", testResult.Reason, retryInterval)
time.Sleep(retryInterval)
}
}
return testResult
}

// validateTraces confirms every OTLP span emitted during the load window reached AWS through the
// X-Ray OTLP endpoint. That endpoint requires Transaction Search (trace segment destination =
// CloudWatchLogs), which stores 100% of ingested spans in the aws/spans log group; the X-Ray query
// APIs (GetTraceSummaries/BatchGetTraces) only see the indexed subset (1% by default), so aws/spans
// is the authoritative surface for OTLP trace delivery. Ingestion lags a few minutes, hence retries.
func validateTraces(traceIDs []string) status.TestResult {
testResult := status.TestResult{Name: "GCE_Traces", Status: status.FAILED}

if len(traceIDs) == 0 {
testResult.Reason = fmt.Errorf("no trace IDs were generated during the load window")
return testResult
}

quoted := make([]string, len(traceIDs))
for i, id := range traceIDs {
quoted[i] = fmt.Sprintf("%q", id)
}
query := fmt.Sprintf("fields traceId | filter traceId in [%s] | dedup traceId", strings.Join(quoted, ", "))
log.Printf("[GCE_Traces] expecting %d trace IDs in %s (sample: %s)", len(traceIDs), spansLogGroup, traceIDs[0])

const maxRetries = 5
const retryInterval = 60 * time.Second
for attempt := 1; attempt <= maxRetries; attempt++ {
since := time.Now().Add(-loadWindow - 10*time.Minute)
rows, err := awsservice.GetLogQueryResults(spansLogGroup, since.Unix(), time.Now().Unix(), query)
if err != nil {
testResult.Reason = fmt.Errorf("attempt %d: %s query failed (is Transaction Search enabled in the account?): %w",
attempt, spansLogGroup, err)
} else {
found := make(map[string]bool, len(rows))
for _, row := range rows {
for _, field := range row {
if aws.ToString(field.Field) == "traceId" {
found[aws.ToString(field.Value)] = true
}
}
}
var missing []string
for _, id := range traceIDs {
if !found[id] {
missing = append(missing, id)
}
}
if len(missing) == 0 {
log.Printf("[GCE_Traces] attempt %d: all %d traces found in %s", attempt, len(traceIDs), spansLogGroup)
testResult.Status = status.SUCCESSFUL
return testResult
}
testResult.Reason = fmt.Errorf("attempt %d: %d/%d traces missing from %s (first missing: %s)",
attempt, len(missing), len(traceIDs), spansLogGroup, missing[0])
}
if attempt < maxRetries {
log.Printf("[GCE_Traces] %v — retrying in %v", testResult.Reason, retryInterval)
time.Sleep(retryInterval)
}
}
return testResult
}

// sendTelemetry pushes OTLP metrics, logs, and traces to the local collector until stop is closed.
func sendTelemetry(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
post("/v1/metrics", buildMetricsPayload(env.InstanceId))
post("/v1/logs", buildLogsPayload(env.InstanceId))
// Only record the trace ID once the collector has accepted the span. Recording it
// unconditionally would make a single transient POST failure guarantee a validation
// failure for a trace that was never actually sent.
payload, traceID := buildTracesPayload(env.InstanceId)
if post("/v1/traces", payload) {
recordTraceID(traceID)
}
}
}
}

// post sends an OTLP payload and reports whether the collector accepted it.
func post(path string, payload []byte) bool {
req, err := http.NewRequest("POST", otlpEndpoint+path, bytes.NewReader(payload))
if err != nil {
log.Printf("failed to build OTLP request for %s: %v", path, err)
return false
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("failed to POST OTLP to %s: %v", path, err)
return false
}
// Drain before closing so the connection can be reused.
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
log.Printf("OTLP POST to %s returned %s", path, resp.Status)
return false
}
return true
}

// filterLogLines returns lines from a multi-line string that contain any of the given substrings (case-insensitive).
func filterLogLines(text string, substrs ...string) []string {
var result []string
for _, line := range strings.Split(text, "\n") {
lower := strings.ToLower(line)
for _, s := range substrs {
if strings.Contains(lower, strings.ToLower(s)) {
result = append(result, line)
break
}
}
}
if len(result) > 50 {
result = result[len(result)-50:]
}
return result
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These functions are the same as the Azure VM test. Consider extracting to a shared utility package.

Comment thread terraform/gcp/gce/main.tf Outdated
# translated YAML. set-env runs before fetch-config so both are set on the first agent start.
provisioner "remote-exec" {
inline = [
"export PATH=$PATH:/usr/local/go/bin",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Is this necessary? I see we're installing go via apt-get, so won't it be in /usr/bin instead?

Comment thread terraform/gcp/README.md Outdated
Comment on lines +23 to +24
An existing VPC network and subnetwork must be passed as `gcp_network_name` /
`gcp_subnetwork_name`; the auto-created `default` network works.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Seems like we'll want to avoid using the default network if we want to lock it down to only the runner_ip since there are some pre-populated firewall rules that will allow any IP (default-allow-ssh).

https://docs.cloud.google.com/firewall/docs/firewalls#more_rules_default_vpc

The terraform/gcp/gce and terraform/gcp/gke modules provision a GCE instance
and a zonal GKE cluster, federate a per-run IAM role to AWS via web identity,
install the agent with the default OTel config, generate OTLP load, and run
the Go suites validating metrics, logs, and traces in CloudWatch. Adds the
GCE and GKE compute types and a README covering the one-time GCP project and
AWS account setup.
@MarcoDN
MarcoDN force-pushed the gcp-integration-tests branch from f69c7ac to 3bfa707 Compare September 17, 2026 10:21
@MarcoDN
MarcoDN marked this pull request as ready for review September 17, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants