mirror of
https://github.com/fnproject/fn.git
synced 2022-10-28 21:29:17 +03:00
Add annotations to routes and apps (#866)
Adds 'annotations' attribute to Routes and Apps
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
27
api/datastore/sql/migrations/8_add_annotations_app.go
Normal file
27
api/datastore/sql/migrations/8_add_annotations_app.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
27
api/datastore/sql/migrations/9_add_annotations_route.go
Normal file
27
api/datastore/sql/migrations/9_add_annotations_route.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;`)
|
||||
|
||||
|
||||
238
api/models/annotations.go
Normal file
238
api/models/annotations.go
Normal file
@@ -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)
|
||||
}
|
||||
261
api/models/annotations_test.go
Normal file
261
api/models/annotations_test.go
Normal file
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user