-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
discovery: add rails service name detector #30091
base: main
Are you sure you want to change the base?
Changes from all commits
8b09070
eb3ca59
fbaf76d
e838cef
d6d1609
6b70228
16064f8
5d0c6d0
c1425fa
c87d97e
d18d420
3f545e3
3d19532
414f1be
0bf6cc5
23e9270
f5c9675
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
// Unless explicitly stated otherwise all files in this repository are licensed | ||
// under the Apache License Version 2.0. | ||
// This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
// Copyright 2024-present Datadog, Inc. | ||
|
||
package usm | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
"io" | ||
"io/fs" | ||
"regexp" | ||
"strings" | ||
|
||
"github.com/shirou/gopsutil/v3/process" | ||
|
||
"github.com/DataDog/datadog-agent/pkg/util/log" | ||
) | ||
|
||
var ( | ||
moduleRegexp = regexp.MustCompile(`module\s+([A-Z][a-zA-Z0-9_]*)`) | ||
matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") | ||
matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") | ||
) | ||
|
||
type railsDetector struct { | ||
ctx DetectionContext | ||
} | ||
|
||
func newRailsDetector(ctx DetectionContext) detector { | ||
return &railsDetector{ctx} | ||
} | ||
|
||
// detect checks if the service is a Rails application by looking for a | ||
// `config/application.rb` file generated by `rails new` when a new rails | ||
// project is created. This file should contain a `module` declaration with the | ||
// application name. | ||
func (r railsDetector) detect(_ []string) (ServiceMetadata, bool) { | ||
var proc *process.Process | ||
|
||
if procEntry, ok := r.ctx.ContextMap[ServiceProc]; ok { | ||
if p, ok := procEntry.(*process.Process); ok { | ||
proc = p | ||
} else { | ||
log.Errorf("could not get process object in rails detector: got type %T", procEntry) | ||
} | ||
} | ||
|
||
cwd, err := proc.Cwd() | ||
if err != nil { | ||
log.Debugf("could not get cwd of process: %s", err) | ||
return ServiceMetadata{}, false | ||
} | ||
|
||
absFile := abs("config/application.rb", cwd) | ||
if _, err := fs.Stat(r.ctx.fs, absFile); err != nil { | ||
return ServiceMetadata{}, false | ||
} | ||
|
||
name, err := r.findRailsApplicationName(absFile) | ||
if err != nil { | ||
log.Debugf("could not find ruby application name: %s", err) | ||
return ServiceMetadata{}, false | ||
} | ||
|
||
return NewServiceMetadata(railsUnderscore(name)), true | ||
} | ||
|
||
// findRailsApplicationName scans the `config/application.rb` file to find the | ||
// Rails application name. | ||
func (r railsDetector) findRailsApplicationName(filename string) (string, error) { | ||
file, err := r.ctx.fs.Open(filename) | ||
if err != nil { | ||
return "", fmt.Errorf("could not open application.rb: %w", err) | ||
} | ||
defer file.Close() | ||
|
||
reader, err := SizeVerifiedReader(file) | ||
if err != nil { | ||
return "", fmt.Errorf("skipping application.rb (%q): %w", filename, err) | ||
} | ||
|
||
bytes, err := io.ReadAll(reader) | ||
yuri-lipnesh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if err != nil { | ||
return "", fmt.Errorf("unable to read application.rb (%q): %w", filename, err) | ||
} | ||
|
||
matches := moduleRegexp.FindSubmatch(bytes) | ||
if len(matches) < 2 { | ||
// No match found | ||
return "", errors.New("could not find Ruby module name") | ||
} | ||
|
||
return string(matches[1]), nil | ||
} | ||
|
||
// railsUnderscore converts a PascalCasedWord to a snake_cased_word. | ||
// It keeps uppercase acronyms together when converting (e.g. "HTTPServer" -> "http_server"). | ||
func railsUnderscore(pascalCasedWord string) string { | ||
snake := matchFirstCap.ReplaceAllString(pascalCasedWord, "${1}_${2}") | ||
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") | ||
Comment on lines
+101
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. operations on strings are expensive, especially when regex is involved There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I looked a bit into this, and AFAICT, doing it in one call to |
||
return strings.ToLower(snake) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
// Unless explicitly stated otherwise all files in this repository are licensed | ||
// under the Apache License Version 2.0. | ||
// This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
// Copyright 2024-present Datadog, Inc. | ||
|
||
//go:build linux | ||
|
||
package usm | ||
|
||
import ( | ||
"path/filepath" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestGenerateNameFromRailsApplicationRb(t *testing.T) { | ||
tests := []struct { | ||
name string | ||
path string | ||
expected string | ||
}{ | ||
{ | ||
name: "name is found", | ||
path: "./testdata/application.rb", | ||
expected: "rails_hello", | ||
}, | ||
{ | ||
name: "name not found", | ||
path: "./testdata/application_invalid.rb", | ||
expected: "", | ||
}, | ||
Comment on lines
+24
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as my other comment suggested, let's work on the test cases to gain full coverage of the function |
||
} | ||
full, err := filepath.Abs("testdata/root") | ||
require.NoError(t, err) | ||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
instance := &railsDetector{ctx: DetectionContext{ | ||
fs: NewSubDirFS(full), | ||
contextMap: make(DetectorContextMap), | ||
}} | ||
value, ok := instance.findRailsApplicationName(tt.path) | ||
assert.Equal(t, len(tt.expected) > 0, ok) | ||
assert.Equal(t, tt.expected, railsUnderscore(value)) | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
require_relative "boot" | ||
|
||
require "rails" | ||
# Pick the frameworks you want: | ||
require "active_model/railtie" | ||
# require "active_job/railtie" | ||
require "active_record/railtie" | ||
# require "active_storage/engine" | ||
require "action_controller/railtie" | ||
# require "action_mailer/railtie" | ||
# require "action_mailbox/engine" | ||
# require "action_text/engine" | ||
require "action_view/railtie" | ||
# require "action_cable/engine" | ||
require "sprockets/railtie" | ||
require "rails/test_unit/railtie" | ||
|
||
# Require the gems listed in Gemfile, including any gems | ||
# you've limited to :test, :development, or :production. | ||
Bundler.require(*Rails.groups) | ||
|
||
module RailsHello | ||
class Application < Rails::Application | ||
# Initialize configuration defaults for originally generated Rails version. | ||
config.load_defaults 6.1 | ||
|
||
# Configuration for the application, engines, and railties goes here. | ||
# | ||
# These settings can be overridden in specific environments using the files | ||
# in config/environments, which are processed later. | ||
# | ||
# config.time_zone = "Central Time (US & Canada)" | ||
# config.eager_load_paths << Rails.root.join("extras") | ||
|
||
# Don't generate system test files. | ||
config.generators.system_tests = nil | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
class SomeRubyClass | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is it necessary to call "abs"? the first parameter is hard-coded and it is not absolute path.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded part is considered relative to the cwd. We use
abs
to make it a absolute path by concatenating it to the cwd.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I meant you can path.Join(cwd, "config/application.rb") without calling abs(), but is up to you.