From c3ac6b01724145a1d48d6ffeca97069ebbf1bad3 Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Tue, 5 May 2026 12:39:19 -0700 Subject: [PATCH 1/3] Add language detection to auto-monitor to inject only the relevant SDK --- pkg/instrumentation/auto/language_detector.go | 353 +++++++++++++++ .../auto/language_detector_test.go | 423 ++++++++++++++++++ pkg/instrumentation/auto/monitor.go | 33 +- 3 files changed, 806 insertions(+), 3 deletions(-) create mode 100644 pkg/instrumentation/auto/language_detector.go create mode 100644 pkg/instrumentation/auto/language_detector_test.go diff --git a/pkg/instrumentation/auto/language_detector.go b/pkg/instrumentation/auto/language_detector.go new file mode 100644 index 000000000..64ac1b06e --- /dev/null +++ b/pkg/instrumentation/auto/language_detector.go @@ -0,0 +1,353 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auto + +import ( + "context" + "encoding/base64" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ecr" + "github.com/go-logr/logr" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + corev1 "k8s.io/api/core/v1" + + "github.com/aws/amazon-cloudwatch-agent-operator/pkg/instrumentation" +) + +// languageDetector inspects container image configs from the registry to determine +// the application runtime language. It reads ENV, CMD, and ENTRYPOINT from the +// image manifest without pulling layers. +type languageDetector struct { + logger logr.Logger + keychain authn.Keychain + timeout time.Duration +} + +func newLanguageDetector(logger logr.Logger) *languageDetector { + return &languageDetector{ + logger: logger, + keychain: authn.NewMultiKeychain(newECRKeychain(logger), authn.DefaultKeychain), + timeout: 5 * time.Second, + } +} + +// ecrKeychain uses the AWS SDK default credential chain (instance profile, IRSA, env vars) +// to authenticate with Amazon ECR. This is the same credential chain customers configure +// via aws configure / IAM roles when setting up EKS. +type ecrKeychain struct { + logger logr.Logger +} + +func newECRKeychain(logger logr.Logger) *ecrKeychain { + return &ecrKeychain{logger: logger} +} + +func (k *ecrKeychain) Resolve(resource authn.Resource) (authn.Authenticator, error) { + registry := resource.RegistryStr() + if !strings.Contains(registry, ".dkr.ecr.") || !strings.Contains(registry, ".amazonaws.com") { + return authn.Anonymous, nil + } + + sess, err := session.NewSession() + if err != nil { + k.logger.V(2).Info("could not create AWS session for ECR auth", "error", err) + return authn.Anonymous, nil + } + + svc := ecr.New(sess) + result, err := svc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{}) + if err != nil { + k.logger.V(2).Info("could not get ECR authorization token", "error", err) + return authn.Anonymous, nil + } + + if len(result.AuthorizationData) == 0 { + return authn.Anonymous, nil + } + + decoded, err := base64.StdEncoding.DecodeString(*result.AuthorizationData[0].AuthorizationToken) + if err != nil { + return authn.Anonymous, nil + } + + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return authn.Anonymous, nil + } + + return authn.FromConfig(authn.AuthConfig{ + Username: parts[0], + Password: parts[1], + }), nil +} + +// detectLanguages inspects all containers in a pod template and returns the set of +// detected languages. Returns an empty set if no language can be confidently determined. +func (d *languageDetector) detectLanguages(podSpec *corev1.PodTemplateSpec) instrumentation.TypeSet { + detected := make(instrumentation.TypeSet) + + for _, container := range podSpec.Spec.Containers { + if lang := d.detectContainer(container); lang != "" { + d.logger.V(2).Info("detected language from container", + "container", container.Name, "image", container.Image, "language", lang) + detected[lang] = nil + } + } + + return detected +} + +// detectContainer fetches the image config from the registry and inspects it. +// Falls back to pod-spec-only detection if the registry fetch fails. +func (d *languageDetector) detectContainer(container corev1.Container) instrumentation.Type { + // Try fetching real image config from registry + if cfg := d.fetchImageConfig(container.Image); cfg != nil { + if lang := d.detectFromConfig(cfg); lang != "" { + return lang + } + } + + // Fallback: check image name for language patterns (handles private registries where config fetch fails) + if lang := d.detectFromImageName(container.Image); lang != "" { + return lang + } + + // Fallback: check pod-spec-level env vars and commands + if lang := d.detectFromEnvVars(container.Env); lang != "" { + return lang + } + if lang := d.detectFromCommand(container.Command, container.Args); lang != "" { + return lang + } + return "" +} + +// detectFromImageName checks the container image reference string for language indicators. +// This is a heuristic fallback for when registry config fetch is not available. +func (d *languageDetector) detectFromImageName(image string) instrumentation.Type { + lower := strings.ToLower(image) + + javaPatterns := []string{ + "openjdk", "jdk", "jre", "eclipse-temurin", "amazoncorretto", + "corretto", "adoptopenjdk", "ibm-semeru", "graalvm", + "tomcat", "jetty", "wildfly", "quarkus", "springboot", + "spring-boot", "maven", "gradle", "libertycore", "payara", + } + for _, p := range javaPatterns { + if strings.Contains(lower, p) { + return instrumentation.TypeJava + } + } + if strings.Contains(lower, "java") && !strings.Contains(lower, "javascript") { + return instrumentation.TypeJava + } + + pythonPatterns := []string{ + "python", "django", "flask", "fastapi", "uvicorn", + "gunicorn", "celery", "conda", "miniconda", "anaconda", + } + for _, p := range pythonPatterns { + if strings.Contains(lower, p) { + return instrumentation.TypePython + } + } + + nodePatterns := []string{ + "node:", "/node:", "nodejs", "node-", "-node", + "express", "nextjs", "next.js", "nestjs", + } + for _, p := range nodePatterns { + if strings.Contains(lower, p) { + return instrumentation.TypeNodeJS + } + } + + dotnetPatterns := []string{ + "dotnet", "aspnet", "asp.net", "mcr.microsoft.com/dotnet", + } + for _, p := range dotnetPatterns { + if strings.Contains(lower, p) { + return instrumentation.TypeDotNet + } + } + + return "" +} + +// fetchImageConfig retrieves the image config (ENV, CMD, ENTRYPOINT, Labels) from the +// registry. Only fetches the manifest and config blob — no layer data is downloaded. +func (d *languageDetector) fetchImageConfig(imageRef string) *v1.Config { + ref, err := name.ParseReference(imageRef) + if err != nil { + d.logger.V(2).Info("could not parse image reference", "image", imageRef, "error", err) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), d.timeout) + defer cancel() + + desc, err := remote.Get(ref, + remote.WithAuthFromKeychain(d.keychain), + remote.WithContext(ctx), + ) + if err != nil { + d.logger.V(2).Info("could not fetch image descriptor", "image", imageRef, "error", err) + return nil + } + + img, err := desc.Image() + if err != nil { + d.logger.V(2).Info("could not get image from descriptor", "image", imageRef, "error", err) + return nil + } + + cfgFile, err := img.ConfigFile() + if err != nil { + d.logger.V(2).Info("could not read image config", "image", imageRef, "error", err) + return nil + } + + return &cfgFile.Config +} + +// detectFromConfig inspects the image config's ENV, ENTRYPOINT, CMD, and Labels. +func (d *languageDetector) detectFromConfig(cfg *v1.Config) instrumentation.Type { + // Check image-level environment variables + if lang := d.detectFromImageEnv(cfg.Env); lang != "" { + return lang + } + + // Check ENTRYPOINT and CMD + if lang := d.detectFromCommand(cfg.Entrypoint, cfg.Cmd); lang != "" { + return lang + } + + return "" +} + +// detectFromImageEnv checks environment variables from the image config (string slice format: "KEY=VALUE"). +func (d *languageDetector) detectFromImageEnv(envVars []string) instrumentation.Type { + for _, env := range envVars { + parts := strings.SplitN(env, "=", 2) + if len(parts) < 2 { + continue + } + envName := strings.ToUpper(parts[0]) + envValue := strings.ToLower(parts[1]) + + if lang := d.classifyEnv(envName, envValue); lang != "" { + return lang + } + } + return "" +} + +// detectFromEnvVars checks environment variables from the pod spec (corev1.EnvVar format). +func (d *languageDetector) detectFromEnvVars(envVars []corev1.EnvVar) instrumentation.Type { + for _, env := range envVars { + envName := strings.ToUpper(env.Name) + envValue := strings.ToLower(env.Value) + + if lang := d.classifyEnv(envName, envValue); lang != "" { + return lang + } + } + return "" +} + +// classifyEnv determines the language from an env var name and value. +func (d *languageDetector) classifyEnv(name, value string) instrumentation.Type { + switch name { + case "JAVA_HOME", "JAVA_TOOL_OPTIONS", "JAVA_OPTS", + "JVM_OPTS", "CATALINA_HOME", "CATALINA_OPTS", + "MAVEN_HOME", "GRADLE_HOME": + return instrumentation.TypeJava + } + + switch name { + case "PYTHONPATH", "PYTHONHOME", "PYTHONDONTWRITEBYTECODE", + "PYTHONUNBUFFERED", "PIP_NO_CACHE_DIR", + "PYTHON_VERSION", "PYTHON_SHA256", "PYTHON_PIP_VERSION", + "DJANGO_SETTINGS_MODULE", "FLASK_APP": + return instrumentation.TypePython + } + + switch name { + case "NODE_PATH", "NODE_ENV", "NODE_OPTIONS", + "NPM_CONFIG_PREFIX", "YARN_CACHE_FOLDER", + "NODE_VERSION", "YARN_VERSION": + return instrumentation.TypeNodeJS + } + + switch name { + case "DOTNET_ROOT", "ASPNETCORE_URLS", "ASPNETCORE_ENVIRONMENT", + "DOTNET_RUNNING_IN_CONTAINER", "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", + "NUGET_PACKAGES", "CORECLR_ENABLE_PROFILING": + return instrumentation.TypeDotNet + } + + if name == "PATH" { + if strings.Contains(value, "/usr/lib/jvm") || strings.Contains(value, "java") { + return instrumentation.TypeJava + } + if strings.Contains(value, "python") { + return instrumentation.TypePython + } + if strings.Contains(value, "/usr/local/lib/node") || strings.Contains(value, "nodejs") { + return instrumentation.TypeNodeJS + } + if strings.Contains(value, "dotnet") { + return instrumentation.TypeDotNet + } + } + return "" +} + +// detectFromCommand checks entrypoint and command args for language indicators. +func (d *languageDetector) detectFromCommand(command []string, args []string) instrumentation.Type { + allParts := append(command, args...) + if len(allParts) == 0 { + return "" + } + + for _, part := range allParts { + lower := strings.ToLower(part) + + if lower == "java" || strings.HasSuffix(lower, "/java") || + strings.HasSuffix(lower, ".jar") || + strings.Contains(lower, "-javaagent:") || + strings.Contains(lower, "org.apache.catalina") || + strings.Contains(lower, "org.springframework") { + return instrumentation.TypeJava + } + + if lower == "python" || lower == "python3" || lower == "python2" || + strings.HasSuffix(lower, "/python") || strings.HasSuffix(lower, "/python3") || + strings.HasSuffix(lower, ".py") || + lower == "gunicorn" || lower == "uvicorn" || lower == "celery" || + lower == "django-admin" || lower == "flask" { + return instrumentation.TypePython + } + + if lower == "node" || lower == "nodejs" || + strings.HasSuffix(lower, "/node") || + strings.HasSuffix(lower, ".js") || strings.HasSuffix(lower, ".mjs") || + lower == "npm" || lower == "yarn" || lower == "npx" || lower == "pnpm" { + return instrumentation.TypeNodeJS + } + + if lower == "dotnet" || strings.HasSuffix(lower, "/dotnet") || + strings.HasSuffix(lower, ".dll") { + return instrumentation.TypeDotNet + } + } + + return "" +} diff --git a/pkg/instrumentation/auto/language_detector_test.go b/pkg/instrumentation/auto/language_detector_test.go new file mode 100644 index 000000000..e2fd6f17f --- /dev/null +++ b/pkg/instrumentation/auto/language_detector_test.go @@ -0,0 +1,423 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auto + +import ( + "testing" + + "github.com/go-logr/logr" + v1 "github.com/google/go-containerregistry/pkg/v1" + corev1 "k8s.io/api/core/v1" + + "github.com/aws/amazon-cloudwatch-agent-operator/pkg/instrumentation" +) + +func newTestDetector() *languageDetector { + return &languageDetector{logger: logr.Discard()} +} + +func TestDetectFromConfig_JavaImages(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + cfg *v1.Config + }{ + { + "openjdk with JAVA_HOME", + &v1.Config{ + Env: []string{"JAVA_HOME=/usr/lib/jvm/java-17-openjdk", "PATH=/usr/lib/jvm/java-17-openjdk/bin:/usr/bin"}, + Cmd: []string{"java", "-jar", "app.jar"}, + }, + }, + { + "corretto with JAVA_TOOL_OPTIONS", + &v1.Config{ + Env: []string{"JAVA_TOOL_OPTIONS=-javaagent:/opt/agent.jar", "JAVA_HOME=/usr/lib/jvm/java-17-amazon-corretto"}, + Entrypoint: []string{"java"}, + Cmd: []string{"-jar", "/app/service.jar"}, + }, + }, + { + "tomcat with CATALINA_HOME", + &v1.Config{ + Env: []string{"CATALINA_HOME=/opt/tomcat", "PATH=/opt/tomcat/bin:/usr/bin"}, + Entrypoint: []string{"catalina.sh"}, + Cmd: []string{"run"}, + }, + }, + { + "spring boot with JVM_OPTS", + &v1.Config{ + Env: []string{"JVM_OPTS=-Xmx512m -Xms256m"}, + Cmd: []string{"java", "-jar", "/app/spring-app.jar"}, + }, + }, + { + "java detected from entrypoint only", + &v1.Config{ + Entrypoint: []string{"java"}, + Cmd: []string{"-jar", "app.jar"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromConfig(tt.cfg) + if result != instrumentation.TypeJava { + t.Errorf("detectFromConfig() = %q, want %q", result, instrumentation.TypeJava) + } + }) + } +} + +func TestDetectFromConfig_PythonImages(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + cfg *v1.Config + }{ + { + "python official image", + &v1.Config{ + Env: []string{"PYTHONPATH=/usr/local/lib/python3.11", "PYTHON_VERSION=3.11.9", "PYTHONDONTWRITEBYTECODE=1"}, + Entrypoint: []string{"python3"}, + }, + }, + { + "django with DJANGO_SETTINGS_MODULE", + &v1.Config{ + Env: []string{"DJANGO_SETTINGS_MODULE=myapp.settings", "PYTHONUNBUFFERED=1"}, + Cmd: []string{"gunicorn", "myapp.wsgi:application"}, + }, + }, + { + "flask app", + &v1.Config{ + Env: []string{"FLASK_APP=app.py"}, + Cmd: []string{"flask", "run", "--host=0.0.0.0"}, + }, + }, + { + "uvicorn from command only", + &v1.Config{ + Cmd: []string{"uvicorn", "main:app", "--host", "0.0.0.0"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromConfig(tt.cfg) + if result != instrumentation.TypePython { + t.Errorf("detectFromConfig() = %q, want %q", result, instrumentation.TypePython) + } + }) + } +} + +func TestDetectFromConfig_NodeImages(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + cfg *v1.Config + }{ + { + "node official image", + &v1.Config{ + Env: []string{"NODE_VERSION=20.11.0", "NODE_ENV=production"}, + Entrypoint: []string{"docker-entrypoint.sh"}, + Cmd: []string{"node"}, + }, + }, + { + "node with NODE_OPTIONS", + &v1.Config{ + Env: []string{"NODE_OPTIONS=--max-old-space-size=4096"}, + Cmd: []string{"node", "server.js"}, + }, + }, + { + "npm start", + &v1.Config{ + Env: []string{"NPM_CONFIG_PREFIX=/home/node/.npm-global"}, + Cmd: []string{"npm", "start"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromConfig(tt.cfg) + if result != instrumentation.TypeNodeJS { + t.Errorf("detectFromConfig() = %q, want %q", result, instrumentation.TypeNodeJS) + } + }) + } +} + +func TestDetectFromConfig_DotNetImages(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + cfg *v1.Config + }{ + { + "aspnet runtime", + &v1.Config{ + Env: []string{"ASPNETCORE_URLS=http://+:8080", "DOTNET_RUNNING_IN_CONTAINER=true"}, + Entrypoint: []string{"dotnet"}, + Cmd: []string{"MyApp.dll"}, + }, + }, + { + "dotnet SDK", + &v1.Config{ + Env: []string{"DOTNET_ROOT=/usr/share/dotnet", "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true"}, + Cmd: []string{"dotnet", "run"}, + }, + }, + { + "dotnet from entrypoint only", + &v1.Config{ + Entrypoint: []string{"dotnet", "MyService.dll"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromConfig(tt.cfg) + if result != instrumentation.TypeDotNet { + t.Errorf("detectFromConfig() = %q, want %q", result, instrumentation.TypeDotNet) + } + }) + } +} + +func TestDetectFromConfig_NoDetection(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + cfg *v1.Config + }{ + { + "generic shell entrypoint", + &v1.Config{ + Entrypoint: []string{"/bin/sh", "-c"}, + Cmd: []string{"exec /app/start"}, + }, + }, + { + "nginx", + &v1.Config{ + Env: []string{"NGINX_VERSION=1.25.4", "PATH=/usr/sbin:/usr/bin"}, + Entrypoint: []string{"/docker-entrypoint.sh"}, + Cmd: []string{"nginx", "-g", "daemon off;"}, + }, + }, + { + "empty config", + &v1.Config{}, + }, + { + "only generic env vars", + &v1.Config{ + Env: []string{"APP_PORT=8080", "LOG_LEVEL=info", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromConfig(tt.cfg) + if result != "" { + t.Errorf("detectFromConfig() = %q, want empty (no detection)", result) + } + }) + } +} + +func TestClassifyEnv(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + envName string + envValue string + expected instrumentation.Type + }{ + {"JAVA_HOME", "JAVA_HOME", "/usr/lib/jvm/java-17", instrumentation.TypeJava}, + {"JAVA_OPTS", "JAVA_OPTS", "-Xmx512m", instrumentation.TypeJava}, + {"CATALINA_HOME", "CATALINA_HOME", "/opt/tomcat", instrumentation.TypeJava}, + {"PYTHONPATH", "PYTHONPATH", "/app", instrumentation.TypePython}, + {"DJANGO_SETTINGS", "DJANGO_SETTINGS_MODULE", "myapp.settings", instrumentation.TypePython}, + {"FLASK_APP", "FLASK_APP", "app.py", instrumentation.TypePython}, + {"PYTHONUNBUFFERED", "PYTHONUNBUFFERED", "1", instrumentation.TypePython}, + {"NODE_ENV", "NODE_ENV", "production", instrumentation.TypeNodeJS}, + {"NODE_OPTIONS", "NODE_OPTIONS", "--max-old-space-size=4096", instrumentation.TypeNodeJS}, + {"NODE_VERSION", "NODE_VERSION", "20.11.0", instrumentation.TypeNodeJS}, + {"DOTNET_ROOT", "DOTNET_ROOT", "/usr/share/dotnet", instrumentation.TypeDotNet}, + {"ASPNETCORE_URLS", "ASPNETCORE_URLS", "http://+:8080", instrumentation.TypeDotNet}, + {"ASPNETCORE_ENVIRONMENT", "ASPNETCORE_ENVIRONMENT", "production", instrumentation.TypeDotNet}, + {"PATH with java", "PATH", "/usr/lib/jvm/bin:/usr/bin", instrumentation.TypeJava}, + {"PATH with python", "PATH", "/usr/local/bin/python:/usr/bin", instrumentation.TypePython}, + {"PATH with dotnet", "PATH", "/usr/share/dotnet:/usr/bin", instrumentation.TypeDotNet}, + {"PYTHON_VERSION", "PYTHON_VERSION", "3.11.15", instrumentation.TypePython}, + {"PYTHON_SHA256", "PYTHON_SHA256", "abc123", instrumentation.TypePython}, + {"PYTHON_PIP_VERSION", "PYTHON_PIP_VERSION", "23.0.1", instrumentation.TypePython}, + {"YARN_VERSION", "YARN_VERSION", "1.22.22", instrumentation.TypeNodeJS}, + {"generic env", "APP_PORT", "8080", ""}, + {"empty", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.classifyEnv(tt.envName, tt.envValue) + if result != tt.expected { + t.Errorf("classifyEnv(%q, %q) = %q, want %q", tt.envName, tt.envValue, result, tt.expected) + } + }) + } +} + +func TestDetectFromImageName(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + image string + expected instrumentation.Type + }{ + {"openjdk", "public.ecr.aws/docker/library/openjdk:17-slim", instrumentation.TypeJava}, + {"corretto", "amazoncorretto:17", instrumentation.TypeJava}, + {"tomcat", "tomcat:10-jdk17", instrumentation.TypeJava}, + {"java in ecr path", "978751493859.dkr.ecr.us-east-1.amazonaws.com/java-sample-app:latest", instrumentation.TypeJava}, + {"python", "public.ecr.aws/docker/library/python:3.11-slim", instrumentation.TypePython}, + {"django", "mycompany/django-app:latest", instrumentation.TypePython}, + {"node official", "node:20-alpine", instrumentation.TypeNodeJS}, + {"nodejs in name", "mycompany/nodejs-api:v2", instrumentation.TypeNodeJS}, + {"dotnet sdk", "mcr.microsoft.com/dotnet/sdk:8.0", instrumentation.TypeDotNet}, + {"aspnet", "mcr.microsoft.com/dotnet/aspnet:8.0", instrumentation.TypeDotNet}, + {"javascript not java", "mycompany/javascript-tools:latest", ""}, + {"ecr image with python in name", "978751493859.dkr.ecr.us-east-1.amazonaws.com/test-custom-python:latest", instrumentation.TypePython}, + {"truly opaque ecr image", "978751493859.dkr.ecr.us-east-1.amazonaws.com/service-abc:v2.3.1", ""}, + {"alpine", "alpine:3.19", ""}, + {"nginx", "nginx:1.25", ""}, + {"busybox", "busybox:latest", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromImageName(tt.image) + if result != tt.expected { + t.Errorf("detectFromImageName(%q) = %q, want %q", tt.image, result, tt.expected) + } + }) + } +} + +func TestDetectFromCommand(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + command []string + args []string + expected instrumentation.Type + }{ + {"java command", []string{"java"}, []string{"-jar", "app.jar"}, instrumentation.TypeJava}, + {"java full path", []string{"/usr/bin/java"}, []string{"-jar", "app.jar"}, instrumentation.TypeJava}, + {"jar in args", []string{"sh", "-c"}, []string{"java -jar /app/service.jar"}, instrumentation.TypeJava}, + {"python command", []string{"python3"}, []string{"app.py"}, instrumentation.TypePython}, + {"python full path", []string{"/usr/local/bin/python"}, []string{"manage.py"}, instrumentation.TypePython}, + {"gunicorn", []string{"gunicorn"}, []string{"app:app"}, instrumentation.TypePython}, + {"uvicorn", []string{"uvicorn"}, []string{"main:app", "--host", "0.0.0.0"}, instrumentation.TypePython}, + {"flask", []string{"flask"}, []string{"run"}, instrumentation.TypePython}, + {".py file", []string{"python3"}, []string{"/app/main.py"}, instrumentation.TypePython}, + {"node command", []string{"node"}, []string{"server.js"}, instrumentation.TypeNodeJS}, + {"node full path", []string{"/usr/local/bin/node"}, []string{"index.js"}, instrumentation.TypeNodeJS}, + {"npm start", []string{"npm"}, []string{"start"}, instrumentation.TypeNodeJS}, + {"yarn", []string{"yarn"}, []string{"serve"}, instrumentation.TypeNodeJS}, + {".js file", []string{"node"}, []string{"/app/dist/main.js"}, instrumentation.TypeNodeJS}, + {".mjs file", []string{"node"}, []string{"app.mjs"}, instrumentation.TypeNodeJS}, + {"dotnet command", []string{"dotnet"}, []string{"MyApp.dll"}, instrumentation.TypeDotNet}, + {"dotnet full path", []string{"/usr/share/dotnet/dotnet"}, []string{"run"}, instrumentation.TypeDotNet}, + {".dll file", []string{"dotnet"}, []string{"/app/MyService.dll"}, instrumentation.TypeDotNet}, + {"sleep command", []string{"sleep"}, []string{"infinity"}, ""}, + {"shell command", []string{"sh", "-c"}, []string{"echo hello"}, ""}, + {"empty", []string{}, []string{}, ""}, + {"nginx", []string{"nginx"}, []string{"-g", "daemon off;"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromCommand(tt.command, tt.args) + if result != tt.expected { + t.Errorf("detectFromCommand(%v, %v) = %q, want %q", tt.command, tt.args, result, tt.expected) + } + }) + } +} + +func TestDetectFromEnvVars_PodSpec(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + env []corev1.EnvVar + expected instrumentation.Type + }{ + {"JAVA_HOME", []corev1.EnvVar{{Name: "JAVA_HOME", Value: "/usr/lib/jvm/java-17"}}, instrumentation.TypeJava}, + {"PYTHONPATH", []corev1.EnvVar{{Name: "PYTHONPATH", Value: "/app"}}, instrumentation.TypePython}, + {"NODE_ENV", []corev1.EnvVar{{Name: "NODE_ENV", Value: "production"}}, instrumentation.TypeNodeJS}, + {"ASPNETCORE_URLS", []corev1.EnvVar{{Name: "ASPNETCORE_URLS", Value: "http://+:8080"}}, instrumentation.TypeDotNet}, + {"generic env", []corev1.EnvVar{{Name: "APP_PORT", Value: "8080"}}, ""}, + {"empty", []corev1.EnvVar{}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromEnvVars(tt.env) + if result != tt.expected { + t.Errorf("detectFromEnvVars() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestDetectFromImageEnv(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + env []string + expected instrumentation.Type + }{ + {"java home", []string{"JAVA_HOME=/usr/lib/jvm/java-17"}, instrumentation.TypeJava}, + {"python version", []string{"PYTHONPATH=/usr/local/lib/python3.11"}, instrumentation.TypePython}, + {"node version", []string{"NODE_VERSION=20.11.0"}, instrumentation.TypeNodeJS}, + {"dotnet root", []string{"DOTNET_ROOT=/usr/share/dotnet"}, instrumentation.TypeDotNet}, + {"multiple envs - java first", []string{"APP_PORT=8080", "JAVA_HOME=/usr/lib/jvm"}, instrumentation.TypeJava}, + {"no signal", []string{"APP_PORT=8080", "LOG_LEVEL=info"}, ""}, + {"PYTHON_VERSION from base image", []string{"PATH=/usr/local/bin:/usr/bin", "PYTHON_VERSION=3.11.15", "PYTHON_SHA256=abc123"}, instrumentation.TypePython}, + {"NODE_VERSION from base image", []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/bin", "NODE_VERSION=20.20.2", "YARN_VERSION=1.22.22"}, instrumentation.TypeNodeJS}, + {"malformed env", []string{"NOEQUALSSIGN"}, ""}, + {"empty", []string{}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromImageEnv(tt.env) + if result != tt.expected { + t.Errorf("detectFromImageEnv() = %q, want %q", result, tt.expected) + } + }) + } +} diff --git a/pkg/instrumentation/auto/monitor.go b/pkg/instrumentation/auto/monitor.go index 82eaef7ce..ceb0dc505 100644 --- a/pkg/instrumentation/auto/monitor.go +++ b/pkg/instrumentation/auto/monitor.go @@ -95,6 +95,7 @@ type Monitor struct { deploymentInformer cache.SharedIndexInformer daemonsetInformer cache.SharedIndexInformer statefulsetInformer cache.SharedIndexInformer + langDetector *languageDetector } func (m *Monitor) MutateAndPatchAll(ctx context.Context) { @@ -179,6 +180,7 @@ func NewMonitor(ctx context.Context, config MonitorConfig, k8sClient kubernetes. deploymentInformer: deploymentInformer, daemonsetInformer: daemonsetInformer, statefulsetInformer: statefulSetInformer, + langDetector: newLanguageDetector(logger), } _, err = serviceInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -450,7 +452,9 @@ func getTemplateSpecLabels(obj metav1.Object) labels.Set { } } -// MutateObject adds all enabled languages in config. Should only be run if selected by auto monitor or custom selector +// MutateObject adds detected or configured languages. When auto-monitor is active, it first +// attempts to detect the application language from container image, env vars, and commands. +// Falls back to all configured languages only if detection yields no results. func (m *Monitor) MutateObject(oldObj client.Object, obj client.Object) any { if !safeToMutate(oldObj, obj, m.config.RestartPods) { return map[string]string{} @@ -458,8 +462,22 @@ func (m *Monitor) MutateObject(oldObj client.Object, obj client.Object) any { languagesToAnnotate := m.config.CustomSelector.LanguagesOf(obj, false) if m.isWorkloadAutoMonitored(obj) { - for l := range m.config.Languages { - languagesToAnnotate[l] = nil + // Attempt to detect the language from container spec before falling back to all languages + detected := m.detectLanguagesFromWorkload(obj) + if len(detected) > 0 { + m.logger.V(1).Info("auto-monitor detected language(s) from container spec", + "objName", obj.GetName(), "detected", detected) + for l := range detected { + if _, ok := m.config.Languages[l]; ok { + languagesToAnnotate[l] = nil + } + } + } else { + m.logger.V(1).Info("auto-monitor could not detect language, falling back to all configured languages", + "objName", obj.GetName(), "languages", m.config.Languages) + for l := range m.config.Languages { + languagesToAnnotate[l] = nil + } } } @@ -471,6 +489,15 @@ func (m *Monitor) MutateObject(oldObj client.Object, obj client.Object) any { return mutate(obj, languagesToAnnotate) } +// detectLanguagesFromWorkload extracts the pod template from a workload and runs language detection. +func (m *Monitor) detectLanguagesFromWorkload(obj client.Object) instrumentation.TypeSet { + podTemplate := getPodTemplate(obj) + if podTemplate == nil { + return nil + } + return m.langDetector.detectLanguages(podTemplate) +} + // returns if workload is auto monitored (does not include custom selector) func (m *Monitor) isWorkloadAutoMonitored(obj client.Object) bool { if isNamespace(obj) { From 43c2d49f632daad71cc9e6a197478fd10903a03a Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Mon, 18 May 2026 08:25:07 -0700 Subject: [PATCH 2/3] removing network call --- pkg/instrumentation/auto/language_detector.go | 154 +--------- .../auto/language_detector_test.go | 277 +++--------------- 2 files changed, 39 insertions(+), 392 deletions(-) diff --git a/pkg/instrumentation/auto/language_detector.go b/pkg/instrumentation/auto/language_detector.go index 64ac1b06e..38a12925d 100644 --- a/pkg/instrumentation/auto/language_detector.go +++ b/pkg/instrumentation/auto/language_detector.go @@ -4,90 +4,26 @@ package auto import ( - "context" - "encoding/base64" "strings" - "time" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ecr" "github.com/go-logr/logr" - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/remote" corev1 "k8s.io/api/core/v1" "github.com/aws/amazon-cloudwatch-agent-operator/pkg/instrumentation" ) -// languageDetector inspects container image configs from the registry to determine -// the application runtime language. It reads ENV, CMD, and ENTRYPOINT from the -// image manifest without pulling layers. +// languageDetector determines the application runtime language from container +// metadata available in the pod spec: image name, env vars, and command/args. type languageDetector struct { - logger logr.Logger - keychain authn.Keychain - timeout time.Duration + logger logr.Logger } func newLanguageDetector(logger logr.Logger) *languageDetector { return &languageDetector{ - logger: logger, - keychain: authn.NewMultiKeychain(newECRKeychain(logger), authn.DefaultKeychain), - timeout: 5 * time.Second, + logger: logger, } } -// ecrKeychain uses the AWS SDK default credential chain (instance profile, IRSA, env vars) -// to authenticate with Amazon ECR. This is the same credential chain customers configure -// via aws configure / IAM roles when setting up EKS. -type ecrKeychain struct { - logger logr.Logger -} - -func newECRKeychain(logger logr.Logger) *ecrKeychain { - return &ecrKeychain{logger: logger} -} - -func (k *ecrKeychain) Resolve(resource authn.Resource) (authn.Authenticator, error) { - registry := resource.RegistryStr() - if !strings.Contains(registry, ".dkr.ecr.") || !strings.Contains(registry, ".amazonaws.com") { - return authn.Anonymous, nil - } - - sess, err := session.NewSession() - if err != nil { - k.logger.V(2).Info("could not create AWS session for ECR auth", "error", err) - return authn.Anonymous, nil - } - - svc := ecr.New(sess) - result, err := svc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{}) - if err != nil { - k.logger.V(2).Info("could not get ECR authorization token", "error", err) - return authn.Anonymous, nil - } - - if len(result.AuthorizationData) == 0 { - return authn.Anonymous, nil - } - - decoded, err := base64.StdEncoding.DecodeString(*result.AuthorizationData[0].AuthorizationToken) - if err != nil { - return authn.Anonymous, nil - } - - parts := strings.SplitN(string(decoded), ":", 2) - if len(parts) != 2 { - return authn.Anonymous, nil - } - - return authn.FromConfig(authn.AuthConfig{ - Username: parts[0], - Password: parts[1], - }), nil -} - // detectLanguages inspects all containers in a pod template and returns the set of // detected languages. Returns an empty set if no language can be confidently determined. func (d *languageDetector) detectLanguages(podSpec *corev1.PodTemplateSpec) instrumentation.TypeSet { @@ -104,22 +40,12 @@ func (d *languageDetector) detectLanguages(podSpec *corev1.PodTemplateSpec) inst return detected } -// detectContainer fetches the image config from the registry and inspects it. -// Falls back to pod-spec-only detection if the registry fetch fails. +// detectContainer determines the language from pod-spec metadata only: +// image name patterns, env vars, and command/args. func (d *languageDetector) detectContainer(container corev1.Container) instrumentation.Type { - // Try fetching real image config from registry - if cfg := d.fetchImageConfig(container.Image); cfg != nil { - if lang := d.detectFromConfig(cfg); lang != "" { - return lang - } - } - - // Fallback: check image name for language patterns (handles private registries where config fetch fails) if lang := d.detectFromImageName(container.Image); lang != "" { return lang } - - // Fallback: check pod-spec-level env vars and commands if lang := d.detectFromEnvVars(container.Env); lang != "" { return lang } @@ -130,7 +56,6 @@ func (d *languageDetector) detectContainer(container corev1.Container) instrumen } // detectFromImageName checks the container image reference string for language indicators. -// This is a heuristic fallback for when registry config fetch is not available. func (d *languageDetector) detectFromImageName(image string) instrumentation.Type { lower := strings.ToLower(image) @@ -181,73 +106,6 @@ func (d *languageDetector) detectFromImageName(image string) instrumentation.Typ return "" } -// fetchImageConfig retrieves the image config (ENV, CMD, ENTRYPOINT, Labels) from the -// registry. Only fetches the manifest and config blob — no layer data is downloaded. -func (d *languageDetector) fetchImageConfig(imageRef string) *v1.Config { - ref, err := name.ParseReference(imageRef) - if err != nil { - d.logger.V(2).Info("could not parse image reference", "image", imageRef, "error", err) - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), d.timeout) - defer cancel() - - desc, err := remote.Get(ref, - remote.WithAuthFromKeychain(d.keychain), - remote.WithContext(ctx), - ) - if err != nil { - d.logger.V(2).Info("could not fetch image descriptor", "image", imageRef, "error", err) - return nil - } - - img, err := desc.Image() - if err != nil { - d.logger.V(2).Info("could not get image from descriptor", "image", imageRef, "error", err) - return nil - } - - cfgFile, err := img.ConfigFile() - if err != nil { - d.logger.V(2).Info("could not read image config", "image", imageRef, "error", err) - return nil - } - - return &cfgFile.Config -} - -// detectFromConfig inspects the image config's ENV, ENTRYPOINT, CMD, and Labels. -func (d *languageDetector) detectFromConfig(cfg *v1.Config) instrumentation.Type { - // Check image-level environment variables - if lang := d.detectFromImageEnv(cfg.Env); lang != "" { - return lang - } - - // Check ENTRYPOINT and CMD - if lang := d.detectFromCommand(cfg.Entrypoint, cfg.Cmd); lang != "" { - return lang - } - - return "" -} - -// detectFromImageEnv checks environment variables from the image config (string slice format: "KEY=VALUE"). -func (d *languageDetector) detectFromImageEnv(envVars []string) instrumentation.Type { - for _, env := range envVars { - parts := strings.SplitN(env, "=", 2) - if len(parts) < 2 { - continue - } - envName := strings.ToUpper(parts[0]) - envValue := strings.ToLower(parts[1]) - - if lang := d.classifyEnv(envName, envValue); lang != "" { - return lang - } - } - return "" -} // detectFromEnvVars checks environment variables from the pod spec (corev1.EnvVar format). func (d *languageDetector) detectFromEnvVars(envVars []corev1.EnvVar) instrumentation.Type { diff --git a/pkg/instrumentation/auto/language_detector_test.go b/pkg/instrumentation/auto/language_detector_test.go index e2fd6f17f..7eceb3cd6 100644 --- a/pkg/instrumentation/auto/language_detector_test.go +++ b/pkg/instrumentation/auto/language_detector_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/go-logr/logr" - v1 "github.com/google/go-containerregistry/pkg/v1" corev1 "k8s.io/api/core/v1" "github.com/aws/amazon-cloudwatch-agent-operator/pkg/instrumentation" @@ -17,233 +16,6 @@ func newTestDetector() *languageDetector { return &languageDetector{logger: logr.Discard()} } -func TestDetectFromConfig_JavaImages(t *testing.T) { - d := newTestDetector() - - tests := []struct { - name string - cfg *v1.Config - }{ - { - "openjdk with JAVA_HOME", - &v1.Config{ - Env: []string{"JAVA_HOME=/usr/lib/jvm/java-17-openjdk", "PATH=/usr/lib/jvm/java-17-openjdk/bin:/usr/bin"}, - Cmd: []string{"java", "-jar", "app.jar"}, - }, - }, - { - "corretto with JAVA_TOOL_OPTIONS", - &v1.Config{ - Env: []string{"JAVA_TOOL_OPTIONS=-javaagent:/opt/agent.jar", "JAVA_HOME=/usr/lib/jvm/java-17-amazon-corretto"}, - Entrypoint: []string{"java"}, - Cmd: []string{"-jar", "/app/service.jar"}, - }, - }, - { - "tomcat with CATALINA_HOME", - &v1.Config{ - Env: []string{"CATALINA_HOME=/opt/tomcat", "PATH=/opt/tomcat/bin:/usr/bin"}, - Entrypoint: []string{"catalina.sh"}, - Cmd: []string{"run"}, - }, - }, - { - "spring boot with JVM_OPTS", - &v1.Config{ - Env: []string{"JVM_OPTS=-Xmx512m -Xms256m"}, - Cmd: []string{"java", "-jar", "/app/spring-app.jar"}, - }, - }, - { - "java detected from entrypoint only", - &v1.Config{ - Entrypoint: []string{"java"}, - Cmd: []string{"-jar", "app.jar"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := d.detectFromConfig(tt.cfg) - if result != instrumentation.TypeJava { - t.Errorf("detectFromConfig() = %q, want %q", result, instrumentation.TypeJava) - } - }) - } -} - -func TestDetectFromConfig_PythonImages(t *testing.T) { - d := newTestDetector() - - tests := []struct { - name string - cfg *v1.Config - }{ - { - "python official image", - &v1.Config{ - Env: []string{"PYTHONPATH=/usr/local/lib/python3.11", "PYTHON_VERSION=3.11.9", "PYTHONDONTWRITEBYTECODE=1"}, - Entrypoint: []string{"python3"}, - }, - }, - { - "django with DJANGO_SETTINGS_MODULE", - &v1.Config{ - Env: []string{"DJANGO_SETTINGS_MODULE=myapp.settings", "PYTHONUNBUFFERED=1"}, - Cmd: []string{"gunicorn", "myapp.wsgi:application"}, - }, - }, - { - "flask app", - &v1.Config{ - Env: []string{"FLASK_APP=app.py"}, - Cmd: []string{"flask", "run", "--host=0.0.0.0"}, - }, - }, - { - "uvicorn from command only", - &v1.Config{ - Cmd: []string{"uvicorn", "main:app", "--host", "0.0.0.0"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := d.detectFromConfig(tt.cfg) - if result != instrumentation.TypePython { - t.Errorf("detectFromConfig() = %q, want %q", result, instrumentation.TypePython) - } - }) - } -} - -func TestDetectFromConfig_NodeImages(t *testing.T) { - d := newTestDetector() - - tests := []struct { - name string - cfg *v1.Config - }{ - { - "node official image", - &v1.Config{ - Env: []string{"NODE_VERSION=20.11.0", "NODE_ENV=production"}, - Entrypoint: []string{"docker-entrypoint.sh"}, - Cmd: []string{"node"}, - }, - }, - { - "node with NODE_OPTIONS", - &v1.Config{ - Env: []string{"NODE_OPTIONS=--max-old-space-size=4096"}, - Cmd: []string{"node", "server.js"}, - }, - }, - { - "npm start", - &v1.Config{ - Env: []string{"NPM_CONFIG_PREFIX=/home/node/.npm-global"}, - Cmd: []string{"npm", "start"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := d.detectFromConfig(tt.cfg) - if result != instrumentation.TypeNodeJS { - t.Errorf("detectFromConfig() = %q, want %q", result, instrumentation.TypeNodeJS) - } - }) - } -} - -func TestDetectFromConfig_DotNetImages(t *testing.T) { - d := newTestDetector() - - tests := []struct { - name string - cfg *v1.Config - }{ - { - "aspnet runtime", - &v1.Config{ - Env: []string{"ASPNETCORE_URLS=http://+:8080", "DOTNET_RUNNING_IN_CONTAINER=true"}, - Entrypoint: []string{"dotnet"}, - Cmd: []string{"MyApp.dll"}, - }, - }, - { - "dotnet SDK", - &v1.Config{ - Env: []string{"DOTNET_ROOT=/usr/share/dotnet", "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true"}, - Cmd: []string{"dotnet", "run"}, - }, - }, - { - "dotnet from entrypoint only", - &v1.Config{ - Entrypoint: []string{"dotnet", "MyService.dll"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := d.detectFromConfig(tt.cfg) - if result != instrumentation.TypeDotNet { - t.Errorf("detectFromConfig() = %q, want %q", result, instrumentation.TypeDotNet) - } - }) - } -} - -func TestDetectFromConfig_NoDetection(t *testing.T) { - d := newTestDetector() - - tests := []struct { - name string - cfg *v1.Config - }{ - { - "generic shell entrypoint", - &v1.Config{ - Entrypoint: []string{"/bin/sh", "-c"}, - Cmd: []string{"exec /app/start"}, - }, - }, - { - "nginx", - &v1.Config{ - Env: []string{"NGINX_VERSION=1.25.4", "PATH=/usr/sbin:/usr/bin"}, - Entrypoint: []string{"/docker-entrypoint.sh"}, - Cmd: []string{"nginx", "-g", "daemon off;"}, - }, - }, - { - "empty config", - &v1.Config{}, - }, - { - "only generic env vars", - &v1.Config{ - Env: []string{"APP_PORT=8080", "LOG_LEVEL=info", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := d.detectFromConfig(tt.cfg) - if result != "" { - t.Errorf("detectFromConfig() = %q, want empty (no detection)", result) - } - }) - } -} - func TestClassifyEnv(t *testing.T) { d := newTestDetector() @@ -392,31 +164,48 @@ func TestDetectFromEnvVars_PodSpec(t *testing.T) { } } -func TestDetectFromImageEnv(t *testing.T) { +func TestDetectContainer(t *testing.T) { d := newTestDetector() tests := []struct { - name string - env []string - expected instrumentation.Type + name string + container corev1.Container + expected instrumentation.Type }{ - {"java home", []string{"JAVA_HOME=/usr/lib/jvm/java-17"}, instrumentation.TypeJava}, - {"python version", []string{"PYTHONPATH=/usr/local/lib/python3.11"}, instrumentation.TypePython}, - {"node version", []string{"NODE_VERSION=20.11.0"}, instrumentation.TypeNodeJS}, - {"dotnet root", []string{"DOTNET_ROOT=/usr/share/dotnet"}, instrumentation.TypeDotNet}, - {"multiple envs - java first", []string{"APP_PORT=8080", "JAVA_HOME=/usr/lib/jvm"}, instrumentation.TypeJava}, - {"no signal", []string{"APP_PORT=8080", "LOG_LEVEL=info"}, ""}, - {"PYTHON_VERSION from base image", []string{"PATH=/usr/local/bin:/usr/bin", "PYTHON_VERSION=3.11.15", "PYTHON_SHA256=abc123"}, instrumentation.TypePython}, - {"NODE_VERSION from base image", []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/bin", "NODE_VERSION=20.20.2", "YARN_VERSION=1.22.22"}, instrumentation.TypeNodeJS}, - {"malformed env", []string{"NOEQUALSSIGN"}, ""}, - {"empty", []string{}, ""}, + { + name: "detected from image name", + container: corev1.Container{Image: "amazoncorretto:17"}, + expected: instrumentation.TypeJava, + }, + { + name: "detected from env var", + container: corev1.Container{ + Image: "123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest", + Env: []corev1.EnvVar{{Name: "JAVA_HOME", Value: "/usr/lib/jvm/java-17"}}, + }, + expected: instrumentation.TypeJava, + }, + { + name: "detected from command", + container: corev1.Container{ + Image: "123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest", + Command: []string{"python3"}, + Args: []string{"app.py"}, + }, + expected: instrumentation.TypePython, + }, + { + name: "opaque image, no signals", + container: corev1.Container{Image: "123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest"}, + expected: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := d.detectFromImageEnv(tt.env) + result := d.detectContainer(tt.container) if result != tt.expected { - t.Errorf("detectFromImageEnv() = %q, want %q", result, tt.expected) + t.Errorf("detectContainer() = %q, want %q", result, tt.expected) } }) } From 0b62aade44751629522fdf8bc191c8bea20d7d02 Mon Sep 17 00:00:00 2001 From: victor wang Date: Wed, 26 Aug 2026 14:57:23 -0700 Subject: [PATCH 3/3] chore: trigger ci re-run for flaky unit tests