Bootaction client initial implementation

This is the implementation of a standalone bootaction
signal API client to be used on deployed nodes for signaling
details and results of bootactions.

Change-Id: Icc3d39253a02457a76f79d7a7c06333ae494d735
This commit is contained in:
Scott Hussey 2018-08-23 16:59:13 -05:00
parent 9a52dca199
commit ecb3e01527
7 changed files with 645 additions and 5 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

121
go/src/baclient/config.go Normal file
View File

@ -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
}

43
go/src/baclient/exec.go Normal file
View File

@ -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
}

46
go/src/baclient/types.go Normal file
View File

@ -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"`
}