-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathprobe.go
More file actions
304 lines (261 loc) · 7.22 KB
/
probe.go
File metadata and controls
304 lines (261 loc) · 7.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
package probe
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/mattn/go-isatty"
)
type Probe struct {
FilePath string
workflow Workflow
Config Config
}
type Config struct {
Log io.Writer
Verbose bool
RT bool
}
func New(path string, v bool) *Probe {
// Set TTY detection for embedded plugins
if isatty.IsTerminal(os.Stdout.Fd()) {
_ = os.Setenv("PROBE_TTY", "1")
}
return &Probe{
FilePath: path,
Config: Config{
Log: os.Stdout,
Verbose: v,
RT: false,
},
}
}
func (p *Probe) Do() error {
if err := p.Load(); err != nil {
return err
}
return p.workflow.Start(p.Config)
}
func (p *Probe) ExitStatus() int {
return p.workflow.exitStatus
}
// DagAscii returns the ASCII art representation of the workflow job dependencies with steps
func (p *Probe) DagAscii() (string, error) {
if err := p.Load(); err != nil {
return "", err
}
return p.workflow.RenderDagAscii(), nil
}
// DagMermaid returns the Mermaid format representation of the workflow job dependencies
func (p *Probe) DagMermaid() (string, error) {
if err := p.Load(); err != nil {
return "", err
}
return p.workflow.RenderDagMermaid(), nil
}
func (p *Probe) Load() error {
files, err := p.yamlFiles()
if err != nil {
return NewFileError("load_yaml_files", "failed to locate YAML files", err).
WithContext("workflow_path", p.FilePath)
}
y, err := p.readYamlFiles(files)
if err != nil {
return NewFileError("read_yaml_files", "failed to read YAML files", err).
WithContext("files", files)
}
v := validator.New()
dec := yaml.NewDecoder(bytes.NewReader([]byte(y)), yaml.Validator(v), yaml.AllowDuplicateMapKey())
if err = dec.Decode(&p.workflow); err != nil {
return NewConfigurationError("decode_yaml", "failed to decode YAML workflow", err).
WithContext("workflow_path", p.FilePath)
}
// Set base path for resolving relative paths in embedded actions
if len(files) > 0 {
absPath, err := filepath.Abs(files[0])
if err == nil {
p.workflow.basePath = filepath.Dir(absPath)
}
}
p.setDefaultsToSteps()
// Additional validation and ID initialization
if err := p.validateIDs(); err != nil {
return err
}
// Validate repeat and retry limits
if err := p.validateRepeatLimits(); err != nil {
return err
}
p.initializeEmptyIDs()
return nil
}
func (p *Probe) readYamlFiles(paths []string) (string, error) {
var y strings.Builder
for i, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
y.Write(data)
// Add newline between files to prevent concatenation issues
if i < len(paths)-1 && len(data) > 0 && data[len(data)-1] != '\n' {
y.WriteByte('\n')
}
}
return y.String(), nil
}
// yamlFiles resolves paths and returns a list of YAML (.yml and .yaml) files
func (p *Probe) yamlFiles() ([]string, error) {
var files []string
paths := strings.SplitSeq(p.FilePath, ",")
for path := range paths {
path = strings.TrimSpace(path)
// Check if it's a single file or a directory
if info, err := os.Stat(path); err == nil {
if info.IsDir() {
// Read all YAML files from the directory
rfiles, err := os.ReadDir(path)
if err != nil {
return nil, err
}
for _, rf := range rfiles {
if !rf.IsDir() && p.isYamlFile(rf.Name()) {
files = append(files, filepath.Join(path, rf.Name()))
}
}
} else if p.isYamlFile(path) {
// Single file path
files = append(files, path)
}
} else {
// Handle wildcard pattern
matches, err := filepath.Glob(path)
if err != nil {
return nil, err
}
if len(matches) == 0 {
return nil, fmt.Errorf("%s: no such file or directory", path)
}
for _, match := range matches {
if p.isYamlFile(match) {
files = append(files, match)
}
}
}
}
return files, nil
}
// isYAMLFile checks if the filename has a .yml or .yaml extension
func (p *Probe) isYamlFile(f string) bool {
return strings.HasSuffix(f, ".yml") || strings.HasSuffix(f, ".yaml")
}
func (p *Probe) setDefaultsToSteps() {
for _, job := range p.workflow.Jobs {
if job.Defaults == nil {
continue
}
dataMap, ok := job.Defaults.(map[string]any)
if !ok {
continue
}
for key, values := range dataMap {
defaults, defok := values.(map[string]any)
if !defok {
continue
}
for _, s := range job.Steps {
if s.Uses != key {
continue
}
p.setDefaults(s.With, defaults)
}
}
}
}
func (p *Probe) setDefaults(data, defaults map[string]any) {
for key, defaultValue := range defaults {
// If key does not exist in data
if _, exists := data[key]; !exists {
data[key] = defaultValue
continue
}
// If you have a nested map with a key of data
if nestedDefault, ok := defaultValue.(map[string]any); ok {
if nestedData, ok := data[key].(map[string]any); ok {
// Recursively set default values
p.setDefaults(nestedData, nestedDefault)
}
}
}
}
// validateIDs checks for duplicate job IDs and step IDs across the workflow
func (p *Probe) validateIDs() error {
jobIDs := make(map[string]int) // jobID -> jobIndex
globalStepIDs := make(map[string]string) // stepID -> "job[index].step[index]"
for jobIdx, job := range p.workflow.Jobs {
// Check for duplicate job IDs
if job.ID != "" {
if existingIdx, exists := jobIDs[job.ID]; exists {
return NewConfigurationError("duplicate_job_id",
fmt.Sprintf("duplicate job ID '%s' found in job %d (already used in job %d)",
job.ID, jobIdx, existingIdx), nil)
}
jobIDs[job.ID] = jobIdx
}
// Check for duplicate step IDs across all jobs
for stepIdx, step := range job.Steps {
if step.ID != "" {
stepLocation := fmt.Sprintf("job[%d].step[%d]", jobIdx, stepIdx)
if existingLocation, exists := globalStepIDs[step.ID]; exists {
return NewConfigurationError("duplicate_step_id",
fmt.Sprintf("duplicate step ID '%s' found in %s (already used in %s)",
step.ID, stepLocation, existingLocation), nil)
}
globalStepIDs[step.ID] = stepLocation
}
}
}
return nil
}
// initializeEmptyIDs initializes empty job IDs and step IDs with index numbers
func (p *Probe) initializeEmptyIDs() {
for jobIdx := range p.workflow.Jobs {
job := &p.workflow.Jobs[jobIdx]
// Initialize empty job ID with index number
if job.ID == "" {
job.ID = fmt.Sprintf("job_%d", jobIdx)
}
// Initialize empty step IDs with index numbers
for stepIdx, step := range job.Steps {
if step.ID == "" {
step.ID = fmt.Sprintf("step_%d", stepIdx)
}
}
}
}
// validateRepeatLimits validates repeat count and max_attempts against configured limits
func (p *Probe) validateRepeatLimits() error {
for jobIdx, job := range p.workflow.Jobs {
// Validate repeat configuration
if job.Repeat != nil {
if err := job.Repeat.Validate(); err != nil {
return NewConfigurationError("invalid_repeat",
fmt.Sprintf("job %d (%s): %v", jobIdx, job.Name, err), nil)
}
}
// Validate retry configuration for each step
for stepIdx, step := range job.Steps {
if step.Retry != nil {
if err := step.Retry.Validate(); err != nil {
return NewConfigurationError("invalid_retry",
fmt.Sprintf("job %d (%s), step %d: %v", jobIdx, job.Name, stepIdx, err), nil)
}
}
}
}
return nil
}