From d25b5af59d38fb02cef0beb8b530a1387ae236cc Mon Sep 17 00:00:00 2001 From: Owen Cliffe Date: Tue, 20 Mar 2018 18:02:49 +0000 Subject: [PATCH] Add annotations to routes and apps (#866) Adds 'annotations' attribute to Routes and Apps --- Gopkg.lock | 6 +- Gopkg.toml | 2 +- .../internal/datastoreutil/validator.go | 1 + .../sql/migrations/8_add_annotations_app.go | 27 + .../sql/migrations/9_add_annotations_route.go | 27 + api/datastore/sql/sql.go | 21 +- api/models/annotations.go | 238 ++++++++ api/models/annotations_test.go | 261 +++++++++ api/models/app.go | 25 +- api/models/error.go | 20 + api/models/route.go | 47 +- api/server/apps_test.go | 30 +- docs/README.md | 1 + docs/contributors/annotations.md | 151 +++++ docs/swagger.yml | 12 +- test/fn-api-tests/README.md | 5 +- test/fn-api-tests/annotation_cases.go | 84 +++ test/fn-api-tests/apps_api.go | 125 ++-- test/fn-api-tests/apps_test.go | 324 ++++++++--- test/fn-api-tests/calls_test.go | 23 +- test/fn-api-tests/config_cases.go | 29 + test/fn-api-tests/exec_test.go | 143 +++-- test/fn-api-tests/formats_test.go | 16 +- test/fn-api-tests/init_test.go | 4 +- test/fn-api-tests/routes_api.go | 249 ++------ test/fn-api-tests/routes_test.go | 545 ++++++++++++++---- test/fn-api-tests/utils.go | 53 +- test/fn-system-tests/exec_test.go | 28 +- vendor/github.com/fnproject/fn_go/VERSION | 2 +- .../github.com/fnproject/fn_go/models/app.go | 5 +- .../fnproject/fn_go/models/route.go | 3 + 31 files changed, 1838 insertions(+), 669 deletions(-) create mode 100644 api/datastore/sql/migrations/8_add_annotations_app.go create mode 100644 api/datastore/sql/migrations/9_add_annotations_route.go create mode 100644 api/models/annotations.go create mode 100644 api/models/annotations_test.go create mode 100644 docs/contributors/annotations.md create mode 100644 test/fn-api-tests/annotation_cases.go create mode 100644 test/fn-api-tests/config_cases.go diff --git a/Gopkg.lock b/Gopkg.lock index 63d9d869f..96958a57f 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -193,8 +193,8 @@ "client/routes", "models" ] - revision = "1c5ec475d4536388b366b1f075945cd64d8c1cb4" - version = "0.2.4" + revision = "e2f92e36625a4b93c596ac3b912a4994ae574f64" + version = "0.2.6" [[projects]] name = "github.com/fsouza/go-dockerclient" @@ -653,6 +653,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "d8661bd78eda741b449a3675c52207af1bd738c63cb4f74d018edbaf0e9ef77d" + inputs-digest = "bd152d9d0bb0ac9975ecc30f368747c21a047f30bd5037fb19e4835631baae52" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 78952a80b..ca38e9b6e 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -27,7 +27,7 @@ ignored = ["github.com/fnproject/fn/cli"] [[constraint]] name = "github.com/fnproject/fn_go" - version = "0.2.0" + version = "0.2.6" [[constraint]] name = "github.com/gin-gonic/gin" diff --git a/api/datastore/internal/datastoreutil/validator.go b/api/datastore/internal/datastoreutil/validator.go index bd669289d..a7bfe8194 100644 --- a/api/datastore/internal/datastoreutil/validator.go +++ b/api/datastore/internal/datastoreutil/validator.go @@ -51,6 +51,7 @@ func (v *validator) UpdateApp(ctx context.Context, app *models.App) (*models.App if app.Name == "" { return nil, models.ErrAppsMissingName } + return v.Datastore.UpdateApp(ctx, app) } diff --git a/api/datastore/sql/migrations/8_add_annotations_app.go b/api/datastore/sql/migrations/8_add_annotations_app.go new file mode 100644 index 000000000..39de04259 --- /dev/null +++ b/api/datastore/sql/migrations/8_add_annotations_app.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "context" + + "github.com/fnproject/fn/api/datastore/sql/migratex" + "github.com/jmoiron/sqlx" +) + +func up8(ctx context.Context, tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, "ALTER TABLE apps ADD annotations TEXT;") + + return err +} + +func down8(ctx context.Context, tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, "ALTER TABLE apps DROP COLUMN annotations;") + return err +} + +func init() { + Migrations = append(Migrations, &migratex.MigFields{ + VersionFunc: vfunc(8), + UpFunc: up8, + DownFunc: down8, + }) +} diff --git a/api/datastore/sql/migrations/9_add_annotations_route.go b/api/datastore/sql/migrations/9_add_annotations_route.go new file mode 100644 index 000000000..f9fbc88ee --- /dev/null +++ b/api/datastore/sql/migrations/9_add_annotations_route.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "context" + + "github.com/fnproject/fn/api/datastore/sql/migratex" + "github.com/jmoiron/sqlx" +) + +func up9(ctx context.Context, tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, "ALTER TABLE routes ADD annotations TEXT;") + + return err +} + +func down9(ctx context.Context, tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, "ALTER TABLE routes DROP COLUMN annotations;") + return err +} + +func init() { + Migrations = append(Migrations, &migratex.MigFields{ + VersionFunc: vfunc(9), + UpFunc: up9, + DownFunc: down9, + }) +} diff --git a/api/datastore/sql/sql.go b/api/datastore/sql/sql.go index 849e84beb..0a34eb81f 100644 --- a/api/datastore/sql/sql.go +++ b/api/datastore/sql/sql.go @@ -50,6 +50,7 @@ var tables = [...]string{`CREATE TABLE IF NOT EXISTS routes ( type varchar(16) NOT NULL, headers text NOT NULL, config text NOT NULL, + annotations text NOT NULL, created_at text, updated_at varchar(256), PRIMARY KEY (app_name, path) @@ -58,6 +59,7 @@ var tables = [...]string{`CREATE TABLE IF NOT EXISTS routes ( `CREATE TABLE IF NOT EXISTS apps ( name varchar(256) NOT NULL PRIMARY KEY, config text NOT NULL, + annotations text NOT NULL, created_at varchar(256), updated_at varchar(256) );`, @@ -83,7 +85,7 @@ var tables = [...]string{`CREATE TABLE IF NOT EXISTS routes ( } const ( - routeSelector = `SELECT app_name, path, image, format, memory, cpus, type, timeout, idle_timeout, headers, config, created_at, updated_at FROM routes` + routeSelector = `SELECT app_name, path, image, format, memory, cpus, type, timeout, idle_timeout, headers, config, annotations, created_at, updated_at FROM routes` callSelector = `SELECT id, created_at, started_at, completed_at, status, app_name, path, stats, error FROM calls` ) @@ -254,12 +256,14 @@ func (ds *sqlStore) InsertApp(ctx context.Context, app *models.App) (*models.App query := ds.db.Rebind(`INSERT INTO apps ( name, config, + annotations, created_at, updated_at ) VALUES ( :name, :config, + :annotations, :created_at, :updated_at );`) @@ -290,7 +294,7 @@ func (ds *sqlStore) UpdateApp(ctx context.Context, newapp *models.App) (*models. err := ds.Tx(func(tx *sqlx.Tx) error { // NOTE: must query whole object since we're returning app, Update logic // must only modify modifiable fields (as seen here). need to fix brittle.. - query := tx.Rebind(`SELECT name, config, created_at, updated_at FROM apps WHERE name=?`) + query := tx.Rebind(`SELECT name, config, annotations, created_at, updated_at FROM apps WHERE name=?`) row := tx.QueryRowxContext(ctx, query, app.Name) err := row.StructScan(app) @@ -301,8 +305,12 @@ func (ds *sqlStore) UpdateApp(ctx context.Context, newapp *models.App) (*models. } app.Update(newapp) + err = app.Validate() + if err != nil { + return err + } - query = tx.Rebind(`UPDATE apps SET config=:config, updated_at=:updated_at WHERE name=:name`) + query = tx.Rebind(`UPDATE apps SET config=:config, annotations=:annotations, updated_at=:updated_at WHERE name=:name`) res, err := tx.NamedExecContext(ctx, query, app) if err != nil { return err @@ -355,7 +363,7 @@ func (ds *sqlStore) RemoveApp(ctx context.Context, appName string) error { } func (ds *sqlStore) GetApp(ctx context.Context, name string) (*models.App, error) { - query := ds.db.Rebind(`SELECT name, config, created_at, updated_at FROM apps WHERE name=?`) + query := ds.db.Rebind(`SELECT name, config, annotations, created_at, updated_at FROM apps WHERE name=?`) row := ds.db.QueryRowxContext(ctx, query, name) var res models.App @@ -378,7 +386,7 @@ func (ds *sqlStore) GetApps(ctx context.Context, filter *models.AppFilter) ([]*m if err != nil { return nil, err } - query = ds.db.Rebind(fmt.Sprintf("SELECT DISTINCT name, config, created_at, updated_at FROM apps %s", query)) + query = ds.db.Rebind(fmt.Sprintf("SELECT DISTINCT name, config, annotations, created_at, updated_at FROM apps %s", query)) rows, err := ds.db.QueryxContext(ctx, query, args...) if err != nil { return nil, err @@ -434,6 +442,7 @@ func (ds *sqlStore) InsertRoute(ctx context.Context, route *models.Route) (*mode idle_timeout, headers, config, + annotations, created_at, updated_at ) @@ -449,6 +458,7 @@ func (ds *sqlStore) InsertRoute(ctx context.Context, route *models.Route) (*mode :idle_timeout, :headers, :config, + :annotations, :created_at, :updated_at );`) @@ -490,6 +500,7 @@ func (ds *sqlStore) UpdateRoute(ctx context.Context, newroute *models.Route) (*m idle_timeout = :idle_timeout, headers = :headers, config = :config, + annotations = :annotations, updated_at = :updated_at WHERE app_name=:app_name AND path=:path;`) diff --git a/api/models/annotations.go b/api/models/annotations.go new file mode 100644 index 000000000..b9a0abf57 --- /dev/null +++ b/api/models/annotations.go @@ -0,0 +1,238 @@ +package models + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "regexp" +) + +// Annotations encapsulates key-value metadata associated with resource. The structure is immutable via its public API and nil-safe for its contract +// permissive nilability is here to simplify updates and reduce the need for nil handling in extensions - annotations should be updated by over-writing the original object: +// target.Annotations = target.Annotations.With("fooKey",1) +// old MD remains empty +// Annotations is lenable +type Annotations map[string]*annotationValue + +// annotationValue encapsulates a value in the annotations map, +// This is stored in its compacted, un-parsed JSON format for later (re-) parsing into specific structs or values +// annotationValue objects are immutable after JSON load +type annotationValue []byte + +const ( + maxAnnotationValueBytes = 512 + maxAnnotationKeyBytes = 128 + maxAnnotationsKeys = 100 +) + +// Equals is defined based on un-ordered k/v comparison at of the annotation keys and (compacted) values of annotations, JSON object-value equality for values is property-order dependent +func (m Annotations) Equals(other Annotations) bool { + if len(m) != len(other) { + return false + } + for k1, v1 := range m { + v2, _ := other[k1] + if v2 == nil { + return false + } + if !bytes.Equal(*v1, *v2) { + return false + } + } + return true +} + +func EmptyAnnotations() Annotations { + return nil +} + +func (mv *annotationValue) String() string { + return string(*mv) +} + +func (v *annotationValue) MarshalJSON() ([]byte, error) { + return *v, nil +} + +func (mv *annotationValue) isEmptyValue() bool { + sval := string(*mv) + return sval == "\"\"" || sval == "null" +} + +// UnmarshalJSON compacts annotation values but does not alter key-ordering for keys +func (mv *annotationValue) UnmarshalJSON(val []byte) error { + buf := bytes.Buffer{} + err := json.Compact(&buf, val) + if err != nil { + return err + } + if err != nil { + return err + } + *mv = buf.Bytes() + return nil +} + +var validKeyRegex = regexp.MustCompile("^[!-~]+$") + +func validateField(key string, value annotationValue) APIError { + + if !validKeyRegex.Match([]byte(key)) { + return ErrInvalidAnnotationKey + } + + keyLen := len([]byte(key)) + + if keyLen > maxAnnotationKeyBytes { + return ErrInvalidAnnotationKeyLength + } + + if value.isEmptyValue() { + return ErrInvalidAnnotationValue + } + + if len(value) > maxAnnotationValueBytes { + return ErrInvalidAnnotationValueLength + } + + return nil +} + +// With Creates a new annotations object containing the specified value - this does not perform size checks on the total number of keys +// this validates the correctness of the key and value. this returns a new the annotations object with the key set. +func (m Annotations) With(key string, data interface{}) (Annotations, error) { + + if data == nil || data == "" { + return nil, errors.New("empty annotation value") + } + + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, err + } + + newVal := jsonBytes + err = validateField(key, newVal) + + if err != nil { + return nil, err + } + + var newMd Annotations + if m == nil { + newMd = make(Annotations, 1) + } else { + newMd = m.clone() + } + mv := annotationValue(newVal) + newMd[key] = &mv + return newMd, nil +} + +// Validate validates a final annotations object prior to store, +// This will reject partial/patch changes with empty values (containing deletes) +func (m Annotations) Validate() APIError { + + for k, v := range m { + err := validateField(k, *v) + if err != nil { + return err + } + } + + if len(m) > maxAnnotationsKeys { + return ErrTooManyAnnotationKeys + } + return nil +} + +// Get returns a raw JSON value of a annotation key +func (m Annotations) Get(key string) ([]byte, bool) { + if v, ok := m[key]; ok { + return *v, ok + } + return nil, false +} + +// Without returns a new annotations object with a value excluded +func (m Annotations) Without(key string) Annotations { + nuVal := m.clone() + delete(nuVal, key) + return nuVal +} + +// MergeChange merges a delta (possibly including deletes) with an existing annotations object and returns a new (copy) annotations object or an error. +// This assumes that both old and new annotations objects contain only valid keys and only newVs may contain deletes +func (m Annotations) MergeChange(newVs Annotations) Annotations { + newMd := m.clone() + + for k, v := range newVs { + if v.isEmptyValue() { + delete(newMd, k) + } else { + if newMd == nil { + newMd = make(Annotations) + } + newMd[k] = v + } + } + + if len(newMd) == 0 { + return EmptyAnnotations() + } + return newMd +} + +// clone produces a key-wise copy of the underlying annotations +// publically MD can be copied by reference as it's (by contract) immutable +func (m Annotations) clone() Annotations { + + if m == nil { + return nil + } + newMd := make(Annotations, len(m)) + for ok, ov := range m { + newMd[ok] = ov + } + return newMd +} + +// Value implements sql.Valuer, returning a string +func (m Annotations) Value() (driver.Value, error) { + if len(m) < 1 { + return driver.Value(string("")), nil + } + var b bytes.Buffer + err := json.NewEncoder(&b).Encode(m) + return driver.Value(b.String()), err +} + +// Scan implements sql.Scanner +func (m *Annotations) Scan(value interface{}) error { + if value == nil || value == "" { + *m = nil + return nil + } + bv, err := driver.String.ConvertValue(value) + if err == nil { + var b []byte + switch x := bv.(type) { + case []byte: + b = x + case string: + b = []byte(x) + } + + if len(b) > 0 { + return json.Unmarshal(b, m) + } + + *m = nil + return nil + } + + // otherwise, return an error + return fmt.Errorf("annotations invalid db format: %T %T value, err: %v", value, bv, err) +} diff --git a/api/models/annotations_test.go b/api/models/annotations_test.go new file mode 100644 index 000000000..7605ab36a --- /dev/null +++ b/api/models/annotations_test.go @@ -0,0 +1,261 @@ +package models + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +type testObj struct { + Md Annotations `json:"annotations,omitempty"` +} + +type myJson struct { + Foo string `json:"foo,omitempty"` + Bar string `json:"bar,omitempty"` +} + +func (m Annotations) withRawKey(key string, val string) Annotations { + newMd := make(Annotations) + for k, v := range m { + newMd[k] = v + } + + v := annotationValue([]byte(val)) + newMd[key] = &v + return newMd +} + +func mustParseMd(t *testing.T, md string) Annotations { + mdObj := make(Annotations) + + err := json.Unmarshal([]byte(md), &mdObj) + if err != nil { + t.Fatalf("Failed to parse must-parse value %s %v", md, err) + } + return mdObj +} + +func TestAnnotationsEqual(t *testing.T) { + annWithVal, _ := EmptyAnnotations().With("foo", "Bar") + + tcs := []struct { + a Annotations + b Annotations + equals bool + }{ + {EmptyAnnotations(), EmptyAnnotations(), true}, + {annWithVal, EmptyAnnotations(), false}, + {annWithVal, annWithVal, true}, + {EmptyAnnotations().withRawKey("v1", `"a"`), EmptyAnnotations().withRawKey("v1", `"b"`), false}, + {EmptyAnnotations().withRawKey("v1", `"a"`), EmptyAnnotations().withRawKey("v2", `"a"`), false}, + + {annWithVal.Without("foo"), EmptyAnnotations(), true}, + {mustParseMd(t, + "{ \r\n\t"+`"md.1":{ `+"\r\n\t"+` + + "subkey1": "value\n with\n newlines", + + "subkey2": true + } + }`), mustParseMd(t, `{"md.1":{"subkey1":"value\n with\n newlines", "subkey2":true}}`), true}, + } + + for _, tc := range tcs { + if tc.a.Equals(tc.b) != tc.equals { + t.Errorf("Annotations equality mismatch - expecting (%v == %v) = %v", tc.b, tc.a, tc.equals) + } + if tc.b.Equals(tc.a) != tc.equals { + t.Errorf("Annotations reflexive equality mismatch - expecting (%v == %v) = %v", tc.b, tc.a, tc.equals) + } + } + +} + +var annCases = []struct { + val *testObj + valString string +}{ + {val: &testObj{Md: EmptyAnnotations()}, valString: "{}"}, + {val: &testObj{Md: EmptyAnnotations().withRawKey("stringval", `"bar"`)}, valString: `{"annotations":{"stringval":"bar"}}`}, + {val: &testObj{Md: EmptyAnnotations().withRawKey("intval", `1001`)}, valString: `{"annotations":{"intval":1001}}`}, + {val: &testObj{Md: EmptyAnnotations().withRawKey("floatval", "3.141")}, valString: `{"annotations":{"floatval":3.141}}`}, + {val: &testObj{Md: EmptyAnnotations().withRawKey("objval", `{"foo":"fooval","bar":"barval"}`)}, valString: `{"annotations":{"objval":{"foo":"fooval","bar":"barval"}}}`}, + {val: &testObj{Md: EmptyAnnotations().withRawKey("objval", `{"foo":"fooval","bar":{"barbar":"barbarval"}}`)}, valString: `{"annotations":{"objval":{"foo":"fooval","bar":{"barbar":"barbarval"}}}}`}, + {val: &testObj{Md: EmptyAnnotations().withRawKey("objval", `{"foo":"JSON newline \\n string"}`)}, valString: `{"annotations":{"objval":{"foo":"JSON newline \\n string"}}}`}, +} + +func TestAnnotationsJSONMarshalling(t *testing.T) { + + for _, tc := range annCases { + v, err := json.Marshal(tc.val) + if err != nil { + t.Fatalf("Failed to marshal json into %s: %v", tc.valString, err) + } + if string(v) != tc.valString { + t.Errorf("Invalid annotations value, expected %s, got %s", tc.valString, string(v)) + } + } + +} + +func TestAnnotationsJSONUnMarshalling(t *testing.T) { + + for _, tc := range annCases { + tv := testObj{} + err := json.Unmarshal([]byte(tc.valString), &tv) + if err != nil { + t.Fatalf("Failed to unmarshal json into %s: %v", tc.valString, err) + } + if !reflect.DeepEqual(&tv, tc.val) { + t.Errorf("Invalid annotations value, expected %v, got %v", tc.val, tv) + } + } + +} + +func TestAnnotationsWithHonorsKeyLimits(t *testing.T) { + var validKeys = []string{ + "ok", + strings.Repeat("a", maxAnnotationKeyBytes), + "fnproject/internal/foo", + "foo.bar.com.baz", + "foo$bar!_+-()[]:@/<>$", + } + for _, k := range validKeys { + m, err := EmptyAnnotations().With(k, "value") + + if err != nil { + t.Errorf("Should have accepted valid key %s,%v", k, err) + } + + err = m.Validate() + if err != nil { + t.Errorf("Should have validate valid key %s,%v", k, err) + } + + } + + var invalidKeys = []struct { + key string + err APIError + }{ + {"", ErrInvalidAnnotationKey}, + {" ", ErrInvalidAnnotationKey}, + {"\u00e9", ErrInvalidAnnotationKey}, + {"foo bar", ErrInvalidAnnotationKey}, + {strings.Repeat("a", maxAnnotationKeyBytes+1), ErrInvalidAnnotationKeyLength}, + } + for _, kc := range invalidKeys { + _, err := EmptyAnnotations().With(kc.key, "value") + if err == nil { + t.Errorf("Should have rejected invalid key %s", kc.key) + } + + m := EmptyAnnotations().withRawKey(kc.key, "\"data\"") + err = m.Validate() + if err != kc.err { + t.Errorf("Should have returned validation error %v, for key %s got %v", kc.err, kc.key, err) + } + + } + +} + +func TestAnnotationsHonorsValueLimits(t *testing.T) { + validValues := []interface{}{ + "ok", + &myJson{Foo: "foo"}, + strings.Repeat("a", maxAnnotationValueBytes-2), + []string{strings.Repeat("a", maxAnnotationValueBytes-4)}, + + 1, + []string{"a", "b", "c"}, + true, + } + + for _, v := range validValues { + + _, err := EmptyAnnotations().With("key", v) + if err != nil { + t.Errorf("Should have accepted valid value %s,%v", v, err) + } + + rawJson, err := json.Marshal(v) + if err != nil { + panic(err) + } + md := EmptyAnnotations().withRawKey("key", string(rawJson)) + + err = md.Validate() + if err != nil { + t.Errorf("Should have validated valid value successfully %s, got error %v", string(rawJson), err) + } + } + + invalidValues := []struct { + val interface{} + err APIError + }{ + {"", ErrInvalidAnnotationValue}, + {nil, ErrInvalidAnnotationValue}, + {strings.Repeat("a", maxAnnotationValueBytes-1), ErrInvalidAnnotationValueLength}, + {[]string{strings.Repeat("a", maxAnnotationValueBytes-3)}, ErrInvalidAnnotationValueLength}, + } + + for _, v := range invalidValues { + _, err := EmptyAnnotations().With("key", v.val) + if err == nil { + t.Errorf("Should have rejected invalid value \"%v\"", v) + } + + rawJson, err := json.Marshal(v.val) + if err != nil { + panic(err) + } + md := EmptyAnnotations().withRawKey("key", string(rawJson)) + + err = md.Validate() + if err != v.err { + t.Errorf("Expected validation error %v for '%s', got %v", v.err, string(rawJson), err) + } + } + +} + +func TestMergeAnnotations(t *testing.T) { + + mdWithNKeys := func(n int) Annotations { + md := EmptyAnnotations() + for i := 0; i < n; i++ { + md = md.withRawKey(fmt.Sprintf("key-%d", i), "val") + } + return md + } + validCases := []struct { + first Annotations + second Annotations + result Annotations + }{ + {first: EmptyAnnotations(), second: EmptyAnnotations(), result: EmptyAnnotations()}, + {first: EmptyAnnotations().withRawKey("key1", "\"val\""), second: EmptyAnnotations(), result: EmptyAnnotations().withRawKey("key1", "\"val\"")}, + {first: EmptyAnnotations(), second: EmptyAnnotations().withRawKey("key1", "\"val\""), result: EmptyAnnotations().withRawKey("key1", "\"val\"")}, + {first: EmptyAnnotations().withRawKey("key1", "\"val\""), second: EmptyAnnotations().withRawKey("key1", "\"val\""), result: EmptyAnnotations().withRawKey("key1", "\"val\"")}, + {first: EmptyAnnotations().withRawKey("key1", "\"val1\""), second: EmptyAnnotations().withRawKey("key2", "\"val2\""), result: EmptyAnnotations().withRawKey("key1", "\"val1\"").withRawKey("key2", "\"val2\"")}, + {first: EmptyAnnotations().withRawKey("key1", "\"val1\""), second: EmptyAnnotations().withRawKey("key1", "\"\""), result: EmptyAnnotations()}, + {first: EmptyAnnotations().withRawKey("key1", "\"val1\""), second: EmptyAnnotations().withRawKey("key2", "\"\""), result: EmptyAnnotations().withRawKey("key1", "\"val1\"")}, + {first: mdWithNKeys(maxAnnotationsKeys - 1), second: EmptyAnnotations().withRawKey("newkey", "\"val\""), result: mdWithNKeys(maxAnnotationsKeys-1).withRawKey("newkey", "\"val\"")}, + } + + for _, v := range validCases { + newMd := v.first.MergeChange(v.second) + + if !reflect.DeepEqual(newMd, v.result) { + t.Errorf("Change %v + %v : expected %v got %v", v.first, v.second, v.result, newMd) + } + + } + +} diff --git a/api/models/app.go b/api/models/app.go index 6bba0a4ea..052c01027 100644 --- a/api/models/app.go +++ b/api/models/app.go @@ -8,10 +8,11 @@ import ( ) type App struct { - Name string `json:"name" db:"name"` - Config Config `json:"config,omitempty" db:"config"` - CreatedAt strfmt.DateTime `json:"created_at,omitempty" db:"created_at"` - UpdatedAt strfmt.DateTime `json:"updated_at,omitempty" db:"updated_at"` + Name string `json:"name" db:"name"` + Config Config `json:"config,omitempty" db:"config"` + Annotations Annotations `json:"annotations,omitempty" db:"annotations"` + CreatedAt strfmt.DateTime `json:"created_at,omitempty" db:"created_at"` + UpdatedAt strfmt.DateTime `json:"updated_at,omitempty" db:"updated_at"` } func (a *App) SetDefaults() { @@ -39,6 +40,10 @@ func (a *App) Validate() error { return ErrAppsInvalidName } } + err := a.Annotations.Validate() + if err != nil { + return err + } return nil } @@ -53,6 +58,7 @@ func (a *App) Clone() *App { clone.Config[k] = v } } + return clone } @@ -63,6 +69,7 @@ func (a1 *App) Equals(a2 *App) bool { eq := true eq = eq && a1.Name == a2.Name eq = eq && a1.Config.Equals(a2.Config) + eq = eq && a1.Annotations.Equals(a2.Annotations) // NOTE: datastore tests are not very fun to write with timestamp checks, // and these are not values the user may set so we kind of don't care. //eq = eq && time.Time(a1.CreatedAt).Equal(time.Time(a2.CreatedAt)) @@ -70,15 +77,15 @@ func (a1 *App) Equals(a2 *App) bool { return eq } -// Update adds entries from patch to a.Config, and removes entries with empty values. -func (a *App) Update(src *App) { +// Update adds entries from patch to a.Config and a.Annotations, and removes entries with empty values. +func (a *App) Update(patch *App) { original := a.Clone() - if src.Config != nil { + if patch.Config != nil { if a.Config == nil { a.Config = make(Config) } - for k, v := range src.Config { + for k, v := range patch.Config { if v == "" { delete(a.Config, k) } else { @@ -87,6 +94,8 @@ func (a *App) Update(src *App) { } } + a.Annotations = a.Annotations.MergeChange(patch.Annotations) + if !a.Equals(original) { a.UpdatedAt = strfmt.DateTime(time.Now()) } diff --git a/api/models/error.go b/api/models/error.go index 9e5ffe559..0de55ffb6 100644 --- a/api/models/error.go +++ b/api/models/error.go @@ -185,6 +185,26 @@ var ( code: http.StatusBadGateway, error: fmt.Errorf("function response too large"), } + ErrInvalidAnnotationKey = err{ + code: http.StatusBadRequest, + error: errors.New("Invalid annotation key, annotation keys must be non-empty ascii strings excluding whitespace"), + } + ErrInvalidAnnotationKeyLength = err{ + code: http.StatusBadRequest, + error: fmt.Errorf("Invalid annotation key length, annotation keys may not be larger than %d bytes", maxAnnotationKeyBytes), + } + ErrInvalidAnnotationValue = err{ + code: http.StatusBadRequest, + error: errors.New("Invalid annotation value, annotation values may only be non-empty strings, numbers, objects, or arrays"), + } + ErrInvalidAnnotationValueLength = err{ + code: http.StatusBadRequest, + error: fmt.Errorf("Invalid annotation value length, annotation values may not be larger than %d bytes when serialized as JSON", maxAnnotationValueBytes), + } + ErrTooManyAnnotationKeys = err{ + code: http.StatusBadRequest, + error: fmt.Errorf("Invalid annotation change, new key(s) exceed maximum permitted number of annotations keys (%d)", maxAnnotationsKeys), + } ) // APIError any error that implements this interface will return an API response diff --git a/api/models/route.go b/api/models/route.go index f08c4e0f3..42f4f985b 100644 --- a/api/models/route.go +++ b/api/models/route.go @@ -36,6 +36,7 @@ type Route struct { Timeout int32 `json:"timeout" db:"timeout"` IdleTimeout int32 `json:"idle_timeout" db:"idle_timeout"` Config Config `json:"config,omitempty" db:"config"` + Annotations Annotations `json:"annotations,omitempty" db:"annotations"` CreatedAt strfmt.DateTime `json:"created_at,omitempty" db:"created_at"` UpdatedAt strfmt.DateTime `json:"updated_at,omitempty" db:"updated_at"` } @@ -129,6 +130,11 @@ func (r *Route) Validate() error { return ErrRoutesInvalidMemory } + err = r.Annotations.Validate() + if err != nil { + return err + } + return nil } @@ -169,6 +175,7 @@ func (r1 *Route) Equals(r2 *Route) bool { eq = eq && r1.Timeout == r2.Timeout eq = eq && r1.IdleTimeout == r2.IdleTimeout eq = eq && r1.Config.Equals(r2.Config) + eq = eq && r1.Annotations.Equals(r2.Annotations) // NOTE: datastore tests are not very fun to write with timestamp checks, // and these are not values the user may set so we kind of don't care. //eq = eq && time.Time(r1.CreatedAt).Equal(time.Time(r2.CreatedAt)) @@ -179,35 +186,35 @@ func (r1 *Route) Equals(r2 *Route) bool { // Update updates fields in r with non-zero field values from new, and sets // updated_at if any of the fields change. 0-length slice Header values, and // empty-string Config values trigger removal of map entry. -func (r *Route) Update(new *Route) { +func (r *Route) Update(patch *Route) { original := r.Clone() - if new.Image != "" { - r.Image = new.Image + if patch.Image != "" { + r.Image = patch.Image } - if new.Memory != 0 { - r.Memory = new.Memory + if patch.Memory != 0 { + r.Memory = patch.Memory } - if new.CPUs != 0 { - r.CPUs = new.CPUs + if patch.CPUs != 0 { + r.CPUs = patch.CPUs } - if new.Type != "" { - r.Type = new.Type + if patch.Type != "" { + r.Type = patch.Type } - if new.Timeout != 0 { - r.Timeout = new.Timeout + if patch.Timeout != 0 { + r.Timeout = patch.Timeout } - if new.IdleTimeout != 0 { - r.IdleTimeout = new.IdleTimeout + if patch.IdleTimeout != 0 { + r.IdleTimeout = patch.IdleTimeout } - if new.Format != "" { - r.Format = new.Format + if patch.Format != "" { + r.Format = patch.Format } - if new.Headers != nil { + if patch.Headers != nil { if r.Headers == nil { r.Headers = Headers(make(http.Header)) } - for k, v := range new.Headers { + for k, v := range patch.Headers { if len(v) == 0 { http.Header(r.Headers).Del(k) } else { @@ -215,11 +222,11 @@ func (r *Route) Update(new *Route) { } } } - if new.Config != nil { + if patch.Config != nil { if r.Config == nil { r.Config = make(Config) } - for k, v := range new.Config { + for k, v := range patch.Config { if v == "" { delete(r.Config, k) } else { @@ -228,6 +235,8 @@ func (r *Route) Update(new *Route) { } } + r.Annotations = r.Annotations.MergeChange(patch.Annotations) + if !r.Equals(original) { r.UpdatedAt = strfmt.DateTime(time.Now()) } diff --git a/api/server/apps_test.go b/api/server/apps_test.go index 75b311062..d2eed2201 100644 --- a/api/server/apps_test.go +++ b/api/server/apps_test.go @@ -52,9 +52,11 @@ func TestAppCreate(t *testing.T) { {datastore.NewMock(), logs.NewMock(), "/v1/apps", `{ "app": { "name": "1234567890123456789012345678901" } }`, http.StatusBadRequest, models.ErrAppsTooLongName}, {datastore.NewMock(), logs.NewMock(), "/v1/apps", `{ "app": { "name": "&&%@!#$#@$" } }`, http.StatusBadRequest, models.ErrAppsInvalidName}, {datastore.NewMock(), logs.NewMock(), "/v1/apps", `{ "app": { "name": "&&%@!#$#@$" } }`, http.StatusBadRequest, models.ErrAppsInvalidName}, - + {datastore.NewMock(), logs.NewMock(), "/v1/apps", `{ "app": { "name": "app", "annotations" : { "":"val" }}}`, http.StatusBadRequest, models.ErrInvalidAnnotationKey}, + {datastore.NewMock(), logs.NewMock(), "/v1/apps", `{ "app": { "name": "app", "annotations" : { "key":"" }}}`, http.StatusBadRequest, models.ErrInvalidAnnotationValue}, // success {datastore.NewMock(), logs.NewMock(), "/v1/apps", `{ "app": { "name": "teste" } }`, http.StatusOK, nil}, + {datastore.NewMock(), logs.NewMock(), "/v1/apps", `{ "app": { "name": "teste" , "annotations": {"k1":"v1", "k2":[]}}}`, http.StatusOK, nil}, } { rnr, cancel := testRunner(t) srv := testServer(test.mock, &mqs.Mock{}, test.logDB, rnr, ServerTypeFull) @@ -72,8 +74,8 @@ func TestAppCreate(t *testing.T) { resp := getErrorResponse(t, rec) if !strings.Contains(resp.Error.Message, test.expectedError.Error()) { - t.Errorf("Test %d: Expected error message to have `%s`", - i, test.expectedError.Error()) + t.Errorf("Test %d: Expected error message to have `%s` but got `%s`", + i, test.expectedError.Error(), resp.Error.Message) } } @@ -279,6 +281,20 @@ func TestAppUpdate(t *testing.T) { // errors {datastore.NewMock(), logs.NewMock(), "/v1/apps/myapp", ``, http.StatusBadRequest, models.ErrInvalidJSON}, + // Addresses #380 + {datastore.NewMockInit( + []*models.App{{ + Name: "myapp", + }}, nil, nil, + ), logs.NewMock(), "/v1/apps/myapp", `{ "app": { "name": "othername" } }`, http.StatusConflict, nil}, + + // success: add/set MD key + {datastore.NewMockInit( + []*models.App{{ + Name: "myapp", + }}, nil, nil, + ), logs.NewMock(), "/v1/apps/myapp", `{ "app": { "annotations": {"k-0" : "val"} } }`, http.StatusOK, nil}, + // success {datastore.NewMockInit( []*models.App{{ @@ -286,12 +302,12 @@ func TestAppUpdate(t *testing.T) { }}, nil, nil, ), logs.NewMock(), "/v1/apps/myapp", `{ "app": { "config": { "test": "1" } } }`, http.StatusOK, nil}, - // Addresses #380 + // success {datastore.NewMockInit( []*models.App{{ Name: "myapp", }}, nil, nil, - ), logs.NewMock(), "/v1/apps/myapp", `{ "app": { "name": "othername" } }`, http.StatusConflict, nil}, + ), logs.NewMock(), "/v1/apps/myapp", `{ "app": { "config": { "test": "1" } } }`, http.StatusOK, nil}, } { rnr, cancel := testRunner(t) srv := testServer(test.mock, &mqs.Mock{}, test.logDB, rnr, ServerTypeFull) @@ -308,8 +324,8 @@ func TestAppUpdate(t *testing.T) { resp := getErrorResponse(t, rec) if !strings.Contains(resp.Error.Message, test.expectedError.Error()) { - t.Errorf("Test %d: Expected error message to have `%s`", - i, test.expectedError.Error()) + t.Errorf("Test %d: Expected error message to have `%s` but was `%s`", + i, test.expectedError.Error(), resp.Error.Message) } } diff --git a/docs/README.md b/docs/README.md index f8c2074d1..7e99f2382 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,3 +57,4 @@ If you are operating Fn, this section is for you. If you are working on the Fn Project, want to work on it or are creating extensions, this section is for you. * [Writing Extensions](contributors/extensions.md) +* [Using Annotations](contributors/annotations.md) diff --git a/docs/contributors/annotations.md b/docs/contributors/annotations.md new file mode 100644 index 000000000..cf9c8d1d2 --- /dev/null +++ b/docs/contributors/annotations.md @@ -0,0 +1,151 @@ +# App and Route Annotations + +App and Route annotations allow users building on the Fn platform to encode consumer-specific data inline with Apps and Routes. + +Annotations can be used to either communicate and carry information externally (only by API users) or to communicate between external applications and Fn extensions. + +## Use cases/Examples: + +### Externally defined/consumed annotations + +Software using Fn as a service, attaches non-identifying metadata annotatinos to Fn resources for subsequent reads (e.g. my reference for this function/app ) + +Writer : API user, Reader: API user + +E.g. platform "platX" creates/modifies functions, has an internal reference it wants to associate with an object for later retrieval (it can't query/search Fn by this attribute) + +``` +POST /v1/apps + + { + ... + "annotations" : { + "platx.com/ref" : "adsfasdfads" + } + ... + } +``` + +### Extensions: Allow passing data from user or upstream input to extensions (configuration of extensions) + +Writer : API user, Reader: Fn platform extension (use), API user (informational) + +``` +POST /v1/apps +... + { + ... + "annotations" : { + "my_cloud_provider.com/network_id" : "network.id" + } + ... +} +``` + +### Extensions: Allow indicating internally derived/set values to user (API extension sets/generates annotations, prevents user from changing it) + +Writer : Internal platform extension, Reader: API user. + + +``` +GET /v1/apps/myapp +... + { + ... + "annotations" : { + "my_cloud_provider.com/create_user" : "foo@foo.com" + } + ... +} +``` + + + +``` +PATCH /v1/apps/myapp +... + { + ... + "annotations" : { + "my_cloud_provider.com/create_user" : "foo@foo.com" + } + ... +} + +HTTP/1.1 400 Invalid operation + +{ + "error": "annotation key cannot be changed", +} +``` + +## Content Examples +example : user attaches local annotations + +```json +PUT /v1/apps/foo + +{ + app: { + ... + "annotations": { + "mylabel": "super-cool-fn", + "myMetaData": { + "k1": "foo", + "number": 5000 + "array" : [1,2,3] + } + } + ... + } +} +``` + +User sets extension-specific annotations: + +``` +PUT /v1/apps/foo +{ + ... + "annotations": { + "example.extension.com/v1/myval" : "val" + } + ... +} +``` + +## Key Syntax and Namespaces + +A key consists of any printable (non-extended) ascii characters excluding whitespace characters. + +The maximum (byte) size of a key is 128 bytes (excluding quotes). + +Keys are stored as free text with the object. Normatively extensions and systems using annotations *must* use a namespace prefix based on an identified domain and followed by at least one '/' character. + +Systems *should* use independent annotation keys for any value that can be changed independently. + +Extensions *should not* interact with annotations keys that are not prefixed with a domain they own. + +## Value syntax + +Values may contain any valid JSON value (object/array/string/number) except the empty string `""` and `null` + +The serialised JSON representation (rendered without excess whitespace as a string) of a single value must not exceed a 512 bytes. + +## Modifying and deleting annotation keys + +A key can be modified by a PATCH operation containing a partial `annotations` object indicating the keys to update (or delete) + +A key can be deleted by a PATCH operation by setting its value to an empty string. + +For each element that of data that can be changed independently, you *should* use a new top-level annotation key. + +## Maximum number of keys + +A user may not add keys in a PATCH or PUT operation if the total number of keys after changes exceeds 100 keys. + +Fn may return a larger number of keys. + +## Extension interaction with resource modification + +An extension may prevent a PUT,PATCH or POST operation on a domain object based on the value of an annotation passed in by a user, in this case this should result in an HTTP 400 error with an informational message indicating that an error was present in the annotations and containing the exact key or keys which caused the error. diff --git a/docs/swagger.yml b/docs/swagger.yml index 8bb431d97..d88c3d42a 100644 --- a/docs/swagger.yml +++ b/docs/swagger.yml @@ -519,6 +519,11 @@ definitions: default: 30 format: int32 description: Hot functions idle timeout before termination. Value in Seconds + annotations: + type: object + description: Route annotations - this is a map of annotations attached to this route, keys must not exceed 128 bytes and must consist of non-whitespace printable ascii characters, and the seralized representation of individual values must not exeed 512 bytes + additionalProperties: + type: object created_at: type: string format: date-time @@ -539,9 +544,14 @@ definitions: readOnly: true config: type: object - description: Application configuration, applied to all routes. + description: Application function configuration, applied to all routes. additionalProperties: type: string + annotations: + type: object + description: Application annotations - this is a map of annotations attached to this app, keys must not exceed 128 bytes and must consist of non-whitespace printable ascii characters, and the seralized representation of individual values must not exeed 512 bytes + additionalProperties: + type: object created_at: type: string format: date-time diff --git a/test/fn-api-tests/README.md b/test/fn-api-tests/README.md index 1fb512062..a9a1cac2f 100644 --- a/test/fn-api-tests/README.md +++ b/test/fn-api-tests/README.md @@ -2,12 +2,15 @@ FN integration API tests ====================================== +These are tests that can either run locally against the current codebase (e.g. in an IDE) or remotely against a running Fn instance. + + Test dependencies ----------------- ```bash DOCKER_HOST - for building images -FN_API_URL - Fn API endpoint +FN_API_URL - Fn API endpoint - leave this unset to test using the local codebase ``` How to run tests? diff --git a/test/fn-api-tests/annotation_cases.go b/test/fn-api-tests/annotation_cases.go new file mode 100644 index 000000000..dab2e0b1f --- /dev/null +++ b/test/fn-api-tests/annotation_cases.go @@ -0,0 +1,84 @@ +package tests + +import ( + "fmt" + "reflect" + "strings" +) + +// common test cases around annotations (shared by any objects that support it) + +const ( + maxAnnotationKeys = 100 + maxAnnotationValueSize = 512 + maxAnnotationKeySize = 128 +) + +var emptyAnnMap = map[string]interface{}{} + +func makeAnnMap(size int) map[string]interface{} { + md := make(map[string]interface{}, size) + for i := 0; i < size; i++ { + md[fmt.Sprintf("k-%d", i)] = "val" + } + return md +} + +var createAnnotationsValidCases = []struct { + name string + annotations map[string]interface{} +}{ + {"valid_string", map[string]interface{}{"key": "value"}}, + {"valid_array", map[string]interface{}{"key": []interface{}{"value1", "value2"}}}, + {"valid_object", map[string]interface{}{"key": map[string]interface{}{"foo": "bar"}}}, + {"max_value_size", map[string]interface{}{"key": strings.Repeat("a", maxAnnotationValueSize-2)}}, + {"max_key_size", map[string]interface{}{strings.Repeat("a", maxAnnotationKeySize): "val"}}, + {"max_map_size", makeAnnMap(maxAnnotationKeys)}, +} + +var createAnnotationsErrorCases = []struct { + name string + annotations map[string]interface{} +}{ + {"value_too_long", map[string]interface{}{"key": strings.Repeat("a", maxAnnotationValueSize-1)}}, + {"key_too_long", map[string]interface{}{strings.Repeat("a", maxAnnotationKeySize+1): "value"}}, + {"whitespace_in_key", map[string]interface{}{" bad key ": "value"}}, + {"too_many_keys", makeAnnMap(maxAnnotationKeys + 1)}, +} + +var updateAnnotationsValidCases = []struct { + name string + initial map[string]interface{} + change map[string]interface{} + expected map[string]interface{} +}{ + {"overwrite_existing_annotation_keys", map[string]interface{}{"key": "value1"}, map[string]interface{}{"key": "value2"}, map[string]interface{}{"key": "value2"}}, + {"delete_annotation_key", map[string]interface{}{"key": "value1"}, map[string]interface{}{"key": ""}, map[string]interface{}{}}, + {"set_to_max_size_with_deletes", map[string]interface{}{"key": "value1"}, func() map[string]interface{} { + md := makeAnnMap(100) + md["key"] = "" + return md + }(), makeAnnMap(100)}, + {"noop_with_max_keys", makeAnnMap(maxAnnotationKeys), emptyAnnMap, makeAnnMap(maxAnnotationKeys)}, +} + +var updateAnnotationsErrorCases = []struct { + name string + initial map[string]interface{} + change map[string]interface{} +}{ + {"too_many_key_after_update", makeAnnMap(100), map[string]interface{}{"key": "value1"}}, + {"value_too_long", map[string]interface{}{}, map[string]interface{}{"key": strings.Repeat("a", maxAnnotationValueSize-1)}}, + {"key_too_long", map[string]interface{}{}, map[string]interface{}{strings.Repeat("a", maxAnnotationKeySize+1): "value"}}, + {"whitespace_in_key", map[string]interface{}{}, map[string]interface{}{" bad key ": "value"}}, + {"too_many_keys_in_update", map[string]interface{}{}, makeAnnMap(maxAnnotationKeys + 1)}, +} + +//AnnotationsEquivalent checks if two annotations maps are semantically equivalent, including nil == empty map +func AnnotationsEquivalent(md1, md2 map[string]interface{}) bool { + + if len(md1) == 0 && len(md2) == 0 { + return true + } + return reflect.DeepEqual(md1, md2) +} diff --git a/test/fn-api-tests/apps_api.go b/test/fn-api-tests/apps_api.go index cbfeffe15..e5254d5f8 100644 --- a/test/fn-api-tests/apps_api.go +++ b/test/fn-api-tests/apps_api.go @@ -2,6 +2,7 @@ package tests import ( "context" + "log" "strings" "testing" "time" @@ -11,117 +12,57 @@ import ( "github.com/fnproject/fn_go/models" ) -func CheckAppResponseError(t *testing.T, e error) { - if e != nil { - switch err := e.(type) { - case *apps.DeleteAppsAppNotFound: - t.Errorf("Unexpected error occurred: %v Original Location: %s", err.Payload.Error.Message, MyCaller()) - t.FailNow() - case *apps.DeleteAppsAppDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v Orig Location: %s", err.Payload.Error.Message, err.Code(), MyCaller()) - t.FailNow() - case *apps.PostAppsDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v Orig Location: %s", err.Payload.Error.Message, err.Code(), MyCaller()) - t.FailNow() - case *apps.GetAppsAppNotFound: - if !strings.Contains("App not found", err.Payload.Error.Message) { - t.Errorf("Unexpected error occurred: %v Original Location: %s", err.Payload.Error.Message, MyCaller()) - t.FailNow() - } - case *apps.GetAppsAppDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v Orig Location: %s", err.Payload.Error.Message, err.Code(), MyCaller()) - t.FailNow() - case *apps.PatchAppsAppDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v Orig Location: %s", err.Payload.Error.Message, err.Code(), MyCaller()) - t.FailNow() - case *apps.PatchAppsAppNotFound: - t.Errorf("Unexpected error occurred: %v. Original Location: %s", err.Payload.Error.Message, MyCaller()) - t.FailNow() - case *apps.PatchAppsAppBadRequest: - t.Errorf("Unexpected error occurred: %v. Original Location: %s", err.Payload.Error.Message, MyCaller()) - t.FailNow() - default: - t.Errorf("Unable to determine type of error: %s Original Location: %s", err, MyCaller()) - t.FailNow() - } - } -} - -func CreateAppNoAssert(ctx context.Context, fnclient *client.Fn, appName string, config map[string]string) (*apps.PostAppsOK, error) { +// PostApp creates an app and esures it is deleted on teardown if it was created +func (s *TestHarness) PostApp(app *models.App) (*apps.PostAppsOK, error) { cfg := &apps.PostAppsParams{ Body: &models.AppWrapper{ - App: &models.App{ - Config: config, - Name: appName, - }, + App: app, }, - Context: ctx, + Context: s.Context, } - ok, err := fnclient.Apps.PostApps(cfg) + ok, err := s.Client.Apps.PostApps(cfg) + if err == nil { - approutesLock.Lock() - _, got := appsandroutes[appName] - if !got { - appsandroutes[appName] = []string{} - } - approutesLock.Unlock() + s.createdApps[ok.Payload.App.Name] = true } return ok, err } -func CreateApp(t *testing.T, ctx context.Context, fnclient *client.Fn, appName string, config map[string]string) { - appPayload, err := CreateAppNoAssert(ctx, fnclient, appName, config) - CheckAppResponseError(t, err) - if !strings.Contains(appName, appPayload.Payload.App.Name) { - t.Errorf("App name mismatch.\nExpected: %v\nActual: %v", - appName, appPayload.Payload.App.Name) +// GivenAppExists creates an app and ensures it is deleted on teardown, this fatals if the app is not created +func (s *TestHarness) GivenAppExists(t *testing.T, app *models.App) { + + appPayload, err := s.PostApp(app) + if err != nil { + t.Fatalf("Failed to create app %v", app) + + } + if !strings.Contains(app.Name, appPayload.Payload.App.Name) { + t.Fatalf("App name mismatch.\nExpected: %v\nActual: %v", + app.Name, appPayload.Payload.App.Name) } } -func CreateUpdateApp(t *testing.T, ctx context.Context, fnclient *client.Fn, appName string, config map[string]string) *apps.PatchAppsAppOK { - CreateApp(t, ctx, fnclient, appName, map[string]string{"A": "a"}) - cfg := &apps.PatchAppsAppParams{ - App: appName, - Body: &models.AppWrapper{ - App: &models.App{ - Config: config, - Name: "", - }, - }, - Context: ctx, +// AppMustExist fails the test if the specified app does not exist +func (s *TestHarness) AppMustExist(t *testing.T, appName string) *models.App { + app, err := s.Client.Apps.GetAppsApp(&apps.GetAppsAppParams{ + App: s.AppName, + Context: s.Context, + }) + if err != nil { + t.Fatalf("Expected new route to create app got %v", err) + return nil } - - appPayload, err := fnclient.Apps.PatchAppsApp(cfg) - CheckAppResponseError(t, err) - return appPayload -} - -func DeleteApp(t *testing.T, ctx context.Context, fnclient *client.Fn, appName string) { - cfg := &apps.DeleteAppsAppParams{ - App: appName, - Context: ctx, - } - - _, err := fnclient.Apps.DeleteAppsApp(cfg) - CheckAppResponseError(t, err) -} - -func GetApp(t *testing.T, ctx context.Context, fnclient *client.Fn, appName string) *models.App { - cfg := &apps.GetAppsAppParams{ - App: appName, - Context: ctx, - } - - app, err := fnclient.Apps.GetAppsApp(cfg) - CheckAppResponseError(t, err) return app.Payload.App } -func DeleteAppNoT(ctx context.Context, fnclient *client.Fn, appName string) { +func safeDeleteApp(ctx context.Context, fnclient *client.Fn, appName string) { cfg := &apps.DeleteAppsAppParams{ App: appName, Context: ctx, } cfg.WithTimeout(time.Second * 60) - fnclient.Apps.DeleteAppsApp(cfg) + _, err := fnclient.Apps.DeleteAppsApp(cfg) + if _, ok := err.(*apps.DeleteAppsAppNotFound); err != nil && !ok { + log.Printf("Error cleaning up app %s: %v", appName, err) + } } diff --git a/test/fn-api-tests/apps_test.go b/test/fn-api-tests/apps_test.go index 7dfcdb88c..444ef5357 100644 --- a/test/fn-api-tests/apps_test.go +++ b/test/fn-api-tests/apps_test.go @@ -1,126 +1,300 @@ package tests import ( + "github.com/fnproject/fn_go/client/apps" + "github.com/fnproject/fn_go/models" "reflect" "strings" "testing" - "time" - - "github.com/fnproject/fn_go/client/apps" ) func TestAppDeleteNotFound(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() + s := SetupHarness() + defer s.Cleanup() + cfg := &apps.DeleteAppsAppParams{ App: "missing-app", Context: s.Context, } - cfg.WithTimeout(time.Second * 60) + _, err := s.Client.Apps.DeleteAppsApp(cfg) - if err == nil { + + if _, ok := err.(*apps.DeleteAppsAppNotFound); !ok { t.Errorf("Error during app delete: we should get HTTP 404, but got: %s", err.Error()) } } func TestAppGetNotFound(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() + s := SetupHarness() + defer s.Cleanup() + cfg := &apps.GetAppsAppParams{ App: "missing-app", Context: s.Context, } - cfg.WithTimeout(time.Second * 60) _, err := s.Client.Apps.GetAppsApp(cfg) - CheckAppResponseError(t, err) + + if _, ok := err.(*apps.GetAppsAppNotFound); !ok { + t.Errorf("Error during get: we should get HTTP 404, but got: %s", err.Error()) + } + + if !strings.Contains(err.(*apps.GetAppsAppNotFound).Payload.Error.Message, "App not found") { + t.Errorf("Error during app delete: unexpeted error `%s`, wanted `App not found`", err.Error()) + } } func TestAppCreateNoConfigSuccess(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - DeleteApp(t, s.Context, s.Client, s.AppName) + s := SetupHarness() + defer s.Cleanup() + + resp, err := s.PostApp(&models.App{ + Name: s.AppName, + }) + + if err != nil { + t.Errorf("Failed to create simple app %v", err) + return + } + + if resp.Payload.App.Name != s.AppName { + t.Errorf("app name in response %s does not match new app %s ", resp.Payload.App.Name, s.AppName) + } + +} + +func TestSetAppAnnotationsOnCreate(t *testing.T) { + t.Parallel() + for _, tci := range createAnnotationsValidCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run("valid_"+tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + app, err := s.PostApp(&models.App{ + Name: s.AppName, + Annotations: tc.annotations, + }) + + if err != nil { + t.Fatalf("Failed to create app with valid annotations %v got error %v", tc.annotations, err) + } + + gotMd := app.Payload.App.Annotations + if !AnnotationsEquivalent(gotMd, tc.annotations) { + t.Errorf("Returned annotations %v does not match set annotations %v", gotMd, tc.annotations) + } + + getApp := s.AppMustExist(t, s.AppName) + + if !AnnotationsEquivalent(getApp.Annotations, tc.annotations) { + t.Errorf("GET annotations '%v' does not match set annotations %v", getApp.Annotations, tc.annotations) + } + + }) + } + + for _, tci := range createAnnotationsErrorCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run("invalid_"+tc.name, func(ti *testing.T) { + ti.Parallel() + s := SetupHarness() + defer s.Cleanup() + + _, err := s.PostApp(&models.App{ + Name: s.AppName, + Annotations: tc.annotations, + }) + + if err == nil { + t.Fatalf("Created app with invalid annotations %v but expected error", tc.annotations) + } + + if _, ok := err.(*apps.PostAppsBadRequest); !ok { + t.Errorf("Expecting bad request for invalid annotations, got %v", err) + } + + }) + } +} + +func TestUpdateAppAnnotationsOnPatch(t *testing.T) { + t.Parallel() + + for _, tci := range updateAnnotationsValidCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run("valid_"+tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{ + Name: s.AppName, + Annotations: tc.initial, + }) + + res, err := s.Client.Apps.PatchAppsApp(&apps.PatchAppsAppParams{ + App: s.AppName, + Context: s.Context, + Body: &models.AppWrapper{ + App: &models.App{ + Annotations: tc.change, + }, + }, + }) + + if err != nil { + t.Fatalf("Failed to patch annotations with %v on app: %v", tc.change, err) + } + + gotMd := res.Payload.App.Annotations + if !AnnotationsEquivalent(gotMd, tc.expected) { + t.Errorf("Returned annotations %v does not match set annotations %v", gotMd, tc.expected) + } + + getApp := s.AppMustExist(t, s.AppName) + + if !AnnotationsEquivalent(getApp.Annotations, tc.expected) { + t.Errorf("GET annotations '%v' does not match set annotations %v", getApp.Annotations, tc.expected) + } + }) + } + + for _, tci := range updateAnnotationsErrorCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run("invalid_"+tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{ + Name: s.AppName, + Annotations: tc.initial, + }) + + _, err := s.Client.Apps.PatchAppsApp(&apps.PatchAppsAppParams{ + App: s.AppName, + Context: s.Context, + Body: &models.AppWrapper{ + App: &models.App{ + Annotations: tc.change, + }, + }, + }) + + if err == nil { + t.Errorf("patched app with invalid annotations %v but expected error", tc.change) + } + if _, ok := err.(*apps.PatchAppsAppBadRequest); !ok { + t.Errorf("Expecting bad request for invalid annotations, got %v", err) + } + + }) + } } func TestAppCreateWithConfigSuccess(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{"A": "a"}) - DeleteApp(t, s.Context, s.Client, s.AppName) + s := SetupHarness() + defer s.Cleanup() + + validConfig := map[string]string{"A": "a"} + appPayload, err := s.PostApp(&models.App{ + Name: s.AppName, + Config: validConfig, + }) + + if err != nil { + t.Fatalf("Failed to create app with valid config got %v", err) + } + + if !reflect.DeepEqual(validConfig, appPayload.Payload.App.Config) { + t.Errorf("Expecting config %v but got %v in response", validConfig, appPayload.Payload.App.Config) + } + } func TestAppInsect(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{"A": "a"}) - app := GetApp(t, s.Context, s.Client, s.AppName) - val, ok := app.Config["A"] - if !ok { - t.Error("Error during app config inspect: config map misses required entity `A` with value `a`.") + s := SetupHarness() + defer s.Cleanup() + + validConfig := map[string]string{"A": "a"} + + s.GivenAppExists(t, &models.App{Name: s.AppName, + Config: validConfig}) + + appOk, err := s.Client.Apps.GetAppsApp(&apps.GetAppsAppParams{ + App: s.AppName, + Context: s.Context, + }) + + if err != nil { + t.Fatalf("Expected valid response to get app, got %v", err) } - if !strings.Contains("a", val) { - t.Errorf("App config value is different. Expected: `a`. Actual %v", val) + + if !reflect.DeepEqual(validConfig, appOk.Payload.App.Config) { + t.Errorf("Returned config %v does not match requested config %v", appOk.Payload.App.Config, validConfig) } - DeleteApp(t, s.Context, s.Client, s.AppName) } -func TestAppPatchSameConfig(t *testing.T) { +func TestAppPatchConfig(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - config := map[string]string{ - "A": "a", + + for _, tci := range updateConfigCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{ + Name: s.AppName, + Config: tc.intialConfig, + }) + + patch, err := s.Client.Apps.PatchAppsApp(&apps.PatchAppsAppParams{ + App: s.AppName, + Body: &models.AppWrapper{ + App: &models.App{ + Config: tc.change, + }, + }, + Context: s.Context, + }) + + if err != nil { + t.Fatalf("Failed to patch app with valid value %v, %v", tc.change, err) + } + + if !ConfigEquivalent(patch.Payload.App.Config, tc.expected) { + t.Errorf("Expected returned app config to be %v, but was %v", tc.expected, patch.Payload.App.Config) + } + + }) } - appUpdatePayload := CreateUpdateApp(t, s.Context, s.Client, s.AppName, config) - _, ok := appUpdatePayload.Payload.App.Config["A"] - if !ok { - t.Error("Error during app update: config map misses required entity `A` with value `a`.") - } - - DeleteApp(t, s.Context, s.Client, s.AppName) -} - -func TestAppPatchOverwriteConfig(t *testing.T) { - t.Parallel() - s := SetupDefaultSuite() - config := map[string]string{ - "A": "b", - } - appPayload := CreateUpdateApp(t, s.Context, s.Client, s.AppName, config) - val, ok := appPayload.Payload.App.Config["A"] - if !ok { - t.Error("Error during app config inspect: config map misses required entity `A` with value `a`.") - } - if !strings.Contains("b", val) { - t.Errorf("App config value is different. Expected: `b`. Actual %v", val) - } - DeleteApp(t, s.Context, s.Client, s.AppName) -} - -func TestAppsPatchConfigAddValue(t *testing.T) { - t.Parallel() - s := SetupDefaultSuite() - config := map[string]string{ - "B": "b", - } - appPayload := CreateUpdateApp(t, s.Context, s.Client, s.AppName, config) - val, ok := appPayload.Payload.App.Config["B"] - if !ok { - t.Error("Error during app config inspect: config map misses required entity `B` with value `b`.") - } - if !strings.Contains("b", val) { - t.Errorf("App config value is different. Expected: `b`. Actual %v", val) - } - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestAppDuplicate(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - _, err := CreateAppNoAssert(s.Context, s.Client, s.AppName, map[string]string{}) - if reflect.TypeOf(err) != reflect.TypeOf(apps.NewPostAppsConflict()) { - CheckAppResponseError(t, err) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + + _, err := s.PostApp(&models.App{Name: s.AppName}) + + if _, ok := err.(*apps.PostAppsConflict); !ok { + t.Errorf("Expecting conflict response on duplicate app, got %v", err) } - DeleteApp(t, s.Context, s.Client, s.AppName) + } diff --git a/test/fn-api-tests/calls_test.go b/test/fn-api-tests/calls_test.go index 43b9bf0ab..2bf8155e6 100644 --- a/test/fn-api-tests/calls_test.go +++ b/test/fn-api-tests/calls_test.go @@ -8,11 +8,12 @@ import ( "time" "github.com/fnproject/fn_go/client/call" + "github.com/fnproject/fn_go/models" ) func TestCallsMissingApp(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() + s := SetupHarness() cfg := &call.GetAppsAppCallsParams{ App: s.AppName, Path: &s.RoutePath, @@ -26,10 +27,11 @@ func TestCallsMissingApp(t *testing.T) { func TestCallsDummy(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, s.BasicRoute()) cfg := &call.GetAppsAppCallsCallParams{ Call: "dummy", @@ -42,15 +44,15 @@ func TestCallsDummy(t *testing.T) { t.Error("Must fail because `dummy` call does not exist.") } - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestGetExactCall(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, s.BasicRoute()) u := url.URL{ Scheme: "http", @@ -76,5 +78,4 @@ func TestGetExactCall(t *testing.T) { t.Error(retryErr.Error()) } - DeleteApp(t, s.Context, s.Client, s.AppName) } diff --git a/test/fn-api-tests/config_cases.go b/test/fn-api-tests/config_cases.go new file mode 100644 index 000000000..59c6be37b --- /dev/null +++ b/test/fn-api-tests/config_cases.go @@ -0,0 +1,29 @@ +package tests + +import "reflect" + +// common test cases around config for apps/routes + +var updateConfigCases = []struct { + name string + intialConfig map[string]string + change map[string]string + expected map[string]string +}{ + {"preserve existing config keys with nop", map[string]string{"key": "value1"}, map[string]string{}, map[string]string{"key": "value1"}}, + + {"preserve existing config keys with change", map[string]string{"key": "value1"}, map[string]string{"key": "value1"}, map[string]string{"key": "value1"}}, + + {"overwrite existing config keys", map[string]string{"key": "value1"}, map[string]string{"key": "value2"}, map[string]string{"key": "value2"}}, + + {"delete config key", map[string]string{"key": "value1"}, map[string]string{"key": ""}, map[string]string{}}, +} + +//ConfigEquivalent checks if two config objects are semantically equivalent (including nils) +func ConfigEquivalent(a map[string]string, b map[string]string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) + +} diff --git a/test/fn-api-tests/exec_test.go b/test/fn-api-tests/exec_test.go index 04ce1dee1..671d9b537 100644 --- a/test/fn-api-tests/exec_test.go +++ b/test/fn-api-tests/exec_test.go @@ -12,6 +12,7 @@ import ( "github.com/fnproject/fn_go/client/call" "github.com/fnproject/fn_go/client/operations" + "github.com/fnproject/fn_go/models" ) func CallAsync(t *testing.T, u url.URL, content io.Reader) string { @@ -58,10 +59,13 @@ func CallSync(t *testing.T, u url.URL, content io.Reader) string { func TestCanCallfunction(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, "sync", - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + rt := s.BasicRoute() + rt.Type = "sync" + s.GivenRouteExists(t, s.AppName, rt) u := url.URL{ Scheme: "http", @@ -79,15 +83,15 @@ func TestCanCallfunction(t *testing.T) { if !strings.Contains(expectedOutput, output.String()) { t.Errorf("Assertion error.\n\tExpected: %v\n\tActual: %v", expectedOutput, output.String()) } - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestCallOutputMatch(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, "sync", - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + s.GivenAppExists(t, &models.App{Name: s.AppName}) + rt := s.BasicRoute() + rt.Type = "sync" + s.GivenRouteExists(t, s.AppName, rt) u := url.URL{ Scheme: "http", @@ -108,16 +112,17 @@ func TestCallOutputMatch(t *testing.T) { if !strings.Contains(expectedOutput, output.String()) { t.Errorf("Assertion error.\n\tExpected: %v\n\tActual: %v", expectedOutput, output.String()) } - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestCanCallAsync(t *testing.T) { newRouteType := "async" t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, "sync", - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + + s := SetupHarness() + s.GivenAppExists(t, &models.App{Name: s.AppName}) + rt := s.BasicRoute() + rt.Type = "sync" + s.GivenRouteExists(t, s.AppName, rt) u := url.URL{ Scheme: "http", @@ -125,25 +130,22 @@ func TestCanCallAsync(t *testing.T) { } u.Path = path.Join(u.Path, "r", s.AppName, s.RoutePath) - _, err := UpdateRoute( - t, s.Context, s.Client, - s.AppName, s.RoutePath, - s.Image, newRouteType, s.Format, - s.Memory, s.RouteConfig, s.RouteHeaders, "") - - CheckRouteResponseError(t, err) + s.GivenRoutePatched(t, s.AppName, s.RoutePath, &models.Route{ + Type: newRouteType, + }) CallAsync(t, u, &bytes.Buffer{}) - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestCanGetAsyncState(t *testing.T) { newRouteType := "async" t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, "sync", - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + rt := s.BasicRoute() + rt.Type = "sync" + s.GivenRouteExists(t, s.AppName, rt) u := url.URL{ Scheme: "http", @@ -151,13 +153,9 @@ func TestCanGetAsyncState(t *testing.T) { } u.Path = path.Join(u.Path, "r", s.AppName, s.RoutePath) - _, err := UpdateRoute( - t, s.Context, s.Client, - s.AppName, s.RoutePath, - s.Image, newRouteType, s.Format, - s.Memory, s.RouteConfig, s.RouteHeaders, "") - - CheckRouteResponseError(t, err) + s.GivenRoutePatched(t, s.AppName, rt.Path, &models.Route{ + Type: newRouteType, + }) callID := CallAsync(t, u, &bytes.Buffer{}) cfg := &call.GetAppsAppCallsCallParams{ @@ -198,26 +196,27 @@ func TestCanGetAsyncState(t *testing.T) { t.Errorf("Call object status mismatch.\n\tExpected: %v\n\tActual:%v", "success", callObject.Status) } } - - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestCanCauseTimeout(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - routePath := "/" + RandStringBytes(10) - image := "funcy/timeout:0.0.1" - routeType := "sync" + s := SetupHarness() + defer s.Cleanup() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, routePath, image, routeType, - s.Format, int32(10), s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s.GivenAppExists(t, &models.App{Name: s.AppName}) + + rt := s.BasicRoute() + timeout := int32(10) + rt.Timeout = &timeout + rt.Type = "sync" + rt.Image = "funcy/timeout:0.0.1" + s.GivenRouteExists(t, s.AppName, rt) u := url.URL{ Scheme: "http", Host: Host(), } - u.Path = path.Join(u.Path, "r", s.AppName, routePath) + u.Path = path.Join(u.Path, "r", s.AppName, rt.Path) content := &bytes.Buffer{} json.NewEncoder(content).Encode(struct { @@ -254,24 +253,24 @@ func TestCanCauseTimeout(t *testing.T) { "output", "callObj.Payload.Call.Status") } } - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestCallResponseHeadersMatch(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - routePath := "/os.environ" - image := "denismakogon/os.environ" - routeType := "sync" - CreateRoute(t, s.Context, s.Client, s.AppName, routePath, image, routeType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + rt := s.BasicRoute() + rt.Image = "denismakogon/os.environ" + rt.Type = "sync" + s.GivenRouteExists(t, s.AppName, rt) u := url.URL{ Scheme: "http", Host: Host(), } - u.Path = path.Join(u.Path, "r", s.AppName, routePath) + u.Path = path.Join(u.Path, "r", s.AppName, rt.Path) content := &bytes.Buffer{} output := &bytes.Buffer{} CallFN(u.String(), content, output, "POST", @@ -284,26 +283,26 @@ func TestCallResponseHeadersMatch(t *testing.T) { t.Errorf("HEADER_ACCEPT='application/xml, application/json; q=0.2' "+ "should be in output, have:%s\n", res) } - DeleteRoute(t, s.Context, s.Client, s.AppName, routePath) - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestCanWriteLogs(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - routePath := "/log" - image := "funcy/log:0.0.1" - routeType := "sync" + s := SetupHarness() + defer s.Cleanup() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, routePath, image, routeType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + rt := s.BasicRoute() + rt.Path = "/log" + rt.Image = "funcy/log:0.0.1" + rt.Type = "sync" + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, rt) u := url.URL{ Scheme: "http", Host: Host(), } - u.Path = path.Join(u.Path, "r", s.AppName, routePath) + u.Path = path.Join(u.Path, "r", s.AppName, rt.Path) content := &bytes.Buffer{} json.NewEncoder(content).Encode(struct { Size int @@ -323,26 +322,27 @@ func TestCanWriteLogs(t *testing.T) { t.Error(err.Error()) } - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestOversizedLog(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - routePath := "/log" - image := "funcy/log:0.0.1" - routeType := "sync" + s := SetupHarness() + defer s.Cleanup() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, routePath, image, routeType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + rt := s.BasicRoute() + rt.Path = "/log" + rt.Image = "funcy/log:0.0.1" + rt.Type = "sync" + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, rt) size := 1 * 1024 * 1024 * 1024 u := url.URL{ Scheme: "http", Host: Host(), } - u.Path = path.Join(u.Path, "r", s.AppName, routePath) + u.Path = path.Join(u.Path, "r", s.AppName, rt.Path) content := &bytes.Buffer{} json.NewEncoder(content).Encode(struct { Size int @@ -366,5 +366,4 @@ func TestOversizedLog(t *testing.T) { size/1024, len(log)) } } - DeleteApp(t, s.Context, s.Client, s.AppName) } diff --git a/test/fn-api-tests/formats_test.go b/test/fn-api-tests/formats_test.go index ad7019b15..b07fb797d 100644 --- a/test/fn-api-tests/formats_test.go +++ b/test/fn-api-tests/formats_test.go @@ -3,6 +3,7 @@ package tests import ( "bytes" "encoding/json" + "github.com/fnproject/fn_go/models" "net/url" "path" "strconv" @@ -16,16 +17,16 @@ type JSONResponse struct { func TestFnJSONFormats(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() + s := SetupHarness() + defer s.Cleanup() // TODO(treeder): put image in fnproject @ dockerhub - image := "denismakogon/test-hot-json-go:0.0.1" - format := "json" - route := "/test-hot-json-go" - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, route, image, "sync", - format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s.GivenAppExists(t, &models.App{Name: s.AppName}) + rt := s.BasicRoute() + rt.Image = "denismakogon/test-hot-json-go:0.0.1" + rt.Format = "json" + s.GivenRouteExists(t, s.AppName, rt) u := url.URL{ Scheme: "http", @@ -63,5 +64,4 @@ func TestFnJSONFormats(t *testing.T) { } } - DeleteApp(t, s.Context, s.Client, s.AppName) } diff --git a/test/fn-api-tests/init_test.go b/test/fn-api-tests/init_test.go index bf6151ded..229862504 100644 --- a/test/fn-api-tests/init_test.go +++ b/test/fn-api-tests/init_test.go @@ -8,10 +8,8 @@ import ( func TestMain(m *testing.M) { // call flag.Parse() here if TestMain uses flags - s := SetupDefaultSuite() result := m.Run() - Cleanup() - s.Cancel() + if result == 0 { fmt.Fprintln(os.Stdout, "😀 👍 🎗") } diff --git a/test/fn-api-tests/routes_api.go b/test/fn-api-tests/routes_api.go index 6b628e48c..3e208ad5a 100644 --- a/test/fn-api-tests/routes_api.go +++ b/test/fn-api-tests/routes_api.go @@ -1,228 +1,97 @@ package tests import ( - "context" "testing" - "github.com/fnproject/fn_go/client" "github.com/fnproject/fn_go/client/routes" "github.com/fnproject/fn_go/models" ) -func CheckRouteResponseError(t *testing.T, e error) { - if e != nil { - switch err := e.(type) { - case *routes.PostAppsAppRoutesDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) - t.FailNow() - case *routes.PostAppsAppRoutesBadRequest: - t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) - t.FailNow() - case *routes.PostAppsAppRoutesConflict: - t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) - t.FailNow() - case *routes.GetAppsAppRoutesRouteNotFound: - t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) - t.FailNow() - case *routes.GetAppsAppRoutesRouteDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) - t.FailNow() - case *routes.DeleteAppsAppRoutesRouteNotFound: - t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) - t.FailNow() - case *routes.DeleteAppsAppRoutesRouteDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) - t.FailNow() - case *routes.GetAppsAppRoutesNotFound: - t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) - t.FailNow() - case *routes.GetAppsAppRoutesDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) - t.FailNow() - case *routes.PatchAppsAppRoutesRouteBadRequest: - t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) - t.FailNow() - case *routes.PatchAppsAppRoutesRouteNotFound: - t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) - t.FailNow() - case *routes.PatchAppsAppRoutesRouteDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) - case *routes.PutAppsAppRoutesRouteBadRequest: - t.Errorf("Unexpected error occurred: %v.", err.Payload.Error.Message) - case *routes.PutAppsAppRoutesRouteDefault: - t.Errorf("Unexpected error occurred: %v. Status code: %v", err.Payload.Error.Message, err.Code()) - t.FailNow() - default: - t.Errorf("Unable to determine type of error: %s", err) - t.FailNow() - } - } -} +func AssertRouteMatches(t *testing.T, expected *models.Route, got *models.Route) { -func assertRouteFields(t *testing.T, routeObject *models.Route, path, image, routeType, routeFormat string) { - - rPath := routeObject.Path - rImage := routeObject.Image - rType := routeObject.Type - rTimeout := *routeObject.Timeout - rIdleTimeout := *routeObject.IDLETimeout - rFormat := routeObject.Format - - if rPath != path { - t.Errorf("Route path mismatch. Expected: %v. Actual: %v", path, rPath) + if expected.Path != got.Path { + t.Errorf("Route path mismatch. Expected: %v. Actual: %v", expected.Path, got.Path) } - if rImage != image { - t.Errorf("Route image mismatch. Expected: %v. Actual: %v", image, rImage) + if expected.Image != got.Image { + t.Errorf("Route image mismatch. Expected: %v. Actual: %v", expected.Image, got.Image) } - if rType != routeType { - t.Errorf("Route type mismatch. Expected: %v. Actual: %v", routeType, rType) + if expected.Image != got.Image { + t.Errorf("Route type mismatch. Expected: %v. Actual: %v", expected.Image, got.Image) } - if rTimeout == 0 { - t.Error("Route timeout should have default value of 30 seconds, but got 0 seconds") - } - if rIdleTimeout == 0 { - t.Error("Route idle timeout should have default value of 30 seconds, but got 0 seconds") - } - if rFormat != routeFormat { - t.Errorf("Route format mismatch. Expected: %v. Actual: %v", routeFormat, rFormat) + if expected.Format != got.Format { + t.Errorf("Route format mismatch. Expected: %v. Actual: %v", expected.Format, got.Format) } } -func createRoute(ctx context.Context, fnclient *client.Fn, appName, image, routePath, routeType, routeFormat string, timeout, idleTimeout int32, routeConfig map[string]string, headers map[string][]string) (*routes.PostAppsAppRoutesOK, error) { +// PostRoute Creates a route and deletes the corresponding app (if created) on teardown +func (s *TestHarness) PostRoute(appName string, route *models.Route) (*routes.PostAppsAppRoutesOK, error) { cfg := &routes.PostAppsAppRoutesParams{ App: appName, Body: &models.RouteWrapper{ - Route: &models.Route{ - Config: routeConfig, - Headers: headers, - Image: image, - Path: routePath, - Type: routeType, - Format: routeFormat, - Timeout: &timeout, - IDLETimeout: &idleTimeout, - }, + Route: route, }, - Context: ctx, + Context: s.Context, } - ok, err := fnclient.Routes.PostAppsAppRoutes(cfg) + ok, err := s.Client.Routes.PostAppsAppRoutes(cfg) + if err == nil { - approutesLock.Lock() - r, got := appsandroutes[appName] - if got { - appsandroutes[appName] = append(r, routePath) - } else { - appsandroutes[appName] = []string{routePath} - } - approutesLock.Unlock() + s.createdApps[appName] = true } return ok, err } -func CreateRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath, image, routeType, routeFormat string, timeout, idleTimeout int32, routeConfig map[string]string, headers map[string][]string) { - routeResponse, err := createRoute(ctx, fnclient, appName, image, routePath, routeType, routeFormat, timeout, idleTimeout, routeConfig, headers) - CheckRouteResponseError(t, err) - - assertRouteFields(t, routeResponse.Payload.Route, routePath, image, routeType, routeFormat) +func (s *TestHarness) BasicRoute() *models.Route { + return &models.Route{ + Format: s.Format, + Path: s.RoutePath, + Image: s.Image, + Type: s.RouteType, + Timeout: &s.Timeout, + IDLETimeout: &s.IdleTimeout, + } } -func deleteRoute(ctx context.Context, fnclient *client.Fn, appName, routePath string) (*routes.DeleteAppsAppRoutesRouteOK, error) { - cfg := &routes.DeleteAppsAppRoutesRouteParams{ - App: appName, - Route: routePath, - Context: ctx, +//GivenRouteExists creates a route using the specified arguments, failing the test if the creation fails, this tears down any apps that are created when the test is complete +func (s *TestHarness) GivenRouteExists(t *testing.T, appName string, route *models.Route) { + _, err := s.PostRoute(appName, route) + if err != nil { + t.Fatalf("Expected route to be created, got %v", err) } - return fnclient.Routes.DeleteAppsAppRoutesRoute(cfg) } -func DeleteRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath string) { - _, err := deleteRoute(ctx, fnclient, appName, routePath) - CheckRouteResponseError(t, err) -} - -func ListRoutes(t *testing.T, ctx context.Context, fnclient *client.Fn, appName string) []*models.Route { - cfg := &routes.GetAppsAppRoutesParams{ - App: appName, - Context: ctx, - } - - routesResponse, err := fnclient.Routes.GetAppsAppRoutes(cfg) - CheckRouteResponseError(t, err) - return routesResponse.Payload.Routes -} - -func GetRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath string) *models.Route { +//RouteMustExist checks that a route exists, failing the test if it doesn't, returns the route +func (s *TestHarness) RouteMustExist(t *testing.T, appName string, routePath string) *models.Route { cfg := &routes.GetAppsAppRoutesRouteParams{ App: appName, Route: routePath[1:], - Context: ctx, + Context: s.Context, } - routeResponse, err := fnclient.Routes.GetAppsAppRoutesRoute(cfg) - CheckRouteResponseError(t, err) + routeResponse, err := s.Client.Routes.GetAppsAppRoutesRoute(cfg) + if err != nil { + t.Fatalf("Expected route %s %s to exist but got %v", appName, routePath, err) + } return routeResponse.Payload.Route } -func UpdateRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath, image, routeType, format string, memory uint64, routeConfig map[string]string, headers map[string][]string, newRoutePath string) (*routes.PatchAppsAppRoutesRouteOK, error) { +//GivenRoutePatched applies a patch to a route, failing the test if this fails. +func (s *TestHarness) GivenRoutePatched(t *testing.T, appName, routeName string, rt *models.Route) { - routeObject := GetRoute(t, ctx, fnclient, appName, routePath) - if routeObject.Config == nil { - routeObject.Config = map[string]string{} - } - - if routeObject.Headers == nil { - routeObject.Headers = map[string][]string{} - } - - routeObject.Path = "" - if newRoutePath != "" { - routeObject.Path = newRoutePath - } - - if routeConfig != nil { - for k, v := range routeConfig { - if string(k[0]) == "-" { - delete(routeObject.Config, string(k[1:])) - continue - } - routeObject.Config[k] = v - } - } - if headers != nil { - for k, v := range headers { - if string(k[0]) == "-" { - delete(routeObject.Headers, k) - continue - } - routeObject.Headers[k] = v - } - } - if image != "" { - routeObject.Image = image - } - if format != "" { - routeObject.Format = format - } - if routeType != "" { - routeObject.Type = routeType - } - if memory > 0 { - routeObject.Memory = memory - } - - cfg := &routes.PatchAppsAppRoutesRouteParams{ + _, err := s.Client.Routes.PatchAppsAppRoutesRoute(&routes.PatchAppsAppRoutesRouteParams{ App: appName, - Context: ctx, + Route: routeName, + Context: s.Context, Body: &models.RouteWrapper{ - Route: routeObject, + Route: rt, }, - Route: routePath, - } + }) - return fnclient.Routes.PatchAppsAppRoutesRoute(cfg) + if err != nil { + t.Fatalf("Failed to patch route %s %s : %v", appName, routeName, err) + } } func assertContainsRoute(routeModels []*models.Route, expectedRoute string) bool { @@ -234,24 +103,22 @@ func assertContainsRoute(routeModels []*models.Route, expectedRoute string) bool return false } -func DeployRoute(t *testing.T, ctx context.Context, fnclient *client.Fn, appName, routePath, image, routeType, routeFormat string, routeConfig map[string]string, headers map[string][]string) *models.Route { +//PutRoute creates a route via PUT, tearing down any apps that are created when the test is complete +func (s *TestHarness) PutRoute(appName string, routePath string, route *models.Route) (*routes.PutAppsAppRoutesRouteOK, error) { cfg := &routes.PutAppsAppRoutesRouteParams{ App: appName, - Context: ctx, + Context: s.Context, Route: routePath, Body: &models.RouteWrapper{ - Route: &models.Route{ - Config: routeConfig, - Headers: headers, - Image: image, - Path: routePath, - Type: routeType, - Format: routeFormat, - }, + Route: route, }, } - route, err := fnclient.Routes.PutAppsAppRoutesRoute(cfg) - CheckRouteResponseError(t, err) - return route.Payload.Route + resp, err := s.Client.Routes.PutAppsAppRoutesRoute(cfg) + + if err == nil { + s.createdApps[appName] = true + } + + return resp, err } diff --git a/test/fn-api-tests/routes_test.go b/test/fn-api-tests/routes_test.go index 57390fb60..9afd2a320 100644 --- a/test/fn-api-tests/routes_test.go +++ b/test/fn-api-tests/routes_test.go @@ -6,206 +6,497 @@ import ( "reflect" "github.com/fnproject/fn/api/id" + "github.com/fnproject/fn_go/client/apps" + "github.com/fnproject/fn_go/client/routes" "github.com/fnproject/fn_go/models" ) -func TestCreateRouteEmptyType(t *testing.T) { +func TestShouldRejectEmptyRouteType(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - _, err := createRoute(s.Context, s.Client, s.AppName, s.RoutePath, s.Image, "", - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + + _, err := s.PostRoute(s.AppName, &models.Route{ + Path: s.RoutePath, + Image: s.Image, + Type: "v", + Format: s.Format, + }) + if err == nil { t.Errorf("Should fail with Invalid route Type.") } - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestCanCreateRoute(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) - DeleteApp(t, s.Context, s.Client, s.AppName) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + _, err := s.PostRoute(s.AppName, &models.Route{ + Path: s.RoutePath, + Image: s.Image, + Format: s.Format, + }) + + if err != nil { + t.Errorf("expected route success, got %v", err) + } + // TODO validate route returned matches request } func TestListRoutes(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) - if !assertContainsRoute(ListRoutes(t, s.Context, s.Client, s.AppName), s.RoutePath) { + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, s.BasicRoute()) + + cfg := &routes.GetAppsAppRoutesParams{ + App: s.AppName, + Context: s.Context, + } + + routesResponse, err := s.Client.Routes.GetAppsAppRoutes(cfg) + + if err != nil { + t.Fatalf("Expecting list routes to be successful, got %v", err) + } + if !assertContainsRoute(routesResponse.Payload.Routes, s.RoutePath) { t.Errorf("Unable to find corresponding route `%v` in list", s.RoutePath) } - DeleteApp(t, s.Context, s.Client, s.AppName) } func TestInspectRoute(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() - rObjects := []*models.Route{GetRoute(t, s.Context, s.Client, s.AppName, s.RoutePath)} - if !assertContainsRoute(rObjects, s.RoutePath) { - t.Errorf("Unable to find corresponding route `%v` in list", s.RoutePath) + s.GivenAppExists(t, &models.App{Name: s.AppName}) + newRt := s.BasicRoute() + s.GivenRouteExists(t, s.AppName, newRt) + + resp, err := s.Client.Routes.GetAppsAppRoutesRoute(&routes.GetAppsAppRoutesRouteParams{ + App: s.AppName, + Route: newRt.Path[1:], + Context: s.Context, + }) + + if err != nil { + t.Fatalf("Failed to get route %s, %v", s.RoutePath, err) } - DeleteApp(t, s.Context, s.Client, s.AppName) + gotRt := resp.Payload.Route + + AssertRouteMatches(t, newRt, gotRt) + } -func TestCanUpdateRouteType(t *testing.T) { - newRouteType := "sync" - t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) - - routeResp, err := UpdateRoute( - t, s.Context, s.Client, - s.AppName, s.RoutePath, - s.Image, newRouteType, s.Format, - s.Memory, s.RouteConfig, s.RouteHeaders, "") - - CheckRouteResponseError(t, err) - assertRouteFields(t, routeResp.Payload.Route, s.RoutePath, s.Image, newRouteType, s.Format) - - DeleteApp(t, s.Context, s.Client, s.AppName) +var validRouteUpdates = []struct { + name string + update *models.Route + extract func(*models.Route) interface{} +}{ + {"route type (sync)", &models.Route{Type: "sync"}, func(m *models.Route) interface{} { return m.Type }}, + {"route type (async)", &models.Route{Type: "async"}, func(m *models.Route) interface{} { return m.Type }}, + {"format (json)", &models.Route{Format: "json"}, func(m *models.Route) interface{} { return m.Format }}, + {"format (default)", &models.Route{Format: "default"}, func(m *models.Route) interface{} { return m.Format }}, + // ... } -func TestCanUpdateRouteConfig(t *testing.T) { +func TestCanUpdateRouteAttributes(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) - newRouteConf := map[string]string{ - "A": "a", + for _, tci := range validRouteUpdates { + tc := tci + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, s.BasicRoute()) + + routeResp, err := s.Client.Routes.PatchAppsAppRoutesRoute( + &routes.PatchAppsAppRoutesRouteParams{ + App: s.AppName, + Context: s.Context, + Route: s.RoutePath, + Body: &models.RouteWrapper{ + Route: tc.update, + }, + }, + ) + if err != nil { + t.Fatalf("Failed to patch route, got %v", err) + } + + got := tc.extract(routeResp.Payload.Route) + change := tc.extract(tc.update) + if !reflect.DeepEqual(got, change) { + t.Errorf("Expected value in response tobe %v but was %v", change, got) + } + }) } - routeResp, err := UpdateRoute( - t, s.Context, s.Client, - s.AppName, s.RoutePath, - s.Image, s.RouteType, s.Format, - s.Memory, newRouteConf, s.RouteHeaders, "") +} - CheckRouteResponseError(t, err) - assertRouteFields(t, routeResp.Payload.Route, s.RoutePath, s.Image, s.RouteType, s.Format) +func TestRoutePatchConfig(t *testing.T) { + t.Parallel() + for _, tci := range updateConfigCases { + tc := tci + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + s.GivenAppExists(t, &models.App{Name: s.AppName}) + route := s.BasicRoute() + route.Config = tc.intialConfig - DeleteApp(t, s.Context, s.Client, s.AppName) + s.GivenRouteExists(t, s.AppName, route) + + routeResp, err := s.Client.Routes.PatchAppsAppRoutesRoute( + &routes.PatchAppsAppRoutesRouteParams{ + App: s.AppName, + Route: s.RoutePath, + Body: &models.RouteWrapper{ + Route: &models.Route{ + Config: tc.change, + }, + }, + Context: s.Context, + }, + ) + + if err != nil { + t.Fatalf("Failed to patch route, got %v", err) + } + actual := routeResp.Payload.Route.Config + if !ConfigEquivalent(actual, tc.expected) { + t.Errorf("Expected config : %v after update, got %v", tc.expected, actual) + } + + }) + } + +} + +func TestSetRouteAnnotationsOnCreate(t *testing.T) { + t.Parallel() + for _, tci := range createAnnotationsValidCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run("valid_"+tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{ + Name: s.AppName, + }) + rt := s.BasicRoute() + rt.Annotations = tc.annotations + + route, err := s.Client.Routes.PostAppsAppRoutes(&routes.PostAppsAppRoutesParams{ + App: s.AppName, + Context: s.Context, + Body: &models.RouteWrapper{ + Route: rt, + }, + }) + + if err != nil { + t.Fatalf("Failed to create route with valid annotations %v got error %v", tc.annotations, err) + } + + gotMd := route.Payload.Route.Annotations + if !AnnotationsEquivalent(gotMd, tc.annotations) { + t.Errorf("Returned annotations %v does not match set annotations %v", gotMd, tc.annotations) + } + + getRoute := s.RouteMustExist(t, s.AppName, s.RoutePath) + + if !AnnotationsEquivalent(getRoute.Annotations, tc.annotations) { + t.Errorf("GET annotations '%v' does not match set annotations %v", getRoute.Annotations, tc.annotations) + } + + }) + } + + for _, tci := range createAnnotationsErrorCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run("invalid_"+tc.name, func(ti *testing.T) { + ti.Parallel() + s := SetupHarness() + defer s.Cleanup() + + _, err := s.PostApp(&models.App{ + Name: s.AppName, + Annotations: tc.annotations, + }) + + if err == nil { + t.Fatalf("Created app with invalid annotations %v but expected error", tc.annotations) + } + + if _, ok := err.(*apps.PostAppsBadRequest); !ok { + t.Errorf("Expecting bad request for invalid annotations, got %v", err) + } + + }) + } +} + +func TestSetRouteMetadataOnPatch(t *testing.T) { + t.Parallel() + + for _, tci := range updateAnnotationsValidCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run("valid_"+tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + rt := s.BasicRoute() + rt.Annotations = tc.initial + s.GivenRouteExists(t, s.AppName, rt) + + res, err := s.Client.Routes.PatchAppsAppRoutesRoute(&routes.PatchAppsAppRoutesRouteParams{ + App: s.AppName, + Route: s.RoutePath[1:], + Context: s.Context, + Body: &models.RouteWrapper{ + Route: &models.Route{ + Annotations: tc.change, + }, + }, + }) + + if err != nil { + t.Fatalf("Failed to patch annotations with %v on route: %v", tc.change, err) + } + + gotMd := res.Payload.Route.Annotations + if !AnnotationsEquivalent(gotMd, tc.expected) { + t.Errorf("Returned annotations %v does not match set annotations %v", gotMd, tc.expected) + } + + getRoute := s.RouteMustExist(t, s.AppName, s.RoutePath) + + if !AnnotationsEquivalent(getRoute.Annotations, tc.expected) { + t.Errorf("GET annotations '%v' does not match set annotations %v", getRoute.Annotations, tc.expected) + } + }) + } + + for _, tci := range updateAnnotationsErrorCases { + // iterator mutation meets parallelism... pfft + tc := tci + t.Run("invalid_"+tc.name, func(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{ + Name: s.AppName, + }) + rt := s.BasicRoute() + rt.Annotations = tc.initial + s.GivenRouteExists(t, s.AppName, rt) + + _, err := s.Client.Routes.PatchAppsAppRoutesRoute(&routes.PatchAppsAppRoutesRouteParams{ + App: s.AppName, + Route: s.RoutePath[1:], + Context: s.Context, + Body: &models.RouteWrapper{ + Route: &models.Route{ + Annotations: tc.change, + }, + }, + }) + + if err == nil { + t.Errorf("patched route with invalid annotations %v but expected error", tc.change) + } + if _, ok := err.(*routes.PatchAppsAppRoutesRouteBadRequest); !ok { + t.Errorf("Expecting bad request for invalid annotations, got %v", err) + } + + }) + } } func TestCantUpdateRoutePath(t *testing.T) { t.Parallel() - newRoutePath := id.New().String() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() - _, err := UpdateRoute( - t, s.Context, s.Client, - s.AppName, s.RoutePath, - s.Image, s.RouteType, s.Format, - s.Memory, s.RouteConfig, s.RouteHeaders, newRoutePath) + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, s.BasicRoute()) + + _, err := s.Client.Routes.PatchAppsAppRoutesRoute( + &routes.PatchAppsAppRoutesRouteParams{ + App: s.AppName, + Route: s.RoutePath, + Body: &models.RouteWrapper{ + Route: &models.Route{ + Path: id.New().String(), + }, + }, + }) if err == nil { - t.Errorf("Route path suppose to be immutable, but it's not.") + t.Fatalf("Expected error when patching route") + } + if _, ok := err.(*routes.PatchAppsAppRoutesRouteBadRequest); ok { + t.Errorf("Error should be bad request when updating route path ") } - DeleteApp(t, s.Context, s.Client, s.AppName) } -func TestRouteDuplicate(t *testing.T) { +func TestRoutePreventsDuplicate(t *testing.T) { t.Parallel() - newRouteType := "async" - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, s.BasicRoute()) + + _, err := s.PostRoute(s.AppName, s.BasicRoute()) - _, err := createRoute(s.Context, s.Client, s.AppName, s.Image, s.RoutePath, - newRouteType, s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) if err == nil { t.Errorf("Route duplicate error should appear, but it didn't") } - - DeleteApp(t, s.Context, s.Client, s.AppName) + if _, ok := err.(*routes.PostAppsAppRoutesConflict); !ok { + t.Errorf("Error should be a conflict when creating a new route, got %v", err) + } } func TestCanDeleteRoute(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() - DeleteRoute(t, s.Context, s.Client, s.AppName, s.RoutePath) - DeleteApp(t, s.Context, s.Client, s.AppName) -} + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, s.BasicRoute()) -func TestCantDeleteRoute(t *testing.T) { - t.Parallel() - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) + _, err := s.Client.Routes.DeleteAppsAppRoutesRoute(&routes.DeleteAppsAppRoutesRouteParams{ + App: s.AppName, + Route: s.RoutePath, + Context: s.Context, + }) - _, err := deleteRoute(s.Context, s.Client, s.AppName, "dummy-route") - if err == nil { - t.Error("Delete from missing route must fail.") + if err != nil { + t.Errorf("Expected success when deleting existing route, got %v", err) } - DeleteApp(t, s.Context, s.Client, s.AppName) } -func TestDeployNewApp(t *testing.T) { +func TestCantDeleteMissingRoute(t *testing.T) { t.Parallel() - s := SetupDefaultSuite() - DeployRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, s.Format, s.RouteConfig, s.RouteHeaders) - GetApp(t, s.Context, s.Client, s.AppName) - GetRoute(t, s.Context, s.Client, s.AppName, s.RoutePath) - DeleteApp(t, s.Context, s.Client, s.AppName) + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + + _, err := s.Client.Routes.DeleteAppsAppRoutesRoute(&routes.DeleteAppsAppRoutesRouteParams{ + App: s.AppName, + Route: s.RoutePath, + Context: s.Context, + }) + + if err == nil { + t.Fatalf("Expected error when deleting non-existing route, got none") + } + + if _, ok := err.(*routes.DeleteAppsAppRoutesRouteNotFound); !ok { + t.Fatalf("Expected not-found when deleting non-existing route, got %v", err) + + } } -func TestDeployExistingApp(t *testing.T) { - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - DeployRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, s.Format, s.RouteConfig, s.RouteHeaders) - GetApp(t, s.Context, s.Client, s.AppName) - GetRoute(t, s.Context, s.Client, s.AppName, s.RoutePath) - DeleteApp(t, s.Context, s.Client, s.AppName) +func TestPutRouteCreatesNewApp(t *testing.T) { + t.Parallel() + s := SetupHarness() + defer s.Cleanup() + + _, err := s.PutRoute(s.AppName, s.RoutePath, s.BasicRoute()) + + if err != nil { + t.Fatalf("Expected new route to be created, got %v", err) + } + + s.AppMustExist(t, s.AppName) + s.RouteMustExist(t, s.AppName, s.RoutePath) + } -func TestDeployUpdate(t *testing.T) { +func TestPutRouteToExistingApp(t *testing.T) { + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + _, err := s.PutRoute(s.AppName, s.RoutePath, s.BasicRoute()) + if err != nil { + t.Fatalf("Failed to create route, got error %v", err) + } + s.AppMustExist(t, s.AppName) + s.RouteMustExist(t, s.AppName, s.RoutePath) +} + +func TestPutRouteUpdatesRoute(t *testing.T) { newRouteType := "sync" - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := SetupHarness() + defer s.Cleanup() - updatedRoute := DeployRoute( - t, s.Context, s.Client, - s.AppName, s.RoutePath, - s.Image, newRouteType, - s.Format, s.RouteConfig, s.RouteHeaders) - assertRouteFields(t, updatedRoute, s.RoutePath, s.Image, newRouteType, s.Format) + s.GivenAppExists(t, &models.App{Name: s.AppName}) + s.GivenRouteExists(t, s.AppName, s.BasicRoute()) - DeleteApp(t, s.Context, s.Client, s.AppName) + changed := s.BasicRoute() + changed.Type = newRouteType + + updatedRoute, err := s.PutRoute(s.AppName, s.RoutePath, changed) + + if err != nil { + t.Fatalf("Failed to update route, got %v", err) + } + got := updatedRoute.Payload.Route.Type + if got != newRouteType { + t.Errorf("expected type to be %v after update, got %v", newRouteType, got) + } } -func TestMulpileDeployExistingApp(t *testing.T) { - s := SetupDefaultSuite() - CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) +func TestPutIsIdempotentForHeaders(t *testing.T) { + s := SetupHarness() + defer s.Cleanup() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + routeHeaders := map[string][]string{} routeHeaders["A"] = []string{"a"} routeHeaders["B"] = []string{"b"} - DeployRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, s.Format, s.RouteConfig, routeHeaders) - sameRoute := DeployRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, s.RouteType, s.Format, s.RouteConfig, routeHeaders) - if ok := reflect.DeepEqual(sameRoute.Headers, routeHeaders); !ok { + + r1 := s.BasicRoute() + r1.Headers = routeHeaders + + updatedRoute1, err := s.PutRoute(s.AppName, s.RoutePath, r1) + + if err != nil { + t.Fatalf("Failed to update route, got %v", err) + } + if firstMatches := reflect.DeepEqual(routeHeaders, updatedRoute1.Payload.Route.Headers); !firstMatches { + t.Errorf("Route headers should remain the same after multiple deploys with exact the same parameters '%v' != '%v'", routeHeaders, updatedRoute1.Payload.Route.Headers) + } + + updatedRoute2, err := s.PutRoute(s.AppName, s.RoutePath, r1) + + if bothmatch := reflect.DeepEqual(updatedRoute1.Payload.Route.Headers, updatedRoute2.Payload.Route.Headers); !bothmatch { t.Error("Route headers should remain the same after multiple deploys with exact the same parameters") } - DeleteApp(t, s.Context, s.Client, s.AppName) } diff --git a/test/fn-api-tests/utils.go b/test/fn-api-tests/utils.go index 047f0fcd1..7a4d35777 100644 --- a/test/fn-api-tests/utils.go +++ b/test/fn-api-tests/utils.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "runtime" "strings" "sync" "testing" @@ -48,11 +47,9 @@ func APIClient() *client.Fn { } var ( - getServer sync.Once - cancel2 context.CancelFunc - s *server.Server - appsandroutes = make(map[string][]string) - approutesLock sync.Mutex + getServer sync.Once + cancel2 context.CancelFunc + s *server.Server ) func getServerWithCancel() (*server.Server, context.CancelFunc) { @@ -95,7 +92,9 @@ func getServerWithCancel() (*server.Server, context.CancelFunc) { return s, cancel2 } -type SuiteSetup struct { +// TestHarness provides context and pre-configured clients to an individual test, it has some helper functions to create Apps and Routes that mirror the underlying client operations and clean them up after the test is complete +// This is not goroutine safe and each test case should use its own harness. +type TestHarness struct { Context context.Context Client *client.Fn AppName string @@ -109,6 +108,8 @@ type SuiteSetup struct { RouteConfig map[string]string RouteHeaders map[string][]string Cancel context.CancelFunc + + createdApps map[string]bool } func RandStringBytes(n int) string { @@ -119,9 +120,10 @@ func RandStringBytes(n int) string { return strings.ToLower(string(b)) } -func SetupDefaultSuite() *SuiteSetup { +// SetupHarness creates a test harness for a test case - this picks up external options and +func SetupHarness() *TestHarness { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - ss := &SuiteSetup{ + ss := &TestHarness{ Context: ctx, Client: APIClient(), AppName: "fnintegrationtestapp" + RandStringBytes(10), @@ -135,6 +137,7 @@ func SetupDefaultSuite() *SuiteSetup { Memory: uint64(256), Timeout: int32(30), IdleTimeout: int32(30), + createdApps: make(map[string]bool), } if Host() != "localhost:8080" { @@ -153,18 +156,16 @@ func SetupDefaultSuite() *SuiteSetup { return ss } -func Cleanup() { +func (s *TestHarness) Cleanup() { ctx := context.Background() - c := APIClient() - approutesLock.Lock() - defer approutesLock.Unlock() - for appName, rs := range appsandroutes { - for _, routePath := range rs { - deleteRoute(ctx, c, appName, routePath) - } - DeleteAppNoT(ctx, c, appName) + + //for _,ar := range s.createdRoutes { + // deleteRoute(ctx, s.Client, ar.appName, ar.routeName) + //} + + for app, _ := range s.createdApps { + safeDeleteApp(ctx, s.Client, app) } - appsandroutes = make(map[string][]string) } func EnvAsHeader(req *http.Request, selectedEnv []string) { @@ -214,20 +215,6 @@ func init() { rand.Seed(time.Now().UnixNano()) } -func MyCaller() string { - fpcs := make([]uintptr, 1) - n := runtime.Callers(3, fpcs) - if n == 0 { - return "n/a" - } - fun := runtime.FuncForPC(fpcs[0] - 1) - if fun == nil { - return "n/a" - } - f, l := fun.FileLine(fpcs[0] - 1) - return fmt.Sprintf("%s:%d", f, l) -} - func APICallWithRetry(t *testing.T, attempts int, sleep time.Duration, callback func() error) (err error) { for i := 0; i < attempts; i++ { err = callback() diff --git a/test/fn-system-tests/exec_test.go b/test/fn-system-tests/exec_test.go index a8a2f5577..c038caad9 100644 --- a/test/fn-system-tests/exec_test.go +++ b/test/fn-system-tests/exec_test.go @@ -10,6 +10,7 @@ import ( "testing" apiutils "github.com/fnproject/fn/test/fn-api-tests" + "github.com/fnproject/fn_go/models" ) func LB() (string, error) { @@ -23,10 +24,14 @@ func LB() (string, error) { } func TestCanExecuteFunction(t *testing.T) { - s := apiutils.SetupDefaultSuite() - apiutils.CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - apiutils.CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, "sync", - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := apiutils.SetupHarness() + s.GivenAppExists(t, &models.App{Name: s.AppName}) + defer s.Cleanup() + + rt := s.BasicRoute() + rt.Type = "sync" + + s.GivenRouteExists(t, s.AppName, rt) lb, err := LB() if err != nil { @@ -48,14 +53,18 @@ func TestCanExecuteFunction(t *testing.T) { if !strings.Contains(expectedOutput, output.String()) { t.Errorf("Assertion error.\n\tExpected: %v\n\tActual: %v", expectedOutput, output.String()) } - apiutils.DeleteApp(t, s.Context, s.Client, s.AppName) } func TestBasicConcurrentExecution(t *testing.T) { - s := apiutils.SetupDefaultSuite() - apiutils.CreateApp(t, s.Context, s.Client, s.AppName, map[string]string{}) - apiutils.CreateRoute(t, s.Context, s.Client, s.AppName, s.RoutePath, s.Image, "sync", - s.Format, s.Timeout, s.IdleTimeout, s.RouteConfig, s.RouteHeaders) + s := apiutils.SetupHarness() + + s.GivenAppExists(t, &models.App{Name: s.AppName}) + defer s.Cleanup() + + rt := s.BasicRoute() + rt.Type = "sync" + + s.GivenRouteExists(t, s.AppName, rt) lb, err := LB() if err != nil { @@ -93,5 +102,4 @@ func TestBasicConcurrentExecution(t *testing.T) { } } - apiutils.DeleteApp(t, s.Context, s.Client, s.AppName) } diff --git a/vendor/github.com/fnproject/fn_go/VERSION b/vendor/github.com/fnproject/fn_go/VERSION index 72f9fa820..53a75d673 100644 --- a/vendor/github.com/fnproject/fn_go/VERSION +++ b/vendor/github.com/fnproject/fn_go/VERSION @@ -1 +1 @@ -0.2.4 \ No newline at end of file +0.2.6 diff --git a/vendor/github.com/fnproject/fn_go/models/app.go b/vendor/github.com/fnproject/fn_go/models/app.go index cb4a42773..265178eb6 100644 --- a/vendor/github.com/fnproject/fn_go/models/app.go +++ b/vendor/github.com/fnproject/fn_go/models/app.go @@ -17,7 +17,10 @@ import ( // swagger:model App type App struct { - // Application configuration, applied to all routes. + // Application annotations - this is a map of annotations attached to this app, keys must not exceed 128 bytes and must consist of non-whitespace printable ascii characters, and the seralized representation of individual values must not exeed 512 bytes + Annotations map[string]interface{} `json:"annotations,omitempty"` + + // Application function configuration, applied to all routes. Config map[string]string `json:"config,omitempty"` // Time when app was created. Always in UTC. diff --git a/vendor/github.com/fnproject/fn_go/models/route.go b/vendor/github.com/fnproject/fn_go/models/route.go index 89fd8995d..4f1a25883 100644 --- a/vendor/github.com/fnproject/fn_go/models/route.go +++ b/vendor/github.com/fnproject/fn_go/models/route.go @@ -19,6 +19,9 @@ import ( // swagger:model Route type Route struct { + // Route annotations - this is a map of annotations attached to this route, keys must not exceed 128 bytes and must consist of non-whitespace printable ascii characters, and the seralized representation of individual values must not exeed 512 bytes + Annotations map[string]interface{} `json:"annotations,omitempty"` + // Route configuration - overrides application configuration Config map[string]string `json:"config,omitempty"`