blob: a4fc808b812fd78556defca910a27e69f5b1203e [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package manifest defines structure of YAML files with target definitions.
package manifest
import (
"fmt"
"io"
"io/ioutil"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v2"
"go.chromium.org/luci/common/errors"
)
// Manifest is a definition of what to build, how and where.
//
// Comments here describe the structure of the manifest file on disk. In the
// loaded form all paths use filepath.Separator as a directory separator.
type Manifest struct {
// Name is the name of this target, required.
//
// When building Docker images it is an image name (without registry or any
// tags).
Name string `yaml:"name"`
// ManifestDir is a directory that contains this manifest file.
//
// Populated when it is loaded.
ManifestDir string `yaml:"-"`
// Extends is a unix-style path (relative to this YAML file) to a manifest
// used as a base.
//
// Optional.
//
// Such base manifests usually contain definitions shared by many files, such
// as "imagepins" and "infra".
//
// Dicts are merged (recursively), lists are joined (base entries first).
Extends string `yaml:"extends,omitempty"`
// Dockerfile is a unix-style path to the image's Dockerfile, relative to this
// YAML file.
//
// Presence of this field indicates that the manifest describes how to build
// a docker image. If its missing, docker related subcommands won't work.
//
// All images referenced in this Dockerfile are resolved into concrete digests
// via an external file. See ImagePins field for more information.
Dockerfile string `yaml:"dockerfile,omitempty"`
// ContextDir is a unix-style path to the directory to use as a basis for
// the build. The path is relative to this YAML file.
//
// All files there end up available to the remote builder (e.g. a docker
// daemon will see this directory as a context directory when building
// the image).
//
// All symlinks there are resolved to their targets. Only +w and +x file mode
// bits are preserved (all files have 0444 mode by default, +w adds additional
// 0200 bit and +x adds additional 0111 bis). All other file metadata (owners,
// setuid bits, modification times) are ignored.
//
// The default value depends on whether Dockerfile is set. If it is, then
// ContextDir defaults to the directory with Dockerfile. Otherwise the context
// directory is assumed to be empty.
ContextDir string `yaml:"contextdir,omitempty"`
// InputsDir is an optional directory that can be used to reference files
// consumed by build steps (as "${inputsdir}/path").
//
// Unlike ContextDir, its full content does not automatically end up in the
// output.
InputsDir string `yaml:"inputsdir,omitempty"`
// ImagePins is a unix-style path to the YAML file with pre-resolved mapping
// from (docker image, tag) pair to the corresponding docker image digest.
//
// The path is relative to the manifest YAML file. It should point to a YAML
// file with the following structure:
//
// pins:
// - image: <img>
// tag: <tag>
// digest: sha256:<sha256>
// - image: <img>
// tag: <tag>
// digest: sha256:<sha256>
// ...
//
// See dockerfile.Pins struct for more details.
//
// This file will be used to rewrite the input Dockerfile to reference all
// images (in "FROM ..." lines) only by their digests. This is useful for
// reproducibility of builds.
//
// Only following forms of "FROM ..." statement are allowed:
// * FROM <image> [AS <name>] (assumes "latest" tag)
// * FROM <image>[:<tag>] [AS <name>] (resolves the given tag)
// * FROM <image>[@<digest>] [AS <name>] (passes the definition through)
//
// In particular ARGs in FROM line (e.g. "FROM base:${CODE_VERSION}") are
// not supported.
//
// If not set, the Dockerfile must use only digests to begin with, i.e.
// all FROM statements should have form "FROM <image>@<digest>".
//
// Ignored if Dockerfile field is not set.
ImagePins string `yaml:"imagepins,omitempty"`
// Deterministic is true if Dockerfile (with all "FROM" lines resolved) can be
// understood as a pure function of inputs in ContextDir, i.e. it does not
// depend on the state of the world.
//
// Examples of things that make Dockerfile NOT deterministic:
// * Using "apt-get" or any other remote calls to non-pinned resources.
// * Cloning repositories from "master" ref (or similar).
// * Fetching external resources using curl or wget.
//
// When building an image marked as deterministic, the builder will calculate
// a hash of all inputs (including resolve Dockerfile itself) and check
// whether there's already an image built from them. If there is, the build
// will be skipped completely and the existing image reused.
//
// Images marked as non-deterministic are always rebuilt and reuploaded, even
// if nothing in ContextDir has changed.
Deterministic *bool `yaml:"deterministic,omitempty"`
// Infra is configuration of the build infrastructure to use: Google Storage
// bucket, Cloud Build project, etc.
//
// Keys are names of presets (like "dev", "prod"). What preset is used is
// controlled via "-infra" command line flag (defaults to "dev").
Infra map[string]Infra `yaml:"infra"`
// Build defines a series of local build steps.
//
// Each step may add more files to the context directory. The actual
// `contextdir` directory on disk won't be modified. Files produced here are
// stored in a temp directory and the final context directory is constructed
// from the full recursive copy of `contextdir` and files emitted here.
Build []*BuildStep `yaml:"build,omitempty"`
}
// Infra contains configuration of build infrastructure to use: Google Storage
// bucket, Cloud Build project, etc.
type Infra struct {
// Storage specifies Google Storage location to store *.tar.gz tarballs
// produced after executing all local build steps.
//
// Expected format is "gs://<bucket>/<prefix>". Tarballs will be stored as
// "gs://<bucket>/<prefix>/<name>/<sha256>.tar.gz", where <name> comes from
// the manifest and <sha256> is a hex sha256 digest of the tarball.
//
// The bucket should exist already. Its contents is trusted, i.e. if there's
// an object with desired <sha256>.tar.gz there already, it won't be replaced.
//
// Required when using Cloud Build.
Storage string `yaml:"storage"`
// Registry is a Cloud Registry to push images to e.g. "gcr.io/something".
//
// If empty, images will be built and then just discarded (will not be pushed
// anywhere). Useful to verify Dockerfile is working without accumulating
// cruft.
Registry string `yaml:"registry"`
// CloudBuild contains configuration of Cloud Build infrastructure.
CloudBuild CloudBuildConfig `yaml:"cloudbuild"`
// Notify indicates what downstream services to notify once the image is
// built.
//
// It is not interpreted by cloudbuildhelper itself, just passed as is to
// the JSON output of `build` command. Callers (usually the images_builder
// recipe) know meaning of this field.
Notify []NotifyConfig `yaml:"notify"`
}
// NotifyConfig is a single item in `notify` list.
//
// It is just an arbitrary YAML dict not interpreted by the cloudbuildhelper.
type NotifyConfig map[string]interface{}
// rebaseOnTop implements "extends" logic.
func (i *Infra) rebaseOnTop(b Infra) {
setIfEmpty(&i.Storage, b.Storage)
setIfEmpty(&i.Registry, b.Registry)
i.CloudBuild.rebaseOnTop(b.CloudBuild)
if len(b.Notify) != 0 {
i.Notify = append([]NotifyConfig(nil), b.Notify...)
}
}
// CloudBuildConfig contains configuration of Cloud Build infrastructure.
type CloudBuildConfig struct {
Project string `yaml:"project"` // name of Cloud Project to use for builds
Docker string `yaml:"docker"` // version of "docker" tool to use for builds
}
// rebaseOnTop implements "extends" logic.
func (c *CloudBuildConfig) rebaseOnTop(b CloudBuildConfig) {
setIfEmpty(&c.Project, b.Project)
setIfEmpty(&c.Docker, b.Docker)
}
// BuildStep is one local build operation.
//
// It takes a local checkout and produces one or more output files put into
// the context directory.
//
// This struct is a "case class" with union of all supported build step kinds.
// The chosen "case" is returned by Concrete() method.
type BuildStep struct {
// Fields common to two or more build step kinds.
// Dest specifies a location to put the result into.
//
// Applies to `copy`, `go_build` and `go_gae_bundle` steps.
//
// Usually prefixed with "${contextdir}/" to indicate it is relative to
// the context directory.
//
// Optional in the original YAML, always populated after Manifest is parsed.
// See individual *BuildStep structs for defaults.
Dest string `yaml:"dest,omitempty"`
// Cwd is a working directory to run the command in.
//
// Applies to `run` and `go_build` steps.
//
// Default is ${inputsdir}, ${contextdir} or ${manifestdir}, whichever is set.
Cwd string `yaml:"cwd,omitempty"`
// Disjoint set of possible build kinds.
//
// To add a new step kind:
// 1. Add a new embedded struct here with definition of the step.
// 2. Add methods to implement ConcreteBuildStep.
// 3. Add one more entry to a slice in wireStep(...) below.
// 4. Add the actual step implementation to builder/step*.go.
// 5. Add one more type switch to Builder.Build() in builder/builder.go.
CopyBuildStep `yaml:",inline"` // copy a file or directory into the output
GoBuildStep `yaml:",inline"` // build go binary using "go build"
RunBuildStep `yaml:",inline"` // run a command that modifies the checkout
GoGAEBundleBuildStep `yaml:",inline"` // bundle Go source code for GAE
manifest *Manifest // the manifest that defined this step
index int // zero-based index of the step in its parent manifest
concrete ConcreteBuildStep // pointer to one of *BuildStep above
}
// ConcreteBuildStep is implemented by various *BuildStep structs.
type ConcreteBuildStep interface {
String() string // used for human logs only, doesn't have to encode all details
isEmpty() bool // true if the struct is not populated
initStep(bs *BuildStep, dirs map[string]string) error // populates 'bs' and self
}
// Concrete returns a pointer to some concrete populated *BuildStep.
func (bs *BuildStep) Concrete() ConcreteBuildStep { return bs.concrete }
// CopyBuildStep indicates we want to copy a file or directory.
//
// Doesn't materialize copies on disk, just puts them directly into the output
// file set.
type CopyBuildStep struct {
// Copy is a path to copy files from.
//
// Should start with either "${contextdir}/", "${inputsdir}/" or
// "${manifestdir}/" to indicate the root path.
//
// Can either be a directory or a file. Whatever it is, it will be put into
// the output as Dest. By default Dest is "${contextdir}/<basename of Copy>"
// (i.e. we copy Copy into the root of the context dir).
Copy string `yaml:"copy,omitempty"`
}
func (s *CopyBuildStep) String() string { return fmt.Sprintf("copy %q", s.Copy) }
func (s *CopyBuildStep) isEmpty() bool { return s.Copy == "" }
func (s *CopyBuildStep) initStep(bs *BuildStep, dirs map[string]string) (err error) {
if s.Copy, err = renderPath("copy", s.Copy, dirs); err != nil {
return err
}
if bs.Dest == "" {
bs.Dest = "${contextdir}/" + filepath.Base(s.Copy)
}
if bs.Dest, err = renderPath("dest", bs.Dest, dirs); err != nil {
return err
}
return
}
// GoBuildStep indicates we want to build a go command binary.
//
// Doesn't materialize the build output on disk, just puts it directly into the
// output file set.
type GoBuildStep struct {
// GoBinary specifies a go command binary to build.
//
// This is a path (relative to GOPATH) to some 'main' package. It will be
// built roughly as:
//
// $ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build <go_binary> -o <dest>
//
// Where <dest> is taken from Dest and it must be under the context directory.
// It is set to "${contextdir}/<go package name>" by default.
GoBinary string `yaml:"go_binary,omitempty"`
}
func (s *GoBuildStep) String() string { return fmt.Sprintf("go build %q", s.GoBinary) }
func (s *GoBuildStep) isEmpty() bool { return s.GoBinary == "" }
func (s *GoBuildStep) initStep(bs *BuildStep, dirs map[string]string) (err error) {
if err = renderCwd(&bs.Cwd, dirs); err != nil {
return
}
if bs.Dest == "" {
bs.Dest = "${contextdir}/" + path.Base(s.GoBinary)
}
bs.Dest, err = renderPath("dest", bs.Dest, dirs)
return
}
// RunBuildStep indicates we want to run some arbitrary command.
//
// The command may modify the checkout or populate the context dir.
type RunBuildStep struct {
// Run indicates a command to run along with all its arguments.
//
// Strings that start with "${contextdir}/", "${inputsdir}/" or
// "${manifestdir}/" will be rendered as absolute paths.
Run []string `yaml:"run,omitempty"`
// Outputs is a list of files or directories to put into the output.
//
// They are something that `run` should be generating.
//
// They are expected to be under "${contextdir}". A single output entry
// "${contextdir}/generated/file" is equivalent to a copy step that "picks up"
// the generated file:
// - copy: ${contextdir}/generated/file
// dest: ${contextdir}/generated/file
//
// If outputs are generated outside of the context directory, use `copy` steps
// explicitly.
Outputs []string
}
func (s *RunBuildStep) String() string { return fmt.Sprintf("run %q", s.Run) }
func (s *RunBuildStep) isEmpty() bool { return len(s.Run) == 0 && len(s.Outputs) == 0 }
func (s *RunBuildStep) initStep(bs *BuildStep, dirs map[string]string) (err error) {
if len(s.Run) == 0 {
return errors.Reason("bad `run` value: must not be empty").Err()
}
for i, val := range s.Run {
if isTemplatedPath(val) {
rel, err := renderPath(fmt.Sprintf("run[%d]", i), val, dirs)
if err != nil {
return err
}
// We are going to pass these arguments to a command with different cwd,
// need to make sure they are absolute.
if s.Run[i], err = filepath.Abs(rel); err != nil {
return errors.Annotate(err, "bad `run[%d]` %q", i, rel).Err()
}
}
}
if err = renderCwd(&bs.Cwd, dirs); err != nil {
return err
}
for i, out := range s.Outputs {
if s.Outputs[i], err = renderPath(fmt.Sprintf("output[%d]", i), out, dirs); err != nil {
return err
}
}
return
}
// GoGAEBundleBuildStep can be used to prepare a tarball with Go GAE app source.
//
// Given a path to a GAE module yaml (that should reside in a directory with
// some `main` go package), it:
// * Copies all files in the modules directory (including all non-go files)
// to `_gopath/src/<its import path>`
// * Copies all *.go code with transitive dependencies to `_gopath/src/`.
// * Makes `Dest` a symlink pointing to `_gopath/src/<import path>`.
//
// This ensures "gcloud app deploy" eventually can upload all *.go files needed
// to deploy a module.
type GoGAEBundleBuildStep struct {
// GoGAEBundle is path to GAE module YAML.
GoGAEBundle string `yaml:"go_gae_bundle,omitempty"`
}
func (s *GoGAEBundleBuildStep) String() string { return fmt.Sprintf("go gae bundle %q", s.GoGAEBundle) }
func (s *GoGAEBundleBuildStep) isEmpty() bool { return s.GoGAEBundle == "" }
func (s *GoGAEBundleBuildStep) initStep(bs *BuildStep, dirs map[string]string) (err error) {
if s.GoGAEBundle, err = renderPath("go_gae_bundle", s.GoGAEBundle, dirs); err != nil {
return
}
bs.Dest, err = renderPath("dest", bs.Dest, dirs)
return
}
// Load loads the manifest from the given path, traversing all "extends" links.
//
// After the manifest is loaded, its fields (like ContextDir) can be manipulated
// (e.g. to set defaults), after which all "${dir}/" references in build steps
// must be resolved by a call to RenderSteps.
func Load(path string) (*Manifest, error) {
return loadRecursive(path, 0)
}
// parse reads the manifest and populates paths there.
//
// If cwd is not empty, rebases all relative paths in it on top of it.
//
// Does not traverse "extends" links.
func parse(r io.Reader, cwd string) (*Manifest, error) {
body, err := ioutil.ReadAll(r)
if err != nil {
return nil, errors.Annotate(err, "failed to read the manifest body").Err()
}
out := Manifest{}
if err = yaml.Unmarshal(body, &out); err != nil {
return nil, errors.Annotate(err, "failed to parse the manifest").Err()
}
if err := out.initBase(cwd); err != nil {
return nil, err
}
return &out, nil
}
// loadRecursive implements Load by tracking how deep we go as a simple
// protection against recursive "extends" links.
func loadRecursive(path string, fileCount int) (*Manifest, error) {
r, err := os.Open(path)
if err != nil {
return nil, errors.Annotate(err, "when opening manifest file").Err()
}
defer r.Close()
m, err := parse(r, filepath.Dir(path))
switch {
case err != nil:
return nil, errors.Annotate(err, "when parsing %q", path).Err()
case m.Extends == "":
return m, nil
case fileCount > 10:
return nil, errors.Reason("too much nesting").Err()
}
base, err := loadRecursive(m.Extends, fileCount+1)
if err != nil {
return nil, errors.Annotate(err, "when loading %q", path).Err()
}
m.rebaseOnTop(base)
return m, nil
}
// initBase initializes pointers in steps and rebases paths.
//
// Doesn't yet touch actual bodies of steps, they will be initialized later
// when the whole manifest tree is loaded, see RenderSteps.
func (m *Manifest) initBase(cwd string) error {
if err := validateName(m.Name); err != nil {
return errors.Annotate(err, `bad "name" field`).Err()
}
m.ManifestDir = cwd
normPath(&m.Extends, cwd)
normPath(&m.Dockerfile, cwd)
normPath(&m.ContextDir, cwd)
normPath(&m.InputsDir, cwd)
normPath(&m.ImagePins, cwd)
if m.ContextDir == "" && m.Dockerfile != "" {
m.ContextDir = filepath.Dir(m.Dockerfile)
}
for k, v := range m.Infra {
if err := validateInfra(v); err != nil {
return errors.Annotate(err, "in infra section %q", k).Err()
}
}
for i, b := range m.Build {
if err := wireStep(b, m, i); err != nil {
return errors.Annotate(err, "bad build step #%d", i+1).Err()
}
}
return nil
}
// RenderSteps replaces "${dir}/" in paths in steps with actual values.
func (m *Manifest) RenderSteps() error {
for _, b := range m.Build {
dirs := map[string]string{
"contextdir": m.ContextDir,
"inputsdir": m.InputsDir,
"manifestdir": b.manifest.ManifestDir,
}
if err := b.concrete.initStep(b, dirs); err != nil {
return errors.Annotate(err, "bad build step #%d in %q", b.index+1, b.manifest.ManifestDir).Err()
}
}
return nil
}
// rebaseOnTop implements "extends" logic.
func (m *Manifest) rebaseOnTop(b *Manifest) {
m.Extends = "" // resolved now
setIfEmpty(&m.Dockerfile, b.Dockerfile)
setIfEmpty(&m.ContextDir, b.ContextDir)
setIfEmpty(&m.InputsDir, b.InputsDir)
setIfEmpty(&m.ImagePins, b.ImagePins)
if m.Deterministic == nil && b.Deterministic != nil {
cpy := *b.Deterministic
m.Deterministic = &cpy
}
// Rebase all entries already present in 'm' on top of entries in 'b'.
for k, v := range m.Infra {
if base, ok := b.Infra[k]; ok {
v.rebaseOnTop(base)
m.Infra[k] = v
}
}
// Copy all entries in 'b' that are not in 'm'.
for k, v := range b.Infra {
if _, ok := m.Infra[k]; !ok {
if m.Infra == nil {
m.Infra = make(map[string]Infra, 1)
}
m.Infra[k] = v
}
}
// Steps are just joined (base ones first).
m.Build = append(b.Build, m.Build...)
}
func setIfEmpty(a *string, b string) {
if *a == "" {
*a = b
}
}
// validateName validates "name" field in the manifest.
func validateName(t string) error {
const forbidden = "\\:@"
switch {
case t == "":
return errors.Reason("can't be empty, it's required").Err()
case strings.ContainsAny(t, forbidden):
return errors.Reason("%q contains forbidden symbols (any of %q)", t, forbidden).Err()
default:
return nil
}
}
func validateInfra(i Infra) error {
if i.Storage != "" {
url, err := url.Parse(i.Storage)
if err != nil {
return errors.Annotate(err, "bad storage %q", i.Storage).Err()
}
switch {
case url.Scheme != "gs":
return errors.Reason("bad storage %q, only gs:// is supported currently", i.Storage).Err()
case url.Host == "":
return errors.Reason("bad storage %q, bucket name is missing", i.Storage).Err()
}
}
return nil
}
// wireStep initializes `concrete` and `manifest` pointers in the step.
//
// Doesn't touch any other fields.
func wireStep(bs *BuildStep, m *Manifest, index int) error {
set := make([]ConcreteBuildStep, 0, 1)
for _, s := range []ConcreteBuildStep{
&bs.CopyBuildStep,
&bs.GoBuildStep,
&bs.RunBuildStep,
&bs.GoGAEBundleBuildStep,
} {
if !s.isEmpty() {
set = append(set, s)
}
}
// One and only one substruct should be populated.
switch {
case len(set) == 0:
return errors.Reason("unrecognized or empty").Err()
case len(set) > 1:
return errors.Reason("ambiguous").Err()
default:
bs.manifest = m
bs.index = index
bs.concrete = set[0]
return nil
}
}
func normPath(p *string, cwd string) {
if *p != "" {
*p = filepath.FromSlash(*p)
if !filepath.IsAbs(*p) && cwd != "" {
*p = filepath.Join(cwd, *p)
}
}
}
// isTemplatedPath is true if 'p' starts with "${<something>}[/]".
func isTemplatedPath(p string) bool {
parts := strings.SplitN(p, "/", 2)
return strings.HasPrefix(parts[0], "${") && strings.HasSuffix(parts[0], "}")
}
// renderPath verifies `p` starts with "${<something>}[/]", replaces it with
// dirs[<something>], and normalizes the result.
func renderPath(title, p string, dirs map[string]string) (string, error) {
if p == "" {
return "", errors.Reason("bad `%s`: must not be empty", title).Err()
}
// Helper for error messages.
keys := func() string {
ks := make([]string, 0, len(dirs))
for k := range dirs {
ks = append(ks, fmt.Sprintf("${%s}", k))
}
sort.Strings(ks)
return strings.Join(ks, " or ")
}
parts := strings.SplitN(p, "/", 2)
if !strings.HasPrefix(parts[0], "${") || !strings.HasSuffix(parts[0], "}") {
return "", errors.Reason("bad `%s`: must start with %s", title, keys()).Err()
}
switch val, ok := dirs[strings.TrimSuffix(strings.TrimPrefix(parts[0], "${"), "}")]; {
case !ok:
return "", errors.Reason("bad `%s`: unknown dir variable %s, expecting %s", title, parts[0], keys()).Err()
case val == "":
return "", errors.Reason("bad `%s`: dir variable %s it not set", title, parts[0]).Err()
case len(parts) == 1:
return val, nil
default:
return filepath.Join(val, filepath.FromSlash(parts[1])), nil
}
}
// renderCwd renders `cwd` to be an absolute path.
func renderCwd(cwd *string, dirs map[string]string) error {
if *cwd == "" {
switch {
case dirs["inputsdir"] != "":
*cwd = "${inputsdir}"
case dirs["contextdir"] != "":
*cwd = "${contextdir}"
case dirs["manifestdir"] != "":
*cwd = "${manifestdir}"
}
}
var err error
*cwd, err = renderPath("cwd", *cwd, dirs)
return err
}