Skip to content

Commit 1e2e1cf

Browse files
authored
Merge pull request #1 from GoCodeAlone/fix/ws02-github-plugin
Fix GitHub plugin: MaxBytesReader, webhook routing, dynamic step fields
2 parents c92c9cb + e2488aa commit 1e2e1cf

File tree

6 files changed

+204
-62
lines changed

6 files changed

+204
-62
lines changed

internal/module_webhook.go

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"crypto/sha256"
77
"encoding/hex"
88
"encoding/json"
9+
"errors"
910
"fmt"
11+
"io"
1012
"net/http"
1113
"strings"
1214
"time"
@@ -98,12 +100,9 @@ func (m *webhookModule) SetMessageSubscriber(_ sdk.MessageSubscriber) {}
98100
// Init is a no-op; the module is ready after construction.
99101
func (m *webhookModule) Init() error { return nil }
100102

101-
// Start registers the HTTP webhook handler with the global mux.
102-
// The engine must be running its HTTP server for this to receive requests.
103-
func (m *webhookModule) Start(_ context.Context) error {
104-
http.HandleFunc("/webhooks/github", m.handleWebhook)
105-
return nil
106-
}
103+
// Start is a no-op; the webhook route is declared via ConfigFragment so the
104+
// engine's HTTP server registers it through the normal config pipeline.
105+
func (m *webhookModule) Start(_ context.Context) error { return nil }
107106

108107
// Stop is a no-op.
109108
func (m *webhookModule) Stop(_ context.Context) error { return nil }
@@ -306,27 +305,16 @@ func normalizeGenericEvent(event *GitEvent, payload map[string]any) {
306305
}
307306

308307
// readLimitedBody reads up to maxBytes from the request body.
308+
// It uses io.LimitReader to cap reads safely without requiring a ResponseWriter.
309+
// If the body is exactly maxBytes, an extra byte is attempted to detect overflow.
309310
func readLimitedBody(r *http.Request, maxBytes int64) ([]byte, error) {
310-
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
311-
buf := make([]byte, 0, 4096)
312-
tmp := make([]byte, 4096)
313-
var total int64
314-
for {
315-
n, err := r.Body.Read(tmp)
316-
if n > 0 {
317-
total += int64(n)
318-
if total > maxBytes {
319-
return nil, fmt.Errorf("request body exceeds %d bytes", maxBytes)
320-
}
321-
buf = append(buf, tmp[:n]...)
322-
}
323-
if err != nil {
324-
if err.Error() == "EOF" || err.Error() == "http: request body too large" {
325-
break
326-
}
327-
// io.EOF is expected at end of body
328-
break
329-
}
311+
lr := io.LimitReader(r.Body, maxBytes+1)
312+
buf, err := io.ReadAll(lr)
313+
if err != nil && !errors.Is(err, io.EOF) {
314+
return nil, fmt.Errorf("failed to read request body: %w", err)
315+
}
316+
if int64(len(buf)) > maxBytes {
317+
return nil, fmt.Errorf("request body exceeds %d bytes", maxBytes)
330318
}
331319
return buf, nil
332320
}

internal/plugin.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ import (
66
sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk"
77
)
88

9+
// webhookRouteConfig is the config fragment YAML that declares the GitHub
10+
// webhook HTTP route so the engine's HTTP server registers it via the normal
11+
// config pipeline instead of the unreachable global DefaultServeMux.
12+
const webhookRouteConfig = `
13+
workflows:
14+
github-webhook-receiver:
15+
triggers:
16+
- type: http
17+
config:
18+
path: /webhooks/github
19+
method: POST
20+
steps: []
21+
`
22+
923
// githubPlugin implements sdk.PluginProvider, sdk.ModuleProvider, and sdk.StepProvider.
1024
type githubPlugin struct{}
1125

@@ -61,3 +75,11 @@ func (p *githubPlugin) CreateStep(typeName, name string, config map[string]any)
6175
return nil, fmt.Errorf("github plugin: unknown step type %q", typeName)
6276
}
6377
}
78+
79+
// ConfigFragment implements sdk.ConfigProvider.
80+
// It returns a config fragment that declares the /webhooks/github HTTP route
81+
// so the engine registers it through the normal config pipeline rather than
82+
// through the global DefaultServeMux (which is unreachable in a gRPC plugin).
83+
func (p *githubPlugin) ConfigFragment() ([]byte, error) {
84+
return []byte(webhookRouteConfig), nil
85+
}

