Skip to content

Commit e26033e

Browse files
authored
feat: support glob pattern matching in collect-on-failure paths (#142)
- Add shell glob pattern support (*, ?, [...]) for paths in collect-on-failure config - Patterns are expanded inside the container/pod via sh -c 'ls -d -- <pattern> 2>/dev/null || true' before copying - Non-glob paths behave exactly as before
1 parent 36221fa commit e26033e

File tree

3 files changed

+204
-2
lines changed

3 files changed

+204
-2
lines changed

internal/components/collector/collector_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,76 @@ func TestListPods_ResourceFormat(t *testing.T) {
122122
}
123123
}
124124

125+
func TestContainsGlob(t *testing.T) {
126+
tests := []struct {
127+
path string
128+
want bool
129+
}{
130+
{"/skywalking/logs/", false},
131+
{"/tmp/dump.hprof", false},
132+
{"/tmp/app[1].log", true}, // [1] is a valid shell character class
133+
{"/tmp/app[].log", false}, // [] is not a valid character class
134+
{"/skywalking/logs*", true},
135+
{"/tmp/*.hprof", true},
136+
{"/tmp/dump-[0-9].hprof", true},
137+
{"/var/log/?oo", true},
138+
}
139+
for _, tt := range tests {
140+
t.Run(tt.path, func(t *testing.T) {
141+
if got := containsGlob(tt.path); got != tt.want {
142+
t.Errorf("containsGlob(%q) = %v, want %v", tt.path, got, tt.want)
143+
}
144+
})
145+
}
146+
}
147+
148+
func TestValidateGlobPattern(t *testing.T) {
149+
tests := []struct {
150+
pattern string
151+
wantErr bool
152+
}{
153+
{"/skywalking/logs*", false},
154+
{"/tmp/*.hprof", false},
155+
{"/tmp/dump-[0-9].hprof", false},
156+
{"/var/log/app-?.log", false},
157+
{"'; rm -rf /; '", true},
158+
{"/path with spaces/*", true},
159+
{"/tmp/$(whoami)", true},
160+
{"/tmp/`id`", true},
161+
{"/tmp/foo|bar", true},
162+
{"/tmp/foo;bar", true},
163+
{"/tmp/foo&bar", true},
164+
}
165+
for _, tt := range tests {
166+
t.Run(tt.pattern, func(t *testing.T) {
167+
err := validateGlobPattern(tt.pattern)
168+
if (err != nil) != tt.wantErr {
169+
t.Errorf("validateGlobPattern(%q) error = %v, wantErr %v", tt.pattern, err, tt.wantErr)
170+
}
171+
})
172+
}
173+
}
174+
175+
func TestExpandPodGlob_NoGlob(t *testing.T) {
176+
paths, err := expandPodGlob("", "default", "pod-0", "", "/skywalking/logs/")
177+
if err != nil {
178+
t.Fatalf("unexpected error: %v", err)
179+
}
180+
if len(paths) != 1 || paths[0] != "/skywalking/logs/" {
181+
t.Errorf("expected [/skywalking/logs/], got %v", paths)
182+
}
183+
}
184+
185+
func TestExpandContainerGlob_NoGlob(t *testing.T) {
186+
paths, err := expandContainerGlob("abc123", "svc", "/var/log/app.log")
187+
if err != nil {
188+
t.Fatalf("unexpected error: %v", err)
189+
}
190+
if len(paths) != 1 || paths[0] != "/var/log/app.log" {
191+
t.Errorf("expected [/var/log/app.log], got %v", paths)
192+
}
193+
}
194+
125195
func TestComposeCollectItem_NoService(t *testing.T) {
126196
err := composeCollectItem("/fake/compose.yml", "test-project", t.TempDir(), &config.CollectItem{
127197
Paths: []string{"/tmp"},

internal/components/collector/compose.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,15 @@ func composeCollectItem(composeFile, projectName, outputDir string, item *config
7575
// Collect specified files
7676
var errs []string
7777
for _, p := range item.Paths {
78-
if err := collectContainerFile(outputDir, item.Service, containerID, p); err != nil {
78+
paths, err := expandContainerGlob(containerID, item.Service, p)
79+
if err != nil {
7980
errs = append(errs, fmt.Sprintf("service %s path %s: %v", item.Service, p, err))
81+
continue
82+
}
83+
for _, expanded := range paths {
84+
if err := collectContainerFile(outputDir, item.Service, containerID, expanded); err != nil {
85+
errs = append(errs, fmt.Sprintf("service %s path %s: %v", item.Service, expanded, err))
86+
}
8087
}
8188
}
8289

@@ -123,6 +130,41 @@ func collectContainerInspect(outputDir, service, containerID string) error {
123130
return nil
124131
}
125132

133+
// expandContainerGlob expands a glob pattern inside a Docker container.
134+
// If the path has no glob characters it is returned as-is.
135+
func expandContainerGlob(containerID, service, pattern string) ([]string, error) {
136+
if !containsGlob(pattern) {
137+
return []string{pattern}, nil
138+
}
139+
140+
if err := validateGlobPattern(pattern); err != nil {
141+
return nil, err
142+
}
143+
144+
cmd := fmt.Sprintf("docker exec %s sh -c 'ls -d -- %s 2>/dev/null || true'", containerID, pattern)
145+
stdout, stderr, err := util.ExecuteCommand(cmd)
146+
if err != nil {
147+
logger.Log.Warnf("failed to expand glob %s in service %s: %v, stderr: %s", pattern, service, err, stderr)
148+
return nil, fmt.Errorf("glob expansion failed for %s: %v, stderr: %s", pattern, err, stderr)
149+
}
150+
151+
var paths []string
152+
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
153+
line = strings.TrimSpace(line)
154+
if line != "" {
155+
paths = append(paths, line)
156+
}
157+
}
158+
159+
if len(paths) == 0 {
160+
logger.Log.Warnf("glob %s matched no files in service %s", pattern, service)
161+
return nil, fmt.Errorf("glob %s matched no files", pattern)
162+
}
163+
164+
logger.Log.Infof("glob %s expanded to %d path(s) in service %s", pattern, len(paths), service)
165+
return paths, nil
166+
}
167+
126168
func collectContainerFile(outputDir, service, containerID, srcPath string) error {
127169
// Preserve the full source path under the service directory to avoid collisions.
128170
// e.g. /var/log/nginx/ -> outputDir/serviceName/var/log/nginx/

internal/components/collector/kind.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"os"
2323
"path/filepath"
24+
"regexp"
2425
"strings"
2526

2627
"github.com/apache/skywalking-infra-e2e/internal/config"
@@ -74,8 +75,15 @@ func kindCollectItem(kubeConfigPath, outputDir string, item *config.CollectItem)
7475

7576
// Collect specified files
7677
for _, p := range item.Paths {
77-
if err := collectPodFile(kubeConfigPath, outputDir, pod.namespace, pod.name, item.Container, p); err != nil {
78+
paths, err := expandPodGlob(kubeConfigPath, pod.namespace, pod.name, item.Container, p)
79+
if err != nil {
7880
errs = append(errs, fmt.Sprintf("pod %s/%s path %s: %v", pod.namespace, pod.name, p, err))
81+
continue
82+
}
83+
for _, expanded := range paths {
84+
if err := collectPodFile(kubeConfigPath, outputDir, pod.namespace, pod.name, item.Container, expanded); err != nil {
85+
errs = append(errs, fmt.Sprintf("pod %s/%s path %s: %v", pod.namespace, pod.name, expanded, err))
86+
}
7987
}
8088
}
8189
}
@@ -151,6 +159,88 @@ func collectPodDescribe(kubeConfigPath, outputDir, namespace, podName string) er
151159
return nil
152160
}
153161

162+
// containsGlob reports whether the path contains glob metacharacters.
163+
func containsGlob(path string) bool {
164+
if strings.ContainsAny(path, "*?") {
165+
return true
166+
}
167+
// Only treat '[' as a glob when followed by a matching ']' with at least
168+
// one character between them, so literal brackets (e.g. "app[1].log")
169+
// that don't form a valid character class are not misidentified.
170+
for i := 0; i < len(path); i++ {
171+
if path[i] != '[' {
172+
continue
173+
}
174+
for j := i + 1; j < len(path); j++ {
175+
if path[j] != ']' {
176+
continue
177+
}
178+
// Check there is at least one non-']' char between '[' and ']'.
179+
for k := i + 1; k < j; k++ {
180+
if path[k] != ']' {
181+
return true
182+
}
183+
}
184+
break
185+
}
186+
}
187+
return false
188+
}
189+
190+
// validPathPattern matches paths that contain only safe characters for shell interpolation.
191+
// Allowed: alphanumeric, /, ., -, _, *, ?, [, ].
192+
var validPathPattern = regexp.MustCompile(`^[a-zA-Z0-9/_.*?\[\]\-]+$`)
193+
194+
// validateGlobPattern checks that a glob pattern contains only safe characters
195+
// to prevent shell injection when interpolated into sh -c commands.
196+
func validateGlobPattern(pattern string) error {
197+
if !validPathPattern.MatchString(pattern) {
198+
return fmt.Errorf("glob pattern %q contains unsupported characters", pattern)
199+
}
200+
return nil
201+
}
202+
203+
// expandPodGlob expands a glob pattern inside a pod. If the path has no glob
204+
// characters it is returned as-is. Otherwise kubectl exec runs sh to expand
205+
// the pattern and returns the matched paths.
206+
func expandPodGlob(kubeConfigPath, namespace, podName, container, pattern string) ([]string, error) {
207+
if !containsGlob(pattern) {
208+
return []string{pattern}, nil
209+
}
210+
211+
if err := validateGlobPattern(pattern); err != nil {
212+
return nil, err
213+
}
214+
215+
cmd := fmt.Sprintf("kubectl --kubeconfig %s -n %s exec %s", kubeConfigPath, namespace, podName)
216+
if container != "" {
217+
cmd += fmt.Sprintf(" -c %s", container)
218+
}
219+
cmd += fmt.Sprintf(" -- sh -c 'ls -d -- %s 2>/dev/null || true'", pattern)
220+
221+
stdout, stderr, err := util.ExecuteCommand(cmd)
222+
if err != nil {
223+
logger.Log.Warnf("failed to expand glob %s in pod %s/%s: %v, stderr: %s", pattern, namespace, podName, err, stderr)
224+
return nil, fmt.Errorf("glob expansion failed for %s: %v, stderr: %s", pattern, err, stderr)
225+
}
226+
227+
var paths []string
228+
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
229+
line = strings.TrimSpace(line)
230+
if line != "" {
231+
paths = append(paths, line)
232+
}
233+
}
234+
235+
if len(paths) == 0 {
236+
logger.Log.Warnf("glob %s matched no files in pod %s/%s", pattern, namespace, podName)
237+
return nil, fmt.Errorf("glob %s matched no files", pattern)
238+
}
239+
240+
logger.Log.Infof("glob %s expanded to %d path(s) in pod %s/%s", pattern, len(paths), namespace, podName)
241+
return paths, nil
242+
}
243+
154244
func collectPodFile(kubeConfigPath, outputDir, namespace, podName, container, srcPath string) error {
155245
// Preserve the full source path under the pod directory to avoid collisions.
156246
// e.g. /skywalking/logs/ -> outputDir/namespace/podName/skywalking/logs/

0 commit comments

Comments
 (0)