Skip to content

Commit

Permalink
Add env_vars configuration (#11)
Browse files Browse the repository at this point in the history
This allows multiple canaries to use the same script and configure it using env variables
  • Loading branch information
julienduchesne authored Nov 30, 2021
1 parent cc044e4 commit 02d875a
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 51 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ spec:
notification_context: "My Cluster: `dev-us-east-1`" # Additional context to be added to the end of messages
min_failure_delay: "2m" # Fail all successive runs after a failure (keyed to the namespace + name + phase) within the given duration (defaults to 2m). This prevents reruns. Set this to a duration slightly above the testing interval
wait_for_results: "true" # Wait until the K6 analysis is completed before returning. This is required to fail/succeed on thresholds (defaults to true)
env_vars: "{\"KEY\": \"value\"}" # Injects additional environment variables at runtime
kubernetes_secrets: "{\"TEST_VAR\": \"other-namespace/secret-name/secret-key\"}" # Injects additional environment variables from secrets, at runtime
```
Expand Down
79 changes: 52 additions & 27 deletions pkg/handlers/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ type launchPayload struct {
MinFailureDelay time.Duration
MinFailureDelayString string `json:"min_failure_delay"`

// Set environment variables when running the k6 script
EnvVars map[string]string
EnvVarsString string `json:"env_vars"`

// Inject secrets to environment (map of `<ENV>` -> `<namespace (default: payload namespace)>/<secret name>/<secret key>`)
KubernetesSecrets map[string]string
KubernetesSecretsString string `json:"kubernetes_secrets"`
Expand Down Expand Up @@ -81,39 +85,55 @@ func newLaunchPayload(req *http.Request) (*launchPayload, error) {
return nil, fmt.Errorf("error while validating base webhook: %w", err)
}

if payload.Metadata.Script == "" {
return nil, errors.New("missing script")
if err := payload.validate(); err != nil {
return nil, err
}

return payload, nil
}

func (p *launchPayload) validate() error {
var err error

if p.Metadata.Script == "" {
return errors.New("missing script")
}

if p.Metadata.UploadToCloudString == "" {
p.Metadata.UploadToCloud = false
} else if p.Metadata.UploadToCloud, err = strconv.ParseBool(p.Metadata.UploadToCloudString); err != nil {
return fmt.Errorf("error parsing value for 'upload_to_cloud': %w", err)
}

if payload.Metadata.UploadToCloudString == "" {
payload.Metadata.UploadToCloud = false
} else if payload.Metadata.UploadToCloud, err = strconv.ParseBool(payload.Metadata.UploadToCloudString); err != nil {
return nil, fmt.Errorf("error parsing value for 'upload_to_cloud': %w", err)
if p.Metadata.WaitForResultsString == "" {
p.Metadata.WaitForResults = true
} else if p.Metadata.WaitForResults, err = strconv.ParseBool(p.Metadata.WaitForResultsString); err != nil {
return fmt.Errorf("error parsing value for 'wait_for_results': %w", err)
}

if payload.Metadata.WaitForResultsString == "" {
payload.Metadata.WaitForResults = true
} else if payload.Metadata.WaitForResults, err = strconv.ParseBool(payload.Metadata.WaitForResultsString); err != nil {
return nil, fmt.Errorf("error parsing value for 'wait_for_results': %w", err)
if p.Metadata.SlackChannelsString != "" {
p.Metadata.SlackChannels = strings.Split(p.Metadata.SlackChannelsString, ",")
}

if payload.Metadata.SlackChannelsString != "" {
payload.Metadata.SlackChannels = strings.Split(payload.Metadata.SlackChannelsString, ",")
if p.Metadata.MinFailureDelayString == "" {
p.Metadata.MinFailureDelay = 2 * time.Minute
} else if p.Metadata.MinFailureDelay, err = time.ParseDuration(p.Metadata.MinFailureDelayString); err != nil {
return fmt.Errorf("error parsing value for 'min_failure_delay': %w", err)
}

if payload.Metadata.MinFailureDelayString == "" {
payload.Metadata.MinFailureDelay = 2 * time.Minute
} else if payload.Metadata.MinFailureDelay, err = time.ParseDuration(payload.Metadata.MinFailureDelayString); err != nil {
return nil, fmt.Errorf("error parsing value for 'min_failure_delay': %w", err)
if p.Metadata.EnvVarsString != "" {
if err := json.Unmarshal([]byte(p.Metadata.EnvVarsString), &p.Metadata.EnvVars); err != nil {
return fmt.Errorf("error parsing value for 'env_vars': %w", err)
}
}

if payload.Metadata.KubernetesSecretsString != "" {
if err := json.Unmarshal([]byte(payload.Metadata.KubernetesSecretsString), &payload.Metadata.KubernetesSecrets); err != nil {
return nil, fmt.Errorf("error parsing value for 'kubernetes_secrets': %w", err)
if p.Metadata.KubernetesSecretsString != "" {
if err := json.Unmarshal([]byte(p.Metadata.KubernetesSecretsString), &p.Metadata.KubernetesSecrets); err != nil {
return fmt.Errorf("error parsing value for 'kubernetes_secrets': %w", err)
}
}

return payload, nil
return nil
}

type launchHandler struct {
Expand Down Expand Up @@ -152,16 +172,21 @@ func (h *launchHandler) setLastFailureTime(payload *launchPayload) {
h.lastFailureTime[payload.key()] = time.Now()
}

func (h *launchHandler) fetchSecrets(payload *launchPayload) (map[string]string, error) {
func (h *launchHandler) buildEnvVars(payload *launchPayload) (map[string]string, error) {
envVars := payload.Metadata.EnvVars

if len(payload.Metadata.KubernetesSecrets) == 0 {
return nil, nil
return envVars, nil
}

if h.kubeClient == nil {
return nil, errors.New("kubernetes client is not configured")
}

secrets := make(map[string]string)
if envVars == nil {
envVars = make(map[string]string)
}

for env, secret := range payload.Metadata.KubernetesSecrets {
parts := strings.SplitN(secret, "/", 3)
namespace := payload.Namespace
Expand All @@ -176,12 +201,12 @@ func (h *launchHandler) fetchSecrets(payload *launchPayload) (map[string]string,
return nil, fmt.Errorf("error fetching secret %s/%s: %w", namespace, secretName, err)
}
if v, ok := secret.Data[secretKey]; ok {
secrets[env] = string(v)
envVars[env] = string(v)
} else {
return nil, fmt.Errorf("secret %s/%s does not have key %s", namespace, secretName, secretKey)
}
}
return secrets, nil
return envVars, nil
}

func (h *launchHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
Expand Down Expand Up @@ -218,14 +243,14 @@ func (h *launchHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
}

cmdLog.Info("fetching secrets (if any)")
secrets, err := h.fetchSecrets(payload)
envVars, err := h.buildEnvVars(payload)
if err != nil {
fail(err.Error())
return
}

cmdLog.Info("launching k6 test")
cmd, err := h.client.Start(payload.Metadata.Script, payload.Metadata.UploadToCloud, secrets, &buf)
cmd, err := h.client.Start(payload.Metadata.Script, payload.Metadata.UploadToCloud, envVars, &buf)
if err != nil {
fail(fmt.Sprintf("error while launching the test: %v", err))
return
Expand Down
94 changes: 70 additions & 24 deletions pkg/handlers/launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ func TestNewLaunchPayload(t *testing.T) {
"wait_for_results": "false",
"slack_channels": "test,test2",
"min_failure_delay": "3m",
"kubernetes_secrets": "{\"TEST_VAR\": \"secret/key\"}"
"kubernetes_secrets": "{\"TEST_VAR\": \"secret/key\"}",
"env_vars": "{\"TEST_VAR2\": \"value\"}"
}
}`)),
},
Expand All @@ -101,6 +102,8 @@ func TestNewLaunchPayload(t *testing.T) {
p.Metadata.MinFailureDelayString = "3m"
p.Metadata.KubernetesSecrets = map[string]string{"TEST_VAR": "secret/key"}
p.Metadata.KubernetesSecretsString = `{"TEST_VAR": "secret/key"}`
p.Metadata.EnvVars = map[string]string{"TEST_VAR2": "value"}
p.Metadata.EnvVarsString = `{"TEST_VAR2": "value"}`
return p
}(),
},
Expand Down Expand Up @@ -132,6 +135,13 @@ func TestNewLaunchPayload(t *testing.T) {
},
wantErr: errors.New(`error parsing value for 'kubernetes_secrets': json: cannot unmarshal array into Go value of type map[string]string`),
},
{
name: "invalid env_vars",
request: &http.Request{
Body: ioutil.NopCloser(strings.NewReader(`{"name": "test", "namespace": "test", "phase": "pre-rollout", "metadata": {"script": "my-script", "env_vars": "[]"}}`)),
},
wantErr: errors.New(`error parsing value for 'env_vars': json: cannot unmarshal array into Go value of type map[string]string`),
},
}

