diff --git a/.gitignore b/.gitignore index 6a7b6077..0496aa8d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist/ clab-* tests/robot/*-yang-models/ .idea/ +docs/prd/ diff --git a/docs/tree.mermaid b/docs/tree.mermaid deleted file mode 100644 index 59ba25d7..00000000 --- a/docs/tree.mermaid +++ /dev/null @@ -1,124 +0,0 @@ ---- -title: sdcio -- pkg/tree ---- -classDiagram - - class sharedEntryAttributes { - parent Entry - pathElemName string - childs map[string]Entry - leafVariants LeafVariants - schema *sdcpb.SchemaElem - choicesResolvers choiceCasesResolvers - treeContext *TreeContext - } - - class Entry { - <> - Path() []string - PathName() string - AddChild(context.Context, Entry) error - AddCacheUpdateRecursive(ctx context.Context, u *cache.Update, new bool) error - StringIndent(result []string) []string - GetHighestPrecedence(u UpdateSlice, onlyNewOrUpdated bool) UpdateSlice - GetByOwner(owner string, result []*LeafEntry) []*LeafEntry - MarkOwnerDelete(o string) - GetDeletes([][]string) [][]string - Walk(f EntryVisitor) error - ShouldDelete() bool - IsDeleteKeyAttributesInLevelDown(level int, keys []string, result [][]string) [][]string - ValidateMandatory() error - ValidateMandatoryWithKeys(level int, attribute string) error - GetHighestPrecedenceValueOfBranch() int32 - GetSchema() *sdcpb.SchemaElem - IsRoot() bool - FinishInsertionPhase() - GetParent() Entry - } - - class EntryImpl { - s sharedEntryAttributes - } - - class RootEntry { - s sharedEntryAttributes - } - - - - class LeafVariants { - <> - []*LeafEntry - - GetHighestPrecedenceValue() int32 - GetHighestPrecedence(onlyIfPrioChanged bool) *LeafEntry - GetByOwner(owner string) *LeafEntry - ShouldDelete() bool - } - - class LeafEntry { - *cache.Update - IsNew bool - Delete bool - IsUpdated bool - MarkUpdate(u *cache.Update) - MarkDelete() - String() string - } - - class choiceCasesResolvers { - <> - map[string]*choiceCasesResolver - - AddChoice(name string) *choiceCasesResolver - GetSkipElements() []string - GetChoiceElementNeighbors(elemName string) []string - } - - class choiceCaseResolver { - cases map[string]*choicesCase - elementToCaseMapping map[string]string - - GetElementNames() []string - - } - - class choicesCase { - name string - elements map[string]*choicesCaseElement - - GetLowestPriorityValue() int32 - GetLowestPriorityValueOld() int32 - } - - class choicesCaseElement { - name string - value int32 - new bool - - AddCase(name string, elements []string) *choicesCase - SetValue(elemName string, v int32, new bool) - getBestCaseName() string - getOldBestCaseName() string - GetSkipElements() []string - } - -LeafEntry ..o LeafVariants - -LeafVariants ..o sharedEntryAttributes - -choiceCaseResolver ..o choiceCasesResolvers - -choiceCasesResolvers <-- sharedEntryAttributes : uses - -choicesCase ..o choiceCaseResolver - -choicesCaseElement ..o choicesCase - - -Entry <|.. RootEntry -Entry <|.. sharedEntryAttributes - -EntryImpl ..> sharedEntryAttributes : «refine» -RootEntry ..> sharedEntryAttributes : «refine» - diff --git a/go.mod b/go.mod index 82891a09..7bf6e174 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/sdcio/cache v0.0.38 github.com/sdcio/logger v0.0.3 github.com/sdcio/schema-server v0.0.34 - github.com/sdcio/sdc-protos v0.0.54 + github.com/sdcio/sdc-protos v0.0.55-0.20260601095759-67240812f373 github.com/sdcio/yang-parser v0.0.12 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 4f8192a6..380f104c 100644 --- a/go.sum +++ b/go.sum @@ -191,8 +191,8 @@ github.com/sdcio/logger v0.0.3 h1:IFUbObObGry+S8lHGwOQKKRxJSuOphgRU/hxVhOdMOM= github.com/sdcio/logger v0.0.3/go.mod h1:yWaOxK/G6vszjg8tKZiMqiEjlZouHsjFME4zSk+SAEA= github.com/sdcio/schema-server v0.0.34 h1:NNDOkvtUMONtBA7cVvN96F+FWGD/Do6HNqfchy9B8eI= github.com/sdcio/schema-server v0.0.34/go.mod h1:6t8HLXpqUqEJmE5yNZh29u/KZw0jlOICdNWns7zE4GE= -github.com/sdcio/sdc-protos v0.0.54 h1:1EbtU9ZbbJHFPOFGi5aW8Th79cuY9i+AJaP0ABVx8hw= -github.com/sdcio/sdc-protos v0.0.54/go.mod h1:YMLHbey0/aL1qtLW8csSYVPafsgnnn7aY54HkV5dbyQ= +github.com/sdcio/sdc-protos v0.0.55-0.20260601095759-67240812f373 h1:r/bNcNL7QSC1g4NVT3e3OptBAhvzeBLNk6WQjJ3jMS4= +github.com/sdcio/sdc-protos v0.0.55-0.20260601095759-67240812f373/go.mod h1:NsvzvHnTonLcwQ/WNzxzBCauQmqxpuviaW0wh7Lkrts= github.com/sdcio/yang-parser v0.0.12 h1:RSSeqfAOIsJx5Lno5u4/ezyOmQYUduQ22rBfU/mtpJ4= github.com/sdcio/yang-parser v0.0.12/go.mod h1:CBqn3Miq85qmFVGHxHXHLluXkaIOsTzV06IM4DW6+D4= github.com/sirikothe/gotextfsm v1.0.1-0.20200816110946-6aa2cfd355e4 h1:FHUL2HofYJuslFOQdy/JjjP36zxqIpd/dcoiwLMIs7k= diff --git a/mocks/mocktarget/target.go b/mocks/mocktarget/target.go index 1fccdd01..1d5dd393 100644 --- a/mocks/mocktarget/target.go +++ b/mocks/mocktarget/target.go @@ -92,18 +92,18 @@ func (mr *MockTargetMockRecorder) Get(ctx, req any) *gomock.Call { } // Set mocks base method. -func (m *MockTarget) Set(ctx context.Context, source types.TargetSource) (*sdcpb.SetDataResponse, error) { +func (m *MockTarget) Set(ctx context.Context, plan types.SouthboundSetPlan) (*sdcpb.SetDataResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Set", ctx, source) + ret := m.ctrl.Call(m, "Set", ctx, plan) ret0, _ := ret[0].(*sdcpb.SetDataResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // Set indicates an expected call of Set. -func (mr *MockTargetMockRecorder) Set(ctx, source any) *gomock.Call { +func (mr *MockTargetMockRecorder) Set(ctx, plan any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockTarget)(nil).Set), ctx, source) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockTarget)(nil).Set), ctx, plan) } // Status mocks base method. diff --git a/pkg/config/datastore.go b/pkg/config/datastore.go index 5c93bc4d..eef8e50a 100644 --- a/pkg/config/datastore.go +++ b/pkg/config/datastore.go @@ -31,6 +31,21 @@ const ( ncCommitDatastoreCandidate = "candidate" ) +// DeviceProfile selects NOS-specific southbound behaviour. Wire values are YAML/JSON +// string scalars. [DeviceProfileNone] is the default (omitted or empty in config). +type DeviceProfile string + +const ( + // DeviceProfileNone selects generic southbound driver behaviour (no NOS-specific + // materialization). It is the zero value and serializes as omitted/empty in YAML/JSON. + DeviceProfileNone DeviceProfile = "" + // DeviceProfileCiscoIOSXR enables IOS-XR-specific gNMI materialization for + // type=gnmi (JSON / JSON_IETF granular path encoding; other encodings use the + // generic gNMI plan builder). For type=netconf the value is accepted for + // configuration consistency but currently has no profile-specific behaviour. + DeviceProfileCiscoIOSXR DeviceProfile = "cisco-ios-xr" +) + type DatastoreConfig struct { Name string `yaml:"name,omitempty" json:"name,omitempty"` Schema *SchemaConfig `yaml:"schema,omitempty" json:"schema,omitempty"` @@ -86,6 +101,10 @@ type SBI struct { ConnectRetry time.Duration `yaml:"connect-retry,omitempty" json:"connect-retry,omitempty"` // Timeout Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"` + // DeviceProfile selects NOS-specific southbound behaviour. Use the + // [DeviceProfile] constants ([DeviceProfileNone] or [DeviceProfileCiscoIOSXR]); + // unknown values are rejected when the datastore configuration is validated. + DeviceProfile DeviceProfile `yaml:"device-profile,omitempty" json:"device-profile,omitempty"` } type SBIGnmiOptions struct { @@ -164,7 +183,20 @@ func (ds *DatastoreConfig) ValidateSetDefaults() error { return nil } +// IsCiscoIOSXR reports whether this SBI uses the Cisco IOS-XR device profile. +// Callers outside pkg/config should use this predicate rather than comparing +// DeviceProfile directly, so the profile string stays contained here. +func (s *SBI) IsCiscoIOSXR() bool { + return s.DeviceProfile == DeviceProfileCiscoIOSXR +} + func (s *SBI) validateSetDefaults() error { + switch s.DeviceProfile { + case DeviceProfileNone, DeviceProfileCiscoIOSXR: + default: + return fmt.Errorf("unknown device-profile: %q", s.DeviceProfile) + } + switch s.Type { case sbiNOOP: return nil diff --git a/pkg/config/sbi_device_profile_test.go b/pkg/config/sbi_device_profile_test.go new file mode 100644 index 00000000..017f161a --- /dev/null +++ b/pkg/config/sbi_device_profile_test.go @@ -0,0 +1,79 @@ +// Copyright 2024 Nokia +// +// 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 config + +import "testing" + +// validGNMISBI returns an SBI configured for gNMI with the given encoding and +// device-profile, with address/port filled in so validateSetDefaults passes all +// other checks. +func validGNMISBI(encoding string, deviceProfile DeviceProfile) *SBI { + return &SBI{ + Type: sbiGNMI, + Address: "192.0.2.1", + Port: 57400, + GnmiOptions: &SBIGnmiOptions{Encoding: encoding}, + DeviceProfile: deviceProfile, + } +} + +func TestSBI_IsCiscoIOSXR(t *testing.T) { + if (&SBI{DeviceProfile: DeviceProfileCiscoIOSXR}).IsCiscoIOSXR() != true { + t.Fatal("expected true for cisco-ios-xr profile") + } + if (&SBI{DeviceProfile: DeviceProfileNone}).IsCiscoIOSXR() != false { + t.Fatal("expected false for DeviceProfileNone") + } + if (&SBI{}).IsCiscoIOSXR() != false { + t.Fatal("expected false for zero-value DeviceProfile (same as DeviceProfileNone)") + } + if (&SBI{DeviceProfile: DeviceProfile("other")}).IsCiscoIOSXR() != false { + t.Fatal("expected false for unrelated profile") + } +} + +func TestSBI_validateSetDefaults_DeviceProfile_UnknownProfileIsRejected(t *testing.T) { + sbi := validGNMISBI("json_ietf", DeviceProfile("not-a-valid-profile")) + if err := sbi.validateSetDefaults(); err == nil { + t.Fatal("expected error for unknown device-profile, got nil") + } +} + +func TestSBI_validateSetDefaults_DeviceProfile_CiscoIOSXRNetconfIsAccepted(t *testing.T) { + sbi := &SBI{ + Type: sbiNETCONF, + Address: "192.0.2.1", + Port: 830, + NetconfOptions: &SBINetconfOptions{}, + DeviceProfile: DeviceProfileCiscoIOSXR, + } + if err := sbi.validateSetDefaults(); err != nil { + t.Fatalf("unexpected error for cisco-ios-xr + netconf: %v", err) + } +} + +func TestSBI_validateSetDefaults_DeviceProfile_CiscoIOSXRGNMIJSONIsAccepted(t *testing.T) { + sbi := validGNMISBI("json_ietf", DeviceProfileCiscoIOSXR) + if err := sbi.validateSetDefaults(); err != nil { + t.Fatalf("unexpected error for cisco-ios-xr + gnmi + json_ietf: %v", err) + } +} + +func TestSBI_validateSetDefaults_DeviceProfile_CiscoIOSXRGNMIProtoIsAccepted(t *testing.T) { + sbi := validGNMISBI("proto", DeviceProfileCiscoIOSXR) + if err := sbi.validateSetDefaults(); err != nil { + t.Fatalf("unexpected error for cisco-ios-xr + gnmi + proto: %v", err) + } +} diff --git a/pkg/datastore/intent_rpc.go b/pkg/datastore/intent_rpc.go index 974384d5..7f6009a7 100644 --- a/pkg/datastore/intent_rpc.go +++ b/pkg/datastore/intent_rpc.go @@ -21,8 +21,9 @@ import ( "github.com/beevik/etree" - targettypes "github.com/sdcio/data-server/pkg/datastore/target/types" + "github.com/sdcio/data-server/pkg/datastore/target/materialize" "github.com/sdcio/data-server/pkg/tree" + treeapi "github.com/sdcio/data-server/pkg/tree/api" "github.com/sdcio/data-server/pkg/tree/api/adapter" "github.com/sdcio/data-server/pkg/tree/consts" "github.com/sdcio/data-server/pkg/tree/importer/proto" @@ -34,22 +35,21 @@ import ( var ErrIntentNotFound = errors.New("intent not found") -func (d *Datastore) applyIntent(ctx context.Context, source targettypes.TargetSource) (*sdcpb.SetDataResponse, error) { +func (d *Datastore) applyIntent(ctx context.Context, entry treeapi.Entry, replace bool) (*sdcpb.SetDataResponse, error) { log := logf.FromContext(ctx) var err error var rsp *sdcpb.SetDataResponse - // send set request only if there are updates and/or deletes - if containsChanges, _ := source.ContainsChanges(ctx); !containsChanges { - return &sdcpb.SetDataResponse{}, nil - } - if d.sbi == nil { return nil, fmt.Errorf("%s is not connected", d.config.Name) } - rsp, err = d.sbi.Set(ctx, source) + plan, err := materialize.BuildPlan(ctx, d.schemaClient, d.config.SBI, entry, replace) + if err != nil { + return nil, err + } + rsp, err = d.sbi.Set(ctx, plan) if err != nil { return nil, err } diff --git a/pkg/datastore/sync.go b/pkg/datastore/sync.go index 42101ec5..51461dff 100644 --- a/pkg/datastore/sync.go +++ b/pkg/datastore/sync.go @@ -6,7 +6,6 @@ import ( "sync" "github.com/sdcio/data-server/pkg/tree" - "github.com/sdcio/data-server/pkg/tree/api/adapter" "github.com/sdcio/data-server/pkg/tree/consts" "github.com/sdcio/data-server/pkg/tree/importer" "github.com/sdcio/data-server/pkg/tree/ops" @@ -165,7 +164,7 @@ func (d *Datastore) performRevert(ctx context.Context, t *tree.RootEntry) error if performApply { log.Info("reverting after sync") - resp, err := d.applyIntent(ctx, adapter.NewEntryOutputAdapter(t.Entry)) + resp, err := d.applyIntent(ctx, t.Entry, false) if err != nil { respJ := protojson.MarshalOptions{Multiline: false} respStr, _ := respJ.Marshal(resp) diff --git a/pkg/datastore/target/gnmi/get_test.go b/pkg/datastore/target/gnmi/get_test.go new file mode 100644 index 00000000..286cf243 --- /dev/null +++ b/pkg/datastore/target/gnmi/get_test.go @@ -0,0 +1,79 @@ +// Copyright 2024 Nokia +// +// 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 gnmi_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/sdcio/data-server/pkg/config" + "github.com/sdcio/data-server/pkg/datastore/target/gnmi" + targettypes "github.com/sdcio/data-server/pkg/datastore/target/types" + sdcpb "github.com/sdcio/sdc-protos/sdcpb" +) + +// capturingGetTarget implements gnmi.GetTarget. +// The first Get call is captured to the channel, then an error is returned so +// that internalGetSync exits early without touching RunningStore. +type capturingGetTarget struct { + captured chan *sdcpb.GetDataRequest +} + +func (c *capturingGetTarget) Get(_ context.Context, req *sdcpb.GetDataRequest) (*sdcpb.GetDataResponse, error) { + select { + case c.captured <- req: + default: + } + return nil, fmt.Errorf("capture-and-stop") +} + +// TestGetSync_syncConfig_UsesDataTypeConfig asserts that GetSync requests +// DataType_CONFIG (not DataType_ALL) when it issues the periodic Get to the device. +func TestGetSync_syncConfig_UsesDataTypeConfig(t *testing.T) { + captured := make(chan *sdcpb.GetDataRequest, 1) + target := &capturingGetTarget{captured: captured} + + syncConf := &config.SyncProtocol{ + Name: "test-get-sync", + Interval: time.Hour, // long enough that only the initial sync fires + Encoding: "JSON_IETF", + } + + // RunningStore is never accessed because Get() returns an error, causing + // internalGetSync to return early. Passing nil satisfies the interface. + var rs targettypes.RunningStore + + gs, err := gnmi.NewGetSync(context.Background(), target, syncConf, rs, nil) + if err != nil { + t.Fatalf("NewGetSync: %v", err) + } + + if err := gs.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer gs.Stop() + + select { + case req := <-captured: + if req.DataType != sdcpb.DataType_CONFIG { + t.Errorf("syncConfig: want DataType_CONFIG (%v), got %v", + sdcpb.DataType_CONFIG, req.DataType) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for GetSync to issue its first Get request") + } +} diff --git a/pkg/datastore/target/gnmi/gnmi.go b/pkg/datastore/target/gnmi/gnmi.go index 117f8341..0c50af9a 100644 --- a/pkg/datastore/target/gnmi/gnmi.go +++ b/pkg/datastore/target/gnmi/gnmi.go @@ -18,7 +18,6 @@ import ( "context" "encoding/json" "fmt" - "strings" "time" "github.com/AlekSi/pointer" @@ -153,61 +152,24 @@ func (t *gnmiTarget) Get(ctx context.Context, req *sdcpb.GetDataRequest) (*sdcpb return schemaRsp, nil } -func (t *gnmiTarget) Set(ctx context.Context, source targetTypes.TargetSource) (*sdcpb.SetDataResponse, error) { +// Set dispatches a pre-built SouthboundSetPlan to the gNMI target. +// The plan must carry a GnmiSetPlan; encoding is done upstream by the +// materialize layer. +func (t *gnmiTarget) Set(ctx context.Context, plan targetTypes.SouthboundSetPlan) (*sdcpb.SetDataResponse, error) { log := logf.FromContext(ctx).WithName("Set") ctx = logf.IntoContext(ctx, log) - var upds []*sdcpb.Update - var deletes []*sdcpb.Path - var err error - if t == nil { return nil, fmt.Errorf("%s", "not connected") } - // deletes from protos - deletes, err = source.ToProtoDeletes(ctx) - if err != nil { - return nil, err + gp, ok := plan.GnmiPlan() + if !ok { + return nil, fmt.Errorf("gnmi target received a non-gNMI SouthboundSetPlan") } - switch strings.ToLower(t.cfg.GnmiOptions.Encoding) { - case "json": - jsonData, err := source.ToJson(ctx, true) - if err != nil { - return nil, err - } - if jsonData != nil { - jsonBytes, err := json.Marshal(jsonData) - if err != nil { - return nil, err - } - if len(jsonBytes) > 0 { - upds = []*sdcpb.Update{{Path: &sdcpb.Path{}, Value: &sdcpb.TypedValue{Value: &sdcpb.TypedValue_JsonVal{JsonVal: jsonBytes}}}} - } - } - - case "json_ietf": - jsonData, err := source.ToJsonIETF(ctx, true) - if err != nil { - return nil, err - } - if jsonData != nil { - jsonBytes, err := json.Marshal(jsonData) - if err != nil { - return nil, err - } - if len(jsonBytes) > 0 { - upds = []*sdcpb.Update{{Path: &sdcpb.Path{}, Value: &sdcpb.TypedValue{Value: &sdcpb.TypedValue_JsonIetfVal{JsonIetfVal: jsonBytes}}}} - } - } - - case "proto": - upds, err = source.ToProtoUpdates(ctx, true) - if err != nil { - return nil, err - } - } + upds := gp.Updates + deletes := gp.Deletes if len(deletes) == 0 && len(upds) == 0 { return &sdcpb.SetDataResponse{}, nil diff --git a/pkg/datastore/target/gnmi/permodule/encode.go b/pkg/datastore/target/gnmi/permodule/encode.go new file mode 100644 index 00000000..49e7df08 --- /dev/null +++ b/pkg/datastore/target/gnmi/permodule/encode.go @@ -0,0 +1,270 @@ +// Copyright 2024 Nokia +// +// 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 permodule encodes gNMI Set plans with one Update per YANG module at +// the datastore root: Path.Origin is the module name and Path.Elem[0] is the +// module's top-level container. The encoder walks direct children of the root +// tree entry, groups them by YANG module, and serialises each group into one +// sdcpb.Update. +// +// Some targets require this layout (for example Cisco IOS-XR with JSON +// encodings); the package name reflects the encoding shape, not a specific +// device profile constant. +package permodule + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/openconfig/gnmi/proto/gnmi" + schemaClient "github.com/sdcio/data-server/pkg/datastore/clients/schema" + targettypes "github.com/sdcio/data-server/pkg/datastore/target/types" + "github.com/sdcio/data-server/pkg/tree/api" + treetypes "github.com/sdcio/data-server/pkg/tree/types" + "github.com/sdcio/data-server/pkg/tree/ops" + "github.com/sdcio/data-server/pkg/utils" + sdcpb "github.com/sdcio/sdc-protos/sdcpb" +) + +// Encode builds a GnmiSetPlan from the tree entry using per-YANG-module grouping. +// +// - replace=false (merge): only new-or-updated leaves per module are included; +// modules with no changed leaves produce no Update. Merge deletes are +// included with Path.Origin resolved to the owning YANG module. +// - replace=true: for every module present in the tree, a module-root delete +// (with Path.Origin) is emitted followed by the module's full update, all +// in a single GnmiSetPlan so the device applies them atomically. +// +// scb is used only for resolving the YANG module name of path-only delete +// entries (arising from choice-case changes); it may be nil if the tree is +// known to have no such entries. +func Encode( + ctx context.Context, + scb schemaClient.SchemaClientBound, + entry api.Entry, + encoding gnmi.Encoding, + replace bool, +) (*targettypes.GnmiSetPlan, error) { + if replace { + return encodeReplace(ctx, entry, encoding) + } + return encodeMerge(ctx, scb, entry, encoding) +} + +// moduleGroup collects all direct children of the root that belong to the +// same YANG module. +type moduleGroup struct { + moduleName string + children []api.Entry +} + +// groupByModule walks the direct children of entry and returns one +// moduleGroup per distinct YANG module name, preserving insertion order. +func groupByModule(entry api.Entry) []moduleGroup { + var order []string + groups := map[string]*moduleGroup{} + + for _, child := range entry.GetChilds(treetypes.DescendMethodActiveChilds) { + schema := child.GetSchema() + if schema == nil { + continue + } + mod := utils.GetSchemaElemModuleName(schema) + if mod == "" { + continue + } + if _, exists := groups[mod]; !exists { + groups[mod] = &moduleGroup{moduleName: mod} + order = append(order, mod) + } + groups[mod].children = append(groups[mod].children, child) + } + + result := make([]moduleGroup, 0, len(order)) + for _, mod := range order { + result = append(result, *groups[mod]) + } + return result +} + +// serializeChild serialises a single child entry to a JSON or JSON_IETF blob. +// Returns nil, nil when the result is empty (e.g. no new-or-updated leaves in +// merge mode). +func serializeChild(ctx context.Context, child api.Entry, encoding gnmi.Encoding, onlyNewOrUpdated bool) ([]byte, error) { + var data any + var err error + + switch encoding { + case gnmi.Encoding_JSON_IETF: + data, err = ops.ToJsonIETF(ctx, child, onlyNewOrUpdated) + default: + data, err = ops.ToJson(ctx, child, onlyNewOrUpdated) + } + if err != nil { + return nil, fmt.Errorf("permodule: serialise %s: %w", child.PathName(), err) + } + if data == nil { + return nil, nil + } + b, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("permodule: marshal %s: %w", child.PathName(), err) + } + return b, nil +} + +// makeTypedValue wraps a JSON byte slice in the appropriate TypedValue variant. +func makeTypedValue(b []byte, encoding gnmi.Encoding) *sdcpb.TypedValue { + if encoding == gnmi.Encoding_JSON_IETF { + return &sdcpb.TypedValue{Value: &sdcpb.TypedValue_JsonIetfVal{JsonIetfVal: b}} + } + return &sdcpb.TypedValue{Value: &sdcpb.TypedValue_JsonVal{JsonVal: b}} +} + +// moduleRootPath constructs the gNMI path for a module root container. +func moduleRootPath(moduleName, containerName string) *sdcpb.Path { + return &sdcpb.Path{ + Origin: moduleName, + Elem: []*sdcpb.PathElem{{Name: containerName}}, + } +} + +// encodeMerge builds a GnmiSetPlan for a merge (non-replace) transaction. +func encodeMerge( + ctx context.Context, + scb schemaClient.SchemaClientBound, + entry api.Entry, + encoding gnmi.Encoding, +) (*targettypes.GnmiSetPlan, error) { + groups := groupByModule(entry) + + plan := &targettypes.GnmiSetPlan{} + + for _, g := range groups { + for _, child := range g.children { + b, err := serializeChild(ctx, child, encoding, true) + if err != nil { + return nil, err + } + if b == nil { + // No new-or-updated leaves in this module — omit. + continue + } + plan.Updates = append(plan.Updates, &sdcpb.Update{ + Path: moduleRootPath(g.moduleName, child.PathName()), + Value: makeTypedValue(b, encoding), + }) + } + } + + // Collect merge deletes with Origin resolved. + deletes, err := mergeDeletes(ctx, scb, entry) + if err != nil { + return nil, err + } + plan.Deletes = deletes + + return plan, nil +} + +// encodeReplace builds a GnmiSetPlan for a replace transaction. +// For each module present in the tree it emits a module-root delete +// (with Path.Origin) followed by the full module update. +func encodeReplace( + ctx context.Context, + entry api.Entry, + encoding gnmi.Encoding, +) (*targettypes.GnmiSetPlan, error) { + groups := groupByModule(entry) + + plan := &targettypes.GnmiSetPlan{} + + for _, g := range groups { + for _, child := range g.children { + b, err := serializeChild(ctx, child, encoding, false) + if err != nil { + return nil, err + } + rootPath := moduleRootPath(g.moduleName, child.PathName()) + + // Per-module root delete before the full update. + plan.Deletes = append(plan.Deletes, rootPath) + if b != nil { + plan.Updates = append(plan.Updates, &sdcpb.Update{ + Path: rootPath, + Value: makeTypedValue(b, encoding), + }) + } + } + } + + return plan, nil +} + +// mergeDeletes collects all explicit delete paths from the tree and resolves +// the YANG module origin for each one. +func mergeDeletes( + ctx context.Context, + scb schemaClient.SchemaClientBound, + entry api.Entry, +) ([]*sdcpb.Path, error) { + rawDeletes, err := ops.GetDeletes(entry, true) + if err != nil { + return nil, fmt.Errorf("permodule: collect deletes: %w", err) + } + if len(rawDeletes) == 0 { + return nil, nil + } + + result := make([]*sdcpb.Path, 0, len(rawDeletes)) + for _, del := range rawDeletes { + origin, err := resolveDeleteOrigin(ctx, scb, del) + if err != nil { + return nil, err + } + p := del.SdcpbPath() + withOrigin := &sdcpb.Path{ + Origin: origin, + Elem: p.GetElem(), + } + result = append(result, withOrigin) + } + return result, nil +} + +// resolveDeleteOrigin determines the YANG module name for a delete entry. +// +// - For schema-attached entries (api.Entry with GetSchema() != nil) the +// module is read directly via GetSchemaElemModuleName. +// - For path-only entries (DeleteEntryImpl, no schema attached) the module +// is resolved by fetching the schema for the first path element via scb. +func resolveDeleteOrigin(ctx context.Context, scb schemaClient.SchemaClientBound, del treetypes.DeleteEntry) (string, error) { + // Schema-attached: the entry already carries the schema element. + if e, ok := del.(api.Entry); ok && e.GetSchema() != nil { + return utils.GetSchemaElemModuleName(e.GetSchema()), nil + } + + // Path-only: look up via schema client. + path := del.SdcpbPath() + if len(path.GetElem()) == 0 || scb == nil { + return "", nil + } + firstElem := &sdcpb.Path{Elem: []*sdcpb.PathElem{{Name: path.GetElem()[0].GetName()}}} + rsp, err := scb.GetSchemaSdcpbPath(ctx, firstElem) + if err != nil { + return "", fmt.Errorf("permodule: schema lookup for delete path %v: %w", path, err) + } + return utils.GetSchemaElemModuleName(rsp.GetSchema()), nil +} diff --git a/pkg/datastore/target/gnmi/permodule/encode_test.go b/pkg/datastore/target/gnmi/permodule/encode_test.go new file mode 100644 index 00000000..1874b233 --- /dev/null +++ b/pkg/datastore/target/gnmi/permodule/encode_test.go @@ -0,0 +1,317 @@ +// Copyright 2024 Nokia +// +// 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 permodule_test + +import ( + "context" + "encoding/json" + "runtime" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/openconfig/gnmi/proto/gnmi" + schemaClient "github.com/sdcio/data-server/pkg/datastore/clients/schema" + "github.com/sdcio/data-server/pkg/datastore/target/gnmi/permodule" + "github.com/sdcio/data-server/pkg/pool" + "github.com/sdcio/data-server/pkg/tree" + "github.com/sdcio/data-server/pkg/tree/consts" + "github.com/sdcio/data-server/pkg/tree/types" + "github.com/sdcio/data-server/pkg/utils/testhelper" + sdcpb "github.com/sdcio/sdc-protos/sdcpb" + "go.uber.org/mock/gomock" +) + +// newTestRoot builds a tree root wired to the test schema and returns the root +// and the schema client bound to it (for passing to Encode). +func newTestRoot(t *testing.T, mockCtrl *gomock.Controller) (*tree.RootEntry, schemaClient.SchemaClientBound) { + t.Helper() + scb, err := testhelper.GetSchemaClientBound(t, mockCtrl) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + tc := tree.NewTreeContext(scb, pool.NewSharedTaskPool(ctx, runtime.GOMAXPROCS(0))) + root, err := tree.NewTreeRoot(ctx, tc) + if err != nil { + t.Fatal(err) + } + return root, scb +} + +// interfaceUpdates returns a single interface entry update. +// name must match the sdcio_model_if interface-name pattern (e.g. "ethernet-1/1"). +func interfaceUpdates(name, desc string) []*sdcpb.Update { + return []*sdcpb.Update{{ + Path: &sdcpb.Path{Elem: []*sdcpb.PathElem{ + {Name: "interface", Key: map[string]string{"name": name}}, + {Name: "description"}, + }}, + Value: &sdcpb.TypedValue{Value: &sdcpb.TypedValue_StringVal{StringVal: desc}}, + }} +} + +// networkInstanceUpdates returns a single network-instance description update (module sdcio_model_ni). +func networkInstanceUpdates(name, desc string) []*sdcpb.Update { + return []*sdcpb.Update{{ + Path: &sdcpb.Path{Elem: []*sdcpb.PathElem{ + {Name: "network-instance", Key: map[string]string{"name": name}}, + {Name: "description"}, + }}, + Value: &sdcpb.TypedValue{Value: &sdcpb.TypedValue_StringVal{StringVal: desc}}, + }} +} + +// addToRoot adds updates with the given flags. +func addToRoot(t *testing.T, root *tree.RootEntry, updates []*sdcpb.Update, flags *types.UpdateInsertFlags) { + t.Helper() + ctx := context.Background() + if err := testhelper.AddToRoot(ctx, root.Entry, updates, flags, "owner1", 5); err != nil { + t.Fatal(err) + } +} + +func finish(t *testing.T, root *tree.RootEntry) { + t.Helper() + if err := root.FinishInsertionPhase(context.Background()); err != nil { + t.Fatal(err) + } +} + +// originSet collects Path.Origin values from a list of Updates. +func originSet(updates []*sdcpb.Update) map[string]bool { + m := make(map[string]bool, len(updates)) + for _, u := range updates { + m[u.GetPath().GetOrigin()] = true + } + return m +} + +// deleteOriginSet collects Origin values from a list of Paths. +func deleteOriginSet(paths []*sdcpb.Path) map[string]bool { + m := make(map[string]bool, len(paths)) + for _, p := range paths { + m[p.GetOrigin()] = true + } + return m +} + +// --- Cycle 1: multi-module merge emits one Update per module ---------- + +func TestEncode_MultiModuleMerge_TwoUpdates(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + + upds := append(interfaceUpdates("ethernet-1/1", "uplink"), networkInstanceUpdates("default", "Default NI")...) + addToRoot(t, root, upds, testhelper.FlagsNew) + finish(t, root) + + plan, err := permodule.Encode(context.Background(), scb, root.Entry, gnmi.Encoding_JSON_IETF, false) + if err != nil { + t.Fatal(err) + } + + if len(plan.Updates) != 2 { + t.Fatalf("want 2 Updates (one per module), got %d", len(plan.Updates)) + } + origins := originSet(plan.Updates) + if !origins["sdcio_model_if"] { + t.Errorf("missing Update for module sdcio_model_if; origins: %v", origins) + } + if !origins["sdcio_model_ni"] { + t.Errorf("missing Update for module sdcio_model_ni; origins: %v", origins) + } + for _, u := range plan.Updates { + if len(u.GetPath().GetElem()) != 1 { + t.Errorf("Update for %q: want exactly 1 path elem, got %d", + u.GetPath().GetOrigin(), len(u.GetPath().GetElem())) + } + } + if len(plan.Deletes) != 0 { + t.Errorf("merge with no deletes: want 0 Deletes, got %d", len(plan.Deletes)) + } +} + +// --- Cycle 2: single-module tree emits exactly 1 Update --------------- + +func TestEncode_SingleModuleMerge_OneUpdate(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + + addToRoot(t, root, interfaceUpdates("ethernet-1/1", "uplink"), testhelper.FlagsNew) + finish(t, root) + + plan, err := permodule.Encode(context.Background(), scb, root.Entry, gnmi.Encoding_JSON_IETF, false) + if err != nil { + t.Fatal(err) + } + + if len(plan.Updates) != 1 { + t.Fatalf("want 1 Update, got %d", len(plan.Updates)) + } + u := plan.Updates[0] + if u.GetPath().GetOrigin() != "sdcio_model_if" { + t.Errorf("want origin sdcio_model_if, got %q", u.GetPath().GetOrigin()) + } + if len(u.GetPath().GetElem()) != 1 || u.GetPath().GetElem()[0].GetName() != "interface" { + t.Errorf("want Path.Elem[0].Name=interface, got %v", u.GetPath().GetElem()) + } +} + +// --- Cycle 3: unchanged module is omitted in merge -------------------- + +func TestEncode_MergeOmitsModuleWithNoNewLeaves(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + + ctx := context.Background() + // Running config carries the NI — the device already has it. + // We use RunningValuesPrio / RunningIntentName so the tree considers it + // "equal to running" and omits it when onlyNewOrUpdated=true. + niUpd := networkInstanceUpdates("default", "Default NI") + if err := testhelper.AddToRoot(ctx, root.Entry, niUpd, testhelper.FlagsExisting, + consts.RunningIntentName, consts.RunningValuesPrio); err != nil { + t.Fatal(err) + } + // Intent also carries the same NI (same value) so it is NOT new. + if err := testhelper.AddToRoot(ctx, root.Entry, niUpd, testhelper.FlagsExisting, "owner1", 5); err != nil { + t.Fatal(err) + } + // Only the interface entry is new. + addToRoot(t, root, interfaceUpdates("ethernet-1/1", "uplink"), testhelper.FlagsNew) + finish(t, root) + + plan, err := permodule.Encode(ctx, scb, root.Entry, gnmi.Encoding_JSON_IETF, false) + if err != nil { + t.Fatal(err) + } + + // Only the interface module has new/updated leaves. + if len(plan.Updates) != 1 { + t.Fatalf("want 1 Update (interface only), got %d", len(plan.Updates)) + } + if plan.Updates[0].GetPath().GetOrigin() != "sdcio_model_if" { + t.Errorf("want sdcio_model_if, got %q", plan.Updates[0].GetPath().GetOrigin()) + } +} + +// --- Cycle 4: replace emits per-module delete then update ------------- + +func TestEncode_MultiModuleReplace_DeletesAndUpdates(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + + upds := append(interfaceUpdates("ethernet-1/1", "uplink"), networkInstanceUpdates("default", "Default NI")...) + addToRoot(t, root, upds, testhelper.FlagsNew) + finish(t, root) + + plan, err := permodule.Encode(context.Background(), scb, root.Entry, gnmi.Encoding_JSON_IETF, true) + if err != nil { + t.Fatal(err) + } + + if len(plan.Updates) != 2 { + t.Fatalf("want 2 Updates, got %d", len(plan.Updates)) + } + if len(plan.Deletes) != 2 { + t.Fatalf("want 2 Deletes (one per module), got %d", len(plan.Deletes)) + } + + dOrigins := deleteOriginSet(plan.Deletes) + if !dOrigins["sdcio_model_if"] { + t.Errorf("missing Delete for sdcio_model_if; delete origins: %v", dOrigins) + } + if !dOrigins["sdcio_model_ni"] { + t.Errorf("missing Delete for sdcio_model_ni; delete origins: %v", dOrigins) + } + for _, d := range plan.Deletes { + if len(d.GetElem()) != 1 { + t.Errorf("Delete for %q: want 1 path elem, got %d", d.GetOrigin(), len(d.GetElem())) + } + } + + uOrigins := originSet(plan.Updates) + if diff := cmp.Diff(dOrigins, uOrigins); diff != "" { + t.Errorf("Update and Delete origin sets differ (-delete +update):\n%s", diff) + } +} + +// --- Cycle 5: merge deletes carry Path.Origin ------------------------- + +func TestEncode_MergeDeletes_OriginIsSet(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + + ctx := context.Background() + // The running config has the NI — the device already has it. + niUpd := networkInstanceUpdates("default", "Default NI") + if err := testhelper.AddToRoot(ctx, root.Entry, niUpd, testhelper.FlagsExisting, + consts.RunningIntentName, consts.RunningValuesPrio); err != nil { + t.Fatal(err) + } + // The intent owner marks the same NI description for deletion (with the original + // value preserved so the serializer does not encounter a nil TypedValue). + if err := testhelper.AddToRoot(ctx, root.Entry, niUpd, testhelper.FlagsDelete, "owner1", 5); err != nil { + t.Fatal(err) + } + // Also add a new interface entry so we have at least one Update. + addToRoot(t, root, interfaceUpdates("ethernet-1/1", "uplink"), testhelper.FlagsNew) + finish(t, root) + + plan, err := permodule.Encode(ctx, scb, root.Entry, gnmi.Encoding_JSON_IETF, false) + if err != nil { + t.Fatal(err) + } + + if len(plan.Updates) < 1 { + t.Fatalf("want ≥1 Update, got %d", len(plan.Updates)) + } + if len(plan.Deletes) < 1 { + t.Fatalf("want ≥1 Delete (the NI deletion), got 0") + } + for _, d := range plan.Deletes { + if d.GetOrigin() == "" { + t.Errorf("Delete path %v has empty Origin", d) + } + } +} + +// --- Bonus: JSON content validation ----------------------------------- + +func TestEncode_JSONContent_MatchesLeafValues(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + + addToRoot(t, root, networkInstanceUpdates("default", "Default NI"), testhelper.FlagsNew) + finish(t, root) + + plan, err := permodule.Encode(context.Background(), scb, root.Entry, gnmi.Encoding_JSON, false) + if err != nil { + t.Fatal(err) + } + if len(plan.Updates) != 1 { + t.Fatalf("want 1 Update, got %d", len(plan.Updates)) + } + + jsonBytes := plan.Updates[0].GetValue().GetJsonVal() + if jsonBytes == nil { + t.Fatal("expected JsonVal, got nil") + } + + var got any + if err := json.Unmarshal(jsonBytes, &got); err != nil { + t.Fatalf("unmarshal JSON value: %v", err) + } + t.Logf("encoded JSON:\n%s", func() string { b, _ := json.MarshalIndent(got, "", " "); return string(b) }()) +} diff --git a/pkg/datastore/target/materialize/materialize.go b/pkg/datastore/target/materialize/materialize.go new file mode 100644 index 00000000..abb74e4e --- /dev/null +++ b/pkg/datastore/target/materialize/materialize.go @@ -0,0 +1,186 @@ +// Copyright 2024 Nokia +// +// 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 materialize converts a validated tree entry into a SouthboundSetPlan +// suitable for a specific SBI driver, separating encoding decisions from transport. +package materialize + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/openconfig/gnmi/proto/gnmi" + "github.com/sdcio/data-server/pkg/config" + schemaClient "github.com/sdcio/data-server/pkg/datastore/clients/schema" + gnmiutils "github.com/sdcio/data-server/pkg/datastore/target/gnmi/utils" + "github.com/sdcio/data-server/pkg/datastore/target/gnmi/permodule" + targettypes "github.com/sdcio/data-server/pkg/datastore/target/types" + "github.com/sdcio/data-server/pkg/tree/api" + "github.com/sdcio/data-server/pkg/tree/ops" + "github.com/sdcio/data-server/pkg/utils" + sdcpb "github.com/sdcio/sdc-protos/sdcpb" +) + +// BuildPlan encodes the tree entry into a SouthboundSetPlan for the given SBI. +// If replace is true the plan carries replace semantics (root-level delete for +// gNMI, nc:operation="replace" on the root element for NETCONF). +// +// scb is the schema client bound to this datastore; it is only consumed when +// SBI type is gNMI, the plan uses per-module JSON encoding (see below), and +// merge-delete paths need YANG module origins resolved. It may be nil for +// PROTO, NETCONF, and generic single-root gNMI paths. +// +// Supported SBI types: +// - "gnmi" with empty DeviceProfile → GnmiSetPlan (single root update) +// - "gnmi" with DeviceProfile "cisco-ios-xr" + json/json_ietf → GnmiSetPlan (per YANG module via permodule) +// - "gnmi" with DeviceProfile "cisco-ios-xr" + proto → GnmiSetPlan (generic, single root update) +// - "netconf" with empty DeviceProfile → NetconfSetPlan +// - "netconf" with DeviceProfile "cisco-ios-xr" → NetconfSetPlan (profile accepted; no IOS-XR-specific shaping yet) +func BuildPlan(ctx context.Context, scb schemaClient.SchemaClientBound, sbi *config.SBI, entry api.Entry, replace bool) (targettypes.SouthboundSetPlan, error) { + if sbi == nil { + // No SBI configuration present (e.g. noop target in tests); return an + // empty plan so the driver can decide what to do with it. + return targettypes.SouthboundSetPlan{}, nil + } + if sbi.Type == "gnmi" && sbi.IsCiscoIOSXR() { + encoding := gnmi.Encoding(gnmiutils.ParseGnmiEncoding(sbi.GnmiOptions.Encoding)) + switch encoding { + case gnmi.Encoding_JSON, gnmi.Encoding_JSON_IETF: + plan, err := permodule.Encode(ctx, scb, entry, encoding, replace) + if err != nil { + return targettypes.SouthboundSetPlan{}, err + } + return targettypes.SouthboundSetPlan{Gnmi: plan}, nil + } + // proto (and any other encoding) falls through to the generic gNMI path. + } + + switch sbi.Type { + case "gnmi": + return buildGnmiPlan(ctx, sbi, entry, replace) + case "netconf": + return buildNetconfPlan(ctx, sbi, entry, replace) + default: + return targettypes.SouthboundSetPlan{}, fmt.Errorf("materialize: unknown SBI type: %q", sbi.Type) + } +} + +// buildGnmiPlan builds a GnmiSetPlan from the tree entry. +// Encoding is selected based on sbi.GnmiOptions.Encoding: +// - JSON → whole tree serialised as a single root-level JSON update +// - JSON_IETF → whole tree serialised as a single root-level JSON_IETF update +// - PROTO (default) → individual leaf-level updates via ToProtoUpdates +func buildGnmiPlan(ctx context.Context, sbi *config.SBI, entry api.Entry, replace bool) (targettypes.SouthboundSetPlan, error) { + encoding := gnmi.Encoding(gnmiutils.ParseGnmiEncoding(sbi.GnmiOptions.Encoding)) + + updates, err := buildGnmiUpdates(ctx, entry, encoding) + if err != nil { + return targettypes.SouthboundSetPlan{}, err + } + + deletes, err := buildGnmiDeletes(ctx, entry, replace) + if err != nil { + return targettypes.SouthboundSetPlan{}, err + } + + return targettypes.SouthboundSetPlan{ + Gnmi: &targettypes.GnmiSetPlan{ + Updates: updates, + Deletes: deletes, + }, + }, nil +} + +// buildGnmiUpdates returns the update list for a gNMI Set request. +// For JSON/JSON_IETF encoding the whole tree is serialised as a single +// root-level update. For PROTO encoding individual leaf updates are returned. +func buildGnmiUpdates(ctx context.Context, entry api.Entry, encoding gnmi.Encoding) ([]*sdcpb.Update, error) { + switch encoding { + case gnmi.Encoding_JSON: + data, err := ops.ToJson(ctx, entry, true) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + b, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("materialize: marshal JSON: %w", err) + } + return []*sdcpb.Update{{ + Path: &sdcpb.Path{Elem: []*sdcpb.PathElem{}}, + Value: &sdcpb.TypedValue{Value: &sdcpb.TypedValue_JsonVal{JsonVal: b}}, + }}, nil + + case gnmi.Encoding_JSON_IETF: + data, err := ops.ToJsonIETF(ctx, entry, true) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + b, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("materialize: marshal JSON_IETF: %w", err) + } + return []*sdcpb.Update{{ + Path: &sdcpb.Path{Elem: []*sdcpb.PathElem{}}, + Value: &sdcpb.TypedValue{Value: &sdcpb.TypedValue_JsonIetfVal{JsonIetfVal: b}}, + }}, nil + + default: + // PROTO: per-leaf updates + return ops.ToProtoUpdates(ctx, entry, true) + } +} + +// buildGnmiDeletes returns the delete list for a gNMI Set request. +// For replace semantics a single root-path delete is returned so the driver +// replaces the full device configuration. For merge semantics the tree's own +// computed deletes are used. +func buildGnmiDeletes(ctx context.Context, entry api.Entry, replace bool) ([]*sdcpb.Path, error) { + if replace { + return []*sdcpb.Path{{Elem: []*sdcpb.PathElem{}}}, nil + } + return ops.ToProtoDeletes(ctx, entry) +} + +// buildNetconfPlan builds a NetconfSetPlan from the tree entry. +// For replace semantics nc:operation="replace" is stamped on the document root. +func buildNetconfPlan(ctx context.Context, sbi *config.SBI, entry api.Entry, replace bool) (targettypes.SouthboundSetPlan, error) { + opts := sbi.NetconfOptions + + doc, err := ops.ToXML(ctx, entry, true, + opts.IncludeNS, + opts.OperationWithNamespace, + opts.UseOperationRemove, + ) + if err != nil { + return targettypes.SouthboundSetPlan{}, err + } + + if replace { + utils.AddXMLOperation(&doc.Element, utils.XMLOperationReplace, + opts.OperationWithNamespace, + opts.UseOperationRemove, + ) + } + + return targettypes.SouthboundSetPlan{ + Netconf: &targettypes.NetconfSetPlan{Doc: doc}, + }, nil +} diff --git a/pkg/datastore/target/materialize/materialize_test.go b/pkg/datastore/target/materialize/materialize_test.go new file mode 100644 index 00000000..814264af --- /dev/null +++ b/pkg/datastore/target/materialize/materialize_test.go @@ -0,0 +1,217 @@ +// Copyright 2024 Nokia +// +// 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 materialize_test + +import ( + "context" + "runtime" + "testing" + + "github.com/sdcio/data-server/pkg/config" + schemaClient "github.com/sdcio/data-server/pkg/datastore/clients/schema" + "github.com/sdcio/data-server/pkg/datastore/target/materialize" + "github.com/sdcio/data-server/pkg/pool" + "github.com/sdcio/data-server/pkg/tree" + "github.com/sdcio/data-server/pkg/tree/types" + "github.com/sdcio/data-server/pkg/utils/testhelper" + sdcpb "github.com/sdcio/sdc-protos/sdcpb" + "go.uber.org/mock/gomock" +) + +// newTestRoot creates an empty tree root backed by the real test schema and +// returns the schema client so tests can pass it to BuildPlan. +func newTestRoot(t *testing.T, mockCtrl *gomock.Controller) (*tree.RootEntry, schemaClient.SchemaClientBound) { + t.Helper() + ctx := context.Background() + scb, err := testhelper.GetSchemaClientBound(t, mockCtrl) + if err != nil { + t.Fatalf("GetSchemaClientBound: %v", err) + } + tp := pool.NewSharedTaskPool(ctx, runtime.GOMAXPROCS(0)) + tc := tree.NewTreeContext(scb, tp) + root, err := tree.NewTreeRoot(ctx, tc) + if err != nil { + t.Fatalf("NewTreeRoot: %v", err) + } + return root, scb +} + +// interfaceUpdates returns a single interface entry update. +func interfaceUpdates(name, desc string) []*sdcpb.Update { + return []*sdcpb.Update{{ + Path: &sdcpb.Path{Elem: []*sdcpb.PathElem{ + {Name: "interface", Key: map[string]string{"name": name}}, + {Name: "description"}, + }}, + Value: &sdcpb.TypedValue{Value: &sdcpb.TypedValue_StringVal{StringVal: desc}}, + }} +} + +// networkInstanceUpdates returns a single network-instance description update (module sdcio_model_ni). +func networkInstanceUpdates(name, desc string) []*sdcpb.Update { + return []*sdcpb.Update{{ + Path: &sdcpb.Path{Elem: []*sdcpb.PathElem{ + {Name: "network-instance", Key: map[string]string{"name": name}}, + {Name: "description"}, + }}, + Value: &sdcpb.TypedValue{Value: &sdcpb.TypedValue_StringVal{StringVal: desc}}, + }} +} + +// addAndFinish inserts updates into the root and calls FinishInsertionPhase. +func addAndFinish(t *testing.T, root *tree.RootEntry, updates []*sdcpb.Update, flags *types.UpdateInsertFlags) { + t.Helper() + if err := testhelper.AddToRoot(context.Background(), root.Entry, updates, flags, "owner1", 5); err != nil { + t.Fatal(err) + } + if err := root.FinishInsertionPhase(context.Background()); err != nil { + t.Fatal(err) + } +} + +// --- Cycle 1: IOS-XR + json_ietf → per-module GnmiSetPlan --------------- + +func TestBuildPlan_CiscoIOSXR_JsonIETF_PerModulePlan(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + + upds := append(interfaceUpdates("ethernet-1/1", "uplink"), networkInstanceUpdates("default", "Default NI")...) + addAndFinish(t, root, upds, testhelper.FlagsNew) + + sbi := &config.SBI{ + Type: "gnmi", + DeviceProfile: config.DeviceProfileCiscoIOSXR, + GnmiOptions: &config.SBIGnmiOptions{Encoding: "JSON_IETF"}, + } + + plan, err := materialize.BuildPlan(context.Background(), scb, sbi, root.Entry, false) + if err != nil { + t.Fatalf("BuildPlan: unexpected error: %v", err) + } + if plan.Gnmi == nil { + t.Fatalf("BuildPlan: expected Gnmi plan, got nil") + } + if len(plan.Gnmi.Updates) != 2 { + t.Fatalf("want 2 Updates (one per YANG module), got %d", len(plan.Gnmi.Updates)) + } + origins := make(map[string]bool) + for _, u := range plan.Gnmi.Updates { + origins[u.GetPath().GetOrigin()] = true + if u.GetValue().GetJsonIetfVal() == nil { + t.Errorf("Update for %q: expected JsonIetfVal, got nil", u.GetPath().GetOrigin()) + } + } + if !origins["sdcio_model_if"] { + t.Errorf("missing Update for module sdcio_model_if; origins: %v", origins) + } + if !origins["sdcio_model_ni"] { + t.Errorf("missing Update for module sdcio_model_ni; origins: %v", origins) + } +} + +// --- Cycle 2: IOS-XR + proto → generic (non-split) GnmiSetPlan ---------- + +func TestBuildPlan_CiscoIOSXR_Proto_GenericPlan(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + + addAndFinish(t, root, interfaceUpdates("ethernet-1/1", "uplink"), testhelper.FlagsNew) + + sbi := &config.SBI{ + Type: "gnmi", + DeviceProfile: config.DeviceProfileCiscoIOSXR, + GnmiOptions: &config.SBIGnmiOptions{Encoding: "PROTO"}, + } + + plan, err := materialize.BuildPlan(context.Background(), scb, sbi, root.Entry, false) + if err != nil { + t.Fatalf("BuildPlan: unexpected error: %v", err) + } + if plan.Gnmi == nil { + t.Fatalf("BuildPlan: expected Gnmi plan, got nil") + } + // Generic path: updates are per-leaf proto values, not per-module JSON blobs. + // The key assertion is that no Update carries a non-empty Origin (no per-module grouping). + for _, u := range plan.Gnmi.Updates { + if u.GetPath().GetOrigin() != "" { + t.Errorf("proto plan must not set Path.Origin, got %q", u.GetPath().GetOrigin()) + } + } +} + +// NETCONF accepts cisco-ios-xr profile for config consistency; materialization is +// unchanged from the generic NETCONF path (no GnmiOptions required). +func TestBuildPlan_CiscoIOSXR_Netconf_ReturnsNetconfSetPlan(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + sbi := &config.SBI{ + Type: "netconf", + DeviceProfile: config.DeviceProfileCiscoIOSXR, + NetconfOptions: &config.SBINetconfOptions{}, + } + + plan, err := materialize.BuildPlan(context.Background(), scb, sbi, root.Entry, false) + if err != nil { + t.Fatalf("BuildPlan: unexpected error: %v", err) + } + if plan.Netconf == nil { + t.Errorf("BuildPlan: expected Netconf plan, got nil") + } + if plan.Gnmi != nil { + t.Errorf("BuildPlan: expected no Gnmi plan, got non-nil") + } +} + +// --- Existing generic path tests ----------------------------------------- + +func TestBuildPlan_GnmiSBI_ReturnsGnmiSetPlan(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + sbi := &config.SBI{ + Type: "gnmi", + GnmiOptions: &config.SBIGnmiOptions{Encoding: "PROTO"}, + } + + plan, err := materialize.BuildPlan(context.Background(), scb, sbi, root.Entry, false) + if err != nil { + t.Fatalf("BuildPlan: unexpected error: %v", err) + } + if plan.Gnmi == nil { + t.Errorf("BuildPlan: expected Gnmi plan, got nil") + } + if plan.Netconf != nil { + t.Errorf("BuildPlan: expected no Netconf plan, got non-nil") + } +} + +func TestBuildPlan_NetconfSBI_ReturnsNetconfSetPlan(t *testing.T) { + mockCtrl := gomock.NewController(t) + root, scb := newTestRoot(t, mockCtrl) + sbi := &config.SBI{ + Type: "netconf", + NetconfOptions: &config.SBINetconfOptions{}, + } + + plan, err := materialize.BuildPlan(context.Background(), scb, sbi, root.Entry, false) + if err != nil { + t.Fatalf("BuildPlan: unexpected error: %v", err) + } + if plan.Netconf == nil { + t.Errorf("BuildPlan: expected Netconf plan, got nil") + } + if plan.Gnmi != nil { + t.Errorf("BuildPlan: expected no Gnmi plan, got non-nil") + } +} diff --git a/pkg/datastore/target/netconf/nc.go b/pkg/datastore/target/netconf/nc.go index 3628dd28..149ec925 100644 --- a/pkg/datastore/target/netconf/nc.go +++ b/pkg/datastore/target/netconf/nc.go @@ -159,16 +159,24 @@ func (t *ncTarget) Get(ctx context.Context, req *sdcpb.GetDataRequest) (*sdcpb.G return result, nil } -func (t *ncTarget) Set(ctx context.Context, source types.TargetSource) (*sdcpb.SetDataResponse, error) { +// Set dispatches a pre-built SouthboundSetPlan to the NETCONF target. +// The plan must carry a NetconfSetPlan with the XML document already built +// by the materialize layer (issue 003). +func (t *ncTarget) Set(ctx context.Context, plan types.SouthboundSetPlan) (*sdcpb.SetDataResponse, error) { log := logf.FromContext(ctx).WithName("Set") ctx = logf.IntoContext(ctx, log) if !t.Status().IsConnected() { return nil, fmt.Errorf("%s", types.TargetStatusNotConnected) } + np, ok := plan.NetconfPlan() + if !ok { + return nil, fmt.Errorf("netconf target received a non-NETCONF SouthboundSetPlan") + } + switch t.sbiConfig.NetconfOptions.CommitDatastore { case "running", "candidate": - return t.setToDevice(ctx, t.sbiConfig.NetconfOptions.CommitDatastore, source) + return t.setToDevice(ctx, t.sbiConfig.NetconfOptions.CommitDatastore, np) } // should not get here if the config validation happened. return nil, fmt.Errorf("unknown commit-datastore: %s", t.sbiConfig.NetconfOptions.CommitDatastore) @@ -236,14 +244,14 @@ func filterRPCErrors(xml *etree.Document, severity string) ([]string, error) { return result, nil } -func (t *ncTarget) setToDevice(ctx context.Context, commitDatastore string, source types.TargetSource) (*sdcpb.SetDataResponse, error) { +func (t *ncTarget) setToDevice(ctx context.Context, commitDatastore string, np *types.NetconfSetPlan) (*sdcpb.SetDataResponse, error) { log := logf.FromContext(ctx).WithValues("commit-datastore", commitDatastore) - xtree, err := source.ToXML(ctx, true, t.sbiConfig.NetconfOptions.IncludeNS, t.sbiConfig.NetconfOptions.OperationWithNamespace, t.sbiConfig.NetconfOptions.UseOperationRemove) - if err != nil { - return nil, err + + if np.Doc == nil { + return &sdcpb.SetDataResponse{Timestamp: time.Now().UnixNano()}, nil } - xdoc, err := xtree.WriteToString() + xdoc, err := np.Doc.WriteToString() if err != nil { return nil, err } diff --git a/pkg/datastore/target/noop/noop.go b/pkg/datastore/target/noop/noop.go index a07a93b9..3e9594ff 100644 --- a/pkg/datastore/target/noop/noop.go +++ b/pkg/datastore/target/noop/noop.go @@ -70,38 +70,40 @@ func (t *noopTarget) Get(ctx context.Context, req *sdcpb.GetDataRequest) (*sdcpb return result, nil } -func (t *noopTarget) Set(ctx context.Context, source types.TargetSource) (*sdcpb.SetDataResponse, error) { +func (t *noopTarget) Set(ctx context.Context, plan types.SouthboundSetPlan) (*sdcpb.SetDataResponse, error) { log := logf.FromContext(ctx).WithName("Set") - ctx = logf.IntoContext(ctx, log) - - upds, err := source.ToProtoUpdates(ctx, true) - if err != nil { - return nil, err - } - - deletes, err := source.ToProtoDeletes(ctx) - if err != nil { - return nil, err - } result := &sdcpb.SetDataResponse{ - Response: make([]*sdcpb.UpdateResult, 0, - len(upds)+len(deletes)), Timestamp: time.Now().UnixNano(), } - for _, upd := range upds { - result.Response = append(result.Response, &sdcpb.UpdateResult{ - Path: upd.GetPath(), - Op: sdcpb.UpdateResult_UPDATE, - }) + if gp, ok := plan.GnmiPlan(); ok { + result.Response = make([]*sdcpb.UpdateResult, 0, len(gp.Updates)+len(gp.Deletes)) + for _, upd := range gp.Updates { + log.V(logf.VDebug).Info("noop set update", "path", upd.GetPath()) + result.Response = append(result.Response, &sdcpb.UpdateResult{ + Path: upd.GetPath(), + Op: sdcpb.UpdateResult_UPDATE, + }) + } + for _, p := range gp.Deletes { + log.V(logf.VDebug).Info("noop set delete", "path", p) + result.Response = append(result.Response, &sdcpb.UpdateResult{ + Path: p, + Op: sdcpb.UpdateResult_DELETE, + }) + } + return result, nil } - for _, p := range deletes { - result.Response = append(result.Response, &sdcpb.UpdateResult{ - Path: p, - Op: sdcpb.UpdateResult_DELETE, - }) + + if _, ok := plan.NetconfPlan(); ok { + log.V(logf.VDebug).Info("noop set netconf plan received") + result.Response = []*sdcpb.UpdateResult{} + return result, nil } + + log.V(logf.VDebug).Info("noop set empty plan received") + result.Response = []*sdcpb.UpdateResult{} return result, nil } diff --git a/pkg/datastore/target/noop/noop_test.go b/pkg/datastore/target/noop/noop_test.go new file mode 100644 index 00000000..114203da --- /dev/null +++ b/pkg/datastore/target/noop/noop_test.go @@ -0,0 +1,84 @@ +// Copyright 2024 Nokia +// +// 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 noop + +import ( + "context" + "testing" + + sdcpb "github.com/sdcio/sdc-protos/sdcpb" + + "github.com/sdcio/data-server/pkg/datastore/target/types" +) + +func TestNoopTarget_Set_GnmiPlan_ReturnsUpdateResultsForUpdatesAndDeletes(t *testing.T) { + nt, err := NewNoopTarget(context.Background(), "test") + if err != nil { + t.Fatalf("NewNoopTarget: %v", err) + } + + plan := types.SouthboundSetPlan{ + Gnmi: &types.GnmiSetPlan{ + Updates: []*sdcpb.Update{ + {Path: &sdcpb.Path{Elem: []*sdcpb.PathElem{{Name: "interface"}}}}, + {Path: &sdcpb.Path{Elem: []*sdcpb.PathElem{{Name: "bgp"}}}}, + }, + Deletes: []*sdcpb.Path{ + {Elem: []*sdcpb.PathElem{{Name: "routing"}}}, + }, + }, + } + + rsp, err := nt.Set(context.Background(), plan) + if err != nil { + t.Fatalf("Set: unexpected error: %v", err) + } + if rsp == nil { + t.Fatal("Set: expected non-nil response") + } + + wantOps := []sdcpb.UpdateResult_Operation{ + sdcpb.UpdateResult_UPDATE, + sdcpb.UpdateResult_UPDATE, + sdcpb.UpdateResult_DELETE, + } + if len(rsp.Response) != len(wantOps) { + t.Fatalf("Set: expected %d response entries, got %d", len(wantOps), len(rsp.Response)) + } + for i, want := range wantOps { + if rsp.Response[i].Op != want { + t.Errorf("entry %d: want Op %v, got %v", i, want, rsp.Response[i].Op) + } + } +} + +func TestNoopTarget_Set_NetconfPlan_ReturnsEmptyResponse(t *testing.T) { + nt, err := NewNoopTarget(context.Background(), "test") + if err != nil { + t.Fatalf("NewNoopTarget: %v", err) + } + + plan := types.SouthboundSetPlan{ + Netconf: &types.NetconfSetPlan{}, + } + + rsp, err := nt.Set(context.Background(), plan) + if err != nil { + t.Fatalf("Set: unexpected error: %v", err) + } + if rsp == nil { + t.Fatal("Set: expected non-nil response") + } +} diff --git a/pkg/datastore/target/target.go b/pkg/datastore/target/target.go index 993069ae..f816155d 100644 --- a/pkg/datastore/target/target.go +++ b/pkg/datastore/target/target.go @@ -39,7 +39,7 @@ const ( type Target interface { Get(ctx context.Context, req *sdcpb.GetDataRequest) (*sdcpb.GetDataResponse, error) - Set(ctx context.Context, source types.TargetSource) (*sdcpb.SetDataResponse, error) + Set(ctx context.Context, plan types.SouthboundSetPlan) (*sdcpb.SetDataResponse, error) AddSyncs(ctx context.Context, sps ...*config.SyncProtocol) error Status() *types.TargetStatus Close(ctx context.Context) error diff --git a/pkg/datastore/target/types/setplan.go b/pkg/datastore/target/types/setplan.go new file mode 100644 index 00000000..1e663051 --- /dev/null +++ b/pkg/datastore/target/types/setplan.go @@ -0,0 +1,54 @@ +// Copyright 2024 Nokia +// +// 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 types + +import ( + "github.com/beevik/etree" + sdcpb "github.com/sdcio/sdc-protos/sdcpb" +) + +// GnmiSetPlan holds a pre-encoded gNMI Set payload. +// Replace intent is expressed as module-level delete entries in Deletes +// followed by the corresponding update entries in Updates, all in one plan. +// There is no Replaces field. +type GnmiSetPlan struct { + Updates []*sdcpb.Update + Deletes []*sdcpb.Path +} + +// NetconfSetPlan holds a pre-built XML document ready for an EditConfig call. +type NetconfSetPlan struct { + Doc *etree.Document +} + +// SouthboundSetPlan is a discriminated wrapper carrying exactly one of +// GnmiSetPlan or NetconfSetPlan. Callers check which variant is populated +// via the GnmiPlan / NetconfPlan accessors. +type SouthboundSetPlan struct { + Gnmi *GnmiSetPlan + Netconf *NetconfSetPlan +} + +// GnmiPlan returns the embedded GnmiSetPlan and true if this plan targets a +// gNMI driver, or nil and false otherwise. +func (p SouthboundSetPlan) GnmiPlan() (*GnmiSetPlan, bool) { + return p.Gnmi, p.Gnmi != nil +} + +// NetconfPlan returns the embedded NetconfSetPlan and true if this plan +// targets a NETCONF driver, or nil and false otherwise. +func (p SouthboundSetPlan) NetconfPlan() (*NetconfSetPlan, bool) { + return p.Netconf, p.Netconf != nil +} diff --git a/pkg/datastore/target/types/targetsource.go b/pkg/datastore/target/types/targetsource.go deleted file mode 100644 index c90b05c3..00000000 --- a/pkg/datastore/target/types/targetsource.go +++ /dev/null @@ -1,21 +0,0 @@ -package types - -import ( - "context" - - "github.com/beevik/etree" - sdcpb "github.com/sdcio/sdc-protos/sdcpb" -) - -type TargetSource interface { - // ToJson returns the Tree contained structure as JSON - // use e.g. json.MarshalIndent() on the returned struct - ToJson(ctx context.Context, onlyNewOrUpdated bool) (any, error) - // ToJsonIETF returns the Tree contained structure as JSON_IETF - // use e.g. json.MarshalIndent() on the returned struct - ToJsonIETF(ctx context.Context, onlyNewOrUpdated bool) (any, error) - ToXML(ctx context.Context, onlyNewOrUpdated bool, honorNamespace bool, operationWithNamespace bool, useOperationRemove bool) (*etree.Document, error) - ToProtoUpdates(ctx context.Context, onlyNewOrUpdated bool) ([]*sdcpb.Update, error) - ToProtoDeletes(ctx context.Context) ([]*sdcpb.Path, error) - ContainsChanges(ctx context.Context) (bool, error) -} diff --git a/pkg/datastore/transaction_rpc.go b/pkg/datastore/transaction_rpc.go index d89d55b1..e04a6edc 100644 --- a/pkg/datastore/transaction_rpc.go +++ b/pkg/datastore/transaction_rpc.go @@ -11,7 +11,6 @@ import ( "github.com/sdcio/data-server/pkg/datastore/types" "github.com/sdcio/data-server/pkg/tree" "github.com/sdcio/data-server/pkg/tree/api" - "github.com/sdcio/data-server/pkg/tree/api/adapter" "github.com/sdcio/data-server/pkg/tree/consts" treeproto "github.com/sdcio/data-server/pkg/tree/importer/proto" "github.com/sdcio/data-server/pkg/tree/ops" @@ -134,10 +133,8 @@ func (d *Datastore) replaceIntent(ctx context.Context, transaction *types.Transa // we use the TargetSourceReplace, that adjustes the tree results in a way // that the whole config tree is getting replaced. - replaceRoot := types.NewTargetSourceReplace(adapter.NewEntryOutputAdapter(root.Entry)) - // apply the resulting config to the device - dataResp, err := d.applyIntent(ctx, replaceRoot) + dataResp, err := d.applyIntent(ctx, root.Entry, true) if err != nil { return nil, err } @@ -344,7 +341,7 @@ func (d *Datastore) lowlevelTransactionSet(ctx context.Context, transaction *typ } // apply the resulting config to the device - dataResp, err := d.applyIntent(ctx, adapter.NewEntryOutputAdapter(root.Entry)) + dataResp, err := d.applyIntent(ctx, root.Entry, false) if err != nil { return nil, err } diff --git a/pkg/datastore/types/target_source_replace.go b/pkg/datastore/types/target_source_replace.go deleted file mode 100644 index 9df1ee0a..00000000 --- a/pkg/datastore/types/target_source_replace.go +++ /dev/null @@ -1,55 +0,0 @@ -package types - -import ( - "context" - - "github.com/beevik/etree" - "github.com/sdcio/data-server/pkg/datastore/target/types" - "github.com/sdcio/data-server/pkg/utils" - sdcpb "github.com/sdcio/sdc-protos/sdcpb" -) - -// TargetSourceReplace takes a TargetSource and proxies the calls. -// Calls to ToProtoDeletes(...) [used with gnmi proto, json & json_ietf encodings] -// and ToXML(...) used in case of netconf are altered. -// - ToProtoDeletes(...) returns only the root path. -// - ToXML(...) returns the TargetSource generated etree.Document, but sets the -// replace flag on the root element -type TargetSourceReplace struct { - types.TargetSource -} - -// NewTargetSourceReplace constructor for TargetSourceReplace -func NewTargetSourceReplace(ts types.TargetSource) *TargetSourceReplace { - return &TargetSourceReplace{ - ts, - } -} - -// ToProtoDeletes In the Replace case, we need to delete from the Root down. So the call is -// not forwarded to the TargetSource but the root path is returned -func (t *TargetSourceReplace) ToProtoDeletes(ctx context.Context) ([]*sdcpb.Path, error) { - return []*sdcpb.Path{ - { - Elem: []*sdcpb.PathElem{}, - }, - }, nil -} - -// ToXML in the XML case, we need to add the XMLReplace operation to the root element -// So the call is forwarded to the original TargetSource, the attribute is added and returned to the caller -func (t *TargetSourceReplace) ToXML(ctx context.Context, onlyNewOrUpdated bool, honorNamespace bool, operationWithNamespace bool, useOperationRemove bool) (*etree.Document, error) { - // forward call to original TargetSource - et, err := t.TargetSource.ToXML(ctx, onlyNewOrUpdated, honorNamespace, operationWithNamespace, useOperationRemove) - if err != nil { - return nil, err - } - // Add replace operation to the root element - utils.AddXMLOperation(&et.Element, utils.XMLOperationReplace, operationWithNamespace, useOperationRemove) - return et, nil -} - -func (t *TargetSourceReplace) ContainsChanges(ctx context.Context) (bool, error) { - // for replace we assume it always contains changes, even if the original TargetSource does not - return true, nil -} diff --git a/pkg/server/datastore.go b/pkg/server/datastore.go index 10746556..ea518c72 100644 --- a/pkg/server/datastore.go +++ b/pkg/server/datastore.go @@ -104,9 +104,10 @@ func (s *Server) CreateDataStore(ctx context.Context, req *sdcpb.CreateDataStore reqTarget := req.GetTarget() sbi := &config.SBI{ - Type: reqTarget.GetType(), - Port: reqTarget.GetPort(), - Address: reqTarget.GetAddress(), + Type: reqTarget.GetType(), + Port: reqTarget.GetPort(), + Address: reqTarget.GetAddress(), + DeviceProfile: sdcpbDeviceProfileToConfig(reqTarget.GetDeviceProfile()), } switch strings.ToLower(reqTarget.GetType()) { @@ -306,8 +307,9 @@ func (s *Server) datastoreToRsp(ctx context.Context, ds *datastore.Datastore) (* DatastoreName: ds.Config().Name, } rsp.Target = &sdcpb.Target{ - Type: ds.Config().SBI.Type, - Address: ds.Config().SBI.Address, + Type: ds.Config().SBI.Type, + Address: ds.Config().SBI.Address, + DeviceProfile: configDeviceProfileToSdcpb(ds.Config().SBI.DeviceProfile), } rsp.Intents, err = ds.IntentsList(ctx) if err != nil { @@ -352,3 +354,21 @@ func (s *Server) BlameConfig(ctx context.Context, req *sdcpb.BlameConfigRequest) }, nil } + +func sdcpbDeviceProfileToConfig(p sdcpb.DeviceProfile) config.DeviceProfile { + switch p { + case sdcpb.DeviceProfile_DEVICE_PROFILE_CISCO_IOS_XR: + return config.DeviceProfileCiscoIOSXR + default: + return config.DeviceProfileNone + } +} + +func configDeviceProfileToSdcpb(p config.DeviceProfile) sdcpb.DeviceProfile { + switch p { + case config.DeviceProfileCiscoIOSXR: + return sdcpb.DeviceProfile_DEVICE_PROFILE_CISCO_IOS_XR + default: + return sdcpb.DeviceProfile_DEVICE_PROFILE_GENERIC + } +} diff --git a/pkg/tree/api/adapter/entryoutputadapter.go b/pkg/tree/api/adapter/entryoutputadapter.go deleted file mode 100644 index 5222d807..00000000 --- a/pkg/tree/api/adapter/entryoutputadapter.go +++ /dev/null @@ -1,45 +0,0 @@ -package adapter - -import ( - "context" - - "github.com/beevik/etree" - "github.com/sdcio/data-server/pkg/tree/api" - "github.com/sdcio/data-server/pkg/tree/ops" - sdcpb "github.com/sdcio/sdc-protos/sdcpb" -) - -type EntryOutputAdapter struct { - entry api.Entry -} - -func NewEntryOutputAdapter(e api.Entry) *EntryOutputAdapter { - return &EntryOutputAdapter{ - entry: e, - } -} - -func (t *EntryOutputAdapter) ToJson(ctx context.Context, onlyNewOrUpdated bool) (any, error) { - return ops.ToJson(ctx, t.entry, onlyNewOrUpdated) -} - -func (t *EntryOutputAdapter) ToJsonIETF(ctx context.Context, onlyNewOrUpdated bool) (any, error) { - return ops.ToJsonIETF(ctx, t.entry, onlyNewOrUpdated) -} - -func (t *EntryOutputAdapter) ToXML(ctx context.Context, onlyNewOrUpdated bool, honorNamespace bool, operationWithNamespace bool, useOperationRemove bool) (*etree.Document, error) { - return ops.ToXML(ctx, t.entry, onlyNewOrUpdated, honorNamespace, operationWithNamespace, useOperationRemove) -} - -func (t *EntryOutputAdapter) ToProtoUpdates(ctx context.Context, onlyNewOrUpdated bool) ([]*sdcpb.Update, error) { - return ops.ToProtoUpdates(ctx, t.entry, onlyNewOrUpdated) -} - -func (t *EntryOutputAdapter) ToProtoDeletes(ctx context.Context) ([]*sdcpb.Path, error) { - return ops.ToProtoDeletes(ctx, t.entry) -} - -func (t *EntryOutputAdapter) ContainsChanges(ctx context.Context) (bool, error) { - // TODO: needs to be implemented properly, for now we assume it contains changes - return true, nil -} diff --git a/tests/schema/sdcio_model.yang b/tests/schema/sdcio_model.yang index f6f03ceb..8e2415cc 100644 --- a/tests/schema/sdcio_model.yang +++ b/tests/schema/sdcio_model.yang @@ -42,7 +42,6 @@ module sdcio_model { import sdcio_model_count { prefix sdcio_model_count; } - description "This is the test schema for sdcio";