internal/resolve.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package internal
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// resolveField performs basic template resolution on value, replacing
9+
// {{.field}} references with values looked up from triggerData, stepOutputs,
10+
// and current (in that priority order).
11+
//
12+
// Supported reference forms:
13+
//
14+
// {{.field}} — look up "field" in triggerData
15+
// {{.steps.stepName.field}} — look up stepOutputs["stepName"]["field"]
16+
// {{.current.field}} — look up "field" in current
17+
//
18+
// If the placeholder cannot be resolved the original placeholder text is left
19+
// in place so misconfiguration is visible rather than silently swallowed.
20+
func resolveField(value string, triggerData map[string]any, stepOutputs map[string]map[string]any, current map[string]any) string {
21+
if !strings.Contains(value, "{{") {
22+
return value
23+
}
24+
25+
result := value
26+
// Iterate until no more replacements can be made (handles multiple refs).
27+
for strings.Contains(result, "{{") {
28+
start := strings.Index(result, "{{")
29+
end := strings.Index(result, "}}")
30+
if end < start {
31+
break
32+
}
33+
placeholder := result[start : end+2]
34+
inner := strings.TrimSpace(result[start+2 : end])
35+
36+
resolved, ok := lookupRef(inner, triggerData, stepOutputs, current)
37+
if ok {
38+
result = strings.Replace(result, placeholder, fmt.Sprintf("%v", resolved), 1)
39+
} else {
40+
// Leave the unresolvable placeholder and stop to avoid an infinite loop.
41+
break
42+
}
43+
}
44+
return result
45+
}
46+
47+
// lookupRef resolves a single template reference (the content between {{ and }}).
48+
func lookupRef(ref string, triggerData map[string]any, stepOutputs map[string]map[string]any, current map[string]any) (any, bool) {
49+
// Strip leading dot.
50+
ref = strings.TrimPrefix(ref, ".")
51+
52+
parts := strings.SplitN(ref, ".", 3)
53+
54+
switch parts[0] {
55+
case "steps":
56+
// {{.steps.<stepName>.<field>}}
57+
if len(parts) < 3 {
58+
return nil, false
59+
}
60+
stepName, field := parts[1], parts[2]
61+
if stepOutputs == nil {
62+
return nil, false
63+
}
64+
outputs, ok := stepOutputs[stepName]
65+
if !ok {
66+
return nil, false
67+
}
68+
v, ok := outputs[field]
69+
return v, ok
70+
71+
case "current":
72+
// {{.current.<field>}}
73+
if len(parts) < 2 {
74+
return nil, false
75+
}
76+
field := strings.Join(parts[1:], ".")
77+
if current == nil {
78+
return nil, false
79+
}
80+
v, ok := current[field]
81+
return v, ok
82+
83+
default:
84+
// {{.field}} — look up directly in triggerData.
85+
field := strings.Join(parts, ".")
86+
if triggerData == nil {
87+
return nil, false
88+
}
89+
v, ok := triggerData[field]
90+
return v, ok
91+
}
92+
}

internal/step_action_status.go

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"strconv"
8+
"strings"
89
"time"
910

