Skip to content

Commit

Permalink
Expand parameters to support the Array type.
Browse files Browse the repository at this point in the history
Instead of just strings, add support for users to supply parameters in
the form of an array. Also include appropriate type validation and
defaulting to support backwards-compatibility. Fixes tektoncd#207.

The size of a user-supplied array is not specified anywhere in the
corresponding ParamSpec, which is useful for defining tasks that
require a dynamic number of strings used as distinct elements in
an array. For instance, one might use an array parameter in an
image-building task that feeds in a dynamic number of flags via the
args field for a particular step.

The implementation is defined such that an Array parameter can only be
used if the replacement string is completely isolated and within a field
that is an array of strings (currently eligible fields are 'command'
and 'args'). For instance, the webhook will prevent an array parameter
from being used in the 'image' field. Similarly, an array parameter used
in a composite string, like 'value=${array}', is invalid and will be
caught. The decision was made to completely prevent any use of array
parameters as strings because it clutters Tekton with unnecessary
functionality and is naturally extensible beyond the intended scope.
For instance, if the behavior was defined such that array parameters
used in the context of strings were automatically converted by
separating them with a space, then that relatively arbitrary
implementation could be questioned and eventually lead to needing to
specify a custom delimiter (like commas), and so on.

Another implementation detail to note is that the ArrayOrString type was
necessary, in combination with implementing json.Marshaller, to support
backwards-compatibility with strings. This is based off IntOrString from
kubernetes/apimachinery, and allows the value-type to be immediately
parsed and decided through the webhook.

Lastly, a possibly unintuitive design decision is that if an EMPTY array
parameter properly replaces an array element, then that string in the
original array will be completely removed.
  • Loading branch information
EliZucker committed Jul 25, 2019
1 parent 42bc3fb commit b036adb
Show file tree
Hide file tree
Showing 36 changed files with 1,739 additions and 172 deletions.
93 changes: 91 additions & 2 deletions pkg/apis/pipeline/v1alpha1/param_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,24 @@ limitations under the License.

package v1alpha1

import (
"context"
"encoding/json"
"fmt"

"github.com/tektoncd/pipeline/pkg/templating"
)

