Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enable saving config between steps in a single feature #103

Merged
merged 7 commits into from
Mar 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pkg/environment/magic.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import (
"testing"

corev1 "k8s.io/api/core/v1"

"knative.dev/reconciler-test/pkg/feature"
"knative.dev/reconciler-test/pkg/state"
)

func NewGlobalEnvironment(ctx context.Context) GlobalEnvironment {
Expand Down Expand Up @@ -132,9 +134,19 @@ func (mr *MagicEnvironment) Prerequisite(ctx context.Context, t *testing.T, f *f
})
}

// Test implements Environment.Test.
// In the MagicEnvironment implementation, the Store that is inside of the
// Feature will be assigned to the context. If no Store is set on Feature,
// Test will create a new store.KVStore and set it on the feature and then
// apply it to the Context.
func (mr *MagicEnvironment) Test(ctx context.Context, t *testing.T, f *feature.Feature) {
t.Helper() // Helper marks the calling function as a test helper function.

if f.State == nil {
f.State = &state.KVStore{}
}
ctx = state.ContextWith(ctx, f.State)

steps := feature.CollapseSteps(f.Steps)

for _, timing := range feature.Timings() {
Expand Down
3 changes: 3 additions & 0 deletions pkg/feature/feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ package feature
import (
"context"
"fmt"

"knative.dev/reconciler-test/pkg/state"
)

// Feature is a list of steps and feature name.
type Feature struct {
Name string
Steps []Step
State state.Store
}

// FeatureSet is a list of features and feature set name.
Expand Down
70 changes: 70 additions & 0 deletions pkg/state/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
Copyright 2021 The Knative Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package state

import (
"context"
)

type envKey struct{}

// ContextWith decorates the given context with the provided Store, and returns
// the resulting context.
func ContextWith(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, envKey{}, s)
}

// FromContext returns the Store from Context, if not found FromContext will
// panic.
// TODO: revisit if we really want to panic here... likely not.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO panic is absolutely fine here, the Store must always exists in the context passed to StepFn. If it's not there, it's a reconciler-test bug

func FromContext(ctx context.Context) Store {
slinkydeveloper marked this conversation as resolved.
Show resolved Hide resolved
if e, ok := ctx.Value(envKey{}).(Store); ok {
return e
}
panic("no Store found in context")
}

// Fail is defined to avoid circular dependency with the feature package.
type fail interface {
Error(args ...interface{})
}

// Get the string value from the kvstore from key.
func GetStringOrFail(ctx context.Context, t fail, key string) string {
value := ""
state := FromContext(ctx)
if err := state.Get(ctx, key, &value); err != nil {
t.Error(err)
}
return value
}

// Get gets the key from the Store into the provided value
func GetOrFail(ctx context.Context, t fail, key string, value interface{}) {
state := FromContext(ctx)
if err := state.Get(ctx, key, value); err != nil {
t.Error(err)
}
}

// Set sets the key into the Store from the provided value
func SetOrFail(ctx context.Context, t fail, key string, value interface{}) {
state := FromContext(ctx)
if err := state.Set(ctx, key, value); err != nil {
t.Error(err)
}
}
27 changes: 27 additions & 0 deletions pkg/state/interfaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
Copyright 2021 The Knative Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package state

import "context"

// This is a subset of knative.dev/pkg/kvstore.Interface
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: @slinkydeveloper ^^

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason why we're trying to match that particular interface about config maps?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that we can swap out this impl for the same that is used in pkg.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are trying to make synergy here, not reboil the world. I made the interface a subset because some of those things did not make sense in the context of features, but I want to use that impl in pkg at some point later.

type Store interface {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of defining a separate interface, would it be bad if we did not define this new interface, but instead in the InMemory implementation, we return NonImplemented error instead?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was attempting to allow to use one of those impls, but not vendor it in directly because the Load and Init and Save did not make sense for Features

// Get gets the key from the Store into the provided value
Get(ctx context.Context, key string, value interface{}) error
// Set sets the key into the Store from the provided value
Set(ctx context.Context, key string, value interface{}) error
}
67 changes: 67 additions & 0 deletions pkg/state/kvstore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
Copyright 2021 The Knative Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package state

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

type KVStore struct {
store map[string]string
mux sync.Mutex
}

// Get retrieves and unmarshals the value from the map.
func (cs *KVStore) Get(_ context.Context, key string, value interface{}) error {
cs.mux.Lock()
defer cs.mux.Unlock()

if cs.store == nil {
cs.store = make(map[string]string)
}

v, ok := cs.store[key]

if !ok {
return fmt.Errorf("key %s does not exist", key)
}
err := json.Unmarshal([]byte(v), value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah wait i didn't saw it, why storing json? Can't we store just void pointers to things and then cast them when retrieving them? The state to store doesn't necessarily define conversion back and forth to json, and for some reason the user might even not be able to define such conversion (e.g. when you're using a struct from an external library as state)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I avoided this way to prevent mutation of the thing that is stored in the kvstore. Set, Get, mutate get'ed object, Get updated object?? is unexpected to me. cheap and cheerful way is json.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point, but maybe what I want to store inside the kvstore is exactly a mutable object, like a list.

In any case, my problem with json is that you might end up storing objects which are not very json compatible but where marshalling doesn't fail, and you will lose some information, and together with it debugging time.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the impl can evolve over time, this is more about the interface and the current problemset is really "how do I pass that url to the next phase"

if you start working out ways of passing callbacks I will burn the building down.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

net.Url is a too simplistic example (and even with it, sometimes encoding and decoding it back and forth to json has weird behaviours). Any more complex state where json encoding/decoding is not defined won't work with this implementation.

if you start working out ways of passing callbacks I will burn the building down.

That's state of the test too 😄

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is a kv store, if you can't save it to a config map, it will not work. That is the bar.

Copy link
Contributor

@slinkydeveloper slinkydeveloper Mar 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why are we using the kv store model for something which is always in memory by design? It's an in memory map, it's the state of the test, it's not something that has to be read/write to a config map.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might let us do some really interesting things later, like retries or test startup in the middle, or capture test state.

if err != nil {
return fmt.Errorf("failed to Unmarshal %q: %v", v, err)
}
return nil
}

// Set marshals and sets the value given under specified key.
func (cs *KVStore) Set(_ context.Context, key string, value interface{}) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we expect users to check the error everytime and fail the StepFn if the Set/Get returns an error? Isn't that too much and makes the usage unnecessary complicated? Can't we just panic in case of an error? These Get/Set errors are not recoverable anyway.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am matching a subset of this interface: https://github.com/knative/pkg/blob/master/kvstore/kvstore.go#L23

Copy link
Contributor

@slinkydeveloper slinkydeveloper Mar 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's an implementation, i guess you can always have the GetOrFail and SetOrFail

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't do that because KVStore is not used, it is Store, and Store is the pkg kvstore interface.

I have added methods like this to the context.

cs.mux.Lock()
defer cs.mux.Unlock()

if cs.store == nil {
cs.store = make(map[string]string)
}

bytes, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to Marshal: %v", err)
}
cs.store[key] = string(bytes)
return nil
}
91 changes: 91 additions & 0 deletions pkg/state/kvstore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
Copyright 2021 The Knative Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package state

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"
)

