Skip to content

Commit b56241c

Browse files
[CLD-870]: fix: allow empty payload field in input file (#607)
Previously we reject any input with empty payload field in the pipeline input, however there are scenarios where user wants to pass in empty input [here](https://chainlink-core.slack.com/archives/C08QF1BEW4T/p1764199008922179) ``` environment: testnet domain: ccip changesets: propose_jobs: # Changeset 1 chainOverrides: - 16015286601757825753 # list, not bare map key - 9762610643973837292 payload: # empty ``` Solution: Changed the validation to check for the existence of the key rather than the truthiness of the value Now when we pass {"deploy_link_token": {"payload": null}}, the validation will correctly recognize that the payload field exists (even though its value is null), and the pipeline will proceed successfully. This allows you to explicitly pass nil payloads when needed. JIRA: https://smartcontract-it.atlassian.net/browse/CLD-870
1 parent 49d9309 commit b56241c

File tree

5 files changed

+260
-12
lines changed

5 files changed

+260
-12
lines changed

.changeset/silver-chairs-wish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": patch
3+
---
4+
5+
fix: allow empty payload field in input file

engine/cld/changeset/common.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ func (f WrappedChangeSet[C]) WithJSON(_ C, inputStr string) ConfiguredChangeSet
100100
return config, fmt.Errorf("JSON must be in JSON format with 'payload' fields: %w", err)
101101
}
102102

103-
// If payload is null or empty, return an error
104-
if len(inputObject.Payload) == 0 || string(inputObject.Payload) == "null" {
105-
return config, errors.New("'payload' field is required and cannot be empty")
103+
// If payload is null, decode it as null (which will give zero value)
104+
// If payload is missing, return an error
105+
if len(inputObject.Payload) == 0 {
106+
return config, errors.New("'payload' field is required")
106107
}
107108

108109
payloadDecoder := json.NewDecoder(strings.NewReader(string(inputObject.Payload)))
@@ -159,9 +160,10 @@ func (f WrappedChangeSet[C]) WithEnvInput(opts ...EnvInputOption[C]) ConfiguredC
159160
return config, fmt.Errorf("JSON must be in JSON format with 'payload' fields: %w", err)
160161
}
161162

162-
// If payload is null or empty, return an error
163-
if len(inputObject.Payload) == 0 || string(inputObject.Payload) == "null" {
164-
return config, errors.New("'payload' field is required and cannot be empty")
163+
// If payload is null, decode it as null (which will give zero value)
164+
// If payload is missing, return an error
165+
if len(inputObject.Payload) == 0 {
166+
return config, errors.New("'payload' field is required")
165167
}
166168

167169
payloadDecoder := json.NewDecoder(strings.NewReader(string(inputObject.Payload)))
@@ -229,9 +231,10 @@ func (f WrappedChangeSet[C]) WithConfigResolver(resolver fresolvers.ConfigResolv
229231
return zero, fmt.Errorf("failed to parse resolver input as JSON: %w", err)
230232
}
231233

232-
// If payload is null or empty, return an error
233-
if len(inputObject.Payload) == 0 || string(inputObject.Payload) == "null" {
234-
return zero, errors.New("'payload' field is required and cannot be empty")
234+
// If payload is null, pass it to the resolver (which will receive null)
235+
// If payload field is missing, return an error
236+
if len(inputObject.Payload) == 0 {
237+
return zero, errors.New("'payload' field is required")
235238
}
236239

237240
// Call resolver – automatically unmarshal into its expected input type.

engine/cld/changeset/common_test.go

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,14 +449,135 @@ func TestWithConfigResolver_InvalidJSON(t *testing.T) {
449449
assert.Contains(t, err.Error(), "failed to parse resolver input as JSON")
450450
}
451451

452+
func TestWithConfigResolver_NullPayload(t *testing.T) {
453+
manager := resolvers.NewConfigResolverManager()
454+
resolver := func(input map[string]any) (any, error) {
455+
// When payload is null, input will be nil
456+
if input == nil {
457+
return "resolved_from_null", nil
458+
}
459+
460+
return "should_not_reach_here", nil
461+
}
462+
manager.Register(resolver, resolvers.ResolverInfo{Description: "Test"})
463+
464+
t.Setenv("DURABLE_PIPELINE_INPUT", `{"payload": null}`)
465+
466+
cs := deployment.CreateChangeSet(
467+
func(e deployment.Environment, config string) (deployment.ChangesetOutput, error) {
468+
assert.Equal(t, "resolved_from_null", config)
469+
return deployment.ChangesetOutput{}, nil
470+
},
471+
func(e deployment.Environment, config string) error { return nil },
472+
)
473+
474+
configured := Configure(cs).WithConfigResolver(resolver)
475+
env := deployment.Environment{Logger: logger.Test(t)}
476+
477+
_, err := configured.Apply(env)
478+
require.NoError(t, err, "null payload should be valid and passed to resolver")
479+
}
480+
481+
func TestWithJSON_NullPayload(t *testing.T) {
482+
t.Parallel()
483+
484+
type TestConfig struct {
485+
Value string `json:"value"`
486+
Count int `json:"count"`
487+
}
488+
489+
cs := deployment.CreateChangeSet(
490+
func(e deployment.Environment, config TestConfig) (deployment.ChangesetOutput, error) {
491+
// When payload is null, config should be zero value
492+
assert.Empty(t, config.Value)
493+
assert.Equal(t, 0, config.Count)
494+
495+
return deployment.ChangesetOutput{}, nil
496+
},
497+
func(e deployment.Environment, config TestConfig) error { return nil },
498+
)
499+
env := deployment.Environment{Logger: logger.Test(t)}
500+
configured := Configure(cs).WithJSON(TestConfig{}, `{"payload":null}`)
501+
502+
_, err := configured.Apply(env)
503+
require.NoError(t, err, "null payload should be valid")
504+
}
505+
506+
func TestWithJSON_MissingPayload(t *testing.T) {
507+
t.Parallel()
508+
509+
type TestConfig struct {
510+
Value string `json:"value"`
511+
}
512+
513+
cs := deployment.CreateChangeSet(
514+
func(e deployment.Environment, config TestConfig) (deployment.ChangesetOutput, error) {
515+
return deployment.ChangesetOutput{}, nil
516+
},
517+
func(e deployment.Environment, config TestConfig) error { return nil },
518+
)
519+
env := deployment.Environment{Logger: logger.Test(t)}
520+
configured := Configure(cs).WithJSON(TestConfig{}, `{"notPayload":"value"}`)
521+
522+
_, err := configured.Apply(env)
523+
require.Error(t, err)
524+
assert.Contains(t, err.Error(), "'payload' field is required")
525+
}
526+
527+
func TestWithEnvInput_NullPayload(t *testing.T) {
528+
type TestConfig struct {
529+
Value string `json:"value"`
530+
Count int `json:"count"`
531+
}
532+
533+
t.Setenv("DURABLE_PIPELINE_INPUT", `{"payload":null}`)
534+
535+
cs := deployment.CreateChangeSet(
536+
func(e deployment.Environment, config TestConfig) (deployment.ChangesetOutput, error) {
537+
// When payload is null, config should be zero value
538+
assert.Empty(t, config.Value)
539+
assert.Equal(t, 0, config.Count)
540+
541+
return deployment.ChangesetOutput{}, nil
542+
},
543+
func(e deployment.Environment, config TestConfig) error { return nil },
544+
)
545+
env := deployment.Environment{Logger: logger.Test(t)}
546+
configured := Configure(cs).WithEnvInput()
547+
548+
_, err := configured.Apply(env)
549+
require.NoError(t, err, "null payload should be valid")
550+
}
551+
552+
func TestWithEnvInput_MissingPayload(t *testing.T) {
553+
type TestConfig struct {
554+
Value string
555+
}
556+
557+
t.Setenv("DURABLE_PIPELINE_INPUT", `{"notPayload":"value"}`)
558+
559+
cs := deployment.CreateChangeSet(
560+
func(e deployment.Environment, config TestConfig) (deployment.ChangesetOutput, error) {
561+
return deployment.ChangesetOutput{}, nil
562+
},
563+
func(e deployment.Environment, config TestConfig) error { return nil },
564+
)
565+
env := deployment.Environment{Logger: logger.Test(t)}
566+
configured := Configure(cs).WithEnvInput()
567+
568+
_, err := configured.Apply(env)
569+
require.Error(t, err)
570+
assert.Contains(t, err.Error(), "'payload' field is required")
571+
}
572+
452573
func TestWithConfigResolver_MissingPayload(t *testing.T) {
453574
manager := resolvers.NewConfigResolverManager()
454575
resolver := func(input map[string]any) (any, error) {
455576
return "config", nil
456577
}
457578
manager.Register(resolver, resolvers.ResolverInfo{Description: "Test"})
458579

459-
t.Setenv("DURABLE_PIPELINE_INPUT", `{"payload": null}`)
580+
t.Setenv("DURABLE_PIPELINE_INPUT", `{"notPayload":"value"}`)
460581

461582
cs := deployment.CreateChangeSet(
462583
func(e deployment.Environment, config string) (deployment.ChangesetOutput, error) {
@@ -470,7 +591,7 @@ func TestWithConfigResolver_MissingPayload(t *testing.T) {
470591

471592
_, err := configured.Apply(env)
472593
require.Error(t, err)
473-
assert.Contains(t, err.Error(), "'payload' field is required and cannot be empty")
594+
assert.Contains(t, err.Error(), "'payload' field is required")
474595
}
475596

476597
func TestWithConfigResolver_ResolverError(t *testing.T) {

engine/cld/legacy/cli/commands/durable-pipelines_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,6 +1506,17 @@ changesets:
15061506
expectError: true,
15071507
errorContains: "is missing required 'payload' field",
15081508
},
1509+
{
1510+
name: "null payload field - should be valid",
1511+
yamlContent: `environment: testnet
1512+
domain: test
1513+
changesets:
1514+
- test_changeset:
1515+
payload: null`,
1516+
index: 0,
1517+
expectedName: "test_changeset",
1518+
expectedJSON: `{"payload":null}`,
1519+
},
15091520
}
15101521

15111522
for _, tt := range tests {
@@ -1740,6 +1751,114 @@ changesets: [
17401751
}
17411752
}
17421753

1754+
//nolint:paralleltest
1755+
func TestSetDurablePipelineInputFromYAML_NullPayload(t *testing.T) {
1756+
testDomain := domain.NewDomain(t.TempDir(), "test")
1757+
env := "testnet"
1758+
1759+
// Create workspace structure
1760+
workspaceRoot := t.TempDir()
1761+
inputsDir := filepath.Join(workspaceRoot, "domains", testDomain.String(), env, "durable_pipelines", "inputs")
1762+
require.NoError(t, os.MkdirAll(inputsDir, 0755))
1763+
1764+
// Mock workspace root discovery
1765+
require.NoError(t, os.MkdirAll(filepath.Join(workspaceRoot, "domains"), 0755))
1766+
1767+
// Set up the test to run from within the workspace
1768+
originalWd, err := os.Getwd()
1769+
require.NoError(t, err)
1770+
require.NoError(t, os.Chdir(workspaceRoot))
1771+
t.Cleanup(func() {
1772+
require.NoError(t, os.Chdir(originalWd))
1773+
})
1774+
1775+
tests := []struct {
1776+
name string
1777+
yamlContent string
1778+
changesetName string
1779+
expectError bool
1780+
expectedJSON string
1781+
description string
1782+
}{
1783+
{
1784+
name: "object format with null payload - should be valid",
1785+
yamlContent: `environment: testnet
1786+
domain: test
1787+
changesets:
1788+
deploy_link_token:
1789+
payload: null`,
1790+
changesetName: "deploy_link_token",
1791+
expectError: false,
1792+
expectedJSON: `{"payload":null}`,
1793+
description: "Should allow explicit null payload in object format",
1794+
},
1795+
{
1796+
name: "array format with null payload - should be valid",
1797+
yamlContent: `environment: testnet
1798+
domain: test
1799+
changesets:
1800+
- deploy_link_token:
1801+
payload: null`,
1802+
changesetName: "deploy_link_token",
1803+
expectError: false,
1804+
expectedJSON: `{"payload":null}`,
1805+
description: "Should allow explicit null payload in array format",
1806+
},
1807+
{
1808+
name: "object format with missing payload - should error",
1809+
yamlContent: `environment: testnet
1810+
domain: test
1811+
changesets:
1812+
deploy_link_token:
1813+
notPayload: 123`,
1814+
changesetName: "deploy_link_token",
1815+
expectError: true,
1816+
description: "Should error when payload field is completely missing",
1817+
},
1818+
{
1819+
name: "object format with empty payload object - should be valid",
1820+
yamlContent: `environment: testnet
1821+
domain: test
1822+
changesets:
1823+
deploy_link_token:
1824+
payload: {}`,
1825+
changesetName: "deploy_link_token",
1826+
expectError: false,
1827+
expectedJSON: `{"payload":{}}`,
1828+
description: "Should allow empty object as payload",
1829+
},
1830+
}
1831+
1832+
for _, tt := range tests {
1833+
t.Run(tt.name, func(t *testing.T) {
1834+
// Create unique YAML file for this test
1835+
safeName := strings.ReplaceAll(strings.ReplaceAll(tt.name, " ", "-"), "/", "-")
1836+
yamlFileName := fmt.Sprintf("test-null-payload-%s.yaml", safeName)
1837+
yamlFilePath := filepath.Join(inputsDir, yamlFileName)
1838+
require.NoError(t, os.WriteFile(yamlFilePath, []byte(tt.yamlContent), 0644)) //nolint:gosec
1839+
1840+
// Clear any previous DURABLE_PIPELINE_INPUT
1841+
os.Unsetenv("DURABLE_PIPELINE_INPUT")
1842+
1843+
err := setDurablePipelineInputFromYAML(yamlFileName, tt.changesetName, testDomain, env)
1844+
1845+
if tt.expectError {
1846+
require.Error(t, err, tt.description)
1847+
require.Contains(t, err.Error(), "is missing required 'payload' field", tt.description)
1848+
} else {
1849+
require.NoError(t, err, tt.description)
1850+
1851+
// Verify that DURABLE_PIPELINE_INPUT was set
1852+
durablePipelineInput := os.Getenv("DURABLE_PIPELINE_INPUT")
1853+
require.NotEmpty(t, durablePipelineInput, "DURABLE_PIPELINE_INPUT should be set")
1854+
1855+
// Verify the JSON structure
1856+
require.JSONEq(t, tt.expectedJSON, durablePipelineInput, tt.description)
1857+
}
1858+
})
1859+
}
1860+
}
1861+
17431862
//nolint:paralleltest
17441863
func TestDurablePipelineRunWithObjectFormatError(t *testing.T) {
17451864
env := "testnet"

engine/cld/legacy/cli/commands/durable_pipeline_yaml.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ func setChangesetEnvironmentVariable(changesetName string, changesetData any, in
205205
}
206206

207207
payload, payloadExists := changesetMap["payload"]
208-
if !payloadExists || payload == nil {
208+
if !payloadExists {
209209
return fmt.Errorf("changeset '%s' in input file %s is missing required 'payload' field", changesetName, inputFileName)
210210
}
211211

0 commit comments

Comments
 (0)