blob: f4c1bf94b9b30722b1e24ab712e867c7b81ff984 [file] [log] [blame]
David K. Bainbridgeb5415042016-05-13 17:06:10 -07001package main
2
3import (
David K. Bainbridgeefa951d2016-05-26 10:54:25 -07004 "bytes"
5 "encoding/json"
David K. Bainbridgeb5415042016-05-13 17:06:10 -07006 "fmt"
7 "log"
David K. Bainbridgeefa951d2016-05-26 10:54:25 -07008 "net/http"
David K. Bainbridgeb5415042016-05-13 17:06:10 -07009 "net/url"
David K. Bainbridgeefa951d2016-05-26 10:54:25 -070010 "os/exec"
David K. Bainbridgeb5415042016-05-13 17:06:10 -070011 "regexp"
12 "strconv"
13 "strings"
David K. Bainbridgeefa951d2016-05-26 10:54:25 -070014 "time"
David K. Bainbridgeb5415042016-05-13 17:06:10 -070015
16 maas "github.com/juju/gomaasapi"
17)
18
19// Action how to get from there to here
20type Action func(*maas.MAASObject, MaasNode, ProcessingOptions) error
21
22// Transition the map from where i want to be from where i might be
23type Transition struct {
24 Target string
25 Current string
26 Using Action
27}
28
29// ProcessingOptions used to determine on what hosts to operate
30type ProcessingOptions struct {
31 Filter struct {
32 Zones struct {
33 Include []string
34 Exclude []string
35 }
36 Hosts struct {
37 Include []string
38 Exclude []string
39 }
40 }
41 Mappings map[string]interface{}
42 Verbose bool
43 Preview bool
44 AlwaysRename bool
David K. Bainbridgeefa951d2016-05-26 10:54:25 -070045 ProvTracker Tracker
46 ProvisionURL string
47 ProvisionTTL time.Duration
David K. Bainbridgeb5415042016-05-13 17:06:10 -070048}
49
50// Transitions the actual map
51//
52// Currently this is a hand compiled / optimized "next step" table. This should
53// really be generated from the state machine chart input. Once this has been
54// accomplished you should be able to determine the action to take given your
55// target state and your current state.
David K. Bainbridgeefa951d2016-05-26 10:54:25 -070056var Transitions = map[string]map[string][]Action{
David K. Bainbridgeb5415042016-05-13 17:06:10 -070057 "Deployed": {
David K. Bainbridgeefa951d2016-05-26 10:54:25 -070058 "New": []Action{Reset, Commission},
59 "Deployed": []Action{Provision, Done},
60 "Ready": []Action{Reset, Aquire},
61 "Allocated": []Action{Reset, Deploy},
62 "Retired": []Action{Reset, AdminState},
63 "Reserved": []Action{Reset, AdminState},
64 "Releasing": []Action{Reset, Wait},
65 "DiskErasing": []Action{Reset, Wait},
66 "Deploying": []Action{Reset, Wait},
67 "Commissioning": []Action{Reset, Wait},
68 "Missing": []Action{Reset, Fail},
69 "FailedReleasing": []Action{Reset, Fail},
70 "FailedDiskErasing": []Action{Reset, Fail},
71 "FailedDeployment": []Action{Reset, Fail},
72 "Broken": []Action{Reset, Fail},
73 "FailedCommissioning": []Action{Reset, Fail},
David K. Bainbridgeb5415042016-05-13 17:06:10 -070074 },
75}
76
77const (
78 // defaultStateMachine Would be nice to drive from a graph language
79 defaultStateMachine string = `
David K. Bainbridged9b966f2016-05-31 13:30:05 -070080 (New)->(Commissioning)
David K. Bainbridgeb5415042016-05-13 17:06:10 -070081 (Commissioning)->(FailedCommissioning)
82 (FailedCommissioning)->(New)
83 (Commissioning)->(Ready)
84 (Ready)->(Deploying)
85 (Ready)->(Allocated)
86 (Allocated)->(Deploying)
87 (Deploying)->(Deployed)
88 (Deploying)->(FailedDeployment)
89 (FailedDeployment)->(Broken)
90 (Deployed)->(Releasing)
91 (Releasing)->(FailedReleasing)
92 (FailedReleasing)->(Broken)
93 (Releasing)->(DiskErasing)
94 (DiskErasing)->(FailedEraseDisk)
95 (FailedEraseDisk)->(Broken)
96 (Releasing)->(Ready)
97 (DiskErasing)->(Ready)
David K. Bainbridgeefa951d2016-05-26 10:54:25 -070098 (Broken)->(Ready)
David K. Bainbridged9b966f2016-05-31 13:30:05 -070099 (Deployed)->(Provisioning)
100 (Provisioning)->|a|
101 |a|->(Execute Script)->|b|
102 |a|->(HTTP PUT)
103 (HTTP PUT)->(HTTP GET)
104 (HTTP GET)->(HTTP GET)
105 (HTTP GET)->|b|
106 |b|->(Provisioned)
107 |b|->(ProvisionError)
108 (ProvisionError)->(Provisioning)`
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700109)
110
111// updateName - changes the name of the MAAS node based on the configuration file
112func updateNodeName(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
113 macs := node.MACs()
114
115 // Get current node name and strip off domain name
116 current := node.Hostname()
117 if i := strings.IndexRune(current, '.'); i != -1 {
118 current = current[:i]
119 }
120 for _, mac := range macs {
121 if entry, ok := options.Mappings[mac]; ok {
122 if name, ok := entry.(map[string]interface{})["hostname"]; ok && current != name.(string) {
123 nodesObj := client.GetSubObject("nodes")
124 nodeObj := nodesObj.GetSubObject(node.ID())
125 log.Printf("RENAME '%s' to '%s'\n", node.Hostname(), name.(string))
126
127 if !options.Preview {
128 nodeObj.Update(url.Values{"hostname": []string{name.(string)}})
129 }
130 }
131 }
132 }
133 return nil
134}
135
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700136// Reset we are at the target state, nothing to do
137var Reset = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
138 if options.Verbose {
139 log.Printf("RESET: %s", node.Hostname())
140 }
141
142 if options.AlwaysRename {
143 updateNodeName(client, node, options)
144 }
145
146 options.ProvTracker.Clear(node.ID())
147
148 return nil
149}
150
151// Provision we are at the target state, nothing to do
152var Provision = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
153 if options.Verbose {
154 log.Printf("CHECK PROVISION: %s", node.Hostname())
155 }
156
157 if options.AlwaysRename {
158 updateNodeName(client, node, options)
159 }
160
161 record, err := options.ProvTracker.Get(node.ID())
162 if options.Verbose {
163 log.Printf("[info] Current state of node '%s' is '%s'", node.Hostname(), record.State.String())
164 }
165 if err != nil {
166 log.Printf("[warn] unable to retrieve provisioning state of node '%s' : %s", node.Hostname(), err)
167 } else if record.State == Unprovisioned || record.State == ProvisionError {
168 var err error = nil
169 var callout *url.URL
170 log.Printf("PROVISION '%s'", node.Hostname())
171 if len(options.ProvisionURL) > 0 {
172 if options.Verbose {
173 log.Printf("[info] Provisioning callout to '%s'", options.ProvisionURL)
174 }
175 callout, err = url.Parse(options.ProvisionURL)
176 if err != nil {
177 log.Printf("[error] Failed to parse provisioning URL '%s' : %s", options.ProvisionURL, err)
178 } else {
179 ips := node.IPs()
180 ip := ""
181 if len(ips) > 0 {
182 ip = ips[0]
183 }
David K. Bainbridged9b966f2016-05-31 13:30:05 -0700184 macs := node.MACs()
185 mac := ""
186 if len(macs) > 0 {
187 mac = macs[0]
188 }
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700189 switch callout.Scheme {
190 // If the scheme is a file, then we will execute the refereced file
191 case "", "file":
192 if options.Verbose {
193 log.Printf("[info] executing local script file '%s'", callout.Path)
194 }
195 record.State = Provisioning
196 record.Timestamp = time.Now().Unix()
197 options.ProvTracker.Set(node.ID(), record)
David K. Bainbridged9b966f2016-05-31 13:30:05 -0700198 err = exec.Command(callout.Path, node.ID(), node.Hostname(), ip, mac).Run()
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700199 if err != nil {
200 log.Printf("[error] Failed to execute '%s' : %s", options.ProvisionURL, err)
201 } else {
202 if options.Verbose {
203 log.Printf("[info] Marking node '%s' with ID '%s' as provisioned",
204 node.Hostname(), node.ID())
205 }
206 record.State = Provisioned
207 options.ProvTracker.Set(node.ID(), record)
208 }
209
210 default:
211 if options.Verbose {
212 log.Printf("[info] POSTing to '%s'", options.ProvisionURL)
213 }
214 data := map[string]string{
215 "id": node.ID(),
216 "name": node.Hostname(),
217 "ip": ip,
David K. Bainbridged9b966f2016-05-31 13:30:05 -0700218 "mac": mac,
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700219 }
220 hc := http.Client{}
221 var b []byte
222 b, err = json.Marshal(data)
223 if err != nil {
224 log.Printf("[error] Unable to marshal node data : %s", err)
225 } else {
226 var req *http.Request
227 var resp *http.Response
228 if options.Verbose {
229 log.Printf("[debug] POSTing data '%s'", string(b))
230 }
231 req, err = http.NewRequest("POST", options.ProvisionURL, bytes.NewReader(b))
232 if err != nil {
233 log.Printf("[error] Unable to construct POST request to provisioner : %s",
234 err)
235 } else {
236 req.Header.Add("Content-Type", "application/json")
237 resp, err = hc.Do(req)
238 if err != nil {
239 log.Printf("[error] Unable to process POST request : %s",
240 err)
241 } else {
242 defer resp.Body.Close()
243 if resp.StatusCode == 202 {
244 record.State = Provisioning
245 } else {
246 record.State = ProvisionError
247 }
248 options.ProvTracker.Set(node.ID(), record)
249 }
250 }
251 }
252 }
253 }
254 }
255
256 if err != nil {
257 if options.Verbose {
258 log.Printf("[warn] Not marking node '%s' with ID '%s' as provisioned, because of error '%s'",
259 node.Hostname(), node.ID(), err)
260 record.State = ProvisionError
261 options.ProvTracker.Set(node.ID(), record)
262 }
263 }
264 } else if record.State == Provisioning && time.Since(time.Unix(record.Timestamp, 0)) > options.ProvisionTTL {
265 log.Printf("[error] Provisioning of node '%s' has passed provisioning TTL of '%v'",
266 node.Hostname(), options.ProvisionTTL)
267 record.State = ProvisionError
268 options.ProvTracker.Set(node.ID(), record)
269 } else if record.State == Provisioning {
270 callout, err := url.Parse(options.ProvisionURL)
271 if err != nil {
272 log.Printf("[error] Unable to parse provisioning URL '%s' : %s", options.ProvisionURL, err)
273 } else if callout.Scheme != "file" {
274 var req *http.Request
275 var resp *http.Response
276 if options.Verbose {
277 log.Printf("[info] Fetching provisioning state for node '%s'", node.Hostname())
278 }
279 req, err = http.NewRequest("GET", options.ProvisionURL+"/"+node.ID(), nil)
280 if err != nil {
281 log.Printf("[error] Unable to construct GET request to provisioner : %s", err)
282 } else {
283 hc := http.Client{}
284 resp, err = hc.Do(req)
285 if err != nil {
286 log.Printf("[error] Failed to quest provision state for node '%s' : %s",
287 node.Hostname(), err)
288 } else {
289 switch resp.StatusCode {
290 case 200: // OK - provisioning completed
291 if options.Verbose {
292 log.Printf("[info] Marking node '%s' with ID '%s' as provisioned",
293 node.Hostname(), node.ID())
294 }
295 record.State = Provisioned
296 options.ProvTracker.Set(node.ID(), record)
297 case 202: // Accepted - in the provisioning state
298 // Noop, presumably alread in this state
299 default: // Consider anything else an erorr
300 record.State = ProvisionError
301 options.ProvTracker.Set(node.ID(), record)
302 }
303 }
304 }
305 }
306 } else if options.Verbose {
307 log.Printf("[info] Not invoking provisioning for '%s', currned state is '%s'", node.Hostname(),
308 record.State.String())
309 }
310
311 return nil
312}
313
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700314// Done we are at the target state, nothing to do
315var Done = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
316 // As devices are normally in the "COMPLETED" state we don't want to
317 // log this fact unless we are in verbose mode. I suspect it would be
318 // nice to log it once when the device transitions from a non COMPLETE
319 // state to a complete state, but that would require keeping state.
320 if options.Verbose {
321 log.Printf("COMPLETE: %s", node.Hostname())
322 }
323
324 if options.AlwaysRename {
325 updateNodeName(client, node, options)
326 }
327
328 return nil
329}
330
331// Deploy cause a node to deploy
332var Deploy = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
333 log.Printf("DEPLOY: %s", node.Hostname())
334
335 if options.AlwaysRename {
336 updateNodeName(client, node, options)
337 }
338
339 if !options.Preview {
340 nodesObj := client.GetSubObject("nodes")
341 myNode := nodesObj.GetSubObject(node.ID())
342 // Start the node with the trusty distro. This should really be looked up or
343 // a parameter default
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700344 _, err := myNode.CallPost("start", url.Values{"distro_series": []string{"trusty"}})
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700345 if err != nil {
346 log.Printf("ERROR: DEPLOY '%s' : '%s'", node.Hostname(), err)
347 return err
348 }
349 }
350 return nil
351}
352
353// Aquire aquire a machine to a specific operator
354var Aquire = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
355 log.Printf("AQUIRE: %s", node.Hostname())
356 nodesObj := client.GetSubObject("nodes")
357
358 if options.AlwaysRename {
359 updateNodeName(client, node, options)
360 }
361
362 if !options.Preview {
363 // With a new version of MAAS we have to make sure the node is linked
364 // to the subnet vid DHCP before we move to the Aquire state. To do this
365 // We need to unlink the interface to the subnet and then relink it.
366 //
367 // Iterate through all the interfaces on the node, searching for ones
368 // that are valid and not DHCP and move them to DHCP
369 ifcsObj := client.GetSubObject("nodes").GetSubObject(node.ID()).GetSubObject("interfaces")
370 ifcsListObj, err := ifcsObj.CallGet("", url.Values{})
371 if err != nil {
372 return err
373 }
374
375 ifcsArray, err := ifcsListObj.GetArray()
376 if err != nil {
377 return err
378 }
379
380 for _, ifc := range ifcsArray {
381 ifcMap, err := ifc.GetMap()
382 if err != nil {
383 return err
384 }
385
386 // Iterate over the links assocated with the interface, looking for
387 // links with a subnect as well as a mode of "auto"
388 links, ok := ifcMap["links"]
389 if ok {
390 linkArray, err := links.GetArray()
391 if err != nil {
392 return err
393 }
394
395 for _, link := range linkArray {
396 linkMap, err := link.GetMap()
397 if err != nil {
398 return err
399 }
400 subnet, ok := linkMap["subnet"]
401 if ok {
402 subnetMap, err := subnet.GetMap()
403 if err != nil {
404 return err
405 }
406
407 val, err := linkMap["mode"].GetString()
408 if err != nil {
409 return err
410 }
411
412 if val == "auto" {
413 // Found one we like, so grab the subnet from the data and
414 // then relink this as DHCP
415 cidr, err := subnetMap["cidr"].GetString()
416 if err != nil {
417 return err
418 }
419
420 fifcID, err := ifcMap["id"].GetFloat64()
421 if err != nil {
422 return err
423 }
424 ifcID := strconv.Itoa(int(fifcID))
425
426 flID, err := linkMap["id"].GetFloat64()
427 if err != nil {
428 return err
429 }
430 lID := strconv.Itoa(int(flID))
431
432 ifcObj := ifcsObj.GetSubObject(ifcID)
433 _, err = ifcObj.CallPost("unlink_subnet", url.Values{"id": []string{lID}})
434 if err != nil {
435 return err
436 }
437 _, err = ifcObj.CallPost("link_subnet", url.Values{"mode": []string{"DHCP"}, "subnet": []string{cidr}})
438 if err != nil {
439 return err
440 }
441 }
442 }
443 }
444 }
445 }
446 _, err = nodesObj.CallPost("acquire",
447 url.Values{"name": []string{node.Hostname()}})
448 if err != nil {
449 log.Printf("ERROR: AQUIRE '%s' : '%s'", node.Hostname(), err)
450 return err
451 }
452 }
453 return nil
454}
455
456// Commission cause a node to be commissioned
457var Commission = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
458 updateNodeName(client, node, options)
459
460 // Need to understand the power state of the node. We only want to move to "Commissioning" if the node
461 // power is off. If the node power is not off, then turn it off.
462 state := node.PowerState()
463 switch state {
464 case "on":
465 // Attempt to turn the node off
466 log.Printf("POWER DOWN: %s", node.Hostname())
467 if !options.Preview {
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700468 //POST /api/1.0/nodes/{system_id}/ op=stop
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700469 nodesObj := client.GetSubObject("nodes")
470 nodeObj := nodesObj.GetSubObject(node.ID())
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700471 _, err := nodeObj.CallPost("stop", url.Values{"stop_mode": []string{"soft"}})
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700472 if err != nil {
473 log.Printf("ERROR: Commission '%s' : changing power start to off : '%s'", node.Hostname(), err)
474 }
475 return err
476 }
477 break
478 case "off":
479 // We are off so move to commissioning
480 log.Printf("COMISSION: %s", node.Hostname())
481 if !options.Preview {
482 nodesObj := client.GetSubObject("nodes")
483 nodeObj := nodesObj.GetSubObject(node.ID())
484
485 updateNodeName(client, node, options)
486
487 _, err := nodeObj.CallPost("commission", url.Values{})
488 if err != nil {
489 log.Printf("ERROR: Commission '%s' : '%s'", node.Hostname(), err)
490 }
491 return err
492 }
493 break
494 default:
495 // We are in a state from which we can't move forward.
496 log.Printf("ERROR: %s has invalid power state '%s'", node.Hostname(), state)
497 break
498 }
499 return nil
500}
501
502// Wait a do nothing state, while work is being done
503var Wait = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
504 log.Printf("WAIT: %s", node.Hostname())
505 return nil
506}
507
508// Fail a state from which we cannot, currently, automatically recover
509var Fail = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
510 log.Printf("FAIL: %s", node.Hostname())
511 return nil
512}
513
514// AdminState an administrative state from which we should make no automatic transition
515var AdminState = func(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
516 log.Printf("ADMIN: %s", node.Hostname())
517 return nil
518}
519
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700520func findActions(target string, current string) ([]Action, error) {
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700521 targets, ok := Transitions[target]
522 if !ok {
523 log.Printf("[warn] unable to find transitions to target state '%s'", target)
524 return nil, fmt.Errorf("Could not find transition to target state '%s'", target)
525 }
526
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700527 actions, ok := targets[current]
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700528 if !ok {
529 log.Printf("[warn] unable to find transition from current state '%s' to target state '%s'",
530 current, target)
531 return nil, fmt.Errorf("Could not find transition from current state '%s' to target state '%s'",
532 current, target)
533 }
534
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700535 return actions, nil
536}
537
538// ProcessActions
539func ProcessActions(actions []Action, client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
540 var err error
541 for _, action := range actions {
542 if err = action(client, node, options); err != nil {
543 log.Printf("[error] Error while processing action for node '%s' : %s", node.Hostname, err)
544 break
545 }
546 }
547 return err
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700548}
549
550// ProcessNode something
551func ProcessNode(client *maas.MAASObject, node MaasNode, options ProcessingOptions) error {
552 substatus, err := node.GetInteger("substatus")
553 if err != nil {
554 return err
555 }
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700556 actions, err := findActions("Deployed", MaasNodeStatus(substatus).String())
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700557 if err != nil {
558 return err
559 }
560
561 if options.Preview {
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700562 ProcessActions(actions, client, node, options)
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700563 } else {
David K. Bainbridgeefa951d2016-05-26 10:54:25 -0700564 go ProcessActions(actions, client, node, options)
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700565 }
566 return nil
567}
568
569func buildFilter(filter []string) ([]*regexp.Regexp, error) {
570
571 results := make([]*regexp.Regexp, len(filter))
572 for i, v := range filter {
573 r, err := regexp.Compile(v)
574 if err != nil {
575 return nil, err
576 }
577 results[i] = r
578 }
579 return results, nil
580}
581
582func matchedFilter(include []*regexp.Regexp, target string) bool {
583 for _, e := range include {
584 if e.MatchString(target) {
585 return true
586 }
587 }
588 return false
589}
590
591// ProcessAll something
592func ProcessAll(client *maas.MAASObject, nodes []MaasNode, options ProcessingOptions) []error {
593 errors := make([]error, len(nodes))
594 includeHosts, err := buildFilter(options.Filter.Hosts.Include)
595 if err != nil {
596 log.Fatalf("[error] invalid regular expression for include filter '%s' : %s", options.Filter.Hosts.Include, err)
597 }
598
599 includeZones, err := buildFilter(options.Filter.Zones.Include)
600 if err != nil {
601 log.Fatalf("[error] invalid regular expression for include filter '%v' : %s", options.Filter.Zones.Include, err)
602 }
603
604 for i, node := range nodes {
605 // For hostnames we always match on an empty filter
606 if len(includeHosts) >= 0 && matchedFilter(includeHosts, node.Hostname()) {
607
608 // For zones we don't match on an empty filter
609 if len(includeZones) >= 0 && matchedFilter(includeZones, node.Zone()) {
610 err := ProcessNode(client, node, options)
611 if err != nil {
612 errors[i] = err
613 } else {
614 errors[i] = nil
615 }
616 } else {
617 if options.Verbose {
618 log.Printf("[info] ignoring node '%s' as its zone '%s' didn't match include zone name filter '%v'",
619 node.Hostname(), node.Zone(), options.Filter.Zones.Include)
620 }
621 }
622 } else {
623 if options.Verbose {
624 log.Printf("[info] ignoring node '%s' as it didn't match include hostname filter '%v'",
625 node.Hostname(), options.Filter.Hosts.Include)
626 }
627 }
628 }
629 return errors
630}