mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
Passing --device-map to cuda-checkpoint isn't implemented yet, but see comment in runsc/boot/nvproxy.go:getNvproxyDeviceRemapIDs(). PiperOrigin-RevId: 937715462
545 lines
18 KiB
Go
545 lines
18 KiB
Go
// Copyright 2025 The gVisor Authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package specutils
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
specs "github.com/opencontainers/runtime-spec/specs-go"
|
|
"gvisor.dev/gvisor/pkg/log"
|
|
"gvisor.dev/gvisor/runsc/config"
|
|
)
|
|
|
|
func validateErrorWithMsg(field, cName string, oldV, newV any, msg string) error {
|
|
return fmt.Errorf("%q does not match across checkpoint restore for container: %q, checkpoint %#v restore %#v, got error %v", field, cName, oldV, newV, msg)
|
|
}
|
|
|
|
func validateError(field, cName string, oldV, newV any) error {
|
|
return fmt.Errorf("%q does not match across checkpoint restore for container: %q, checkpoint %#v restore %#v", field, cName, oldV, newV)
|
|
}
|
|
|
|
func cloneMount(mnt specs.Mount) specs.Mount {
|
|
cloneMnt := specs.Mount{
|
|
Source: mnt.Source,
|
|
Destination: mnt.Destination,
|
|
Type: mnt.Type,
|
|
}
|
|
cloneMnt.Options = make([]string, len(mnt.Options))
|
|
copy(cloneMnt.Options, mnt.Options)
|
|
sort.Strings(cloneMnt.Options)
|
|
cloneMnt.UIDMappings = make([]specs.LinuxIDMapping, len(mnt.UIDMappings))
|
|
copy(cloneMnt.UIDMappings, mnt.UIDMappings)
|
|
cloneMnt.GIDMappings = make([]specs.LinuxIDMapping, len(mnt.GIDMappings))
|
|
copy(cloneMnt.GIDMappings, mnt.GIDMappings)
|
|
return cloneMnt
|
|
}
|
|
|
|
// validateMounts validates the mounts in the checkpoint and restore spec.
|
|
// Duplicate mounts are allowed iff all the fields in the mount are same.
|
|
func validateMounts(field, cName string, o, n []specs.Mount) error {
|
|
// Create a new mount map without source as source path can vary
|
|
// across checkpoint restore.
|
|
oldMnts := make(map[string]specs.Mount)
|
|
for _, m := range o {
|
|
oldMnts[m.Destination] = cloneMount(m)
|
|
}
|
|
newMnts := make(map[string]specs.Mount)
|
|
for _, m := range n {
|
|
mnt := cloneMount(m)
|
|
oldMnt, ok := oldMnts[mnt.Destination]
|
|
if !ok {
|
|
return validateError(field, cName, o, n)
|
|
}
|
|
|
|
// Duplicate mounts are allowed iff all fields in specs.Mount are same.
|
|
if val, ok := newMnts[mnt.Destination]; ok {
|
|
if !reflect.DeepEqual(val, mnt) {
|
|
return validateErrorWithMsg(field, cName, o, n, "invalid mount in the restore spec")
|
|
}
|
|
continue
|
|
}
|
|
newMnts[mnt.Destination] = mnt
|
|
|
|
if err := validateArray(field, cName, oldMnt.UIDMappings, mnt.UIDMappings); err != nil {
|
|
return validateError(field, cName, o, n)
|
|
}
|
|
oldMnt.UIDMappings, mnt.UIDMappings = []specs.LinuxIDMapping{}, []specs.LinuxIDMapping{}
|
|
if err := validateArray(field, cName, oldMnt.GIDMappings, mnt.GIDMappings); err != nil {
|
|
return validateError(field, cName, o, n)
|
|
}
|
|
oldMnt.GIDMappings, mnt.GIDMappings = []specs.LinuxIDMapping{}, []specs.LinuxIDMapping{}
|
|
|
|
oldMnt.Source, mnt.Source = "", ""
|
|
if !reflect.DeepEqual(oldMnt, mnt) {
|
|
return validateError(field, cName, o, n)
|
|
}
|
|
}
|
|
if len(oldMnts) != len(newMnts) {
|
|
return validateError(field, cName, o, n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDevices(field, cName string, o, n []specs.LinuxDevice) error {
|
|
var err error
|
|
o, n, err = removeNvproxyFrontendDevices(field, cName, o, n)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(o) != len(n) {
|
|
return validateErrorWithMsg(field, cName, o, n, "length mismatch")
|
|
}
|
|
if len(o) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Create with only Path and Type fields as other fields can vary during restore.
|
|
devs := make(map[specs.LinuxDevice]struct{})
|
|
for _, d := range o {
|
|
dev := specs.LinuxDevice{
|
|
Path: d.Path,
|
|
Type: d.Type,
|
|
}
|
|
if _, ok := devs[dev]; ok {
|
|
return fmt.Errorf("duplicate device found in the spec %v before checkpoint for container %v", o, cName)
|
|
}
|
|
devs[dev] = struct{}{}
|
|
}
|
|
for _, d := range n {
|
|
dev := specs.LinuxDevice{
|
|
Path: d.Path,
|
|
Type: d.Type,
|
|
}
|
|
if _, ok := devs[dev]; !ok {
|
|
return validateError(field, cName, o, n)
|
|
}
|
|
delete(devs, dev)
|
|
}
|
|
if len(devs) != 0 {
|
|
return validateError(field, cName, o, n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// These annotations are allowed to be changed during restore.
|
|
var allowedRestoreFlagAnnotations = []string{
|
|
annotationFlagPrefix + "debug",
|
|
annotationFlagPrefix + "debug-log",
|
|
annotationFlagPrefix + "debug-command",
|
|
annotationFlagPrefix + "debug-log-format",
|
|
annotationFlagPrefix + "strace",
|
|
annotationFlagPrefix + "strace-syscalls",
|
|
annotationFlagPrefix + "strace-log-size",
|
|
annotationFlagPrefix + "log-packets",
|
|
}
|
|
|
|
func shouldValidateAnnotation(key string) bool {
|
|
const (
|
|
gvisorPrefix = "dev.gvisor."
|
|
internalPrefix = "dev.gvisor.internal."
|
|
mntPrefix = "dev.gvisor.spec.mount."
|
|
)
|
|
// Only validate gVisor annotations.
|
|
if !strings.HasPrefix(key, gvisorPrefix) {
|
|
return false
|
|
}
|
|
// Do not validate internal annotations. They might container
|
|
// checkpoint/restore specific information which ought to change.
|
|
if strings.HasPrefix(key, internalPrefix) {
|
|
return false
|
|
}
|
|
// Do not validate container name remap annotations. These are only set
|
|
// during restore.
|
|
if strings.HasPrefix(key, annotationContainerNameRemap) {
|
|
return false
|
|
}
|
|
// The source of a mount can change during restore.
|
|
if strings.HasPrefix(key, mntPrefix) && strings.HasSuffix(key, ".source") {
|
|
return false
|
|
}
|
|
// Flag annotations controlling debug logging can change. They don't impact
|
|
// the restorability of the snapshot.
|
|
if strings.HasPrefix(key, annotationFlagPrefix) {
|
|
for _, allowed := range allowedRestoreFlagAnnotations {
|
|
if key == allowed {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func extractAnnotationsToValidate(o map[string]string) map[string]string {
|
|
n := make(map[string]string)
|
|
for key, val := range o {
|
|
if shouldValidateAnnotation(key) {
|
|
n[key] = val
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func validateAnnotations(cName string, before, after map[string]string) error {
|
|
oldM := extractAnnotationsToValidate(before)
|
|
newM := extractAnnotationsToValidate(after)
|
|
if !reflect.DeepEqual(oldM, newM) {
|
|
return validateError("Annotations", cName, oldM, newM)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateArray performs a deep comparison of two arrays, checking for equality
|
|
// at every level of nesting. Note that this method:
|
|
// * does not allow duplicates in the arrays.
|
|
// * does not depend on the order of the elements in the arrays.
|
|
func validateArray[T any](field, cName string, oldArr, newArr []T) error {
|
|
if len(oldArr) != len(newArr) {
|
|
return validateErrorWithMsg(field, cName, oldArr, newArr, "length mismatch")
|
|
}
|
|
if len(oldArr) == 0 {
|
|
return nil
|
|
}
|
|
oldMap := make(map[any]struct{})
|
|
newMap := make(map[any]struct{})
|
|
for i := 0; i < len(oldArr); i++ {
|
|
key := oldArr[i]
|
|
if _, ok := oldMap[key]; ok {
|
|
return validateErrorWithMsg(field, cName, oldArr, newArr, "duplicate value")
|
|
}
|
|
oldMap[key] = struct{}{}
|
|
|
|
key = newArr[i]
|
|
if _, ok := newMap[key]; ok {
|
|
return validateErrorWithMsg(field, cName, oldArr, newArr, "duplicate value")
|
|
}
|
|
newMap[key] = struct{}{}
|
|
}
|
|
if !reflect.DeepEqual(oldMap, newMap) {
|
|
return validateError(field, cName, oldArr, newArr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateMap[K comparable, V comparable](field, cName string, oldM map[K]V, newM map[K]V) error {
|
|
if len(oldM) != len(newM) {
|
|
return validateError(field, cName, oldM, newM)
|
|
}
|
|
for k, v1 := range oldM {
|
|
v2, ok := newM[k]
|
|
if !ok || v1 != v2 {
|
|
return validateError(field, cName, oldM, newM)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCapabilities(field, cName string, oldCaps, newCaps *specs.LinuxCapabilities) error {
|
|
if oldCaps == nil && newCaps == nil {
|
|
return nil
|
|
}
|
|
if oldCaps == nil || newCaps == nil {
|
|
return validateError(field, cName, oldCaps, newCaps)
|
|
}
|
|
|
|
if err := validateArray(field+".Bounding", cName, oldCaps.Bounding, newCaps.Bounding); err != nil {
|
|
return err
|
|
}
|
|
if err := validateArray(field+".Effective", cName, oldCaps.Effective, newCaps.Effective); err != nil {
|
|
return err
|
|
}
|
|
if err := validateArray(field+".Inheritable", cName, oldCaps.Inheritable, newCaps.Inheritable); err != nil {
|
|
return err
|
|
}
|
|
if err := validateArray(field+".Permitted", cName, oldCaps.Permitted, newCaps.Permitted); err != nil {
|
|
return err
|
|
}
|
|
if err := validateArray(field+".Ambient", cName, oldCaps.Ambient, newCaps.Ambient); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateResources(field, cName string, oldR, newR *specs.LinuxResources) error {
|
|
if oldR == nil && newR == nil {
|
|
return nil
|
|
}
|
|
if oldR == nil || newR == nil {
|
|
return validateError(field, cName, oldR, newR)
|
|
}
|
|
before := *oldR
|
|
after := *newR
|
|
if err := validateArray(field+".HugepageLimits", cName, before.HugepageLimits, after.HugepageLimits); err != nil {
|
|
return validateError(field+".HugepageLimits", cName, oldR, newR)
|
|
}
|
|
before.HugepageLimits, after.HugepageLimits = nil, nil
|
|
|
|
// LinuxResources.Devices is not used in gVisor, also the major and minor
|
|
// versions of the devices can change across checkpoint restore. Mark them
|
|
// to nil as there is no need to validate each device.
|
|
before.Devices, after.Devices = nil, nil
|
|
|
|
if err := validateMap(field+".Rdma", cName, before.Rdma, after.Rdma); err != nil {
|
|
return err
|
|
}
|
|
before.Rdma, after.Rdma = nil, nil
|
|
if err := validateMap(field+".Unified", cName, before.Unified, after.Unified); err != nil {
|
|
return err
|
|
}
|
|
before.Unified, after.Unified = nil, nil
|
|
|
|
if !reflect.DeepEqual(before, after) {
|
|
return validateError(field, cName, oldR, newR)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyNamespaceArr(namespaceArr []specs.LinuxNamespace) []specs.LinuxNamespace {
|
|
arr := make([]specs.LinuxNamespace, 0, len(namespaceArr))
|
|
for _, n := range namespaceArr {
|
|
// Namespace path can change during restore.
|
|
arr = append(arr, specs.LinuxNamespace{Type: n.Type})
|
|
}
|
|
return arr
|
|
}
|
|
|
|
func validateNamespaces(field, cName string, oldN, newN []specs.LinuxNamespace) error {
|
|
oldArr := copyNamespaceArr(oldN)
|
|
newArr := copyNamespaceArr(newN)
|
|
return validateArray(field, cName, oldArr, newArr)
|
|
}
|
|
|
|
func validateStruct(field, cName string, oldS, newS any) error {
|
|
if !reflect.DeepEqual(oldS, newS) {
|
|
return validateError(field, cName, oldS, newS)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ifNil[T any](v *T) *T {
|
|
if v != nil {
|
|
return v
|
|
}
|
|
var t T
|
|
return &t
|
|
}
|
|
|
|
// TODO(b/397790973): Check why the exec path gets resolved inconsistently. We
|
|
// are not resolving any paths in the spec, maybe this is happening at a higher
|
|
// layer. Ideally we should not be resolving any paths, this is a
|
|
// workaround fix.
|
|
func validateArgs(oldArgs, newArgs []string, cName string) error {
|
|
if len(oldArgs) != len(newArgs) {
|
|
return validateError("Args", cName, oldArgs, newArgs)
|
|
}
|
|
|
|
if len(oldArgs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if oldArgs[0] != newArgs[0] {
|
|
oldExecName := filepath.Base(oldArgs[0])
|
|
newExecName := filepath.Base(newArgs[0])
|
|
if oldExecName != newExecName {
|
|
return validateError("Args", cName, oldArgs, newArgs)
|
|
}
|
|
log.Warningf("Validating args with the exec paths, old: %v new: %v", oldArgs[0], newArgs[0])
|
|
}
|
|
|
|
if !slices.Equal(oldArgs[1:], newArgs[1:]) {
|
|
return validateError("Args", cName, oldArgs, newArgs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSpecForContainer(oSpec, nSpec *specs.Spec, cName string) error {
|
|
oldSpec := *oSpec
|
|
newSpec := *nSpec
|
|
|
|
// Validate OCI version.
|
|
if oldSpec.Version != newSpec.Version {
|
|
return validateError("OCI Version", cName, oldSpec.Version, newSpec.Version)
|
|
}
|
|
oldSpec.Version, newSpec.Version = "", ""
|
|
|
|
// Validate specs.Spec.Root. Note that Root.Path can change during restore.
|
|
oldSpec.Root, newSpec.Root = ifNil(oldSpec.Root), ifNil(newSpec.Root)
|
|
oldRoot, newRoot := *oldSpec.Root, *newSpec.Root
|
|
if oldRoot.Readonly != newRoot.Readonly {
|
|
return validateError("Root.Readonly", cName, oldRoot.Readonly, newRoot.Readonly)
|
|
}
|
|
oldSpec.Root.Path, newSpec.Root.Path = "", ""
|
|
|
|
// Validate specs.Spec.Mounts.
|
|
if err := validateMounts("Mounts", cName, oldSpec.Mounts, newSpec.Mounts); err != nil {
|
|
return err
|
|
}
|
|
oldSpec.Mounts, newSpec.Mounts = nil, nil
|
|
|
|
// Validate specs.Annotations.
|
|
if err := validateAnnotations(cName, oldSpec.Annotations, newSpec.Annotations); err != nil {
|
|
return err
|
|
}
|
|
oldSpec.Annotations, newSpec.Annotations = nil, nil
|
|
|
|
// Validate specs.Process.
|
|
oldSpec.Process, newSpec.Process = ifNil(oldSpec.Process), ifNil(newSpec.Process)
|
|
oldProcess, newProcess := *oldSpec.Process, *newSpec.Process
|
|
if oldProcess.Terminal != newProcess.Terminal {
|
|
return validateError("Terminal", cName, oldProcess.Terminal, newProcess.Terminal)
|
|
}
|
|
if oldProcess.Cwd != newProcess.Cwd {
|
|
return validateError("Cwd", cName, oldProcess.Cwd, newProcess.Cwd)
|
|
}
|
|
if err := validateStruct("User", cName, oldProcess.User, newProcess.User); err != nil {
|
|
return err
|
|
}
|
|
oldProcess.User, newProcess.User = specs.User{}, specs.User{}
|
|
if err := validateArray("Rlimits", cName, oldProcess.Rlimits, newProcess.Rlimits); err != nil {
|
|
return err
|
|
}
|
|
oldProcess.Rlimits, newProcess.Rlimits = nil, nil
|
|
if err := validateArgs(oldProcess.Args, newProcess.Args, cName); err != nil {
|
|
return err
|
|
}
|
|
oldProcess.Args, newProcess.Args = nil, nil
|
|
if err := validateCapabilities("Capabilities", cName, oldProcess.Capabilities, newProcess.Capabilities); err != nil {
|
|
return err
|
|
}
|
|
oldProcess.Capabilities, newProcess.Capabilities = nil, nil
|
|
|
|
// Validate specs.Linux.
|
|
oldSpec.Linux, newSpec.Linux = ifNil(oldSpec.Linux), ifNil(newSpec.Linux)
|
|
oldLinux, newLinux := *oldSpec.Linux, *newSpec.Linux
|
|
if err := validateMap("Sysctl", cName, oldLinux.Sysctl, newLinux.Sysctl); err != nil {
|
|
return err
|
|
}
|
|
oldLinux.Sysctl, newLinux.Sysctl = nil, nil
|
|
if err := validateStruct("Seccomp", cName, oldLinux.Seccomp, newLinux.Seccomp); err != nil {
|
|
return err
|
|
}
|
|
oldLinux.Seccomp, newLinux.Seccomp = nil, nil
|
|
if err := validateDevices("Devices", cName, oldLinux.Devices, newLinux.Devices); err != nil {
|
|
return err
|
|
}
|
|
oldLinux.Devices, newLinux.Devices = nil, nil
|
|
if err := validateResources("Resources", cName, oldLinux.Resources, newLinux.Resources); err != nil {
|
|
// Resource limits can be changed during restore, log a warning and do not
|
|
// return error.
|
|
log.Warningf("specs.Linux.Resources has been changed during restore, err %v", err)
|
|
}
|
|
oldLinux.Resources, newLinux.Resources = nil, nil
|
|
if err := validateArray("UIDMappings", cName, oldLinux.UIDMappings, newLinux.UIDMappings); err != nil {
|
|
return err
|
|
}
|
|
oldLinux.UIDMappings, newLinux.UIDMappings = nil, nil
|
|
if err := validateArray("GIDMappings", cName, oldLinux.GIDMappings, newLinux.GIDMappings); err != nil {
|
|
return err
|
|
}
|
|
oldLinux.GIDMappings, newLinux.GIDMappings = nil, nil
|
|
if err := validateNamespaces("Namespace", cName, oldLinux.Namespaces, newLinux.Namespaces); err != nil {
|
|
return err
|
|
}
|
|
oldLinux.Namespaces, newLinux.Namespaces = nil, nil
|
|
|
|
// Hostname, Domainname, Environment variables and CgroupsPath are
|
|
// allowed to change during restore. Hooks contain callbacks for
|
|
// lifecycle of the container such as prestart and teardown, and can
|
|
// change. Do not validate these fields.
|
|
oldSpec.Hostname, newSpec.Hostname = "", ""
|
|
oldSpec.Domainname, newSpec.Domainname = "", ""
|
|
oldProcess.Env, newProcess.Env = nil, nil
|
|
oldLinux.CgroupsPath, newLinux.CgroupsPath = "", ""
|
|
oldSpec.Hooks, newSpec.Hooks = nil, nil
|
|
|
|
// Validate remaining fields of specs.Process.
|
|
if ok := reflect.DeepEqual(oldProcess, newProcess); !ok {
|
|
return validateError("Process", cName, oldProcess, newProcess)
|
|
}
|
|
oldSpec.Process, newSpec.Process = nil, nil
|
|
|
|
// Validate remaining fields of specs.Linux.
|
|
if ok := reflect.DeepEqual(oldLinux, newLinux); !ok {
|
|
return validateError("Linux", cName, oldLinux, newLinux)
|
|
}
|
|
oldSpec.Linux, newSpec.Linux = nil, nil
|
|
|
|
if ok := reflect.DeepEqual(oldSpec, newSpec); !ok {
|
|
return validateError("Spec", cName, oldSpec, newSpec)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate OCI specs before restoring the containers.
|
|
func validateSpecs(oldSpecs, newSpecs map[string]*specs.Spec) error {
|
|
for cName, newSpec := range newSpecs {
|
|
oldSpec, ok := oldSpecs[cName]
|
|
if !ok {
|
|
return fmt.Errorf("checkpoint image does not contain spec for container: %q", cName)
|
|
}
|
|
if err := validateSpecForContainer(oldSpec, newSpec, cName); err != nil {
|
|
return fmt.Errorf("failed to validate spec for container %q: %w", cName, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RestoreValidateSpec deals with spec validation according to the given policy during restore.
|
|
func RestoreValidateSpec(oldSpecs, newSpecs map[string]*specs.Spec, conf *config.Config) error {
|
|
switch conf.RestoreSpecValidation {
|
|
case config.RestoreSpecValidationIgnore:
|
|
return nil
|
|
case config.RestoreSpecValidationWarning:
|
|
// Log a warning if the spec validation fails.
|
|
if err := validateSpecs(oldSpecs, newSpecs); err != nil {
|
|
log.Warningf("Failed to validate restore spec (ignoring error as per configuration): %v", err)
|
|
}
|
|
case config.RestoreSpecValidationEnforce:
|
|
// Restoring containers will be aborted if spec validation fails.
|
|
if err := validateSpecs(oldSpecs, newSpecs); err != nil {
|
|
return fmt.Errorf("failed to validate restore spec: %w", err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("invalid option for restore spec validation %d", conf.RestoreSpecValidation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ConvertSpecsToString returns the map of container specs in string format.
|
|
func ConvertSpecsToString(specs map[string]*specs.Spec) (string, error) {
|
|
jsonData, err := json.Marshal(specs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("json marshal error to convert container specs map to string, %v", err)
|
|
}
|
|
return string(jsonData), nil
|
|
}
|
|
|
|
// GetSpecsFromString returns the map of container specs from string.
|
|
func GetSpecsFromString(checkpointedSpecs string) (map[string]*specs.Spec, error) {
|
|
var containerSpecs map[string]*specs.Spec
|
|
err := json.Unmarshal([]byte(checkpointedSpecs), &containerSpecs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("json unmarshal error to get container specs map, %v", err)
|
|
}
|
|
return containerSpecs, nil
|
|
}
|