updated with changes to support automated triggering of post-deploymet provisioning
diff --git a/automation/Dockerfile b/automation/Dockerfile
index 5e1be43..d1b6318 100644
--- a/automation/Dockerfile
+++ b/automation/Dockerfile
@@ -3,6 +3,14 @@
RUN apk --update add git
WORKDIR /go
-RUN go get github.com/ciena/cord-maas-automation
+RUN go get github.com/tools/godep
+ADD . /go/src/github.com/ciena/cord-maas-automation
+
+WORKDIR /go/src/github.com/ciena/cord-maas-automation
+RUN /go/bin/godep restore
+
+WORKDIR /go
+
+RUN go install github.com/ciena/cord-maas-automation
ENTRYPOINT ["/go/bin/cord-maas-automation"]
diff --git a/automation/Dockerfile.ansible b/automation/Dockerfile.ansible
new file mode 100644
index 0000000..4b5a239
--- /dev/null
+++ b/automation/Dockerfile.ansible
@@ -0,0 +1,21 @@
+FROM ubuntu:14.04
+
+RUN apt-get update -y && \
+ sudo apt-get install -y software-properties-common && \
+ apt-add-repository ppa:ansible/ansible && \
+ apt-get update -y && \
+ apt-get install -y git ansible golang
+
+RUN mkdir /go
+WORKDIR /go
+ENV GOPATH=/go
+RUN go get github.com/tools/godep
+ADD . /go/src/github.com/ciena/cord-maas-automation
+
+WORKDIR /go/src/github.com/ciena/cord-maas-automation
+RUN /go/bin/godep restore
+
+WORKDIR /go
+RUN go install github.com/ciena/cord-maas-automation
+
+ENTRYPOINT ["/go/bin/cord-maas-automation"]
diff --git a/automation/Godeps/Godeps.json b/automation/Godeps/Godeps.json
index 77ff54f..c55b337 100644
--- a/automation/Godeps/Godeps.json
+++ b/automation/Godeps/Godeps.json
@@ -6,6 +6,11 @@
],
"Deps": [
{
+ "ImportPath": "github.com/fzzy/radix/redis",
+ "Comment": "v0.5.6-2-g3528e87",
+ "Rev": "3528e87a910c6730810cec1be187f989d7d9442a"
+ },
+ {
"ImportPath": "github.com/juju/gomaasapi",
"Rev": "e173bc8d8d3304ff11b0ded5f6d4eea0cb560a40"
},
diff --git a/automation/README.md b/automation/README.md
index ee49648..aa76475 100644
--- a/automation/README.md
+++ b/automation/README.md
@@ -84,3 +84,43 @@
Currently (January 26, 2016) the automation only supports a deployed target
state and will not act on hosts that are in a failed, broken, or error state.
![](lifecycle.png)
+
+### Post Deployment Provisioning
+All the states in the state machine are defined and maintained by
+MAAS except the states Provisioning, ProvisionError, and Provisioned. These
+states are used to track the post-deployment provisioning that is part of the
+automation.
+
+Post deployment provisioning can be accomplished either by the specification of
+a script to execute or the specification of a URL to trigger.
+
+#### Executing a Script
+A script to execute to post deploy provision a node can be specified via the
+environment as `PROVISION_URL`. After a node is deployed this script will be
+executed with three (3) parameters:
+- node ID - the node ID that MAAS uses to track the node
+- name - the name of the node in MAAS
+- ip - the IP address assigned to the node
+
+It is important to note that when executing a script that the script is run
+within the docker container that is running the automation. Thus any script
+must be mounted as a volume into the container.
+
+#### Triggering a URL
+Alternatively the automation can trigger a URL to POST deploy provision a node.
+In this instance, automation will `POST` a `JSON` object to the specified URL
+with the values:
+- "id" - the node ID that MAAS uses to track the node
+- "name" - the name of the node in MAAS
+- "ip" - the IP address assigned to the node
+
+The provider specified should return "202 Accept" to acknowledge the acceptance
+of the request. The automation controller will poll for status on the
+provisioning so the provider should accept the request but not process it
+while the client is blocked.
+
+The automation controller will periodically poll for provisioning status for a
+given node by doing a `HTTP GET` on the specified provisioning URL appended with
+`/` and the `ID` of the node. The provider should either return `202 Accept` if
+the node is still being provisioned, `200 OK` if the provisioning is complete
+and successful, or any other response which will be treated as an error.
diff --git a/automation/lifecycle.png b/automation/lifecycle.png
index 422f247..a81847e 100644
--- a/automation/lifecycle.png
+++ b/automation/lifecycle.png
Binary files differ
diff --git a/automation/maas-flow.go b/automation/maas-flow.go
index 1514e32..f443cef 100644
--- a/automation/maas-flow.go
+++ b/automation/maas-flow.go
@@ -26,6 +26,9 @@
}
}`
defaultMapping = "{}"
+ PROVISION_URL = "PROVISION_URL"
+ PROVISION_TTL = "PROVISION_TTL"
+ DEFAULT_TTL = "30m"
)
var apiKey = flag.String("apikey", "", "key with which to access MAAS server")
@@ -92,6 +95,20 @@
Preview: *preview,
Verbose: *verbose,
AlwaysRename: *always,
+ ProvTracker: NewTracker(),
+ ProvisionURL: os.Getenv(PROVISION_URL),
+ }
+
+ var ttl string
+ if ttl = os.Getenv(PROVISION_TTL); ttl == "" {
+ ttl = "30m"
+ }
+
+ var err error
+ options.ProvisionTTL, err = time.ParseDuration(ttl)
+ if err != nil {
+ log.Printf("[warn] unable to parse specified duration of '%s', defaulting to '%s'",
+ ttl, DEFAULT_TTL)
}
// Determine the filter, this can either be specified on the the command
diff --git a/automation/node.go b/automation/node.go
index d364fe0..fe61add 100644
--- a/automation/node.go
+++ b/automation/node.go
@@ -80,6 +80,27 @@
return hn
}
+// IPs get the IP Addresses
+func (n *MaasNode) IPs() []string {
+ ifaceObj, _ := n.GetMap()["interface_set"]
+ ifaces, _ := ifaceObj.GetArray()
+ result := []string{}
+
+ for _, iface := range ifaces {
+ obj, _ := iface.GetMap()
+ linksObj, _ := obj["links"]
+ links, _ := linksObj.GetArray()
+ for _, link := range links {
+ linkObj, _ := link.GetMap()
+ ipObj, _ := linkObj["ip_address"]
+ ip, _ := ipObj.GetString()
+ result = append(result, ip)
+ }
+ }
+
+ return result
+}
+
// MACs get the MAC Addresses
func (n *MaasNode) MACs() []string {
macsObj, _ := n.GetMap()["macaddress_set"]
diff --git a/automation/state.go b/automation/state.go
index 4b94089..696f58f 100644
--- a/automation/state.go
+++ b/automation/state.go
@@ -1,12 +1,17 @@
package main
import (
+ "bytes"
+ "encoding/json"
"fmt"
"log"
+ "net/http"
"net/url"
+ "os/exec"
"regexp"
"strconv"
"strings"
+ "time"
maas "github.com/juju/gomaasapi"
)
@@ -37,6 +42,9 @@
Verbose bool
Preview bool
AlwaysRename bool
+ ProvTracker Tracker
+ ProvisionURL string
+ ProvisionTTL time.Duration
}
// Transitions the actual map
@@ -45,24 +53,24 @@
// really be generated from the state machine chart input. Once this has been
// accomplished you should be able to determine the action to take given your
// target state and your current state.
-var Transitions = map[string]map[string]Action{
+var Transitions = map[string]map[string][]Action{
"Deployed": {
- "New": Commission,
- "Deployed": Done,
- "Ready": Aquire,
- "Allocated": Deploy,
- "Retired": AdminState,
- "Reserved": AdminState,
- "Releasing": Wait,
- "DiskErasing": Wait,
- "Deploying": Wait,
- "Commissioning": Wait,
- "Missing": Fail,
- "FailedReleasing": Fail,
- "FailedDiskErasing": Fail,
- "FailedDeployment": Fail,
- "Broken": Fail,
- "FailedCommissioning": Fail,
+ "New": []Action{Reset, Commission},
+ "Deployed": []Action{Provision, Done},
+ "Ready": []Action{Reset, Aquire},
+ "Allocated": []Action{Reset, Deploy},
+ "Retired": []Action{Reset, AdminState},
+ "Reserved": []Action{Reset, AdminState},
+ "Releasing": []Action{Reset, Wait},
+ "DiskErasing": []Action{Reset, Wait},
+ "Deploying": []Action{Reset, Wait},
+ "Commissioning": []Action{Reset, Wait},
+ "Missing": []Action{Reset, Fail},
+ "FailedReleasing": []Action{Reset, Fail},
+ "FailedDiskErasing": []Action{Reset, Fail},
+ "FailedDeployment": []Action{Reset, Fail},
+ "Broken": []Action{Reset, Fail},
+ "FailedCommissioning": []Action{Reset, Fail},
},
}
@@ -87,7 +95,11 @@
(FailedEraseDisk)->(Broken)
(Releasing)->(Ready)
(DiskErasing)->(Ready)
- (Broken)->(Ready)`
+ (Broken)->(Ready)
+ (Deployed)->(Provisioning)
+ (Provisioning)->(ProvisionError)
+ (ProvisionError)->(Provisioning)
+ (Provisioning)->(Provisioned)`
)
// updateName - changes the name of the MAAS node based on the configuration file
@@ -115,6 +127,178 @@
return nil
}
+// Reset we are at the target state, nothing to do
+var Reset = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
+ if options.Verbose {
+ log.Printf("RESET: %s", node.Hostname())
+ }
+
+ if options.AlwaysRename {
+ updateNodeName(client, node, options)
+ }
+
+ options.ProvTracker.Clear(node.ID())
+
+ return nil
+}
+
+// Provision we are at the target state, nothing to do
+var Provision = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
+ if options.Verbose {
+ log.Printf("CHECK PROVISION: %s", node.Hostname())
+ }
+
+ if options.AlwaysRename {
+ updateNodeName(client, node, options)
+ }
+
+ record, err := options.ProvTracker.Get(node.ID())
+ if options.Verbose {
+ log.Printf("[info] Current state of node '%s' is '%s'", node.Hostname(), record.State.String())
+ }
+ if err != nil {
+ log.Printf("[warn] unable to retrieve provisioning state of node '%s' : %s", node.Hostname(), err)
+ } else if record.State == Unprovisioned || record.State == ProvisionError {
+ var err error = nil
+ var callout *url.URL
+ log.Printf("PROVISION '%s'", node.Hostname())
+ if len(options.ProvisionURL) > 0 {
+ if options.Verbose {
+ log.Printf("[info] Provisioning callout to '%s'", options.ProvisionURL)
+ }
+ callout, err = url.Parse(options.ProvisionURL)
+ if err != nil {
+ log.Printf("[error] Failed to parse provisioning URL '%s' : %s", options.ProvisionURL, err)
+ } else {
+ ips := node.IPs()
+ ip := ""
+ if len(ips) > 0 {
+ ip = ips[0]
+ }
+ switch callout.Scheme {
+ // If the scheme is a file, then we will execute the refereced file
+ case "", "file":
+ if options.Verbose {
+ log.Printf("[info] executing local script file '%s'", callout.Path)
+ }
+ record.State = Provisioning
+ record.Timestamp = time.Now().Unix()
+ options.ProvTracker.Set(node.ID(), record)
+ err = exec.Command(callout.Path, node.ID(), node.Hostname(), ip).Run()
+ if err != nil {
+ log.Printf("[error] Failed to execute '%s' : %s", options.ProvisionURL, err)
+ } else {
+ if options.Verbose {
+ log.Printf("[info] Marking node '%s' with ID '%s' as provisioned",
+ node.Hostname(), node.ID())
+ }
+ record.State = Provisioned
+ options.ProvTracker.Set(node.ID(), record)
+ }
+
+ default:
+ if options.Verbose {
+ log.Printf("[info] POSTing to '%s'", options.ProvisionURL)
+ }
+ data := map[string]string{
+ "id": node.ID(),
+ "name": node.Hostname(),
+ "ip": ip,
+ }
+ hc := http.Client{}
+ var b []byte
+ b, err = json.Marshal(data)
+ if err != nil {
+ log.Printf("[error] Unable to marshal node data : %s", err)
+ } else {
+ var req *http.Request
+ var resp *http.Response
+ if options.Verbose {
+ log.Printf("[debug] POSTing data '%s'", string(b))
+ }
+ req, err = http.NewRequest("POST", options.ProvisionURL, bytes.NewReader(b))
+ if err != nil {
+ log.Printf("[error] Unable to construct POST request to provisioner : %s",
+ err)
+ } else {
+ req.Header.Add("Content-Type", "application/json")
+ resp, err = hc.Do(req)
+ if err != nil {
+ log.Printf("[error] Unable to process POST request : %s",
+ err)
+ } else {
+ defer resp.Body.Close()
+ if resp.StatusCode == 202 {
+ record.State = Provisioning
+ } else {
+ record.State = ProvisionError
+ }
+ options.ProvTracker.Set(node.ID(), record)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if err != nil {
+ if options.Verbose {
+ log.Printf("[warn] Not marking node '%s' with ID '%s' as provisioned, because of error '%s'",
+ node.Hostname(), node.ID(), err)
+ record.State = ProvisionError
+ options.ProvTracker.Set(node.ID(), record)
+ }
+ }
+ } else if record.State == Provisioning && time.Since(time.Unix(record.Timestamp, 0)) > options.ProvisionTTL {
+ log.Printf("[error] Provisioning of node '%s' has passed provisioning TTL of '%v'",
+ node.Hostname(), options.ProvisionTTL)
+ record.State = ProvisionError
+ options.ProvTracker.Set(node.ID(), record)
+ } else if record.State == Provisioning {
+ callout, err := url.Parse(options.ProvisionURL)
+ if err != nil {
+ log.Printf("[error] Unable to parse provisioning URL '%s' : %s", options.ProvisionURL, err)
+ } else if callout.Scheme != "file" {
+ var req *http.Request
+ var resp *http.Response
+ if options.Verbose {
+ log.Printf("[info] Fetching provisioning state for node '%s'", node.Hostname())
+ }
+ req, err = http.NewRequest("GET", options.ProvisionURL+"/"+node.ID(), nil)
+ if err != nil {
+ log.Printf("[error] Unable to construct GET request to provisioner : %s", err)
+ } else {
+ hc := http.Client{}
+ resp, err = hc.Do(req)
+ if err != nil {
+ log.Printf("[error] Failed to quest provision state for node '%s' : %s",
+ node.Hostname(), err)
+ } else {
+ switch resp.StatusCode {
+ case 200: // OK - provisioning completed
+ if options.Verbose {
+ log.Printf("[info] Marking node '%s' with ID '%s' as provisioned",
+ node.Hostname(), node.ID())
+ }
+ record.State = Provisioned
+ options.ProvTracker.Set(node.ID(), record)
+ case 202: // Accepted - in the provisioning state
+ // Noop, presumably alread in this state
+ default: // Consider anything else an erorr
+ record.State = ProvisionError
+ options.ProvTracker.Set(node.ID(), record)
+ }
+ }
+ }
+ }
+ } else if options.Verbose {
+ log.Printf("[info] Not invoking provisioning for '%s', currned state is '%s'", node.Hostname(),
+ record.State.String())
+ }
+
+ return nil
+}
+
// Done we are at the target state, nothing to do
var Done = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
// As devices are normally in the "COMPLETED" state we don't want to
@@ -145,7 +329,7 @@
myNode := nodesObj.GetSubObject(node.ID())
// Start the node with the trusty distro. This should really be looked up or
// a parameter default
- _, err := myNode.CallPost("start", url.Values {"distro_series" : []string{"trusty"}})
+ _, err := myNode.CallPost("start", url.Values{"distro_series": []string{"trusty"}})
if err != nil {
log.Printf("ERROR: DEPLOY '%s' : '%s'", node.Hostname(), err)
return err
@@ -269,10 +453,10 @@
// Attempt to turn the node off
log.Printf("POWER DOWN: %s", node.Hostname())
if !options.Preview {
- //POST /api/1.0/nodes/{system_id}/ op=stop
+ //POST /api/1.0/nodes/{system_id}/ op=stop
nodesObj := client.GetSubObject("nodes")
nodeObj := nodesObj.GetSubObject(node.ID())
- _, err := nodeObj.CallPost("stop", url.Values{"stop_mode" : []string{"soft"}})
+ _, err := nodeObj.CallPost("stop", url.Values{"stop_mode": []string{"soft"}})
if err != nil {
log.Printf("ERROR: Commission '%s' : changing power start to off : '%s'", node.Hostname(), err)
}
@@ -321,14 +505,14 @@
return nil
}
-func findAction(target string, current string) (Action, error) {
+func findActions(target string, current string) ([]Action, error) {
targets, ok := Transitions[target]
if !ok {
log.Printf("[warn] unable to find transitions to target state '%s'", target)
return nil, fmt.Errorf("Could not find transition to target state '%s'", target)
}
- action, ok := targets[current]
+ actions, ok := targets[current]
if !ok {
log.Printf("[warn] unable to find transition from current state '%s' to target state '%s'",
current, target)
@@ -336,7 +520,19 @@
current, target)
}
- return action, nil
+ return actions, nil
+}
+
+// ProcessActions
+func ProcessActions(actions []Action, client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
+ var err error
+ for _, action := range actions {
+ if err = action(client, node, options); err != nil {
+ log.Printf("[error] Error while processing action for node '%s' : %s", node.Hostname, err)
+ break
+ }
+ }
+ return err
}
// ProcessNode something
@@ -345,15 +541,15 @@
if err != nil {
return err
}
- action, err := findAction("Deployed", MaasNodeStatus(substatus).String())
+ actions, err := findActions("Deployed", MaasNodeStatus(substatus).String())
if err != nil {
return err
}
if options.Preview {
- action(client, node, options)
+ ProcessActions(actions, client, node, options)
} else {
- go action(client, node, options)
+ go ProcessActions(actions, client, node, options)
}
return nil
}
diff --git a/automation/tracker.go b/automation/tracker.go
new file mode 100644
index 0000000..9f38b23
--- /dev/null
+++ b/automation/tracker.go
@@ -0,0 +1,137 @@
+package main
+
+import (
+ "encoding/json"
+ "github.com/fzzy/radix/redis"
+ "log"
+ "net/url"
+ "os"
+)
+
+type ProvisionState int8
+
+const (
+ Unprovisioned ProvisionState = iota
+ ProvisionError
+ Provisioning
+ Provisioned
+)
+
+func (s *ProvisionState) String() string {
+ switch *s {
+ case Unprovisioned:
+ return "UNPROVISIONED"
+ case ProvisionError:
+ return "PROVISIONERROR"
+ case Provisioning:
+ return "PROVISIONING"
+ case Provisioned:
+ return "PROVISIONED"
+ default:
+ return "UNKNOWN"
+ }
+}
+
+// TrackerRecord state kept for each node to be provisioned
+type TrackerRecord struct {
+ State ProvisionState
+
+ // Timeestamp maintains the time the node started provisioning, eventually will be used to time out
+ // provisinion states
+ Timestamp int64
+}
+
+// Tracker used to track if a node has been post deployed provisioned
+type Tracker interface {
+ Get(key string) (*TrackerRecord, error)
+ Set(key string, record *TrackerRecord) error
+ Clear(key string) error
+}
+
+// RedisTracker redis implementation of the tracker interface
+type RedisTracker struct {
+ client *redis.Client
+}
+
+func (t *RedisTracker) Get(key string) (*TrackerRecord, error) {
+ reply := t.client.Cmd("get", key)
+ if reply.Err != nil {
+ return nil, reply.Err
+ }
+ if reply.Type == redis.NilReply {
+ var record TrackerRecord
+ record.State = Unprovisioned
+ return &record, nil
+ }
+
+ value, err := reply.Str()
+ if err != nil {
+ return nil, err
+ }
+ var record TrackerRecord
+ err = json.Unmarshal([]byte(value), &record)
+ if err != nil {
+ return nil, err
+ }
+ return &record, nil
+}
+
+func (t *RedisTracker) Set(key string, record *TrackerRecord) error {
+ reply := t.client.Cmd("set", key, true)
+ return reply.Err
+}
+
+func (t *RedisTracker) Clear(key string) error {
+ reply := t.client.Cmd("del", key)
+ return reply.Err
+}
+
+// MemoryTracker in memory implementation of the tracker interface
+type MemoryTracker struct {
+ data map[string]TrackerRecord
+}
+
+func (m *MemoryTracker) Get(key string) (*TrackerRecord, error) {
+ if value, ok := m.data[key]; ok {
+ return &value, nil
+ }
+ var record TrackerRecord
+ record.State = Unprovisioned
+ return &record, nil
+}
+
+func (m *MemoryTracker) Set(key string, record *TrackerRecord) error {
+ m.data[key] = *record
+ return nil
+}
+
+func (m *MemoryTracker) Clear(key string) error {
+ delete(m.data, key)
+ return nil
+}
+
+// NetTracker constructs an implemetation of the Tracker interface. Which implementation selected
+// depends on the environment. If a link to a redis instance is defined then this will
+// be used, else an in memory version will be used.
+func NewTracker() Tracker {
+ // Check the environment to see if we are linked to a redis DB
+ if os.Getenv("AUTODB_ENV_REDIS_VERSION") != "" {
+ tracker := new(RedisTracker)
+ if spec := os.Getenv("AUTODB_PORT"); spec != "" {
+ port, err := url.Parse(spec)
+ checkError(err, "[error] unable to lookup to redis database : %s", err)
+ tracker.client, err = redis.Dial(port.Scheme, port.Host)
+ checkError(err, "[error] unable to connect to redis database : '%s' : %s", port, err)
+ log.Println("[info] Using REDIS to track provisioning status of nodes")
+ return tracker
+ } else {
+ log.Fatalf("[error] looks like we are configured for REDIS, but no PORT defined in environment")
+ }
+ }
+
+ // Else fallback to an in memory tracker
+ tracker := new(MemoryTracker)
+ tracker.data = make(map[string]TrackerRecord)
+ log.Println("[info] Using memory based structures to track provisioning status of nodes")
+ return tracker
+}
diff --git a/build.gradle b/build.gradle
index dc49de6..8a32e71 100644
--- a/build.gradle
+++ b/build.gradle
@@ -43,7 +43,16 @@
}
task buildAutomationImage(type: Exec) {
- commandLine '/usr/bin/docker', 'build', '-t', "cord-maas-automation", "./automation"
+ commandLine '/usr/bin/docker', 'build', '-t', "cord-maas-automation", "-f", "./automation/Dockerfile", "./automation"
+}
+
+task buildAutomationImageAnsible(type: Exec) {
+ commandLine '/usr/bin/docker', 'build', '-t', "cord-maas-automation:ansible", "-f", "./automation/Dockerfile.ansible", "./automation"
+}
+
+task buildAutomationImages {
+ dependsOn buildAutomationImage
+ dependsOn buildAutomationImageAnsible
}
task tagAutomationImage(type: Exec) {
@@ -51,11 +60,31 @@
commandLine '/usr/bin/docker', 'tag', 'cord-maas-automation', "$targetReg/cord-maas-automation:$targetTag"
}
+task tagAutomationImageAnsible(type: Exec) {
+ dependsOn buildAutomationImageAnsible
+ commandLine '/usr/bin/docker', 'tag', 'cord-maas-automation:ansible', "$targetReg/cord-maas-automation:$targetTag-ansible"
+}
+
+task tagAutomationImages {
+ dependsOn tagAutomationImage
+ dependsOn tagAutomationImageAnsible
+}
+
task publishAutomationImage(type: Exec) {
dependsOn tagAutomationImage
commandLine '/usr/bin/docker', 'push', "$targetReg/cord-maas-automation:$targetTag"
}
+task publishAutomationImageAnsible(type: Exec) {
+ dependsOn tagAutomationImageAnsible
+ commandLine '/usr/bin/docker', 'push', "$targetReg/cord-maas-automation:$targetTag-ansible"
+}
+
+task publishAutomationImages {
+ dependsOn publishAutomationImage
+ dependsOn publishAutomationImageAnsible
+}
+
task buildHarvesterImage(type: Exec) {
commandLine '/usr/bin/docker', 'build', '-t', "cord-maas-dhcp-harvester", "./harvester"
}
@@ -85,19 +114,23 @@
task buildImages {
dependsOn buildBootstrapImage
dependsOn buildHarvesterImage
- dependsOn buildAutomationImage
+ dependsOn buildAutomationImages
}
task tagImages {
dependsOn tagBootstrapImage
dependsOn tagHarvesterImage
- dependsOn tagAutomationImage
+ dependsOn tagAutomationImages
}
task publish {
dependsOn publishBootstrapImage
dependsOn publishHarvesterImage
- dependsOn publishAutomationImage
+ dependsOn publishAutomationImages
+}
+
+task build {
+ dependsOn buildImages
}
// ~~~~~~~~~~~~~~~~~~~ Deployment / Test Tasks ~~~~~~~~~~~~~~~~~~~~~~~