Skip to content

RSDK 1820 Support modular validation and implicit dependencies #1975

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

Merged
merged 14 commits into from
Mar 10, 2023
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
10 changes: 6 additions & 4 deletions config/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ func findConverter(subtype resource.Subtype, model resource.Model, attr string)
return nil
}

func findMapConverter(subtype resource.Subtype, model resource.Model) AttributeMapConverter {
// FindMapConverter finds the component AttributeMapConverter for the given subtype and model.
func FindMapConverter(subtype resource.Subtype, model resource.Model) AttributeMapConverter {
for _, r := range componentAttributeMapConverters {
if r.Subtype == subtype && r.Model == model {
return r.Conv
Expand All @@ -196,7 +197,8 @@ func findMapConverter(subtype resource.Subtype, model resource.Model) AttributeM
return nil
}

func findServiceMapConverter(svcType resource.Subtype, model resource.Model) AttributeMapConverter {
// FindServiceMapConverter finds the service AttributeMapConverter for the given subtype and model.
func FindServiceMapConverter(svcType resource.Subtype, model resource.Model) AttributeMapConverter {
for _, r := range serviceAttributeMapConverters {
if r.SvcType == svcType && r.Model == model {
return r.Conv
Expand Down Expand Up @@ -552,7 +554,7 @@ func processConfig(unprocessedConfig *Config, fromCloud bool) (*Config, error) {

for idx, c := range cfg.Components {
cType := resource.NewSubtype(c.Namespace, "component", c.Type)
conv := findMapConverter(cType, c.Model)
conv := FindMapConverter(cType, c.Model)
// inner attributes may have their own converters
for k, v := range c.Attributes {
attrConv := findConverter(cType, c.Model, k)
Expand All @@ -579,7 +581,7 @@ func processConfig(unprocessedConfig *Config, fromCloud bool) (*Config, error) {
}

for idx, c := range cfg.Services {
conv := findServiceMapConverter(resource.NewSubtype(c.Namespace, resource.ResourceTypeService, c.Type), c.Model)
conv := FindServiceMapConverter(resource.NewSubtype(c.Namespace, resource.ResourceTypeService, c.Type), c.Model)
if conv == nil {
continue
}
Expand Down
1 change: 0 additions & 1 deletion examples/customresources/demos/complexmodule/module.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@
"type": "base",
"name": "base1",
"model": "acme:demo:mybase",
"depends_on": ["motor1", "motor2"],
"attributes": {
"motorL": "motor1",
"motorR": "motor2"
Expand Down
51 changes: 40 additions & 11 deletions examples/customresources/models/mybase/mybase.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mybase

import (
"context"
"fmt"
"math"

"github.com/edaniels/golog"
Expand All @@ -19,12 +20,26 @@ import (
)

var (
Model = resource.NewModel("acme", "demo", "mybase")
errUnimplemented = errors.New("unimplemented")
Model = resource.NewModel("acme", "demo", "mybase")
errUnimplemented = errors.New("unimplemented")
)

func init() {
registry.RegisterComponent(base.Subtype, Model, registry.Component{Constructor: newBase})

// Use RegisterComponentAttributeMapConverter to register a custom configuration
// struct that has a Validate(string) ([]string, error) method.
//
// The Validate method will automatically be called in RDK's module manager to
// Validate the MyBase's configuration and register implicit dependencies.
config.RegisterComponentAttributeMapConverter(
base.Subtype,
Model,
func(attributes config.AttributeMap) (interface{}, error) {
var conf MyBaseConfig
return config.TransformAttributeMapToStruct(&conf, attributes)
},
&MyBaseConfig{})
}

func newBase(ctx context.Context, deps registry.Dependencies, config config.Component, logger golog.Logger) (interface{}, error) {
Expand Down Expand Up @@ -55,18 +70,30 @@ func (base *MyBase) Reconfigure(cfg config.Component, deps registry.Dependencies
}
}

if base.left == nil || base.right == nil {
return errors.New("motorL and motorR must both be in depends_on")
return nil
}

type MyBaseConfig struct {
LeftMotor string `json:"motorL"`
RightMotor string `json:"motorR"`
}

func (cfg *MyBaseConfig) Validate(path string) ([]string, error) {
if cfg.LeftMotor == "" {
return nil, fmt.Errorf(`expected "motorL" attribute for mybase %q`, path)
}
if cfg.RightMotor == "" {
return nil, fmt.Errorf(`expected "motorR" attribute for mybase %q`, path)
}

return nil
return []string{cfg.LeftMotor, cfg.RightMotor}, nil
}

type MyBase struct {
generic.Echo
left motor.Motor
right motor.Motor
logger golog.Logger
left motor.Motor
right motor.Motor
logger golog.Logger
}

func (base *MyBase) MoveStraight(ctx context.Context, distanceMm int, mmPerSec float64, extra map[string]interface{}) error {
Expand All @@ -83,10 +110,12 @@ func (base *MyBase) SetVelocity(ctx context.Context, linear, angular r3.Vector,

func (base *MyBase) SetPower(ctx context.Context, linear, angular r3.Vector, extra map[string]interface{}) error {
base.logger.Debugf("SetPower Linear: %.2f Angular: %.2f", linear.Y, angular.Z)
if math.Abs(linear.Y) < 0.01 && math.Abs(angular.Z) < 0.01 { return base.Stop(ctx, extra)}
if math.Abs(linear.Y) < 0.01 && math.Abs(angular.Z) < 0.01 {
return base.Stop(ctx, extra)
}
sum := math.Abs(linear.Y) + math.Abs(angular.Z)
err1 := base.left.SetPower(ctx, (linear.Y - angular.Z)/sum, extra)
err2 := base.right.SetPower(ctx, (linear.Y + angular.Z)/sum, extra)
err1 := base.left.SetPower(ctx, (linear.Y-angular.Z)/sum, extra)
err2 := base.right.SetPower(ctx, (linear.Y+angular.Z)/sum, extra)
return multierr.Combine(err1, err2)
}

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ require (
go.uber.org/atomic v1.10.0
go.uber.org/multierr v1.9.0
go.uber.org/zap v1.24.0
go.viam.com/api v0.1.86
go.viam.com/api v0.1.87
go.viam.com/slam v0.1.30
go.viam.com/test v1.1.1-0.20220909204145-f61b7c01c33e
go.viam.com/utils v0.1.15
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1572,8 +1572,8 @@ go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
go.viam.com/api v0.1.86 h1:zLOfkPZL1BEXiqujZqbJAuAZRFfvxsBdq8Lwb2cuvks=
go.viam.com/api v0.1.86/go.mod h1:v7SdHHR8bvAxV9UGuUsp6MWQYcRHssHkI5FC05W+uyg=
go.viam.com/api v0.1.87 h1:27k+TsGCqrnsdshCAaKkBAsf3ksS7c8rXM5Eji69VOo=
go.viam.com/api v0.1.87/go.mod h1:v7SdHHR8bvAxV9UGuUsp6MWQYcRHssHkI5FC05W+uyg=
go.viam.com/slam v0.1.30 h1:JpvHtCR/X0+8I77QGBiJczoISpc4JQIZcQIHT9V7HUA=
go.viam.com/slam v0.1.30/go.mod h1:xPgjNsRQhdvxCRncRmkuyqteqWr12eAO+1tnMM49BaI=
go.viam.com/test v1.1.1-0.20220909204145-f61b7c01c33e h1:1tJIJv/zsobFV5feR2ZiG/+VGZkRPVHqJrSTkXbWfqQ=
Expand Down
38 changes: 38 additions & 0 deletions module/modmanager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import (
pb "go.viam.com/api/module/v1"
"go.viam.com/utils/pexec"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"

"go.viam.com/rdk/config"
rdkgrpc "go.viam.com/rdk/grpc"
Expand All @@ -27,6 +29,8 @@ import (
"go.viam.com/rdk/robot"
)

var validateConfigTimeout = 5 * time.Second

// NewManager returns a Manager.
func NewManager(r robot.LocalRobot) (modmaninterface.ModuleManager, error) {
return &Manager{
Expand Down Expand Up @@ -195,6 +199,40 @@ func (mgr *Manager) RemoveResource(ctx context.Context, name resource.Name) erro
return err
}

// ValidateConfig determines whether the given config is valid and returns its implicit
// dependencies.
func (mgr *Manager) ValidateConfig(ctx context.Context, cfg config.Component) ([]string, error) {
mgr.mu.RLock()
defer mgr.mu.RUnlock()
module, ok := mgr.getModule(cfg)
if !ok {
return nil,
errors.Errorf("no module registered to serve resource api %s and model %s",
cfg.ResourceName().Subtype, cfg.Model)
}

cfgProto, err := config.ComponentConfigToProto(&cfg)
if err != nil {
return nil, err
}

// Override context with new timeout.
var cancel func()
ctx, cancel = context.WithTimeout(ctx, validateConfigTimeout)
defer cancel()

resp, err := module.client.ValidateConfig(ctx, &pb.ValidateConfigRequest{Config: cfgProto})
// Swallow "Unimplemented" gRPC errors from modules that lack ValidateConfig
// receiving logic.
if err != nil && status.Code(err) == codes.Unimplemented {
return nil, nil
}
if err != nil {
return nil, err
}
return resp.Dependencies, nil
}

func (mgr *Manager) getModule(cfg config.Component) (*module, bool) {
for _, module := range mgr.modules {
var api resource.RPCSubtype
Expand Down
98 changes: 98 additions & 0 deletions module/modmanager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"os"
"os/exec"
"testing"
"time"

"github.com/edaniels/golog"
"go.viam.com/test"
goutils "go.viam.com/utils"

"go.viam.com/rdk/components/base"
"go.viam.com/rdk/components/generic"
"go.viam.com/rdk/components/motor"
"go.viam.com/rdk/config"
Expand Down Expand Up @@ -125,6 +127,15 @@ func TestModManagerFunctions(t *testing.T) {
ok = mgr.IsModularResource(resource.NameFromSubtype(generic.Subtype, "missing"))
test.That(t, ok, test.ShouldBeFalse)

t.Log("test ValidateConfig")
// ValidateConfig for cfgCounter1 will not actually call any Validate functionality,
// as the mycounter model does not have a configuration object with Validate.
// Assert that ValidateConfig does not fail in this case (allows unimplemented
// validation).
deps, err := mgr.ValidateConfig(ctx, cfgCounter1)
test.That(t, err, test.ShouldBeNil)
test.That(t, deps, test.ShouldBeNil)

t.Log("test ReconfigureResource")
// Reconfigure should replace the proxied object, resetting the counter
ret, err = counter.DoCommand(ctx, map[string]interface{}{"command": "add", "value": 73})
Expand Down Expand Up @@ -158,3 +169,90 @@ func TestModManagerFunctions(t *testing.T) {
err = mgr.Close(ctx)
test.That(t, err, test.ShouldBeNil)
}

func TestModManagerValidation(t *testing.T) {
ctx := context.Background()
logger := golog.NewTestLogger(t)
modExe := utils.ResolveFile("examples/customresources/demos/complexmodule/run.sh")

// Precompile module to avoid timeout issues when building takes too long.
builder := exec.Command("go", "build", ".")
builder.Dir = utils.ResolveFile("examples/customresources/demos/complexmodule")
out, err := builder.CombinedOutput()
test.That(t, string(out), test.ShouldEqual, "")
test.That(t, err, test.ShouldBeNil)

myBaseModel := resource.NewModel("acme", "demo", "mybase")
cfgMyBase1 := config.Component{
Name: "mybase1",
API: base.Subtype,
Model: myBaseModel,
Attributes: map[string]interface{}{
"motorL": "motor1",
"motorR": "motor2",
},
}
_, err = cfgMyBase1.Validate("test")
test.That(t, err, test.ShouldBeNil)
// cfgMyBase2 is missing required attributes "motorL" and "motorR" and should
// cause module Validation error.
cfgMyBase2 := config.Component{
Name: "mybase2",
API: base.Subtype,
Model: myBaseModel,
}
_, err = cfgMyBase2.Validate("test")
test.That(t, err, test.ShouldBeNil)

myRobot := &inject.Robot{}
myRobot.LoggerFunc = func() golog.Logger {
return logger
}

// This cannot use t.TempDir() as the path it gives on MacOS exceeds module.MaxSocketAddressLength.
parentAddr, err := os.MkdirTemp("", "viam-test-*")
test.That(t, err, test.ShouldBeNil)
defer os.RemoveAll(parentAddr)
parentAddr += "/parent.sock"

myRobot.ModuleAddressFunc = func() (string, error) {
return parentAddr, nil
}

t.Log("adding complex module")
mgr, err := NewManager(myRobot)
test.That(t, err, test.ShouldBeNil)

modCfg := config.Module{
Name: "complex-module",
ExePath: modExe,
}
err = mgr.Add(ctx, modCfg)
test.That(t, err, test.ShouldBeNil)

reg := registry.ComponentLookup(base.Subtype, myBaseModel)
test.That(t, reg.Constructor, test.ShouldNotBeNil)

t.Log("test ValidateConfig")
deps, err := mgr.ValidateConfig(ctx, cfgMyBase1)
test.That(t, err, test.ShouldBeNil)
test.That(t, deps, test.ShouldNotBeNil)
test.That(t, deps[0], test.ShouldResemble, "motor1")
test.That(t, deps[1], test.ShouldResemble, "motor2")

_, err = mgr.ValidateConfig(ctx, cfgMyBase2)
test.That(t, err, test.ShouldNotBeNil)
test.That(t, err.Error(), test.ShouldResemble,
`rpc error: code = Unknown desc = error validating resource: expected "motorL" attribute for mybase "mybase2"`)

// Test that ValidateConfig respects validateConfigTimeout by artificially
// lowering it to an impossibly small duration.
validateConfigTimeout = 1 * time.Nanosecond
_, err = mgr.ValidateConfig(ctx, cfgMyBase1)
test.That(t, err, test.ShouldNotBeNil)
test.That(t, err.Error(), test.ShouldResemble,
"rpc error: code = DeadlineExceeded desc = context deadline exceeded")

err = mgr.Close(ctx)
test.That(t, err, test.ShouldBeNil)
}
1 change: 1 addition & 0 deletions module/modmaninterface/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type ModuleManager interface {
ReconfigureResource(ctx context.Context, cfg config.Component, deps []string) error
RemoveResource(ctx context.Context, name resource.Name) error
IsModularResource(name resource.Name) bool
ValidateConfig(ctx context.Context, cfg config.Component) ([]string, error)

Provides(cfg config.Component) bool

Expand Down
43 changes: 43 additions & 0 deletions module/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,49 @@ func (m *Module) ReconfigureResource(ctx context.Context, req *pb.ReconfigureRes
return nil, errors.Errorf("can't recreate resource %+v", req.Config)
}

// Validator is a resource configuration object that implements Validate.
type Validator interface {
// Validate ensures that the object is valid and returns any implicit dependencies.
Validate(path string) ([]string, error)
}

// ValidateConfig receives the validation request for a resource from the parent.
func (m *Module) ValidateConfig(ctx context.Context,
req *pb.ValidateConfigRequest,
) (*pb.ValidateConfigResponse, error) {
c, err := config.ComponentConfigFromProto(req.Config)
if err != nil {
return nil, err
}

// Try to find map converter for a component.
cType := resource.NewSubtype(c.Namespace, c.API.ResourceType, c.API.ResourceSubtype)
conv := config.FindMapConverter(cType, c.Model)
// If no map converter for a component exists, try to find map converter for a
// service.
if conv == nil {
conv = config.FindServiceMapConverter(cType, c.Model)
}
if conv != nil {
converted, err := conv(c.Attributes)
if err != nil {
return nil, errors.Wrapf(err, "error converting attributes for resource")
}
validator, ok := converted.(Validator)
if ok {
implicitDeps, err := validator.Validate(c.Name)
if err != nil {
return nil, errors.Wrapf(err, "error validating resource")
}
return &pb.ValidateConfigResponse{Dependencies: implicitDeps}, nil
}
}

// Resource configuration object does not implement Validate, but return an
// empty response and no error to maintain backward compatibility.
return &pb.ValidateConfigResponse{}, nil
}

// RemoveResource receives the request for resource removal.
func (m *Module) RemoveResource(ctx context.Context, req *pb.RemoveResourceRequest) (*pb.RemoveResourceResponse, error) {
slowWatcher, slowWatcherCancel := utils.SlowGoroutineWatcher(
Expand Down
Loading