// buildPipelines converts a set of resolved, valid references into pipelines. func (c *AppConfig) buildPipelines(components app.ComponentReferences, environment app.Environment) (app.PipelineGroup, error) { pipelines := app.PipelineGroup{} pipelineBuilder := app.NewPipelineBuilder(c.Name, c.GetBuildEnvironment(environment), c.OutputDocker).To(c.To) for _, group := range components.Group() { glog.V(4).Infof("found group: %v", group) common := app.PipelineGroup{} for _, ref := range group { refInput := ref.Input() from := refInput.String() var ( pipeline *app.Pipeline err error ) switch { case refInput.ExpectToBuild: glog.V(4).Infof("will add %q secrets into a build for a source build of %q", strings.Join(c.Secrets, ","), refInput.Uses) if err := refInput.Uses.AddBuildSecrets(c.Secrets); err != nil { return nil, fmt.Errorf("unable to add build secrets %q: %v", strings.Join(c.Secrets, ","), err) } glog.V(4).Infof("will use %q as the base image for a source build of %q", ref, refInput.Uses) if pipeline, err = pipelineBuilder.NewBuildPipeline(from, refInput.ResolvedMatch, refInput.Uses); err != nil { return nil, fmt.Errorf("can't build %q: %v", refInput.Uses, err) } default: glog.V(4).Infof("will include %q", ref) if pipeline, err = pipelineBuilder.NewImagePipeline(from, refInput.ResolvedMatch); err != nil { return nil, fmt.Errorf("can't include %q: %v", refInput, err) } } if c.Deploy { if err := pipeline.NeedsDeployment(environment, c.Labels); err != nil { return nil, fmt.Errorf("can't set up a deployment for %q: %v", refInput, err) } } if c.NoOutput { pipeline.Build.Output = nil } if err := pipeline.Validate(); err != nil { switch err.(type) { case app.CircularOutputReferenceError: if len(c.To) == 0 { // Output reference was generated, return error. return nil, err } // Output reference was explicitly provided, print warning. fmt.Fprintf(c.ErrOut, "--> WARNING: %v\n", err) default: return nil, err } } common = append(common, pipeline) if err := common.Reduce(); err != nil { return nil, fmt.Errorf("can't create a pipeline from %s: %v", common, err) } describeBuildPipelineWithImage(c.Out, ref, pipeline, c.originNamespace) } pipelines = append(pipelines, common...) } return pipelines, nil }
// buildPipelines converts a set of resolved, valid references into pipelines. func (c *AppConfig) buildPipelines(components app.ComponentReferences, environment app.Environment) (app.PipelineGroup, error) { pipelines := app.PipelineGroup{} pipelineBuilder := app.NewPipelineBuilder(c.Name, c.GetBuildEnvironment(environment), c.OutputDocker) for _, group := range components.Group() { glog.V(4).Infof("found group: %#v", group) common := app.PipelineGroup{} for _, ref := range group { refInput := ref.Input() from := refInput.String() var ( pipeline *app.Pipeline err error ) if refInput.ExpectToBuild { glog.V(4).Infof("will use %q as the base image for a source build of %q", ref, refInput.Uses) if pipeline, err = pipelineBuilder.NewBuildPipeline(from, refInput.ResolvedMatch, refInput.Uses); err != nil { return nil, fmt.Errorf("can't build %q: %v", refInput, err) } } else { glog.V(4).Infof("will include %q", ref) if pipeline, err = pipelineBuilder.NewImagePipeline(from, refInput.ResolvedMatch); err != nil { return nil, fmt.Errorf("can't include %q: %v", refInput, err) } } if c.Deploy { if err := pipeline.NeedsDeployment(environment, c.Labels); err != nil { return nil, fmt.Errorf("can't set up a deployment for %q: %v", refInput, err) } } common = append(common, pipeline) if err := common.Reduce(); err != nil { return nil, fmt.Errorf("can't create a pipeline from %s: %v", common, err) } describeBuildPipelineWithImage(c.Out, ref, pipeline, c.originNamespace) } pipelines = append(pipelines, common...) } return pipelines, nil }
// buildPipelines converts a set of resolved, valid references into pipelines. func (c *AppConfig) buildPipelines(components app.ComponentReferences, environment app.Environment) (app.PipelineGroup, error) { pipelines := app.PipelineGroup{} pipelineBuilder := app.NewPipelineBuilder(c.Name, c.GetBuildEnvironment(environment), c.OutputDocker).To(c.To) for _, group := range components.Group() { glog.V(4).Infof("found group: %v", group) common := app.PipelineGroup{} for _, ref := range group { refInput := ref.Input() from := refInput.String() var pipeline *app.Pipeline switch { case refInput.ExpectToBuild: glog.V(4).Infof("will add %q secrets into a build for a source build of %q", strings.Join(c.Secrets, ","), refInput.Uses) if err := refInput.Uses.AddBuildSecrets(c.Secrets); err != nil { return nil, fmt.Errorf("unable to add build secrets %q: %v", strings.Join(c.Secrets, ","), err) } var ( image *app.ImageRef err error ) if refInput.ResolvedMatch != nil { inputImage, err := app.InputImageFromMatch(refInput.ResolvedMatch) if err != nil { return nil, fmt.Errorf("can't build %q: %v", from, err) } if !inputImage.AsImageStream && from != "scratch" && (refInput.Uses == nil || refInput.Uses.GetStrategy() != generate.StrategyPipeline) { msg := "Could not find an image stream match for %q. Make sure that a Docker image with that tag is available on the node for the build to succeed." glog.Warningf(msg, from) } image = inputImage } glog.V(4).Infof("will use %q as the base image for a source build of %q", ref, refInput.Uses) if pipeline, err = pipelineBuilder.NewBuildPipeline(from, image, refInput.Uses); err != nil { return nil, fmt.Errorf("can't build %q: %v", refInput.Uses, err) } default: inputImage, err := app.InputImageFromMatch(refInput.ResolvedMatch) if err != nil { return nil, fmt.Errorf("can't include %q: %v", from, err) } if !inputImage.AsImageStream { msg := "Could not find an image stream match for %q. Make sure that a Docker image with that tag is available on the node for the deployment to succeed." glog.Warningf(msg, from) } glog.V(4).Infof("will include %q", ref) if pipeline, err = pipelineBuilder.NewImagePipeline(from, inputImage); err != nil { return nil, fmt.Errorf("can't include %q: %v", refInput, err) } } if c.Deploy { if err := pipeline.NeedsDeployment(environment, c.Labels, c.AsTestDeployment); err != nil { return nil, fmt.Errorf("can't set up a deployment for %q: %v", refInput, err) } } if c.NoOutput { pipeline.Build.Output = nil } if refInput.Uses != nil && refInput.Uses.GetStrategy() == generate.StrategyPipeline { pipeline.Build.Output = nil pipeline.Deployment = nil pipeline.Image = nil pipeline.InputImage = nil } common = append(common, pipeline) if err := common.Reduce(); err != nil { return nil, fmt.Errorf("can't create a pipeline from %s: %v", common, err) } describeBuildPipelineWithImage(c.Out, ref, pipeline, c.OriginNamespace) } pipelines = append(pipelines, common...) } return pipelines, nil }
// Generate accepts a set of Docker compose project paths and converts them in an // OpenShift template definition. func Generate(paths ...string) (*templateapi.Template, error) { for i := range paths { path, err := filepath.Abs(paths[i]) if err != nil { return nil, err } paths[i] = path } var bases []string for _, s := range paths { bases = append(bases, filepath.Dir(s)) } context := &project.Context{ ComposeFiles: paths, } p := project.NewProject(context) if err := p.Parse(); err != nil { return nil, err } template := &templateapi.Template{} template.Name = p.Name serviceOrder := sets.NewString() warnings := make(map[string][]string) for k, v := range p.Configs { serviceOrder.Insert(k) warnUnusableComposeElements(k, v, warnings) } g := app.NewImageRefGenerator() var errs []error var pipelines app.PipelineGroup builds := make(map[string]*app.Pipeline) // identify colocated components due to shared volumes joins := make(map[string]sets.String) volumesFrom := make(map[string][]string) for _, k := range serviceOrder.List() { if joins[k] == nil { joins[k] = sets.NewString(k) } v := p.Configs[k] for _, from := range v.VolumesFrom { switch parts := strings.Split(from, ":"); len(parts) { case 1: joins[k].Insert(parts[0]) volumesFrom[k] = append(volumesFrom[k], parts[0]) case 2: target := parts[1] if parts[1] == "ro" || parts[1] == "rw" { target = parts[0] } joins[k].Insert(target) volumesFrom[k] = append(volumesFrom[k], target) case 3: joins[k].Insert(parts[1]) volumesFrom[k] = append(volumesFrom[k], parts[1]) } } } joinOrder := sets.NewString() for k := range joins { joinOrder.Insert(k) } var colocated []sets.String for _, k := range joinOrder.List() { set := joins[k] matched := -1 for i, existing := range colocated { if set.Intersection(existing).Len() == 0 { continue } if matched != -1 { return nil, fmt.Errorf("%q belongs with %v, but %v also contains some overlapping elements", k, set, colocated[matched]) } existing.Insert(set.List()...) matched = i continue } if matched == -1 { colocated = append(colocated, set) } } // identify service aliases aliases := make(map[string]sets.String) for _, v := range p.Configs { for _, s := range v.Links.Slice() { parts := strings.SplitN(s, ":", 2) if len(parts) != 2 || parts[0] == parts[1] { continue } set := aliases[parts[0]] if set == nil { set = sets.NewString() aliases[parts[0]] = set } set.Insert(parts[1]) } } // find and define build pipelines for _, k := range serviceOrder.List() { v := p.Configs[k] if len(v.Build) == 0 { continue } if _, ok := builds[v.Build]; ok { continue } var base, relative string for _, s := range bases { if !strings.HasPrefix(v.Build, s) { continue } base = s path, err := filepath.Rel(base, v.Build) if err != nil { return nil, fmt.Errorf("path is not relative to base: %v", err) } relative = path break } if len(base) == 0 { return nil, fmt.Errorf("build path outside of the compose file: %s", v.Build) } // if this is a Git repository, make the path relative if root, err := git.NewRepository().GetRootDir(base); err == nil { if relative, err = filepath.Rel(root, filepath.Join(base, relative)); err != nil { return nil, fmt.Errorf("unable to find relative path for Git repository: %v", err) } base = root } buildPath := filepath.Join(base, relative) // TODO: what if there is no origin for this repo? glog.V(4).Infof("compose service: %#v", v) repo, err := app.NewSourceRepositoryWithDockerfile(buildPath, "") if err != nil { errs = append(errs, err) continue } repo.BuildWithDocker() info := repo.Info() if info == nil || info.Dockerfile == nil { errs = append(errs, fmt.Errorf("unable to locate a Dockerfile in %s", v.Build)) continue } node := info.Dockerfile.AST() baseImage := dockerfileutil.LastBaseImage(node) if len(baseImage) == 0 { errs = append(errs, fmt.Errorf("the Dockerfile in the repository %q has no FROM instruction", info.Path)) continue } var ports []string for _, s := range v.Ports { container, _ := extractFirstPorts(s) ports = append(ports, container) } image, err := g.FromNameAndPorts(baseImage, ports) if err != nil { errs = append(errs, err) continue } image.AsImageStream = true image.TagDirectly = true image.ObjectName = k image.Tag = "from" pipeline, err := app.NewPipelineBuilder(k, nil, false).To(k).NewBuildPipeline(k, image, repo) if err != nil { errs = append(errs, err) continue } if len(relative) > 0 { pipeline.Build.Source.ContextDir = relative } // TODO: this should not be necessary pipeline.Build.Source.Name = k pipeline.Name = k pipeline.Image.ObjectName = k glog.V(4).Infof("created pipeline %+v", pipeline) builds[v.Build] = pipeline pipelines = append(pipelines, pipeline) } if len(errs) > 0 { return nil, utilerrs.NewAggregate(errs) } // create deployment groups for _, pod := range colocated { var group app.PipelineGroup commonMounts := make(map[string]string) for _, k := range pod.List() { v := p.Configs[k] glog.V(4).Infof("compose service: %#v", v) var inputImage *app.ImageRef if len(v.Image) != 0 { image, err := g.FromName(v.Image) if err != nil { errs = append(errs, err) continue } image.AsImageStream = true image.TagDirectly = true image.ObjectName = k inputImage = image } if inputImage == nil { if previous, ok := builds[v.Build]; ok { inputImage = previous.Image } } if inputImage == nil { errs = append(errs, fmt.Errorf("could not find an input image for %q", k)) continue } inputImage.ContainerFn = func(c *kapi.Container) { if len(v.ContainerName) > 0 { c.Name = v.ContainerName } for _, s := range v.Ports { container, _ := extractFirstPorts(s) if port, err := strconv.Atoi(container); err == nil { c.Ports = append(c.Ports, kapi.ContainerPort{ContainerPort: port}) } } c.Args = v.Command.Slice() if len(v.Entrypoint.Slice()) > 0 { c.Command = v.Entrypoint.Slice() } if len(v.WorkingDir) > 0 { c.WorkingDir = v.WorkingDir } c.Env = append(c.Env, app.ParseEnvironment(v.Environment.Slice()...).List()...) if uid, err := strconv.Atoi(v.User); err == nil { uid64 := int64(uid) if c.SecurityContext == nil { c.SecurityContext = &kapi.SecurityContext{} } c.SecurityContext.RunAsUser = &uid64 } c.TTY = v.Tty if v.StdinOpen { c.StdinOnce = true c.Stdin = true } if v.Privileged { if c.SecurityContext == nil { c.SecurityContext = &kapi.SecurityContext{} } t := true c.SecurityContext.Privileged = &t } if v.ReadOnly { if c.SecurityContext == nil { c.SecurityContext = &kapi.SecurityContext{} } t := true c.SecurityContext.ReadOnlyRootFilesystem = &t } if v.MemLimit > 0 { q := resource.NewQuantity(v.MemLimit, resource.DecimalSI) if c.Resources.Limits == nil { c.Resources.Limits = make(kapi.ResourceList) } c.Resources.Limits[kapi.ResourceMemory] = *q } if quota := v.CPUQuota; quota > 0 { if quota < 1000 { quota = 1000 // minQuotaPeriod } milliCPU := quota * 1000 // milliCPUtoCPU milliCPU = milliCPU / 100000 // quotaPeriod q := resource.NewMilliQuantity(milliCPU, resource.DecimalSI) if c.Resources.Limits == nil { c.Resources.Limits = make(kapi.ResourceList) } c.Resources.Limits[kapi.ResourceCPU] = *q } if shares := v.CPUShares; shares > 0 { if shares < 2 { shares = 2 // minShares } milliCPU := shares * 1000 // milliCPUtoCPU milliCPU = milliCPU / 1024 // sharesPerCPU q := resource.NewMilliQuantity(milliCPU, resource.DecimalSI) if c.Resources.Requests == nil { c.Resources.Requests = make(kapi.ResourceList) } c.Resources.Requests[kapi.ResourceCPU] = *q } mountPoints := make(map[string][]string) for _, s := range v.Volumes { switch parts := strings.SplitN(s, ":", 3); len(parts) { case 1: mountPoints[""] = append(mountPoints[""], parts[0]) case 2: fallthrough default: mountPoints[parts[0]] = append(mountPoints[parts[0]], parts[1]) } } for from, at := range mountPoints { name, ok := commonMounts[from] if !ok { name = fmt.Sprintf("dir-%d", len(commonMounts)+1) commonMounts[from] = name } for _, path := range at { c.VolumeMounts = append(c.VolumeMounts, kapi.VolumeMount{Name: name, MountPath: path}) } } } pipeline, err := app.NewPipelineBuilder(k, nil, true).To(k).NewImagePipeline(k, inputImage) if err != nil { errs = append(errs, err) break } if err := pipeline.NeedsDeployment(nil, nil, false); err != nil { return nil, err } group = append(group, pipeline) } if err := group.Reduce(); err != nil { return nil, err } pipelines = append(pipelines, group...) } if len(errs) > 0 { return nil, utilerrs.NewAggregate(errs) } acceptors := app.Acceptors{app.NewAcceptUnique(kapi.Scheme), app.AcceptNew} objects := app.Objects{} accept := app.NewAcceptFirst() for _, p := range pipelines { accepted, err := p.Objects(accept, acceptors) if err != nil { return nil, fmt.Errorf("can't setup %q: %v", p.From, err) } objects = append(objects, accepted...) } // create services for each object with a name based on alias. containers := make(map[string]*kapi.Container) var services []*kapi.Service for _, obj := range objects { switch t := obj.(type) { case *deployapi.DeploymentConfig: ports := app.UniqueContainerToServicePorts(app.AllContainerPorts(t.Spec.Template.Spec.Containers...)) if len(ports) == 0 { continue } svc := app.GenerateService(t.ObjectMeta, t.Spec.Selector) if aliases[svc.Name].Len() == 1 { svc.Name = aliases[svc.Name].List()[0] } svc.Spec.Ports = ports services = append(services, svc) // take a reference to each container for i := range t.Spec.Template.Spec.Containers { c := &t.Spec.Template.Spec.Containers[i] containers[c.Name] = c } } } for _, svc := range services { objects = append(objects, svc) } // for each container that defines VolumesFrom, copy equivalent mounts. // TODO: ensure mount names are unique? for target, otherContainers := range volumesFrom { for _, from := range otherContainers { for _, volume := range containers[from].VolumeMounts { containers[target].VolumeMounts = append(containers[target].VolumeMounts, volume) } } } template.Objects = objects // generate warnings if len(warnings) > 0 { allWarnings := sets.NewString() for msg, services := range warnings { allWarnings.Insert(fmt.Sprintf("%s: %s", strings.Join(services, ","), msg)) } if template.Annotations == nil { template.Annotations = make(map[string]string) } template.Annotations[app.GenerationWarningAnnotation] = fmt.Sprintf("not all docker-compose fields were honored:\n* %s", strings.Join(allWarnings.List(), "\n* ")) } return template, nil }
// Generate accepts a path to an app.json file and generates a template from it func (g *Generator) Generate(body []byte) (*templateapi.Template, error) { appJSON := &AppJSON{} if err := json.Unmarshal(body, appJSON); err != nil { return nil, err } glog.V(4).Infof("app.json: %#v", appJSON) name := g.Name if len(name) == 0 && len(g.LocalPath) > 0 { name = filepath.Base(g.LocalPath) } template := &templateapi.Template{} template.Name = name template.Annotations = make(map[string]string) template.Annotations["openshift.io/website"] = appJSON.Website template.Annotations["k8s.io/display-name"] = appJSON.Name template.Annotations["k8s.io/description"] = appJSON.Description template.Annotations["tags"] = strings.Join(appJSON.Keywords, ",") template.Annotations["iconURL"] = appJSON.Logo // create parameters and environment for containers allEnv := make(app.Environment) for k, v := range appJSON.Env { if v.EnvVar != nil { allEnv[k] = fmt.Sprintf("${%s}", k) } } envVars := allEnv.List() for _, v := range envVars { env := appJSON.Env[v.Name] if env.EnvVar == nil { continue } e := env.EnvVar displayName := v.Name displayName = strings.Join(strings.Split(strings.ToLower(displayName), "_"), " ") displayName = strings.ToUpper(displayName[:1]) + displayName[1:] param := templateapi.Parameter{ Name: v.Name, DisplayName: displayName, Description: e.Description, Value: e.Value, } switch e.Generator { case "secret": param.Generate = "expression" param.From = "[a-zA-Z0-9]{14}" } if len(param.Value) == 0 && e.Default != nil { switch t := e.Default.(type) { case string: param.Value = t case float64, float32: out, _ := json.Marshal(t) param.Value = string(out) } } template.Parameters = append(template.Parameters, param) } warnings := make(map[string][]string) if len(appJSON.Formation) == 0 { glog.V(4).Infof("No formation in app.json, adding a default web") // TODO: read Procfile for command? appJSON.Formation = map[string]Formation{ "web": { Quantity: 1, }, } msg := "adding a default formation 'web' with scale 1" warnings[msg] = append(warnings[msg], "app.json") } formations := sets.NewString() for k := range appJSON.Formation { formations.Insert(k) } var primaryFormation = "web" if _, ok := appJSON.Formation["web"]; !ok || len(appJSON.Formation) == 1 { for k := range appJSON.Formation { primaryFormation = k break } } imageGen := app.NewImageRefGenerator() buildPath := appJSON.Repository if len(buildPath) == 0 && len(g.LocalPath) > 0 { buildPath = g.LocalPath } if len(buildPath) == 0 { return nil, fmt.Errorf("app.json did not contain a repository URL and no local path was specified") } repo, err := app.NewSourceRepository(buildPath, generate.StrategyDocker) if err != nil { return nil, err } var ports []string var pipelines app.PipelineGroup baseImage := g.BaseImage if len(baseImage) == 0 { baseImage = appJSON.Image } if len(baseImage) == 0 { return nil, fmt.Errorf("Docker image required: provide an --image flag or 'image' key in app.json") } fakeDockerfile := heredoc.Docf(` # Generated from app.json FROM %s `, baseImage) dockerfilePath := filepath.Join(buildPath, "Dockerfile") if df, err := app.NewDockerfileFromFile(dockerfilePath); err == nil { repo.Info().Dockerfile = df repo.Info().Path = dockerfilePath ports = dockerfile.LastExposedPorts(df.AST()) } // TODO: look for procfile for more info? image, err := imageGen.FromNameAndPorts(baseImage, ports) if err != nil { return nil, err } image.AsImageStream = true image.TagDirectly = true image.ObjectName = name image.Tag = "from" pipeline, err := app.NewPipelineBuilder(name, nil, false).To(name).NewBuildPipeline(name, image, repo) if err != nil { return nil, err } // TODO: this should not be necessary pipeline.Build.Source.Name = name pipeline.Build.Source.DockerfileContents = fakeDockerfile pipeline.Name = name pipeline.Image.ObjectName = name glog.V(4).Infof("created pipeline %+v", pipeline) pipelines = append(pipelines, pipeline) var errs []error // create deployments for each formation var group app.PipelineGroup for _, component := range formations.List() { componentName := fmt.Sprintf("%s-%s", name, component) if formations.Len() == 1 { componentName = name } formationName := component formation := appJSON.Formation[component] inputImage := pipelines[0].Image inputImage.ContainerFn = func(c *kapi.Container) { for _, s := range ports { if port, err := strconv.Atoi(s); err == nil { c.Ports = append(c.Ports, kapi.ContainerPort{ContainerPort: int32(port)}) } } if len(formation.Command) > 0 { c.Args = []string{formation.Command} } else { msg := "no command defined, defaulting to command in the Procfile" warnings[msg] = append(warnings[msg], formationName) c.Args = []string{"/bin/sh", "-c", fmt.Sprintf("$(grep %s Procfile | cut -f 2 -d :)", formationName)} } c.Env = append(c.Env, envVars...) c.Resources = resourcesForProfile(formation.Size) } pipeline, err := app.NewPipelineBuilder(componentName, nil, true).To(componentName).NewImagePipeline(componentName, inputImage) if err != nil { errs = append(errs, err) break } if err := pipeline.NeedsDeployment(nil, nil, false); err != nil { return nil, err } if cmd, ok := appJSON.Scripts["postdeploy"]; ok && primaryFormation == component { pipeline.Deployment.PostHook = &app.DeploymentHook{Shell: cmd} delete(appJSON.Scripts, "postdeploy") } group = append(group, pipeline) } if err := group.Reduce(); err != nil { return nil, err } pipelines = append(pipelines, group...) if len(errs) > 0 { return nil, utilerrs.NewAggregate(errs) } acceptors := app.Acceptors{app.NewAcceptUnique(kapi.Scheme), app.AcceptNew} objects := app.Objects{} accept := app.NewAcceptFirst() for _, p := range pipelines { accepted, err := p.Objects(accept, acceptors) if err != nil { return nil, fmt.Errorf("can't setup %q: %v", p.From, err) } objects = append(objects, accepted...) } // create services for each object with a name based on alias. var services []*kapi.Service for _, obj := range objects { switch t := obj.(type) { case *deployapi.DeploymentConfig: ports := app.UniqueContainerToServicePorts(app.AllContainerPorts(t.Spec.Template.Spec.Containers...)) if len(ports) == 0 { continue } svc := app.GenerateService(t.ObjectMeta, t.Spec.Selector) svc.Spec.Ports = ports services = append(services, svc) } } for _, svc := range services { objects = append(objects, svc) } template.Objects = objects // generate warnings warnUnusableAppJSONElements("app.json", appJSON, warnings) if len(warnings) > 0 { allWarnings := sets.NewString() for msg, services := range warnings { allWarnings.Insert(fmt.Sprintf("%s: %s", strings.Join(services, ","), msg)) } if template.Annotations == nil { template.Annotations = make(map[string]string) } template.Annotations[app.GenerationWarningAnnotation] = fmt.Sprintf("not all app.json fields were honored:\n* %s", strings.Join(allWarnings.List(), "\n* ")) } return template, nil }