From 50278cb22be92b88b83915b471fdb94713e8e6de Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 23 Nov 2023 15:16:18 +0100 Subject: [PATCH] [no-relnote] Refactor CDI ContainerEdit creation This change refactors the creation of contianer edits to make provision for injecting options that affect the generated CDI specifications. Signed-off-by: Evan Lezar --- internal/edits/api.go | 29 +++++++++ internal/edits/edits.go | 56 ----------------- internal/edits/edits_test.go | 2 +- internal/edits/lib.go | 92 ++++++++++++++++++++++++++++ internal/edits/options.go | 31 ++++++++++ internal/edits/resource.go | 30 +++++++++ internal/modifier/discover.go | 6 +- pkg/nvcdi/full-gpu-nvml.go | 14 +---- pkg/nvcdi/gds.go | 9 +-- pkg/nvcdi/lib-csv.go | 11 +--- pkg/nvcdi/lib-nvml.go | 3 +- pkg/nvcdi/lib-wsl.go | 9 +-- pkg/nvcdi/lib.go | 10 +++ pkg/nvcdi/management.go | 11 ++-- pkg/nvcdi/mig-device-nvml.go | 9 +-- pkg/nvcdi/mofed.go | 9 +-- pkg/nvcdi/transform/merged-device.go | 6 +- 17 files changed, 222 insertions(+), 115 deletions(-) create mode 100644 internal/edits/api.go create mode 100644 internal/edits/lib.go create mode 100644 internal/edits/options.go create mode 100644 internal/edits/resource.go diff --git a/internal/edits/api.go b/internal/edits/api.go new file mode 100644 index 00000000..87437653 --- /dev/null +++ b/internal/edits/api.go @@ -0,0 +1,29 @@ +/** +# Copyright 2024 NVIDIA CORPORATION +# +# 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 edits + +import ( + "tags.cncf.io/container-device-interface/pkg/cdi" + + "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" + "github.com/NVIDIA/nvidia-container-toolkit/internal/oci" +) + +type Interface interface { + EditsFromDiscoverer(discover.Discover) (*cdi.ContainerEdits, error) + SpecModifierFromDiscoverer(discover.Discover) (oci.SpecModifier, error) +} diff --git a/internal/edits/edits.go b/internal/edits/edits.go index 029e7885..3d4f031a 100644 --- a/internal/edits/edits.go +++ b/internal/edits/edits.go @@ -17,15 +17,11 @@ package edits import ( - "fmt" - ociSpecs "github.com/opencontainers/runtime-spec/specs-go" "tags.cncf.io/container-device-interface/pkg/cdi" "tags.cncf.io/container-device-interface/specs-go" - "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" "github.com/NVIDIA/nvidia-container-toolkit/internal/logger" - "github.com/NVIDIA/nvidia-container-toolkit/internal/oci" ) type edits struct { @@ -33,58 +29,6 @@ type edits struct { logger logger.Interface } -// NewSpecEdits creates a SpecModifier that defines the required OCI spec edits (as CDI ContainerEdits) from the specified -// discoverer. -func NewSpecEdits(logger logger.Interface, d discover.Discover) (oci.SpecModifier, error) { - c, err := FromDiscoverer(d) - if err != nil { - return nil, fmt.Errorf("error constructing container edits: %v", err) - } - e := edits{ - ContainerEdits: *c, - logger: logger, - } - - return &e, nil -} - -// FromDiscoverer creates CDI container edits for the specified discoverer. -func FromDiscoverer(d discover.Discover) (*cdi.ContainerEdits, error) { - devices, err := d.Devices() - if err != nil { - return nil, fmt.Errorf("failed to discover devices: %v", err) - } - - mounts, err := d.Mounts() - if err != nil { - return nil, fmt.Errorf("failed to discover mounts: %v", err) - } - - hooks, err := d.Hooks() - if err != nil { - return nil, fmt.Errorf("failed to discover hooks: %v", err) - } - - c := NewContainerEdits() - for _, d := range devices { - edits, err := device(d).toEdits() - if err != nil { - return nil, fmt.Errorf("failed to created container edits for device: %v", err) - } - c.Append(edits) - } - - for _, m := range mounts { - c.Append(mount(m).toEdits()) - } - - for _, h := range hooks { - c.Append(hook(h).toEdits()) - } - - return c, nil -} - // NewContainerEdits is a utility function to create a CDI ContainerEdits struct. func NewContainerEdits() *cdi.ContainerEdits { c := cdi.ContainerEdits{ diff --git a/internal/edits/edits_test.go b/internal/edits/edits_test.go index 0c891860..2ca3bafa 100644 --- a/internal/edits/edits_test.go +++ b/internal/edits/edits_test.go @@ -25,7 +25,7 @@ import ( ) func TestFromDiscovererAllowsMountsToIterate(t *testing.T) { - edits, err := FromDiscoverer(discover.None{}) + edits, err := New().EditsFromDiscoverer(discover.None{}) require.NoError(t, err) require.Empty(t, edits.Mounts) diff --git a/internal/edits/lib.go b/internal/edits/lib.go new file mode 100644 index 00000000..797882c6 --- /dev/null +++ b/internal/edits/lib.go @@ -0,0 +1,92 @@ +/** +# Copyright 2024 NVIDIA CORPORATION +# +# 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 edits + +import ( + "fmt" + + "tags.cncf.io/container-device-interface/pkg/cdi" + + "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" + "github.com/NVIDIA/nvidia-container-toolkit/internal/logger" + "github.com/NVIDIA/nvidia-container-toolkit/internal/oci" +) + +// New creates an Interface from the supplied options. +func New(opts ...Option) Interface { + o := &options{} + for _, opt := range opts { + opt(o) + } + if o.logger == nil { + o.logger = logger.New() + } + + return o +} + +// EditsFromDiscoverer creates CDI container edits for the specified discoverer. +func (o *options) EditsFromDiscoverer(d discover.Discover) (*cdi.ContainerEdits, error) { + devices, err := d.Devices() + if err != nil { + return nil, fmt.Errorf("failed to discover devices: %w", err) + } + + mounts, err := d.Mounts() + if err != nil { + return nil, fmt.Errorf("failed to discover mounts: %w", err) + } + + hooks, err := d.Hooks() + if err != nil { + return nil, fmt.Errorf("failed to discover hooks: %w", err) + } + + c := NewContainerEdits() + for _, d := range devices { + edits, err := device(d).toEdits() + if err != nil { + return nil, fmt.Errorf("failed to create container edits for device: %w", err) + } + c.Append(edits) + } + + for _, m := range mounts { + c.Append(mount(m).toEdits()) + } + + for _, h := range hooks { + c.Append(hook(h).toEdits()) + } + + return c, nil +} + +// SpecModifierFromDiscoverer creates a SpecModifier that defines the required OCI spec edits (as CDI ContainerEdits) from the specified +// discoverer. +func (o *options) SpecModifierFromDiscoverer(d discover.Discover) (oci.SpecModifier, error) { + c, err := o.EditsFromDiscoverer(d) + if err != nil { + return nil, fmt.Errorf("error constructing container edits: %w", err) + } + e := edits{ + ContainerEdits: *c, + logger: o.logger, + } + + return &e, nil +} diff --git a/internal/edits/options.go b/internal/edits/options.go new file mode 100644 index 00000000..e9bfd1c5 --- /dev/null +++ b/internal/edits/options.go @@ -0,0 +1,31 @@ +/** +# Copyright 2024 NVIDIA CORPORATION +# +# 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 edits + +import "github.com/NVIDIA/nvidia-container-toolkit/internal/logger" + +type options struct { + logger logger.Interface +} + +type Option func(*options) + +func WithLogger(logger logger.Interface) Option { + return func(o *options) { + o.logger = logger + } +} diff --git a/internal/edits/resource.go b/internal/edits/resource.go new file mode 100644 index 00000000..2337a837 --- /dev/null +++ b/internal/edits/resource.go @@ -0,0 +1,30 @@ +/** +# Copyright 2024 NVIDIA CORPORATION +# +# 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 edits + +import ( + "tags.cncf.io/container-device-interface/pkg/cdi" + "tags.cncf.io/container-device-interface/specs-go" +) + +// NewResource creates a CDI resource (Device) with the specified name. +func NewResource(name string, edits *cdi.ContainerEdits) specs.Device { + return specs.Device{ + Name: name, + ContainerEdits: *edits.ContainerEdits, + } +} diff --git a/internal/modifier/discover.go b/internal/modifier/discover.go index b249c559..0f0ec476 100644 --- a/internal/modifier/discover.go +++ b/internal/modifier/discover.go @@ -45,7 +45,11 @@ func NewModifierFromDiscoverer(logger logger.Interface, d discover.Discover) (oc // Modify applies the modifications required by discoverer to the incomming OCI spec. // These modifications are applied in-place. func (m discoverModifier) Modify(spec *specs.Spec) error { - specEdits, err := edits.NewSpecEdits(m.logger, m.discoverer) + e := edits.New( + edits.WithLogger(m.logger), + ) + + specEdits, err := e.SpecModifierFromDiscoverer(m.discoverer) if err != nil { return fmt.Errorf("failed to get required container edits: %v", err) } diff --git a/pkg/nvcdi/full-gpu-nvml.go b/pkg/nvcdi/full-gpu-nvml.go index 881d102d..f3859743 100644 --- a/pkg/nvcdi/full-gpu-nvml.go +++ b/pkg/nvcdi/full-gpu-nvml.go @@ -30,7 +30,7 @@ import ( // GetGPUDeviceSpecs returns the CDI device specs for the full GPU represented by 'device'. func (l *nvmllib) GetGPUDeviceSpecs(i int, d device.Device) ([]specs.Device, error) { - edits, err := l.GetGPUDeviceEdits(d) + e, err := l.GetGPUDeviceEdits(d) if err != nil { return nil, fmt.Errorf("failed to get edits for device: %v", err) } @@ -41,10 +41,7 @@ func (l *nvmllib) GetGPUDeviceSpecs(i int, d device.Device) ([]specs.Device, err return nil, fmt.Errorf("failed to get device name: %v", err) } for _, name := range names { - spec := specs.Device{ - Name: name, - ContainerEdits: *edits.ContainerEdits, - } + spec := edits.NewResource(name, e) deviceSpecs = append(deviceSpecs, spec) } @@ -58,12 +55,7 @@ func (l *nvmllib) GetGPUDeviceEdits(d device.Device) (*cdi.ContainerEdits, error return nil, fmt.Errorf("failed to create device discoverer: %v", err) } - editsForDevice, err := edits.FromDiscoverer(device) - if err != nil { - return nil, fmt.Errorf("failed to create container edits for device: %v", err) - } - - return editsForDevice, nil + return (*nvcdilib)(l).editsFromDiscoverer(device) } // newFullGPUDiscoverer creates a discoverer for the full GPU defined by the specified device. diff --git a/pkg/nvcdi/gds.go b/pkg/nvcdi/gds.go index 915cb94a..35b30b48 100644 --- a/pkg/nvcdi/gds.go +++ b/pkg/nvcdi/gds.go @@ -38,22 +38,19 @@ func (l *gdslib) GetAllDeviceSpecs() ([]specs.Device, error) { if err != nil { return nil, fmt.Errorf("failed to create GPUDirect Storage discoverer: %v", err) } - edits, err := edits.FromDiscoverer(discoverer) + e, err := (*nvcdilib)(l).editsFromDiscoverer(discoverer) if err != nil { return nil, fmt.Errorf("failed to create container edits for GPUDirect Storage: %v", err) } - deviceSpec := specs.Device{ - Name: "all", - ContainerEdits: *edits.ContainerEdits, - } + deviceSpec := edits.NewResource("all", e) return []specs.Device{deviceSpec}, nil } // GetCommonEdits generates a CDI specification that can be used for ANY devices func (l *gdslib) GetCommonEdits() (*cdi.ContainerEdits, error) { - return edits.FromDiscoverer(discover.None{}) + return edits.NewContainerEdits(), nil } // GetSpec is unsppported for the gdslib specs. diff --git a/pkg/nvcdi/lib-csv.go b/pkg/nvcdi/lib-csv.go index 649b801a..06f21402 100644 --- a/pkg/nvcdi/lib-csv.go +++ b/pkg/nvcdi/lib-csv.go @@ -23,7 +23,6 @@ import ( "tags.cncf.io/container-device-interface/pkg/cdi" "tags.cncf.io/container-device-interface/specs-go" - "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" "github.com/NVIDIA/nvidia-container-toolkit/internal/edits" "github.com/NVIDIA/nvidia-container-toolkit/internal/platform-support/tegra" "github.com/NVIDIA/nvidia-container-toolkit/pkg/nvcdi/spec" @@ -53,7 +52,7 @@ func (l *csvlib) GetAllDeviceSpecs() ([]specs.Device, error) { if err != nil { return nil, fmt.Errorf("failed to create discoverer for CSV files: %v", err) } - e, err := edits.FromDiscoverer(d) + e, err := (*nvcdilib)(l).editsFromDiscoverer(d) if err != nil { return nil, fmt.Errorf("failed to create container edits for CSV files: %v", err) } @@ -64,10 +63,7 @@ func (l *csvlib) GetAllDeviceSpecs() ([]specs.Device, error) { } var deviceSpecs []specs.Device for _, name := range names { - deviceSpec := specs.Device{ - Name: name, - ContainerEdits: *e.ContainerEdits, - } + deviceSpec := edits.NewResource(name, e) deviceSpecs = append(deviceSpecs, deviceSpec) } @@ -76,8 +72,7 @@ func (l *csvlib) GetAllDeviceSpecs() ([]specs.Device, error) { // GetCommonEdits generates a CDI specification that can be used for ANY devices func (l *csvlib) GetCommonEdits() (*cdi.ContainerEdits, error) { - d := discover.None{} - return edits.FromDiscoverer(d) + return edits.NewContainerEdits(), nil } // GetGPUDeviceEdits generates a CDI specification that can be used for GPU devices diff --git a/pkg/nvcdi/lib-nvml.go b/pkg/nvcdi/lib-nvml.go index ab7cb8ba..fc78e1d9 100644 --- a/pkg/nvcdi/lib-nvml.go +++ b/pkg/nvcdi/lib-nvml.go @@ -26,7 +26,6 @@ import ( "tags.cncf.io/container-device-interface/pkg/cdi" "tags.cncf.io/container-device-interface/specs-go" - "github.com/NVIDIA/nvidia-container-toolkit/internal/edits" "github.com/NVIDIA/nvidia-container-toolkit/pkg/nvcdi/spec" ) @@ -74,7 +73,7 @@ func (l *nvmllib) GetCommonEdits() (*cdi.ContainerEdits, error) { return nil, fmt.Errorf("failed to create discoverer for common entities: %v", err) } - return edits.FromDiscoverer(common) + return (*nvcdilib)(l).editsFromDiscoverer(common) } // GetDeviceSpecsByID returns the CDI device specs for the GPU(s) represented by diff --git a/pkg/nvcdi/lib-wsl.go b/pkg/nvcdi/lib-wsl.go index 1c96c538..69aff1f0 100644 --- a/pkg/nvcdi/lib-wsl.go +++ b/pkg/nvcdi/lib-wsl.go @@ -39,15 +39,12 @@ func (l *wsllib) GetSpec() (spec.Interface, error) { // GetAllDeviceSpecs returns the device specs for all available devices. func (l *wsllib) GetAllDeviceSpecs() ([]specs.Device, error) { device := newDXGDeviceDiscoverer(l.logger, l.devRoot) - deviceEdits, err := edits.FromDiscoverer(device) + e, err := (*nvcdilib)(l).editsFromDiscoverer(device) if err != nil { return nil, fmt.Errorf("failed to create container edits for DXG device: %v", err) } - deviceSpec := specs.Device{ - Name: "all", - ContainerEdits: *deviceEdits.ContainerEdits, - } + deviceSpec := edits.NewResource("all", e) return []specs.Device{deviceSpec}, nil } @@ -59,7 +56,7 @@ func (l *wsllib) GetCommonEdits() (*cdi.ContainerEdits, error) { return nil, fmt.Errorf("failed to create discoverer for WSL driver: %v", err) } - return edits.FromDiscoverer(driver) + return (*nvcdilib)(l).editsFromDiscoverer(driver) } // GetGPUDeviceEdits generates a CDI specification that can be used for GPU devices diff --git a/pkg/nvcdi/lib.go b/pkg/nvcdi/lib.go index d2db3b6c..ac08c97f 100644 --- a/pkg/nvcdi/lib.go +++ b/pkg/nvcdi/lib.go @@ -24,6 +24,8 @@ import ( "github.com/NVIDIA/go-nvml/pkg/nvml" "tags.cncf.io/container-device-interface/pkg/cdi" + "github.com/NVIDIA/nvidia-container-toolkit/internal/discover" + "github.com/NVIDIA/nvidia-container-toolkit/internal/edits" "github.com/NVIDIA/nvidia-container-toolkit/internal/logger" "github.com/NVIDIA/nvidia-container-toolkit/internal/lookup/root" "github.com/NVIDIA/nvidia-container-toolkit/internal/platform-support/tegra/csv" @@ -190,6 +192,14 @@ func (m *wrapper) GetCommonEdits() (*cdi.ContainerEdits, error) { return edits, nil } +// editsFromDiscoverer +func (l *nvcdilib) editsFromDiscoverer(d discover.Discover) (*cdi.ContainerEdits, error) { + e := edits.New( + edits.WithLogger(l.logger), + ) + return e.EditsFromDiscoverer(d) +} + // resolveMode resolves the mode for CDI spec generation based on the current system. func (l *nvcdilib) resolveMode() (rmode string) { if l.mode != ModeAuto { diff --git a/pkg/nvcdi/management.go b/pkg/nvcdi/management.go index 4648e5bb..cae1a347 100644 --- a/pkg/nvcdi/management.go +++ b/pkg/nvcdi/management.go @@ -43,19 +43,16 @@ func (m *managementlib) GetAllDeviceSpecs() ([]specs.Device, error) { return nil, fmt.Errorf("failed to create device discoverer: %v", err) } - edits, err := edits.FromDiscoverer(devices) + e, err := (*nvcdilib)(m).editsFromDiscoverer(devices) if err != nil { return nil, fmt.Errorf("failed to create edits from discoverer: %v", err) } - if len(edits.DeviceNodes) == 0 { + if len(e.DeviceNodes) == 0 { return nil, fmt.Errorf("no NVIDIA device nodes found") } - device := specs.Device{ - Name: "all", - ContainerEdits: *edits.ContainerEdits, - } + device := edits.NewResource("all", e) return []specs.Device{device}, nil } @@ -71,7 +68,7 @@ func (m *managementlib) GetCommonEdits() (*cdi.ContainerEdits, error) { return nil, fmt.Errorf("failed to create driver library discoverer: %v", err) } - edits, err := edits.FromDiscoverer(driver) + edits, err := (*nvcdilib)(m).editsFromDiscoverer(driver) if err != nil { return nil, fmt.Errorf("failed to create edits from discoverer: %v", err) } diff --git a/pkg/nvcdi/mig-device-nvml.go b/pkg/nvcdi/mig-device-nvml.go index 91fe879c..516a0f8d 100644 --- a/pkg/nvcdi/mig-device-nvml.go +++ b/pkg/nvcdi/mig-device-nvml.go @@ -29,7 +29,7 @@ import ( // GetMIGDeviceSpecs returns the CDI device specs for the full GPU represented by 'device'. func (l *nvmllib) GetMIGDeviceSpecs(i int, d device.Device, j int, mig device.MigDevice) ([]specs.Device, error) { - edits, err := l.GetMIGDeviceEdits(d, mig) + e, err := l.GetMIGDeviceEdits(d, mig) if err != nil { return nil, fmt.Errorf("failed to get edits for device: %v", err) } @@ -40,10 +40,7 @@ func (l *nvmllib) GetMIGDeviceSpecs(i int, d device.Device, j int, mig device.Mi } var deviceSpecs []specs.Device for _, name := range names { - spec := specs.Device{ - Name: name, - ContainerEdits: *edits.ContainerEdits, - } + spec := edits.NewResource(name, e) deviceSpecs = append(deviceSpecs, spec) } return deviceSpecs, nil @@ -60,7 +57,7 @@ func (l *nvmllib) GetMIGDeviceEdits(parent device.Device, mig device.MigDevice) return nil, fmt.Errorf("failed to create device discoverer: %v", err) } - editsForDevice, err := edits.FromDiscoverer(deviceNodes) + editsForDevice, err := (*nvcdilib)(l).editsFromDiscoverer(deviceNodes) if err != nil { return nil, fmt.Errorf("failed to create container edits for Compute Instance: %v", err) } diff --git a/pkg/nvcdi/mofed.go b/pkg/nvcdi/mofed.go index 9f45cfc9..2b40fd9f 100644 --- a/pkg/nvcdi/mofed.go +++ b/pkg/nvcdi/mofed.go @@ -38,22 +38,19 @@ func (l *mofedlib) GetAllDeviceSpecs() ([]specs.Device, error) { if err != nil { return nil, fmt.Errorf("failed to create MOFED discoverer: %v", err) } - edits, err := edits.FromDiscoverer(discoverer) + e, err := (*nvcdilib)(l).editsFromDiscoverer(discoverer) if err != nil { return nil, fmt.Errorf("failed to create container edits for MOFED devices: %v", err) } - deviceSpec := specs.Device{ - Name: "all", - ContainerEdits: *edits.ContainerEdits, - } + deviceSpec := edits.NewResource("all", e) return []specs.Device{deviceSpec}, nil } // GetCommonEdits generates a CDI specification that can be used for ANY devices func (l *mofedlib) GetCommonEdits() (*cdi.ContainerEdits, error) { - return edits.FromDiscoverer(discover.None{}) + return edits.NewContainerEdits(), nil } // GetSpec is unsppported for the mofedlib specs. diff --git a/pkg/nvcdi/transform/merged-device.go b/pkg/nvcdi/transform/merged-device.go index 523876e3..b7f992ee 100644 --- a/pkg/nvcdi/transform/merged-device.go +++ b/pkg/nvcdi/transform/merged-device.go @@ -109,7 +109,6 @@ func mergeDeviceSpecs(deviceSpecs []specs.Device, mergedDeviceName string) (*spe } mergedEdits := edits.NewContainerEdits() - for _, d := range deviceSpecs { d := d edit := cdi.ContainerEdits{ @@ -118,9 +117,6 @@ func mergeDeviceSpecs(deviceSpecs []specs.Device, mergedDeviceName string) (*spe mergedEdits.Append(&edit) } - merged := specs.Device{ - Name: mergedDeviceName, - ContainerEdits: *mergedEdits.ContainerEdits, - } + merged := edits.NewResource(mergedDeviceName, mergedEdits) return &merged, nil }