// ParamSpec defines arbitrary parameters needed beyond typed inputs (such as
// resources). Parameter values are provided by users as inputs on a TaskRun
// or PipelineRun.
type ParamSpec struct {
// Name declares the name by which a parameter is referenced.
Name string `json:"name"`
// Type is the user-specified type of the parameter. The possible types
// are currently "string" and "array", and "string" is the default.
// +optional
Type ParamType `json:"type,omitempty"`
// Description is a user-facing description of the parameter that may be
// used to populate a UI.
// +optional
Expand All @@ -30,11 +42,88 @@ type ParamSpec struct {
// default is set, a Task may be executed without a supplied value for the
// parameter.
// +optional
Default string `json:"default,omitempty"`
Default *ArrayOrString `json:"default,omitempty"`
}

func (pp *ParamSpec) SetDefaults(ctx context.Context) {
if pp != nil && pp.Type == "" {
if pp.Default != nil {
// propagate the parsed ArrayOrString's type to the parent ParamSpec's type
pp.Type = pp.Default.Type
} else {
// ParamTypeString is the default value (when no type can be inferred from the default value)
pp.Type = ParamTypeString
}
}
}

// Param declares a value to use for the Param called Name.
// Param declares a value to use for the Param called Name, and is used in the
// specific context of PipelineResources.
type Param struct {
Name string `json:"name"`
Value string `json:"value"`
}

// ArrayOrStringParam declares an ArrayOrString to use for the parameter called name.
type ArrayOrStringParam struct {
Name string `json:"name"`
Value ArrayOrString `json:"value"`
}

// ParamType indicates the type of an input parameter;
// Used to distinguish between a single string and an array of strings.
type ParamType string

// Valid ParamTypes:
const (
ParamTypeString ParamType = "string"
ParamTypeArray ParamType = "array"
)

// AllParamTypes can be used for ParamType validation.
var AllParamTypes = []ParamType{ParamTypeString, ParamTypeArray}

// ArrayOrString is modeled after IntOrString in kubernetes/apimachinery:

// ArrayOrString is a type that can hold a single string or string array.
// Used in JSON unmarshalling so that a single JSON field can accept
// either an individual string or an array of strings.
type ArrayOrString struct {
Type ParamType // Represents the stored type of ArrayOrString.
StringVal string
ArrayVal []string
}

// UnmarshalJSON implements the json.Unmarshaller interface.
func (arrayOrString *ArrayOrString) UnmarshalJSON(value []byte) error {
if value[0] == '"' {
arrayOrString.Type = ParamTypeString
return json.Unmarshal(value, &arrayOrString.StringVal)
}
arrayOrString.Type = ParamTypeArray
return json.Unmarshal(value, &arrayOrString.ArrayVal)
}

// MarshalJSON implements the json.Marshaller interface.
func (arrayOrString ArrayOrString) MarshalJSON() ([]byte, error) {
switch arrayOrString.Type {
case ParamTypeString:
return json.Marshal(arrayOrString.StringVal)
case ParamTypeArray:
return json.Marshal(arrayOrString.ArrayVal)
default:
return []byte{}, fmt.Errorf("impossible ArrayOrString.Type: %q", arrayOrString.Type)
}
}

func (arrayOrString *ArrayOrString) ApplyReplacements(stringReplacements map[string]string, arrayReplacements map[string][]string) {
if arrayOrString.Type == ParamTypeString {
arrayOrString.StringVal = templating.ApplyReplacements(arrayOrString.StringVal, stringReplacements)
} else {
var newArrayVal []string
for _, v := range arrayOrString.ArrayVal {
newArrayVal = append(newArrayVal, templating.ApplyArrayReplacements(v, stringReplacements, arrayReplacements)...)
}
arrayOrString.ArrayVal = newArrayVal
}
}
189 changes: 189 additions & 0 deletions pkg/apis/pipeline/v1alpha1/param_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
Copyright 2019 The Tekton Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1alpha1_test

import (
"context"
"encoding/json"
"reflect"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/tektoncd/pipeline/pkg/apis/pipeline/v1alpha1"
"github.com/tektoncd/pipeline/test/builder"
)

func TestParamSpec_SetDefaults(t *testing.T) {
tests := []struct {
name string
before *v1alpha1.ParamSpec
defaultsApplied *v1alpha1.ParamSpec
}{{
name: "inferred string type",
before: &v1alpha1.ParamSpec{
Name: "parametername",
},
defaultsApplied: &v1alpha1.ParamSpec{
Name: "parametername",
Type: v1alpha1.ParamTypeString,
},
}, {
name: "inferred type from default value",
before: &v1alpha1.ParamSpec{
Name: "parametername",
Default: builder.ArrayOrString("an", "array"),
},
defaultsApplied: &v1alpha1.ParamSpec{
Name: "parametername",
Type: v1alpha1.ParamTypeArray,
Default: builder.ArrayOrString("an", "array"),
},
}, {
name: "fully defined ParamSpec",
before: &v1alpha1.ParamSpec{
Name: "parametername",
Type: v1alpha1.ParamTypeArray,
Description: "a description",
Default: builder.ArrayOrString("an", "array"),
},
defaultsApplied: &v1alpha1.ParamSpec{
Name: "parametername",
Type: v1alpha1.ParamTypeArray,
Description: "a description",
Default: builder.ArrayOrString("an", "array"),
},
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
tc.before.SetDefaults(ctx)
if d := cmp.Diff(tc.before, tc.defaultsApplied); d != "" {
t.Errorf("ParamSpec.SetDefaults/%s (-want, +got) = %v", tc.name, d)
}
})
}
}

