11package framework
22
33import (
4+ "context"
45 "embed"
56 "fmt"
67 "io/fs"
78 "os"
89 "path/filepath"
910 "strings"
11+ "time"
12+
13+ "github.com/google/go-github/v72/github"
1014)
1115
1216//go:embed observability/*
@@ -22,18 +26,189 @@ const (
2226 LocalPrometheusURL = "http://localhost:3000/explore?panes=%7B%22qZw%22:%7B%22datasource%22:%22PBFA97CFB590B2093%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%22,%22range%22:true,%22datasource%22:%7B%22type%22:%22prometheus%22,%22uid%22:%22PBFA97CFB590B2093%22%7D%7D%5D,%22range%22:%7B%22from%22:%22now-15m%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1"
2327 LocalPostgresDebugURL = "http://localhost:3000/d/000000039/postgresql-database?orgId=1&refresh=5s&var-DS_PROMETHEUS=PBFA97CFB590B2093&var-interval=$__auto_interval_interval&var-namespace=&var-release=&var-instance=postgres_exporter_0:9187&var-datname=All&var-mode=All&from=now-15m&to=now"
2428 LocalPyroScopeURL = "http://localhost:4040/?query=process_cpu%3Acpu%3Ananoseconds%3Acpu%3Ananoseconds%7Bservice_name%3D%22chainlink-node%22%7D&from=now-15m"
29+
30+ CTFCacheDir = ".local/share/ctf"
31+ DefaultGitHubOwner = "smartcontractkit"
32+ DefaultGitHubRepo = "chainlink-testing-framework"
33+ DefaultObservabilityPath = "framework/observability"
2534)
2635
36+ // resolveObservabilitySource determines where to load observability files from based on the source parameter
37+ // - Empty string: use embedded files
38+ // - file:// prefix: use local filesystem
39+ // - http(s):// prefix: download and cache from remote URL
40+ func resolveObservabilitySource (source string ) (fs.FS , string , error ) {
41+ if source == "" {
42+ // Default: use embedded files
43+ return EmbeddedObservabilityFiles , "observability" , nil
44+ }
45+
46+ if strings .HasPrefix (source , "file://" ) {
47+ // Local filesystem path
48+ localPath := strings .TrimPrefix (source , "file://" )
49+ if _ , err := os .Stat (localPath ); err != nil {
50+ return nil , "" , fmt .Errorf ("local observability path does not exist: %s: %w" , localPath , err )
51+ }
52+ return os .DirFS (localPath ), "." , nil
53+ }
54+
55+ if strings .HasPrefix (source , "http://" ) || strings .HasPrefix (source , "https://" ) {
56+ // Remote URL: download and cache
57+ cachePath , err := downloadAndCacheObservabilityFiles (source )
58+ if err != nil {
59+ return nil , "" , fmt .Errorf ("failed to download observability files: %w" , err )
60+ }
61+ return os .DirFS (cachePath ), "." , nil
62+ }
63+
64+ return nil , "" , fmt .Errorf ("invalid source format: %s (must be empty, file://, or http(s)://)" , source )
65+ }
66+
67+ // downloadAndCacheObservabilityFiles downloads observability files from a GitHub URL and caches them
68+ func downloadAndCacheObservabilityFiles (url string ) (string , error ) {
69+ // Parse URL to extract repo info and create cache key
70+ // Expected format: https://github.com/owner/repo/tree/ref/path/to/observability
71+ owner , repo , ref , path , err := parseGitHubURL (url )
72+ if err != nil {
73+ return "" , err
74+ }
75+
76+ // Create cache directory using just the ref
77+ homeDir , err := os .UserHomeDir ()
78+ if err != nil {
79+ return "" , fmt .Errorf ("failed to get home directory: %w" , err )
80+ }
81+ cachedPath := filepath .Join (homeDir , CTFCacheDir , "observability" , ref )
82+
83+ // Check if already cached and has content
84+ if info , err := os .Stat (cachedPath ); err == nil && info .IsDir () {
85+ // Verify the cache directory has files
86+ entries , err := os .ReadDir (cachedPath )
87+ if err == nil && len (entries ) > 0 {
88+ L .Debug ().Msgf ("Using cached observability files from %s" , cachedPath )
89+ return cachedPath , nil
90+ }
91+ L .Debug ().Msg ("Cache directory exists but is empty, re-downloading" )
92+ }
93+
94+ L .Info ().Msgf ("Downloading observability files from GitHub: %s/%s@%s (path: %s)" , owner , repo , ref , path )
95+
96+ // Create GitHub client with optional authentication and timeout context
97+ ctx , cancel := context .WithTimeout (context .Background (), 5 * time .Minute )
98+ defer cancel ()
99+
100+ var client * github.Client
101+ if token := os .Getenv ("GITHUB_TOKEN" ); token != "" {
102+ L .Debug ().Msg ("Using authenticated GitHub client" )
103+ client = github .NewClient (nil ).WithAuthToken (token )
104+ } else {
105+ L .Debug ().Msg ("Using unauthenticated GitHub client" )
106+ client = github .NewClient (nil )
107+ }
108+
109+ // Download directory contents recursively
110+ if err := downloadDirectoryRecursive (ctx , client , owner , repo , ref , path , cachedPath ); err != nil {
111+ return "" , fmt .Errorf ("failed to download directory: %w" , err )
112+ }
113+
114+ L .Info ().Msgf ("Observability files cached at: %s" , cachedPath )
115+ return cachedPath , nil
116+ }
117+
118+ // parseGitHubURL parses a GitHub URL and extracts owner, repo, ref, and path
119+ func parseGitHubURL (url string ) (owner , repo , ref , path string , err error ) {
120+ // Expected format: https://github.com/owner/repo/tree/ref/path/to/observability
121+ url = strings .TrimPrefix (url , "https://github.com/" )
122+ url = strings .TrimPrefix (url , "http://github.com/" )
123+ parts := strings .Split (url , "/" )
124+
125+ if len (parts ) < 5 {
126+ return "" , "" , "" , "" , fmt .Errorf ("invalid GitHub URL format (expected: https://github.com/owner/repo/tree|blob/ref/path)" )
127+ }
128+
129+ owner = parts [0 ]
130+ repo = parts [1 ]
131+ treeOrBlob := parts [2 ]
132+ ref = parts [3 ]
133+ path = strings .Join (parts [4 :], "/" )
134+
135+ if treeOrBlob != "tree" && treeOrBlob != "blob" {
136+ return "" , "" , "" , "" , fmt .Errorf ("unsupported GitHub URL type: %s (expected 'tree' or 'blob')" , treeOrBlob )
137+ }
138+
139+ return owner , repo , ref , path , nil
140+ }
141+
142+ // downloadDirectoryRecursive recursively downloads a directory from GitHub
143+ func downloadDirectoryRecursive (ctx context.Context , client * github.Client , owner , repo , ref , path , destPath string ) error {
144+ // Get directory contents
145+ _ , directoryContent , _ , err := client .Repositories .GetContents (ctx , owner , repo , path , & github.RepositoryContentGetOptions {
146+ Ref : ref ,
147+ })
148+ if err != nil {
149+ return fmt .Errorf ("failed to get directory contents: %w" , err )
150+ }
151+
152+ // Create destination directory
153+ if err := os .MkdirAll (destPath , 0o755 ); err != nil {
154+ return fmt .Errorf ("failed to create directory: %w" , err )
155+ }
156+
157+ // Process each item in the directory
158+ for _ , item := range directoryContent {
159+ if item .GetName () == "README.md" {
160+ continue
161+ }
162+
163+ itemPath := item .GetPath ()
164+ itemName := item .GetName ()
165+ targetPath := filepath .Join (destPath , itemName )
166+
167+ switch item .GetType () {
168+ case "file" :
169+ // Download file
170+ fileContent , _ , _ , err := client .Repositories .GetContents (ctx , owner , repo , itemPath , & github.RepositoryContentGetOptions {
171+ Ref : ref ,
172+ })
173+ if err != nil {
174+ return fmt .Errorf ("failed to get file %s: %w" , itemPath , err )
175+ }
176+
177+ content , err := fileContent .GetContent ()
178+ if err != nil {
179+ return fmt .Errorf ("failed to decode file %s: %w" , itemPath , err )
180+ }
181+
182+ if err := os .WriteFile (targetPath , []byte (content ), 0o644 ); err != nil {
183+ return fmt .Errorf ("failed to write file %s: %w" , targetPath , err )
184+ }
185+
186+ case "dir" :
187+ // Recursively download subdirectory
188+ if err := downloadDirectoryRecursive (ctx , client , owner , repo , ref , itemPath , targetPath ); err != nil {
189+ return err
190+ }
191+ }
192+ }
193+
194+ return nil
195+ }
196+
27197// extractAllFiles goes through the embedded directory and extracts all files to the current directory
28198func extractAllFiles (embeddedDir string ) error {
199+ return extractAllFilesFromFS (EmbeddedObservabilityFiles , embeddedDir )
200+ }
201+
202+ // extractAllFilesFromFS goes through a filesystem and extracts all files to the current directory
203+ func extractAllFilesFromFS (fsys fs.FS , embeddedDir string ) error {
29204 // Get current working directory where CLI is running
30205 currentDir , err := os .Getwd ()
31206 if err != nil {
32207 return fmt .Errorf ("failed to get current directory: %w" , err )
33208 }
34209
35- // Walk through the embedded files
36- err = fs .WalkDir (EmbeddedObservabilityFiles , embeddedDir , func (path string , d fs.DirEntry , err error ) error {
210+ // Walk through the files
211+ err = fs .WalkDir (fsys , embeddedDir , func (path string , d fs.DirEntry , err error ) error {
37212 if err != nil {
38213 return fmt .Errorf ("error walking the directory: %w" , err )
39214 }
@@ -46,8 +221,8 @@ func extractAllFiles(embeddedDir string) error {
46221 return nil
47222 }
48223
49- // Read file content from embedded file system
50- content , err := EmbeddedObservabilityFiles .ReadFile (path )
224+ // Read file content from file system
225+ content , err := fs .ReadFile (fsys , path )
51226 if err != nil {
52227 return fmt .Errorf ("failed to read file %s: %w" , path , err )
53228 }
@@ -123,15 +298,28 @@ func BlockScoutDown(url string) error {
123298
124299// ObservabilityUpOnlyLoki slim stack with only Loki to verify specific logs of CL nodes or services in tests
125300func ObservabilityUpOnlyLoki () error {
301+ return ObservabilityUpOnlyLokiWithSource ("" )
302+ }
303+
304+ // ObservabilityUpOnlyLokiWithSource slim stack with only Loki using custom observability file source
305+ // source can be:
306+ // - "" (empty): use embedded files (default)
307+ // - "file:///path/to/observability": use local filesystem
308+ // - "https://github.com/owner/repo/tree/tag/framework/observability": download from GitHub
309+ func ObservabilityUpOnlyLokiWithSource (source string ) error {
126310 L .Info ().Msg ("Creating local observability stack" )
127- if err := extractAllFiles ("observability" ); err != nil {
311+ fsys , dir , err := resolveObservabilitySource (source )
312+ if err != nil {
313+ return err
314+ }
315+ if err := extractAllFilesFromFS (fsys , dir ); err != nil {
128316 return err
129317 }
130318 _ = DefaultNetwork (nil )
131319 if err := NewPromtail (); err != nil {
132320 return err
133321 }
134- err : = RunCommand ("bash" , "-c" , fmt .Sprintf (`
322+ err = RunCommand ("bash" , "-c" , fmt .Sprintf (`
135323 cd %s && \
136324 docker compose up -d loki grafana
137325 ` , "compose" ))
@@ -145,15 +333,28 @@ func ObservabilityUpOnlyLoki() error {
145333
146334// ObservabilityUp standard stack with logs/metrics for load testing and observability
147335func ObservabilityUp () error {
336+ return ObservabilityUpWithSource ("" )
337+ }
338+
339+ // ObservabilityUpWithSource standard stack with logs/metrics using custom observability file source
340+ // source can be:
341+ // - "" (empty): use embedded files (default)
342+ // - "file:///path/to/observability": use local filesystem
343+ // - "https://github.com/owner/repo/tree/tag/framework/observability": download from GitHub
344+ func ObservabilityUpWithSource (source string ) error {
148345 L .Info ().Msg ("Creating local observability stack" )
149- if err := extractAllFiles ("observability" ); err != nil {
346+ fsys , dir , err := resolveObservabilitySource (source )
347+ if err != nil {
348+ return err
349+ }
350+ if err := extractAllFilesFromFS (fsys , dir ); err != nil {
150351 return err
151352 }
152353 _ = DefaultNetwork (nil )
153354 if err := NewPromtail (); err != nil {
154355 return err
155356 }
156- err : = RunCommand ("bash" , "-c" , fmt .Sprintf (`
357+ err = RunCommand ("bash" , "-c" , fmt .Sprintf (`
157358 cd %s && \
158359 docker compose up -d otel-collector prometheus loki grafana
159360 ` , "compose" ))
@@ -170,15 +371,28 @@ func ObservabilityUp() error {
170371
171372// ObservabilityUpFull full stack for load testing and performance investigations
172373func ObservabilityUpFull () error {
374+ return ObservabilityUpFullWithSource ("" )
375+ }
376+
377+ // ObservabilityUpFullWithSource full stack for load testing using custom observability file source
378+ // source can be:
379+ // - "" (empty): use embedded files (default)
380+ // - "file:///path/to/observability": use local filesystem
381+ // - "https://github.com/owner/repo/tree/tag/framework/observability": download from GitHub
382+ func ObservabilityUpFullWithSource (source string ) error {
173383 L .Info ().Msg ("Creating full local observability stack" )
174- if err := extractAllFiles ("observability" ); err != nil {
384+ fsys , dir , err := resolveObservabilitySource (source )
385+ if err != nil {
386+ return err
387+ }
388+ if err := extractAllFilesFromFS (fsys , dir ); err != nil {
175389 return err
176390 }
177391 _ = DefaultNetwork (nil )
178392 if err := NewPromtail (); err != nil {
179393 return err
180394 }
181- err : = RunCommand ("bash" , "-c" , fmt .Sprintf (`
395+ err = RunCommand ("bash" , "-c" , fmt .Sprintf (`
182396 cd %s && \
183397 docker compose up -d
184398 ` , "compose" ))
0 commit comments