for _, tc := range testCases {
Expand Down Expand Up @@ -464,56 +474,83 @@ func TestBadPayload(t *testing.T) {
assert.Equal(t, 400, rr.Result().StatusCode)
}

func TestGetSecret(t *testing.T) {
func TestEnvVars(t *testing.T) {
fullResults, resultParts := getTestOutput(t)

for _, tc := range []struct {
name string
setting string
secretsSetting string
envVarsSetting string
kubernetesObjects []runtime.Object
nilKubeClient bool
expected string
expectedEnvVars map[string]string
expectedCode int
}{
{
name: "working example",
setting: `{\"TEST_VAR\": \"other-namespace/secret-name/secret-key\"}`,
name: "no secrets",
expected: string(fullResults),
expectedCode: 200,
},
{
name: "direct env vars",
envVarsSetting: `{\"FOO\": \"bar\", \"BAZ\": \"qux\"}`,
expected: string(fullResults),
expectedEnvVars: map[string]string{"FOO": "bar", "BAZ": "qux"},
expectedCode: 200,
},
{
name: "working example",
secretsSetting: `{\"TEST_VAR\": \"other-namespace/secret-name/secret-key\"}`,
kubernetesObjects: []runtime.Object{
&v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret-name", Namespace: "other-namespace"}, Type: "Opaque", Data: map[string][]byte{"secret-key": []byte("secret-value")}},
},
expected: string(fullResults),
expectedCode: 200,
expected: string(fullResults),
expectedEnvVars: map[string]string{"TEST_VAR": "secret-value"},
expectedCode: 200,
},
{
name: "no given namespace (defaults to the payload namespace)",
setting: `{\"TEST_VAR\": \"secret-name/secret-key\"}`,
name: "both env vars and secrets",
envVarsSetting: `{\"FOO\": \"bar\", \"BAZ\": \"qux\"}`,
secretsSetting: `{\"TEST_VAR\": \"other-namespace/secret-name/secret-key\"}`,
kubernetesObjects: []runtime.Object{
&v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret-name", Namespace: "other-namespace"}, Type: "Opaque", Data: map[string][]byte{"secret-key": []byte("secret-value")}},
},
expected: string(fullResults),
expectedEnvVars: map[string]string{"FOO": "bar", "BAZ": "qux", "TEST_VAR": "secret-value"},
expectedCode: 200,
},
{
name: "no given namespace (defaults to the payload namespace)",
secretsSetting: `{\"TEST_VAR\": \"secret-name/secret-key\"}`,
kubernetesObjects: []runtime.Object{
&v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret-name", Namespace: "test-space"}, Type: "Opaque", Data: map[string][]byte{"secret-key": []byte("secret-value")}},
},
expected: string(fullResults),
expectedCode: 200,
expected: string(fullResults),
expectedEnvVars: map[string]string{"TEST_VAR": "secret-value"},
expectedCode: 200,
},
{
name: "missing secret",
setting: `{\"TEST_VAR\": \"secret-name/secret-key\"}`,
expected: "error fetching secret test-space/secret-name: secrets \"secret-name\" not found\n",
expectedCode: 400,
name: "missing secret",
secretsSetting: `{\"TEST_VAR\": \"secret-name/secret-key\"}`,
expected: "error fetching secret test-space/secret-name: secrets \"secret-name\" not found\n",
expectedCode: 400,
},
{
name: "missing secret key",
setting: `{\"TEST_VAR\": \"secret-name/secret-key\"}`,
name: "missing secret key",
secretsSetting: `{\"TEST_VAR\": \"secret-name/secret-key\"}`,
kubernetesObjects: []runtime.Object{
&v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret-name", Namespace: "test-space"}, Type: "Opaque", Data: map[string][]byte{"other-key": []byte("secret-value")}},
},
expected: "secret test-space/secret-name does not have key secret-key\n",
expectedCode: 400,
},
{
name: "no kube client",
setting: `{\"TEST_VAR\": \"secret-name/secret-key\"}`,
expected: "kubernetes client is not configured\n",
expectedCode: 400,
nilKubeClient: true,
name: "no kube client",
secretsSetting: `{\"TEST_VAR\": \"secret-name/secret-key\"}`,
expected: "kubernetes client is not configured\n",
expectedCode: 400,
nilKubeClient: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -527,7 +564,7 @@ func TestGetSecret(t *testing.T) {
// Expected calls
// * Start the run
var bufferWriter io.Writer
k6Client.EXPECT().Start("my-script", false, map[string]string{"TEST_VAR": "secret-value"}, gomock.Any()).DoAndReturn(func(scriptContent string, upload bool, envVars map[string]string, outputWriter io.Writer) (k6.TestRun, error) {
k6Client.EXPECT().Start("my-script", false, tc.expectedEnvVars, gomock.Any()).DoAndReturn(func(scriptContent string, upload bool, envVars map[string]string, outputWriter io.Writer) (k6.TestRun, error) {
bufferWriter = outputWriter
outputWriter.Write([]byte(resultParts[0]))
return testRun, nil
Expand All @@ -549,7 +586,16 @@ func TestGetSecret(t *testing.T) {

// Make request
request := &http.Request{
Body: ioutil.NopCloser(strings.NewReader(fmt.Sprintf(`{"name": "test-name", "namespace": "test-space", "phase": "pre-rollout", "metadata": {"script": "my-script", "kubernetes_secrets": "%s"}}`, tc.setting))),
Body: ioutil.NopCloser(strings.NewReader(fmt.Sprintf(`{
"name": "test-name",
"namespace": "test-space",
"phase": "pre-rollout",
"metadata": {
"script": "my-script",
"kubernetes_secrets": "%s",
"env_vars": "%s"
}
}`, tc.secretsSetting, tc.envVarsSetting))),
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, request)
Expand Down

0 comments on commit 02d875a

Please sign in to comment.