diff --git a/builder/build.go b/builder/build.go new file mode 100644 index 000000000..bcbe73200 --- /dev/null +++ b/builder/build.go @@ -0,0 +1,153 @@ +package builder + +import ( + "io" + "sync" + "time" + + "github.com/drone/drone/common" + "github.com/samalba/dockerclient" +) + +// B is a type passed to build nodes. B implements an io.Writer +// and will accumulate build output during execution. +type B struct { + sync.Mutex + + Repo *common.Repo + Build *common.Build + Task *common.Task + Clone *common.Clone + + client dockerclient.Client + + writer io.Writer + + exitCode int + + start time.Time // Time build started + duration time.Duration + timerOn bool + + containers []string +} + +// NewB returns a new Build context. +func NewB(client dockerclient.Client, w io.Writer) *B { + return &B{ + client: client, + writer: w, + } +} + +// Run creates and runs a Docker container. +func (b *B) Run(conf *dockerclient.ContainerConfig) (string, error) { + b.Lock() + defer b.Unlock() + + name, err := b.client.CreateContainer(conf, "") + if err != nil { + // on error try to pull the Docker image. + // note that this may not be the cause of + // the error, but we'll try just in case. + b.client.PullImage(conf.Image, nil) + + // then try to re-create + name, err = b.client.CreateContainer(conf, "") + if err != nil { + return name, err + } + } + b.containers = append(b.containers, name) + err = b.client.StartContainer(name, &conf.HostConfig) + if err != nil { + return name, err + } + + return name, nil +} + +// Inspect inspects the running Docker container and returns +// the contianer runtime information and state. +func (b *B) Inspect(name string) (*dockerclient.ContainerInfo, error) { + return b.client.InspectContainer(name) +} + +// Remove stops and removes the named Docker container. +func (b *B) Remove(name string) { + b.client.StopContainer(name, 5) + b.client.KillContainer(name, "9") + b.client.RemoveContainer(name, true, true) +} + +// RemoveAll stops and removes all Docker containers that were +// created and started during the build process. +func (b *B) RemoveAll() { + b.Lock() + defer b.Unlock() + + for i := len(b.containers) - 1; i >= 0; i-- { + b.Remove(b.containers[i]) + } +} + +// Logs returns an io.ReadCloser for reading the build stream of +// the named Docker container. +func (b *B) Logs(name string) (io.ReadCloser, error) { + opts := dockerclient.LogOptions{ + Follow: true, + Stderr: true, + Stdout: true, + Timestamps: false, + } + return b.client.ContainerLogs(name, &opts) +} + +// StartTimer starts timing a build. This function is called automatically +// before a build starts, but it can also used to resume timing after +// a call to StopTimer. +func (b *B) StartTimer() { + b.Lock() + defer b.Unlock() + + if !b.timerOn { + b.start = time.Now() + b.timerOn = true + } +} + +// StopTimer stops timing a build. This can be used to pause the timer +// while performing complex initialization that you don't want to measure. +func (b *B) StopTimer() { + b.Lock() + defer b.Unlock() + + if b.timerOn { + b.duration += time.Now().Sub(b.start) + b.timerOn = false + } +} + +// Write writes the build stdout and stderr to the result. +func (b *B) Write(p []byte) (n int, err error) { + return b.writer.Write(p) +} + +// Exit writes the function as having failed but continues execution. +func (b *B) Exit(code int) { + b.Lock() + defer b.Unlock() + + if code != 0 { // never override non-zero exit + b.exitCode = code + } +} + +// ExitCode reports the build exit code. A non-zero value indicates +// the build exited with errors. +func (b *B) ExitCode() int { + b.Lock() + defer b.Unlock() + + return b.exitCode +} diff --git a/builder/builder.go b/builder/builder.go new file mode 100644 index 000000000..203011df4 --- /dev/null +++ b/builder/builder.go @@ -0,0 +1,81 @@ +package builder + +import "github.com/drone/drone/common" + +// Builder represents a build execution tree. +type Builder struct { + builds Node + deploy Node + notify Node +} + +// Run runs the build, deploy and notify nodes +// in the build tree. +func (b *Builder) Run(build *B) error { + var err error + err = b.RunBuild(build) + if err != nil { + return err + } + err = b.RunDeploy(build) + if err != nil { + return err + } + return b.RunNotify(build) +} + +// RunBuild runs only the build node. +func (b *Builder) RunBuild(build *B) error { + return b.builds.Run(build) +} + +// RunDeploy runs only the deploy node. +func (b *Builder) RunDeploy(build *B) error { + return b.notify.Run(build) +} + +// RunNotify runs on the notify node. +func (b *Builder) RunNotify(build *B) error { + return b.notify.Run(build) +} + +func (b *Builder) HasDeploy() bool { + return len(b.deploy.(serialNode)) != 0 +} + +func (b *Builder) HasNotify() bool { + return len(b.notify.(serialNode)) != 0 +} + +// Load loads a build configuration file. +func Load(conf *common.Config) *Builder { + var ( + builds []Node + deploys []Node + notifys []Node + ) + + for _, step := range conf.Compose { + builds = append(builds, &serviceNode{step}) // compose + } + builds = append(builds, &batchNode{conf.Setup}) // setup + if conf.Clone != nil { + builds = append(builds, &batchNode{conf.Clone}) // clone + } + builds = append(builds, &batchNode{conf.Build}) // build + + for _, step := range conf.Publish { + deploys = append(deploys, &batchNode{step}) // publish + } + for _, step := range conf.Deploy { + deploys = append(deploys, &batchNode{step}) // deploy + } + for _, step := range conf.Notify { + notifys = append(notifys, &batchNode{step}) // notify + } + return &Builder{ + serialNode(builds), + serialNode(deploys), + serialNode(notifys), + } +} diff --git a/builder/copy.go b/builder/copy.go new file mode 100644 index 000000000..ade4a9e27 --- /dev/null +++ b/builder/copy.go @@ -0,0 +1,124 @@ +package builder + +import ( + "encoding/binary" + "errors" + "io" +) + +const ( + StdWriterPrefixLen = 8 + StdWriterFdIndex = 0 + StdWriterSizeIndex = 4 +) + +type StdType [StdWriterPrefixLen]byte + +var ( + Stdin StdType = StdType{0: 0} + Stdout StdType = StdType{0: 1} + Stderr StdType = StdType{0: 2} +) + +type StdWriter struct { + io.Writer + prefix StdType + sizeBuf []byte +} + +var ErrInvalidStdHeader = errors.New("Unrecognized input header") + +// StdCopy is a modified version of io.Copy. +// +// StdCopy will demultiplex `src`, assuming that it contains two streams, +// previously multiplexed together using a StdWriter instance. +// As it reads from `src`, StdCopy will write to `dstout` and `dsterr`. +// +// StdCopy will read until it hits EOF on `src`. It will then return a nil error. +// In other words: if `err` is non nil, it indicates a real underlying error. +// +// `written` will hold the total number of bytes written to `dstout` and `dsterr`. +func StdCopy(dstout, dsterr io.Writer, src io.Reader) (written int64, err error) { + var ( + buf = make([]byte, 32*1024+StdWriterPrefixLen+1) + bufLen = len(buf) + nr, nw int + er, ew error + out io.Writer + frameSize int + ) + + for { + // Make sure we have at least a full header + for nr < StdWriterPrefixLen { + var nr2 int + nr2, er = src.Read(buf[nr:]) + nr += nr2 + if er == io.EOF { + if nr < StdWriterPrefixLen { + return written, nil + } + break + } + if er != nil { + return 0, er + } + } + + // Check the first byte to know where to write + switch buf[StdWriterFdIndex] { + case 0: + fallthrough + case 1: + // Write on stdout + out = dstout + case 2: + // Write on stderr + out = dsterr + default: + return 0, ErrInvalidStdHeader + } + + // Retrieve the size of the frame + frameSize = int(binary.BigEndian.Uint32(buf[StdWriterSizeIndex : StdWriterSizeIndex+4])) + + // Check if the buffer is big enough to read the frame. + // Extend it if necessary. + if frameSize+StdWriterPrefixLen > bufLen { + buf = append(buf, make([]byte, frameSize+StdWriterPrefixLen-bufLen+1)...) + bufLen = len(buf) + } + + // While the amount of bytes read is less than the size of the frame + header, we keep reading + for nr < frameSize+StdWriterPrefixLen { + var nr2 int + nr2, er = src.Read(buf[nr:]) + nr += nr2 + if er == io.EOF { + if nr < frameSize+StdWriterPrefixLen { + return written, nil + } + break + } + if er != nil { + return 0, er + } + } + + // Write the retrieved frame (without header) + nw, ew = out.Write(buf[StdWriterPrefixLen : frameSize+StdWriterPrefixLen]) + if ew != nil { + return 0, ew + } + // If the frame has not been fully written: error + if nw != frameSize { + return 0, io.ErrShortWrite + } + written += int64(nw) + + // Move the rest of the buffer to the beginning + copy(buf, buf[frameSize+StdWriterPrefixLen:]) + // Move the index + nr -= frameSize + StdWriterPrefixLen + } +} diff --git a/builder/docker/ambassador.go b/builder/docker/ambassador.go new file mode 100644 index 000000000..8b5fe4cf0 --- /dev/null +++ b/builder/docker/ambassador.go @@ -0,0 +1,110 @@ +package docker + +import ( + "errors" + + log "github.com/Sirupsen/logrus" + "github.com/samalba/dockerclient" +) + +var errNop = errors.New("Operation not supported") + +// Ambassador is a wrapper around the Docker client that +// provides a shared volume and network for all containers. +type Ambassador struct { + dockerclient.Client + name string +} + +// NewAmbassador creates an ambassador container and wraps the Docker +// client to inject the ambassador volume and network into containers. +func NewAmbassador(client dockerclient.Client) (_ *Ambassador, err error) { + amb := &Ambassador{client, ""} + + conf := &dockerclient.ContainerConfig{} + host := &dockerclient.HostConfig{} + conf.Entrypoint = []string{"/bin/sleep"} + conf.Cmd = []string{"86400"} + conf.Image = "busybox" + conf.Volumes = map[string]struct{}{} + conf.Volumes["/drone"] = struct{}{} + + // creates the ambassador container + amb.name, err = client.CreateContainer(conf, "") + if err != nil { + log.WithField("ambassador", conf.Image).Errorln(err) + + // on failure attempts to pull the image + client.PullImage(conf.Image, nil) + + // then attempts to re-create the container + amb.name, err = client.CreateContainer(conf, "") + if err != nil { + log.WithField("ambassador", conf.Image).Errorln(err) + return nil, err + } + } + err = client.StartContainer(amb.name, host) + if err != nil { + log.WithField("ambassador", conf.Image).Errorln(err) + } + return amb, err +} + +// Destroy stops and deletes the ambassador container. +func (c *Ambassador) Destroy() error { + c.Client.StopContainer(c.name, 5) + c.Client.KillContainer(c.name, "9") + return c.Client.RemoveContainer(c.name, true, true) +} + +// CreateContainer creates a container. +func (c *Ambassador) CreateContainer(conf *dockerclient.ContainerConfig, name string) (string, error) { + log.WithField("image", conf.Image).Infoln("create container") + + // add the affinity flag for swarm + conf.Env = append(conf.Env, "affinity:container=="+c.name) + + id, err := c.Client.CreateContainer(conf, name) + if err != nil { + log.WithField("image", conf.Image).Errorln(err) + } + return id, err +} + +// StartContainer starts a container. The ambassador volume +// is automatically linked. The ambassador network is linked +// iff a network mode is not already specified. +func (c *Ambassador) StartContainer(id string, conf *dockerclient.HostConfig) error { + log.WithField("container", id).Debugln("start container") + + conf.VolumesFrom = append(conf.VolumesFrom, c.name) + if len(conf.NetworkMode) == 0 { + conf.NetworkMode = "container:" + c.name + } + err := c.Client.StartContainer(id, conf) + if err != nil { + log.WithField("container", id).Errorln(err) + } + return err +} + +// StopContainer stops a container. +func (c *Ambassador) StopContainer(id string, timeout int) error { + log.WithField("container", id).Debugln("stop container") + err := c.Client.StopContainer(id, timeout) + if err != nil { + log.WithField("container", id).Errorln(err) + } + return err +} + +// PullImage pulls an image. +func (c *Ambassador) PullImage(name string, auth *dockerclient.AuthConfig) error { + log.WithField("image", name).Debugln("pull image") + err := c.Client.PullImage(name, auth) + if err != nil { + log.WithField("image", name).Errorln(err) + } + return err +} diff --git a/builder/node.go b/builder/node.go new file mode 100644 index 000000000..ce4a753fe --- /dev/null +++ b/builder/node.go @@ -0,0 +1,103 @@ +package builder + +import ( + "sync" + + "github.com/drone/drone/common" +) + +// Node is an element in the build execution tree. +type Node interface { + Run(*B) error +} + +// parallelNode runs a set of build nodes in parallel. +type parallelNode []Node + +func (n parallelNode) Run(b *B) error { + var wg sync.WaitGroup + for _, node := range n { + wg.Add(1) + + go func(node Node) { + defer wg.Done() + node.Run(b) + }(node) + } + wg.Wait() + return nil +} + +// serialNode runs a set of build nodes in sequential order. +type serialNode []Node + +func (n serialNode) Run(b *B) error { + for _, node := range n { + err := node.Run(b) + if err != nil { + return err + } + if b.ExitCode() != 0 { + return nil + } + } + return nil +} + +// batchNode runs a container and blocks until complete. +type batchNode struct { + step *common.Step +} + +func (n *batchNode) Run(b *B) error { + + // switch { + // case n.step.Condition == nil: + // case n.step.Condition.MatchBranch(b.Commit.Branch) == false: + // return nil + // case n.step.Condition.MatchOwner(b.Repo.Owner) == false: + // return nil + // } + + // creates the container conf + conf := toContainerConfig(n.step) + if n.step.Config != nil { + conf.Cmd = toCommand(b, n.step) + } + + // inject environment vars + injectEnv(b, conf) + + name, err := b.Run(conf) + if err != nil { + return err + } + + // streams the logs to the build results + rc, err := b.Logs(name) + if err != nil { + return err + } + StdCopy(b, b, rc) + //io.Copy(b, rc) + + // inspects the results and writes the + // build result exit code + info, err := b.Inspect(name) + if err != nil { + return err + } + b.Exit(info.State.ExitCode) + return nil +} + +// serviceNode runs a container, blocking, writes output, uses config section +type serviceNode struct { + step *common.Step +} + +func (n *serviceNode) Run(b *B) error { + conf := toContainerConfig(n.step) + _, err := b.Run(conf) + return err +} diff --git a/builder/pool/pool.go b/builder/pool/pool.go new file mode 100644 index 000000000..31061029b --- /dev/null +++ b/builder/pool/pool.go @@ -0,0 +1,89 @@ +package pool + +import ( + "sync" + + "github.com/samalba/dockerclient" +) + +// TODO (bradrydzewski) ability to cancel work. +// TODO (bradrydzewski) ability to remove a worker. + +type Pool struct { + sync.Mutex + clients map[dockerclient.Client]bool + clientc chan dockerclient.Client +} + +func New() *Pool { + return &Pool{ + clients: make(map[dockerclient.Client]bool), + clientc: make(chan dockerclient.Client, 999), + } +} + +// Allocate allocates a client to the pool to +// be available to accept work. +func (p *Pool) Allocate(c dockerclient.Client) bool { + if p.IsAllocated(c) { + return false + } + + p.Lock() + p.clients[c] = true + p.Unlock() + + p.clientc <- c + return true +} + +// IsAllocated is a helper function that returns +// true if the client is currently allocated to +// the Pool. +func (p *Pool) IsAllocated(c dockerclient.Client) bool { + p.Lock() + defer p.Unlock() + _, ok := p.clients[c] + return ok +} + +// Deallocate removes the worker from the pool of +// available clients. If the client is currently +// reserved and performing work it will finish, +// but no longer be given new work. +func (p *Pool) Deallocate(c dockerclient.Client) { + p.Lock() + defer p.Unlock() + delete(p.clients, c) +} + +// List returns a list of all Workers currently +// allocated to the Pool. +func (p *Pool) List() []dockerclient.Client { + p.Lock() + defer p.Unlock() + + var clients []dockerclient.Client + for c := range p.clients { + clients = append(clients, c) + } + return clients +} + +// Reserve reserves the next available worker to +// start doing work. Once work is complete, the +// worker should be released back to the pool. +func (p *Pool) Reserve() <-chan dockerclient.Client { + return p.clientc +} + +// Release releases the worker back to the pool +// of available workers. +func (p *Pool) Release(c dockerclient.Client) bool { + if !p.IsAllocated(c) { + return false + } + + p.clientc <- c + return true +} diff --git a/builder/util.go b/builder/util.go new file mode 100644 index 000000000..ba36a0f09 --- /dev/null +++ b/builder/util.go @@ -0,0 +1,98 @@ +package builder + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/drone/drone/common" + "github.com/samalba/dockerclient" +) + +// helper function that converts the build step to +// a containerConfig for use with the dockerclient +func toContainerConfig(step *common.Step) *dockerclient.ContainerConfig { + config := &dockerclient.ContainerConfig{ + Image: step.Image, + Env: step.Environment, + Cmd: step.Command, + Entrypoint: step.Entrypoint, + WorkingDir: step.WorkingDir, + HostConfig: dockerclient.HostConfig{ + Privileged: step.Privileged, + NetworkMode: step.NetworkMode, + }, + } + + config.Volumes = map[string]struct{}{} + for _, path := range step.Volumes { + if strings.Index(path, ":") == -1 { + continue + } + parts := strings.Split(path, ":") + config.Volumes[parts[1]] = struct{}{} + config.HostConfig.Binds = append(config.HostConfig.Binds, path) + } + + return config +} + +// helper function to inject drone-specific environment +// variables into the container. +func injectEnv(b *B, conf *dockerclient.ContainerConfig) { + var branch string + var commit string + if b.Build.Commit != nil { + branch = b.Build.Commit.Ref + commit = b.Build.Commit.Sha + } else { + branch = b.Build.PullRequest.Target.Ref + commit = b.Build.PullRequest.Target.Sha + } + + conf.Env = append(conf.Env, "DRONE=true") + conf.Env = append(conf.Env, fmt.Sprintf("DRONE_BRANCH=%s", branch)) + conf.Env = append(conf.Env, fmt.Sprintf("DRONE_COMMIT=%s", commit)) + + // for jenkins campatibility + conf.Env = append(conf.Env, "CI=true") + conf.Env = append(conf.Env, fmt.Sprintf("WORKSPACE=%s", b.Clone.Dir)) + conf.Env = append(conf.Env, fmt.Sprintf("JOB_NAME=%s/%s", b.Repo.Owner, b.Repo.Name)) + conf.Env = append(conf.Env, fmt.Sprintf("BUILD_ID=%d", b.Build.Number)) + conf.Env = append(conf.Env, fmt.Sprintf("BUILD_DIR=%s", b.Clone.Dir)) + conf.Env = append(conf.Env, fmt.Sprintf("GIT_BRANCH=%s", branch)) + conf.Env = append(conf.Env, fmt.Sprintf("GIT_COMMIT=%s", commit)) + +} + +// helper function to encode the build step to +// a json string. Primarily used for plugins, which +// expect a json encoded string in stdin or arg[1]. +func toCommand(b *B, step *common.Step) []string { + p := payload{ + b.Repo, + b.Build, + b.Task, + b.Clone, + step.Config, + } + return []string{p.Encode()} +} + +// payload represents the payload of a plugin +// that is serialized and sent to the plugin in JSON +// format via stdin or arg[1]. +type payload struct { + Repo *common.Repo `json:"repo"` + Build *common.Build `json:"build"` + Task *common.Task `json:"task"` + Clone *common.Clone `json:"clone"` + + Config map[string]interface{} `json:"vargs"` +} + +// Encode encodes the payload in JSON format. +func (p *payload) Encode() string { + out, _ := json.Marshal(p) + return string(out) +} diff --git a/common/build.go b/common/build.go index 7e236db02..78033b11d 100644 --- a/common/build.go +++ b/common/build.go @@ -74,3 +74,20 @@ type Remote struct { FullName string `json:"full_name,omitempty"` Clone string `json:"clone_url,omitempty"` } + +type Clone struct { + Origin string `json:"origin"` + Remote string `json:"remote"` + Branch string `json:"branch"` + Sha string `json:"sha"` + Ref string `json:"ref"` + Dir string `json:"dir"` + Netrc *Netrc `json:"netrc"` + Keypair *Keypair `json:"keypair"` +} + +type Netrc struct { + Machine string `json:"machine"` + Login string `json:"login"` + Password string `json:"user"` +} diff --git a/common/config.go b/common/config.go new file mode 100644 index 000000000..5a8718961 --- /dev/null +++ b/common/config.go @@ -0,0 +1,104 @@ +package common + +import ( + "path/filepath" + "strings" +) + +// Config represents a repository build configuration. +type Config struct { + Setup *Step + Clone *Step + Build *Step + + Compose map[string]*Step + Publish map[string]*Step + Deploy map[string]*Step + Notify map[string]*Step + + Matrix Matrix + Axis Axis +} + +// Matrix represents the build matrix. +type Matrix map[string][]string + +// Axis represents a single permutation of entries +// from the build matrix. +type Axis map[string]string + +// String returns a string representation of an Axis as +// a comma-separated list of environment variables. +func (a Axis) String() string { + var envs []string + for k, v := range a { + envs = append(envs, k+"="+v) + } + return strings.Join(envs, " ") +} + +// Step represents a step in the build process, including +// the execution environment and parameters. +type Step struct { + Image string + Pull bool + Privileged bool + Environment []string + Entrypoint []string + Command []string + Volumes []string + WorkingDir string `yaml:"working_dir"` + NetworkMode string `yaml:"net"` + + // Condition represents a set of conditions that must + // be met in order to execute this step. + Condition *Condition `yaml:"when"` + + // Config represents the unique configuration details + // for each plugin. + Config map[string]interface{} `yaml:"config,inline"` +} + +// Condition represents a set of conditions that must +// be met in order to proceed with a build or build step. +type Condition struct { + Owner string // Indicates the step should run only for this repo (useful for forks) + Branch string // Indicates the step should run only for this branch + + // Indicates the step should only run when the following + // matrix values are present for the sub-build. + Matrix map[string]string +} + +// MatchBranch is a helper function that returns true +// if all_branches is true. Else it returns false if a +// branch condition is specified, and the branch does +// not match. +func (c *Condition) MatchBranch(branch string) bool { + if len(c.Branch) == 0 { + return true + } + match, _ := filepath.Match(c.Branch, branch) + return match +} + +// MatchOwner is a helper function that returns false +// if an owner condition is specified and the repository +// owner does not match. +// +// This is useful when you want to prevent forks from +// executing deployment, publish or notification steps. +func (c *Condition) MatchOwner(owner string) bool { + if len(c.Owner) == 0 { + return true + } + parts := strings.Split(owner, "/") + switch len(parts) { + case 2: + return c.Owner == parts[0] + case 3: + return c.Owner == parts[1] + default: + return c.Owner == owner + } +} diff --git a/parser/inject/inject.go b/parser/inject/inject.go new file mode 100644 index 000000000..1ff7795c0 --- /dev/null +++ b/parser/inject/inject.go @@ -0,0 +1,54 @@ +package inject + +import ( + "sort" + "strings" + + "github.com/drone/drone/common" + "gopkg.in/yaml.v2" +) + +// Inject injects a map of parameters into a raw string and returns +// the resulting string. +// +// Parameters are represented in the string using $$ notation, similar +// to how environment variables are defined in Makefiles. +func Inject(raw string, params map[string]string) string { + if params == nil { + return raw + } + keys := []string{} + for k := range params { + keys = append(keys, k) + } + sort.Sort(sort.Reverse(sort.StringSlice(keys))) + injected := raw + for _, k := range keys { + v := params[k] + injected = strings.Replace(injected, "$$"+k, v, -1) + } + return injected +} + +// InjectSafe attempts to safely inject parameters without leaking +// parameters in the Build or Compose section of the yaml file. +// +// The intended use case for this function are public pull requests. +// We want to avoid a malicious pull request that allows someone +// to inject and print private variables. +func InjectSafe(raw string, params map[string]string) string { + before, _ := parse(raw) + after, _ := parse(Inject(raw, params)) + before.Notify = after.Notify + before.Publish = after.Publish + before.Deploy = after.Deploy + result, _ := yaml.Marshal(before) + return string(result) +} + +// helper funtion to parse a yaml configuration file. +func parse(raw string) (*common.Config, error) { + cfg := common.Config{} + err := yaml.Unmarshal([]byte(raw), &cfg) + return &cfg, err +} diff --git a/parser/inject/inject_test.go b/parser/inject/inject_test.go new file mode 100644 index 000000000..97266357b --- /dev/null +++ b/parser/inject/inject_test.go @@ -0,0 +1,67 @@ +package inject + +import ( + "testing" + + "github.com/franela/goblin" +) + +func Test_Inject(t *testing.T) { + + g := goblin.Goblin(t) + g.Describe("Inject params", func() { + + g.It("Should replace vars with $$", func() { + s := "echo $$FOO $BAR" + m := map[string]string{} + m["FOO"] = "BAZ" + g.Assert("echo BAZ $BAR").Equal(Inject(s, m)) + }) + + g.It("Should not replace vars with single $", func() { + s := "echo $FOO $BAR" + m := map[string]string{} + m["FOO"] = "BAZ" + g.Assert(s).Equal(Inject(s, m)) + }) + + g.It("Should not replace vars in nil map", func() { + s := "echo $$FOO $BAR" + g.Assert(s).Equal(Inject(s, nil)) + }) + }) +} + +func Test_InjectSafe(t *testing.T) { + + g := goblin.Goblin(t) + g.Describe("Safely Inject params", func() { + + m := map[string]string{} + m["TOKEN"] = "FOO" + m["SECRET"] = "BAR" + c, _ := parse(InjectSafe(yml, m)) + + g.It("Should replace vars in notify section", func() { + g.Assert(c.Deploy["digital_ocean"].Config["token"]).Equal("FOO") + g.Assert(c.Deploy["digital_ocean"].Config["secret"]).Equal("BAR") + }) + + g.It("Should not replace vars in script section", func() { + g.Assert(c.Build.Config["commands"].([]interface{})[0]).Equal("echo $$TOKEN") + g.Assert(c.Build.Config["commands"].([]interface{})[1]).Equal("echo $$SECRET") + }) + }) +} + +var yml = ` +build: + image: foo + commands: + - echo $$TOKEN + - echo $$SECRET +deploy: + digital_ocean: + token: $$TOKEN + secret: $$SECRET +` diff --git a/parser/lint.go b/parser/lint.go new file mode 100644 index 000000000..a05396617 --- /dev/null +++ b/parser/lint.go @@ -0,0 +1,105 @@ +package parser + +import ( + "fmt" + "strings" + + "github.com/drone/drone/common" +) + +// lintRule defines a function that runs lint +// checks against a Yaml Config file. If the rule +// fails it should return an error message. +type lintRule func(*common.Config) error + +var lintRules = [...]lintRule{ + expectBuild, + expectImage, + expectCommand, + expectTrustedSetup, + expectTrustedClone, + expectTrustedPublish, + expectTrustedDeploy, + expectTrustedNotify, +} + +// Lint runs all lint rules against the Yaml Config. +func Lint(c *common.Config) error { + for _, rule := range lintRules { + err := rule(c) + if err != nil { + return err + } + } + return nil +} + +// lint rule that fails when no build is defined +func expectBuild(c *common.Config) error { + if c.Build == nil { + return fmt.Errorf("Yaml must define a build section") + } + return nil +} + +// lint rule that fails when no build image is defined +func expectImage(c *common.Config) error { + if len(c.Build.Image) == 0 { + return fmt.Errorf("Yaml must define a build image") + } + return nil +} + +// lint rule that fails when no build commands are defined +func expectCommand(c *common.Config) error { + if c.Build.Config == nil || c.Build.Config["commands"] == nil { + return fmt.Errorf("Yaml must define build commands") + } + return nil +} + +// lint rule that fails when a non-trusted clone plugin is used. +func expectTrustedClone(c *common.Config) error { + if c.Clone != nil && strings.Contains(c.Clone.Image, "/") { + return fmt.Errorf("Yaml must use trusted clone plugins") + } + return nil +} + +// lint rule that fails when a non-trusted setup plugin is used. +func expectTrustedSetup(c *common.Config) error { + if c.Setup != nil && strings.Contains(c.Setup.Image, "/") { + return fmt.Errorf("Yaml must use trusted setup plugins") + } + return nil +} + +// lint rule that fails when a non-trusted publish plugin is used. +func expectTrustedPublish(c *common.Config) error { + for _, step := range c.Publish { + if strings.Contains(step.Image, "/") { + return fmt.Errorf("Yaml must use trusted publish plugins") + } + } + return nil +} + +// lint rule that fails when a non-trusted deploy plugin is used. +func expectTrustedDeploy(c *common.Config) error { + for _, step := range c.Deploy { + if strings.Contains(step.Image, "/") { + return fmt.Errorf("Yaml must use trusted deploy plugins") + } + } + return nil +} + +// lint rule that fails when a non-trusted notify plugin is used. +func expectTrustedNotify(c *common.Config) error { + for _, step := range c.Notify { + if strings.Contains(step.Image, "/") { + return fmt.Errorf("Yaml must use trusted notify plugins") + } + } + return nil +} diff --git a/parser/lint_test.go b/parser/lint_test.go new file mode 100644 index 000000000..e4fc50a33 --- /dev/null +++ b/parser/lint_test.go @@ -0,0 +1,92 @@ +package parser + +import ( + "testing" + + "github.com/drone/drone/common" + "github.com/franela/goblin" +) + +func Test_Linter(t *testing.T) { + + g := goblin.Goblin(t) + g.Describe("Linter", func() { + + g.It("Should fail when nil build", func() { + c := &common.Config{} + g.Assert(expectBuild(c) != nil).IsTrue() + }) + + g.It("Should fail when no image", func() { + c := &common.Config{ + Build: &common.Step{}, + } + g.Assert(expectImage(c) != nil).IsTrue() + }) + + g.It("Should fail when no commands", func() { + c := &common.Config{ + Build: &common.Step{}, + } + g.Assert(expectCommand(c) != nil).IsTrue() + }) + + g.It("Should pass when proper Build provided", func() { + c := &common.Config{ + Build: &common.Step{ + Config: map[string]interface{}{ + "commands": []string{"echo hi"}, + }, + }, + } + g.Assert(expectImage(c) != nil).IsTrue() + }) + + g.It("Should fail when untrusted setup image", func() { + c := &common.Config{Setup: &common.Step{Image: "foo/bar"}} + g.Assert(expectTrustedSetup(c) != nil).IsTrue() + }) + + g.It("Should fail when untrusted clone image", func() { + c := &common.Config{Clone: &common.Step{Image: "foo/bar"}} + g.Assert(expectTrustedClone(c) != nil).IsTrue() + }) + + g.It("Should fail when untrusted publish image", func() { + c := &common.Config{} + c.Publish = map[string]*common.Step{} + c.Publish["docker"] = &common.Step{Image: "foo/bar"} + g.Assert(expectTrustedPublish(c) != nil).IsTrue() + }) + + g.It("Should fail when untrusted deploy image", func() { + c := &common.Config{} + c.Deploy = map[string]*common.Step{} + c.Deploy["amazon"] = &common.Step{Image: "foo/bar"} + g.Assert(expectTrustedDeploy(c) != nil).IsTrue() + }) + + g.It("Should fail when untrusted notify image", func() { + c := &common.Config{} + c.Notify = map[string]*common.Step{} + c.Notify["hipchat"] = &common.Step{Image: "foo/bar"} + g.Assert(expectTrustedNotify(c) != nil).IsTrue() + }) + + g.It("Should pass linter when build properly setup", func() { + c := &common.Config{} + c.Build = &common.Step{} + c.Build.Image = "golang" + c.Build.Config = map[string]interface{}{} + c.Build.Config["commands"] = []string{"go build", "go test"} + c.Publish = map[string]*common.Step{} + c.Publish["docker"] = &common.Step{Image: "docker"} + c.Deploy = map[string]*common.Step{} + c.Deploy["kubernetes"] = &common.Step{Image: "kubernetes"} + c.Notify = map[string]*common.Step{} + c.Notify["email"] = &common.Step{Image: "email"} + g.Assert(Lint(c) == nil).IsTrue() + }) + + }) +} diff --git a/parser/matrix/matrix.go b/parser/matrix/matrix.go new file mode 100644 index 000000000..f22a4adae --- /dev/null +++ b/parser/matrix/matrix.go @@ -0,0 +1,109 @@ +package matrix + +import ( + "strings" + + "gopkg.in/yaml.v2" +) + +const ( + limitTags = 10 + limitAxis = 25 +) + +// Matrix represents the build matrix. +type Matrix map[string][]string + +// Axis represents a single permutation of entries +// from the build matrix. +type Axis map[string]string + +// String returns a string representation of an Axis as +// a comma-separated list of environment variables. +func (a Axis) String() string { + var envs []string + for k, v := range a { + envs = append(envs, k+"="+v) + } + return strings.Join(envs, " ") +} + +// Parse parses the Matrix section of the yaml file and +// returns a list of axis. +func Parse(raw string) ([]Axis, error) { + matrix, err := parseMatrix(raw) + if err != nil { + return nil, err + } + + // if not a matrix build return an array + // with just the single axis. + if len(matrix) == 0 { + return nil, nil + } + + return Calc(matrix), nil +} + +// Calc calculates the permutations for th build matrix. +// +// Note that this method will cap the number of permutations +// to 25 to prevent an overly expensive calculation. +func Calc(matrix Matrix) []Axis { + // calculate number of permutations and + // extract the list of tags + // (ie go_version, redis_version, etc) + var perm int + var tags []string + for k, v := range matrix { + perm *= len(v) + if perm == 0 { + perm = len(v) + } + tags = append(tags, k) + } + + // structure to hold the transformed + // result set + axisList := []Axis{} + + // for each axis calculate the uniqe + // set of values that should be used. + for p := 0; p < perm; p++ { + axis := map[string]string{} + decr := perm + for i, tag := range tags { + elems := matrix[tag] + decr = decr / len(elems) + elem := p / decr % len(elems) + axis[tag] = elems[elem] + + // enforce a maximum number of tags + // in the build matrix. + if i > limitTags { + break + } + } + + // append to the list of axis. + axisList = append(axisList, axis) + + // enforce a maximum number of axis + // that should be calculated. + if p > limitAxis { + break + } + } + + return axisList +} + +// helper function to parse the Matrix data from +// the raw yaml file. +func parseMatrix(raw string) (Matrix, error) { + data := struct { + Matrix map[string][]string + }{} + err := yaml.Unmarshal([]byte(raw), &data) + return data.Matrix, err +} diff --git a/parser/matrix/matrix_test.go b/parser/matrix/matrix_test.go new file mode 100644 index 000000000..35e1f978a --- /dev/null +++ b/parser/matrix/matrix_test.go @@ -0,0 +1,33 @@ +package matrix + +import ( + "testing" + + "github.com/franela/goblin" +) + +func Test_Matrix(t *testing.T) { + + g := goblin.Goblin(t) + g.Describe("Calculate matrix", func() { + + m := map[string][]string{} + m["go_version"] = []string{"go1", "go1.2"} + m["python_version"] = []string{"3.2", "3.3"} + m["django_version"] = []string{"1.7", "1.7.1", "1.7.2"} + m["redis_version"] = []string{"2.6", "2.8"} + axis := Calc(m) + + g.It("Should calculate permutations", func() { + g.Assert(len(axis)).Equal(24) + }) + + g.It("Should not duplicate permutations", func() { + set := map[string]bool{} + for _, perm := range axis { + set[perm.String()] = true + } + g.Assert(len(set)).Equal(24) + }) + }) +} diff --git a/parser/parse.go b/parser/parse.go new file mode 100644 index 000000000..f10664746 --- /dev/null +++ b/parser/parse.go @@ -0,0 +1,99 @@ +package parser + +import ( + "github.com/drone/drone/common" + "github.com/drone/drone/parser/inject" + "github.com/drone/drone/parser/matrix" + + "gopkg.in/yaml.v2" +) + +// Opts specifies parser options that will permit +// or deny certain Yaml settings. +type Opts struct { + Volumes bool + Network bool + Privileged bool +} + +var defaultOpts = &Opts{ + Volumes: false, + Network: false, + Privileged: false, +} + +// Parse parses a build matrix and returns +// a list of build configurations for each axis +// using the default parsing options. +func Parse(raw string) ([]*common.Config, error) { + return ParseOpts(raw, defaultOpts) +} + +// ParseOpts parses a build matrix and returns +// a list of build configurations for each axis +// using the provided parsing options. +func ParseOpts(raw string, opts *Opts) ([]*common.Config, error) { + confs, err := parse(raw) + if err != nil { + return nil, err + } + for _, conf := range confs { + err := Lint(conf) + if err != nil { + return nil, err + } + transformSetup(conf) + transformClone(conf) + transformBuild(conf) + transformImages(conf) + transformDockerPlugin(conf) + if !opts.Network { + rmNetwork(conf) + } + if !opts.Volumes { + rmVolumes(conf) + } + if !opts.Privileged { + rmPrivileged(conf) + } + } + return confs, nil +} + +// helper function to parse a matrix configuraiton file. +func parse(raw string) ([]*common.Config, error) { + axis, err := matrix.Parse(raw) + if err != nil { + return nil, err + } + confs := []*common.Config{} + + // when no matrix values exist we should return + // a single config value with an empty label. + if len(axis) == 0 { + conf, err := parseYaml(raw) + if err != nil { + return nil, err + } + confs = append(confs, conf) + } + + for _, ax := range axis { + // inject the matrix values into the raw script + injected := inject.Inject(raw, ax) + conf, err := parseYaml(injected) + if err != nil { + return nil, err + } + conf.Axis = common.Axis(ax) + confs = append(confs, conf) + } + return confs, nil +} + +// helper funtion to parse a yaml configuration file. +func parseYaml(raw string) (*common.Config, error) { + conf := &common.Config{} + err := yaml.Unmarshal([]byte(raw), conf) + return conf, err +} diff --git a/parser/trans.go b/parser/trans.go new file mode 100644 index 000000000..09f88fa44 --- /dev/null +++ b/parser/trans.go @@ -0,0 +1,183 @@ +package parser + +import ( + "strings" + + "github.com/drone/drone/common" +) + +// transformRule applies a check or transformation rule +// to the build configuration. +type transformRule func(*common.Config) + +// Transform executes the default transformers that +// ensure the minimal Yaml configuration is in place +// and correctly configured. +func Transform(c *common.Config) { + transformSetup(c) + transformClone(c) + transformBuild(c) + transformImages(c) + transformDockerPlugin(c) +} + +// TransformSafe executes all transformers that remove +// privileged options from the Yaml. +func TransformSafe(c *common.Config) { + rmPrivileged(c) + rmVolumes(c) + rmNetwork(c) +} + +// transformSetup is a transformer that adds a default +// setup step if none exists. +func transformSetup(c *common.Config) { + c.Setup = &common.Step{} + c.Setup.Image = "plugins/drone-build" + c.Setup.Config = c.Build.Config +} + +// transformClone is a transformer that adds a default +// clone step if none exists. +func transformClone(c *common.Config) { + if c.Clone == nil { + c.Clone = &common.Step{} + } + if len(c.Clone.Image) == 0 { + c.Clone.Image = "plugins/drone-git" + c.Clone.Volumes = nil + c.Clone.NetworkMode = "" + } + if c.Clone.Config == nil { + c.Clone.Config = map[string]interface{}{} + c.Clone.Config["depth"] = 50 + c.Clone.Config["recursive"] = true + } +} + +// transformBuild is a transformer that removes the +// build configuration vargs. They should have +// already been transferred to the Setup step. +func transformBuild(c *common.Config) { + c.Build.Config = nil + c.Build.Entrypoint = []string{"/bin/bash", "-e"} + c.Build.Command = []string{"/drone/bin/build.sh"} +} + +// transformImages is a transformer that ensures every +// step has an image and uses a fully-qualified +// image name. +func transformImages(c *common.Config) { + c.Setup.Image = imageName(c.Setup.Image) + c.Clone.Image = imageName(c.Clone.Image) + for name, step := range c.Publish { + step.Image = imageNameDefault(step.Image, name) + } + for name, step := range c.Deploy { + step.Image = imageNameDefault(step.Image, name) + } + for name, step := range c.Notify { + step.Image = imageNameDefault(step.Image, name) + } +} + +// transformDockerPlugin is a transformer that ensures the +// official Docker plugin can runs in privileged mode. It +// will disable volumes and network mode for added protection. +func transformDockerPlugin(c *common.Config) { + for _, step := range c.Publish { + if step.Image == "plugins/drone-docker" { + step.Privileged = true + step.Volumes = nil + step.NetworkMode = "" + step.Entrypoint = []string{} + break + } + } +} + +// rmPrivileged is a transformer that ensures every +// step is executed in non-privileged mode. +func rmPrivileged(c *common.Config) { + c.Setup.Privileged = false + c.Clone.Privileged = false + c.Build.Privileged = false + for _, step := range c.Publish { + if step.Image == "plugins/drone-docker" { + continue // the official docker plugin is the only exception here + } + step.Privileged = false + } + for _, step := range c.Deploy { + step.Privileged = false + } + for _, step := range c.Notify { + step.Privileged = false + } + for _, step := range c.Compose { + step.Privileged = false + } +} + +// rmVolumes is a transformer that ensures every +// step is executed without volumes. +func rmVolumes(c *common.Config) { + c.Setup.Volumes = nil + c.Clone.Volumes = nil + c.Build.Volumes = nil + for _, step := range c.Publish { + step.Volumes = nil + } + for _, step := range c.Deploy { + step.Volumes = nil + } + for _, step := range c.Notify { + step.Volumes = nil + } + for _, step := range c.Compose { + step.Volumes = nil + } +} + +// rmNetwork is a transformer that ensures every +// step is executed with default bridge networking. +func rmNetwork(c *common.Config) { + c.Setup.NetworkMode = "" + c.Clone.NetworkMode = "" + c.Build.NetworkMode = "" + for _, step := range c.Publish { + step.NetworkMode = "" + } + for _, step := range c.Deploy { + step.NetworkMode = "" + } + for _, step := range c.Notify { + step.NetworkMode = "" + } + for _, step := range c.Compose { + step.NetworkMode = "" + } +} + +// imageName is a helper function that resolves the +// image name. When using official drone plugins it +// is possible to use an alias name. This converts to +// the fully qualified name. +func imageName(name string) string { + if strings.Contains(name, "/") { + return name + } + name = strings.Replace(name, "_", "-", -1) + name = "plugins/drone-" + name + return name +} + +// imageNameDefault is a helper function that resolves +// the image name. If the image name is blank the +// default name is used instead. +func imageNameDefault(name, defaultName string) string { + if len(name) == 0 { + name = defaultName + } + return imageName(name) +} diff --git a/parser/trans_test.go b/parser/trans_test.go new file mode 100644 index 000000000..f64053dbb --- /dev/null +++ b/parser/trans_test.go @@ -0,0 +1,146 @@ +package parser + +import ( + "testing" + + "github.com/drone/drone/common" + "github.com/franela/goblin" +) + +func Test_Transform(t *testing.T) { + + g := goblin.Goblin(t) + g.Describe("Transform", func() { + + g.It("Should transform setup step", func() { + c := &common.Config{} + c.Build = &common.Step{} + c.Build.Config = map[string]interface{}{} + transformSetup(c) + g.Assert(c.Setup != nil).IsTrue() + g.Assert(c.Setup.Image).Equal("plugins/drone-build") + g.Assert(c.Setup.Config).Equal(c.Build.Config) + }) + + g.It("Should transform clone step", func() { + c := &common.Config{} + transformClone(c) + g.Assert(c.Clone != nil).IsTrue() + g.Assert(c.Clone.Image).Equal("plugins/drone-git") + }) + + g.It("Should transform build", func() { + c := &common.Config{} + c.Build = &common.Step{} + c.Build.Config = map[string]interface{}{} + c.Build.Config["commands"] = []string{"echo hello"} + transformBuild(c) + g.Assert(len(c.Build.Config)).Equal(0) + g.Assert(c.Build.Entrypoint[0]).Equal("/bin/bash") + g.Assert(c.Build.Command[0]).Equal("/drone/bin/build.sh") + }) + + g.It("Should transform images", func() { + c := &common.Config{} + c.Setup = &common.Step{Image: "foo"} + c.Clone = &common.Step{Image: "foo/bar"} + c.Build = &common.Step{Image: "golang"} + c.Publish = map[string]*common.Step{"google_compute": &common.Step{}} + c.Deploy = map[string]*common.Step{"amazon": &common.Step{}} + c.Notify = map[string]*common.Step{"slack": &common.Step{}} + transformImages(c) + + g.Assert(c.Setup.Image).Equal("plugins/drone-foo") + g.Assert(c.Clone.Image).Equal("foo/bar") + g.Assert(c.Build.Image).Equal("golang") + g.Assert(c.Publish["google_compute"].Image).Equal("plugins/drone-google-compute") + g.Assert(c.Deploy["amazon"].Image).Equal("plugins/drone-amazon") + g.Assert(c.Notify["slack"].Image).Equal("plugins/drone-slack") + }) + + g.It("Should transform docker plugin", func() { + c := &common.Config{} + c.Publish = map[string]*common.Step{} + c.Publish["docker"] = &common.Step{Image: "plugins/drone-docker"} + transformDockerPlugin(c) + g.Assert(c.Publish["docker"].Privileged).Equal(true) + }) + + g.It("Should remove privileged flag", func() { + c := &common.Config{} + c.Setup = &common.Step{Privileged: true} + c.Clone = &common.Step{Privileged: true} + c.Build = &common.Step{Privileged: true} + c.Compose = map[string]*common.Step{"postgres": &common.Step{Privileged: true}} + c.Publish = map[string]*common.Step{"google": &common.Step{Privileged: true}} + c.Deploy = map[string]*common.Step{"amazon": &common.Step{Privileged: true}} + c.Notify = map[string]*common.Step{"slack": &common.Step{Privileged: true}} + rmPrivileged(c) + + g.Assert(c.Setup.Privileged).Equal(false) + g.Assert(c.Clone.Privileged).Equal(false) + g.Assert(c.Build.Privileged).Equal(false) + g.Assert(c.Compose["postgres"].Privileged).Equal(false) + g.Assert(c.Publish["google"].Privileged).Equal(false) + g.Assert(c.Deploy["amazon"].Privileged).Equal(false) + g.Assert(c.Notify["slack"].Privileged).Equal(false) + }) + + g.It("Should not remove docker plugin privileged flag", func() { + c := &common.Config{} + c.Setup = &common.Step{} + c.Clone = &common.Step{} + c.Build = &common.Step{} + c.Publish = map[string]*common.Step{} + c.Publish["docker"] = &common.Step{Image: "plugins/drone-docker"} + transformDockerPlugin(c) + g.Assert(c.Publish["docker"].Privileged).Equal(true) + }) + + g.It("Should remove volumes", func() { + c := &common.Config{} + c.Setup = &common.Step{Volumes: []string{"/:/tmp"}} + c.Clone = &common.Step{Volumes: []string{"/:/tmp"}} + c.Build = &common.Step{Volumes: []string{"/:/tmp"}} + c.Compose = map[string]*common.Step{"postgres": &common.Step{Volumes: []string{"/:/tmp"}}} + c.Publish = map[string]*common.Step{"google": &common.Step{Volumes: []string{"/:/tmp"}}} + c.Deploy = map[string]*common.Step{"amazon": &common.Step{Volumes: []string{"/:/tmp"}}} + c.Notify = map[string]*common.Step{"slack": &common.Step{Volumes: []string{"/:/tmp"}}} + rmVolumes(c) + + g.Assert(len(c.Setup.Volumes)).Equal(0) + g.Assert(len(c.Clone.Volumes)).Equal(0) + g.Assert(len(c.Build.Volumes)).Equal(0) + g.Assert(len(c.Compose["postgres"].Volumes)).Equal(0) + g.Assert(len(c.Publish["google"].Volumes)).Equal(0) + g.Assert(len(c.Deploy["amazon"].Volumes)).Equal(0) + g.Assert(len(c.Notify["slack"].Volumes)).Equal(0) + }) + + g.It("Should remove network", func() { + c := &common.Config{} + c.Setup = &common.Step{NetworkMode: "host"} + c.Clone = &common.Step{NetworkMode: "host"} + c.Build = &common.Step{NetworkMode: "host"} + c.Compose = map[string]*common.Step{"postgres": &common.Step{NetworkMode: "host"}} + c.Publish = map[string]*common.Step{"google": &common.Step{NetworkMode: "host"}} + c.Deploy = map[string]*common.Step{"amazon": &common.Step{NetworkMode: "host"}} + c.Notify = map[string]*common.Step{"slack": &common.Step{NetworkMode: "host"}} + rmNetwork(c) + + g.Assert(c.Setup.NetworkMode).Equal("") + g.Assert(c.Clone.NetworkMode).Equal("") + g.Assert(c.Build.NetworkMode).Equal("") + g.Assert(c.Compose["postgres"].NetworkMode).Equal("") + g.Assert(c.Publish["google"].NetworkMode).Equal("") + g.Assert(c.Deploy["amazon"].NetworkMode).Equal("") + g.Assert(c.Notify["slack"].NetworkMode).Equal("") + }) + + g.It("Should return full qualified image name", func() { + g.Assert(imageName("microsoft/azure")).Equal("microsoft/azure") + g.Assert(imageName("azure")).Equal("plugins/drone-azure") + g.Assert(imageName("azure_storage")).Equal("plugins/drone-azure-storage") + }) + }) +}