1011
sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk"
@@ -33,6 +34,7 @@ type actionStatusConfig struct {
3334
Owner string `yaml:"owner"`
3435
Repo string `yaml:"repo"`
3536
RunID int64 `yaml:"run_id"`
37+
RunIDRaw string // raw string value for dynamic {{.field}} resolution
3638
Token string `yaml:"token"`
3739
Wait bool `yaml:"wait"`
3840
PollInterval time.Duration `yaml:"poll_interval"`
@@ -70,6 +72,8 @@ func parseActionStatusConfig(raw map[string]any) (actionStatusConfig, error) {
7072
}
7173

7274
// run_id can be provided as int, int64, float64, or string.
75+
// When the string contains a template reference (e.g. {{.steps.trigger.run_id}})
76+
// the literal value is stored in RunIDRaw and resolved at Execute time.
7377
switch v := raw["run_id"].(type) {
7478
case int:
7579
cfg.RunID = int64(v)
@@ -79,14 +83,18 @@ func parseActionStatusConfig(raw map[string]any) (actionStatusConfig, error) {
7983
cfg.RunID = int64(v)
8084
case string:
8185
if v != "" {
82-
n, err := strconv.ParseInt(v, 10, 64)
83-
if err != nil {
84-
return cfg, fmt.Errorf("config.run_id is not a valid integer: %w", err)
86+
if strings.Contains(v, "{{") {
87+
cfg.RunIDRaw = v
88+
} else {
89+
n, err := strconv.ParseInt(v, 10, 64)
90+
if err != nil {
91+
return cfg, fmt.Errorf("config.run_id is not a valid integer: %w", err)
92+
}
93+
cfg.RunID = n
8594
}
86-
cfg.RunID = n
8795
}
8896
}
89-
if cfg.RunID == 0 {
97+
if cfg.RunID == 0 && cfg.RunIDRaw == "" {
9098
return cfg, fmt.Errorf("config.run_id is required")
9199
}
92100

@@ -118,27 +126,47 @@ func parseActionStatusConfig(raw map[string]any) (actionStatusConfig, error) {
118126
}
119127

120128
// Execute checks the status of the configured workflow run.
129+
// triggerData, stepOutputs, and current are used to resolve dynamic field
130+
// references (e.g. {{.steps.trigger.run_id}}) in owner, repo, and run_id.
121131
// When wait=true it polls until the run completes or the timeout elapses.
122132
func (s *actionStatusStep) Execute(
123133
ctx context.Context,
124-
_ map[string]any,
125-
_ map[string]map[string]any,
126-
_ map[string]any,
134+
triggerData map[string]any,
135+
stepOutputs map[string]map[string]any,
136+
current map[string]any,
127137
_ map[string]any,
128138
) (*sdk.StepResult, error) {
129139
token := s.config.Token
130140
if token == "" {
131141
return errorResult("GITHUB_TOKEN is not configured"), nil
132142
}
133143

144+
// Resolve dynamic owner / repo.
145+
owner := resolveField(s.config.Owner, triggerData, stepOutputs, current)
146+
repo := resolveField(s.config.Repo, triggerData, stepOutputs, current)
147+
148+
// Resolve run_id — may be a static int or a dynamic template reference.
149+
runID := s.config.RunID
150+
if s.config.RunIDRaw != "" {
151+
resolved := resolveField(s.config.RunIDRaw, triggerData, stepOutputs, current)
152+
n, err := strconv.ParseInt(resolved, 10, 64)
153+
if err != nil {
154+
return errorResult(fmt.Sprintf("run_id resolved to non-integer value %q: %v", resolved, err)), nil
155+
}
156+
runID = n
157+
}
158+
if runID == 0 {
159+
return errorResult("run_id resolved to zero — check pipeline context"), nil
160+
}
161+
134162
if !s.config.Wait {
135-
return s.fetchStatus(ctx, token)
163+
return s.fetchStatusDynamic(ctx, owner, repo, runID, token)
136164
}
137165

138166
// Poll with timeout.
139167
deadline := time.Now().Add(s.config.Timeout)
140168
for {
141-
result, err := s.fetchStatus(ctx, token)
169+
result, err := s.fetchStatusDynamic(ctx, owner, repo, runID, token)
142170
if err != nil {
143171
return nil, err
144172
}
@@ -149,7 +177,7 @@ func (s *actionStatusStep) Execute(
149177
}
150178

151179
if time.Now().After(deadline) {
152-
return errorResult(fmt.Sprintf("timeout waiting for workflow run %d after %s", s.config.RunID, s.config.Timeout)), nil
180+
return errorResult(fmt.Sprintf("timeout waiting for workflow run %d after %s", runID, s.config.Timeout)), nil
153181
}
154182

155183
select {
@@ -160,9 +188,10 @@ func (s *actionStatusStep) Execute(
160188
}
161189
}
162190

163-
// fetchStatus retrieves the current state of the workflow run from the GitHub API.
164-
func (s *actionStatusStep) fetchStatus(ctx context.Context, token string) (*sdk.StepResult, error) {
165-
run, err := s.ghClient.GetWorkflowRun(ctx, s.config.Owner, s.config.Repo, s.config.RunID, token)
191+
// fetchStatusDynamic retrieves the current state of a workflow run from the
192+
// GitHub API using caller-supplied (already-resolved) owner, repo, and runID.
193+
func (s *actionStatusStep) fetchStatusDynamic(ctx context.Context, owner, repo string, runID int64, token string) (*sdk.StepResult, error) {
194+
run, err := s.ghClient.GetWorkflowRun(ctx, owner, repo, runID, token)
166195
if err != nil {
167196
return errorResult(fmt.Sprintf("failed to get workflow run: %v", err)), nil
168197
}

internal/step_action_trigger.go

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -92,39 +92,43 @@ func parseActionTriggerConfig(raw map[string]any) (actionTriggerConfig, error) {
9292
}
9393

9494
// Execute triggers the configured GitHub Actions workflow.
95-
// It returns the trigger confirmation and stops on error.
95+
// triggerData, stepOutputs, and current are used to resolve dynamic field
96+
// references (e.g. {{.owner}}, {{.steps.prev.ref}}) in the config values.
9697
func (s *actionTriggerStep) Execute(
9798
ctx context.Context,
98-
_ map[string]any,
99-
_ map[string]map[string]any,
100-
_ map[string]any,
99+
triggerData map[string]any,
100+
stepOutputs map[string]map[string]any,
101+
current map[string]any,
101102
_ map[string]any,
102103
) (*sdk.StepResult, error) {
103104
token := s.config.Token
104105
if token == "" {
105106
return errorResult("GITHUB_TOKEN is not configured"), nil
106107
}
107108

108-
err := s.ghClient.TriggerWorkflow(
109-
ctx,
110-
s.config.Owner,
111-
s.config.Repo,
112-
s.config.Workflow,
113-
s.config.Ref,
114-
s.config.Inputs,
115-
token,
116-
)
109+
owner := resolveField(s.config.Owner, triggerData, stepOutputs, current)
110+
repo := resolveField(s.config.Repo, triggerData, stepOutputs, current)
111+
workflow := resolveField(s.config.Workflow, triggerData, stepOutputs, current)
112+
ref := resolveField(s.config.Ref, triggerData, stepOutputs, current)
113+
114+
// Resolve template references in each input value.
115+
inputs := make(map[string]string, len(s.config.Inputs))
116+
for k, v := range s.config.Inputs {
117+
inputs[k] = resolveField(v, triggerData, stepOutputs, current)
118+
}
119+
120+
err := s.ghClient.TriggerWorkflow(ctx, owner, repo, workflow, ref, inputs, token)
117121
if err != nil {
118122
return errorResult(fmt.Sprintf("failed to trigger workflow: %v", err)), nil
119123
}
120124

121125
return &sdk.StepResult{
122126
Output: map[string]any{
123127
"triggered": true,
124-
"owner": s.config.Owner,
125-
"repo": s.config.Repo,
126-
"workflow": s.config.Workflow,
127-
"ref": s.config.Ref,
128+
"owner": owner,
129+
"repo": repo,
130+
"workflow": workflow,
131+
"ref": ref,
128132
},
129133
}, nil
130134
}

0 commit comments

Comments
 (0)