func TestArrayOrString_ApplyReplacements(t *testing.T) {
type args struct {
input *v1alpha1.ArrayOrString
stringReplacements map[string]string
arrayReplacements map[string][]string
}
tests := []struct {
name string
args args
expectedOutput *v1alpha1.ArrayOrString
}{{
name: "no replacements on array",
args: args{
input: builder.ArrayOrString("an", "array"),
stringReplacements: map[string]string{"some": "value", "anotherkey": "value"},
arrayReplacements: map[string][]string{"arraykey": {"array", "value"}, "sdfdf": {"sdf", "sdfsd"}},
},
expectedOutput: builder.ArrayOrString("an", "array"),
}, {
name: "string replacements on string",
args: args{
input: builder.ArrayOrString("astring${some} asdf ${anotherkey}"),
stringReplacements: map[string]string{"some": "value", "anotherkey": "value"},
arrayReplacements: map[string][]string{"arraykey": {"array", "value"}, "sdfdf": {"asdf", "sdfsd"}},
},
expectedOutput: builder.ArrayOrString("astringvalue asdf value"),
}, {
name: "single array replacement",
args: args{
input: builder.ArrayOrString("firstvalue", "${arraykey}", "lastvalue"),
stringReplacements: map[string]string{"some": "value", "anotherkey": "value"},
arrayReplacements: map[string][]string{"arraykey": {"array", "value"}, "sdfdf": {"asdf", "sdfsd"}},
},
expectedOutput: builder.ArrayOrString("firstvalue", "array", "value", "lastvalue"),
}, {
name: "multiple array replacement",
args: args{
input: builder.ArrayOrString("firstvalue", "${arraykey}", "lastvalue", "${sdfdf}"),
stringReplacements: map[string]string{"some": "value", "anotherkey": "value"},
arrayReplacements: map[string][]string{"arraykey": {"array", "value"}, "sdfdf": {"asdf", "sdfsd"}},
},
expectedOutput: builder.ArrayOrString("firstvalue", "array", "value", "lastvalue", "asdf", "sdfsd"),
}, {
name: "empty array replacement",
args: args{
input: builder.ArrayOrString("firstvalue", "${arraykey}", "lastvalue"),
stringReplacements: map[string]string{"some": "value", "anotherkey": "value"},
arrayReplacements: map[string][]string{"arraykey": {}},
},
expectedOutput: builder.ArrayOrString("firstvalue", "lastvalue"),
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.args.input.ApplyReplacements(tt.args.stringReplacements, tt.args.arrayReplacements)
if d := cmp.Diff(tt.expectedOutput, tt.args.input); d != "" {
t.Errorf("ApplyReplacements() output did not match expected value %s", d)
}
})
}
}

type ArrayOrStringHolder struct {
AOrS v1alpha1.ArrayOrString `json:"val"`
}

func TestArrayOrString_UnmarshalJSON(t *testing.T) {
cases := []struct {
input string
result v1alpha1.ArrayOrString
}{
{"{\"val\": \"123\"}", *builder.ArrayOrString("123")},
{"{\"val\": \"\"}", *builder.ArrayOrString("")},
{"{\"val\":[]}", v1alpha1.ArrayOrString{Type: v1alpha1.ParamTypeArray, ArrayVal: []string{}}},
{"{\"val\":[\"oneelement\"]}", v1alpha1.ArrayOrString{Type: v1alpha1.ParamTypeArray, ArrayVal: []string{"oneelement"}}},
{"{\"val\":[\"multiple\", \"elements\"]}", v1alpha1.ArrayOrString{Type: v1alpha1.ParamTypeArray, ArrayVal: []string{"multiple", "elements"}}},
}

for _, c := range cases {
var result ArrayOrStringHolder
if err := json.Unmarshal([]byte(c.input), &result); err != nil {
t.Errorf("Failed to unmarshal input '%v': %v", c.input, err)
}
if !reflect.DeepEqual(result.AOrS, c.result) {
t.Errorf("Failed to unmarshal input '%v': expected %+v, got %+v", c.input, c.result, result)
}
}
}

func TestArrayOrString_MarshalJSON(t *testing.T) {
cases := []struct {
input v1alpha1.ArrayOrString
result string
}{
{*builder.ArrayOrString("123"), "{\"val\":\"123\"}"},
{*builder.ArrayOrString("123", "1234"), "{\"val\":[\"123\",\"1234\"]}"},
{*builder.ArrayOrString("a", "a", "a"), "{\"val\":[\"a\",\"a\",\"a\"]}"},
}

for _, c := range cases {
input := ArrayOrStringHolder{c.input}
result, err := json.Marshal(&input)
if err != nil {
t.Errorf("Failed to marshal input '%v': %v", input, err)
}
if string(result) != c.result {
t.Errorf("Failed to marshal input '%v': expected: %+v, got %q", input, c.result, string(result))
}
}
}
3 changes: 3 additions & 0 deletions pkg/apis/pipeline/v1alpha1/pipeline_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ func (ps *PipelineSpec) SetDefaults(ctx context.Context) {
pt.TaskRef.Kind = NamespacedTaskKind
}
}
for i := range ps.Params {
ps.Params[i].SetDefaults(ctx)
}
}
2 changes: 1 addition & 1 deletion pkg/apis/pipeline/v1alpha1/pipeline_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ type PipelineTask struct {
Resources *PipelineTaskResources `json:"resources,omitempty"`
// Parameters declares parameters passed to this task.
// +optional
Params []Param `json:"params,omitempty"`
Params []ArrayOrStringParam `json:"params,omitempty"`
}

// PipelineTaskParam is used to provide arbitrary string parameters to a Task.
Expand Down
Loading

0 comments on commit b036adb

Please sign in to comment.