diff --git a/docs/framework-cf_metrics_exporter.md b/docs/framework-cf_metrics_exporter.md new file mode 100644 index 000000000..3d2d44b46 --- /dev/null +++ b/docs/framework-cf_metrics_exporter.md @@ -0,0 +1,40 @@ +# cf-metrics-exporter (Agent Mode) + +This framework integrates the [cf-metrics-exporter](https://github.com/rabobank/cf-metrics-exporter) as a Java agent in the Java buildpack. + +## Enabling the Exporter + +Set the following environment variable in the cloud foundry env to enable the agent (via manifest.yml or `cf set-env`): + +``` +CF_METRICS_EXPORTER_ENABLED=true +``` + +## Configuration + +- **CF_METRICS_EXPORTER_ENABLED**: Set to `true` to enable the agent (default: disabled). +- **CF_METRICS_EXPORTER_PROPS**: (Optional) Properties string to pass to the agent, e.g. `enableLogEmitter,rpsType=tomcat-bean`. + +## How it Works + +- The agent JAR is downloaded during the buildpack supply phase. +- The agent is injected into the JVM at runtime using the `-javaagent` option. +- If `CF_METRICS_EXPORTER_PROPS` is set, its value is appended to the `-javaagent` option. + +## Example + +``` +CF_METRICS_EXPORTER_ENABLED=true +CF_METRICS_EXPORTER_PROPS="enableLogEmitter,rpsType=tomcat-bean" +``` + +## Version + +- Default version: 0.7.1 +- Default download URI: https://github.com/rabobank/cf-metrics-exporter/releases/download/0.7.1/cf-metrics-exporter-0.7.1.jar + +## Notes + +- The agent is injected with priority 43 in JAVA_OPTS (after other APM agents). + + diff --git a/manifest.yml b/manifest.yml index 482033813..1aecc2d5c 100644 --- a/manifest.yml +++ b/manifest.yml @@ -80,6 +80,8 @@ default_versions: version: 7.x - name: newrelic version: 8.x +- name: cf-metrics-exporter + version: 0.7.x url_to_dependency_map: - match: openjdk-jre-(\d+\.\d+\.\d+) @@ -544,6 +546,14 @@ dependencies: cf_stacks: - cflinuxfs4 +# cf-metrics-exporter Agent +- name: cf-metrics-exporter + version: 0.7.1 + uri: https://repo1.maven.org/maven2/io/github/rabobank/cf-metrics-exporter/0.7.1/cf-metrics-exporter-0.7.1.jar + sha256: 7ebabd3ffd812082cf92a513c8d2ac52906f5b42cd952cbe740bd5d5b086e79b + cf_stacks: + - cflinuxfs4 + # Container Security Provider # Note: Always enabled by default, provides container-specific security context - name: container-security-provider diff --git a/src/java/finalize/finalize.go b/src/java/finalize/finalize.go index 00117cae4..551bb8e04 100644 --- a/src/java/finalize/finalize.go +++ b/src/java/finalize/finalize.go @@ -2,9 +2,11 @@ package finalize import ( "fmt" - "github.com/cloudfoundry/java-buildpack/src/java/common" "os" "path/filepath" + "strings" + + "github.com/cloudfoundry/java-buildpack/src/java/common" "github.com/cloudfoundry/java-buildpack/src/java/containers" "github.com/cloudfoundry/java-buildpack/src/java/frameworks" @@ -157,7 +159,7 @@ func (f *Finalizer) finalizeFrameworks() error { return nil } - f.Log.Info("Finalizing frameworks: %v", frameworkNames) + f.Log.Info("Finalizing frameworks: %v", strings.Join(frameworkNames, ",")) // Finalize all detected frameworks for i, framework := range detectedFrameworks { diff --git a/src/java/frameworks/cf_metrics_exporter.go b/src/java/frameworks/cf_metrics_exporter.go new file mode 100644 index 000000000..7222727d3 --- /dev/null +++ b/src/java/frameworks/cf_metrics_exporter.go @@ -0,0 +1,126 @@ +package frameworks + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/cloudfoundry/java-buildpack/src/java/common" + "github.com/cloudfoundry/libbuildpack" +) + +const cfMetricsExporterDependencyName = "cf-metrics-exporter" +const cfMetricsExporterDirName = "cf_metrics_exporter" + +// Installer interface for dependency installation +// Allows for mocking in tests +// Only the InstallDependency method is needed for this framework +// (matches the signature of libbuildpack.Installer) +type Installer interface { + InstallDependency(dep libbuildpack.Dependency, outputDir string) error +} + +type CfMetricsExporterFramework struct { + context *common.Context + installer Installer +} + +func NewCfMetricsExporterFramework(ctx *common.Context) *CfMetricsExporterFramework { + installer := ctx.Installer + if installer == nil { + installer = libbuildpack.NewInstaller(ctx.Manifest) + } + return &CfMetricsExporterFramework{context: ctx, installer: installer} +} + +func (f *CfMetricsExporterFramework) Detect() (string, error) { + enabled := os.Getenv("CF_METRICS_EXPORTER_ENABLED") + if enabled == "true" || enabled == "TRUE" { + _, err := f.context.Manifest.DefaultVersion(cfMetricsExporterDependencyName) + if err != nil { + return "", fmt.Errorf("cf-metrics-exporter version not found in manifest: %w", err) + } + return "CF Metrics Exporter", nil + } + return "", nil +} + +func (f *CfMetricsExporterFramework) getManifestDependency() (libbuildpack.Dependency, *libbuildpack.ManifestEntry, error) { + dep, err := f.context.Manifest.DefaultVersion(cfMetricsExporterDependencyName) + if err != nil { + return libbuildpack.Dependency{}, nil, fmt.Errorf("cf-metrics-exporter version not found in manifest: %w", err) + } + entry, err := f.context.Manifest.GetEntry(dep) + if err != nil { + return dep, nil, fmt.Errorf("cf-metrics-exporter manifest entry not found: %w", err) + } + return dep, entry, nil +} + +func (f *CfMetricsExporterFramework) Supply() error { + enabled := os.Getenv("CF_METRICS_EXPORTER_ENABLED") + if enabled != "true" && enabled != "TRUE" { + return nil + } + + dep, _, err := f.getManifestDependency() + if err != nil { + return err + } + + agentDir := filepath.Join(f.context.Stager.DepDir(), cfMetricsExporterDirName) + jarName := fmt.Sprintf("cf-metrics-exporter-%s.jar", dep.Version) + jarPath := filepath.Join(agentDir, jarName) + + // Ensure agent directory exists + if err := os.MkdirAll(agentDir, 0755); err != nil { + return fmt.Errorf("failed to create agent dir: %w", err) + } + + // Download the JAR if not present + if _, err := os.Stat(jarPath); os.IsNotExist(err) { + if err := f.installer.InstallDependency(dep, agentDir); err != nil { + return fmt.Errorf("failed to download cf-metrics-exporter: %w", err) + } + if _, err := os.Stat(jarPath); err != nil { + return fmt.Errorf("expected jar file not found after download: %w", err) + } + } + + // Log activation, including properties if set + props := os.Getenv("CF_METRICS_EXPORTER_PROPS") + if props != "" { + f.context.Log.Info("CF Metrics Exporter v%s enabled, with properties: %s", dep.Version, props) + } else { + f.context.Log.Info("CF Metrics Exporter v%s enabled", dep.Version) + } + + return nil +} + +func (f *CfMetricsExporterFramework) Finalize() error { + enabled := os.Getenv("CF_METRICS_EXPORTER_ENABLED") + if enabled != "true" && enabled != "TRUE" { + return nil + } + + dep, _, err := f.getManifestDependency() + if err != nil { + return err + } + + jarName := fmt.Sprintf("cf-metrics-exporter-%s.jar", dep.Version) + depsIdx := f.context.Stager.DepsIdx() + agentPath := fmt.Sprintf("$DEPS_DIR/%s/cf_metrics_exporter/%s", depsIdx, jarName) + + props := os.Getenv("CF_METRICS_EXPORTER_PROPS") + var javaOpt string + if props != "" { + javaOpt = fmt.Sprintf("-javaagent:%s=%s", agentPath, props) + } else { + javaOpt = fmt.Sprintf("-javaagent:%s", agentPath) + } + + // Priority 43: after SkyWalking (41), Splunk OTEL (42) + return writeJavaOptsFile(f.context, 43, cfMetricsExporterDirName, javaOpt) +} diff --git a/src/java/frameworks/cf_metrics_exporter_test.go b/src/java/frameworks/cf_metrics_exporter_test.go new file mode 100644 index 000000000..a1057aff3 --- /dev/null +++ b/src/java/frameworks/cf_metrics_exporter_test.go @@ -0,0 +1,190 @@ +package frameworks + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cloudfoundry/java-buildpack/src/java/common" + "github.com/cloudfoundry/libbuildpack" +) + +// Helper functions for test setup +func setEnvVars(t *testing.T, vars map[string]string) { + for k, v := range vars { + if err := os.Setenv(k, v); err != nil { + t.Fatalf("Setenv %s failed: %v", k, err) + } + } +} + +func unsetEnvVars(t *testing.T, vars []string) { + for _, k := range vars { + if err := os.Unsetenv(k); err != nil { + t.Fatalf("Unsetenv %s failed: %v", k, err) + } + } +} + +func loadManifest(t *testing.T) *libbuildpack.Manifest { + manifestDir := filepath.Join("../../../") + logger := libbuildpack.NewLogger(os.Stdout) + manifest, err := libbuildpack.NewManifest(manifestDir, logger, time.Now()) + if err != nil { + t.Fatalf("Failed to load manifest.yml: %v", err) + } + return manifest +} + +func TestDetectEnabledWithRealManifest(t *testing.T) { + setEnvVars(t, map[string]string{ + "CF_METRICS_EXPORTER_ENABLED": "true", + "CF_STACK": "cflinuxfs4", + }) + defer unsetEnvVars(t, []string{"CF_METRICS_EXPORTER_ENABLED", "CF_STACK"}) + + manifest := loadManifest(t) + ctx := &common.Context{Manifest: manifest} + ctx.Log = libbuildpack.NewLogger(os.Stdout) + f := NewCfMetricsExporterFramework(ctx) + + name, err := f.Detect() + if err != nil { + t.Fatalf("Detect() error: %v", err) + } + if name == "" { + t.Error("Detect() should return non-empty name when enabled") + } +} + +func TestDetectDisabledWithRealManifest(t *testing.T) { + setEnvVars(t, map[string]string{"CF_METRICS_EXPORTER_ENABLED": "false"}) + defer unsetEnvVars(t, []string{"CF_METRICS_EXPORTER_ENABLED"}) + + manifest := loadManifest(t) + ctx := &common.Context{Manifest: manifest} + ctx.Log = libbuildpack.NewLogger(os.Stdout) + f := NewCfMetricsExporterFramework(ctx) + + name, err := f.Detect() + if err != nil { + t.Fatalf("Detect() error: %v", err) + } + if name != "" { + t.Error("Detect() should return empty name when disabled") + } +} + +func TestSupplyPlacesJarCorrectly(t *testing.T) { + setEnvVars(t, map[string]string{ + "CF_METRICS_EXPORTER_ENABLED": "true", + "CF_STACK": "cflinuxfs4", + }) + defer unsetEnvVars(t, []string{"CF_METRICS_EXPORTER_ENABLED", "CF_STACK"}) + + manifest := loadManifest(t) + // Setup temp dependency dir + tmpDepDir, err := os.MkdirTemp("", "cf_metrics_exporter_test") + if err != nil { + t.Fatalf("Failed to create temp dep dir: %v", err) + } + defer func() { + _ = os.RemoveAll(tmpDepDir) + }() + + args := []string{"", "", tmpDepDir, "0"} + ctx := &common.Context{Manifest: manifest} + ctx.Stager = libbuildpack.NewStager(args, libbuildpack.NewLogger(os.Stdout), manifest) + ctx.Log = libbuildpack.NewLogger(os.Stdout) + + // Pre-create the expected JAR file + jarName := "cf-metrics-exporter-0.7.1.jar" // adjust if version changes in manifest + jarDir := filepath.Join(tmpDepDir, "cf_metrics_exporter") + if err := os.MkdirAll(jarDir, 0755); err != nil { + t.Fatalf("Failed to create jar dir: %v", err) + } + jarPath := filepath.Join(jarDir, jarName) + fJar, err := os.Create(jarPath) + if err != nil { + t.Fatalf("Failed to create jar file: %v", err) + } + if err := fJar.Close(); err != nil { + t.Fatalf("Failed to close jar file: %v", err) + } + + f := NewCfMetricsExporterFramework(ctx) + + if err := f.Supply(); err != nil { + t.Fatalf("Supply() error: %v", err) + } + + // Assert JAR file exists directly in cf_metrics_exporter + if fi, err := os.Stat(jarPath); err != nil { + t.Errorf("JAR file not found at expected path: %s, error: %v", jarPath, err) + } else if fi.IsDir() { + t.Errorf("Expected file but found directory at: %s", jarPath) + } +} + +func TestSupplyLogsProps(t *testing.T) { + setEnvVars(t, map[string]string{ + "CF_METRICS_EXPORTER_ENABLED": "true", + "CF_STACK": "cflinuxfs4", + "CF_METRICS_EXPORTER_PROPS": "foo=bar,abc=123", + }) + defer unsetEnvVars(t, []string{"CF_METRICS_EXPORTER_ENABLED", "CF_STACK", "CF_METRICS_EXPORTER_PROPS"}) + + manifest := loadManifest(t) + tmpDepDir, err := os.MkdirTemp("", "cf_metrics_exporter_test_props") + if err != nil { + t.Fatalf("Failed to create temp dep dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDepDir) }() + + args := []string{"", "", tmpDepDir, "0"} + ctx := &common.Context{Manifest: manifest} + ctx.Stager = libbuildpack.NewStager(args, libbuildpack.NewLogger(os.Stdout), manifest) + + // Pre-create the expected JAR file + jarName := "cf-metrics-exporter-0.7.1.jar" + jarDir := filepath.Join(tmpDepDir, "cf_metrics_exporter") + if err := os.MkdirAll(jarDir, 0755); err != nil { + t.Fatalf("Failed to create jar dir: %v", err) + } + jarPath := filepath.Join(jarDir, jarName) + fJar, err := os.Create(jarPath) + if err != nil { + t.Fatalf("Failed to create jar file: %v", err) + } + if err := fJar.Close(); err != nil { + t.Fatalf("Failed to close jar file: %v", err) + } + + // Capture log output + logBuf := &logBuffer{} + ctx.Log = libbuildpack.NewLogger(logBuf) + + f := NewCfMetricsExporterFramework(ctx) + if err := f.Supply(); err != nil { + t.Fatalf("Supply() error: %v", err) + } + + if got := logBuf.String(); !strings.Contains(got, "foo=bar,abc=123") { + t.Errorf("Expected log to contain CF_METRICS_EXPORTER_PROPS value, got: %s", got) + } +} + +type logBuffer struct { + buf []byte +} + +func (l *logBuffer) Write(p []byte) (n int, err error) { + l.buf = append(l.buf, p...) + return len(p), nil +} + +func (l *logBuffer) String() string { + return string(l.buf) +} diff --git a/src/java/frameworks/framework.go b/src/java/frameworks/framework.go index 77e4173ed..eb99d62c0 100644 --- a/src/java/frameworks/framework.go +++ b/src/java/frameworks/framework.go @@ -73,6 +73,8 @@ func (r *Registry) RegisterStandardFrameworks() { // Metrics & Observability (Priority 1) r.Register(NewMetricWriterFramework(r.context)) + // Register cf-metrics-exporter agent (agent mode) + r.Register(NewCfMetricsExporterFramework(r.context)) // Development Tools (Priority 1) r.Register(NewDebugFramework(r.context))