diff --git a/biz/docker/compose/convert.go b/biz/docker/compose/convert.go index 988b4a4..e1c59fb 100644 --- a/biz/docker/compose/convert.go +++ b/biz/docker/compose/convert.go @@ -4,6 +4,7 @@ import ( "io/ioutil" "strings" + composetypes "github.com/cuigh/swirl/biz/docker/compose/types" "github.com/docker/docker/api/types" networktypes "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/swarm" @@ -48,12 +49,12 @@ func AddStackLabel(namespace Namespace, labels map[string]string) map[string]str return labels } -type networkMap map[string]NetworkConfig +type networkMap map[string]composetypes.NetworkConfig // Networks from the compose-file type to the engine API type func Networks(namespace Namespace, networks networkMap, servicesNetworks map[string]struct{}) (map[string]types.NetworkCreate, []string) { if networks == nil { - networks = make(map[string]NetworkConfig) + networks = make(map[string]composetypes.NetworkConfig) } externalNetworks := []string{} @@ -61,7 +62,7 @@ func Networks(namespace Namespace, networks networkMap, servicesNetworks map[str for internalName := range servicesNetworks { network := networks[internalName] if network.External.External { - externalNetworks = append(externalNetworks, network.External.Name) + externalNetworks = append(externalNetworks, network.Name) continue } @@ -86,38 +87,54 @@ func Networks(namespace Namespace, networks networkMap, servicesNetworks map[str } createOpts.IPAM.Config = append(createOpts.IPAM.Config, config) } - result[internalName] = createOpts + + networkName := namespace.Scope(internalName) + if network.Name != "" { + networkName = network.Name + } + result[networkName] = createOpts } return result, externalNetworks } // Secrets converts secrets from the Compose type to the engine API type -func Secrets(namespace Namespace, secrets map[string]SecretConfig) ([]swarm.SecretSpec, error) { +func Secrets(namespace Namespace, secrets map[string]composetypes.SecretConfig) ([]swarm.SecretSpec, error) { result := []swarm.SecretSpec{} for name, secret := range secrets { if secret.External.External { continue } - data, err := ioutil.ReadFile(secret.File) + var obj swarmFileObject + var err error + if secret.Driver != "" { + obj, err = driverObjectConfig(namespace, name, composetypes.FileObjectConfig(secret)) + } else { + obj, err = fileObjectConfig(namespace, name, composetypes.FileObjectConfig(secret)) + } if err != nil { return nil, err } - - result = append(result, swarm.SecretSpec{ - Annotations: swarm.Annotations{ - Name: namespace.Scope(name), - Labels: AddStackLabel(namespace, secret.Labels), - }, - Data: data, - }) + spec := swarm.SecretSpec{Annotations: obj.Annotations, Data: obj.Data} + if secret.Driver != "" { + spec.Driver = &swarm.Driver{ + Name: secret.Driver, + Options: secret.DriverOpts, + } + } + if secret.TemplateDriver != "" { + spec.Templating = &swarm.Driver{ + Name: secret.TemplateDriver, + } + } + result = append(result, spec) } return result, nil } // Configs converts config objects from the Compose type to the engine API type -func Configs(namespace Namespace, configs map[string]ConfigObjConfig) ([]swarm.ConfigSpec, error) { +func Configs(namespace Namespace, configs map[string]composetypes.ConfigObjConfig) ([]swarm.ConfigSpec, error) { result := []swarm.ConfigSpec{} for name, config := range configs { if config.External.External { @@ -139,3 +156,45 @@ func Configs(namespace Namespace, configs map[string]ConfigObjConfig) ([]swarm.C } return result, nil } + +type swarmFileObject struct { + Annotations swarm.Annotations + Data []byte +} + +func driverObjectConfig(namespace Namespace, name string, obj composetypes.FileObjectConfig) (swarmFileObject, error) { + if obj.Name != "" { + name = obj.Name + } else { + name = namespace.Scope(name) + } + + return swarmFileObject{ + Annotations: swarm.Annotations{ + Name: name, + Labels: AddStackLabel(namespace, obj.Labels), + }, + Data: []byte{}, + }, nil +} + +func fileObjectConfig(namespace Namespace, name string, obj composetypes.FileObjectConfig) (swarmFileObject, error) { + data, err := ioutil.ReadFile(obj.File) + if err != nil { + return swarmFileObject{}, err + } + + if obj.Name != "" { + name = obj.Name + } else { + name = namespace.Scope(name) + } + + return swarmFileObject{ + Annotations: swarm.Annotations{ + Name: name, + Labels: AddStackLabel(namespace, obj.Labels), + }, + Data: data, + }, nil +} diff --git a/biz/docker/compose/convert_service.go b/biz/docker/compose/convert_service.go index 07b16b3..c491363 100644 --- a/biz/docker/compose/convert_service.go +++ b/biz/docker/compose/convert_service.go @@ -1,14 +1,14 @@ package compose import ( + "context" "fmt" "os" "sort" "strings" "time" - "context" - + composetypes "github.com/cuigh/swirl/biz/docker/compose/types" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" @@ -27,7 +27,7 @@ const ( // Services from compose-file types to engine API types func Services( namespace Namespace, - config *Config, + config *composetypes.Config, client *client.Client, ) (map[string]swarm.ServiceSpec, error) { result := make(map[string]swarm.ServiceSpec) @@ -41,7 +41,7 @@ func Services( if err != nil { return nil, errors.Wrapf(err, "service %s", service.Name) } - configs, err := convertServiceConfigObjs(client, namespace, service.Configs, config.Configs) + configs, err := convertServiceConfigObjs(client, namespace, service, config.Configs) if err != nil { return nil, errors.Wrapf(err, "service %s", service.Name) } @@ -60,9 +60,9 @@ func Services( func Service( apiVersion string, namespace Namespace, - service ServiceConfig, - networkConfigs map[string]NetworkConfig, - volumes map[string]VolumeConfig, + service composetypes.ServiceConfig, + networkConfigs map[string]composetypes.NetworkConfig, + volumes map[string]composetypes.VolumeConfig, secrets []*swarm.SecretReference, configs []*swarm.ConfigReference, ) (swarm.ServiceSpec, error) { @@ -110,7 +110,9 @@ func Service( } var privileges swarm.Privileges - privileges.CredentialSpec, err = convertCredentialSpec(namespace, service.CredentialSpec, configs) + privileges.CredentialSpec, err = convertCredentialSpec( + namespace, service.CredentialSpec, configs, + ) if err != nil { return swarm.ServiceSpec{}, err } @@ -134,7 +136,7 @@ func Service( Command: service.Entrypoint, Args: service.Command, Hostname: service.Hostname, - Hosts: sortStrings(convertExtraHosts(service.ExtraHosts)), + Hosts: convertExtraHosts(service.ExtraHosts), DNSConfig: dnsConfig, Healthcheck: healthcheck, Env: sortStrings(convertEnvironment(service.Environment)), @@ -142,7 +144,7 @@ func Service( Dir: service.WorkingDir, User: service.User, Mounts: mounts, - StopGracePeriod: service.StopGracePeriod, + StopGracePeriod: composetypes.ConvertDurationPtr(service.StopGracePeriod), StopSignal: service.StopSignal, TTY: service.Tty, OpenStdin: service.StdinOpen, @@ -150,6 +152,9 @@ func Service( Configs: configs, ReadOnly: service.ReadOnly, Privileges: &privileges, + Isolation: container.Isolation(service.Isolation), + Init: service.Init, + Sysctls: service.Sysctls, }, LogDriver: logDriver, Resources: resources, @@ -157,11 +162,13 @@ func Service( Placement: &swarm.Placement{ Constraints: service.Deploy.Placement.Constraints, Preferences: getPlacementPreference(service.Deploy.Placement.Preferences), + MaxReplicas: service.Deploy.Placement.MaxReplicas, }, }, EndpointSpec: endpoint, Mode: mode, UpdateConfig: convertUpdateConfig(service.Deploy.UpdateConfig), + RollbackConfig: convertUpdateConfig(service.Deploy.RollbackConfig), } // add an image label to serviceSpec @@ -182,7 +189,7 @@ func Service( return serviceSpec, nil } -func getPlacementPreference(preferences []PlacementPreferences) []swarm.PlacementPreference { +func getPlacementPreference(preferences []composetypes.PlacementPreferences) []swarm.PlacementPreference { result := []swarm.PlacementPreference{} for _, preference := range preferences { spreadDescriptor := preference.Spread @@ -200,20 +207,14 @@ func sortStrings(strs []string) []string { return strs } -type byNetworkTarget []swarm.NetworkAttachmentConfig - -func (a byNetworkTarget) Len() int { return len(a) } -func (a byNetworkTarget) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a byNetworkTarget) Less(i, j int) bool { return a[i].Target < a[j].Target } - func convertServiceNetworks( - networks map[string]*ServiceNetworkConfig, + networks map[string]*composetypes.ServiceNetworkConfig, networkConfigs networkMap, namespace Namespace, name string, ) ([]swarm.NetworkAttachmentConfig, error) { if len(networks) == 0 { - networks = map[string]*ServiceNetworkConfig{ + networks = map[string]*composetypes.ServiceNetworkConfig{ defaultNetwork: {}, } } @@ -229,8 +230,8 @@ func convertServiceNetworks( aliases = network.Aliases } target := namespace.Scope(networkName) - if networkConfig.External.External { - target = networkConfig.External.Name + if networkConfig.Name != "" { + target = networkConfig.Name } netAttachConfig := swarm.NetworkAttachmentConfig{ Target: target, @@ -244,7 +245,9 @@ func convertServiceNetworks( nets = append(nets, netAttachConfig) } - sort.Sort(byNetworkTarget(nets)) + sort.Slice(nets, func(i, j int) bool { + return nets[i].Target < nets[j].Target + }) return nets, nil } @@ -252,47 +255,28 @@ func convertServiceNetworks( func convertServiceSecrets( client *client.Client, namespace Namespace, - secrets []ServiceSecretConfig, - secretSpecs map[string]SecretConfig, + secrets []composetypes.ServiceSecretConfig, + secretSpecs map[string]composetypes.SecretConfig, ) ([]*swarm.SecretReference, error) { refs := []*swarm.SecretReference{} - for _, secret := range secrets { - target := secret.Target - if target == "" { - target = secret.Source - } - secretSpec, exists := secretSpecs[secret.Source] + lookup := func(key string) (composetypes.FileObjectConfig, error) { + secretSpec, exists := secretSpecs[key] if !exists { - return nil, errors.Errorf("undefined secret %q", secret.Source) - } - - source := namespace.Scope(secret.Source) - if secretSpec.External.External { - source = secretSpec.External.Name - } - - uid := secret.UID - gid := secret.GID - if uid == "" { - uid = "0" - } - if gid == "" { - gid = "0" - } - mode := secret.Mode - if mode == nil { - mode = uint32Ptr(0444) + return composetypes.FileObjectConfig{}, errors.Errorf("undefined secret %q", key) + } + return composetypes.FileObjectConfig(secretSpec), nil + } + for _, secret := range secrets { + obj, err := convertFileObject(namespace, composetypes.FileReferenceConfig(secret), lookup) + if err != nil { + return nil, err } + file := swarm.SecretReferenceFileTarget(obj.File) refs = append(refs, &swarm.SecretReference{ - File: &swarm.SecretReferenceFileTarget{ - Name: target, - UID: uid, - GID: gid, - Mode: os.FileMode(*mode), - }, - SecretName: source, + File: &file, + SecretName: obj.Name, }) } @@ -309,50 +293,63 @@ func convertServiceSecrets( func convertServiceConfigObjs( client *client.Client, namespace Namespace, - configs []ServiceConfigObjConfig, - configSpecs map[string]ConfigObjConfig, + service composetypes.ServiceConfig, + configSpecs map[string]composetypes.ConfigObjConfig, ) ([]*swarm.ConfigReference, error) { refs := []*swarm.ConfigReference{} - for _, config := range configs { - target := config.Target - if target == "" { - target = config.Source - } - configSpec, exists := configSpecs[config.Source] + lookup := func(key string) (composetypes.FileObjectConfig, error) { + configSpec, exists := configSpecs[key] if !exists { - return nil, errors.Errorf("undefined config %q", config.Source) - } - - source := namespace.Scope(config.Source) - if configSpec.External.External { - source = configSpec.External.Name - } - - uid := config.UID - gid := config.GID - if uid == "" { - uid = "0" - } - if gid == "" { - gid = "0" - } - mode := config.Mode - if mode == nil { - mode = uint32Ptr(0444) + return composetypes.FileObjectConfig{}, errors.Errorf("undefined config %q", key) + } + return composetypes.FileObjectConfig(configSpec), nil + } + for _, config := range service.Configs { + obj, err := convertFileObject(namespace, composetypes.FileReferenceConfig(config), lookup) + if err != nil { + return nil, err } + file := swarm.ConfigReferenceFileTarget(obj.File) refs = append(refs, &swarm.ConfigReference{ - File: &swarm.ConfigReferenceFileTarget{ - Name: target, - UID: uid, - GID: gid, - Mode: os.FileMode(*mode), - }, - ConfigName: source, + File: &file, + ConfigName: obj.Name, }) } + // finally, after converting all of the file objects, create any + // Runtime-type configs that are needed. these are configs that are not + // mounted into the container, but are used in some other way by the + // container runtime. Currently, this only means CredentialSpecs, but in + // the future it may be used for other fields + + // grab the CredentialSpec out of the Service + credSpec := service.CredentialSpec + // if the credSpec uses a config, then we should grab the config name, and + // create a config reference for it. A File or Registry-type CredentialSpec + // does not need this operation. + if credSpec.Config != "" { + // look up the config in the configSpecs. + obj, err := lookup(credSpec.Config) + if err != nil { + return nil, err + } + + // get the actual correct name. + name := namespace.Scope(credSpec.Config) + if obj.Name != "" { + name = obj.Name + } + + // now append a Runtime-type config. + refs = append(refs, &swarm.ConfigReference{ + ConfigName: name, + Runtime: &swarm.ConfigReferenceRuntimeTarget{}, + }) + + } + confs, err := ParseConfigs(client, refs) if err != nil { return nil, err @@ -362,24 +359,86 @@ func convertServiceConfigObjs( return confs, err } +type swarmReferenceTarget struct { + Name string + UID string + GID string + Mode os.FileMode +} + +type swarmReferenceObject struct { + File swarmReferenceTarget + ID string + Name string +} + +func convertFileObject( + namespace Namespace, + config composetypes.FileReferenceConfig, + lookup func(key string) (composetypes.FileObjectConfig, error), +) (swarmReferenceObject, error) { + obj, err := lookup(config.Source) + if err != nil { + return swarmReferenceObject{}, err + } + + source := namespace.Scope(config.Source) + if obj.Name != "" { + source = obj.Name + } + + target := config.Target + if target == "" { + target = config.Source + } + + uid := config.UID + gid := config.GID + if uid == "" { + uid = "0" + } + if gid == "" { + gid = "0" + } + mode := config.Mode + if mode == nil { + mode = uint32Ptr(0444) + } + + return swarmReferenceObject{ + File: swarmReferenceTarget{ + Name: target, + UID: uid, + GID: gid, + Mode: os.FileMode(*mode), + }, + Name: source, + }, nil +} + func uint32Ptr(value uint32) *uint32 { return &value } -func convertExtraHosts(extraHosts map[string]string) []string { +// convertExtraHosts converts : mappings to SwarmKit notation: +// "IP-address hostname(s)". The original order of mappings is preserved. +func convertExtraHosts(extraHosts composetypes.HostsList) []string { hosts := []string{} - for host, ip := range extraHosts { - hosts = append(hosts, fmt.Sprintf("%s %s", ip, host)) + for _, hostIP := range extraHosts { + if v := strings.SplitN(hostIP, ":", 2); len(v) == 2 { + // Convert to SwarmKit notation: IP-address hostname(s) + hosts = append(hosts, fmt.Sprintf("%s %s", v[1], v[0])) + } } return hosts } -func convertHealthcheck(healthcheck *HealthCheckConfig) (*container.HealthConfig, error) { +func convertHealthcheck(healthcheck *composetypes.HealthCheckConfig) (*container.HealthConfig, error) { if healthcheck == nil { return nil, nil } var ( - timeout, interval, startPeriod time.Duration + timeout, interval, startPeriod composetypes.Duration retries int ) if healthcheck.Disable { @@ -405,14 +464,14 @@ func convertHealthcheck(healthcheck *HealthCheckConfig) (*container.HealthConfig } return &container.HealthConfig{ Test: healthcheck.Test, - Timeout: timeout, - Interval: interval, + Timeout: time.Duration(timeout), + Interval: time.Duration(interval), Retries: retries, - StartPeriod: startPeriod, + StartPeriod: time.Duration(startPeriod), }, nil } -func convertRestartPolicy(restart string, source *RestartPolicy) (*swarm.RestartPolicy, error) { +func convertRestartPolicy(restart string, source *composetypes.RestartPolicy) (*swarm.RestartPolicy, error) { // TODO: log if restart is being ignored if source == nil { policy, err := ParseRestartPolicy(restart) @@ -438,13 +497,13 @@ func convertRestartPolicy(restart string, source *RestartPolicy) (*swarm.Restart } return &swarm.RestartPolicy{ Condition: swarm.RestartPolicyCondition(source.Condition), - Delay: source.Delay, + Delay: composetypes.ConvertDurationPtr(source.Delay), MaxAttempts: source.MaxAttempts, - Window: source.Window, + Window: composetypes.ConvertDurationPtr(source.Window), }, nil } -func convertUpdateConfig(source *UpdateConfig) *swarm.UpdateConfig { +func convertUpdateConfig(source *composetypes.UpdateConfig) *swarm.UpdateConfig { if source == nil { return nil } @@ -454,15 +513,15 @@ func convertUpdateConfig(source *UpdateConfig) *swarm.UpdateConfig { } return &swarm.UpdateConfig{ Parallelism: parallel, - Delay: source.Delay, + Delay: time.Duration(source.Delay), FailureAction: source.FailureAction, - Monitor: source.Monitor, + Monitor: time.Duration(source.Monitor), MaxFailureRatio: source.MaxFailureRatio, Order: source.Order, } } -func convertResources(source Resources) (*swarm.ResourceRequirements, error) { +func convertResources(source composetypes.Resources) (*swarm.ResourceRequirements, error) { resources := &swarm.ResourceRequirements{} var err error if source.Limits != nil { @@ -486,21 +545,31 @@ func convertResources(source Resources) (*swarm.ResourceRequirements, error) { return nil, err } } + + var generic []swarm.GenericResource + for _, res := range source.Reservations.GenericResources { + var r swarm.GenericResource + + if res.DiscreteResourceSpec != nil { + r.DiscreteResourceSpec = &swarm.DiscreteGenericResource{ + Kind: res.DiscreteResourceSpec.Kind, + Value: res.DiscreteResourceSpec.Value, + } + } + + generic = append(generic, r) + } + resources.Reservations = &swarm.Resources{ - NanoCPUs: cpus, - MemoryBytes: int64(source.Reservations.MemoryBytes), + NanoCPUs: cpus, + MemoryBytes: int64(source.Reservations.MemoryBytes), + GenericResources: generic, } } return resources, nil } -type byPublishedPort []swarm.PortConfig - -func (a byPublishedPort) Len() int { return len(a) } -func (a byPublishedPort) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a byPublishedPort) Less(i, j int) bool { return a[i].PublishedPort < a[j].PublishedPort } - -func convertEndpointSpec(endpointMode string, source []ServicePortConfig) (*swarm.EndpointSpec, error) { +func convertEndpointSpec(endpointMode string, source []composetypes.ServicePortConfig) (*swarm.EndpointSpec, error) { portConfigs := []swarm.PortConfig{} for _, port := range source { portConfig := swarm.PortConfig{ @@ -512,7 +581,10 @@ func convertEndpointSpec(endpointMode string, source []ServicePortConfig) (*swar portConfigs = append(portConfigs, portConfig) } - sort.Sort(byPublishedPort(portConfigs)) + sort.Slice(portConfigs, func(i, j int) bool { + return portConfigs[i].PublishedPort < portConfigs[j].PublishedPort + }) + return &swarm.EndpointSpec{ Mode: swarm.ResolutionMode(strings.ToLower(endpointMode)), Ports: portConfigs, @@ -561,7 +633,7 @@ func convertDNSConfig(DNS []string, DNSSearch []string) (*swarm.DNSConfig, error return nil, nil } -func convertCredentialSpec(namespace Namespace, spec CredentialSpecConfig, refs []*swarm.ConfigReference) (*swarm.CredentialSpec, error) { +func convertCredentialSpec(namespace Namespace, spec composetypes.CredentialSpecConfig, refs []*swarm.ConfigReference) (*swarm.CredentialSpec, error) { var o []string // Config was added in API v1.40 diff --git a/biz/docker/compose/convert_volume.go b/biz/docker/compose/convert_volume.go index 13c4462..c3d9f6f 100644 --- a/biz/docker/compose/convert_volume.go +++ b/biz/docker/compose/convert_volume.go @@ -1,16 +1,15 @@ package compose import ( - "errors" - "fmt" - + composetypes "github.com/cuigh/swirl/biz/docker/compose/types" "github.com/docker/docker/api/types/mount" + "github.com/pkg/errors" ) -type volumes map[string]VolumeConfig +type volumes map[string]composetypes.VolumeConfig // Volumes from compose-file types to engine api types -func Volumes(serviceVolumes []ServiceVolumeConfig, stackVolumes volumes, namespace Namespace) ([]mount.Mount, error) { +func Volumes(serviceVolumes []composetypes.ServiceVolumeConfig, stackVolumes volumes, namespace Namespace) ([]mount.Mount, error) { var mounts []mount.Mount for _, volumeConfig := range serviceVolumes { @@ -23,43 +22,37 @@ func Volumes(serviceVolumes []ServiceVolumeConfig, stackVolumes volumes, namespa return mounts, nil } -func convertVolumeToMount( - volume ServiceVolumeConfig, +func createMountFromVolume(volume composetypes.ServiceVolumeConfig) mount.Mount { + return mount.Mount{ + Type: mount.Type(volume.Type), + Target: volume.Target, + ReadOnly: volume.ReadOnly, + Source: volume.Source, + Consistency: mount.Consistency(volume.Consistency), + } +} + +func handleVolumeToMount( + volume composetypes.ServiceVolumeConfig, stackVolumes volumes, namespace Namespace, ) (mount.Mount, error) { - result := mount.Mount{ - Type: mount.Type(volume.Type), - Source: volume.Source, - Target: volume.Target, - ReadOnly: volume.ReadOnly, - Consistency: mount.Consistency(volume.Consistency), - } + result := createMountFromVolume(volume) + if volume.Tmpfs != nil { + return mount.Mount{}, errors.New("tmpfs options are incompatible with type volume") + } + if volume.Bind != nil { + return mount.Mount{}, errors.New("bind options are incompatible with type volume") + } // Anonymous volumes if volume.Source == "" { return result, nil } - if volume.Type == "volume" && volume.Bind != nil { - return result, errors.New("bind options are incompatible with type volume") - } - if volume.Type == "bind" && volume.Volume != nil { - return result, errors.New("volume options are incompatible with type bind") - } - - if volume.Bind != nil { - result.BindOptions = &mount.BindOptions{ - Propagation: mount.Propagation(volume.Bind.Propagation), - } - } - // Binds volumes - if volume.Type == "bind" { - return result, nil - } stackVolume, exists := stackVolumes[volume.Source] if !exists { - return result, fmt.Errorf("undefined volume %q", volume.Source) + return mount.Mount{}, errors.Errorf("undefined volume %q", volume.Source) } result.Source = namespace.Scope(volume.Source) @@ -69,16 +62,15 @@ func convertVolumeToMount( result.VolumeOptions.NoCopy = volume.Volume.NoCopy } - // External named volumes - if stackVolume.External.External { - result.Source = stackVolume.External.Name - return result, nil - } - if stackVolume.Name != "" { result.Source = stackVolume.Name } + // External named volumes + if stackVolume.External.External { + return result, nil + } + result.VolumeOptions.Labels = AddStackLabel(namespace, stackVolume.Labels) if stackVolume.Driver != "" || stackVolume.DriverOpts != nil { result.VolumeOptions.DriverConfig = &mount.Driver{ @@ -87,6 +79,84 @@ func convertVolumeToMount( } } - // Named volumes return result, nil } + +func handleBindToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, error) { + result := createMountFromVolume(volume) + + if volume.Source == "" { + return mount.Mount{}, errors.New("invalid bind source, source cannot be empty") + } + if volume.Volume != nil { + return mount.Mount{}, errors.New("volume options are incompatible with type bind") + } + if volume.Tmpfs != nil { + return mount.Mount{}, errors.New("tmpfs options are incompatible with type bind") + } + if volume.Bind != nil { + result.BindOptions = &mount.BindOptions{ + Propagation: mount.Propagation(volume.Bind.Propagation), + } + } + return result, nil +} + +func handleTmpfsToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, error) { + result := createMountFromVolume(volume) + + if volume.Source != "" { + return mount.Mount{}, errors.New("invalid tmpfs source, source must be empty") + } + if volume.Bind != nil { + return mount.Mount{}, errors.New("bind options are incompatible with type tmpfs") + } + if volume.Volume != nil { + return mount.Mount{}, errors.New("volume options are incompatible with type tmpfs") + } + if volume.Tmpfs != nil { + result.TmpfsOptions = &mount.TmpfsOptions{ + SizeBytes: volume.Tmpfs.Size, + } + } + return result, nil +} + +func handleNpipeToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, error) { + result := createMountFromVolume(volume) + + if volume.Source == "" { + return mount.Mount{}, errors.New("invalid npipe source, source cannot be empty") + } + if volume.Volume != nil { + return mount.Mount{}, errors.New("volume options are incompatible with type npipe") + } + if volume.Tmpfs != nil { + return mount.Mount{}, errors.New("tmpfs options are incompatible with type npipe") + } + if volume.Bind != nil { + result.BindOptions = &mount.BindOptions{ + Propagation: mount.Propagation(volume.Bind.Propagation), + } + } + return result, nil +} + +func convertVolumeToMount( + volume composetypes.ServiceVolumeConfig, + stackVolumes volumes, + namespace Namespace, +) (mount.Mount, error) { + + switch volume.Type { + case "volume", "": + return handleVolumeToMount(volume, stackVolumes, namespace) + case "bind": + return handleBindToMount(volume) + case "tmpfs": + return handleTmpfsToMount(volume) + case "npipe": + return handleNpipeToMount(volume) + } + return mount.Mount{}, errors.New("volume type must be volume, bind, tmpfs or npipe") +} diff --git a/biz/docker/compose/interpolate.go b/biz/docker/compose/interpolate.go new file mode 100644 index 0000000..1ef9c87 --- /dev/null +++ b/biz/docker/compose/interpolate.go @@ -0,0 +1,69 @@ +package compose + +import ( + "strconv" + "strings" + + interp "github.com/cuigh/swirl/biz/docker/compose/interpolation" + "github.com/pkg/errors" +) + +var interpolateTypeCastMapping = map[interp.Path]interp.Cast{ + servicePath("configs", interp.PathMatchList, "mode"): toInt, + servicePath("secrets", interp.PathMatchList, "mode"): toInt, + servicePath("healthcheck", "retries"): toInt, + servicePath("healthcheck", "disable"): toBoolean, + servicePath("deploy", "replicas"): toInt, + servicePath("deploy", "update_config", "parallelism"): toInt, + servicePath("deploy", "update_config", "max_failure_ratio"): toFloat, + servicePath("deploy", "restart_policy", "max_attempts"): toInt, + servicePath("ports", interp.PathMatchList, "target"): toInt, + servicePath("ports", interp.PathMatchList, "published"): toInt, + servicePath("ulimits", interp.PathMatchAll): toInt, + servicePath("ulimits", interp.PathMatchAll, "hard"): toInt, + servicePath("ulimits", interp.PathMatchAll, "soft"): toInt, + servicePath("privileged"): toBoolean, + servicePath("read_only"): toBoolean, + servicePath("stdin_open"): toBoolean, + servicePath("tty"): toBoolean, + servicePath("volumes", interp.PathMatchList, "read_only"): toBoolean, + servicePath("volumes", interp.PathMatchList, "volume", "nocopy"): toBoolean, + iPath("networks", interp.PathMatchAll, "external"): toBoolean, + iPath("networks", interp.PathMatchAll, "internal"): toBoolean, + iPath("networks", interp.PathMatchAll, "attachable"): toBoolean, + iPath("volumes", interp.PathMatchAll, "external"): toBoolean, + iPath("secrets", interp.PathMatchAll, "external"): toBoolean, + iPath("configs", interp.PathMatchAll, "external"): toBoolean, +} + +func iPath(parts ...string) interp.Path { + return interp.NewPath(parts...) +} + +func servicePath(parts ...string) interp.Path { + return iPath(append([]string{"services", interp.PathMatchAll}, parts...)...) +} + +func toInt(value string) (interface{}, error) { + return strconv.Atoi(value) +} + +func toFloat(value string) (interface{}, error) { + return strconv.ParseFloat(value, 64) +} + +// should match http://yaml.org/type/bool.html +func toBoolean(value string) (interface{}, error) { + switch strings.ToLower(value) { + case "y", "yes", "true", "on": + return true, nil + case "n", "no", "false", "off": + return false, nil + default: + return nil, errors.Errorf("invalid boolean: %s", value) + } +} + +func interpolateConfig(configDict map[string]interface{}, opts interp.Options) (map[string]interface{}, error) { + return interp.Interpolate(configDict, opts) +} diff --git a/biz/docker/compose/interpolation.go b/biz/docker/compose/interpolation.go deleted file mode 100644 index fd2ce48..0000000 --- a/biz/docker/compose/interpolation.go +++ /dev/null @@ -1,93 +0,0 @@ -package compose - -import "github.com/pkg/errors" - -// Interpolate replaces variables in a string with the values from a mapping -func Interpolate(config map[string]interface{}, section string, mapping Mapping) (map[string]interface{}, error) { - out := map[string]interface{}{} - - for name, item := range config { - if item == nil { - out[name] = nil - continue - } - mapItem, ok := item.(map[string]interface{}) - if !ok { - return nil, errors.Errorf("Invalid type for %s : %T instead of %T", name, item, out) - } - interpolatedItem, err := interpolateSectionItem(name, mapItem, section, mapping) - if err != nil { - return nil, err - } - out[name] = interpolatedItem - } - - return out, nil -} - -func interpolateSectionItem( - name string, - item map[string]interface{}, - section string, - mapping Mapping, -) (map[string]interface{}, error) { - - out := map[string]interface{}{} - - for key, value := range item { - interpolatedValue, err := recursiveInterpolate(value, mapping) - switch err := err.(type) { - case nil: - case *InvalidTemplateError: - return nil, errors.Errorf( - "Invalid interpolation format for %#v option in %s %#v: %#v. You may need to escape any $ with another $.", - key, section, name, err.Template, - ) - default: - return nil, errors.Wrapf(err, "error while interpolating %s in %s %s", key, section, name) - } - out[key] = interpolatedValue - } - - return out, nil - -} - -func recursiveInterpolate( - value interface{}, - mapping Mapping, -) (interface{}, error) { - - switch value := value.(type) { - - case string: - return Substitute(value, mapping) - - case map[string]interface{}: - out := map[string]interface{}{} - for key, elem := range value { - interpolatedElem, err := recursiveInterpolate(elem, mapping) - if err != nil { - return nil, err - } - out[key] = interpolatedElem - } - return out, nil - - case []interface{}: - out := make([]interface{}, len(value)) - for i, elem := range value { - interpolatedElem, err := recursiveInterpolate(elem, mapping) - if err != nil { - return nil, err - } - out[i] = interpolatedElem - } - return out, nil - - default: - return value, nil - - } - -} diff --git a/biz/docker/compose/interpolation/interpolation.go b/biz/docker/compose/interpolation/interpolation.go new file mode 100644 index 0000000..ae5a864 --- /dev/null +++ b/biz/docker/compose/interpolation/interpolation.go @@ -0,0 +1,163 @@ +package interpolation + +import ( + "os" + "strings" + + "github.com/cuigh/swirl/biz/docker/compose/template" + "github.com/pkg/errors" +) + +// Options supported by Interpolate +type Options struct { + // LookupValue from a key + LookupValue LookupValue + // TypeCastMapping maps key paths to functions to cast to a type + TypeCastMapping map[Path]Cast + // Substitution function to use + Substitute func(string, template.Mapping) (string, error) +} + +// LookupValue is a function which maps from variable names to values. +// Returns the value as a string and a bool indicating whether +// the value is present, to distinguish between an empty string +// and the absence of a value. +type LookupValue func(key string) (string, bool) + +// Cast a value to a new type, or return an error if the value can't be cast +type Cast func(value string) (interface{}, error) + +// Interpolate replaces variables in a string with the values from a mapping +func Interpolate(config map[string]interface{}, opts Options) (map[string]interface{}, error) { + if opts.LookupValue == nil { + opts.LookupValue = os.LookupEnv + } + if opts.TypeCastMapping == nil { + opts.TypeCastMapping = make(map[Path]Cast) + } + if opts.Substitute == nil { + opts.Substitute = template.Substitute + } + + out := map[string]interface{}{} + + for key, value := range config { + interpolatedValue, err := recursiveInterpolate(value, NewPath(key), opts) + if err != nil { + return out, err + } + out[key] = interpolatedValue + } + + return out, nil +} + +func recursiveInterpolate(value interface{}, path Path, opts Options) (interface{}, error) { + switch value := value.(type) { + + case string: + newValue, err := opts.Substitute(value, template.Mapping(opts.LookupValue)) + if err != nil || newValue == value { + return value, newPathError(path, err) + } + caster, ok := opts.getCasterForPath(path) + if !ok { + return newValue, nil + } + casted, err := caster(newValue) + return casted, newPathError(path, errors.Wrap(err, "failed to cast to expected type")) + + case map[string]interface{}: + out := map[string]interface{}{} + for key, elem := range value { + interpolatedElem, err := recursiveInterpolate(elem, path.Next(key), opts) + if err != nil { + return nil, err + } + out[key] = interpolatedElem + } + return out, nil + + case []interface{}: + out := make([]interface{}, len(value)) + for i, elem := range value { + interpolatedElem, err := recursiveInterpolate(elem, path.Next(PathMatchList), opts) + if err != nil { + return nil, err + } + out[i] = interpolatedElem + } + return out, nil + + default: + return value, nil + + } +} + +func newPathError(path Path, err error) error { + switch err := err.(type) { + case nil: + return nil + case *template.InvalidTemplateError: + return errors.Errorf( + "invalid interpolation format for %s: %#v. You may need to escape any $ with another $.", + path, err.Template) + default: + return errors.Wrapf(err, "error while interpolating %s", path) + } +} + +const pathSeparator = "." + +// PathMatchAll is a token used as part of a Path to match any key at that level +// in the nested structure +const PathMatchAll = "*" + +// PathMatchList is a token used as part of a Path to match items in a list +const PathMatchList = "[]" + +// Path is a dotted path of keys to a value in a nested mapping structure. A * +// section in a path will match any key in the mapping structure. +type Path string + +// NewPath returns a new Path +func NewPath(items ...string) Path { + return Path(strings.Join(items, pathSeparator)) +} + +// Next returns a new path by append part to the current path +func (p Path) Next(part string) Path { + return Path(string(p) + pathSeparator + part) +} + +func (p Path) parts() []string { + return strings.Split(string(p), pathSeparator) +} + +func (p Path) matches(pattern Path) bool { + patternParts := pattern.parts() + parts := p.parts() + + if len(patternParts) != len(parts) { + return false + } + for index, part := range parts { + switch patternParts[index] { + case PathMatchAll, part: + continue + default: + return false + } + } + return true +} + +func (o Options) getCasterForPath(path Path) (Cast, bool) { + for pattern, caster := range o.TypeCastMapping { + if path.matches(pattern) { + return caster, true + } + } + return nil, false +} diff --git a/biz/docker/compose/loader.go b/biz/docker/compose/loader.go index fb0a96f..ce9c2f2 100644 --- a/biz/docker/compose/loader.go +++ b/biz/docker/compose/loader.go @@ -1,22 +1,38 @@ package compose import ( - "errors" "fmt" "path" "path/filepath" "reflect" "sort" "strings" + "time" "github.com/cuigh/auxo/log" + interp "github.com/cuigh/swirl/biz/docker/compose/interpolation" + "github.com/cuigh/swirl/biz/docker/compose/schema" + "github.com/cuigh/swirl/biz/docker/compose/template" + composetypes "github.com/cuigh/swirl/biz/docker/compose/types" + "github.com/docker/docker/api/types/versions" "github.com/docker/go-connections/nat" - units "github.com/docker/go-units" - shellwords "github.com/mattn/go-shellwords" + "github.com/docker/go-units" + "github.com/mattn/go-shellwords" "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" "gopkg.in/yaml.v2" ) +// Options supported by Load +type Options struct { + // Skip schema validation + SkipValidation bool + // Skip interpolation + SkipInterpolation bool + // Interpolation options + Interpolate *interp.Options +} + // ParseYAML reads the bytes from a file, parses the bytes into a mapping // structure, and returns it. func ParseYAML(source []byte) (map[string]interface{}, error) { @@ -36,90 +52,152 @@ func ParseYAML(source []byte) (map[string]interface{}, error) { } // Load reads a ConfigDetails and returns a fully loaded configuration -func Load(configDetails ConfigDetails) (*Config, error) { +func Load(configDetails composetypes.ConfigDetails, options ...func(*Options)) (*composetypes.Config, error) { if len(configDetails.ConfigFiles) < 1 { - return nil, errors.New("No files specified") - } - if len(configDetails.ConfigFiles) > 1 { - return nil, errors.New("Multiple files are not yet supported") + return nil, errors.Errorf("No files specified") } - configDict := getConfigDict(configDetails) + opts := &Options{ + Interpolate: &interp.Options{ + Substitute: template.Substitute, + LookupValue: configDetails.LookupEnv, + TypeCastMapping: interpolateTypeCastMapping, + }, + } - if services, ok := configDict["services"]; ok { - if servicesDict, ok := services.(map[string]interface{}); ok { - forbidden := getProperties(servicesDict, ForbiddenProperties) + for _, op := range options { + op(opts) + } - if len(forbidden) > 0 { - return nil, &ForbiddenPropertiesError{Properties: forbidden} + configs := []*composetypes.Config{} + var err error + + for _, file := range configDetails.ConfigFiles { + configDict := file.Config + version := schema.Version(configDict) + if configDetails.Version == "" { + configDetails.Version = version + } + if configDetails.Version != version { + return nil, errors.Errorf("version mismatched between two composefiles : %v and %v", configDetails.Version, version) + } + + if err := validateForbidden(configDict); err != nil { + return nil, err + } + + if !opts.SkipInterpolation { + configDict, err = interpolateConfig(configDict, *opts.Interpolate) + if err != nil { + return nil, err } } - } - // todo: Add validation - //if err := schema.Validate(configDict, schema.Version(configDict)); err != nil { - // return nil, err - //} + //if !opts.SkipValidation { + // if err := schema.Validate(configDict, configDetails.Version); err != nil { + // return nil, err + // } + //} - cfg := Config{} - - config, err := interpolateConfig(configDict, configDetails.LookupEnv) - if err != nil { - return nil, err - } - - cfg.Services, err = LoadServices(config["services"], configDetails.WorkingDir, configDetails.LookupEnv) - if err != nil { - return nil, err - } - - cfg.Networks, err = LoadNetworks(config["networks"]) - if err != nil { - return nil, err - } - - cfg.Volumes, err = LoadVolumes(config["volumes"]) - if err != nil { - return nil, err - } - - cfg.Secrets, err = LoadSecrets(config["secrets"], configDetails.WorkingDir) - if err != nil { - return nil, err - } - - cfg.Configs, err = LoadConfigObjs(config["configs"], configDetails.WorkingDir) - return &cfg, err -} - -func interpolateConfig(configDict map[string]interface{}, lookupEnv Mapping) (map[string]map[string]interface{}, error) { - config := make(map[string]map[string]interface{}) - - for _, key := range []string{"services", "networks", "volumes", "secrets", "configs"} { - section, ok := configDict[key] - if !ok { - config[key] = make(map[string]interface{}) - continue - } - var err error - config[key], err = Interpolate(section.(map[string]interface{}), key, lookupEnv) + cfg, err := loadSections(configDict, configDetails) if err != nil { return nil, err } + cfg.Filename = file.Filename + + configs = append(configs, cfg) } - return config, nil + + return merge(configs) +} + +func validateForbidden(configDict map[string]interface{}) error { + servicesDict, ok := configDict["services"].(map[string]interface{}) + if !ok { + return nil + } + forbidden := getProperties(servicesDict, composetypes.ForbiddenProperties) + if len(forbidden) > 0 { + return &ForbiddenPropertiesError{Properties: forbidden} + } + return nil +} + +func loadSections(config map[string]interface{}, configDetails composetypes.ConfigDetails) (*composetypes.Config, error) { + var err error + cfg := composetypes.Config{ + Version: schema.Version(config), + } + + var loaders = []struct { + key string + fnc func(config map[string]interface{}) error + }{ + { + key: "services", + fnc: func(config map[string]interface{}) error { + cfg.Services, err = LoadServices(config, configDetails.WorkingDir, configDetails.LookupEnv) + return err + }, + }, + { + key: "networks", + fnc: func(config map[string]interface{}) error { + cfg.Networks, err = LoadNetworks(config, configDetails.Version) + return err + }, + }, + { + key: "volumes", + fnc: func(config map[string]interface{}) error { + cfg.Volumes, err = LoadVolumes(config, configDetails.Version) + return err + }, + }, + { + key: "secrets", + fnc: func(config map[string]interface{}) error { + cfg.Secrets, err = LoadSecrets(config, configDetails) + return err + }, + }, + { + key: "configs", + fnc: func(config map[string]interface{}) error { + cfg.Configs, err = LoadConfigObjs(config, configDetails) + return err + }, + }, + } + for _, loader := range loaders { + if err := loader.fnc(getSection(config, loader.key)); err != nil { + return nil, err + } + } + cfg.Extras = getExtras(config) + return &cfg, nil +} + +func getSection(config map[string]interface{}, key string) map[string]interface{} { + section, ok := config[key] + if !ok { + return make(map[string]interface{}) + } + return section.(map[string]interface{}) } // GetUnsupportedProperties returns the list of any unsupported properties that are // used in the Compose files. -func GetUnsupportedProperties(configDetails ConfigDetails) []string { +func GetUnsupportedProperties(configDicts ...map[string]interface{}) []string { unsupported := map[string]bool{} - for _, service := range getServices(getConfigDict(configDetails)) { - serviceDict := service.(map[string]interface{}) - for _, property := range UnsupportedProperties { - if _, isSet := serviceDict[property]; isSet { - unsupported[property] = true + for _, configDict := range configDicts { + for _, service := range getServices(configDict) { + serviceDict := service.(map[string]interface{}) + for _, property := range composetypes.UnsupportedProperties { + if _, isSet := serviceDict[property]; isSet { + unsupported[property] = true + } } } } @@ -138,8 +216,17 @@ func sortedKeys(set map[string]bool) []string { // GetDeprecatedProperties returns the list of any deprecated properties that // are used in the compose files. -func GetDeprecatedProperties(configDetails ConfigDetails) map[string]string { - return getProperties(getServices(getConfigDict(configDetails)), DeprecatedProperties) +func GetDeprecatedProperties(configDicts ...map[string]interface{}) map[string]string { + deprecated := map[string]string{} + + for _, configDict := range configDicts { + deprecatedProperties := getProperties(getServices(configDict), composetypes.DeprecatedProperties) + for key, value := range deprecatedProperties { + deprecated[key] = value + } + } + + return deprecated } func getProperties(services map[string]interface{}, propertyMap map[string]string) map[string]string { @@ -168,11 +255,6 @@ func (e *ForbiddenPropertiesError) Error() string { return "Configuration contains forbidden properties" } -// TODO: resolve multiple files into a single config -func getConfigDict(configDetails ConfigDetails) map[string]interface{} { - return configDetails.ConfigFiles[0].Config -} - func getServices(configDict map[string]interface{}) map[string]interface{} { if services, ok := configDict["services"]; ok { if servicesDict, ok := services.(map[string]interface{}); ok { @@ -183,11 +265,13 @@ func getServices(configDict map[string]interface{}) map[string]interface{} { return map[string]interface{}{} } -func transform(source map[string]interface{}, target interface{}) error { +// Transform converts the source into the target struct with compose types transformer +// and the specified transformers if any. +func Transform(source interface{}, target interface{}, additionalTransformers ...Transformer) error { data := mapstructure.Metadata{} config := &mapstructure.DecoderConfig{ DecodeHook: mapstructure.ComposeDecodeHookFunc( - createTransformHook(), + createTransformHook(additionalTransformers...), mapstructure.StringToTimeDurationHookFunc()), Result: target, Metadata: &data, @@ -199,25 +283,38 @@ func transform(source map[string]interface{}, target interface{}) error { return decoder.Decode(source) } -func createTransformHook() mapstructure.DecodeHookFuncType { +// Transformer defines a map to type transformer +type Transformer struct { + TypeOf reflect.Type + Func func(interface{}) (interface{}, error) +} + +func createTransformHook(additionalTransformers ...Transformer) mapstructure.DecodeHookFuncType { transforms := map[reflect.Type]func(interface{}) (interface{}, error){ - reflect.TypeOf(External{}): transformExternal, - reflect.TypeOf(HealthCheckTest{}): transformHealthCheckTest, - reflect.TypeOf(ShellCommand{}): transformShellCommand, - reflect.TypeOf(StringList{}): transformStringList, - reflect.TypeOf(map[string]string{}): transformMapStringString, - reflect.TypeOf(UlimitsConfig{}): transformUlimits, - reflect.TypeOf(UnitBytes(0)): transformSize, - reflect.TypeOf([]ServicePortConfig{}): transformServicePort, - reflect.TypeOf(ServiceSecretConfig{}): transformStringSourceMap, - reflect.TypeOf(ServiceConfigObjConfig{}): transformStringSourceMap, - reflect.TypeOf(StringOrNumberList{}): transformStringOrNumberList, - reflect.TypeOf(map[string]*ServiceNetworkConfig{}): transformServiceNetworkMap, - reflect.TypeOf(MappingWithEquals{}): transformMappingOrListFunc("=", true), - reflect.TypeOf(Labels{}): transformMappingOrListFunc("=", false), - reflect.TypeOf(MappingWithColon{}): transformMappingOrListFunc(":", false), - reflect.TypeOf(ServiceVolumeConfig{}): transformServiceVolumeConfig, - reflect.TypeOf(BuildConfig{}): transformBuildConfig, + reflect.TypeOf(composetypes.External{}): transformExternal, + reflect.TypeOf(composetypes.HealthCheckTest{}): transformHealthCheckTest, + reflect.TypeOf(composetypes.ShellCommand{}): transformShellCommand, + reflect.TypeOf(composetypes.StringList{}): transformStringList, + reflect.TypeOf(map[string]string{}): transformMapStringString, + reflect.TypeOf(composetypes.UlimitsConfig{}): transformUlimits, + reflect.TypeOf(composetypes.UnitBytes(0)): transformSize, + reflect.TypeOf([]composetypes.ServicePortConfig{}): transformServicePort, + reflect.TypeOf(composetypes.ServiceSecretConfig{}): transformStringSourceMap, + reflect.TypeOf(composetypes.ServiceConfigObjConfig{}): transformStringSourceMap, + reflect.TypeOf(composetypes.StringOrNumberList{}): transformStringOrNumberList, + reflect.TypeOf(map[string]*composetypes.ServiceNetworkConfig{}): transformServiceNetworkMap, + reflect.TypeOf(composetypes.Mapping{}): transformMappingOrListFunc("=", false), + reflect.TypeOf(composetypes.MappingWithEquals{}): transformMappingOrListFunc("=", true), + reflect.TypeOf(composetypes.Labels{}): transformMappingOrListFunc("=", false), + reflect.TypeOf(composetypes.MappingWithColon{}): transformMappingOrListFunc(":", false), + reflect.TypeOf(composetypes.HostsList{}): transformListOrMappingFunc(":", false), + reflect.TypeOf(composetypes.ServiceVolumeConfig{}): transformServiceVolumeConfig, + reflect.TypeOf(composetypes.BuildConfig{}): transformBuildConfig, + reflect.TypeOf(composetypes.Duration(0)): transformStringToDuration, + } + + for _, transformer := range additionalTransformers { + transforms[transformer.TypeOf] = transformer.Func } return func(_ reflect.Type, target reflect.Type, data interface{}) (interface{}, error) { @@ -279,8 +376,8 @@ func formatInvalidKeyError(keyPrefix string, key interface{}) error { // LoadServices produces a ServiceConfig map from a compose file Dict // the servicesDict is not validated if directly used. Use Load() to enable validation -func LoadServices(servicesDict map[string]interface{}, workingDir string, lookupEnv Mapping) ([]ServiceConfig, error) { - var services []ServiceConfig +func LoadServices(servicesDict map[string]interface{}, workingDir string, lookupEnv template.Mapping) ([]composetypes.ServiceConfig, error) { + var services []composetypes.ServiceConfig for name, serviceDef := range servicesDict { serviceConfig, err := LoadService(name, serviceDef.(map[string]interface{}), workingDir, lookupEnv) @@ -295,9 +392,9 @@ func LoadServices(servicesDict map[string]interface{}, workingDir string, lookup // LoadService produces a single ServiceConfig from a compose file Dict // the serviceDict is not validated if directly used. Use Load() to enable validation -func LoadService(name string, serviceDict map[string]interface{}, workingDir string, lookupEnv Mapping) (*ServiceConfig, error) { - serviceConfig := &ServiceConfig{} - if err := transform(serviceDict, serviceConfig); err != nil { +func LoadService(name string, serviceDict map[string]interface{}, workingDir string, lookupEnv template.Mapping) (*composetypes.ServiceConfig, error) { + serviceConfig := &composetypes.ServiceConfig{} + if err := Transform(serviceDict, serviceConfig); err != nil { return nil, err } serviceConfig.Name = name @@ -306,11 +403,36 @@ func LoadService(name string, serviceDict map[string]interface{}, workingDir str return nil, err } - resolveVolumePaths(serviceConfig.Volumes, workingDir, lookupEnv) + if err := resolveVolumePaths(serviceConfig.Volumes, workingDir, lookupEnv); err != nil { + return nil, err + } + + serviceConfig.Extras = getExtras(serviceDict) + return serviceConfig, nil } -func updateEnvironment(environment map[string]*string, vars map[string]*string, lookupEnv Mapping) { +func loadExtras(name string, source map[string]interface{}) map[string]interface{} { + if dict, ok := source[name].(map[string]interface{}); ok { + return getExtras(dict) + } + return nil +} + +func getExtras(dict map[string]interface{}) map[string]interface{} { + extras := map[string]interface{}{} + for key, value := range dict { + if strings.HasPrefix(key, "x-") { + extras[key] = value + } + } + if len(extras) == 0 { + return nil + } + return extras +} + +func updateEnvironment(environment map[string]*string, vars map[string]*string, lookupEnv template.Mapping) { for k, v := range vars { interpolatedV, ok := lookupEnv(k) if (v == nil || *v == "") && ok { @@ -322,7 +444,7 @@ func updateEnvironment(environment map[string]*string, vars map[string]*string, } } -func resolveEnvironment(serviceConfig *ServiceConfig, workingDir string, lookupEnv Mapping) error { +func resolveEnvironment(serviceConfig *composetypes.ServiceConfig, workingDir string, lookupEnv template.Mapping) error { environment := make(map[string]*string) if len(serviceConfig.EnvFile) > 0 { @@ -345,12 +467,16 @@ func resolveEnvironment(serviceConfig *ServiceConfig, workingDir string, lookupE return nil } -func resolveVolumePaths(volumes []ServiceVolumeConfig, workingDir string, lookupEnv Mapping) { +func resolveVolumePaths(volumes []composetypes.ServiceVolumeConfig, workingDir string, lookupEnv template.Mapping) error { for i, volume := range volumes { if volume.Type != "bind" { continue } + if volume.Source == "" { + return errors.New(`invalid mount config for type "bind": field Source must not be empty`) + } + filePath := expandUser(volume.Source, lookupEnv) // Check for a Unix absolute path first, to handle a Windows client // with a Unix daemon. This handles a Windows client connecting to a @@ -363,10 +489,12 @@ func resolveVolumePaths(volumes []ServiceVolumeConfig, workingDir string, lookup volume.Source = filePath volumes[i] = volume } + return nil } + // TODO: make this more robust -func expandUser(path string, lookupEnv Mapping) string { +func expandUser(path string, lookupEnv template.Mapping) string { if strings.HasPrefix(path, "~") { home, ok := lookupEnv("HOME") if !ok { @@ -381,9 +509,9 @@ func expandUser(path string, lookupEnv Mapping) string { func transformUlimits(data interface{}) (interface{}, error) { switch value := data.(type) { case int: - return UlimitsConfig{Single: value}, nil + return composetypes.UlimitsConfig{Single: value}, nil case map[string]interface{}: - ulimit := UlimitsConfig{} + ulimit := composetypes.UlimitsConfig{} ulimit.Soft = value["soft"].(int) ulimit.Hard = value["hard"].(int) return ulimit, nil @@ -394,17 +522,31 @@ func transformUlimits(data interface{}) (interface{}, error) { // LoadNetworks produces a NetworkConfig map from a compose file Dict // the source Dict is not validated if directly used. Use Load() to enable validation -func LoadNetworks(source map[string]interface{}) (map[string]NetworkConfig, error) { - networks := make(map[string]NetworkConfig) - err := transform(source, &networks) +func LoadNetworks(source map[string]interface{}, version string) (map[string]composetypes.NetworkConfig, error) { + networks := make(map[string]composetypes.NetworkConfig) + err := Transform(source, &networks) if err != nil { return networks, err } for name, network := range networks { - if network.External.External && network.External.Name == "" { - network.External.Name = name - networks[name] = network + if !network.External.External { + continue } + switch { + case network.External.Name != "": + if network.Name != "" { + return nil, errors.Errorf("network %s: network.external.name and network.name conflict; only use network.name", name) + } + if versions.GreaterThanOrEqualTo(version, "3.5") { + log.Get("compose").Warnf("network %s: network.external.name is deprecated in favor of network.name", name) + } + network.Name = network.External.Name + network.External.Name = "" + case network.Name == "": + network.Name = name + } + network.Extras = loadExtras(name, source) + networks[name] = network } return networks, nil } @@ -417,76 +559,110 @@ func externalVolumeError(volume, key string) error { // LoadVolumes produces a VolumeConfig map from a compose file Dict // the source Dict is not validated if directly used. Use Load() to enable validation -func LoadVolumes(source map[string]interface{}) (map[string]VolumeConfig, error) { - volumes := make(map[string]VolumeConfig) - err := transform(source, &volumes) - if err != nil { +func LoadVolumes(source map[string]interface{}, version string) (map[string]composetypes.VolumeConfig, error) { + volumes := make(map[string]composetypes.VolumeConfig) + if err := Transform(source, &volumes); err != nil { return volumes, err } - for name, volume := range volumes { - if volume.External.External { - if volume.Driver != "" { - return nil, externalVolumeError(name, "driver") - } - if len(volume.DriverOpts) > 0 { - return nil, externalVolumeError(name, "driver_opts") - } - if len(volume.Labels) > 0 { - return nil, externalVolumeError(name, "labels") - } - if volume.External.Name == "" { - volume.External.Name = name - volumes[name] = volume - } else { - log.Get("compose").Warnf("volume %s: volume.external.name is deprecated in favor of volume.name", name) - if volume.Name != "" { - return nil, fmt.Errorf("volume %s: volume.external.name and volume.name conflict; only use volume.name", name) - } - } + for name, volume := range volumes { + if !volume.External.External { + continue } + switch { + case volume.Driver != "": + return nil, externalVolumeError(name, "driver") + case len(volume.DriverOpts) > 0: + return nil, externalVolumeError(name, "driver_opts") + case len(volume.Labels) > 0: + return nil, externalVolumeError(name, "labels") + case volume.External.Name != "": + if volume.Name != "" { + return nil, errors.Errorf("volume %s: volume.external.name and volume.name conflict; only use volume.name", name) + } + if versions.GreaterThanOrEqualTo(version, "3.4") { + log.Get("compose").Warnf("volume %s: volume.external.name is deprecated in favor of volume.name", name) + } + volume.Name = volume.External.Name + volume.External.Name = "" + case volume.Name == "": + volume.Name = name + } + volume.Extras = loadExtras(name, source) + volumes[name] = volume } return volumes, nil } // LoadSecrets produces a SecretConfig map from a compose file Dict // the source Dict is not validated if directly used. Use Load() to enable validation -func LoadSecrets(source map[string]interface{}, workingDir string) (map[string]SecretConfig, error) { - secrets := make(map[string]SecretConfig) - if err := transform(source, &secrets); err != nil { +func LoadSecrets(source map[string]interface{}, details composetypes.ConfigDetails) (map[string]composetypes.SecretConfig, error) { + secrets := make(map[string]composetypes.SecretConfig) + if err := Transform(source, &secrets); err != nil { return secrets, err } for name, secret := range secrets { - if secret.External.External && secret.External.Name == "" { - secret.External.Name = name - secrets[name] = secret - } - if secret.File != "" { - secret.File = absPath(workingDir, secret.File) + obj, err := loadFileObjectConfig(name, "secret", composetypes.FileObjectConfig(secret), details) + if err != nil { + return nil, err } + secretConfig := composetypes.SecretConfig(obj) + secretConfig.Extras = loadExtras(name, source) + secrets[name] = secretConfig } return secrets, nil } // LoadConfigObjs produces a ConfigObjConfig map from a compose file Dict // the source Dict is not validated if directly used. Use Load() to enable validation -func LoadConfigObjs(source map[string]interface{}, workingDir string) (map[string]ConfigObjConfig, error) { - configs := make(map[string]ConfigObjConfig) - if err := transform(source, &configs); err != nil { +func LoadConfigObjs(source map[string]interface{}, details composetypes.ConfigDetails) (map[string]composetypes.ConfigObjConfig, error) { + configs := make(map[string]composetypes.ConfigObjConfig) + if err := Transform(source, &configs); err != nil { return configs, err } for name, config := range configs { - if config.External.External && config.External.Name == "" { - config.External.Name = name - configs[name] = config - } - if config.File != "" { - config.File = absPath(workingDir, config.File) + obj, err := loadFileObjectConfig(name, "config", composetypes.FileObjectConfig(config), details) + if err != nil { + return nil, err } + configConfig := composetypes.ConfigObjConfig(obj) + configConfig.Extras = loadExtras(name, source) + configs[name] = configConfig } return configs, nil } +func loadFileObjectConfig(name string, objType string, obj composetypes.FileObjectConfig, details composetypes.ConfigDetails) (composetypes.FileObjectConfig, error) { + // if "external: true" + switch { + case obj.External.External: + // handle deprecated external.name + if obj.External.Name != "" { + if obj.Name != "" { + return obj, errors.Errorf("%[1]s %[2]s: %[1]s.external.name and %[1]s.name conflict; only use %[1]s.name", objType, name) + } + if versions.GreaterThanOrEqualTo(details.Version, "3.5") { + log.Get("compose").Warnf("%[1]s %[2]s: %[1]s.external.name is deprecated in favor of %[1]s.name", objType, name) + } + obj.Name = obj.External.Name + obj.External.Name = "" + } else { + if obj.Name == "" { + obj.Name = name + } + } + // if not "external: true" + case obj.Driver != "": + if obj.File != "" { + return obj, errors.Errorf("%[1]s %[2]s: %[1]s.driver and %[1]s.file conflict; only use %[1]s.driver", objType, name) + } + default: + obj.File = absPath(details.WorkingDir, obj.File) + } + + return obj, nil +} + func absPath(workingDir string, filePath string) string { if filepath.IsAbs(filePath) { return filePath @@ -642,6 +818,22 @@ func transformMappingOrList(mappingOrList interface{}, sep string, allowNil bool panic(fmt.Errorf("expected a map or a list, got %T: %#v", mappingOrList, mappingOrList)) } +func transformListOrMappingFunc(sep string, allowNil bool) func(interface{}) (interface{}, error) { + return func(data interface{}) (interface{}, error) { + return transformListOrMapping(data, sep, allowNil), nil + } +} + +func transformListOrMapping(listOrMapping interface{}, sep string, allowNil bool) interface{} { + switch value := listOrMapping.(type) { + case map[string]interface{}: + return toStringList(value, sep, allowNil) + case []interface{}: + return listOrMapping + } + panic(errors.Errorf("expected a map or a list, got %T: %#v", listOrMapping, listOrMapping)) +} + func transformShellCommand(value interface{}) (interface{}, error) { if str, ok := value.(string); ok { return shellwords.Parse(str) @@ -670,6 +862,19 @@ func transformSize(value interface{}) (interface{}, error) { panic(fmt.Errorf("invalid type for size %T", value)) } +func transformStringToDuration(value interface{}) (interface{}, error) { + switch value := value.(type) { + case string: + d, err := time.ParseDuration(value) + if err != nil { + return value, err + } + return composetypes.Duration(d), nil + default: + return value, errors.Errorf("invalid type %T for duration", value) + } +} + func toServicePortConfigs(value string) ([]interface{}, error) { var portConfigs []interface{} @@ -691,7 +896,7 @@ func toServicePortConfigs(value string) ([]interface{}, error) { return nil, err } for _, p := range portConfig { - portConfigs = append(portConfigs, ServicePortConfig{ + portConfigs = append(portConfigs, composetypes.ServicePortConfig{ Protocol: string(p.Protocol), Target: p.TargetPort, Published: p.PublishedPort, @@ -721,3 +926,15 @@ func toString(value interface{}, allowNil bool) interface{} { return "" } } + +func toStringList(value map[string]interface{}, separator string, allowNil bool) []string { + output := []string{} + for key, value := range value { + if value == nil && !allowNil { + continue + } + output = append(output, fmt.Sprintf("%s%s%s", key, separator, value)) + } + sort.Strings(output) + return output +} diff --git a/biz/docker/compose/merge.go b/biz/docker/compose/merge.go new file mode 100644 index 0000000..7a5d946 --- /dev/null +++ b/biz/docker/compose/merge.go @@ -0,0 +1,233 @@ +package compose + +import ( + "reflect" + "sort" + + "github.com/cuigh/swirl/biz/docker/compose/types" + "github.com/imdario/mergo" + "github.com/pkg/errors" +) + +type specials struct { + m map[reflect.Type]func(dst, src reflect.Value) error +} + +func (s *specials) Transformer(t reflect.Type) func(dst, src reflect.Value) error { + if fn, ok := s.m[t]; ok { + return fn + } + return nil +} + +func merge(configs []*types.Config) (*types.Config, error) { + base := configs[0] + for _, override := range configs[1:] { + var err error + base.Services, err = mergeServices(base.Services, override.Services) + if err != nil { + return base, errors.Wrapf(err, "cannot merge services from %s", override.Filename) + } + base.Volumes, err = mergeVolumes(base.Volumes, override.Volumes) + if err != nil { + return base, errors.Wrapf(err, "cannot merge volumes from %s", override.Filename) + } + base.Networks, err = mergeNetworks(base.Networks, override.Networks) + if err != nil { + return base, errors.Wrapf(err, "cannot merge networks from %s", override.Filename) + } + base.Secrets, err = mergeSecrets(base.Secrets, override.Secrets) + if err != nil { + return base, errors.Wrapf(err, "cannot merge secrets from %s", override.Filename) + } + base.Configs, err = mergeConfigs(base.Configs, override.Configs) + if err != nil { + return base, errors.Wrapf(err, "cannot merge configs from %s", override.Filename) + } + } + return base, nil +} + +func mergeServices(base, override []types.ServiceConfig) ([]types.ServiceConfig, error) { + baseServices := mapByName(base) + overrideServices := mapByName(override) + specials := &specials{ + m: map[reflect.Type]func(dst, src reflect.Value) error{ + reflect.TypeOf(&types.LoggingConfig{}): safelyMerge(mergeLoggingConfig), + reflect.TypeOf([]types.ServicePortConfig{}): mergeSlice(toServicePortConfigsMap, toServicePortConfigsSlice), + reflect.TypeOf([]types.ServiceSecretConfig{}): mergeSlice(toServiceSecretConfigsMap, toServiceSecretConfigsSlice), + reflect.TypeOf([]types.ServiceConfigObjConfig{}): mergeSlice(toServiceConfigObjConfigsMap, toSServiceConfigObjConfigsSlice), + }, + } + for name, overrideService := range overrideServices { + if baseService, ok := baseServices[name]; ok { + if err := mergo.Merge(&baseService, &overrideService, mergo.WithAppendSlice, mergo.WithOverride, mergo.WithTransformers(specials)); err != nil { + return base, errors.Wrapf(err, "cannot merge service %s", name) + } + baseServices[name] = baseService + continue + } + baseServices[name] = overrideService + } + services := []types.ServiceConfig{} + for _, baseService := range baseServices { + services = append(services, baseService) + } + sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) + return services, nil +} + +func toServiceSecretConfigsMap(s interface{}) (map[interface{}]interface{}, error) { + secrets, ok := s.([]types.ServiceSecretConfig) + if !ok { + return nil, errors.Errorf("not a serviceSecretConfig: %v", s) + } + m := map[interface{}]interface{}{} + for _, secret := range secrets { + m[secret.Source] = secret + } + return m, nil +} + +func toServiceConfigObjConfigsMap(s interface{}) (map[interface{}]interface{}, error) { + secrets, ok := s.([]types.ServiceConfigObjConfig) + if !ok { + return nil, errors.Errorf("not a serviceSecretConfig: %v", s) + } + m := map[interface{}]interface{}{} + for _, secret := range secrets { + m[secret.Source] = secret + } + return m, nil +} + +func toServicePortConfigsMap(s interface{}) (map[interface{}]interface{}, error) { + ports, ok := s.([]types.ServicePortConfig) + if !ok { + return nil, errors.Errorf("not a servicePortConfig slice: %v", s) + } + m := map[interface{}]interface{}{} + for _, p := range ports { + m[p.Published] = p + } + return m, nil +} + +func toServiceSecretConfigsSlice(dst reflect.Value, m map[interface{}]interface{}) error { + s := []types.ServiceSecretConfig{} + for _, v := range m { + s = append(s, v.(types.ServiceSecretConfig)) + } + sort.Slice(s, func(i, j int) bool { return s[i].Source < s[j].Source }) + dst.Set(reflect.ValueOf(s)) + return nil +} + +func toSServiceConfigObjConfigsSlice(dst reflect.Value, m map[interface{}]interface{}) error { + s := []types.ServiceConfigObjConfig{} + for _, v := range m { + s = append(s, v.(types.ServiceConfigObjConfig)) + } + sort.Slice(s, func(i, j int) bool { return s[i].Source < s[j].Source }) + dst.Set(reflect.ValueOf(s)) + return nil +} + +func toServicePortConfigsSlice(dst reflect.Value, m map[interface{}]interface{}) error { + s := []types.ServicePortConfig{} + for _, v := range m { + s = append(s, v.(types.ServicePortConfig)) + } + sort.Slice(s, func(i, j int) bool { return s[i].Published < s[j].Published }) + dst.Set(reflect.ValueOf(s)) + return nil +} + +type tomapFn func(s interface{}) (map[interface{}]interface{}, error) +type writeValueFromMapFn func(reflect.Value, map[interface{}]interface{}) error + +func safelyMerge(mergeFn func(dst, src reflect.Value) error) func(dst, src reflect.Value) error { + return func(dst, src reflect.Value) error { + if src.IsNil() { + return nil + } + if dst.IsNil() { + dst.Set(src) + return nil + } + return mergeFn(dst, src) + } +} + +func mergeSlice(tomap tomapFn, writeValue writeValueFromMapFn) func(dst, src reflect.Value) error { + return func(dst, src reflect.Value) error { + dstMap, err := sliceToMap(tomap, dst) + if err != nil { + return err + } + srcMap, err := sliceToMap(tomap, src) + if err != nil { + return err + } + if err := mergo.Map(&dstMap, srcMap, mergo.WithOverride); err != nil { + return err + } + return writeValue(dst, dstMap) + } +} + +func sliceToMap(tomap tomapFn, v reflect.Value) (map[interface{}]interface{}, error) { + // check if valid + if !v.IsValid() { + return nil, errors.Errorf("invalid value : %+v", v) + } + return tomap(v.Interface()) +} + +func mergeLoggingConfig(dst, src reflect.Value) error { + // Same driver, merging options + if getLoggingDriver(dst.Elem()) == getLoggingDriver(src.Elem()) || + getLoggingDriver(dst.Elem()) == "" || getLoggingDriver(src.Elem()) == "" { + if getLoggingDriver(dst.Elem()) == "" { + dst.Elem().FieldByName("Driver").SetString(getLoggingDriver(src.Elem())) + } + dstOptions := dst.Elem().FieldByName("Options").Interface().(map[string]string) + srcOptions := src.Elem().FieldByName("Options").Interface().(map[string]string) + return mergo.Merge(&dstOptions, srcOptions, mergo.WithOverride) + } + // Different driver, override with src + dst.Set(src) + return nil +} + +func getLoggingDriver(v reflect.Value) string { + return v.FieldByName("Driver").String() +} + +func mapByName(services []types.ServiceConfig) map[string]types.ServiceConfig { + m := map[string]types.ServiceConfig{} + for _, service := range services { + m[service.Name] = service + } + return m +} + +func mergeVolumes(base, override map[string]types.VolumeConfig) (map[string]types.VolumeConfig, error) { + err := mergo.Map(&base, &override, mergo.WithOverride) + return base, err +} + +func mergeNetworks(base, override map[string]types.NetworkConfig) (map[string]types.NetworkConfig, error) { + err := mergo.Map(&base, &override, mergo.WithOverride) + return base, err +} + +func mergeSecrets(base, override map[string]types.SecretConfig) (map[string]types.SecretConfig, error) { + err := mergo.Map(&base, &override, mergo.WithOverride) + return base, err +} + +func mergeConfigs(base, override map[string]types.ConfigObjConfig) (map[string]types.ConfigObjConfig, error) { + err := mergo.Map(&base, &override, mergo.WithOverride) + return base, err +} diff --git a/biz/docker/compose/parse.go b/biz/docker/compose/parse.go index bfe7458..6fb6ffb 100644 --- a/biz/docker/compose/parse.go +++ b/biz/docker/compose/parse.go @@ -5,9 +5,11 @@ import ( "os" "sort" "strings" + + composetypes "github.com/cuigh/swirl/biz/docker/compose/types" ) -func Parse(name, content string) (*Config, error) { +func Parse(name, content string) (*composetypes.Config, error) { //absPath, err := filepath.Abs(composefile) //if err != nil { // return details, err @@ -24,8 +26,8 @@ func Parse(name, content string) (*Config, error) { return nil, err } - details := ConfigDetails{ - ConfigFiles: []ConfigFile{*configFile}, + details := composetypes.ConfigDetails{ + ConfigFiles: []composetypes.ConfigFile{*configFile}, Environment: env, } cfg, err := Load(details) @@ -47,12 +49,12 @@ func propertyWarnings(properties map[string]string) string { return strings.Join(msgs, "\n\n") } -func getConfigFile(name, content string) (*ConfigFile, error) { +func getConfigFile(name, content string) (*composetypes.ConfigFile, error) { config, err := ParseYAML([]byte(content)) if err != nil { return nil, err } - return &ConfigFile{ + return &composetypes.ConfigFile{ Filename: name, Config: config, }, nil @@ -71,7 +73,7 @@ func buildEnvironment(env []string) (map[string]string, error) { return result, nil } -func GetServicesDeclaredNetworks(serviceConfigs []ServiceConfig) map[string]struct{} { +func GetServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} { serviceNetworks := map[string]struct{}{} for _, serviceConfig := range serviceConfigs { if len(serviceConfig.Networks) == 0 { diff --git a/biz/docker/compose/schema/schema.go b/biz/docker/compose/schema/schema.go new file mode 100644 index 0000000..c19d5e1 --- /dev/null +++ b/biz/docker/compose/schema/schema.go @@ -0,0 +1,161 @@ +package schema + +import ( + "fmt" +) + +const ( + defaultVersion = "1.0" + versionField = "version" +) + +//type portsFormatChecker struct{} +// +//func (checker portsFormatChecker) IsFormat(input interface{}) bool { +// // TODO: implement this +// return true +//} +// +//type durationFormatChecker struct{} +// +//func (checker durationFormatChecker) IsFormat(input interface{}) bool { +// _, err := time.ParseDuration(input.(string)) +// return err == nil +//} +// +//func init() { +// gojsonschema.FormatCheckers.Add("expose", portsFormatChecker{}) +// gojsonschema.FormatCheckers.Add("ports", portsFormatChecker{}) +// gojsonschema.FormatCheckers.Add("duration", durationFormatChecker{}) +//} + +// Version returns the version of the config, defaulting to version 1.0 +func Version(config map[string]interface{}) string { + version, ok := config[versionField] + if !ok { + return defaultVersion + } + return normalizeVersion(fmt.Sprintf("%v", version)) +} + +func normalizeVersion(version string) string { + switch version { + case "3": + return "3.0" + default: + return version + } +} + +// Validate uses the jsonschema to validate the configuration +//func Validate(config map[string]interface{}, version string) error { +// schemaData, err := _escFSByte(false, fmt.Sprintf("/data/config_schema_v%s.json", version)) +// if err != nil { +// return errors.Errorf("unsupported Compose file version: %s", version) +// } +// +// schemaLoader := gojsonschema.NewStringLoader(string(schemaData)) +// dataLoader := gojsonschema.NewGoLoader(config) +// +// result, err := gojsonschema.Validate(schemaLoader, dataLoader) +// if err != nil { +// return err +// } +// +// if !result.Valid() { +// return toError(result) +// } +// +// return nil +//} + +//func toError(result *gojsonschema.Result) error { +// err := getMostSpecificError(result.Errors()) +// return err +//} + +//const ( +// jsonschemaOneOf = "number_one_of" +// jsonschemaAnyOf = "number_any_of" +//) + +//func getDescription(err validationError) string { +// switch err.parent.Type() { +// case "invalid_type": +// if expectedType, ok := err.parent.Details()["expected"].(string); ok { +// return fmt.Sprintf("must be a %s", humanReadableType(expectedType)) +// } +// case jsonschemaOneOf, jsonschemaAnyOf: +// if err.child == nil { +// return err.parent.Description() +// } +// return err.child.Description() +// } +// return err.parent.Description() +//} + +//func humanReadableType(definition string) string { +// if definition[0:1] == "[" { +// allTypes := strings.Split(definition[1:len(definition)-1], ",") +// for i, t := range allTypes { +// allTypes[i] = humanReadableType(t) +// } +// return fmt.Sprintf( +// "%s or %s", +// strings.Join(allTypes[0:len(allTypes)-1], ", "), +// allTypes[len(allTypes)-1], +// ) +// } +// if definition == "object" { +// return "mapping" +// } +// if definition == "array" { +// return "list" +// } +// return definition +//} + +//type validationError struct { +// parent gojsonschema.ResultError +// child gojsonschema.ResultError +//} +// +//func (err validationError) Error() string { +// description := getDescription(err) +// return fmt.Sprintf("%s %s", err.parent.Field(), description) +//} + +//func getMostSpecificError(errors []gojsonschema.ResultError) validationError { +// mostSpecificError := 0 +// for i, err := range errors { +// if specificity(err) > specificity(errors[mostSpecificError]) { +// mostSpecificError = i +// continue +// } +// +// if specificity(err) == specificity(errors[mostSpecificError]) { +// // Invalid type errors win in a tie-breaker for most specific field name +// if err.Type() == "invalid_type" && errors[mostSpecificError].Type() != "invalid_type" { +// mostSpecificError = i +// } +// } +// } +// +// if mostSpecificError+1 == len(errors) { +// return validationError{parent: errors[mostSpecificError]} +// } +// +// switch errors[mostSpecificError].Type() { +// case "number_one_of", "number_any_of": +// return validationError{ +// parent: errors[mostSpecificError], +// child: errors[mostSpecificError+1], +// } +// default: +// return validationError{parent: errors[mostSpecificError]} +// } +//} + +//func specificity(err gojsonschema.ResultError) int { +// return len(strings.Split(err.Field(), ".")) +//} \ No newline at end of file diff --git a/biz/docker/compose/template.go b/biz/docker/compose/template.go deleted file mode 100644 index 2884e77..0000000 --- a/biz/docker/compose/template.go +++ /dev/null @@ -1,101 +0,0 @@ -package compose - -import ( - "fmt" - "regexp" - "strings" -) - -var delimiter = "\\$" -var substitution = "[_a-z][_a-z0-9]*(?::?-[^}]+)?" - -var patternString = fmt.Sprintf( - "%s(?i:(?P%s)|(?P%s)|{(?P%s)}|(?P))", - delimiter, delimiter, substitution, substitution, -) - -var pattern = regexp.MustCompile(patternString) - -// InvalidTemplateError is returned when a variable template is not in a valid -// format -type InvalidTemplateError struct { - Template string -} - -func (e InvalidTemplateError) Error() string { - return fmt.Sprintf("Invalid template: %#v", e.Template) -} - -// Mapping is a user-supplied function which maps from variable names to values. -// Returns the value as a string and a bool indicating whether -// the value is present, to distinguish between an empty string -// and the absence of a value. -type Mapping func(string) (string, bool) - -// Substitute variables in the string with their values -func Substitute(template string, mapping Mapping) (string, error) { - var err error - result := pattern.ReplaceAllStringFunc(template, func(substring string) string { - matches := pattern.FindStringSubmatch(substring) - groups := make(map[string]string) - for i, name := range pattern.SubexpNames() { - if i != 0 { - groups[name] = matches[i] - } - } - - substitution := groups["named"] - if substitution == "" { - substitution = groups["braced"] - } - if substitution != "" { - // Soft default (fall back if unset or empty) - if strings.Contains(substitution, ":-") { - name, defaultValue := partition(substitution, ":-") - value, ok := mapping(name) - if !ok || value == "" { - return defaultValue - } - return value - } - - // Hard default (fall back if-and-only-if empty) - if strings.Contains(substitution, "-") { - name, defaultValue := partition(substitution, "-") - value, ok := mapping(name) - if !ok { - return defaultValue - } - return value - } - - // No default (fall back to empty string) - value, ok := mapping(substitution) - if !ok { - return "" - } - return value - } - - if escaped := groups["escaped"]; escaped != "" { - return escaped - } - - err = &InvalidTemplateError{Template: template} - return "" - }) - - return result, err -} - -// Split the string at the first occurrence of sep, and return the part before the separator, -// and the part after the separator. -// -// If the separator is not found, return the string itself, followed by an empty string. -func partition(s, sep string) (string, string) { - if strings.Contains(s, sep) { - parts := strings.SplitN(s, sep, 2) - return parts[0], parts[1] - } - return s, "" -} diff --git a/biz/docker/compose/template/template.go b/biz/docker/compose/template/template.go new file mode 100644 index 0000000..8e172a9 --- /dev/null +++ b/biz/docker/compose/template/template.go @@ -0,0 +1,245 @@ +package template + +import ( + "fmt" + "regexp" + "strings" +) + +var delimiter = "\\$" +var substitution = "[_a-z][_a-z0-9]*(?::?[-?][^}]*)?" + +var patternString = fmt.Sprintf( + "%s(?i:(?P%s)|(?P%s)|{(?P%s)}|(?P))", + delimiter, delimiter, substitution, substitution, +) + +var defaultPattern = regexp.MustCompile(patternString) + +// DefaultSubstituteFuncs contains the default SubstituteFunc used by the docker cli +var DefaultSubstituteFuncs = []SubstituteFunc{ + softDefault, + hardDefault, + requiredNonEmpty, + required, +} + +// InvalidTemplateError is returned when a variable template is not in a valid +// format +type InvalidTemplateError struct { + Template string +} + +func (e InvalidTemplateError) Error() string { + return fmt.Sprintf("Invalid template: %#v", e.Template) +} + +// Mapping is a user-supplied function which maps from variable names to values. +// Returns the value as a string and a bool indicating whether +// the value is present, to distinguish between an empty string +// and the absence of a value. +type Mapping func(string) (string, bool) + +// SubstituteFunc is a user-supplied function that apply substitution. +// Returns the value as a string, a bool indicating if the function could apply +// the substitution and an error. +type SubstituteFunc func(string, Mapping) (string, bool, error) + +// SubstituteWith subsitute variables in the string with their values. +// It accepts additional substitute function. +func SubstituteWith(template string, mapping Mapping, pattern *regexp.Regexp, subsFuncs ...SubstituteFunc) (string, error) { + var err error + result := pattern.ReplaceAllStringFunc(template, func(substring string) string { + matches := pattern.FindStringSubmatch(substring) + groups := matchGroups(matches, pattern) + if escaped := groups["escaped"]; escaped != "" { + return escaped + } + + substitution := groups["named"] + if substitution == "" { + substitution = groups["braced"] + } + + if substitution == "" { + err = &InvalidTemplateError{Template: template} + return "" + } + + for _, f := range subsFuncs { + var ( + value string + applied bool + ) + value, applied, err = f(substitution, mapping) + if err != nil { + return "" + } + if !applied { + continue + } + return value + } + + value, _ := mapping(substitution) + return value + }) + + return result, err +} + +// Substitute variables in the string with their values +func Substitute(template string, mapping Mapping) (string, error) { + return SubstituteWith(template, mapping, defaultPattern, DefaultSubstituteFuncs...) +} + +// ExtractVariables returns a map of all the variables defined in the specified +// composefile (dict representation) and their default value if any. +func ExtractVariables(configDict map[string]interface{}, pattern *regexp.Regexp) map[string]string { + if pattern == nil { + pattern = defaultPattern + } + return recurseExtract(configDict, pattern) +} + +func recurseExtract(value interface{}, pattern *regexp.Regexp) map[string]string { + m := map[string]string{} + + switch value := value.(type) { + case string: + if values, is := extractVariable(value, pattern); is { + for _, v := range values { + m[v.name] = v.value + } + } + case map[string]interface{}: + for _, elem := range value { + submap := recurseExtract(elem, pattern) + for key, value := range submap { + m[key] = value + } + } + + case []interface{}: + for _, elem := range value { + if values, is := extractVariable(elem, pattern); is { + for _, v := range values { + m[v.name] = v.value + } + } + } + } + + return m +} + +type extractedValue struct { + name string + value string +} + +func extractVariable(value interface{}, pattern *regexp.Regexp) ([]extractedValue, bool) { + sValue, ok := value.(string) + if !ok { + return []extractedValue{}, false + } + matches := pattern.FindAllStringSubmatch(sValue, -1) + if len(matches) == 0 { + return []extractedValue{}, false + } + values := []extractedValue{} + for _, match := range matches { + groups := matchGroups(match, pattern) + if escaped := groups["escaped"]; escaped != "" { + continue + } + val := groups["named"] + if val == "" { + val = groups["braced"] + } + name := val + var defaultValue string + switch { + case strings.Contains(val, ":?"): + name, _ = partition(val, ":?") + case strings.Contains(val, "?"): + name, _ = partition(val, "?") + case strings.Contains(val, ":-"): + name, defaultValue = partition(val, ":-") + case strings.Contains(val, "-"): + name, defaultValue = partition(val, "-") + } + values = append(values, extractedValue{name: name, value: defaultValue}) + } + return values, len(values) > 0 +} + +// Soft default (fall back if unset or empty) +func softDefault(substitution string, mapping Mapping) (string, bool, error) { + sep := ":-" + if !strings.Contains(substitution, sep) { + return "", false, nil + } + name, defaultValue := partition(substitution, sep) + value, ok := mapping(name) + if !ok || value == "" { + return defaultValue, true, nil + } + return value, true, nil +} + +// Hard default (fall back if-and-only-if empty) +func hardDefault(substitution string, mapping Mapping) (string, bool, error) { + sep := "-" + if !strings.Contains(substitution, sep) { + return "", false, nil + } + name, defaultValue := partition(substitution, sep) + value, ok := mapping(name) + if !ok { + return defaultValue, true, nil + } + return value, true, nil +} + +func requiredNonEmpty(substitution string, mapping Mapping) (string, bool, error) { + return withRequired(substitution, mapping, ":?", func(v string) bool { return v != "" }) +} + +func required(substitution string, mapping Mapping) (string, bool, error) { + return withRequired(substitution, mapping, "?", func(_ string) bool { return true }) +} + +func withRequired(substitution string, mapping Mapping, sep string, valid func(string) bool) (string, bool, error) { + if !strings.Contains(substitution, sep) { + return "", false, nil + } + name, errorMessage := partition(substitution, sep) + value, ok := mapping(name) + if !ok || !valid(value) { + return "", true, &InvalidTemplateError{ + Template: fmt.Sprintf("required variable %s is missing a value: %s", name, errorMessage), + } + } + return value, true, nil +} + +func matchGroups(matches []string, pattern *regexp.Regexp) map[string]string { + groups := make(map[string]string) + for i, name := range pattern.SubexpNames()[1:] { + groups[name] = matches[i+1] + } + return groups +} + +// Split the string at the first occurrence of sep, and return the part before the separator, +// and the part after the separator. +// +// If the separator is not found, return the string itself, followed by an empty string. +func partition(s, sep string) (string, string) { + if strings.Contains(s, sep) { + parts := strings.SplitN(s, sep, 2) + return parts[0], parts[1] + } + return s, "" +} \ No newline at end of file diff --git a/biz/docker/compose/types.go b/biz/docker/compose/types.go deleted file mode 100644 index 1ac3c85..0000000 --- a/biz/docker/compose/types.go +++ /dev/null @@ -1,354 +0,0 @@ -package compose - -import ( - "time" -) - -// UnsupportedProperties not yet supported by this implementation of the compose file -var UnsupportedProperties = []string{ - "build", - "cap_add", - "cap_drop", - "cgroup_parent", - "devices", - "domainname", - "external_links", - "ipc", - "links", - "mac_address", - "network_mode", - "privileged", - "restart", - "security_opt", - "shm_size", - "sysctls", - "tmpfs", - "ulimits", - "userns_mode", -} - -// DeprecatedProperties that were removed from the v3 format, but their -// use should not impact the behaviour of the application. -var DeprecatedProperties = map[string]string{ - "container_name": "Setting the container name is not supported.", - "expose": "Exposing ports is unnecessary - services on the same network can access each other's containers on any port.", -} - -// ForbiddenProperties that are not supported in this implementation of the -// compose file. -var ForbiddenProperties = map[string]string{ - "extends": "Support for `extends` is not implemented yet.", - "volume_driver": "Instead of setting the volume driver on the service, define a volume using the top-level `volumes` option and specify the driver there.", - "volumes_from": "To share a volume between services, define it using the top-level `volumes` option and reference it from each service that shares it using the service-level `volumes` option.", - "cpu_quota": "Set resource limits using deploy.resources", - "cpu_shares": "Set resource limits using deploy.resources", - "cpuset": "Set resource limits using deploy.resources", - "mem_limit": "Set resource limits using deploy.resources", - "memswap_limit": "Set resource limits using deploy.resources", -} - -// ConfigFile is a filename and the contents of the file as a Dict -type ConfigFile struct { - Filename string - Config map[string]interface{} -} - -// ConfigDetails are the details about a group of ConfigFiles -type ConfigDetails struct { - WorkingDir string - ConfigFiles []ConfigFile - Environment map[string]string -} - -// LookupEnv provides a lookup function for environment variables -func (cd ConfigDetails) LookupEnv(key string) (string, bool) { - v, ok := cd.Environment[key] - return v, ok -} - -// Config is a full compose file configuration -type Config struct { - Services []ServiceConfig - Networks map[string]NetworkConfig - Volumes map[string]VolumeConfig - Secrets map[string]SecretConfig - Configs map[string]ConfigObjConfig -} - -// ServiceConfig is the configuration of one service -type ServiceConfig struct { - Name string - - Build BuildConfig - CapAdd []string `mapstructure:"cap_add"` - CapDrop []string `mapstructure:"cap_drop"` - CgroupParent string `mapstructure:"cgroup_parent"` - Command ShellCommand - Configs []ServiceConfigObjConfig - ContainerName string `mapstructure:"container_name"` - CredentialSpec CredentialSpecConfig `mapstructure:"credential_spec"` - DependsOn []string `mapstructure:"depends_on"` - Deploy DeployConfig - Devices []string - DNS StringList - DNSSearch StringList `mapstructure:"dns_search"` - DomainName string `mapstructure:"domainname"` - Entrypoint ShellCommand - Environment MappingWithEquals - EnvFile StringList `mapstructure:"env_file"` - Expose StringOrNumberList - ExternalLinks []string `mapstructure:"external_links"` - ExtraHosts MappingWithColon `mapstructure:"extra_hosts"` - Hostname string - HealthCheck *HealthCheckConfig - Image string - Ipc string - Labels Labels - Links []string - Logging *LoggingConfig - MacAddress string `mapstructure:"mac_address"` - NetworkMode string `mapstructure:"network_mode"` - Networks map[string]*ServiceNetworkConfig - Pid string - Ports []ServicePortConfig - Privileged bool - ReadOnly bool `mapstructure:"read_only"` - Restart string - Secrets []ServiceSecretConfig - SecurityOpt []string `mapstructure:"security_opt"` - StdinOpen bool `mapstructure:"stdin_open"` - StopGracePeriod *time.Duration `mapstructure:"stop_grace_period"` - StopSignal string `mapstructure:"stop_signal"` - Tmpfs StringList - Tty bool `mapstructure:"tty"` - Ulimits map[string]*UlimitsConfig - User string - Volumes []ServiceVolumeConfig - WorkingDir string `mapstructure:"working_dir"` -} - -// BuildConfig is a type for build -// using the same format at libcompose: https://github.com/docker/libcompose/blob/master/yaml/build.go#L12 -type BuildConfig struct { - Context string - Dockerfile string - Args MappingWithEquals - Labels Labels - CacheFrom StringList `mapstructure:"cache_from"` - Network string - Target string -} - -// ShellCommand is a string or list of string args -type ShellCommand []string - -// StringList is a type for fields that can be a string or list of strings -type StringList []string - -// StringOrNumberList is a type for fields that can be a list of strings or -// numbers -type StringOrNumberList []string - -// MappingWithEquals is a mapping type that can be converted from a list of -// key[=value] strings. -// For the key with an empty value (`key=`), the mapped value is set to a pointer to `""`. -// For the key without value (`key`), the mapped value is set to nil. -type MappingWithEquals map[string]*string - -// Labels is a mapping type for labels -type Labels map[string]string - -// MappingWithColon is a mapping type that can be converted from a list of -// 'key: value' strings -type MappingWithColon map[string]string - -// LoggingConfig the logging configuration for a service -type LoggingConfig struct { - Driver string - Options map[string]string -} - -// DeployConfig the deployment configuration for a service -type DeployConfig struct { - Mode string - Replicas *uint64 - Labels Labels - UpdateConfig *UpdateConfig `mapstructure:"update_config"` - Resources Resources - RestartPolicy *RestartPolicy `mapstructure:"restart_policy"` - Placement Placement - EndpointMode string `mapstructure:"endpoint_mode"` -} - -// HealthCheckConfig the healthcheck configuration for a service -type HealthCheckConfig struct { - Test HealthCheckTest - Timeout *time.Duration - Interval *time.Duration - Retries *uint64 - StartPeriod *time.Duration `mapstructure:"start_period"` - Disable bool -} - -// HealthCheckTest is the command run to test the health of a service -type HealthCheckTest []string - -// UpdateConfig the service update configuration -type UpdateConfig struct { - Parallelism *uint64 - Delay time.Duration - FailureAction string `mapstructure:"failure_action"` - Monitor time.Duration - MaxFailureRatio float32 `mapstructure:"max_failure_ratio"` - Order string -} - -// Resources the resource limits and reservations -type Resources struct { - Limits *Resource - Reservations *Resource -} - -// Resource is a resource to be limited or reserved -type Resource struct { - // TODO: types to convert from units and ratios - NanoCPUs string `mapstructure:"cpus"` - MemoryBytes UnitBytes `mapstructure:"memory"` -} - -// UnitBytes is the bytes type -type UnitBytes int64 - -// RestartPolicy the service restart policy -type RestartPolicy struct { - Condition string - Delay *time.Duration - MaxAttempts *uint64 `mapstructure:"max_attempts"` - Window *time.Duration -} - -// Placement constraints for the service -type Placement struct { - Constraints []string - Preferences []PlacementPreferences -} - -// PlacementPreferences is the preferences for a service placement -type PlacementPreferences struct { - Spread string -} - -// ServiceNetworkConfig is the network configuration for a service -type ServiceNetworkConfig struct { - Aliases []string - Ipv4Address string `mapstructure:"ipv4_address"` - Ipv6Address string `mapstructure:"ipv6_address"` -} - -// ServicePortConfig is the port configuration for a service -type ServicePortConfig struct { - Mode string - Target uint32 - Published uint32 - Protocol string -} - -// ServiceVolumeConfig are references to a volume used by a service -type ServiceVolumeConfig struct { - Type string - Source string - Target string - ReadOnly bool `mapstructure:"read_only"` - Consistency string - Bind *ServiceVolumeBind - Volume *ServiceVolumeVolume -} - -// ServiceVolumeBind are options for a service volume of type bind -type ServiceVolumeBind struct { - Propagation string -} - -// ServiceVolumeVolume are options for a service volume of type volume -type ServiceVolumeVolume struct { - NoCopy bool `mapstructure:"nocopy"` -} - -type fileReferenceConfig struct { - Source string - Target string - UID string - GID string - Mode *uint32 -} - -// ServiceConfigObjConfig is the config obj configuration for a service -type ServiceConfigObjConfig fileReferenceConfig - -// ServiceSecretConfig is the secret configuration for a service -type ServiceSecretConfig fileReferenceConfig - -// UlimitsConfig the ulimit configuration -type UlimitsConfig struct { - Single int - Soft int - Hard int -} - -// NetworkConfig for a network -type NetworkConfig struct { - Driver string - DriverOpts map[string]string `mapstructure:"driver_opts"` - Ipam IPAMConfig - External External - Internal bool - Attachable bool - Labels Labels -} - -// IPAMConfig for a network -type IPAMConfig struct { - Driver string - Config []*IPAMPool -} - -// IPAMPool for a network -type IPAMPool struct { - Subnet string -} - -// VolumeConfig for a volume -type VolumeConfig struct { - Name string - Driver string - DriverOpts map[string]string `mapstructure:"driver_opts"` - External External - Labels Labels -} - -// External identifies a Volume or Network as a reference to a resource that is -// not managed, and should already exist. -// External.name is deprecated and replaced by Volume.name -type External struct { - Name string - External bool -} - -// CredentialSpecConfig for credential spec on Windows -type CredentialSpecConfig struct { - Config string `yaml:",omitempty" json:"config,omitempty"` // Config was added in API v1.40 - File string `yaml:",omitempty" json:"file,omitempty"` - Registry string `yaml:",omitempty" json:"registry,omitempty"` -} - -type fileObjectConfig struct { - File string - External External - Labels Labels -} - -// SecretConfig for a secret -type SecretConfig fileObjectConfig - -// ConfigObjConfig is the config for the swarm "Config" object -type ConfigObjConfig fileObjectConfig diff --git a/biz/docker/compose/types/types.go b/biz/docker/compose/types/types.go new file mode 100644 index 0000000..d77c1b6 --- /dev/null +++ b/biz/docker/compose/types/types.go @@ -0,0 +1,524 @@ +package types + +import ( + "encoding/json" + "fmt" + "time" +) + +// UnsupportedProperties not yet supported by this implementation of the compose file +var UnsupportedProperties = []string{ + "build", + "cap_add", + "cap_drop", + "cgroup_parent", + "devices", + "domainname", + "external_links", + "ipc", + "links", + "mac_address", + "network_mode", + "pid", + "privileged", + "restart", + "security_opt", + "shm_size", + "ulimits", + "userns_mode", +} + +// DeprecatedProperties that were removed from the v3 format, but their +// use should not impact the behaviour of the application. +var DeprecatedProperties = map[string]string{ + "container_name": "Setting the container name is not supported.", + "expose": "Exposing ports is unnecessary - services on the same network can access each other's containers on any port.", +} + +// ForbiddenProperties that are not supported in this implementation of the +// compose file. +var ForbiddenProperties = map[string]string{ + "extends": "Support for `extends` is not implemented yet.", + "volume_driver": "Instead of setting the volume driver on the service, define a volume using the top-level `volumes` option and specify the driver there.", + "volumes_from": "To share a volume between services, define it using the top-level `volumes` option and reference it from each service that shares it using the service-level `volumes` option.", + "cpu_quota": "Set resource limits using deploy.resources", + "cpu_shares": "Set resource limits using deploy.resources", + "cpuset": "Set resource limits using deploy.resources", + "mem_limit": "Set resource limits using deploy.resources", + "memswap_limit": "Set resource limits using deploy.resources", +} + +// ConfigFile is a filename and the contents of the file as a Dict +type ConfigFile struct { + Filename string + Config map[string]interface{} +} + +// ConfigDetails are the details about a group of ConfigFiles +type ConfigDetails struct { + Version string + WorkingDir string + ConfigFiles []ConfigFile + Environment map[string]string +} + +// Duration is a thin wrapper around time.Duration with improved JSON marshalling +type Duration time.Duration + +func (d Duration) String() string { + return time.Duration(d).String() +} + +// ConvertDurationPtr converts a typedefined Duration pointer to a time.Duration pointer with the same value. +func ConvertDurationPtr(d *Duration) *time.Duration { + if d == nil { + return nil + } + res := time.Duration(*d) + return &res +} + +// MarshalJSON makes Duration implement json.Marshaler +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +// MarshalYAML makes Duration implement yaml.Marshaler +func (d Duration) MarshalYAML() (interface{}, error) { + return d.String(), nil +} + +// LookupEnv provides a lookup function for environment variables +func (cd ConfigDetails) LookupEnv(key string) (string, bool) { + v, ok := cd.Environment[key] + return v, ok +} + +// Config is a full compose file configuration +type Config struct { + Filename string `yaml:"-" json:"-"` + Version string `json:"version"` + Services Services `json:"services"` + Networks map[string]NetworkConfig `yaml:",omitempty" json:"networks,omitempty"` + Volumes map[string]VolumeConfig `yaml:",omitempty" json:"volumes,omitempty"` + Secrets map[string]SecretConfig `yaml:",omitempty" json:"secrets,omitempty"` + Configs map[string]ConfigObjConfig `yaml:",omitempty" json:"configs,omitempty"` + Extras map[string]interface{} `yaml:",inline", json:"-"` +} + +// MarshalJSON makes Config implement json.Marshaler +func (c Config) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{ + "version": c.Version, + "services": c.Services, + } + + if len(c.Networks) > 0 { + m["networks"] = c.Networks + } + if len(c.Volumes) > 0 { + m["volumes"] = c.Volumes + } + if len(c.Secrets) > 0 { + m["secrets"] = c.Secrets + } + if len(c.Configs) > 0 { + m["configs"] = c.Configs + } + for k, v := range c.Extras { + m[k] = v + } + return json.Marshal(m) +} + +// Services is a list of ServiceConfig +type Services []ServiceConfig + +// MarshalYAML makes Services implement yaml.Marshaller +func (s Services) MarshalYAML() (interface{}, error) { + services := map[string]ServiceConfig{} + for _, service := range s { + services[service.Name] = service + } + return services, nil +} + +// MarshalJSON makes Services implement json.Marshaler +func (s Services) MarshalJSON() ([]byte, error) { + data, err := s.MarshalYAML() + if err != nil { + return nil, err + } + return json.MarshalIndent(data, "", " ") +} + +// ServiceConfig is the configuration of one service +type ServiceConfig struct { + Name string `yaml:"-" json:"-"` + + Build BuildConfig `yaml:",omitempty" json:"build,omitempty"` + CapAdd []string `mapstructure:"cap_add" yaml:"cap_add,omitempty" json:"cap_add,omitempty"` + CapDrop []string `mapstructure:"cap_drop" yaml:"cap_drop,omitempty" json:"cap_drop,omitempty"` + CgroupParent string `mapstructure:"cgroup_parent" yaml:"cgroup_parent,omitempty" json:"cgroup_parent,omitempty"` + Command ShellCommand `yaml:",omitempty" json:"command,omitempty"` + Configs []ServiceConfigObjConfig `yaml:",omitempty" json:"configs,omitempty"` + ContainerName string `mapstructure:"container_name" yaml:"container_name,omitempty" json:"container_name,omitempty"` + CredentialSpec CredentialSpecConfig `mapstructure:"credential_spec" yaml:"credential_spec,omitempty" json:"credential_spec,omitempty"` + DependsOn []string `mapstructure:"depends_on" yaml:"depends_on,omitempty" json:"depends_on,omitempty"` + Deploy DeployConfig `yaml:",omitempty" json:"deploy,omitempty"` + Devices []string `yaml:",omitempty" json:"devices,omitempty"` + DNS StringList `yaml:",omitempty" json:"dns,omitempty"` + DNSSearch StringList `mapstructure:"dns_search" yaml:"dns_search,omitempty" json:"dns_search,omitempty"` + DomainName string `mapstructure:"domainname" yaml:"domainname,omitempty" json:"domainname,omitempty"` + Entrypoint ShellCommand `yaml:",omitempty" json:"entrypoint,omitempty"` + Environment MappingWithEquals `yaml:",omitempty" json:"environment,omitempty"` + EnvFile StringList `mapstructure:"env_file" yaml:"env_file,omitempty" json:"env_file,omitempty"` + Expose StringOrNumberList `yaml:",omitempty" json:"expose,omitempty"` + ExternalLinks []string `mapstructure:"external_links" yaml:"external_links,omitempty" json:"external_links,omitempty"` + ExtraHosts HostsList `mapstructure:"extra_hosts" yaml:"extra_hosts,omitempty" json:"extra_hosts,omitempty"` + Hostname string `yaml:",omitempty" json:"hostname,omitempty"` + HealthCheck *HealthCheckConfig `yaml:",omitempty" json:"healthcheck,omitempty"` + Image string `yaml:",omitempty" json:"image,omitempty"` + Init *bool `yaml:",omitempty" json:"init,omitempty"` + Ipc string `yaml:",omitempty" json:"ipc,omitempty"` + Isolation string `mapstructure:"isolation" yaml:"isolation,omitempty" json:"isolation,omitempty"` + Labels Labels `yaml:",omitempty" json:"labels,omitempty"` + Links []string `yaml:",omitempty" json:"links,omitempty"` + Logging *LoggingConfig `yaml:",omitempty" json:"logging,omitempty"` + MacAddress string `mapstructure:"mac_address" yaml:"mac_address,omitempty" json:"mac_address,omitempty"` + NetworkMode string `mapstructure:"network_mode" yaml:"network_mode,omitempty" json:"network_mode,omitempty"` + Networks map[string]*ServiceNetworkConfig `yaml:",omitempty" json:"networks,omitempty"` + Pid string `yaml:",omitempty" json:"pid,omitempty"` + Ports []ServicePortConfig `yaml:",omitempty" json:"ports,omitempty"` + Privileged bool `yaml:",omitempty" json:"privileged,omitempty"` + ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty" json:"read_only,omitempty"` + Restart string `yaml:",omitempty" json:"restart,omitempty"` + Secrets []ServiceSecretConfig `yaml:",omitempty" json:"secrets,omitempty"` + SecurityOpt []string `mapstructure:"security_opt" yaml:"security_opt,omitempty" json:"security_opt,omitempty"` + ShmSize string `mapstructure:"shm_size" yaml:"shm_size,omitempty" json:"shm_size,omitempty"` + StdinOpen bool `mapstructure:"stdin_open" yaml:"stdin_open,omitempty" json:"stdin_open,omitempty"` + StopGracePeriod *Duration `mapstructure:"stop_grace_period" yaml:"stop_grace_period,omitempty" json:"stop_grace_period,omitempty"` + StopSignal string `mapstructure:"stop_signal" yaml:"stop_signal,omitempty" json:"stop_signal,omitempty"` + Sysctls Mapping `yaml:",omitempty" json:"sysctls,omitempty"` + Tmpfs StringList `yaml:",omitempty" json:"tmpfs,omitempty"` + Tty bool `mapstructure:"tty" yaml:"tty,omitempty" json:"tty,omitempty"` + Ulimits map[string]*UlimitsConfig `yaml:",omitempty" json:"ulimits,omitempty"` + User string `yaml:",omitempty" json:"user,omitempty"` + UserNSMode string `mapstructure:"userns_mode" yaml:"userns_mode,omitempty" json:"userns_mode,omitempty"` + Volumes []ServiceVolumeConfig `yaml:",omitempty" json:"volumes,omitempty"` + WorkingDir string `mapstructure:"working_dir" yaml:"working_dir,omitempty" json:"working_dir,omitempty"` + + Extras map[string]interface{} `yaml:",inline" json:"-"` +} + +// BuildConfig is a type for build +// using the same format at libcompose: https://github.com/docker/libcompose/blob/master/yaml/build.go#L12 +type BuildConfig struct { + Context string `yaml:",omitempty" json:"context,omitempty"` + Dockerfile string `yaml:",omitempty" json:"dockerfile,omitempty"` + Args MappingWithEquals `yaml:",omitempty" json:"args,omitempty"` + Labels Labels `yaml:",omitempty" json:"labels,omitempty"` + CacheFrom StringList `mapstructure:"cache_from" yaml:"cache_from,omitempty" json:"cache_from,omitempty"` + Network string `yaml:",omitempty" json:"network,omitempty"` + Target string `yaml:",omitempty" json:"target,omitempty"` +} + +// ShellCommand is a string or list of string args +type ShellCommand []string + +// StringList is a type for fields that can be a string or list of strings +type StringList []string + +// StringOrNumberList is a type for fields that can be a list of strings or +// numbers +type StringOrNumberList []string + +// MappingWithEquals is a mapping type that can be converted from a list of +// key[=value] strings. +// For the key with an empty value (`key=`), the mapped value is set to a pointer to `""`. +// For the key without value (`key`), the mapped value is set to nil. +type MappingWithEquals map[string]*string + +// Mapping is a mapping type that can be converted from a list of +// key[=value] strings. +// For the key with an empty value (`key=`), or key without value (`key`), the +// mapped value is set to an empty string `""`. +type Mapping map[string]string + +// Labels is a mapping type for labels +type Labels map[string]string + +// MappingWithColon is a mapping type that can be converted from a list of +// 'key: value' strings +type MappingWithColon map[string]string + +// HostsList is a list of colon-separated host-ip mappings +type HostsList []string + +// LoggingConfig the logging configuration for a service +type LoggingConfig struct { + Driver string `yaml:",omitempty" json:"driver,omitempty"` + Options map[string]string `yaml:",omitempty" json:"options,omitempty"` +} + +// DeployConfig the deployment configuration for a service +type DeployConfig struct { + Mode string `yaml:",omitempty" json:"mode,omitempty"` + Replicas *uint64 `yaml:",omitempty" json:"replicas,omitempty"` + Labels Labels `yaml:",omitempty" json:"labels,omitempty"` + UpdateConfig *UpdateConfig `mapstructure:"update_config" yaml:"update_config,omitempty" json:"update_config,omitempty"` + RollbackConfig *UpdateConfig `mapstructure:"rollback_config" yaml:"rollback_config,omitempty" json:"rollback_config,omitempty"` + Resources Resources `yaml:",omitempty" json:"resources,omitempty"` + RestartPolicy *RestartPolicy `mapstructure:"restart_policy" yaml:"restart_policy,omitempty" json:"restart_policy,omitempty"` + Placement Placement `yaml:",omitempty" json:"placement,omitempty"` + EndpointMode string `mapstructure:"endpoint_mode" yaml:"endpoint_mode,omitempty" json:"endpoint_mode,omitempty"` +} + +// HealthCheckConfig the healthcheck configuration for a service +type HealthCheckConfig struct { + Test HealthCheckTest `yaml:",omitempty" json:"test,omitempty"` + Timeout *Duration `yaml:",omitempty" json:"timeout,omitempty"` + Interval *Duration `yaml:",omitempty" json:"interval,omitempty"` + Retries *uint64 `yaml:",omitempty" json:"retries,omitempty"` + StartPeriod *Duration `mapstructure:"start_period" yaml:"start_period,omitempty" json:"start_period,omitempty"` + Disable bool `yaml:",omitempty" json:"disable,omitempty"` +} + +// HealthCheckTest is the command run to test the health of a service +type HealthCheckTest []string + +// UpdateConfig the service update configuration +type UpdateConfig struct { + Parallelism *uint64 `yaml:",omitempty" json:"parallelism,omitempty"` + Delay Duration `yaml:",omitempty" json:"delay,omitempty"` + FailureAction string `mapstructure:"failure_action" yaml:"failure_action,omitempty" json:"failure_action,omitempty"` + Monitor Duration `yaml:",omitempty" json:"monitor,omitempty"` + MaxFailureRatio float32 `mapstructure:"max_failure_ratio" yaml:"max_failure_ratio,omitempty" json:"max_failure_ratio,omitempty"` + Order string `yaml:",omitempty" json:"order,omitempty"` +} + +// Resources the resource limits and reservations +type Resources struct { + Limits *Resource `yaml:",omitempty" json:"limits,omitempty"` + Reservations *Resource `yaml:",omitempty" json:"reservations,omitempty"` +} + +// Resource is a resource to be limited or reserved +type Resource struct { + // TODO: types to convert from units and ratios + NanoCPUs string `mapstructure:"cpus" yaml:"cpus,omitempty" json:"cpus,omitempty"` + MemoryBytes UnitBytes `mapstructure:"memory" yaml:"memory,omitempty" json:"memory,omitempty"` + GenericResources []GenericResource `mapstructure:"generic_resources" yaml:"generic_resources,omitempty" json:"generic_resources,omitempty"` +} + +// GenericResource represents a "user defined" resource which can +// only be an integer (e.g: SSD=3) for a service +type GenericResource struct { + DiscreteResourceSpec *DiscreteGenericResource `mapstructure:"discrete_resource_spec" yaml:"discrete_resource_spec,omitempty" json:"discrete_resource_spec,omitempty"` +} + +// DiscreteGenericResource represents a "user defined" resource which is defined +// as an integer +// "Kind" is used to describe the Kind of a resource (e.g: "GPU", "FPGA", "SSD", ...) +// Value is used to count the resource (SSD=5, HDD=3, ...) +type DiscreteGenericResource struct { + Kind string `json:"kind"` + Value int64 `json:"value"` +} + +// UnitBytes is the bytes type +type UnitBytes int64 + +// MarshalYAML makes UnitBytes implement yaml.Marshaller +func (u UnitBytes) MarshalYAML() (interface{}, error) { + return fmt.Sprintf("%d", u), nil +} + +// MarshalJSON makes UnitBytes implement json.Marshaler +func (u UnitBytes) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%d"`, u)), nil +} + +// RestartPolicy the service restart policy +type RestartPolicy struct { + Condition string `yaml:",omitempty" json:"condition,omitempty"` + Delay *Duration `yaml:",omitempty" json:"delay,omitempty"` + MaxAttempts *uint64 `mapstructure:"max_attempts" yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"` + Window *Duration `yaml:",omitempty" json:"window,omitempty"` +} + +// Placement constraints for the service +type Placement struct { + Constraints []string `yaml:",omitempty" json:"constraints,omitempty"` + Preferences []PlacementPreferences `yaml:",omitempty" json:"preferences,omitempty"` + MaxReplicas uint64 `mapstructure:"max_replicas_per_node" yaml:"max_replicas_per_node,omitempty" json:"max_replicas_per_node,omitempty"` +} + +// PlacementPreferences is the preferences for a service placement +type PlacementPreferences struct { + Spread string `yaml:",omitempty" json:"spread,omitempty"` +} + +// ServiceNetworkConfig is the network configuration for a service +type ServiceNetworkConfig struct { + Aliases []string `yaml:",omitempty" json:"aliases,omitempty"` + Ipv4Address string `mapstructure:"ipv4_address" yaml:"ipv4_address,omitempty" json:"ipv4_address,omitempty"` + Ipv6Address string `mapstructure:"ipv6_address" yaml:"ipv6_address,omitempty" json:"ipv6_address,omitempty"` +} + +// ServicePortConfig is the port configuration for a service +type ServicePortConfig struct { + Mode string `yaml:",omitempty" json:"mode,omitempty"` + Target uint32 `yaml:",omitempty" json:"target,omitempty"` + Published uint32 `yaml:",omitempty" json:"published,omitempty"` + Protocol string `yaml:",omitempty" json:"protocol,omitempty"` +} + +// ServiceVolumeConfig are references to a volume used by a service +type ServiceVolumeConfig struct { + Type string `yaml:",omitempty" json:"type,omitempty"` + Source string `yaml:",omitempty" json:"source,omitempty"` + Target string `yaml:",omitempty" json:"target,omitempty"` + ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty" json:"read_only,omitempty"` + Consistency string `yaml:",omitempty" json:"consistency,omitempty"` + Bind *ServiceVolumeBind `yaml:",omitempty" json:"bind,omitempty"` + Volume *ServiceVolumeVolume `yaml:",omitempty" json:"volume,omitempty"` + Tmpfs *ServiceVolumeTmpfs `yaml:",omitempty" json:"tmpfs,omitempty"` +} + +// ServiceVolumeBind are options for a service volume of type bind +type ServiceVolumeBind struct { + Propagation string `yaml:",omitempty" json:"propagation,omitempty"` +} + +// ServiceVolumeVolume are options for a service volume of type volume +type ServiceVolumeVolume struct { + NoCopy bool `mapstructure:"nocopy" yaml:"nocopy,omitempty" json:"nocopy,omitempty"` +} + +// ServiceVolumeTmpfs are options for a service volume of type tmpfs +type ServiceVolumeTmpfs struct { + Size int64 `yaml:",omitempty" json:"size,omitempty"` +} + +// FileReferenceConfig for a reference to a swarm file object +type FileReferenceConfig struct { + Source string `yaml:",omitempty" json:"source,omitempty"` + Target string `yaml:",omitempty" json:"target,omitempty"` + UID string `yaml:",omitempty" json:"uid,omitempty"` + GID string `yaml:",omitempty" json:"gid,omitempty"` + Mode *uint32 `yaml:",omitempty" json:"mode,omitempty"` +} + +// ServiceConfigObjConfig is the config obj configuration for a service +type ServiceConfigObjConfig FileReferenceConfig + +// ServiceSecretConfig is the secret configuration for a service +type ServiceSecretConfig FileReferenceConfig + +// UlimitsConfig the ulimit configuration +type UlimitsConfig struct { + Single int `yaml:",omitempty" json:"single,omitempty"` + Soft int `yaml:",omitempty" json:"soft,omitempty"` + Hard int `yaml:",omitempty" json:"hard,omitempty"` +} + +// MarshalYAML makes UlimitsConfig implement yaml.Marshaller +func (u *UlimitsConfig) MarshalYAML() (interface{}, error) { + if u.Single != 0 { + return u.Single, nil + } + return u, nil +} + +// MarshalJSON makes UlimitsConfig implement json.Marshaller +func (u *UlimitsConfig) MarshalJSON() ([]byte, error) { + if u.Single != 0 { + return json.Marshal(u.Single) + } + // Pass as a value to avoid re-entering this method and use the default implementation + return json.Marshal(*u) +} + +// NetworkConfig for a network +type NetworkConfig struct { + Name string `yaml:",omitempty" json:"name,omitempty"` + Driver string `yaml:",omitempty" json:"driver,omitempty"` + DriverOpts map[string]string `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"` + Ipam IPAMConfig `yaml:",omitempty" json:"ipam,omitempty"` + External External `yaml:",omitempty" json:"external,omitempty"` + Internal bool `yaml:",omitempty" json:"internal,omitempty"` + Attachable bool `yaml:",omitempty" json:"attachable,omitempty"` + Labels Labels `yaml:",omitempty" json:"labels,omitempty"` + Extras map[string]interface{} `yaml:",inline" json:"-"` +} + +// IPAMConfig for a network +type IPAMConfig struct { + Driver string `yaml:",omitempty" json:"driver,omitempty"` + Config []*IPAMPool `yaml:",omitempty" json:"config,omitempty"` +} + +// IPAMPool for a network +type IPAMPool struct { + Subnet string `yaml:",omitempty" json:"subnet,omitempty"` +} + +// VolumeConfig for a volume +type VolumeConfig struct { + Name string `yaml:",omitempty" json:"name,omitempty"` + Driver string `yaml:",omitempty" json:"driver,omitempty"` + DriverOpts map[string]string `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"` + External External `yaml:",omitempty" json:"external,omitempty"` + Labels Labels `yaml:",omitempty" json:"labels,omitempty"` + Extras map[string]interface{} `yaml:",inline" json:"-"` +} + +// External identifies a Volume or Network as a reference to a resource that is +// not managed, and should already exist. +// External.name is deprecated and replaced by Volume.name +type External struct { + Name string `yaml:",omitempty" json:"name,omitempty"` + External bool `yaml:",omitempty" json:"external,omitempty"` +} + +// MarshalYAML makes External implement yaml.Marshaller +func (e External) MarshalYAML() (interface{}, error) { + if e.Name == "" { + return e.External, nil + } + return External{Name: e.Name}, nil +} + +// MarshalJSON makes External implement json.Marshaller +func (e External) MarshalJSON() ([]byte, error) { + if e.Name == "" { + return []byte(fmt.Sprintf("%v", e.External)), nil + } + return []byte(fmt.Sprintf(`{"name": %q}`, e.Name)), nil +} + +// CredentialSpecConfig for credential spec on Windows +type CredentialSpecConfig struct { + Config string `yaml:",omitempty" json:"config,omitempty"` // Config was added in API v1.40 + File string `yaml:",omitempty" json:"file,omitempty"` + Registry string `yaml:",omitempty" json:"registry,omitempty"` +} + +// FileObjectConfig is a config type for a file used by a service +type FileObjectConfig struct { + Name string `yaml:",omitempty" json:"name,omitempty"` + File string `yaml:",omitempty" json:"file,omitempty"` + External External `yaml:",omitempty" json:"external,omitempty"` + Labels Labels `yaml:",omitempty" json:"labels,omitempty"` + Extras map[string]interface{} `yaml:",inline" json:"-"` + Driver string `yaml:",omitempty" json:"driver,omitempty"` + DriverOpts map[string]string `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"` + TemplateDriver string `mapstructure:"template_driver" yaml:"template_driver,omitempty" json:"template_driver,omitempty"` +} + +// SecretConfig for a secret +type SecretConfig FileObjectConfig + +// ConfigObjConfig is the config for the swarm "Config" object +type ConfigObjConfig FileObjectConfig diff --git a/biz/docker/compose/volume.go b/biz/docker/compose/volume.go index 94de19c..7829898 100644 --- a/biz/docker/compose/volume.go +++ b/biz/docker/compose/volume.go @@ -5,6 +5,7 @@ import ( "unicode" "unicode/utf8" + "github.com/cuigh/swirl/biz/docker/compose/types" "github.com/docker/docker/api/types/mount" "github.com/pkg/errors" ) @@ -12,8 +13,8 @@ import ( const endOfSpec = rune(0) // ParseVolume parses a volume spec without any knowledge of the target platform -func ParseVolume(spec string) (ServiceVolumeConfig, error) { - volume := ServiceVolumeConfig{} +func ParseVolume(spec string) (types.ServiceVolumeConfig, error) { + volume := types.ServiceVolumeConfig{} switch len(spec) { case 0: @@ -48,7 +49,7 @@ func isWindowsDrive(buffer []rune, char rune) bool { return char == ':' && len(buffer) == 1 && unicode.IsLetter(buffer[0]) } -func populateFieldFromBuffer(char rune, buffer []rune, volume *ServiceVolumeConfig) error { +func populateFieldFromBuffer(char rune, buffer []rune, volume *types.ServiceVolumeConfig) error { strBuffer := string(buffer) switch { case len(buffer) == 0: @@ -73,10 +74,10 @@ func populateFieldFromBuffer(char rune, buffer []rune, volume *ServiceVolumeConf case "rw": volume.ReadOnly = false case "nocopy": - volume.Volume = &ServiceVolumeVolume{NoCopy: true} + volume.Volume = &types.ServiceVolumeVolume{NoCopy: true} default: if isBindOption(option) { - volume.Bind = &ServiceVolumeBind{Propagation: option} + volume.Bind = &types.ServiceVolumeBind{Propagation: option} } // ignore unknown options } @@ -93,7 +94,7 @@ func isBindOption(option string) bool { return false } -func populateType(volume *ServiceVolumeConfig) { +func populateType(volume *types.ServiceVolumeConfig) { switch { // Anonymous volume case volume.Source == "": @@ -111,6 +112,11 @@ func isFilePath(source string) bool { return true } + // windows named pipes + if strings.HasPrefix(source, `\\`) { + return true + } + first, nextIndex := utf8.DecodeRuneInString(source) return isWindowsDrive([]rune{first}, rune(source[nextIndex])) } diff --git a/go.mod b/go.mod index 12bda7e..83ea055 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/gobwas/ws v1.0.0 github.com/google/go-cmp v0.2.0 // indirect github.com/gorilla/mux v1.7.1 // indirect + github.com/imdario/mergo v0.3.7 github.com/kr/pretty v0.1.0 // indirect github.com/mattn/go-shellwords v1.0.3 github.com/mitchellh/mapstructure v1.0.0 diff --git a/go.sum b/go.sum index 27df9da..1642de9 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/gorilla/mux v1.7.1 h1:Dw4jY2nghMMRsh1ol8dv1axHkDwMQK2DHerMNJsIpJU= github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI= +github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=