func TestKVStore_Get(t *testing.T) {
type test1 struct {
Foo string
Bar string
}

tests := []struct {
name string
store map[string]string
key string
target interface{}
want interface{}
wantErr bool
}{{
name: "nil store",
store: nil,
key: "foo",
target: func() interface{} {
return ""
}(),
want: "",
wantErr: true,
}, {
name: "found",
store: map[string]string{
"foo": `"bar"`,
},
key: "foo",
target: func() interface{} {
return ""
}(),
want: "bar",
wantErr: false,
}, {
name: "found struct",
store: map[string]string{
"key": `{"Foo": "hello","Bar": "world"}`,
},
key: "key",
target: func() interface{} {
return test1{}
}(),
want: test1{
Foo: "hello",
Bar: "world",
},
wantErr: false,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := KVStore{
store: tt.store,
}
err := s.Get(context.Background(), tt.key, &tt.target)
if (err != nil) != tt.wantErr {
t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
if diff := cmp.Diff(tt.want, tt.target); diff != "" {
t.Error("Unexpected difference on return value (-want, +got):", diff)
}
}
})
}
}
42 changes: 31 additions & 11 deletions test/example/recorder_feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ limitations under the License.
package example

import (
"context"
"time"

cloudevents "github.com/cloudevents/sdk-go/v2"
"k8s.io/apimachinery/pkg/runtime/schema"

"knative.dev/reconciler-test/pkg/eventshub"
"knative.dev/reconciler-test/pkg/feature"
"knative.dev/reconciler-test/pkg/k8s"
"knative.dev/reconciler-test/pkg/state"

// Dot import the eventshub asserts and sdk-go test packages to include all the assert utilities
. "github.com/cloudevents/sdk-go/v2/test"
Expand All @@ -33,22 +36,39 @@ import (
func RecorderFeature() *feature.Feature {
svc := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "services"}

from := feature.MakeRandomK8sName("sender")
to := feature.MakeRandomK8sName("recorder")

f := new(feature.Feature)

event := FullEvent()

f.Setup("install recorder", eventshub.Install(to, eventshub.StartReceiver))
f.Setup("install sender", eventshub.Install(from, eventshub.StartSender(to), eventshub.InputEvent(event)))

f.Requirement("recorder is addressable", k8s.IsAddressable(svc, to, time.Second, 30*time.Second))
f.Setup("create an event", func(ctx context.Context, t feature.T) {
state.SetOrFail(ctx, t, "event", FullEvent())
})

f.Setup("install recorder", func(ctx context.Context, t feature.T) {
to := feature.MakeRandomK8sName("recorder")
eventshub.Install(to, eventshub.StartReceiver)
state.SetOrFail(ctx, t, "to", to)
})

f.Setup("install sender", func(ctx context.Context, t feature.T) {
to := state.GetStringOrFail(ctx, t, "to")
var event cloudevents.Event
state.GetOrFail(ctx, t, "event", &event)
from := feature.MakeRandomK8sName("sender")
eventshub.Install(from, eventshub.StartSender(to), eventshub.InputEvent(event))
})

f.Requirement("recorder is addressable", func(ctx context.Context, t feature.T) {
to := state.GetStringOrFail(ctx, t, "to")
k8s.IsAddressable(svc, to, time.Second, 30*time.Second)
})

f.Alpha("direct sending between a producer and a recorder").
Must("the recorder received all sent events within the time",
OnStore(to).MatchEvent(HasId(event.ID())).Exact(1),
)
func(ctx context.Context, t feature.T) {
to := state.GetStringOrFail(ctx, t, "to")
var event cloudevents.Event
state.GetOrFail(ctx, t, "event", &event)
OnStore(to).MatchEvent(HasId(event.ID())).Exact(1)
})

return f
}