Skip to content

Commit cf09c89

Browse files
committed
add chainlink admin profile dump
1 parent b33395a commit cf09c89

4 files changed

Lines changed: 278 additions & 2 deletions

File tree

framework/.changeset/v0.15.16.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
- Dump two memory profiles: inuse and alloc
2+
- Dump LOOPs profiles via admin command

framework/docker.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"io"
1111
"os"
1212
"os/exec"
13+
"path"
1314
"path/filepath"
1415
"regexp"
1516
"strings"
@@ -186,6 +187,47 @@ func (dc *DockerClient) CopyFile(containerName, sourceFile, targetPath string) e
186187
return dc.copyToContainer(containerID, sourceFile, targetPath)
187188
}
188189

190+
// CopyFromContainer copies files from a container path and returns a tar archive stream.
191+
func (dc *DockerClient) CopyFromContainer(containerName, sourcePath string) (io.ReadCloser, container.PathStat, error) {
192+
return dc.CopyFromContainerWithContext(context.Background(), containerName, sourcePath)
193+
}
194+
195+
// CopyFromContainerWithContext copies files from a container path and returns a tar archive stream.
196+
func (dc *DockerClient) CopyFromContainerWithContext(ctx context.Context, containerName, sourcePath string) (io.ReadCloser, container.PathStat, error) {
197+
containerID, err := dc.findContainerIDByName(ctx, containerName)
198+
if err != nil {
199+
return nil, container.PathStat{}, fmt.Errorf("failed to find container ID by name: %s", containerName)
200+
}
201+
reader, stat, err := dc.cli.CopyFromContainer(ctx, containerID, sourcePath)
202+
if err != nil {
203+
return nil, container.PathStat{}, fmt.Errorf("could not copy from container %s path %s: %w", containerName, sourcePath, err)
204+
}
205+
return reader, stat, nil
206+
}
207+
208+
// CopyFromContainerToHost copies files from a container path and extracts them into hostDir.
209+
// If sourcePath points to a directory, only its contents are placed inside hostDir.
210+
func (dc *DockerClient) CopyFromContainerToHost(containerName, sourcePath, hostDir string) error {
211+
return dc.CopyFromContainerToHostWithContext(context.Background(), containerName, sourcePath, hostDir)
212+
}
213+
214+
// CopyFromContainerToHostWithContext copies files from a container path and extracts them into hostDir.
215+
// If sourcePath points to a directory, only its contents are placed inside hostDir.
216+
func (dc *DockerClient) CopyFromContainerToHostWithContext(ctx context.Context, containerName, sourcePath, hostDir string) error {
217+
reader, _, err := dc.CopyFromContainerWithContext(ctx, containerName, sourcePath)
218+
if err != nil {
219+
return err
220+
}
221+
defer reader.Close()
222+
223+
if err := os.MkdirAll(hostDir, 0o755); err != nil {
224+
return fmt.Errorf("failed to create host destination directory %s: %w", hostDir, err)
225+
}
226+
227+
stripTopDir := path.Base(path.Clean(sourcePath))
228+
return extractTarArchiveToHostDir(reader, hostDir, stripTopDir)
229+
}
230+
189231
// findContainerIDByName finds a container ID by its name
190232
func (dc *DockerClient) findContainerIDByName(ctx context.Context, containerName string) (string, error) {
191233
containers, err := dc.cli.ContainerList(ctx, container.ListOptions{
@@ -245,6 +287,89 @@ func (dc *DockerClient) copyToContainer(containerID, sourceFile, targetPath stri
245287
return nil
246288
}
247289

290+
func extractTarArchiveToHostDir(reader io.Reader, hostDir, stripTopDir string) error {
291+
tarReader := tar.NewReader(reader)
292+
for {
293+
header, err := tarReader.Next()
294+
if err == io.EOF {
295+
return nil
296+
}
297+
if err != nil {
298+
return fmt.Errorf("failed reading tar stream: %w", err)
299+
}
300+
301+
relativePath, ok := normalizeTarEntryPath(header.Name, stripTopDir)
302+
if !ok {
303+
continue
304+
}
305+
targetPath := filepath.Join(hostDir, filepath.FromSlash(relativePath))
306+
if err := ensureSubpath(hostDir, targetPath); err != nil {
307+
return err
308+
}
309+
310+
switch header.Typeflag {
311+
case tar.TypeDir:
312+
if err := os.MkdirAll(targetPath, 0o755); err != nil {
313+
return fmt.Errorf("failed to create dir %s: %w", targetPath, err)
314+
}
315+
case tar.TypeReg, tar.TypeRegA:
316+
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
317+
return fmt.Errorf("failed to create parent dir for %s: %w", targetPath, err)
318+
}
319+
file, createErr := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
320+
if createErr != nil {
321+
return fmt.Errorf("failed to create file %s: %w", targetPath, createErr)
322+
}
323+
if _, copyErr := io.Copy(file, tarReader); copyErr != nil {
324+
_ = file.Close()
325+
return fmt.Errorf("failed to write file %s: %w", targetPath, copyErr)
326+
}
327+
if closeErr := file.Close(); closeErr != nil {
328+
return fmt.Errorf("failed to close file %s: %w", targetPath, closeErr)
329+
}
330+
}
331+
}
332+
}
333+
334+
func normalizeTarEntryPath(entryName, stripTopDir string) (string, bool) {
335+
normalized := strings.TrimPrefix(entryName, "./")
336+
normalized = path.Clean(normalized)
337+
if normalized == "." || normalized == "/" {
338+
return "", false
339+
}
340+
341+
if stripTopDir != "" {
342+
stripTopDir = path.Clean(stripTopDir)
343+
if normalized == stripTopDir {
344+
return "", false
345+
}
346+
prefix := stripTopDir + "/"
347+
normalized = strings.TrimPrefix(normalized, prefix)
348+
}
349+
350+
normalized = strings.TrimPrefix(normalized, "/")
351+
if normalized == "" {
352+
return "", false
353+
}
354+
return normalized, true
355+
}
356+
357+
func ensureSubpath(baseDir, targetPath string) error {
358+
baseAbs, err := filepath.Abs(baseDir)
359+
if err != nil {
360+
return fmt.Errorf("failed to resolve absolute path for %s: %w", baseDir, err)
361+
}
362+
targetAbs, err := filepath.Abs(targetPath)
363+
if err != nil {
364+
return fmt.Errorf("failed to resolve absolute path for %s: %w", targetPath, err)
365+
}
366+
prefix := baseAbs + string(filepath.Separator)
367+
if targetAbs != baseAbs && !strings.HasPrefix(targetAbs, prefix) {
368+
return fmt.Errorf("unsafe path detected outside destination: %s", targetPath)
369+
}
370+
return nil
371+
}
372+
248373
// SearchLogFile searches logfile using regex and return matches or error
249374
func SearchLogFile(fp string, regex string) ([]string, error) {
250375
file, err := os.Open(fp)

framework/leak/detector_cl_node.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package leak
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"strconv"
@@ -243,10 +244,11 @@ func (cd *CLNodesLeakDetector) Check(t *CLNodesCheck) error {
243244
Str("TestDuration", t.End.Sub(t.Start).String()).
244245
Float64("TestDurationSec", t.End.Sub(t.Start).Seconds()).
245246
Msg("Leaks info")
246-
framework.L.Info().Msg("Downloading pprof profile..")
247-
dumper := NewProfileDumper(framework.LocalPyroscopeBaseURL)
248247

249248
profilesToDump := []string{DefaultProfileType, "memory:inuse_space:bytes:space:bytes"}
249+
framework.L.Info().Msgf("Downloading %d pprof profiles..", len(profilesToDump))
250+
dumper := NewProfileDumper(framework.LocalPyroscopeBaseURL)
251+
250252
for _, profileType := range profilesToDump {
251253
profileSplit := strings.Split(profileType, ":")
252254
outputPath := DefaultOutputPath
@@ -265,5 +267,13 @@ func (cd *CLNodesLeakDetector) Check(t *CLNodesCheck) error {
265267
}
266268
framework.L.Info().Str("Path", profilePath).Str("ProfileType", profileType).Msg("Saved pprof profile")
267269
}
270+
271+
ctx, cancel := context.WithTimeout(context.Background(), DefaultNodeProfileDumpTimeout)
272+
defer cancel()
273+
if err := DumpNodeProfiles(ctx, cd.nodesetName, DefaultAdminProfilesDir); err != nil {
274+
framework.L.Error().Err(err).Msg("Failed to dump node profiles")
275+
errs = append(errs, fmt.Errorf("failed to dump node profiles: %w", err))
276+
}
277+
268278
return errors.Join(errs...)
269279
}

framework/leak/node_dumper.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package leak
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path"
9+
"path/filepath"
10+
"regexp"
11+
"strings"
12+
"time"
13+
14+
"github.com/docker/docker/api/types/container"
15+
"github.com/docker/docker/client"
16+
f "github.com/smartcontractkit/chainlink-testing-framework/framework"
17+
)
18+
19+
var containerNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
20+
21+
const (
22+
DefaultAdminProfilesDir = "admin-profiles"
23+
DefaultNodeProfileDumpTimeout = 5 * time.Minute
24+
)
25+
26+
// DumpNodeProfiles runs chainlink profile collection in each running container
27+
// with a name containing namePattern and copies ./profiles content to dst/profile-<container-name>.
28+
func DumpNodeProfiles(ctx context.Context, namePattern, dst string) error {
29+
f.L.Info().
30+
Str("NamePattern", namePattern).
31+
Str("DestinationDir", dst).
32+
Msg("Dumping node profiles by container name pattern")
33+
34+
if strings.TrimSpace(namePattern) == "" {
35+
return fmt.Errorf("container name pattern must not be empty")
36+
}
37+
if strings.TrimSpace(dst) == "" {
38+
return fmt.Errorf("destination path must not be empty")
39+
}
40+
41+
if err := os.MkdirAll(dst, 0o755); err != nil {
42+
return fmt.Errorf("failed to create destination directory %q: %w", dst, err)
43+
}
44+
45+
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
46+
if err != nil {
47+
return fmt.Errorf("failed to create Docker client: %w", err)
48+
}
49+
defer cli.Close()
50+
dc, err := f.NewDockerClient()
51+
if err != nil {
52+
return fmt.Errorf("failed to create framework docker client: %w", err)
53+
}
54+
55+
containers, err := runningContainers(ctx, cli)
56+
if err != nil {
57+
return err
58+
}
59+
60+
var errs []error
61+
for _, c := range containers {
62+
if !strings.Contains(c.name, namePattern) {
63+
continue
64+
}
65+
66+
// Keep destination names safe and filesystem-friendly.
67+
safeName := containerNameSanitizer.ReplaceAllString(c.name, "_")
68+
targetDir := filepath.Join(dst, fmt.Sprintf("profile-%s", safeName))
69+
if err := os.MkdirAll(targetDir, 0o755); err != nil {
70+
errs = append(errs, fmt.Errorf("failed to create destination directory for %s: %w", c.name, err))
71+
continue
72+
}
73+
74+
f.L.Info().Str("ContainerName", c.name).Msg("Collecting node profile")
75+
76+
out, execErr := dc.ExecContainerWithContext(
77+
ctx,
78+
c.name,
79+
[]string{"chainlink", "admin", "profile", "-seconds", "1", "-output_dir", "./profiles"},
80+
)
81+
if execErr != nil {
82+
errs = append(errs, fmt.Errorf("failed to execute profile command in container %s: %w, output: %s", c.name, execErr, strings.TrimSpace(out)))
83+
continue
84+
}
85+
86+
profilesPath := path.Clean(path.Join(c.workingDir, "profiles"))
87+
if copyErr := dc.CopyFromContainerToHostWithContext(ctx, c.name, profilesPath, targetDir); copyErr != nil {
88+
errs = append(errs, fmt.Errorf("failed to copy profiles from container %s to %s: %w", c.name, targetDir, copyErr))
89+
continue
90+
}
91+
92+
f.L.Info().Str("ContainerName", c.name).Str("Destination", targetDir).Msg("Profiles copied")
93+
}
94+
95+
return errors.Join(errs...)
96+
}
97+
98+
type runningContainer struct {
99+
name string
100+
workingDir string
101+
}
102+
103+
func runningContainers(ctx context.Context, cli *client.Client) ([]runningContainer, error) {
104+
containers, err := cli.ContainerList(ctx, container.ListOptions{})
105+
if err != nil {
106+
return nil, fmt.Errorf("failed to list running Docker containers: %w", err)
107+
}
108+
109+
res := make([]runningContainer, 0, len(containers))
110+
for _, c := range containers {
111+
name := firstContainerName(c.Names)
112+
if name == "" {
113+
continue
114+
}
115+
116+
inspect, inspectErr := cli.ContainerInspect(ctx, c.ID)
117+
if inspectErr != nil {
118+
return nil, fmt.Errorf("failed to inspect container %s: %w", name, inspectErr)
119+
}
120+
workingDir := "/"
121+
if inspect.Config != nil && inspect.Config.WorkingDir != "" {
122+
workingDir = inspect.Config.WorkingDir
123+
}
124+
res = append(res, runningContainer{
125+
name: name,
126+
workingDir: workingDir,
127+
})
128+
}
129+
return res, nil
130+
}
131+
132+
func firstContainerName(names []string) string {
133+
for _, n := range names {
134+
if n == "" {
135+
continue
136+
}
137+
return strings.TrimPrefix(n, "/")
138+
}
139+
return ""
140+
}

0 commit comments

Comments
 (0)