diff --git a/diff/diff.go b/diff/diff.go index b0c14f403..30155b1c4 100644 --- a/diff/diff.go +++ b/diff/diff.go @@ -106,6 +106,7 @@ func (sc *Syncer) init() error { types.Upstream, types.Target, types.Consumer, + types.Developer, types.ACLGroup, types.BasicAuth, types.KeyAuth, types.HMACAuth, types.JWTAuth, types.OAuth2Cred, types.MTLSAuth, @@ -203,6 +204,10 @@ func (sc *Syncer) delete() error { if err != nil { return err } + err = sc.entityDiffers[types.Developer].Deletes(sc.queueEvent) + if err != nil { + return err + } err = sc.entityDiffers[types.Upstream].Deletes(sc.queueEvent) if err != nil { return err @@ -291,6 +296,10 @@ func (sc *Syncer) createUpdate() error { if err != nil { return err } + err = sc.entityDiffers[types.Developer].CreateAndUpdates(sc.queueEvent) + if err != nil { + return err + } err = sc.entityDiffers[types.Upstream].CreateAndUpdates(sc.queueEvent) if err != nil { return err diff --git a/dump/dump.go b/dump/dump.go index cf2e874a3..5c2f25949 100644 --- a/dump/dump.go +++ b/dump/dump.go @@ -239,6 +239,18 @@ func getEnterpriseRBACConfiguration(ctx context.Context, group *errgroup.Group, }) } +func getDeveloperConfiguration(ctx context.Context, group *errgroup.Group, + client *kong.Client, config Config, state *utils.KongRawState) { + group.Go(func() error { + developers, err := GetAllDevelopers(ctx, client, config.SelectorTags) + if err != nil { + return fmt.Errorf("developers: %w", err) + } + state.Developers = developers + return nil + }) +} + // Get queries all the entities using client and returns // all the entities in KongRawState. func Get(ctx context.Context, client *kong.Client, config Config) (*utils.KongRawState, error) { @@ -258,6 +270,8 @@ func Get(ctx context.Context, client *kong.Client, config Config) (*utils.KongRa getProxyConfiguration(ctx, group, client, config, &state) if !config.SkipConsumers { getConsumerConfiguration(ctx, group, client, config, &state) + } else { + getDeveloperConfiguration(ctx, group, client, config, &state) } } @@ -450,6 +464,33 @@ func GetAllConsumers(ctx context.Context, return consumers, nil } +// GetAllDevelopers queries Kong for all the developers using client. +// Please use this method with caution if you have a lot of developers. +func GetAllDevelopers(ctx context.Context, + client *kong.Client, tags []string) ([]*kong.Developer, error) { + var developers []*kong.Developer + opt := newOpt(tags) + + for { + s, nextopt, err := client.Developers.List(ctx, opt) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + developers = append(developers, s...) + if nextopt == nil { + break + } + opt = nextopt + } + return developers, nil +} + // GetAllUpstreams queries Kong for all the Upstreams using client. func GetAllUpstreams(ctx context.Context, client *kong.Client, tags []string) ([]*kong.Upstream, error) { diff --git a/file/builder.go b/file/builder.go index e95949c45..8dc911771 100644 --- a/file/builder.go +++ b/file/builder.go @@ -64,6 +64,7 @@ func (b *stateBuilder) build() (*utils.KongRawState, *utils.KonnectRawState, err b.routes() b.upstreams() b.consumers() + b.developers() b.plugins() b.enterprise() @@ -601,6 +602,35 @@ func (b *stateBuilder) routes() { } } +func (b *stateBuilder) developers() { + if b.err != nil { + return + } + + for _, d := range b.targetContent.Developers { + d := d + if utils.Empty(d.ID) { + developer, err := b.currentState.Developers.Get(*d.Email) + if err == state.ErrNotFound { + d.ID = uuid() + } else if err != nil { + b.err = err + return + } else { + d.ID = kong.String(*developer.ID) + } + } + + b.rawState.Developers = append(b.rawState.Developers, &d.Developer) + + err := b.intermediate.Developers.Add(state.Developer{Developer: d.Developer}) + if err != nil { + b.err = err + return + } + } +} + func (b *stateBuilder) enterprise() { b.rbacRoles() } diff --git a/file/builder_test.go b/file/builder_test.go index 7788ec78b..5c94dcd16 100644 --- a/file/builder_test.go +++ b/file/builder_test.go @@ -262,6 +262,17 @@ func existingDocumentState() *state.KongState { return s } +func existingDeveloperState() *state.KongState { + s, _ := state.NewKongState() + s.Developers.Add(state.Developer{ + Developer: kong.Developer{ + ID: kong.String("4bfcb11f-c962-4817-83e5-9433cf20b663"), + Email: kong.String("foo"), + }, + }) + return s +} + var deterministicUUID = func() *string { version := byte(4) uuid := make([]byte, 16) @@ -2489,3 +2500,77 @@ func Test_stateBuilder_fillPluginConfig(t *testing.T) { }) } } + +func Test_stateBuilder_developers(t *testing.T) { + assert := assert.New(t) + rand.Seed(42) + type fields struct { + currentState *state.KongState + targetContent *Content + } + tests := []struct { + name string + fields fields + want *utils.KongRawState + }{ + { + name: "generates ID for a non-existing developer", + fields: fields{ + targetContent: &Content{ + Developers: []FDeveloper{ + { + Developer: kong.Developer{ + Email: kong.String("foo@example.com"), + }, + }, + }, + }, + currentState: emptyState(), + }, + want: &utils.KongRawState{ + Developers: []*kong.Developer{ + { + ID: kong.String("538c7f96-b164-4f1b-97bb-9f4bb472e89f"), + Email: kong.String("foo@example.com"), + }, + }, + }, + }, + { + name: "matches ID of an existing Developer", + fields: fields{ + targetContent: &Content{ + Developers: []FDeveloper{ + { + Developer: kong.Developer{ + Email: kong.String("foo@example.com"), + }, + }, + }, + }, + currentState: existingDeveloperState(), + }, + want: &utils.KongRawState{ + Developers: []*kong.Developer{ + { + ID: kong.String("5b1484f2-5209-49d9-b43e-92ba09dd9d52"), + Email: kong.String("foo@example.com"), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &stateBuilder{ + targetContent: tt.fields.targetContent, + currentState: tt.fields.currentState, + } + d, _ := utils.GetKongDefaulter() + b.defaulter = d + b.build() + assert.Equal(tt.want, b.rawState) + }) + } +} diff --git a/file/codegen/main.go b/file/codegen/main.go index bb517c235..2caf24e16 100644 --- a/file/codegen/main.go +++ b/file/codegen/main.go @@ -30,6 +30,15 @@ var ( Required: []string{"id"}, }, } + + anyOfEmailOrID = []*jsonschema.Type{ + { + Required: []string{"Email"}, + }, + { + Required: []string{"id"}, + }, + } ) func main() { @@ -54,6 +63,8 @@ func main() { schema.Definitions["FConsumer"].AnyOf = anyOfUsernameOrID + schema.Definitions["FDeveloper"].AnyOf = anyOfEmailOrID + schema.Definitions["FUpstream"].Required = []string{"name"} schema.Definitions["FTarget"].Required = []string{"target"} diff --git a/file/kong_json_schema.json b/file/kong_json_schema.json index f098fd863..596942bec 100644 --- a/file/kong_json_schema.json +++ b/file/kong_json_schema.json @@ -41,6 +41,13 @@ }, "type": "array" }, + "developers": { + "items": { + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/FDeveloper" + }, + "type": "array" + }, "plugins": { "items": { "$ref": "#/definitions/FPlugin" @@ -417,6 +424,58 @@ } ] }, + "FDeveloper": { + "properties": { + "created_at": { + "type": "integer" + }, + "custom_id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "meta": { + "type": "string" + }, + "password": { + "type": "string" + }, + "rbac_user": { + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/RBACUser" + }, + "roles": { + "items": { + "type": "string" + }, + "type": "array" + }, + "status": { + "type": "integer" + }, + "updated_at": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "anyOf": [ + { + "required": [ + "Email" + ] + }, + { + "required": [ + "id" + ] + } + ] + }, "FDocument": { "properties": { "id": { @@ -1214,6 +1273,33 @@ "additionalProperties": false, "type": "object" }, + "RBACUser": { + "properties": { + "comment": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "user_token": { + "type": "string" + }, + "user_token_ident": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, "Route": { "properties": { "created_at": { diff --git a/file/types.go b/file/types.go index 4afeb5c4b..743e4358f 100644 --- a/file/types.go +++ b/file/types.go @@ -592,6 +592,94 @@ func (s FServicePackage) sortKey() string { return "" } +// FDeveloper represents a developer in Kong. +// +k8s:deepcopy-gen=true +type FDeveloper struct { + kong.Developer `yaml:",inline,omitempty"` +} + +// sortKey is used for sorting. +func (d FDeveloper) sortKey() string { + if d.Email != nil { + return *d.Email + } + if d.ID != nil { + return *d.ID + } + return "" +} + +type developer struct { + CreatedAt *int `json:"created_at,omitempty" yaml:"created_at,omitempty"` + ID *string `json:"id,omitempty" yaml:"id,omitempty"` + Status *int `json:"status,omitempty" yaml:"status,omitempty"` + Email *string `json:"email,omitempty" yaml:"email,omitempty"` + CustomID *string `json:"custom_id,omitempty" yaml:"custom_id,omitempty"` + UpdatedAt *int `json:"updated_at,omitempty" yaml:"updated_at,omitempty"` + Roles []*string `json:"roles,omitempty" yaml:"roles,omitempty"` + RbacUser *kong.RBACUser `json:"rbac_user,omitempty" yaml:"rbac_user,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty" yaml:"meta,omitempty"` + Password *string `json:"password,omitempty" yaml:"password,omitempty"` +} + +func copyToDeveloper(fDeveloper FDeveloper) (developer, error) { + d := developer{} + + if fDeveloper.Meta != nil && !utils.Empty(fDeveloper.Meta) { + if err := json.Unmarshal([]byte(*fDeveloper.Meta), &d.Meta); err != nil { + return d, err + } + } + d.CreatedAt = fDeveloper.CreatedAt + d.ID = fDeveloper.ID + d.Status = fDeveloper.Status + d.Email = fDeveloper.Email + d.CustomID = fDeveloper.CustomID + d.UpdatedAt = fDeveloper.UpdatedAt + d.Roles = fDeveloper.Roles + d.RbacUser = fDeveloper.RbacUser + d.Password = fDeveloper.Password + + return d, nil +} + +func copyFromDeveloper(developer developer, fDeveloper *FDeveloper) error { + fDeveloper.CreatedAt = developer.CreatedAt + fDeveloper.ID = developer.ID + fDeveloper.Status = developer.Status + fDeveloper.Email = developer.Email + fDeveloper.CustomID = developer.CustomID + fDeveloper.UpdatedAt = developer.UpdatedAt + fDeveloper.Roles = developer.Roles + fDeveloper.RbacUser = developer.RbacUser + meta, _ := json.Marshal(&developer.Meta) + fDeveloper.Meta = kong.String(string(meta)) + fDeveloper.Password = developer.Password + + return nil +} + +// MarshalJSON is a custom marshal method to handle +// foreign references. +func (d FDeveloper) MarshalJSON() ([]byte, error) { + developer, err := copyToDeveloper(d) + if err != nil { + return []byte(nil), err + } + return json.Marshal(developer) +} + +// UnmarshalJSON is a custom marshal method to handle +// foreign references. +func (d *FDeveloper) UnmarshalJSON(b []byte) error { + var developer developer + err := json.Unmarshal(b, &developer) + if err != nil { + return err + } + return copyFromDeveloper(developer, d) +} + //go:generate go run ./codegen/main.go // Content represents a serialized Kong state. @@ -609,6 +697,8 @@ type Content struct { Certificates []FCertificate `json:"certificates,omitempty" yaml:",omitempty"` CACertificates []FCACertificate `json:"ca_certificates,omitempty" yaml:"ca_certificates,omitempty"` + Developers []FDeveloper `json:"developers,omitempty" yaml:",omitempty"` + RBACRoles []FRBACRole `json:"rbac_roles,omitempty" yaml:"rbac_roles,omitempty"` PluginConfigs map[string]kong.Configuration `json:"_plugin_configs,omitempty" yaml:"_plugin_configs,omitempty"` diff --git a/file/types_test.go b/file/types_test.go index 499669962..6d5272a91 100644 --- a/file/types_test.go +++ b/file/types_test.go @@ -280,6 +280,27 @@ func Test_sortKey(t *testing.T) { sortable: FServiceVersion{}, expectedKey: "", }, + { + sortable: &FDeveloper{ + Developer: kong.Developer{ + Email: kong.String("my-developer"), + ID: kong.String("my-id"), + }, + }, + expectedKey: "my-developer", + }, + { + sortable: &FDeveloper{ + Developer: kong.Developer{ + ID: kong.String("my-id"), + }, + }, + expectedKey: "my-id", + }, + { + sortable: FDeveloper{}, + expectedKey: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -480,3 +501,45 @@ func Test_unwrapURL(t *testing.T) { }) } } + +func Test_unwrapDeveloper(t *testing.T) { + type args struct { + developer developer + fDeveloper *FDeveloper + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + args: args{ + developer: developer{ + Meta: map[string]interface{}{ + "full_name": "Foo BAR", + }, + }, + fDeveloper: &FDeveloper{ + Developer: kong.Developer{ + Meta: kong.String("{\"full_name\": \"Foo BAR\"}"), + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := FDeveloper{} + if err := copyFromDeveloper(tt.args.developer, &in); (err != nil) != tt.wantErr { + t.Errorf("copyFromDeveloper() error = %v, wantErr %v", err, tt.wantErr) + } + dev, err := copyToDeveloper(*tt.args.fDeveloper) + if (err != nil) != tt.wantErr { + t.Errorf("copyToDeveloper() error = %v, wantErr %v", err, tt.wantErr) + } + assert := assert.New(t) + assert.NotNil(dev) + }) + } +} diff --git a/file/writer.go b/file/writer.go index 16d7dc1b1..63036e4ae 100644 --- a/file/writer.go +++ b/file/writer.go @@ -80,6 +80,11 @@ func KongStateToFile(kongState *state.KongState, config WriteConfig) error { return err } + err = populateDevelopers(kongState, file, config) + if err != nil { + return err + } + return WriteContentToFile(file, config.Filename, config.FileFormat) } @@ -121,6 +126,11 @@ func KonnectStateToFile(kongState *state.KongState, config WriteConfig) error { return err } + err = populateDevelopers(kongState, file, config) + if err != nil { + return err + } + return WriteContentToFile(file, config.Filename, config.FileFormat) } @@ -579,6 +589,7 @@ func populateConsumers(kongState *state.KongState, file *Content, utils.MustRemoveTags(&c.Consumer, config.SelectTags) file.Consumers = append(file.Consumers, c) } + rbacRoles, err := kongState.RBACRoles.GetAll() if err != nil { return err @@ -605,6 +616,26 @@ func populateConsumers(kongState *state.KongState, file *Content, return nil } +func populateDevelopers(kongState *state.KongState, file *Content, + config WriteConfig) error { + developers, err := kongState.Developers.GetAll() + if err != nil { + return err + } + + for _, d := range developers { + d := FDeveloper{Developer: d.Developer} + utils.ZeroOutID(&d, d.Email, config.WithID) + utils.ZeroOutTimestamps(&d) + file.Developers = append(file.Developers, d) + } + + sort.SliceStable(file.Developers, func(i, j int) bool { + return compareOrder(file.Developers[i], file.Developers[j]) + }) + return nil +} + func WriteContentToFile(content *Content, filename string, format Format) error { var c []byte var err error diff --git a/file/writer_test.go b/file/writer_test.go index c04a21eb8..6d625e2b6 100644 --- a/file/writer_test.go +++ b/file/writer_test.go @@ -173,6 +173,21 @@ func Test_compareOrder(t *testing.T) { expected: true, }, + { + sortable1: &FDeveloper{ + Developer: kong.Developer{ + Email: kong.String("my-developer-1"), + ID: kong.String("my-id-2"), + }, + }, + sortable2: &FDeveloper{ + Developer: kong.Developer{ + Email: kong.String("my-developer-2"), + ID: kong.String("my-id-2"), + }, + }, + expected: true, + }, { sortable1: &FServicePackage{ Name: kong.String("my-service-package-1"), diff --git a/file/zz_generated.deepcopy.go b/file/zz_generated.deepcopy.go index 68aafc0b1..1d248fa30 100644 --- a/file/zz_generated.deepcopy.go +++ b/file/zz_generated.deepcopy.go @@ -81,6 +81,13 @@ func (in *Content) DeepCopyInto(out *Content) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Developers != nil { + in, out := &in.Developers, &out.Developers + *out = make([]FDeveloper, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.RBACRoles != nil { in, out := &in.RBACRoles, &out.RBACRoles *out = make([]FRBACRole, len(*in)) @@ -291,6 +298,23 @@ func (in *FConsumer) DeepCopy() *FConsumer { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FDeveloper) DeepCopyInto(out *FDeveloper) { + *out = *in + in.Developer.DeepCopyInto(&out.Developer) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FDeveloper. +func (in *FDeveloper) DeepCopy() *FDeveloper { + if in == nil { + return nil + } + out := new(FDeveloper) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FDocument) DeepCopyInto(out *FDocument) { *out = *in diff --git a/state/builder.go b/state/builder.go index f6127b5f9..8e4399fd9 100644 --- a/state/builder.go +++ b/state/builder.go @@ -183,6 +183,13 @@ func buildKong(kongState *KongState, raw *utils.KongRawState) error { } } + for _, d := range raw.Developers { + err := kongState.Developers.Add(Developer{Developer: *d}) + if err != nil { + return fmt.Errorf("inserting developers into state: %w", err) + } + } + for _, r := range raw.RBACRoles { err := kongState.RBACRoles.Add(RBACRole{RBACRole: *r}) if err != nil { diff --git a/state/developer.go b/state/developer.go new file mode 100644 index 000000000..1a788273c --- /dev/null +++ b/state/developer.go @@ -0,0 +1,171 @@ +package state + +import ( + "fmt" + + memdb "github.com/hashicorp/go-memdb" + "github.com/kong/deck/utils" +) + +const ( + developerTableName = "developer" +) + +var developerTableSchema = &memdb.TableSchema{ + Name: developerTableName, + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "ID"}, + }, + "Email": { + Name: "Email", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "Email"}, + AllowMissing: true, + }, + all: allIndex, + }, +} + +// DevelopersCollection stores and indexes Kong Developers. +type DevelopersCollection collection + +// Add adds a developer to the collection +// An error is thrown if developer.ID is empty. +func (k *DevelopersCollection) Add(developer Developer) error { + if utils.Empty(developer.ID) { + return errIDRequired + } + + txn := k.db.Txn(true) + defer txn.Abort() + + var searchBy []string + searchBy = append(searchBy, *developer.ID) + if !utils.Empty(developer.Email) { + searchBy = append(searchBy, *developer.Email) + } + _, err := getDeveloper(txn, searchBy...) + if err == nil { + return fmt.Errorf("inserting developer %v: %w", developer.Console(), ErrAlreadyExists) + } else if err != ErrNotFound { + return err + } + + err = txn.Insert(developerTableName, &developer) + if err != nil { + return err + } + txn.Commit() + return nil +} + +func getDeveloper(txn *memdb.Txn, IDs ...string) (*Developer, error) { + for _, id := range IDs { + res, err := multiIndexLookupUsingTxn(txn, developerTableName, + []string{"Email", "id"}, id) + if err == ErrNotFound { + continue + } + if err != nil { + return nil, err + } + developer, ok := res.(*Developer) + if !ok { + panic(unexpectedType) + } + return &Developer{Developer: *developer.DeepCopy()}, nil + } + return nil, ErrNotFound +} + +// Get gets a developer by email or ID. +func (k *DevelopersCollection) Get(userEmailOrID string) (*Developer, error) { + if userEmailOrID == "" { + return nil, errIDRequired + } + + txn := k.db.Txn(false) + defer txn.Abort() + return getDeveloper(txn, userEmailOrID) +} + +// Update udpates an existing developer. +// It returns an error if the developer is not already present. +func (k *DevelopersCollection) Update(developer Developer) error { + // TODO abstract this in the go-memdb library itself + if utils.Empty(developer.ID) { + return errIDRequired + } + + txn := k.db.Txn(true) + defer txn.Abort() + + err := deleteDeveloper(txn, *developer.ID) + if err != nil { + return err + } + + err = txn.Insert(developerTableName, &developer) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func deleteDeveloper(txn *memdb.Txn, userEmailOrID string) error { + developer, err := getDeveloper(txn, userEmailOrID) + if err != nil { + return err + } + + err = txn.Delete(developerTableName, developer) + if err != nil { + return err + } + return nil +} + +// Delete deletes a developer by email or ID. +func (k *DevelopersCollection) Delete(userEmailOrID string) error { + if userEmailOrID == "" { + return errIDRequired + } + + txn := k.db.Txn(true) + defer txn.Abort() + + err := deleteDeveloper(txn, userEmailOrID) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +// GetAll gets a developer by email or ID. +func (k *DevelopersCollection) GetAll() ([]*Developer, error) { + txn := k.db.Txn(false) + defer txn.Abort() + + iter, err := txn.Get(developerTableName, all, true) + if err != nil { + return nil, err + } + + var res []*Developer + for el := iter.Next(); el != nil; el = iter.Next() { + s, ok := el.(*Developer) + if !ok { + panic(unexpectedType) + } + res = append(res, &Developer{Developer: *s.DeepCopy()}) + } + txn.Commit() + return res, nil +} diff --git a/state/developer_test.go b/state/developer_test.go new file mode 100644 index 000000000..ce30fef0d --- /dev/null +++ b/state/developer_test.go @@ -0,0 +1,164 @@ +package state + +import ( + "testing" + + "github.com/kong/go-kong/kong" + "github.com/stretchr/testify/assert" +) + +func developersCollection() *DevelopersCollection { + return state().Developers +} + +func TestDeveloperInsert(t *testing.T) { + assert := assert.New(t) + collection := developersCollection() + + var developer Developer + + assert.NotNil(collection.Add(developer)) + + developer.ID = kong.String("first") + assert.Nil(collection.Add(developer)) + + // re-insert + developer.Email = kong.String("my-name") + assert.NotNil(collection.Add(developer)) +} + +func TestDeveloperGetUpdate(t *testing.T) { + assert := assert.New(t) + collection := developersCollection() + + var developer Developer + developer.ID = kong.String("first") + developer.Email = kong.String("my-name") + + err := collection.Add(developer) + assert.Nil(err) + + d, err := collection.Get("") + assert.NotNil(err) + assert.Nil(d) + + d, err = collection.Get("first") + assert.Nil(err) + assert.NotNil(d) + + d.ID = nil + d.Email = kong.String("my-updated-name") + + err = collection.Update(*d) + assert.NotNil(err) + + d.ID = kong.String("does-not-exist") + assert.NotNil(collection.Update(*d)) + + d.ID = kong.String("first") + assert.Nil(collection.Update(*d)) + + d, err = collection.Get("my-name") + assert.NotNil(err) + assert.Nil(d) + + d, err = collection.Get("my-updated-name") + assert.Nil(err) + assert.NotNil(d) +} + +// Test to ensure that the memory reference of the pointer returned by Get() +// is different from the one stored in MemDB. +func TestDeveloperGetMemoryReference(t *testing.T) { + assert := assert.New(t) + collection := developersCollection() + + var developer Developer + developer.ID = kong.String("first") + developer.Email = kong.String("my-name") + + err := collection.Add(developer) + assert.Nil(err) + + d, err := collection.Get("first") + assert.Nil(err) + assert.NotNil(d) + d.Email = kong.String("update-should-not-reflect") + + d, err = collection.Get("first") + assert.Nil(err) + assert.Equal("my-name", *d.Email) +} + +func TestDevelopersInvalidType(t *testing.T) { + assert := assert.New(t) + collection := developersCollection() + + type d2 Developer + var d d2 + d.Email = kong.String("my-name") + d.ID = kong.String("first") + txn := collection.db.Txn(true) + assert.Nil(txn.Insert(developerTableName, &d)) + txn.Commit() + + assert.Panics(func() { + collection.Get("my-name") + }) + assert.Panics(func() { + collection.GetAll() + }) +} + +func TestDeveloperDelete(t *testing.T) { + assert := assert.New(t) + collection := developersCollection() + + var developer Developer + developer.ID = kong.String("first") + developer.Email = kong.String("my-developer") + err := collection.Add(developer) + assert.Nil(err) + + d, err := collection.Get("my-developer") + assert.Nil(err) + assert.NotNil(d) + assert.Equal("first", *d.ID) + + err = collection.Delete("first") + assert.Nil(err) + + err = collection.Delete("") + assert.NotNil(err) + + err = collection.Delete(*d.ID) + assert.NotNil(err) +} + +func TestDeveloperGetAll(t *testing.T) { + assert := assert.New(t) + collection := developersCollection() + + developers := []Developer{ + { + Developer: kong.Developer{ + ID: kong.String("first"), + Email: kong.String("my-developer1"), + }, + }, + { + Developer: kong.Developer{ + ID: kong.String("second"), + Email: kong.String("my-developer2"), + }, + }, + } + for _, s := range developers { + assert.Nil(collection.Add(s)) + } + + allDevelopers, err := collection.GetAll() + + assert.Nil(err) + assert.Equal(len(developers), len(allDevelopers)) +} diff --git a/state/state.go b/state/state.go index 691597cfb..99d719677 100644 --- a/state/state.go +++ b/state/state.go @@ -23,6 +23,7 @@ type KongState struct { CACertificates *CACertificatesCollection Plugins *PluginsCollection Consumers *ConsumersCollection + Developers *DevelopersCollection KeyAuths *KeyAuthsCollection HMACAuths *HMACAuthsCollection @@ -61,6 +62,7 @@ func NewKongState() (*KongState, error) { caCertTableName: caCertTableSchema, pluginTableName: pluginTableSchema, consumerTableName: consumerTableSchema, + developerTableName: developerTableSchema, rbacRoleTableName: rbacRoleTableSchema, rbacEndpointPermissionTableName: rbacEndpointPermissionTableSchema, @@ -98,6 +100,7 @@ func NewKongState() (*KongState, error) { state.CACertificates = (*CACertificatesCollection)(&state.common) state.Plugins = (*PluginsCollection)(&state.common) state.Consumers = (*ConsumersCollection)(&state.common) + state.Developers = (*DevelopersCollection)(&state.common) state.RBACRoles = (*RBACRolesCollection)(&state.common) state.RBACEndpointPermissions = (*RBACEndpointPermissionsCollection)(&state.common) diff --git a/state/types.go b/state/types.go index 5685530d1..c9341e56e 100644 --- a/state/types.go +++ b/state/types.go @@ -525,6 +525,59 @@ func forConsumerString(c *kong.Consumer) string { return "" } +// Identifier returns the endpoint key name or ID. +func (d1 *Developer) Identifier() string { + if d1.Email != nil { + return *d1.Email + } + return *d1.ID +} + +// Developer represents a developer in Kong. +// It adds some helper methods along with Meta to the original Developer object. +type Developer struct { + kong.Developer `yaml:",inline"` + Meta +} + +// Console returns an entity's identity in a human +// readable string. +func (d1 *Developer) Console() string { + return d1.Identifier() +} + +// Equal returns true if d1 and d2 are equal. +func (d1 *Developer) Equal(d2 *Developer) bool { + return d1.EqualWithOpts(d2, false, false, false, false) +} + +// EqualWithOpts returns true if d1 and d2 are equal. +// If ignoreID is set to true, IDs will be ignored while comparison. +// If ignoreTS is set to true, timestamp fields will be ignored. +func (d1 *Developer) EqualWithOpts(d2 *Developer, + ignoreID bool, ignoreTS bool, ignorePassword bool, ignoreRBACUser bool) bool { + d1Copy := d1.Developer.DeepCopy() + d2Copy := d2.Developer.DeepCopy() + + if ignoreID { + d1Copy.ID = nil + d2Copy.ID = nil + } + if ignoreTS { + d1Copy.CreatedAt = nil + d2Copy.CreatedAt = nil + } + if ignorePassword { + d1Copy.Password = nil + d2Copy.Password = nil + } + if ignoreRBACUser { + d1Copy.RbacUser = nil + d2Copy.RbacUser = nil + } + return reflect.DeepEqual(d1Copy, d2Copy) +} + // KeyAuth represents a key-auth credential in Kong. // It adds some helper methods along with Meta to the original KeyAuth object. type KeyAuth struct { diff --git a/state/types_test.go b/state/types_test.go index 043beba14..1b1afa046 100644 --- a/state/types_test.go +++ b/state/types_test.go @@ -328,6 +328,32 @@ func TestConsumerEqual(t *testing.T) { assert.False(c1.EqualWithOpts(&c2, false, true)) } +func TestDeveloperEqual(t *testing.T) { + assert := assert.New(t) + + var d1, d2 Developer + d1.ID = kong.String("foo") + d1.Email = kong.String("bar") + + d2.ID = kong.String("foo") + d2.Email = kong.String("baz") + + assert.False(d1.Equal(&d2)) + assert.False(d1.EqualWithOpts(&d2, false, false, false, false)) + + d2.Email = kong.String("bar") + assert.True(d1.Equal(&d2)) + assert.True(d1.EqualWithOpts(&d2, false, false, false, false)) + + d1.ID = kong.String("fuu") + assert.False(d1.EqualWithOpts(&d2, false, false, false, false)) + assert.True(d1.EqualWithOpts(&d2, true, false, false, false)) + + d2.CreatedAt = kong.Int(1) + assert.False(d1.EqualWithOpts(&d2, false, false, false, false)) + assert.False(d1.EqualWithOpts(&d2, false, true, false, false)) +} + func TestKeyAuthEqual(t *testing.T) { assert := assert.New(t) diff --git a/types/core.go b/types/core.go index ec3d189dd..ff5beea4e 100644 --- a/types/core.go +++ b/types/core.go @@ -91,6 +91,8 @@ const ( // OAuth2Cred identifies a OAuth2Cred in Kong. OAuth2Cred EntityType = "oauth2-cred" + // Developer identifies a Developer in Kong. + Developer EntityType = "developer" // RBACRole identifies a RBACRole in Kong Enterprise. RBACRole EntityType = "rbac-role" // RBACEndpointPermission identifies a RBACEndpointPermission in Kong Enterprise. @@ -118,6 +120,7 @@ var AllTypes = []EntityType{ HMACAuth, JWTAuth, OAuth2Cred, MTLSAuth, + Developer, RBACRole, RBACEndpointPermission, ServicePackage, ServiceVersion, Document, @@ -309,6 +312,21 @@ func NewEntity(t EntityType, opts EntityOpts) (Entity, error) { targetState: opts.TargetState, }, }, nil + case Developer: + return entityImpl{ + typ: Developer, + crudActions: &developerCRUD{ + client: opts.KongClient, + }, + postProcessActions: &developerPostAction{ + currentState: opts.CurrentState, + }, + differ: &developerDiffer{ + kind: entityTypeToKind(Developer), + currentState: opts.CurrentState, + targetState: opts.TargetState, + }, + }, nil case RBACEndpointPermission: return entityImpl{ typ: RBACEndpointPermission, diff --git a/types/developer.go b/types/developer.go new file mode 100644 index 000000000..a8fd932cd --- /dev/null +++ b/types/developer.go @@ -0,0 +1,161 @@ +package types + +import ( + "context" + "fmt" + + "github.com/kong/deck/crud" + "github.com/kong/deck/state" + "github.com/kong/go-kong/kong" +) + +// developerCRUD implements crud.Actions interface. +type developerCRUD struct { + client *kong.Client +} + +func developerFromStruct(arg crud.Event) *state.Developer { + developer, ok := arg.Obj.(*state.Developer) + if !ok { + panic("unexpected type, expected *state.developer") + } + return developer +} + +// Create creates a Developer in Kong. +// The arg should be of type crud.Event, containing the developer to be created, +// else the function will panic. +// It returns a the created *state.Developer. +func (s *developerCRUD) Create(ctx context.Context, arg ...crud.Arg) (crud.Arg, error) { + event := crud.EventFromArg(arg[0]) + developer := developerFromStruct(event) + createdDeveloper, err := s.client.Developers.Create(ctx, &developer.Developer) + if err != nil { + return nil, err + } + return &state.Developer{Developer: *createdDeveloper}, nil +} + +// Delete deletes a Developer in Kong. +// The arg should be of type crud.Event, containing the developer to be deleted, +// else the function will panic. +// It returns a the deleted *state.Developer. +func (s *developerCRUD) Delete(ctx context.Context, arg ...crud.Arg) (crud.Arg, error) { + event := crud.EventFromArg(arg[0]) + developer := developerFromStruct(event) + + err := s.client.Developers.Delete(ctx, developer.ID) + if err != nil { + return nil, err + } + return developer, nil +} + +// Update updates a Developer in Kong. +// The arg should be of type crud.Event, containing the developer to be updated, +// else the function will panic. +// It returns a the updated *state.Developer. +func (s *developerCRUD) Update(ctx context.Context, arg ...crud.Arg) (crud.Arg, error) { + event := crud.EventFromArg(arg[0]) + developer := developerFromStruct(event) + + updatedDeveloper, err := s.client.Developers.Create(ctx, &developer.Developer) + if err != nil { + return nil, err + } + return &state.Developer{Developer: *updatedDeveloper}, nil +} + +type developerDiffer struct { + kind crud.Kind + + currentState, targetState *state.KongState +} + +func (d *developerDiffer) Deletes(handler func(crud.Event) error) error { + currentDevelopers, err := d.currentState.Developers.GetAll() + if err != nil { + return fmt.Errorf("error fetching developers from state: %w", err) + } + + for _, developer := range currentDevelopers { + n, err := d.deleteDeveloper(developer) + if err != nil { + return err + } + if n != nil { + err = handler(*n) + if err != nil { + return err + } + } + + } + return nil +} + +func (d *developerDiffer) deleteDeveloper(developer *state.Developer) (*crud.Event, error) { + _, err := d.targetState.Developers.Get(*developer.ID) + if err == state.ErrNotFound { + return &crud.Event{ + Op: crud.Delete, + Kind: d.kind, + Obj: developer, + }, nil + } + if err != nil { + return nil, fmt.Errorf("looking up developer %q: %w", + developer.Identifier(), err) + } + return nil, nil +} + +func (d *developerDiffer) CreateAndUpdates(handler func(crud.Event) error) error { + targetDevelopers, err := d.targetState.Developers.GetAll() + if err != nil { + return fmt.Errorf("error fetching developers from state: %w", err) + } + + for _, developer := range targetDevelopers { + n, err := d.createUpdateDeveloper(developer) + if err != nil { + return err + } + if n != nil { + err = handler(*n) + if err != nil { + return err + } + } + } + return nil +} + +func (d *developerDiffer) createUpdateDeveloper(developer *state.Developer) (*crud.Event, error) { + developerCopy := &state.Developer{Developer: *developer.DeepCopy()} + currentDeveloper, err := d.currentState.Developers.Get(*developer.ID) + + if err == state.ErrNotFound { + // developer not present, create it + return &crud.Event{ + Op: crud.Create, + Kind: d.kind, + Obj: developerCopy, + }, nil + } + if err != nil { + return nil, fmt.Errorf("error looking up developer %q: %w", + developer.Identifier(), err) + } + + // found, check if update needed + if !currentDeveloper.EqualWithOpts(developerCopy, false, true, true, false) { + return &crud.Event{ + Op: crud.Update, + Kind: d.kind, + Obj: developerCopy, + OldObj: currentDeveloper, + }, nil + } + return nil, nil +} diff --git a/types/postProcess.go b/types/postProcess.go index 6195cded4..897768924 100644 --- a/types/postProcess.go +++ b/types/postProcess.go @@ -153,6 +153,22 @@ func (crud *consumerPostAction) Update(ctx context.Context, args ...crud.Arg) (c return nil, crud.currentState.Consumers.Update(*args[0].(*state.Consumer)) } +type developerPostAction struct { + currentState *state.KongState +} + +func (crud *developerPostAction) Create(ctx context.Context, args ...crud.Arg) (crud.Arg, error) { + return nil, crud.currentState.Developers.Add(*args[0].(*state.Developer)) +} + +func (crud *developerPostAction) Delete(ctx context.Context, args ...crud.Arg) (crud.Arg, error) { + return nil, crud.currentState.Developers.Delete(*((args[0].(*state.Developer)).ID)) +} + +func (crud *developerPostAction) Update(ctx context.Context, args ...crud.Arg) (crud.Arg, error) { + return nil, crud.currentState.Developers.Update(*args[0].(*state.Developer)) +} + type keyAuthPostAction struct { currentState *state.KongState } diff --git a/utils/types.go b/utils/types.go index 5473d4f6d..8fe138b69 100644 --- a/utils/types.go +++ b/utils/types.go @@ -46,6 +46,7 @@ type KongRawState struct { Oauth2Creds []*kong.Oauth2Credential MTLSAuths []*kong.MTLSAuth + Developers []*kong.Developer RBACRoles []*kong.RBACRole RBACEndpointPermissions []*kong.RBACEndpointPermission }