diff --git a/Makefile b/Makefile index fcfceb94..1583645a 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ USE_PROXY ?= false PUSH_IMAGE ?= false LABEL ?= commit-id IMAGE ?= ${DOCKER_REGISTRY}/${IMAGE_PREFIX}/${IMAGE_NAME}:${IMAGE_TAG} -GO_BUILDER ?= docker.io/golang:1.10-alpine +GO_BUILDER ?= docker.io/golang:1.10-stretch export @@ -38,7 +38,7 @@ run_images: run_drydock # Run tests .PHONY: tests -tests: pep8 security docs unit_tests +tests: pep8 security docs unit_tests test_baclient # Install external (not managed by tox/pip) dependencies external_dep: requirements-host.txt requirements-host-test.txt @@ -112,7 +112,12 @@ endif # Make target for building bootaction signal client .PHONY: build_baclient build_baclient: external_dep - docker run -tv $(shell realpath go):/work -v $(shell realpath $(BUILD_DIR)):/build -e GOPATH=/work $(GO_BUILDER) go build -o /build/baclient baclient + docker run -tv $(shell realpath go):/work -v $(shell realpath $(BUILD_DIR)):/build -e GOPATH=/work $(GO_BUILDER) go build -o /build/baclient baclient + +# Make target for testing bootaction signal client +.PHONY: test_baclient +test_baclient: external_dep + docker run -tv $(shell realpath go):/work -e GOPATH=/work $(GO_BUILDER) go test -v baclient .PHONY: docs docs: clean drydock_docs diff --git a/go/src/baclient/api_client.go b/go/src/baclient/api_client.go new file mode 100644 index 00000000..494d262b --- /dev/null +++ b/go/src/baclient/api_client.go @@ -0,0 +1,71 @@ +// Copyright 2018 AT&T Intellectual Property. All other rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Interact with the Drydock Bootaction API + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +func (msg *BootactionMessage) post(url string, key string) error { + timeout, _ := time.ParseDuration("60s") + + api_request, err := buildRequest(url, key, msg) + + if err != nil { + return fmt.Errorf("Error build API request: %s", err) + } + + client := &http.Client{ + Timeout: timeout, + } + + resp, err := client.Do(api_request) + + if err != nil { + return fmt.Errorf("Error sending the API request: %s\n", err) + } + + if resp.StatusCode >= 400 { + return fmt.Errorf("Error response: %s", resp.Status) + } + + return nil +} + +func buildRequest(url string, key string, msg *BootactionMessage) (*http.Request, error) { + body, err := json.Marshal(msg) + + if err != nil { + return nil, fmt.Errorf("Error encoding message: %s\n", err) + } + + bodyReader := bytes.NewBuffer(body) + req, err := http.NewRequest(http.MethodPost, url, bodyReader) + + if err != nil { + return nil, fmt.Errorf("Error creating API request: %s\n", err) + } + + req.Header.Add("X-Bootaction-Key", key) + req.Header.Add("Content-Type", "application/json") + + return req, nil +} diff --git a/go/src/baclient/baclient.go b/go/src/baclient/baclient.go index e8c03319..8c3c93de 100644 --- a/go/src/baclient/baclient.go +++ b/go/src/baclient/baclient.go @@ -1,9 +1,131 @@ +// Copyright 2018 AT&T Intellectual Property. All other rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This is a CLI for interacting with the Airship-Drydock Bootaction Signal +// API + package main import ( - "fmt" + "fmt" + "os" ) func main() { - fmt.Printf("Hello World!\n") + conf := parseConfig() + + // Indicates the help CLI flag was given + if conf == nil { + os.Exit(0) + } + + if !conf.validate() { + os.Exit(2) + } + + var err error + + if conf.wrapExecutable != "" { + err = reportExecution(conf) + } else { + err = reportMessage(conf) + } + + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Printf("Bootaction status posted.\n") + os.Exit(0) +} + +func reportExecution(conf *ClientConfig) error { + url := renderURL(conf) + + msg, _ := newMessage("Bootaction starting execution.", false, "") + err := msg.post(url, conf.bootactionKey) + + if err != nil { + return fmt.Errorf("Error accessing API: %s", err) + } + + result := executeAction(conf.wrapExecutable, conf.proxyEnvironment) + + if result { + msg, _ = newMessage("Bootaction execution successful.", false, SUCCESS) + } else { + msg, _ = newMessage("Bootaction execution failed.", true, FAILURE) + } + + err = msg.post(url, conf.bootactionKey) + + if err != nil { + return fmt.Errorf("Error accessing API: %s", err) + } + + return nil +} + +func reportMessage(conf *ClientConfig) error { + url := renderURL(conf) + + msg, err := newMessage(conf.message, conf.isError, conf.status) + + if err != nil { + return fmt.Errorf("Error creating message: %s\n", err) + } + + err = msg.post(url, conf.bootactionKey) + + if err != nil { + return fmt.Errorf("Error accesing API: %s\n", err) + } + + return nil +} + +func renderURL(conf *ClientConfig) (fullURL string) { + fullURL = fmt.Sprintf("%s/%s/", conf.apiURL, conf.bootactionID) + return +} + +func newMessageDetail(msg string, isError bool) (*BootactionDetail, error) { + // isError defaults to false if nil + if msg == "" { + return nil, fmt.Errorf("Error creating MessageDetail, message string undefined.") + } + + var msg_detail BootactionDetail + + msg_detail.Message = msg + msg_detail.IsError = isError + + return &msg_detail, nil +} + +func newMessage(msg string, isError bool, finalStatus string) (*BootactionMessage, error) { + msg_detail, err := newMessageDetail(msg, isError) + + if err != nil { + return nil, fmt.Errorf("Error creating Message: %s", err) + } + + var message BootactionMessage + + message.Status = finalStatus + message.Details = []BootactionDetail{*msg_detail} + + return &message, nil } diff --git a/go/src/baclient/baclient_test.go b/go/src/baclient/baclient_test.go new file mode 100644 index 00000000..9fbdf9e9 --- /dev/null +++ b/go/src/baclient/baclient_test.go @@ -0,0 +1,232 @@ +// Copyright 2018 AT&T Intellectual Property. All other rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Tests for the baclient + +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + mrand "math/rand" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPostIncompleteMessage(t *testing.T) { + var conf ClientConfig + + bootactionID := generateID() + bootactionKey := generateKey() + + ts := buildTestServer(t, bootactionID, bootactionKey, "") + defer ts.Close() + + conf.apiURL = fmt.Sprintf("%s/api/v1.0/bootaction", ts.URL) + conf.bootactionID = bootactionID + conf.bootactionKey = bootactionKey + conf.message = "Testing 1 2 3" + conf.isError = false + + err := reportMessage(&conf) + + if err != nil { + t.Error(fmt.Sprintf("%s", err)) + } + +} + +func TestPostSuccessMessage(t *testing.T) { + var conf ClientConfig + + bootactionID := generateID() + bootactionKey := generateKey() + + ts := buildTestServer(t, bootactionID, bootactionKey, SUCCESS) + defer ts.Close() + + conf.apiURL = fmt.Sprintf("%s/api/v1.0/bootaction", ts.URL) + conf.bootactionID = bootactionID + conf.bootactionKey = bootactionKey + conf.message = "Testing 1 2 3" + conf.isError = false + conf.status = SUCCESS + + err := reportMessage(&conf) + + if err != nil { + t.Error(fmt.Sprintf("%s", err)) + } +} + +func TestPostFailureMessage(t *testing.T) { + var conf ClientConfig + + bootactionID := generateID() + bootactionKey := generateKey() + + ts := buildTestServer(t, bootactionID, bootactionKey, FAILURE) + defer ts.Close() + + conf.apiURL = fmt.Sprintf("%s/api/v1.0/bootaction", ts.URL) + conf.bootactionID = bootactionID + conf.bootactionKey = bootactionKey + conf.message = "Testing 1 2 3" + conf.isError = true + conf.status = FAILURE + + err := reportMessage(&conf) + + if err != nil { + t.Error(fmt.Sprintf("%s", err)) + } +} + +func TestPostSuccessExec(t *testing.T) { + var conf ClientConfig + + bootactionID := generateID() + bootactionKey := generateKey() + + ts := buildTestServer(t, bootactionID, bootactionKey, SUCCESS) + defer ts.Close() + + conf.apiURL = fmt.Sprintf("%s/api/v1.0/bootaction", ts.URL) + conf.bootactionID = bootactionID + conf.bootactionKey = bootactionKey + conf.wrapExecutable = "/bin/true" + + err := reportExecution(&conf) + + if err != nil { + t.Error(fmt.Sprintf("%s", err)) + } +} + +func TestPostFailureExec(t *testing.T) { + var conf ClientConfig + + bootactionID := generateID() + bootactionKey := generateKey() + + ts := buildTestServer(t, bootactionID, bootactionKey, FAILURE) + defer ts.Close() + + conf.apiURL = fmt.Sprintf("%s/api/v1.0/bootaction", ts.URL) + conf.bootactionID = bootactionID + conf.bootactionKey = bootactionKey + conf.wrapExecutable = "/bin/false" + + err := reportExecution(&conf) + + if err != nil { + t.Error(fmt.Sprintf("%s", err)) + } +} + +func generateID() string { + // In order to stay within the Go stdlib and because real randomness here + // isn't that valuable, just pick one of a few hardcoded ulids + var ulidPool [5]string = [5]string{ + "01CP38QN33KZ5E2MZBC0S7PJHR", + "01CP393Q44NW9TFVT1W8QTY2PP", + "01CP39489G7SRNJX6G1E61P4X5", + "01CP394JQEEH6127FCQVB4TBKY", + "01CP394TFYMH38VSM4JNJZHM9Y", + } + + selector := mrand.Int31n(5) + return ulidPool[selector] +} + +func generateKey() string { + key := make([]byte, 32) + _, _ = rand.Read(key) + + keyHex := make([]byte, hex.EncodedLen(len(key))) + hex.Encode(keyHex, key) + + return string(keyHex) +} + +func buildTestServer(t *testing.T, bootactionID string, bootactionKey string, expectedResult string) *httptest.Server { + hf := func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Logf("Request used method %s.\n", r.Method) + w.WriteHeader(405) + return + } + + contentType := r.Header.Get("Content-Type") + + if contentType != "application/json" { + t.Logf("Request had content type '%s'\n", contentType) + w.WriteHeader(415) + return + } + + reqKey := r.Header.Get("X-Bootaction-Key") + + if reqKey != bootactionKey { + t.Logf("Request contained 'X-Bootaction-Key': %s\n", reqKey) + w.WriteHeader(403) + return + } + + if !strings.Contains(r.URL.Path, bootactionID) { + t.Logf("Requested URL path '%s' missing bootactionID\n", r.URL.Path) + w.WriteHeader(404) + return + } + + reqBody, err := ioutil.ReadAll(r.Body) + + if err != nil { + t.Logf("Error reading test request: %s\n", err) + w.WriteHeader(400) + return + } + + var message BootactionMessage + + err = json.Unmarshal(reqBody, &message) + + if err != nil { + t.Logf("Error parsing test request: %s\n", err) + w.WriteHeader(400) + return + } + + if message.Status != "" && message.Status != expectedResult { + t.Logf("Did not receive expected result, instead received '%s'\n", message.Status) + w.WriteHeader(400) + return + } + + t.Logf("Handled request: %s - %s\n", r.Method, r.URL.Path) + t.Logf("Key: %s\n", reqKey) + t.Logf("Body:\n %s\n", reqBody) + w.WriteHeader(201) + return + } + + ts := httptest.NewServer(http.HandlerFunc(hf)) + + return ts +} diff --git a/go/src/baclient/config.go b/go/src/baclient/config.go new file mode 100644 index 00000000..c883629c --- /dev/null +++ b/go/src/baclient/config.go @@ -0,0 +1,121 @@ +// Copyright 2018 AT&T Intellectual Property. All other rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bufio" + "flag" + "fmt" + "os" + "strings" +) + +func parseConfig() *ClientConfig { + var clientConfig ClientConfig + + parseFlagConfig(&clientConfig) + + if clientConfig.showHelp { + flag.PrintDefaults() + return nil + } + + parseEnvConfig(&clientConfig) + + if clientConfig.bootactionKey == "" { + if clientConfig.bootactionKeyPath != "" { + clientConfig.bootactionKey, _ = readKeyFile(clientConfig.bootactionKeyPath) + } + } + + return &clientConfig +} + +func readKeyFile(keyPath string) (string, error) { + keyFile, err := os.Open(keyPath) + defer keyFile.Close() + + if err == nil { + var keyString string + + bufReader := bufio.NewReader(keyFile) + + keyString, err = bufReader.ReadString('\n') + + if err != nil { + return "", fmt.Errorf("Error reading key file: %s", err) + } else { + keyString = strings.Trim(keyString, "\n") + return keyString, nil + } + } else { + return "", fmt.Errorf("Error opening key file: %s", err) + } +} + +func parseFlagConfig(clientConfig *ClientConfig) { + // If neither 's' or 'f' are specified, the API call will omit the 'status' field + success := flag.Bool("s", false, "Does this message indicate bootaction success.") + failure := flag.Bool("f", false, "Does this message indicate bootaction failure.") + + if *failure { + clientConfig.status = FAILURE + } else if *success { + clientConfig.status = SUCCESS + } + + flag.BoolVar(&clientConfig.showHelp, "h", false, "Show help and exit") + flag.BoolVar(&clientConfig.isError, "e", false, "Does this message indicate error") + flag.BoolVar(&clientConfig.proxyEnvironment, "np", false, "When wrapping an executable, should proxying the environment be disabled.") + + flag.StringVar(&clientConfig.apiURL, "url", "", "Drydock API URL") + flag.StringVar(&clientConfig.bootactionID, "id", "", "Bootaction ID") + flag.StringVar(&clientConfig.bootactionKey, "key", "", "Bootaction ID") + flag.StringVar(&clientConfig.bootactionKeyPath, "keyfile", "", "Absolute path to a file containing the API key") + flag.StringVar(&clientConfig.message, "msg", "", "The detail message to record for the bootaction") + flag.StringVar(&clientConfig.wrapExecutable, "exec", "", "The absolute path to an executable to run and report result.") + + flag.Parse() +} + +func parseEnvConfig(clientConfig *ClientConfig) { + // for security, support reading the bootaction key from the environment + baKey := os.Getenv("BOOTACTION_KEY") + + if baKey != "" { + clientConfig.bootactionKey = baKey + } +} + +func (clientConfig *ClientConfig) validate() bool { + valid := true + + if clientConfig.bootactionID == "" { + valid = false + fmt.Printf("No Bootaction ID specified.\n") + } + + if clientConfig.bootactionKey == "" && clientConfig.bootactionKeyPath == "" { + valid = false + fmt.Printf("No Bootaction Key is specified.\n") + } + + if clientConfig.message == "" { + valid = false + fmt.Printf("Status message required.\n") + } + + return valid +} diff --git a/go/src/baclient/exec.go b/go/src/baclient/exec.go new file mode 100644 index 00000000..55a7e431 --- /dev/null +++ b/go/src/baclient/exec.go @@ -0,0 +1,43 @@ +// Copyright 2018 AT&T Intellectual Property. All other rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os/exec" + "strings" +) + +func executeAction(commandLine string, proxyEnv bool) bool { + cmdParts := strings.Split(commandLine, " ") + var cmdArgs []string + + if len(cmdParts) > 1 { + cmdArgs = cmdParts[1:] + } + + cmd := exec.Command(cmdParts[0], cmdArgs...) + + if !proxyEnv { + cmd.Env = []string{} + } + + err := cmd.Run() + + if err != nil { + return false + } + + return true +} diff --git a/go/src/baclient/types.go b/go/src/baclient/types.go new file mode 100644 index 00000000..c192077c --- /dev/null +++ b/go/src/baclient/types.go @@ -0,0 +1,46 @@ +// Copyright 2018 AT&T Intellectual Property. All other rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This is a CLI for interacting with the Airship-Drydock Bootaction Signal +// API + +package main + +const ( + SUCCESS = "success" + FAILURE = "failure" +) + +type ClientConfig struct { + apiURL string + bootactionID string + bootactionKey string + bootactionKeyPath string + message string + isError bool + status string + wrapExecutable string + proxyEnvironment bool + showHelp bool +} + +type BootactionMessage struct { + Status string `json:"status,omitempty"` + Details []BootactionDetail `json:"details"` +} + +type BootactionDetail struct { + Message string `json:"message"` + IsError bool `json:"error"` +}