Skip to content

Commit a40d93b

Browse files
Merge pull request #69 from buildkite/add-secrets-support
Support pipeline `secrets` syntax
2 parents 122acc0 + 494de48 commit a40d93b

11 files changed

Lines changed: 1499 additions & 3 deletions

pipeline.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import (
1313
//
1414
// Standard caveats apply - see the package comment.
1515
type Pipeline struct {
16-
Steps Steps `yaml:"steps"`
17-
Env *ordered.MapSS `yaml:"env,omitempty"`
16+
Steps Steps `yaml:"steps"`
17+
Env *ordered.MapSS `yaml:"env,omitempty"`
18+
Secrets Secrets `yaml:"secrets,omitempty"`
1819

1920
// RemainingFields stores any other top-level mapping items so they at least
2021
// survive an unmarshal-marshal round-trip.

pipeline_secrets_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package pipeline
2+
3+
import (
4+
"testing"
5+
6+
"github.com/buildkite/go-pipeline/ordered"
7+
"github.com/google/go-cmp/cmp"
8+
"gopkg.in/yaml.v3"
9+
)
10+
11+
func TestPipelineSecretsUnmarshal(t *testing.T) {
12+
t.Parallel()
13+
14+
// Test parsing pipeline with build-level secrets
15+
yamlData := `
16+
secrets:
17+
- DATABASE_URL
18+
- API_TOKEN
19+
steps:
20+
- command: echo "hello"
21+
`
22+
23+
var p Pipeline
24+
var node yaml.Node
25+
err := yaml.Unmarshal([]byte(yamlData), &node)
26+
if err != nil {
27+
t.Fatalf("yaml.Unmarshal() error = %v", err)
28+
}
29+
30+
err = ordered.Unmarshal(&node, &p)
31+
if err != nil {
32+
t.Fatalf("ordered.Unmarshal() error = %v", err)
33+
}
34+
35+
want := Secrets{
36+
{Key: "DATABASE_URL", EnvironmentVariable: "DATABASE_URL"},
37+
{Key: "API_TOKEN", EnvironmentVariable: "API_TOKEN"},
38+
}
39+
40+
if diff := cmp.Diff(p.Secrets, want); diff != "" {
41+
t.Errorf("p.Secrets = %v, want %v", p.Secrets, want)
42+
}
43+
}
44+
45+
func TestPipelineSecretsWithSteps(t *testing.T) {
46+
t.Parallel()
47+
48+
// Test complete pipeline with both pipeline and step secrets
49+
yamlData := `
50+
secrets:
51+
- DATABASE_URL
52+
- REDIS_URL
53+
steps:
54+
- command: echo "step1"
55+
secrets:
56+
- API_TOKEN
57+
- DATABASE_URL
58+
- command: echo "step2"
59+
`
60+
61+
var p Pipeline
62+
var node yaml.Node
63+
err := yaml.Unmarshal([]byte(yamlData), &node)
64+
if err != nil {
65+
t.Fatalf("yaml.Unmarshal() error = %v", err)
66+
}
67+
68+
err = ordered.Unmarshal(&node, &p)
69+
if err != nil {
70+
t.Fatalf("ordered.Unmarshal() error = %v", err)
71+
}
72+
73+
want := Secrets{
74+
{Key: "DATABASE_URL", EnvironmentVariable: "DATABASE_URL"},
75+
{Key: "REDIS_URL", EnvironmentVariable: "REDIS_URL"},
76+
}
77+
78+
if diff := cmp.Diff(want, p.Secrets); diff != "" {
79+
t.Errorf("unexpected secrets (-want +got):\n%s", diff)
80+
}
81+
82+
// Check steps
83+
if len(p.Steps) != 2 {
84+
t.Fatalf("len(p.Steps) = %d, want 2", len(p.Steps))
85+
}
86+
87+
// First step should have its own secrets
88+
step1, ok := p.Steps[0].(*CommandStep)
89+
if !ok {
90+
t.Fatalf("p.Steps[0] is not a CommandStep")
91+
}
92+
if len(step1.Secrets) != 2 {
93+
t.Fatalf("len(step1.Secrets) = %d, want 2", len(step1.Secrets))
94+
}
95+
96+
// Second step should have no secrets initially
97+
step2, ok := p.Steps[1].(*CommandStep)
98+
if !ok {
99+
t.Fatalf("p.Steps[1] is not a CommandStep")
100+
}
101+
if len(step2.Secrets) != 0 {
102+
t.Fatalf("len(step2.Secrets) = %d, want 0", len(step2.Secrets))
103+
}
104+
105+
// Test merging for both steps
106+
step1.MergeSecretsFromPipeline(p.Secrets)
107+
step2.MergeSecretsFromPipeline(p.Secrets)
108+
109+
// Step1 should have 3 secrets (2 from step + 1 from pipeline not overridden)
110+
if len(step1.Secrets) != 3 {
111+
t.Fatalf("len(step1.Secrets after merge) = %d, want 3", len(step1.Secrets))
112+
}
113+
114+
// Step2 should have 2 secrets (both from pipeline)
115+
if len(step2.Secrets) != 2 {
116+
t.Fatalf("len(step2.Secrets after merge) = %d, want 2", len(step2.Secrets))
117+
}
118+
}

secret.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package pipeline
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
var (
9+
_ interface {
10+
json.Marshaler
11+
selfInterpolater
12+
} = (*Secret)(nil)
13+
)
14+
15+
// Secret represents a pipeline secret configuration.
16+
type Secret struct {
17+
Key string `json:"key" yaml:"key"`
18+
EnvironmentVariable string `json:"environment_variable,omitempty" yaml:"environment_variable,omitempty"`
19+
RemainingFields map[string]any `yaml:",inline"`
20+
}
21+
22+
// MarshalJSON marshals the secret to JSON.
23+
func (s Secret) MarshalJSON() ([]byte, error) {
24+
return inlineFriendlyMarshalJSON(s)
25+
}
26+
27+
func (s *Secret) interpolate(tf stringTransformer) error {
28+
key, err := tf.Transform(s.Key)
29+
if err != nil {
30+
return fmt.Errorf("interpolating secret key: %w", err)
31+
}
32+
s.Key = key
33+
34+
if s.EnvironmentVariable != "" {
35+
envVar, err := tf.Transform(s.EnvironmentVariable)
36+
if err != nil {
37+
return fmt.Errorf("interpolating environment variable: %w", err)
38+
}
39+
s.EnvironmentVariable = envVar
40+
}
41+
42+
return nil
43+
}

secret_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package pipeline
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/buildkite/interpolate"
8+
"github.com/google/go-cmp/cmp"
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
func TestSecretMarshalJSON(t *testing.T) {
13+
t.Parallel()
14+
15+
secret := Secret{
16+
Key: "DATABASE_URL",
17+
EnvironmentVariable: "DATABASE_URL",
18+
}
19+
20+
want := `{"environment_variable":"DATABASE_URL","key":"DATABASE_URL"}`
21+
got, err := json.Marshal(secret)
22+
if err != nil {
23+
t.Fatalf("json.Marshal(%#v) error = %v", secret, err)
24+
}
25+
26+
if string(got) != want {
27+
t.Errorf("json.Marshal(%#v) = %q, want %q", secret, got, want)
28+
}
29+
}
30+
31+
func TestSecretMarshalYAML(t *testing.T) {
32+
t.Parallel()
33+
34+
secret := Secret{
35+
Key: "DATABASE_URL",
36+
EnvironmentVariable: "DATABASE_URL",
37+
}
38+
39+
got, err := yaml.Marshal(secret)
40+
if err != nil {
41+
t.Fatalf("yaml.Marshal(%#v) error = %v", secret, err)
42+
}
43+
44+
want := "key: DATABASE_URL\nenvironment_variable: DATABASE_URL\n"
45+
if string(got) != want {
46+
t.Errorf("yaml.Marshal(%#v) = %q, want %q", secret, got, want)
47+
}
48+
}
49+
50+
func TestSecretInterpolation(t *testing.T) {
51+
t.Parallel()
52+
53+
secret := Secret{
54+
Key: "${SECRET_NAME}",
55+
EnvironmentVariable: "${ENV_VAR_NAME}",
56+
}
57+
58+
tf := envInterpolator{
59+
env: interpolate.NewMapEnv(map[string]string{
60+
"SECRET_NAME": "DATABASE_URL",
61+
"ENV_VAR_NAME": "DB_CONNECTION",
62+
}),
63+
}
64+
65+
err := secret.interpolate(tf)
66+
if err != nil {
67+
t.Fatalf("secret.interpolate(%#v) error = %v", tf, err)
68+
}
69+
70+
want := Secret{
71+
Key: "DATABASE_URL",
72+
EnvironmentVariable: "DB_CONNECTION",
73+
}
74+
75+
if diff := cmp.Diff(want, secret); diff != "" {
76+
t.Errorf("secret.interpolate(%#v) = %q, want: %q", tf, diff, want)
77+
}
78+
}

secrets.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package pipeline
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/buildkite/go-pipeline/ordered"
8+
"gopkg.in/yaml.v3"
9+
)
10+
11+
var _ interface {
12+
json.Unmarshaler
13+
ordered.Unmarshaler
14+
yaml.Marshaler
15+
} = (*Secrets)(nil)
16+
17+
// Secrets is a sequence of secrets. It is useful for unmarshaling.
18+
type Secrets []Secret
19+
20+
// UnmarshalOrdered unmarshals Secrets from []any (sequence of secret names).
21+
func (s *Secrets) UnmarshalOrdered(o any) error {
22+
switch o := o.(type) {
23+
case nil:
24+
// `secrets: null` is invalid - should be omitted entirely or use valid formats
25+
return fmt.Errorf("unmarshaling secrets: secrets cannot be null")
26+
27+
case *ordered.Map[string, any]:
28+
// Handle map syntax: {"ENV_VAR": "SECRET_KEY"}
29+
return o.Range(func(envVar string, secretKeyVal any) error {
30+
secretKey, ok := secretKeyVal.(string)
31+
if !ok {
32+
return fmt.Errorf("unmarshaling secrets: secret key must be a string, but was %T", secretKeyVal)
33+
}
34+
if secretKey == "" {
35+
return fmt.Errorf("unmarshaling secrets: secret key cannot be empty")
36+
}
37+
if envVar == "" {
38+
return fmt.Errorf("unmarshaling secrets: environment variable name cannot be empty")
39+
}
40+
41+
secret := Secret{
42+
Key: secretKey,
43+
EnvironmentVariable: envVar,
44+
}
45+
*s = append(*s, secret)
46+
return nil
47+
})
48+
49+
case []any:
50+
for _, c := range o {
51+
switch ct := c.(type) {
52+
case string:
53+
secret := Secret{
54+
Key: ct,
55+
EnvironmentVariable: ct, // Default EnvironmentVariable to key value for simple string format
56+
}
57+
*s = append(*s, secret)
58+
59+
case *ordered.Map[string, interface{}]:
60+
// Backend sends ordered.Map format
61+
secret := Secret{}
62+
63+
keyVal, _ := ct.Get("key")
64+
key, _ := keyVal.(string)
65+
if key == "" {
66+
return fmt.Errorf("unmarshaling secret: key must be a non-empty string, but was %[1]T %[1]v", keyVal)
67+
}
68+
secret.Key = key
69+
70+
if envVarVal, _ := ct.Get("environment_variable"); envVarVal != nil {
71+
envVar, ok := envVarVal.(string)
72+
if !ok {
73+
return fmt.Errorf("unmarshaling secret: environment_variable must be a string, but was %T", envVarVal)
74+
}
75+
secret.EnvironmentVariable = envVar
76+
}
77+
78+
*s = append(*s, secret)
79+
80+
default:
81+
return fmt.Errorf("unmarshaling secrets: secret type %T, want string, map[string]any, or *ordered.Map", c)
82+
}
83+
}
84+
85+
default:
86+
return fmt.Errorf("unmarshaling secrets: got %T, want []any or map[string]any", o)
87+
}
88+
89+
return nil
90+
}
91+
92+
// MergeWith merges these secrets with another set of secrets, with the other secrets taking precedence.
93+
// Deduplication is performed based on the EnvironmentVariable field.
94+
func (s Secrets) MergeWith(other Secrets) Secrets {
95+
if len(s) == 0 {
96+
return other
97+
}
98+
if len(other) == 0 {
99+
return s
100+
}
101+
102+
// Create a map to track environment variables we've seen for deduplication
103+
seen := make(map[string]bool)
104+
var result Secrets
105+
106+
for _, secret := range other {
107+
if secret.EnvironmentVariable != "" && !seen[secret.EnvironmentVariable] {
108+
result = append(result, secret)
109+
seen[secret.EnvironmentVariable] = true
110+
}
111+
}
112+
113+
for _, secret := range s {
114+
if secret.EnvironmentVariable != "" && !seen[secret.EnvironmentVariable] {
115+
result = append(result, secret)
116+
seen[secret.EnvironmentVariable] = true
117+
}
118+
}
119+
120+
return result
121+
}
122+
123+
// UnmarshalJSON is used for JSON unmarshaling.
124+
func (s *Secrets) UnmarshalJSON(b []byte) error {
125+
// JSON is just a specific kind of YAML.
126+
var n yaml.Node
127+
if err := yaml.Unmarshal(b, &n); err != nil {
128+
return err
129+
}
130+
return ordered.Unmarshal(&n, &s)
131+
}
132+
133+
func (s Secrets) MarshalYAML() (any, error) {
134+
if len(s) == 0 {
135+
return nil, nil
136+
}
137+
138+
result := make([]Secret, len(s))
139+
copy(result, s)
140+
return result, nil
141+
}

0 commit comments

Comments
 (0)