diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..f67e942 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89a0f37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +.idea + +vendor \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a7a0e3d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +dist: trusty +sudo: required +language: go +go: +- "1.11" +script: +- make build +- make test +- make vet +branches: + only: + - v2 + - master +matrix: + fast_finish: true + allow_failures: + - go: tip + diff --git a/GNUmakefile b/GNUmakefile new file mode 100644 index 0000000..9cf8aaa --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,43 @@ +TEST?=$$(go list ./...) +GOFMT_FILES?=$$(find . -name '*.go') +PKG_NAME=skytap + +default: build + +build: fmtcheck + go install ./skytap + +test: fmtcheck + go get -t $(TEST) + go test -i $(TEST) || exit 1 + echo $(TEST) | \ + xargs -t -n4 go test $(TESTARGS) -timeout=30s -parallel=4 -v + +vet: + @echo "go vet ./skytap" + @go vet $$(go list ./...) ; if [ $$? -eq 1 ]; then \ + echo ""; \ + echo "Vet found suspicious constructs. Please check the reported constructs"; \ + echo "and fix them if necessary before submitting the code for review."; \ + exit 1; \ + fi + +fmt: + gofmt -w $(GOFMT_FILES) + +fmtcheck: + @sh -c "'$(CURDIR)/scripts/gofmtcheck.sh'" + +test-compile: + @if [ "$(TEST)" = "./..." ]; then \ + echo "ERROR: Set TEST to a specific package. For example,"; \ + echo " make test-compile TEST=./$(PKG_NAME)"; \ + exit 1; \ + fi + go test -c $(TEST) $(TESTARGS) + +lint: + golint skytap + +.PHONY: build test vet fmt fmtcheck test-compile lint + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..111e97f --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +[![Travis CI][Travis-Image]][Travis-Url] + +# Skytap GO SDK + +skytap-sdk-go is a Go client library for accessing the Skytap API. + +You can view Skytap API docs here: [https://help.skytap.com/api.html](https://help.skytap.com/api.html) + +### Setup for local development + +- Ensure you have set up your Go environment correctly - see [here](https://golang.org/doc/code.html) for more details. + +- Install Go (skytap-sdk-go is currently built against 1.11) + +- Checkout repository + +``` + mkdir -p "$GOPATH/src/github.com/skytap/" + git clone https://github.com/skytap/skytap-sdk-go.git "$GOPATH/src/github.com/skytap/skytap-sdk-go" + cd "$GOPATH/src/github.com/skytap/skytap-sdk-go" +``` + +### Build SDK + + make build + +### Test SDK + + make test + +[Travis-Image]: https://travis-ci.org/skytap/skytap-sdk-go.svg?branch=v2 +[Travis-Url]: https://travis-ci.org/skytap/skytap-sdk-go \ No newline at end of file diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..6c809c5 --- /dev/null +++ b/api/api.go @@ -0,0 +1,47 @@ +// Copyright 2016 Skytap Inc. +// +// 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 api + +import () + +const ( + RunStateStart = "running" + RunStateStop = "stopped" + RunStatePause = "suspended" + RunStateKill = "halted" + RunStateBusy = "busy" + RunStateReset = "reset" +) + +func isOkStatus(code int) bool { + codes := map[int]bool{ + 200: true, + 201: true, + 204: true, + 401: false, + 404: false, + 409: false, + 422: false, + 423: false, + 429: false, + 500: false, + } + + return codes[code] +} + +func isBusy(code int) bool { + return code == 423 +} diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 0000000..91c1d7d --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,83 @@ +// Copyright 2016 Skytap Inc. +// +// 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 api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "encoding/json" + "fmt" + "io/ioutil" + "os" + + log "github.com/Sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +type testConfig struct { + Username string `json:"username"` + ApiKey string `json:"apiKey"` + TemplateId string `json:"templateId"` + VmId string `json:"vmId"` + VpnId string `json:"vpnId"` +} + +func init() { + log.SetLevel(log.DebugLevel) + log.SetFormatter(&log.TextFormatter{ForceColors: true}) +} + +func skytapClient(t *testing.T) SkytapClient { + c := getTestConfig(t) + fmt.Printf("c: %s, user: %s", c, c.Username) + client := &http.Client{} + return SkytapClient{ + HttpClient: client, + Credentials: SkytapCredentials{Username: c.Username, ApiKey: c.ApiKey}, + } +} + +func getTestConfig(t *testing.T) *testConfig { + configFile, err := os.Open("testdata/config.json") + require.NoError(t, err, "Error reading config.json") + + jsonParser := json.NewDecoder(configFile) + c := &testConfig{} + err = jsonParser.Decode(c) + require.NoError(t, err, "Error parsing config.json") + return c +} + +func getMockServer(client SkytapClient) *httptest.Server { + return getMockServerForString(client, "") +} + +func getMockServerForString(client SkytapClient, content string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, string(content)) + })) + // Divert all API requests to this server + baseUrlOveride = server.URL + client.HttpClient = server.Client() + return server +} + +func readJson(t *testing.T, filename string) string { + str, err := ioutil.ReadFile(filename) + require.NoError(t, err, "Error reading "+filename) + return string(str) +} diff --git a/api/environment.go b/api/environment.go new file mode 100644 index 0000000..b33d4fe --- /dev/null +++ b/api/environment.go @@ -0,0 +1,283 @@ +// Copyright 2016 Skytap Inc. +// +// 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 api + +import ( + "errors" + + log "github.com/Sirupsen/logrus" + "github.com/dghubble/sling" +) + +const ( + EnvironmentPath = "configurations" +) + +/** +Skytap Environment resource. +*/ +type Environment struct { + Id string `json:"id,omitempty"` + Url string `json:"url,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Error []string `json:"errors,omitempty"` + Runstate string `json:"runstate,omitempty"` + Vms []*VirtualMachine `json:"vms,omitempty"` + Networks []Network `json:"networks,omitempty"` +} + +/* + Request body for create commands. +*/ +type CreateEnvironmentBody struct { + TemplateId string `json:"template_id"` +} + +/* + Request body for merge commands. +*/ +type MergeTemplateBody struct { + TemplateId string `json:"template_id"` + VmIds []string `json:"vm_ids"` +} + +/* + Request body for copy environment commands. +*/ +type CopyEnvironmentBody struct { + EnvironmentId string `json:"configuration_id"` + VmIds []string `json:"vm_ids"` +} + +/* + Request body for merge commands. +*/ +type MergeEnvironmentBody struct { + EnvironmentId string `json:"merge_configuration"` + VmIds []string `json:"vm_ids"` +} + +func environmentIdV1Path(envId string) string { return EnvironmentPath + "/" + envId } +func environmentIdPath(envId string) string { return EnvironmentPath + "/" + envId + ".json" } + +func RenameEnvironment(client SkytapClient, envId string, name string, restartEnv bool) (*Environment, error) { + nameReq := func(s *sling.Sling) *sling.Sling { + return s.Put(environmentIdPath(envId)).BodyJSON(&Environment{Name: name}) + } + + interfaceResp := &Environment{} + + log.WithFields(log.Fields{"newName": name, "envId": envId}).Infof("Renaming environment") + _, err := RunSkytapRequest(client, false, interfaceResp, nameReq) + return interfaceResp, err +} + +/* + Adds a VM to an existing environment. +*/ +func (e *Environment) AddVirtualMachine(client SkytapClient, vmId string) (*Environment, error) { + log.WithFields(log.Fields{"vmId": vmId, "envId": e.Id}).Info("Adding virtual machine") + + vm, err := GetVirtualMachine(client, vmId) + if err != nil { + return e, err + } + + template, err := vm.GetTemplate(client) + if err != nil { + return e, err + } + if template != nil { + return e.MergeTemplateVirtualMachine(client, template.Id, vmId) + } + + sourceEnv, err := vm.GetEnvironment(client) + if err != nil { + return e, err + } + if sourceEnv != nil { + return e.MergeEnvironmentVirtualMachine(client, sourceEnv.Id, vmId) + } + + return e, errors.New("Unable to determine source of VM, no environment or template url found") +} + +func (e *Environment) RunstateStr() string { return e.Runstate } + +func (e *Environment) Refresh(client SkytapClient) (RunstateAwareResource, error) { + return GetEnvironment(client, e.Id) +} + +func (e *Environment) WaitUntilInState(client SkytapClient, desiredStates []string, requireStateChange bool) (*Environment, error) { + r, err := WaitUntilInState(client, desiredStates, e, requireStateChange) + newEnv := r.(*Environment) + return newEnv, err +} + +func (e *Environment) WaitUntilReady(client SkytapClient) (*Environment, error) { + return e.WaitUntilInState(client, []string{RunStateStop, RunStateStart, RunStatePause}, false) +} + +/* + Merge an environment based VM into this environment (the VM must be in an existing environment). +*/ +func (e *Environment) MergeEnvironmentVirtualMachine(client SkytapClient, envId string, vmId string) (*Environment, error) { + return e.MergeVirtualMachine(client, &MergeEnvironmentBody{EnvironmentId: envId, VmIds: []string{vmId}}) +} + +/* + Merge a template based VM into this environment (the VM must be in an existing template). +*/ +func (e *Environment) MergeTemplateVirtualMachine(client SkytapClient, templateId string, vmId string) (*Environment, error) { + return e.MergeVirtualMachine(client, &MergeTemplateBody{TemplateId: templateId, VmIds: []string{vmId}}) +} + +/* + Merge arbitrary VM into this environment. + + mergeBody - The correct representation of the request body, see the MergeEnvironmentVirtualMachine and MergeTemplateVirtualMachine methods. +*/ +func (e *Environment) MergeVirtualMachine(client SkytapClient, mergeBody interface{}) (*Environment, error) { + + log.WithFields(log.Fields{"mergeBody": mergeBody, "envId": e.Id}).Info("Merging a VM into environment") + + merge := func(s *sling.Sling) *sling.Sling { + return s.Put(environmentIdPath(e.Id)).BodyJSON(mergeBody) + } + + newEnv := &Environment{} + _, err := RunSkytapRequest(client, false, newEnv, merge) + if err != nil { + log.Errorf("Unable to add VM to environment (%s), requestBody: %+v, cause: %s", e.Id, mergeBody, err) + return e, err + } + return newEnv, nil +} + +/* + Starts an environment. +*/ +func (e *Environment) Start(client SkytapClient) (*Environment, error) { + log.WithFields(log.Fields{"envId": e.Id}).Info("Starting Environment") + + return e.ChangeRunstate(client, RunStateStart, RunStateStart) +} + +/* + Suspends an environment. +*/ +func (e *Environment) Suspend(client SkytapClient) (*Environment, error) { + log.WithFields(log.Fields{"envId": e.Id}).Info("Stopping Environment") + + return e.ChangeRunstate(client, RunStatePause, RunStatePause) +} + +/* + Changes the runstate of the Environment to the specified state and waits until the Environment is in the desired state. +*/ +func (e *Environment) ChangeRunstate(client SkytapClient, runstate string, desiredRunstate string) (*Environment, error) { + log.WithFields(log.Fields{"changeState": runstate, "targetState": desiredRunstate, "envId": e.Id}).Info("Changing VM runstate") + + ready, err := e.WaitUntilReady(client) + if err != nil { + return ready, err + } + changeState := func(s *sling.Sling) *sling.Sling { + return s.Put(environmentIdPath(e.Id)).BodyJSON(&RunstateBody{Runstate: runstate}) + } + _, err = RunSkytapRequest(client, false, nil, changeState) + + if err != nil { + return e, err + } + return e.WaitUntilInState(client, []string{desiredRunstate}, true) +} + +/* + Return an existing environment by id. +*/ +func GetEnvironment(client SkytapClient, envId string) (*Environment, error) { + env := &Environment{} + + getEnv := func(s *sling.Sling) *sling.Sling { + return s.Get(environmentIdPath(envId)) + } + + _, err := RunSkytapRequest(client, true, env, getEnv) + return env, err +} + +/* + Create a new environment from a template. +*/ +func CreateNewEnvironment(client SkytapClient, templateId string) (*Environment, error) { + log.WithFields(log.Fields{"templateId": templateId}).Info("Creating environment from template") + + env := &Environment{} + + createEnv := func(s *sling.Sling) *sling.Sling { + return s.Post(EnvironmentPath + ".json").BodyJSON(&CreateEnvironmentBody{TemplateId: templateId}) + } + + _, err := RunSkytapRequest(client, false, env, createEnv) + return env, err +} + +/* + Create a new environment from a source template, including only specific VMs, which must be a part of the template. +*/ +func CreateNewEnvironmentWithVms(client SkytapClient, templateId string, vmIds []string) (*Environment, error) { + log.WithFields(log.Fields{"templateId": templateId}).Info("Creating environment from template") + + env := &Environment{} + + createEnvWithVM := func(s *sling.Sling) *sling.Sling { + return s.Post(EnvironmentPath + ".json").BodyJSON(&MergeTemplateBody{TemplateId: templateId, VmIds: vmIds}) + } + + _, err := RunSkytapRequest(client, false, env, createEnvWithVM) + return env, err +} + +/* + Create a new environment from a source environment, including only specific VMs, which must be a part of the template. +*/ +func CopyEnvironmentWithVms(client SkytapClient, sourceEnvId string, vmIds []string) (*Environment, error) { + log.WithFields(log.Fields{"sourceEnvId": sourceEnvId}).Info("Copying environment from existing") + + env := &Environment{} + + createEnvWithVM := func(s *sling.Sling) *sling.Sling { + return s.Post(EnvironmentPath + ".json").BodyJSON(&CopyEnvironmentBody{EnvironmentId: sourceEnvId, VmIds: vmIds}) + } + + _, err := RunSkytapRequest(client, false, env, createEnvWithVM) + return env, err +} + +/* + Delete an environment by id. +*/ +func DeleteEnvironment(client SkytapClient, envId string) error { + log.WithFields(log.Fields{"envId": envId}).Info("Deleting environment") + + deleteEnv := func(s *sling.Sling) *sling.Sling { + return s.Delete(EnvironmentPath + "/" + envId) + } + + _, err := RunSkytapRequest(client, false, nil, deleteEnv) + return err +} diff --git a/api/environment_test.go b/api/environment_test.go new file mode 100644 index 0000000..0f19c45 --- /dev/null +++ b/api/environment_test.go @@ -0,0 +1,142 @@ +package api + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateNewEnvironment(t *testing.T) { + envJson := readJson(t, "testdata/environment-1.json") + + client := skytapClient(t) + server := getMockServer(client) + defer server.Close() + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/configurations.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"template_id":"2"}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, envJson) + }) + + env, err := CreateNewEnvironment(client, "2") + require.NoError(t, err, "Error creating environment") + require.Equal(t, "Environment 1", env.Name) +} + +func TestCreateNewEnvironmentWithVms(t *testing.T) { + envJson := readJson(t, "testdata/environment-1.json") + + client := skytapClient(t) + server := getMockServer(client) + defer server.Close() + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/configurations.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"template_id":"2","vm_ids":["1002"]}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, envJson) + }) + + env, err := CreateNewEnvironmentWithVms(client, "2", []string{"1002"}) + require.NoError(t, err, "Error creating environment") + require.Equal(t, "Environment 1", env.Name) +} + +func TestCopyEnvironmentWithVms(t *testing.T) { + envJson := readJson(t, "testdata/environment-1.json") + + client := skytapClient(t) + server := getMockServer(client) + defer server.Close() + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/configurations.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"configuration_id":"1","vm_ids":["1001"]}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, envJson) + }) + + env, err := CopyEnvironmentWithVms(client, "1", []string{"1001"}) + require.NoError(t, err, "Error creating environment") + require.Equal(t, "Environment 1", env.Name) +} + +func TestAddVirtualMachineFromTemplate(t *testing.T) { + envJson := readJson(t, "testdata/environment-1.json") + templJson := readJson(t, "testdata/template-2.json") + templateVmJson := readJson(t, "testdata/vm-1002.json") + + client := skytapClient(t) + server := getMockServerForString(client, envJson) + defer server.Close() + + env, err := GetEnvironment(client, "1") + require.NoError(t, err, "Error getting environment") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tmpstr := "" + if r.Method == "GET" { + // handle requests for vm and its template + if strings.Contains(r.URL.Path, "/vms") { + // replace any hostnames in the vm json + tmpstr = strings.Replace(templateVmJson, "https://cloud.skytap.com", server.URL, -1) + } else { + tmpstr = templJson + } + fmt.Fprintln(w, tmpstr) + } else if r.Method == "PUT" { + require.Equal(t, "/configurations/1.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"template_id":"2","vm_ids":["1002"]}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, envJson) + } + }) + + env, err = env.AddVirtualMachine(client, "1002") + require.NoError(t, err, "Error adding vm from template") + require.Equal(t, "Environment 1", env.Name) +} + +func TestAddVirtualMachineFromEnvironment(t *testing.T) { + envJson := readJson(t, "testdata/environment-1.json") + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, envJson) + defer server.Close() + + env, err := GetEnvironment(client, "1") + require.NoError(t, err, "Error getting environment") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tmpstr := "" + if r.Method == "GET" { + // handle requests for vm and its environment + if strings.Contains(r.URL.Path, "/vms") { + // replace any hostnames in the vm json + tmpstr = strings.Replace(vmJson, "https://cloud.skytap.com", server.URL, -1) + } else { + tmpstr = envJson + } + fmt.Fprintln(w, tmpstr) + } else if r.Method == "PUT" { + require.Equal(t, "/configurations/1.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"merge_configuration":"1","vm_ids":["1001"]}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, envJson) + } + }) + + env, err = env.AddVirtualMachine(client, "1001") + require.NoError(t, err, "Error adding vm from template") + require.Equal(t, "Environment 1", env.Name) +} diff --git a/api/network.go b/api/network.go new file mode 100644 index 0000000..8e016e1 --- /dev/null +++ b/api/network.go @@ -0,0 +1,356 @@ +// Copyright 2016 Skytap Inc. +// +// 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 api + +import ( + "fmt" + + "github.com/dghubble/sling" + + log "github.com/Sirupsen/logrus" +) + +const ( + NetworkPath = "networks" + InterfacePath = "interfaces" + VpnPath = "vpns" + PublishedServicePath = "services" +) + +/* + Network resource. +*/ +type Network struct { + Id string `json:"id"` + Url string `json:"url"` + Name string `json:"name"` + Subnet string `json:"subnet"` + Domain string `json:"domain"` + Gateway string `json:"gateway"` + NetworkType string `json:"network_type"` + Tunnelable bool `json:"tunnelable"` + Tunnels interface{} `json:"tunnels"` + PrimaryNameserver string `json:"primary_nameserver"` + SecondaryNameserver string `json:"secondary_nameserver"` + Region string `json:"region"` + NatSubnet string `json:"nat_subnet"` + NatPoolSize int `json:"nat_pool_size"` + NatPoolRemaining int `json:"nat_pool_remaining"` + VpnAttachments []VpnAttachment `json:"vpn_attachments"` +} + +/* + Network interface inside a VM. +*/ +type NetworkInterface struct { + Id string `json:"id,omitempty"` + Ip string `json:"ip,omitempty"` + PublicIpsCount int `json:"public_ips_count,omitempty"` + Hostname string `json:"hostname,omitempty"` + PublicIps []PublicIp `json:"public_ips,omitempty"` + NatAddresses *NatAddresses `json:"nat_addresses,omitempty"` + Status string `json:"status,omitempty"` + ExternalAddress string `json:"external_address,omitempty"` + NicType string `json:"nic_type,omitempty"` + NetworkId string `json:"network_id,omitempty"` + PublishedServices []PublishedService `json:"services,omitempty"` +} + +/* + Nat addresses stored inside network interface. +*/ +type NatAddresses struct { + VpnNatAddresses []VpnNatAddress `json:"vpn_nat_addresses,omitempty"` + NetworkNatAddresses []NetworkNatAddress `json:"network_nat_addresses,omitempty "` +} + +/* + VPN based NAT address. +*/ +type VpnNatAddress struct { + IpAddress string `json:"ip_address"` + VpnId string `json:"vpn_id"` + VpnName string `json:"vpn_name"` + VpnUrl string `json:"vpn_url"` +} + +/* + Network based NAT address. +*/ +type NetworkNatAddress struct { + IpAddress string `json:"ip_address"` + NetworkId string `json:"network_id"` + NetworkName string `json:"network_name"` + NetworkUrl string `json:"network_url"` + ConfigurationId string `json:"configuration_id"` + ConfigurationUrl string `json:"configuration_url"` +} + +/* + IP type. +*/ +type PublicIp struct { + Id string `json:"id"` + Address string `json:"address"` + Region string `json:"region"` + Nics interface{} `json:"nics"` + VpnId string `json:"vpn_id"` +} + +/* + VPN attachments to network. +*/ +type VpnAttachment struct { + Id string `json:"id"` + Connected bool `json:"connected"` + Vpn Vpn `json:"vpn"` +} + +type Vpn struct { + Id string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + NatEnabled bool `json:"nat_enabled"` + RemoteSubnets string `json:"remote_subnets"` + RemotePeerIp string `json:"remote_subnets"` + CanReconnect bool `json:"can_reconnect"` +} + +/* + Request body for VPN attach commands. +*/ +type AttachVpnBody struct { + VpnId string `json:"vpn_id"` +} + +/* + Response body for VPN attach commands. +*/ +type AttachVpnResult struct { + Id string `json:"id"` + Connected bool `json:"connected"` + Network NetworkInterface `json:"network"` + Vpn interface{} `json:"vpn"` +} + +/* + Request body for VPN connect commands. +*/ +type ConnectVpnBody struct { + Connected bool `json:"connected"` +} + +type PublishedService struct { + Id string `json:"id,omitempty"` + InternalPort int `json:"internal_port,omitempty"` + ExternalIp string `json:"external_ip,omitempty"` + ExternalPort int `json:"external_port,omitempty"` +} + +// CreateAutomaticNetwork - create a new network in an Environment +func CreateAutomaticNetwork( + client SkytapClient, + envId string, + name string, + subnet string, + domain string) (*Network, error) { + + log.WithFields(log.Fields{"envId": envId, "network_name": name}).Info("Adding network to environment") + + createAutoNetwork := func(s *sling.Sling) *sling.Sling { + network := struct { + Name string `json:"name"` + NetworkType string `json:"network_type"` + Subnet string `json:"subnet"` + Domain string `json:"domain"` + }{ + Name: name, + NetworkType: "automatic", + Subnet: subnet, + Domain: domain, + } + + return s.Post(EnvironmentPath + "/" + envId + "/" + NetworkPath + ".json").BodyJSON(network) + } + + network := new(Network) + _, err := RunSkytapRequest(client, false, network, createAutoNetwork) + + return network, err +} + +func CreateManualNetwork( + client SkytapClient, + envId string, + name string, + subnet string, + gateway string) (*Network, error) { + log.WithFields(log.Fields{"envId": envId, "network_name": name}).Info("Adding network to environment") + + createAutoNetwork := func(s *sling.Sling) *sling.Sling { + network := struct { + Name string `json:"name"` + NetworkType string `json:"network_type"` + Subnet string `json:"subnet"` + Gateway string `json:"gateway"` + }{ + Name: name, + NetworkType: "manual", + Subnet: subnet, + Gateway: gateway, + } + return s.Post(EnvironmentPath + "/" + envId + "/" + NetworkPath + ".json").BodyJSON(network) + + } + network := new(Network) + _, err := RunSkytapRequest(client, false, network, createAutoNetwork) + return network, err + +} + +// DeleteNetwork - delete a network from an environment +func DeleteNetwork(client SkytapClient, envId string, netId string) error { + log.WithFields(log.Fields{"envId": envId, "netId": netId}).Info("Deleting network in environment") + + deleteNet := func(s *sling.Sling) *sling.Sling { + return s.Delete(fmt.Sprintf("%s/%s/%s/%s", EnvironmentPath, envId, NetworkPath, netId)) + } + + _, err := RunSkytapRequest(client, false, nil, deleteNet) + return err +} + +/* + Path for all VPNs in a network and environment. +*/ +func vpnsForNetworkInEnvironmentPath(netId string, envId string) string { + return fmt.Sprintf("%s/%s/%s/%s/%s.json", EnvironmentPath, envId, NetworkPath, netId, VpnPath) +} + +/* + Path for a single VPN in a network and environment. +*/ +func vpnForNetworkInEnvironmentPath(netId string, envId string, vpnId string) string { + return fmt.Sprintf("%s/%s/%s/%s/%s/%s", EnvironmentPath, envId, NetworkPath, netId, VpnPath, vpnId) +} + +/* + Attach a network to a VPN, in the context of the given environment. +*/ +func (n *Network) AttachToVpn(client SkytapClient, envId string, vpnId string) (*AttachVpnResult, error) { + log.WithFields(log.Fields{"netId": n.Id, "vpnId": vpnId, "envId": envId}).Info("Attach network to VPN") + + attachBody := &AttachVpnBody{vpnId} + attach := func(s *sling.Sling) *sling.Sling { + return s.Post(vpnsForNetworkInEnvironmentPath(n.Id, envId)).BodyJSON(attachBody) + } + + result := &AttachVpnResult{} + _, err := RunSkytapRequest(client, false, result, attach) + if err != nil { + log.WithFields(log.Fields{"envId": envId, "vpnId": vpnId, "networkId": n.Id, "requestBody": attachBody, "error": err}).Errorf("Unable to attach VPN to environment.") + return result, err + } + return result, nil +} + +/* + Connect to a given VPN in the context of a given environment. +*/ +func (n *Network) ConnectToVpn(client SkytapClient, envId string, vpnId string) error { + return n.ChangeConnectionToVpn(client, envId, vpnId, true) +} + +/* + Disconnect an environment's network from a VPN. +*/ +func (n *Network) DisconnectFromVpn(client SkytapClient, envId string, vpnId string) error { + return n.ChangeConnectionToVpn(client, envId, vpnId, false) +} + +/* + General method for manipulating VPN connection state. +*/ +func (n *Network) ChangeConnectionToVpn(client SkytapClient, envId string, vpnId string, connected bool) error { + log.WithFields(log.Fields{"netId": n.Id, "vpnId": vpnId, "envId": envId, "connected": connected}).Info("Change network VPN connection") + + connectBody := &ConnectVpnBody{connected} + + connect := func(s *sling.Sling) *sling.Sling { + return s.Put(vpnForNetworkInEnvironmentPath(n.Id, envId, vpnId)).BodyJSON(connectBody) + } + + _, err := RunSkytapRequest(client, false, nil, connect) + if err != nil { + log.WithFields(log.Fields{"envId": envId, "vpnId": vpnId, "networkId": n.Id, "requestBody": connectBody, "error": err}).Errorf("Unable to attach VPN to environment.") + } + return err +} + +/* + Detach a network from a VPN in the context of the given environment. +*/ +func (n *Network) DetachFromVpn(client SkytapClient, envId string, vpnId string) error { + log.WithFields(log.Fields{"netId": n.Id, "vpnId": vpnId, "envId": envId}).Info("Detach network from VPN") + + detach := func(s *sling.Sling) *sling.Sling { + return s.Delete(vpnForNetworkInEnvironmentPath(n.Id, envId, vpnId)) + } + + _, err := RunSkytapRequest(client, false, nil, detach) + if err != nil { + log.WithFields(log.Fields{"envId": envId, "vpnId": vpnId, "networkId": n.Id, "error": err}).Errorf("Unable to detach VPN from environment.") + } + return err +} + +func vpnIdPath(vpnId string) string { return VpnPath + "/" + vpnId + ".json" } + +/* + Return an existing VPN by id. +*/ +func GetVpn(client SkytapClient, vpnId string) (*Vpn, error) { + vpn := &Vpn{} + + getVpn := func(s *sling.Sling) *sling.Sling { + return s.Get(vpnIdPath(vpnId)) + } + + _, err := RunSkytapRequest(client, true, vpn, getVpn) + return vpn, err +} + +func (nic *NetworkInterface) AddPublishedService(client SkytapClient, port int, envId, vmId string) (*NetworkInterface, error) { + + log.WithFields(log.Fields{"envId": envId, "vmId": vmId, "interfaceId": nic.Id}).Infof("Adding service") + + service := PublishedService{InternalPort: port} + + addReq := func(s *sling.Sling) *sling.Sling { + path := fmt.Sprintf("%s/%s/%s/%s/%s/%s/%s.json", EnvironmentPath, envId, VmPath, vmId, InterfacePath, nic.Id, PublishedServicePath) + return s.Post(path).BodyJSON(service) + } + + _, err := RunSkytapRequest(client, true, service, addReq) + if err != nil { + return nic, err + } + + nic.PublishedServices = append(nic.PublishedServices, service) + + log.WithField("publishedService", service).Info("Service Added") + + return nic, err +} diff --git a/api/network_test.go b/api/network_test.go new file mode 100644 index 0000000..3c0ec78 --- /dev/null +++ b/api/network_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateAutomaticNetwork(t *testing.T) { + netJson := readJson(t, "testdata/network-1.json") + + client := skytapClient(t) + server := getMockServerForString(client, netJson) + defer server.Close() + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/configurations/1/networks.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"name":"Default Network","network_type":"automatic","subnet":"10.0.0.0/24","domain":"skytap.example"}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, netJson) + }) + + net, err := CreateAutomaticNetwork(client, "1", "Default Network", "10.0.0.0/24", "skytap.example") + require.NoError(t, err, "Error creating automatic network") + require.Equal(t, "Default Network", net.Name) + require.Equal(t, "10.0.0.0/24", net.Subnet) + require.Equal(t, "skytap.example", net.Domain) +} + +func TestCreateManualNetwork(t *testing.T) { + netJson := readJson(t, "testdata/network-2.json") + + client := skytapClient(t) + server := getMockServerForString(client, netJson) + defer server.Close() + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/configurations/1/networks.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"name":"API Network","network_type":"manual","subnet":"10.0.1.0/24","gateway":"10.0.1.254"}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, netJson) + }) + + net, err := CreateManualNetwork(client, "1", "API Network", "10.0.1.0/24", "10.0.1.254") + require.NoError(t, err, "Error creating automatic network") + require.Equal(t, "API Network", net.Name) + require.Equal(t, "10.0.1.0/24", net.Subnet) + require.Equal(t, "10.0.1.254", net.Gateway) +} + +func TestDeleteNetwork(t *testing.T) { + + client := skytapClient(t) + server := getMockServerForString(client, "") + defer server.Close() + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "DELETE", r.Method) + require.Equal(t, "/configurations/1/networks/99", r.URL.Path) + }) + + err := DeleteNetwork(client, "1", "99") + require.NoError(t, err, "Error deleting network") + +} + +func TestAttachVpn(t *testing.T) { + envJson := readJson(t, "testdata/environment-1.json") + attachVpnJson := readJson(t, "testdata/attach-vpn-1.json") + + client := skytapClient(t) + server := getMockServerForString(client, envJson) + defer server.Close() + + env, err := GetEnvironment(client, "1") + require.NoError(t, err, "Error getting environment") + vm := env.Vms[0] + network := env.Networks[0] + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/configurations/1/networks/99/vpns.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"vpn_id":"vpn-1"}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, attachVpnJson) + }) + + result, err := network.AttachToVpn(client, env.Id, "vpn-1") + require.NoError(t, err, "Error attaching VPN") + require.Equal(t, false, result.Connected) + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "PUT", r.Method) + require.Equal(t, "/configurations/1/networks/99/vpns/vpn-1", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"connected":true}`, strings.TrimSpace(string(body))) + + tmpstr := strings.Replace(attachVpnJson, `"connected": false`, `"connected": true`, 1) + fmt.Fprintln(w, tmpstr) + }) + + err = network.ConnectToVpn(client, env.Id, "vpn-1") + require.NoError(t, err, "Error connecting VPN") + + // Verify parsing of NatAddresses + require.Equal(t, "vpn-1", vm.Interfaces[0].NatAddresses.VpnNatAddresses[0].VpnId, "Should have correct VPN id") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "PUT", r.Method) + require.Equal(t, "/configurations/1/networks/99/vpns/vpn-1", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"connected":false}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, attachVpnJson) + }) + + err = network.DisconnectFromVpn(client, env.Id, "vpn-1") + require.NoError(t, err, "Error disconnecting VPN") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "DELETE", r.Method) + require.Equal(t, "/configurations/1/networks/99/vpns/vpn-1", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, "", strings.TrimSpace(string(body))) + fmt.Fprintln(w, attachVpnJson) + }) + + err = network.DetachFromVpn(client, env.Id, "vpn-1") + require.NoError(t, err, "Error detaching VPN") +} + +func TestAddPublishedService(t *testing.T) { + netJson := readJson(t, "testdata/network-1.json") + + client := skytapClient(t) + server := getMockServerForString(client, netJson) + defer server.Close() + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }) + +} diff --git a/api/requests.go b/api/requests.go new file mode 100644 index 0000000..2e1c291 --- /dev/null +++ b/api/requests.go @@ -0,0 +1,278 @@ +// Copyright 2016 Skytap Inc. +// +// 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 api + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "encoding/json" + + log "github.com/Sirupsen/logrus" + "github.com/dghubble/sling" +) + +const ( + AcceptHeaderV2 = "application/vnd.skytap.api.v2+json" + AcceptHeaderV1 = "application/json" + BaseUriV1 = "https://cloud.skytap.com" + BaseUriV2 = "https://cloud.skytap.com/v2" + MetadataUri = "http://gw/skytap" + UserAgent = "skytap-sdk-go" + + maxRetries = 6 +) + +/* + Added for testability. +*/ +var baseUrlOveride = "" + +/* + General skytap json error response. +*/ +type SkytapApiError struct { + Error string `json:error` +} + +/* + Skytap metadata service response. +*/ +type SkytapMetadata struct { + Id string `json:"id"` +} + +/* + Credentials for accessing skytap REST API. +*/ +type SkytapCredentials struct { + Username string + ApiKey string +} + +/* + Skytap client object, needed for all REST calls. +*/ +type SkytapClient struct { + HttpClient *http.Client + Credentials SkytapCredentials +} + +/* + Create a new client from username and key. +*/ +func NewSkytapClient(username string, apiKey string) *SkytapClient { + return NewSkytapClientFromCredentials(SkytapCredentials{username, apiKey}) +} + +/* + Create a new client from credentials +*/ +func NewSkytapClientFromCredentials(credentials SkytapCredentials) *SkytapClient { + return &SkytapClient{&http.Client{}, credentials} +} + +/* + Request methods use this to create/customize the requests. +*/ +type SlingDecorator func(*sling.Sling) *sling.Sling + +/* + Some skytap resources have a runstate in response, use this for monitoring. +*/ +type RunstateBody struct { + Runstate string `json:"runstate"` +} + +/* + A runstate aware resource has a runstate in its representation, which can be used when waiting for a specific state. +*/ +type RunstateAwareResource interface { + // + RunstateStr() string + // Should fetch a fresh representation of the resource and return the current runstate, or error + Refresh(client SkytapClient) (RunstateAwareResource, error) +} + +/* + Wait until the given resource is in one of the desired states. + + If the resource reaches the desired state, a recently fetched representation is returned. Otherwise an error is returned, along + with the result of the last attempt. + + If requireStateChange is set, a transition must occur. The function will wait until the state changes or timeout. +*/ +func WaitUntilInState(client SkytapClient, desiredStates []string, r RunstateAwareResource, requireStateChange bool) (RunstateAwareResource, error) { + log.WithFields(log.Fields{"desiredStates": desiredStates, "resource": r}).Info("Waiting until resource is in desired state") + start := time.Now() + + current, err := r.Refresh(client) + if err != nil { + return current, err + } + + hasChanged := !requireStateChange || current.RunstateStr() != r.RunstateStr() + + maxBusyWaitPeriods := 20 + waitPeriod := 10 * time.Second + for i := 0; i < maxBusyWaitPeriods && !(hasChanged && stringInSlice(current.RunstateStr(), desiredStates)); i++ { + time.Sleep(waitPeriod) + current, err = r.Refresh(client) + if err != nil { + return current, err + } + hasChanged = hasChanged || current.RunstateStr() != r.RunstateStr() + } + if !stringInSlice(current.RunstateStr(), desiredStates) { + return current, errors.New(fmt.Sprintf("Didn't achieve any desired runstate in %s after %d seconds, resource is in runstate %s", desiredStates, time.Now().Unix()-start.Unix(), current.RunstateStr())) + } + return current, err +} + +/* + Runs an initial skytap API request attempt, with retries. + + Returns the resulting response, or error. If no error occurs, the response json will be present in respJson. + + useV2 - If true the request should use V2 API path. + respJson - Interface to fill with response JSON. + slingDecorator - Decorate request with specifics, set request path relative to root, add body, etc. +*/ +func RunSkytapRequest(client SkytapClient, useV2 bool, respJson interface{}, slingDecorator SlingDecorator) (*http.Response, error) { + return runSkytapRequestWithRetry(client, useV2, respJson, slingDecorator, 0) +} + +/* + Return a skytap resource specified as complete GET based URL. +*/ +func GetSkytapResource(client SkytapClient, url string, respObj interface{}) (*http.Response, error) { + fromUrl := func(s *sling.Sling) *sling.Sling { + return s.New().Base(url) + } + return RunSkytapRequest(client, false, respObj, fromUrl) +} + +/* + Runs a skytap API request attempt, retry number as specified by retryNum. + +*/ +func runSkytapRequestWithRetry(client SkytapClient, useV2 bool, respObj interface{}, slingDecorator SlingDecorator, retryNum int) (*http.Response, error) { + baseUrl := BaseUriV1 + if baseUrlOveride != "" { + baseUrl = baseUrlOveride + } else if useV2 { + baseUrl = BaseUriV2 + } + + base := sling.New().Base(baseUrl + "/").Client(client.HttpClient) + s := slingDecorator(base) + skytapError := &SkytapApiError{} + req, err := s.Request() + req.SetBasicAuth(client.Credentials.Username, client.Credentials.ApiKey) + acceptHeader := AcceptHeaderV1 + if useV2 { + acceptHeader = AcceptHeaderV2 + } + req.Header.Set("Accept", acceptHeader) + req.Header.Set("User-Agent", UserAgent) + var resp *http.Response + resp, err = s.Do(req, respObj, skytapError) + + returnError := err + logRequestResponse(req, resp, respObj, returnError) + + if !isOkStatus(resp.StatusCode) { + if isBusy(resp.StatusCode) { + retrySecs := 10 + after := resp.Header.Get("Retry-After") + if after != "" { + parsedSecs, parseErr := strconv.ParseInt(after, 10, 32) + if parseErr != nil { + log.Warnf("Couldn't parse Retry-After (%s)", after) + } else { + retrySecs = int(parsedSecs) + } + } else if retryNum <= maxRetries { + log.WithFields(log.Fields{ + "method": req.Method, + "url": req.URL, + "retryNum": retryNum, + "retryAfterSecs": retrySecs, + }).Info("Got resource busy response, retrying") + time.Sleep(time.Duration(retrySecs) * time.Second) + runSkytapRequestWithRetry(client, useV2, respObj, slingDecorator, retryNum+1) + } else { + log.WithFields(log.Fields{"url": req.URL, "maxRetries": maxRetries, "error": err}).Error("Maximum retries reached") + returnError = errors.New(fmt.Sprintf("Maximum retries (%d) reached calling %s(%s), resource is still busy", maxRetries, req.Method, req.URL)) + } + } else { + if skytapError.Error != "" { + returnError = errors.New(skytapError.Error) + } else if err == nil { + returnError = fmt.Errorf("Received error status code calling SkyTap API, but no additional error info: %s", resp.Status) + logRequestResponse(req, resp, respObj, returnError) + } + } + } + return resp, returnError +} + +func logRequestResponse(req *http.Request, resp *http.Response, respObj interface{}, err error) { + + jsonStr, err := json.Marshal(respObj) + if err != nil { + log.Errorf("Couldn't marshall response: %s", err) + } + + entry := log.WithFields(log.Fields{ + "method": req.Method, + "url": req.URL, + "status": resp.Status, + "marshallError": err, + "responseObject": string(jsonStr), + }) + + if err != nil { + entry.Error("Request caused error") + } else { + entry.Debug("Made request") + } +} + +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +func IsRunningInSkytap() bool { + skytapError := &SkytapApiError{} + response := &SkytapMetadata{} + + client := sling.New().Client(nil) + req, err := sling.New().Get(MetadataUri).Request() + resp, err := client.Do(req, response, skytapError) + if err != nil { + log.Errorf("Failure calling Metadata Service (resp, err), %s, %s", resp, err) + return false + } + return true +} diff --git a/api/requests_test.go b/api/requests_test.go new file mode 100644 index 0000000..778f64e --- /dev/null +++ b/api/requests_test.go @@ -0,0 +1 @@ +package api diff --git a/api/templates.go b/api/templates.go new file mode 100644 index 0000000..1546b57 --- /dev/null +++ b/api/templates.go @@ -0,0 +1,29 @@ +// Copyright 2016 Skytap Inc. +// +// 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 api + +const ( + TemplatePath = "templates" +) + +/* + Skytap template resource. +*/ +type Template struct { + Id string `json:"id"` + Url string `json:"url"` + Name string `json:"name"` + Region string `json:"region"` +} diff --git a/api/testdata/attach-vpn-1.json b/api/testdata/attach-vpn-1.json new file mode 100644 index 0000000..16fa9bc --- /dev/null +++ b/api/testdata/attach-vpn-1.json @@ -0,0 +1,26 @@ +{ + "id": "99-vpn-2312308", + "connected": false, + "network": { + "id": "99", + "subnet": "10.0.0.0/24", + "network_name": "Default Network", + "configuration_id": "1", + "configuration_name": "Environment 1", + "configuration_type": "configuration", + "configuration_url": "https://cloud.skytap.com/configurations/1", + "owner_name": "admin", + "owner_id": "1", + "viewable": true + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } +} diff --git a/api/testdata/config.json.sample b/api/testdata/config.json.sample new file mode 100644 index 0000000..9dc37ad --- /dev/null +++ b/api/testdata/config.json.sample @@ -0,0 +1,7 @@ +{ + "username": "SKYTAP_USERNAME", + "apiKey": "SKYTAP_API_KEY", + "templateId": "TEMPLATE_ID", + "vmId": "VM_ID", + "vpnId": "VPN_ID" +} \ No newline at end of file diff --git a/api/testdata/credentials.json b/api/testdata/credentials.json new file mode 100644 index 0000000..eb21b6e --- /dev/null +++ b/api/testdata/credentials.json @@ -0,0 +1,10 @@ +[ + { + "id": "32257066", + "text": "root / ChangeMe!" + }, + { + "id": "32257068", + "text": "skytap / ChangeMe!" + } +] diff --git a/api/testdata/environment-1.json b/api/testdata/environment-1.json new file mode 100644 index 0000000..72bd85c --- /dev/null +++ b/api/testdata/environment-1.json @@ -0,0 +1,301 @@ +{ + "id": "1", + "url": "https://cloud.skytap.com/configurations/1", + "name": "Environment 1", + "error": "", + "runstate": "stopped", + "rate_limited": false, + "description": "Mock data", + "suspend_on_idle": null, + "suspend_at_time": null, + "routable": false, + "vms": [ + { + "id": "1001", + "name": "Ubuntu VM", + "runstate": "stopped", + "rate_limited": false, + "hardware": { + "cpus": 1, + "supports_multicore": true, + "cpus_per_socket": 1, + "ram": 1024, + "svms": 1, + "guestOS": "centos-64", + "max_cpus": 12, + "min_ram": 256, + "max_ram": 262144, + "vnc_keymap": null, + "uuid": null, + "disks": [ + { + "id": "disk-5971736-13548234-scsi-0-0", + "size": 20480, + "type": "SCSI", + "controller": "0", + "lun": "0" + } + ], + "storage": 20480, + "upgradable": true, + "instance_type": null, + "time_sync_enabled": true, + "rtc_start_time": null, + "copy_paste_enabled": true, + "nested_virtualization": false, + "architecture": "x86" + }, + "error": false, + "asset_id": null, + "hardware_version": 10, + "max_hardware_version": 11, + "interfaces": [ + { + "id": "nic-5971736-13548234-0", + "ip": "10.0.0.1", + "hostname": "host-1", + "mac": "00:50:56:2D:D5:CA", + "services_count": 0, + "services": [], + "public_ips_count": 0, + "public_ips": [], + "vm_id": "1001", + "vm_name": "vagrant host vm", + "status": "Powered off", + "nat_addresses": { + "network_nat_addresses": [ + { + "ip_address": "10.0.4.1", + "network_id": "98", + "network_name": "Default Network", + "network_url": "https://cloud.skytap.com/configurations/1/networks/98", + "configuration_id": "1", + "configuration_name": "Environment 2", + "configuration_url": "https://cloud.skytap.com/configurations/98", + "viewable": true + } + ], + "vpn_nat_addresses": [ + { + "ip_address": "10.1.150.24", + "vpn_id": "vpn-1", + "vpn_name": "VPN 1", + "vpn_url": "https://cloud.skytap.com/vpns/vpn-1" + } + ] + }, + "network_id": "99", + "network_name": "Default Network", + "network_url": "https://cloud.skytap.com/configurations/1/networks/99", + "network_type": "automatic", + "network_subnet": "10.0.0.0/24", + "nic_type": "vmxnet3", + "promiscuous": false, + "secondary_ips": [], + "public_ip_attachments": [] + } + ], + "notes": [], + "labels": [], + "credentials": [ + { + "id": "11801130", + "text": "root / ChangeMe!" + }, + { + "id": "11801132", + "text": "skytap / ChangeMe!" + } + ], + "desktop_resizable": true, + "local_mouse_cursor": true, + "maintenance_lock_engaged": false, + "region_backend": "skytap", + "created_at": "2016/12/13 11:29:56 -0800", + "supports_suspend": true, + "can_change_object_state": true, + "containers": null, + "display_server": "none", + "display_server_port": 0, + "audio_in": false, + "audio_out": false, + "smartclient_connection": { + "display_protocol": null + }, + "configuration_url": "https://cloud.skytap.com/configurations/1", + "publish_set_refs": [ + "https://cloud.skytap.com/configurations/1/publish_sets/2492910" + ] + } + ], + "networks": [ + { + "id": "99", + "url": "https://cloud.skytap.com/configurations/1/networks/99", + "name": "Default Network", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "nat_subnet": "10.0.4.0/22", + "nat_pool_size": 1022, + "nat_pool_remaining": 1021, + "domain": "skytap.example", + "vpn_attachments": [ + { + "id": "99-vpn-1", + "connected": true, + "network": { + "id": "99", + "subnet": "10.0.0.0/24", + "network_name": "Default Network", + "configuration_id": "1" + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24, 10.1.2.0/24, 10.1.4.0/24, 10.1.6.0/24, 10.1.8.0/24, 10.1.14.0/23, 10.1.16.0/24, 172.16.89.0/24, 192.168.0.0/16", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } + } + ], + "tunnelable": true, + "tunnels": [ + { + "id": "tunnel-6631420-11110493", + "status": "not_busy", + "error": null, + "source_network": { + "id": "99", + "url": "https://cloud.skytap.com/configurations/1/networks/99", + "name": "Default Network", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "nat_subnet": "10.0.4.0/22", + "nat_pool_size": 1022, + "nat_pool_remaining": 1021, + "domain": "skytap.example", + "vpn_attachments": [ + { + "id": "99-vpn-1", + "connected": true, + "network": { + "id": "99", + "subnet": "10.0.0.0/24", + "network_name": "Default Network", + "configuration_id": "1" + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24, 10.1.2.0/24, 10.1.4.0/24, 10.1.6.0/24, 10.1.8.0/24, 10.1.14.0/23, 10.1.16.0/24, 172.16.89.0/24, 192.168.0.0/16", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } + } + ] + }, + "target_network": { + "id": "98", + "url": "https://cloud.skytap.com/configurations/98/networks/98", + "name": "Default Network", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "nat_subnet": "10.0.4.0/22", + "nat_pool_size": 1022, + "nat_pool_remaining": 1021, + "domain": "skytap.example", + "vpn_attachments": [ + { + "id": "98-vpn-1", + "connected": true, + "network": { + "id": "98", + "subnet": "10.0.0.0/24", + "network_name": "Default Network", + "configuration_id": "98" + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24, 10.1.2.0/24, 10.1.4.0/24, 10.1.6.0/24, 10.1.8.0/24, 10.1.14.0/23, 10.1.16.0/24, 172.16.89.0/24, 192.168.0.0/16", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } + } + ] + } + } + ] + } + ], + "lockversion": "ed40f5c2a440bbe2cbb4a97a45edbbe690d4f054", + "use_smart_client": true, + "disable_internet": false, + "region": "US-West", + "region_backend": "skytap", + "owner": "https://cloud.skytap.com/users/15386", + "platform_errors": [], + "publish_sets": [ + { + "id": "2492910", + "name": "etrue CentOS 7.2 Server - 64-bit", + "url": "https://cloud.skytap.com/configurations/1/publish_sets/2492910", + "publish_set_type": "single_url", + "runtime_limit": null, + "runtime_left_in_seconds": null, + "expiration_date": null, + "expiration_date_tz": null, + "start_time": null, + "end_time": null, + "time_zone": null, + "multiple_url": false, + "password": null, + "anonymous_smart_rdp": false, + "desktops_url": "https://cloud.skytap.com/vms/9570931149e0ac0d3de7d7d6a0af3a11/desktops", + "use_smart_client": true, + "vms": [ + { + "id": "6751008", + "name": "vagrant host vm", + "access": "run_and_use", + "run_and_use": true, + "vm_ref": "https://cloud.skytap.com/vms/1001", + "error": false, + "desktop_url": "https://cloud.skytap.com/vms/9570931149e0ac0d3de7d7d6a0af3a11/desktops" + } + ], + "custom_content_enabled": false + } + ], + "shutdown_on_idle": null, + "shutdown_at_time": null, + "containers_count": 0, + "container_hosts_count": 0 +} diff --git a/api/testdata/network-1.json b/api/testdata/network-1.json new file mode 100644 index 0000000..0bd3a09 --- /dev/null +++ b/api/testdata/network-1.json @@ -0,0 +1,125 @@ +{ + "id": "99", + "url": "https://cloud.skytap.com/configurations/1/networks/99", + "name": "Default Network", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "nat_subnet": "10.0.4.0/22", + "nat_pool_size": 1022, + "nat_pool_remaining": 1021, + "domain": "skytap.example", + "vpn_attachments": [ + { + "id": "99-vpn-1", + "connected": true, + "network": { + "id": "99", + "subnet": "10.0.0.0/24", + "network_name": "Default Network", + "configuration_id": "1" + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24, 10.1.2.0/24, 10.1.4.0/24, 10.1.6.0/24, 10.1.8.0/24, 10.1.14.0/23, 10.1.16.0/24, 172.16.89.0/24, 192.168.0.0/16", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } + } + ], + "tunnelable": true, + "tunnels": [ + { + "id": "tunnel-6631420-11110493", + "status": "not_busy", + "error": null, + "source_network": { + "id": "99", + "url": "https://cloud.skytap.com/configurations/1/networks/99", + "name": "Default Network", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "nat_subnet": "10.0.4.0/22", + "nat_pool_size": 1022, + "nat_pool_remaining": 1021, + "domain": "skytap.example", + "vpn_attachments": [ + { + "id": "99-vpn-1", + "connected": true, + "network": { + "id": "99", + "subnet": "10.0.0.0/24", + "network_name": "Default Network", + "configuration_id": "1" + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24, 10.1.2.0/24, 10.1.4.0/24, 10.1.6.0/24, 10.1.8.0/24, 10.1.14.0/23, 10.1.16.0/24, 172.16.89.0/24, 192.168.0.0/16", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } + } + ] + }, + "target_network": { + "id": "98", + "url": "https://cloud.skytap.com/configurations/98/networks/98", + "name": "Default Network", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "nat_subnet": "10.0.4.0/22", + "nat_pool_size": 1022, + "nat_pool_remaining": 1021, + "domain": "skytap.example", + "vpn_attachments": [ + { + "id": "98-vpn-1", + "connected": true, + "network": { + "id": "98", + "subnet": "10.0.0.0/24", + "network_name": "Default Network", + "configuration_id": "98" + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24, 10.1.2.0/24, 10.1.4.0/24, 10.1.6.0/24, 10.1.8.0/24, 10.1.14.0/23, 10.1.16.0/24, 172.16.89.0/24, 192.168.0.0/16", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } + } + ] + } + } + ] + } \ No newline at end of file diff --git a/api/testdata/network-2.json b/api/testdata/network-2.json new file mode 100644 index 0000000..8c4b8eb --- /dev/null +++ b/api/testdata/network-2.json @@ -0,0 +1,16 @@ +{ + "id": "2881190", + "url": "https://cloud.skytap.com/configurations/4745364/networks/2881190", + "name": "API Network", + "network_type": "manual", + "subnet": "10.0.1.0/24", + "subnet_addr": "10.0.1.0", + "subnet_size": 24, + "gateway": "10.0.1.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "vpn_attachments": [], + "tunnelable": false, + "tunnels": [] +} \ No newline at end of file diff --git a/api/testdata/template-2.json b/api/testdata/template-2.json new file mode 100644 index 0000000..d31e8bc --- /dev/null +++ b/api/testdata/template-2.json @@ -0,0 +1,202 @@ +{ + "id": "2", + "url": "https://cloud.skytap.com/templates/2", + "name": "Template with 2 VMs", + "region": "US-West", + "region_backend": "skytap", + "containers_count": 0, + "container_hosts_count": 0, + "busy": null, + "lockversion": "5b12f669acf04d6761f00906e6d17576e6399148", + "public": true, + "description": "my template", + "vms": [ + { + "id": "1002", + "name": "Ruby on Rails - Ubuntu Server 14.04 - 64-bit", + "runstate": "stopped", + "rate_limited": false, + "hardware": { + "cpus": 1, + "supports_multicore": true, + "cpus_per_socket": 1, + "ram": 1024, + "svms": 1, + "guestOS": "ubuntu-64", + "max_cpus": 12, + "min_ram": 256, + "max_ram": 262144, + "vnc_keymap": null, + "uuid": null, + "disks": [ + { + "id": "disk-2704096-6305480-scsi-0-0", + "size": 20480, + "type": "SCSI", + "controller": "0", + "lun": "0" + } + ], + "storage": 20480, + "upgradable": true, + "instance_type": null, + "time_sync_enabled": true, + "rtc_start_time": null, + "copy_paste_enabled": true, + "nested_virtualization": false, + "architecture": "x86" + }, + "error": false, + "asset_id": null, + "hardware_version": 10, + "max_hardware_version": 11, + "interfaces": [ + { + "id": "nic-2704096-6305480-0", + "ip": "10.0.0.1", + "hostname": "host-1", + "mac": "00:50:56:14:34:E4", + "services_count": 0, + "services": [], + "public_ips_count": 0, + "public_ips": [], + "vm_id": "1002", + "vm_name": "Ruby on Rails - Ubuntu Server 14.04 - 64-bit", + "status": "Powered off", + "network_id": "2873082", + "network_name": "Default Network", + "network_url": "https://cloud.skytap.com/templates/2/networks/2873082", + "network_type": "automatic", + "network_subnet": "10.0.0.0/24", + "nic_type": "vmxnet3", + "secondary_ips": [] + } + ], + "notes": [], + "labels": [], + "credentials": [ + { + "id": "4995264", + "text": "root / ChangeMe!" + }, + { + "id": "4995266", + "text": "skytap / ChangeMe!" + } + ], + "desktop_resizable": true, + "local_mouse_cursor": true, + "maintenance_lock_engaged": false, + "region_backend": "skytap", + "created_at": "2015/07/23 07:50:08 -0700", + "supports_suspend": true, + "can_change_object_state": true, + "containers": null, + "template_url": "https://cloud.skytap.com/templates/2" + }, + { + "id": "1003", + "name": "Workstation - Ubuntu Desktop 14.04 - 64-bit", + "runstate": "stopped", + "rate_limited": false, + "hardware": { + "cpus": 1, + "supports_multicore": true, + "cpus_per_socket": 1, + "ram": 1024, + "svms": 1, + "guestOS": "ubuntu-64", + "max_cpus": 12, + "min_ram": 256, + "max_ram": 262144, + "vnc_keymap": null, + "uuid": null, + "disks": [ + { + "id": "disk-2704096-6305482-scsi-0-0", + "size": 20480, + "type": "SCSI", + "controller": "0", + "lun": "0" + } + ], + "storage": 20480, + "upgradable": true, + "instance_type": null, + "time_sync_enabled": true, + "rtc_start_time": null, + "copy_paste_enabled": true, + "nested_virtualization": false, + "architecture": "x86" + }, + "error": false, + "asset_id": null, + "hardware_version": 10, + "max_hardware_version": 11, + "interfaces": [ + { + "id": "nic-2704096-6305482-0", + "ip": "10.0.0.2", + "hostname": "host-2", + "mac": "00:50:56:0C:FA:DF", + "services_count": 0, + "services": [], + "public_ips_count": 0, + "public_ips": [], + "vm_id": "1003", + "vm_name": "Workstation - Ubuntu Desktop 14.04 - 64-bit", + "status": "Powered off", + "network_id": "2873082", + "network_name": "Default Network", + "network_url": "https://cloud.skytap.com/templates/2/networks/2873082", + "network_type": "automatic", + "network_subnet": "10.0.0.0/24", + "nic_type": "vmxnet3", + "secondary_ips": [] + } + ], + "notes": [], + "labels": [], + "credentials": [ + { + "id": "4995270", + "text": "root / ChangeMe!" + }, + { + "id": "4995272", + "text": "skytap / ChangeMe!" + } + ], + "desktop_resizable": true, + "local_mouse_cursor": true, + "maintenance_lock_engaged": false, + "region_backend": "skytap", + "created_at": "2015/07/23 07:50:08 -0700", + "supports_suspend": true, + "can_change_object_state": true, + "containers": null, + "template_url": "https://cloud.skytap.com/templates/2" + } + ], + "networks": [ + { + "id": "2873082", + "url": "https://cloud.skytap.com/configurations/2/networks/2873082", + "name": "Default Network", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "domain": "skytap.example", + "vpn_attachments": [], + "tunnelable": false, + "tunnels": [] + } + ], + "publish_sets": [], + "tag_list": "" +} diff --git a/api/testdata/vm-1001.json b/api/testdata/vm-1001.json new file mode 100644 index 0000000..44766db --- /dev/null +++ b/api/testdata/vm-1001.json @@ -0,0 +1,117 @@ +{ + "id": "1001", + "name": "Ubuntu VM", + "runstate": "stopped", + "rate_limited": false, + "hardware": { + "cpus": 1, + "supports_multicore": true, + "cpus_per_socket": 1, + "ram": 1024, + "svms": 1, + "guestOS": "centos-64", + "max_cpus": 12, + "min_ram": 256, + "max_ram": 262144, + "vnc_keymap": null, + "uuid": null, + "disks": [ + { + "id": "disk-5971736-13548234-scsi-0-0", + "size": 20480, + "type": "SCSI", + "controller": "0", + "lun": "0" + } + ], + "storage": 20480, + "upgradable": true, + "instance_type": null, + "time_sync_enabled": true, + "rtc_start_time": null, + "copy_paste_enabled": true, + "nested_virtualization": false, + "architecture": "x86" + }, + "error": false, + "asset_id": null, + "hardware_version": 10, + "max_hardware_version": 11, + "interfaces": [ + { + "id": "nic-5971736-13548234-0", + "ip": "10.0.0.1", + "hostname": "host-1", + "mac": "00:50:56:2D:D5:CA", + "services_count": 0, + "services": [], + "public_ips_count": 0, + "public_ips": [], + "vm_id": "1001", + "vm_name": "vagrant host vm", + "status": "Powered off", + "nat_addresses": { + "network_nat_addresses": [ + { + "ip_address": "10.0.4.1", + "network_id": "98", + "network_name": "Default Network", + "network_url": "https://cloud.skytap.com/configurations/1/networks/98", + "configuration_id": "1", + "configuration_name": "Environment 2", + "configuration_url": "https://cloud.skytap.com/configurations/98", + "viewable": true + } + ], + "vpn_nat_addresses": [ + { + "ip_address": "10.1.150.24", + "vpn_id": "vpn-1", + "vpn_name": "VPN 1", + "vpn_url": "https://cloud.skytap.com/vpns/vpn-1" + } + ] + }, + "network_id": "99", + "network_name": "Default Network", + "network_url": "https://cloud.skytap.com/configurations/1/networks/99", + "network_type": "automatic", + "network_subnet": "10.0.0.0/24", + "nic_type": "vmxnet3", + "promiscuous": false, + "secondary_ips": [], + "public_ip_attachments": [] + } + ], + "notes": [], + "labels": [], + "credentials": [ + { + "id": "11801130", + "text": "root / ChangeMe!" + }, + { + "id": "11801132", + "text": "skytap / ChangeMe!" + } + ], + "desktop_resizable": true, + "local_mouse_cursor": true, + "maintenance_lock_engaged": false, + "region_backend": "skytap", + "created_at": "2016/12/13 11:29:56 -0800", + "supports_suspend": true, + "can_change_object_state": true, + "containers": null, + "display_server": "none", + "display_server_port": 0, + "audio_in": false, + "audio_out": false, + "smartclient_connection": { + "display_protocol": null + }, + "configuration_url": "https://cloud.skytap.com/configurations/1", + "publish_set_refs": [ + "https://cloud.skytap.com/configurations/1/publish_sets/2492910" + ] +} diff --git a/api/testdata/vm-1002.json b/api/testdata/vm-1002.json new file mode 100644 index 0000000..4199b5a --- /dev/null +++ b/api/testdata/vm-1002.json @@ -0,0 +1,104 @@ +{ + "id": "1002", + "name": "Template VM", + "runstate": "stopped", + "rate_limited": false, + "hardware": { + "cpus": 1, + "supports_multicore": true, + "cpus_per_socket": 1, + "ram": 2048, + "svms": 2, + "guestOS": "ubuntu", + "max_cpus": 12, + "min_ram": 256, + "max_ram": 262144, + "vnc_keymap": null, + "uuid": null, + "disks": [ + { + "id": "disk-1812704-4257268-scsi-0-0", + "size": 8192, + "type": "SCSI", + "controller": "0", + "lun": "0" + } + ], + "storage": 8192, + "upgradable": true, + "instance_type": null, + "time_sync_enabled": true, + "rtc_start_time": null, + "copy_paste_enabled": true, + "nested_virtualization": false, + "architecture": "x86" + }, + "error": false, + "asset_id": null, + "hardware_version": 10, + "max_hardware_version": 11, + "interfaces": [ + { + "id": "nic-1812704-4257268-0", + "ip": "10.0.0.2", + "hostname": "skytap-desktop", + "mac": "00:50:56:22:23:38", + "services_count": 0, + "services": [], + "public_ips_count": 0, + "public_ips": [], + "vm_id": "1002", + "vm_name": "Template VM", + "status": "Powered off", + "network_id": "1890796", + "network_name": "Default Network", + "network_url": "https://cloud.skytap.com/templates/2/networks/1890796", + "network_type": "automatic", + "network_subnet": "10.0.0.0/24", + "nic_type": "default", + "secondary_ips": [] + } + ], + "notes": [], + "labels": [ + { + "type": "Language", + "text": "english", + "id": "396" + }, + { + "type": "Vendor", + "text": "ubuntu", + "id": "558" + }, + { + "type": "Disk size", + "text": "8 GB", + "id": "2290" + }, + { + "type": "Version or patch level", + "text": "10.04.1 LTS Desktop", + "id": "2566" + } + ], + "credentials": [ + { + "id": "3279638", + "text": "skytap / ChangeMe!" + }, + { + "id": "3279640", + "text": "root / ChangeMe!" + } + ], + "desktop_resizable": true, + "local_mouse_cursor": true, + "maintenance_lock_engaged": false, + "region_backend": "skytap", + "created_at": "2014/12/19 07:57:08 -0800", + "supports_suspend": true, + "can_change_object_state": true, + "containers": null, + "template_url": "https://cloud.skytap.com/templates/2" +} diff --git a/api/testdata/vpn-1.json b/api/testdata/vpn-1.json new file mode 100644 index 0000000..9f7a1bc --- /dev/null +++ b/api/testdata/vpn-1.json @@ -0,0 +1,134 @@ +{ + "id": "vpn-1", + "url": "https://cloud.skytap.com/vpns/vpn-1", + "name": "VPN 1", + "connection_type": "vpn", + "status": "active", + "enabled": true, + "remote_subnets": [ + { + "id": "10.1.0.0/24", + "cidr_block": "10.1.0.0/24", + "excluded": false + }, + { + "id": "10.1.1.0/24", + "cidr_block": "10.1.1.0/24", + "excluded": false + }, + { + "id": "10.1.2.0/24", + "cidr_block": "10.1.2.0/24", + "excluded": false + }, + { + "id": "10.1.4.0/24", + "cidr_block": "10.1.4.0/24", + "excluded": false + }, + { + "id": "10.1.6.0/24", + "cidr_block": "10.1.6.0/24", + "excluded": false + }, + { + "id": "10.1.8.0/24", + "cidr_block": "10.1.8.0/24", + "excluded": false + }, + { + "id": "10.1.14.0/23", + "cidr_block": "10.1.14.0/23", + "excluded": false + }, + { + "id": "10.1.16.0/24", + "cidr_block": "10.1.16.0/24", + "excluded": false + }, + { + "id": "172.16.89.0/24", + "cidr_block": "172.16.89.0/24", + "excluded": false + }, + { + "id": "192.168.0.0/16", + "cidr_block": "192.168.0.0/16", + "excluded": false + } + ], + "local_subnet": "10.1.128.0/19", + "nat_local_subnet": true, + "route_based": true, + "default_access_level": "use", + "remote_peer_ip": "1.2.3.4", + "region": "US-West", + "vvr": true, + "nat_pool_size": 8190, + "nat_pool_remaining": 2164, + "error": null, + "local_peer_ip": "199.204.218.76", + "attached_network_count": 706, + "connected_network_count": 682, + "network_attachments": [ + { + "id": "11435328-vpn-1", + "connected": true, + "network": { + "id": "11435328", + "subnet": "10.0.0.0/24", + "network_name": "Management 90 UD", + "configuration_id": "19675320" + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24, 10.1.2.0/24, 10.1.4.0/24, 10.1.6.0/24, 10.1.8.0/24, 10.1.14.0/23, 10.1.16.0/24, 172.16.89.0/24, 192.168.0.0/16", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } + }, + { + "id": "11511570-vpn-1", + "connected": true, + "network": { + "id": "11511570", + "subnet": "10.0.0.0/24", + "network_name": "Default Network", + "configuration_id": "19798884" + }, + "vpn": { + "id": "vpn-1", + "name": "VPN 1", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.1.0.0/24, 10.1.1.0/24, 10.1.2.0/24, 10.1.4.0/24, 10.1.6.0/24, 10.1.8.0/24, 10.1.14.0/23, 10.1.16.0/24, 172.16.89.0/24, 192.168.0.0/16", + "remote_peer_ip": "1.2.3.4", + "can_reconnect": true, + "connection_type": "vpn" + } + } + ], + "test_results": { + "phase1": true, + "phase2": true, + "ping": true, + "connect": true + }, + "phase_1_encryption_algorithm": "aes 256", + "phase_1_hash_algorithm": "sha1", + "phase_1_sa_lifetime": 28800, + "phase_1_dh_group": "modp1536", + "phase_2_encryption_algorithm": "aes_gcm", + "phase_2_authentication_algorithm": null, + "phase_2_perfect_forward_secrecy": true, + "phase_2_pfs_group": "modp1536", + "phase_2_sa_lifetime": 3600, + "sa_policy_level": null, + "maximum_segment_size": null, + "dpd_enabled": true, + "region_backend": "skytap" +} diff --git a/api/vm.go b/api/vm.go new file mode 100644 index 0000000..ee54ca4 --- /dev/null +++ b/api/vm.go @@ -0,0 +1,516 @@ +// Copyright 2016 Skytap Inc. +// +// 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 api + +import ( + "fmt" + "strings" + + log "github.com/Sirupsen/logrus" + "github.com/dghubble/sling" +) + +const ( + VmPath = "vms" +) + +/* + Skytap VM resource. +*/ +type VirtualMachine struct { + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty" url:"name"` + Runstate string `json:"runstate,omitempty"` + Error interface{} `json:"error,omitempty"` + TemplateUrl string `json:"template_url,omitempty"` + EnvironmentUrl string `json:"configuration_url,omitempty"` + Interfaces []*NetworkInterface `json:"interfaces,omitempty"` + Hardware Hardware `json:"hardware,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +type VmCredential struct { + Id string `json:"id"` + Text string `json:"text"` +} + +type NameUpdate struct { + Hostname string `json:"hostname"` +} + +type Hardware struct { + Cpus *int `json:"cpus,omitempty"` + CpusPerSocket *int `json:"cpus_per_socket,omitempty"` + Ram *int `json:"ram,omitempty"` + Disks []Disk `json:"disks,omitempty"` +} + +type Disk struct { + Id string `json:"id"` + Size *int `json:"size"` + Type string `json:"type"` + Controller string `json:"controller"` + Lun string `json:"lun"` +} + +type HardwareUpdate struct { + Hardware Hardware `json:"hardware"` +} + +// Paths for VMs. +func vmIdInEnvironmentPath(envId string, vmId string) string { + return fmt.Sprintf("%s/%s/%s/%s.json", EnvironmentPath, envId, VmPath, vmId) +} +func vmIdInTemplatePath(templateId string, vmId string) string { + return fmt.Sprintf("%s/%s/%s/%s.json", TemplatePath, templateId, VmPath, vmId) +} +func vmIdPath(vmId string) string { return fmt.Sprintf("%s/%s", VmPath, vmId) } +func vmUpdatePath(vmId string) string { return fmt.Sprintf("%s/%s.json", VmPath, vmId) } +func vmCredentialPath(vmId string) string { return fmt.Sprintf("%s/%s/credentials.json", VmPath, vmId) } +func networkInterfacePath(envId string, vmId string, interfaceId string) string { + return fmt.Sprintf("%s/%s/%s/%s/%s/%s.json", EnvironmentPath, envId, VmPath, vmId, InterfacePath, interfaceId) +} + +/* + If VM is in a template, returns the template, otherwise nil. +*/ +func (vm *VirtualMachine) GetTemplate(client SkytapClient) (*Template, error) { + if vm.TemplateUrl == "" { + return nil, nil + } + template := &Template{} + _, err := GetSkytapResource(client, vm.TemplateUrl, template) + return template, err +} + +/* + If a VM is in an environment, returns the environment, otherwise nil. +*/ +func (vm *VirtualMachine) GetEnvironment(client SkytapClient) (*Environment, error) { + if vm.EnvironmentUrl == "" { + return nil, nil + } + env := &Environment{} + _, err := GetSkytapResource(client, vm.EnvironmentUrl, env) + return env, err +} + +/* + Fetch fresh representation. +*/ +func (vm *VirtualMachine) Refresh(client SkytapClient) (RunstateAwareResource, error) { + return GetVirtualMachine(client, vm.Id) +} + +func (vm *VirtualMachine) RunstateStr() string { return vm.Runstate } + +/* + Waits until VM is either stopped or started. +*/ +func (vm *VirtualMachine) WaitUntilReady(client SkytapClient) (*VirtualMachine, error) { + return vm.WaitUntilInState(client, []string{RunStateStop, RunStateStart, RunStatePause}, false) +} + +/* + Wait until the VM is in one of the desired states. +*/ +func (vm *VirtualMachine) WaitUntilInState(client SkytapClient, desiredStates []string, requireStateChange bool) (*VirtualMachine, error) { + r, err := WaitUntilInState(client, desiredStates, vm, requireStateChange) + v := r.(*VirtualMachine) + return v, err +} + +/* + Suspends a VM. +*/ +func (vm *VirtualMachine) Suspend(client SkytapClient) (*VirtualMachine, error) { + log.WithFields(log.Fields{"vmId": vm.Id}).Info("Suspending VM") + + return vm.ChangeRunstate(client, RunStatePause, RunStatePause) +} + +/* + Starts a VM. +*/ +func (vm *VirtualMachine) Start(client SkytapClient) (*VirtualMachine, error) { + log.WithFields(log.Fields{"vmId": vm.Id}).Info("Starting VM") + + return vm.ChangeRunstate(client, RunStateStart, RunStateStart) +} + +/* + Stops a VM. Note that some VMs may require user input and cannot be stopped with the method. +*/ +func (vm *VirtualMachine) Stop(client SkytapClient) (*VirtualMachine, error) { + log.WithFields(log.Fields{"vmId": vm.Id}).Info("Stopping VM") + + /* + Need to check current machine state as transitioning from suspended to stopped is not valid. + */ + checkVm, err := GetVirtualMachine(client, vm.Id) + if err != nil { + return vm, err + } + if checkVm.Runstate == RunStatePause { + return vm, fmt.Errorf("Unable to stop a suspended VM.") + } + + /* + There are cases where the call will succeed but the VM cannot be transitioned + to stopped. Generally this is a case where the VM was started and immediately + stopped. In this case the VMware tools didn't have an opportunity to full load. + The VMware tools are required to send a graceful shutdown to the VM. + */ + newVm, err := vm.ChangeRunstate(client, RunStateStop, RunStateStop, RunStateStart) + if err != nil { + return newVm, err + } + if newVm.Error != false { + return nil, fmt.Errorf("Error stopping VM %s, error: %+v", vm.Id, newVm.Error) + } + return newVm, err + +} + +/* + Kills a VM forcefully. +*/ +func (vm *VirtualMachine) Kill(client SkytapClient) (*VirtualMachine, error) { + log.WithFields(log.Fields{"vmId": vm.Id}).Info("Killing VM") + + return vm.ChangeRunstate(client, RunStateKill, RunStateStop) +} + +/* + Changes the runstate of the VM to the specified state and waits until the VM is in the desired state. +*/ +func (vm *VirtualMachine) ChangeRunstate(client SkytapClient, runstate string, desiredRunstates ...string) (*VirtualMachine, error) { + log.WithFields(log.Fields{"changeState": runstate, "targetState": desiredRunstates, "vmId": vm.Id}).Info("Changing VM runstate") + + ready, err := vm.WaitUntilReady(client) + if err != nil { + return ready, err + } + changeState := func(s *sling.Sling) *sling.Sling { + return s.Put(vmIdPath(vm.Id)).BodyJSON(&RunstateBody{Runstate: runstate}) + } + _, err = RunSkytapRequest(client, false, nil, changeState) + + if err != nil { + return vm, err + } + return vm.WaitUntilInState(client, desiredRunstates, true) +} + +func (vm *VirtualMachine) GetCredentials(client SkytapClient) ([]VmCredential, error) { + credentialReq := func(s *sling.Sling) *sling.Sling { + return s.Get(vmCredentialPath(vm.Id)) + } + + credentials := &[]VmCredential{} + + _, err := RunSkytapRequest(client, false, credentials, credentialReq) + return *credentials, err +} + +/* + Add a Disk of a specified size to VM +*/ +func (vm *VirtualMachine) AddDisk(client SkytapClient, envId string, diskSize int, restartVm bool) (*VirtualMachine, error) { + + if vm.Runstate != RunStateStop { + vm, err := vm.Stop(client) + if err != nil { + return vm, err + } + } + + hw := map[string]interface{}{ + "hardware": map[string]interface{}{ + "disks": map[string]interface{}{ + "new": []int{diskSize}, + }, + }, + } + + hardwareReq := func(s *sling.Sling) *sling.Sling { + return s.Put(vmUpdatePath(vm.Id)).BodyJSON(hw) + } + + log.WithFields(log.Fields{"vmId": vm.Id, "diskSize": diskSize}).Infof("Adding disk") + _, err := RunSkytapRequest(client, false, vm, hardwareReq) + + if err != nil { + return vm, err + } + if restartVm { + vm, err = vm.Start(client) + } + + return vm, err + +} + +/* + Resize Disk with specified ID +*/ +func (vm *VirtualMachine) ResizeDisk(client SkytapClient, envId string, diskId string, diskSize int, restartVm bool) (*VirtualMachine, error) { + if vm.Runstate != RunStateStop { + vm, err := vm.Stop(client) + if err != nil { + return vm, err + } + } + + hw := map[string]interface{}{ + "hardware": map[string]interface{}{ + "disks": map[string]interface{}{ + "existing": map[string]interface{}{ + diskId: map[string]interface{}{ + "id": diskId, + "size": diskSize, + }, + }, + }, + }, + } + + hardwareReq := func(s *sling.Sling) *sling.Sling { + return s.Put(vmUpdatePath(vm.Id)).BodyJSON(hw) + } + + log.WithFields(log.Fields{"vmId": vm.Id, "diskId": diskId, "diskSize": diskSize}).Infof("Resizing disk") + _, err := RunSkytapRequest(client, false, vm, hardwareReq) + + if err != nil { + return vm, err + } + if restartVm { + vm, err = vm.Start(client) + } + + return vm, err + +} + +/* + Add a network interface to VM +*/ +func (vm *VirtualMachine) AddNetworkInterface(client SkytapClient, envId, ip, host, nic_type string, restartVm bool) (*NetworkInterface, error) { + log.WithFields(log.Fields{"envId": envId, "vmId": vm.Id, "nic_type": nic_type, "ip": ip, "hostname": host}).Infof("Adding interface") + if vm.Runstate != RunStateStop { + _, err := vm.Stop(client) + if err != nil { + return nil, err + } + vm.WaitUntilInState(client, []string{RunStateStop}, false) + } + + intr := &NetworkInterface{ + Ip: ip, + Hostname: host, + NicType: nic_type, + } + + addReq := func(s *sling.Sling) *sling.Sling { + path := fmt.Sprintf("%s/%s/%s/%s/%s.json", EnvironmentPath, envId, VmPath, vm.Id, InterfacePath) + return s.Post(path).BodyJSON(intr) + } + + _, err := RunSkytapRequest(client, true, intr, addReq) + log.WithField("err", err).Info("Finished Add Interface Request") + if err != nil { + return nil, err + } + + if restartVm { + _, err = vm.Start(client) + } + + vm.Interfaces = append(vm.Interfaces, intr) + return intr, err +} + +/* + Update network interface on VM +*/ +func (vm *VirtualMachine) UpdateNetworkInterface(client SkytapClient, network_interface *NetworkInterface, envId, interfaceId string) error { + log.WithFields(log.Fields{"envId": envId, "vmId": vm.Id, "interfaceId": interfaceId}).Infof("Updating interface") + + updateReq := func(s *sling.Sling) *sling.Sling { + path := fmt.Sprintf("%s/%s/%s/%s/%s/%s.json", EnvironmentPath, envId, VmPath, vm.Id, InterfacePath, interfaceId) + log.WithField("path", path).Info("") + return s.Put(path).BodyJSON(network_interface) + } + _, err := RunSkytapRequest(client, true, network_interface, updateReq) + log.WithField("err", err).Info("Finished Update Interface Request") + + return err +} + +/* + Remove network interface from VM +*/ +func (vm *VirtualMachine) RemoveNetworkInterface(client SkytapClient, envId, interfaceId string) error { + log.WithFields(log.Fields{"envId": envId, "vmId": vm.Id, "interfaceId": interfaceId}).Infof("Removing interface") + delReq := func(s *sling.Sling) *sling.Sling { + return s.Delete(networkInterfacePath(envId, vm.Id, interfaceId)) + } + + _, err := RunSkytapRequest(client, false, nil, delReq) + + return err +} + +/* + Rename network interface on VM +*/ +func (vm *VirtualMachine) RenameNetworkInterface(client SkytapClient, envId string, interfaceId string, name string) (*NetworkInterface, error) { + nameReq := func(s *sling.Sling) *sling.Sling { + return s.Put(networkInterfacePath(envId, vm.Id, interfaceId)).BodyJSON(&NameUpdate{Hostname: name}) + } + + interfaceResp := &NetworkInterface{} + + log.WithFields(log.Fields{"newName": name, "interfaceId": interfaceId, "envId": envId, "vmId": vm.Id}).Infof("Renaming interface") + _, err := RunSkytapRequest(client, false, interfaceResp, nameReq) + return interfaceResp, err +} + +func (vm *VirtualMachine) UpdateHardware(client SkytapClient, hardware Hardware, restartVm bool) (*VirtualMachine, error) { + if vm.Runstate != RunStateStop { + vm, err := vm.Stop(client) + if err != nil { + return vm, err + } + } + + hardwareReq := func(s *sling.Sling) *sling.Sling { + return s.Put(vmUpdatePath(vm.Id)).BodyJSON(&HardwareUpdate{Hardware: hardware}) + } + + newVm := &VirtualMachine{} + + log.WithFields(log.Fields{"vmId": vm.Id}).Infof("Updating VM hardware: %+v", hardware) + _, err := RunSkytapRequest(client, false, newVm, hardwareReq) + + if err != nil { + return newVm, err + } + if restartVm { + newVm, err = newVm.Start(client) + } + + return newVm, err +} + +func (vm *VirtualMachine) ChangeAttribute(client SkytapClient, queryStruct interface{}) (*VirtualMachine, error) { + changeReq := func(s *sling.Sling) *sling.Sling { + return s.Put(vmUpdatePath(vm.Id)).QueryStruct(queryStruct) + } + + newVm := &VirtualMachine{} + + log.WithFields(log.Fields{"vmId": vm.Id}).Infof("Updating VM attribute: %+v", queryStruct) + _, err := RunSkytapRequest(client, false, newVm, changeReq) + + return newVm, err +} + +type NameQuery struct { + Name string `url:"name"` +} + +func (vm *VirtualMachine) SetName(client SkytapClient, name string) (*VirtualMachine, error) { + return vm.ChangeAttribute(client, &NameQuery{name}) +} + +type ContainerHostQuery struct { + ContainerHost bool `url:"container_host"` +} + +func (vm *VirtualMachine) SetContainerHost(client SkytapClient) (*VirtualMachine, error) { + return vm.ChangeAttribute(client, &ContainerHostQuery{true}) +} + +func (c *VmCredential) Username() (string, error) { + parts := strings.Split(c.Text, "/") + if len(parts) != 2 { + return "", fmt.Errorf("Incorrect parts in credential string '%s'", c.Text) + } + return strings.TrimSpace(parts[0]), nil +} + +func (c *VmCredential) Password() (string, error) { + parts := strings.Split(c.Text, "/") + if len(parts) != 2 { + return "", fmt.Errorf("Incorrect parts in credential string '%s'", c.Text) + } + return strings.TrimSpace(parts[1]), nil +} + +/* + Get a VM from an existing environment. +*/ +// TODO see if we can trap the JSON unmarshall error +func GetVirtualMachineInEnvironment(client SkytapClient, envId string, vmId string) (*VirtualMachine, error) { + vm := &VirtualMachine{} + + getVm := func(s *sling.Sling) *sling.Sling { + return s.Get(vmIdInEnvironmentPath(envId, vmId)) + } + + _, err := RunSkytapRequest(client, true, vm, getVm) + return vm, err +} + +/* + Get a VM from an existing template. +*/ +func GetVirtualMachineInTemplate(client SkytapClient, templateId string, vmId string) (*VirtualMachine, error) { + vm := &VirtualMachine{} + + getVm := func(s *sling.Sling) *sling.Sling { + return s.Get(vmIdInTemplatePath(templateId, vmId)) + } + + _, err := RunSkytapRequest(client, true, vm, getVm) + return vm, err +} + +/* + Get a VM without reference to environment or template. The result object should contain information on its source. +*/ +func GetVirtualMachine(client SkytapClient, vmId string) (*VirtualMachine, error) { + vm := &VirtualMachine{} + + getVm := func(s *sling.Sling) *sling.Sling { + return s.Get(vmIdPath(vmId)) + } + + _, err := RunSkytapRequest(client, false, vm, getVm) + return vm, err +} + +/* + Delete a VM. +*/ +func DeleteVirtualMachine(client SkytapClient, vmId string) error { + log.WithFields(log.Fields{"vmId": vmId}).Info("Deleting VM") + + deleteVm := func(s *sling.Sling) *sling.Sling { return s.Delete(vmIdPath(vmId)) } + _, err := RunSkytapRequest(client, false, nil, deleteVm) + return err +} diff --git a/api/vm_test.go b/api/vm_test.go new file mode 100644 index 0000000..060e2f6 --- /dev/null +++ b/api/vm_test.go @@ -0,0 +1,296 @@ +package api + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeleteVirtualMachine(t *testing.T) { + client := skytapClient(t) + server := getMockServer(client) + defer server.Close() + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "DELETE", r.Method) + require.Equal(t, "/vms/1001", r.URL.Path) + fmt.Fprintln(w, "{}") + }) + + err := DeleteVirtualMachine(client, "1001") + require.NoError(t, err, "Error deleting vm") +} + +func TestVmCredentials(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + credJson := readJson(t, "testdata/credentials.json") + + client := skytapClient(t) + server := getMockServerForString(client, vmJson) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, credJson) + }) + + creds, err := vm.GetCredentials(client) + require.NoError(t, err, "Error getting VM credentials") + + user, err := creds[0].Username() + require.NoError(t, err, "Error username") + require.Equal(t, "root", user) + + pass, err := creds[0].Password() + require.NoError(t, err, "Error getting password") + require.Equal(t, "ChangeMe!", pass) +} + +func TestVmWaitUntilReady(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, strings.Replace(vmJson, "stopped", "busy", 1)) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + require.Equal(t, RunStateBusy, vm.Runstate, "Should be busy") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/vms/1001", r.URL.Path) + fmt.Fprintln(w, vmJson) + }) + + vm, err = vm.WaitUntilReady(client) + require.NoError(t, err, "Error waiting for VM") + require.Equal(t, RunStateStop, vm.Runstate, "Should be stopped") +} + +func TestVmStart(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, vmJson) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + require.Equal(t, RunStateStop, vm.Runstate, "Should be stopped") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/vms/1001", r.URL.Path) + if r.Method == "PUT" { + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"runstate":"running"}`, strings.TrimSpace(string(body))) + } + tmpstr := strings.Replace(vmJson, "stopped", "running", 1) + fmt.Fprintln(w, tmpstr) + }) + + started, err := vm.Start(client) + require.NoError(t, err, "Error starting VM") + require.Equal(t, RunStateStart, started.Runstate, "Should be started") +} + +func TestVmSuspend(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, strings.Replace(vmJson, "stopped", "running", 1)) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + require.Equal(t, RunStateStart, vm.Runstate, "Should be started") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/vms/1001", r.URL.Path) + if r.Method == "PUT" { + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"runstate":"suspended"}`, strings.TrimSpace(string(body))) + } + tmpstr := strings.Replace(vmJson, "stopped", "suspended", 1) + fmt.Fprintln(w, tmpstr) + }) + + suspended, err := vm.Suspend(client) + require.NoError(t, err, "Error suspending VM") + require.Equal(t, RunStatePause, suspended.Runstate, "Should be suspended") +} + +func TestVmKill(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, strings.Replace(vmJson, "stopped", "running", 1)) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + require.Equal(t, RunStateStart, vm.Runstate, "Should be started") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/vms/1001", r.URL.Path) + if r.Method == "PUT" { + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"runstate":"halted"}`, strings.TrimSpace(string(body))) + } + fmt.Fprintln(w, vmJson) + }) + + killed, err := vm.Kill(client) + require.NoError(t, err, "Error stopping VM") + require.Equal(t, RunStateStop, killed.Runstate, "Should be stopped/killed") +} + +func TestChangeNetworkHostname(t *testing.T) { + envJson := readJson(t, "testdata/environment-1.json") + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, envJson) + defer server.Close() + + env, err := GetEnvironment(client, "1") + require.NoError(t, err, "Error getting environment") + vm := env.Vms[0] + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "PUT", r.Method) + require.Equal(t, "/configurations/1/vms/1001/interfaces/nic-5971736-13548234-0.json", r.URL.Path) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"hostname":"newname1234"}`, strings.TrimSpace(string(body))) + fmt.Fprintln(w, vmJson) + }) + + _, err = vm.RenameNetworkInterface(client, env.Id, vm.Interfaces[0].Id, "newname1234") + require.NoError(t, err, "Error renaming interface") +} + +func TestUpdateHardware(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, vmJson) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + + cpus := 4 + persock := 2 + hardware := Hardware{&cpus, &persock, nil, nil} + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/vms/1001.json", r.URL.Path) + require.Equal(t, "PUT", r.Method) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"hardware":{"cpus":4,"cpus_per_socket":2}}`, strings.TrimSpace(string(body))) + + tmpstr := strings.Replace(vmJson, `"cpus": 1`, `"cpus": 4`, 1) + tmpstr = strings.Replace(tmpstr, `"cpus_per_socket": 1`, `"cpus_per_socket": 2`, 1) + fmt.Fprintln(w, tmpstr) + }) + + updated, err := vm.UpdateHardware(client, hardware, false) + require.NoError(t, err, "Error updating hardware") + + require.Equal(t, hardware.Cpus, updated.Hardware.Cpus) + require.Equal(t, hardware.CpusPerSocket, updated.Hardware.CpusPerSocket) + require.Equal(t, vm.Hardware.Ram, updated.Hardware.Ram) + + ram := 4096 + updateRam := Hardware{Ram: &ram} + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/vms/1001.json", r.URL.Path) + require.Equal(t, "PUT", r.Method) + body, _ := ioutil.ReadAll(r.Body) + require.Equal(t, `{"hardware":{"ram":4096}}`, strings.TrimSpace(string(body))) + + tmpstr := strings.Replace(vmJson, `"cpus": 1`, `"cpus": 4`, 1) + tmpstr = strings.Replace(tmpstr, `"cpus_per_socket": 1`, `"cpus_per_socket": 2`, 1) + tmpstr = strings.Replace(tmpstr, `"ram": 1024`, `"ram": 4096`, 1) + fmt.Fprintln(w, tmpstr) + }) + + updated, err = vm.UpdateHardware(client, updateRam, false) + require.NoError(t, err, "Error updating ram") + require.Equal(t, hardware.Cpus, updated.Hardware.Cpus) + require.Equal(t, hardware.CpusPerSocket, updated.Hardware.CpusPerSocket) + require.Equal(t, updateRam.Ram, updated.Hardware.Ram) +} + +func TestChangeName(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, vmJson) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "PUT", r.Method) + require.Equal(t, "/vms/1001.json", r.URL.Path) + require.Equal(t, "name=foo", r.URL.RawQuery) + fmt.Fprintln(w, vmJson) + }) + + _, err = vm.SetName(client, "foo") + require.NoError(t, err, "Error updating name") +} + +func TestAddNetworkInterface(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, vmJson) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/configurations/1/vms/1001/interfaces.json", r.URL.Path) + fmt.Fprintln(w, vmJson) + }) + + nic, err := vm.AddNetworkInterface(client, "1", "10.0.0.1", "host-1", "vxnet3", false) + require.NoError(t, err, "Error adding network interface") + require.Equal(t, "10.0.0.1", nic.Ip) + require.Equal(t, "host-1", nic.Hostname) + require.Equal(t, "vxnet3", nic.NicType) +} +func TestDeleteNetworkInterface(t *testing.T) { + vmJson := readJson(t, "testdata/vm-1001.json") + + client := skytapClient(t) + server := getMockServerForString(client, vmJson) + defer server.Close() + + vm, err := GetVirtualMachine(client, "1001") + require.NoError(t, err, "Error creating vm") + + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "DELETE", r.Method) + require.Equal(t, "/configurations/1/vms/1001/interfaces/nic-5971736-13548234-0.json", r.URL.Path) + }) + + err = vm.RemoveNetworkInterface(client, "1", "nic-5971736-13548234-0") + require.NoError(t, err, "Error removing network interface") + +} + +func TestAddDisk(t *testing.T) { + +} diff --git a/scripts/gofmtcheck.sh b/scripts/gofmtcheck.sh new file mode 100755 index 0000000..408835d --- /dev/null +++ b/scripts/gofmtcheck.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Check gofmt +echo "==> Checking that code complies with gofmt requirements..." +gofmt_files=$(gofmt -l `find . -name '*.go'`) +if [[ -n ${gofmt_files} ]]; then + echo 'gofmt needs running on the following files:' + echo "${gofmt_files}" + echo "You can use the command: \`make fmt\` to reformat code." + exit 1 +fi + +exit 0 diff --git a/skytap/client.go b/skytap/client.go new file mode 100644 index 0000000..004d904 --- /dev/null +++ b/skytap/client.go @@ -0,0 +1,235 @@ +package skytap + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" +) + +const ( + version = "1.0.0" + mediaType = "application/json" + + headerRequestID = "X-Request-ID" +) + +// Client is a client to manage and configure the skytap cloud +type Client struct { + // HTTP client to be used for communicating with the SkyTap SDK + hc *http.Client + + // The base URL to be used when issuing requests + BaseURL *url.URL + + // User agent used when issuing requests + UserAgent string + + // Credentials provider to be used for authenticating with the API + Credentials CredentialsProvider + + // Services used for communicating with the API + Projects ProjectsService + Environments EnvironmentsService +} + +// DefaultListParameters are the default pager settings +var DefaultListParameters = &ListParameters{ + Count: intToPtr(100), + Offset: intToPtr(0), +} + +// ListParameters is a Client scoped common struct for listing +type ListParameters struct { + // For paginated result sets, number of results to retrieve. + Count *int + + // For paginated result sets, the offset of results to include. + Offset *int + + // Filters + Filters []ListFilter +} + +// ListFilter is the struct for list filtering +type ListFilter struct { + Name *string + Value *string +} + +// ErrorResponse is the general purpose struct to hold error data +type ErrorResponse struct { + // HTTP response that caused this error + Response *http.Response + + // RequestID returned from the API. + RequestID *string + + // Error message + Message *string `json:"error,omitempty"` +} + +// Error returns a formatted error +func (r *ErrorResponse) Error() string { + if r.RequestID != nil { + return fmt.Sprintf("%v %v: %d (request %q) %v", + r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, *r.RequestID, *r.Message) + } + + return fmt.Sprintf("%v %v: %d %v", + r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, *r.Message) +} + +// NewClient creates a Skytab cloud client +func NewClient(settings Settings) (*Client, error) { + if err := settings.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate client config: %v", err) + } + + client := Client{ + hc: http.DefaultClient, + } + + baseURL, err := url.Parse(settings.baseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse base url: %v", baseURL) + } + + client.BaseURL = baseURL + client.UserAgent = settings.userAgent + client.Credentials = settings.credentials + + client.Projects = &ProjectsServiceClient{&client} + client.Environments = &EnvironmentsServiceClient{&client} + + return &client, nil +} + +func (c *Client) newRequest(ctx context.Context, method, path string, body interface{}) (*http.Request, error) { + rel, err := url.Parse(path) + if err != nil { + return nil, err + } + + u := c.BaseURL.ResolveReference(rel) + var buf io.ReadWriter + if body != nil { + buf = new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + + if body != nil { + req.Header.Set("Content-Type", mediaType) + } + req.Header.Set("Accept", mediaType) + req.Header.Set("User-Agent", c.UserAgent) + + // Retrieve the authentication/authorization header from the clients credential provider + auth, err := c.Credentials.Retrieve(ctx) + if err != nil { + return nil, err + } + + if auth != "" { + req.Header.Set("Authorization", auth) + } + + return req, nil +} + +func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) (*http.Response, error) { + resp, err := c.hc.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + err = checkResponse(resp) + if err != nil { + return resp, err + } + + if v != nil { + if w, ok := v.(io.Writer); ok { + _, err = io.Copy(w, resp.Body) + if err != nil { + return nil, err + } + } else { + err = json.NewDecoder(resp.Body).Decode(v) + if err != nil { + return nil, err + } + } + } + + return resp, err +} + +func (c *Client) setRequestListParameters(req *http.Request, params *ListParameters) error { + if params == nil { + params = DefaultListParameters + } + + q := req.URL.Query() + + if v := params.Count; v != nil { + q.Add("count", strconv.Itoa(*v)) + } + if v := params.Offset; v != nil { + q.Add("offset", strconv.Itoa(*v)) + } + + if v := params.Filters; v != nil && len(v) > 0 { + var filters []string + for _, f := range v { + if f.Name != nil && f.Value != nil { + filters = append(filters, fmt.Sprintf("%s:%s", *f.Name, *f.Value)) + } + } + + q.Add("query", strings.Join(filters, ",")) + } + + req.URL.RawQuery = q.Encode() + + return nil +} + +// checkResponse checks the API response for errors, and returns them if present. A response is considered an +// error if it has a status code outside the 200 range. API error responses are expected to have either no response +// body, or a JSON response body that maps to ErrorResponse. +func checkResponse(r *http.Response) error { + if c := r.StatusCode; c >= 200 && c <= 299 { + return nil + } + + errorResponse := &ErrorResponse{Response: r} + data, err := ioutil.ReadAll(r.Body) + if err == nil && len(data) > 0 { + err := json.Unmarshal(data, errorResponse) + if err != nil { + errorResponse.Message = strToPtr(string(data)) + } + } + + if requestID := r.Header.Get(headerRequestID); requestID != "" { + errorResponse.RequestID = strToPtr(requestID) + } + + return errorResponse +} diff --git a/skytap/convert.go b/skytap/convert.go new file mode 100644 index 0000000..b61fe1c --- /dev/null +++ b/skytap/convert.go @@ -0,0 +1,29 @@ +package skytap + +// ptrToStr returns a string value for the passed string pointer. +// It returns the empty string if the pointer is nil. +func ptrToStr(s *string) string { + if s != nil { + return *s + } + return "" +} + +// strToPtr returns a pointer to the passed string. +func strToPtr(s string) *string { + return &s +} + +// ptrToInt returns an int value for the passed int pointer. +// It returns 0 if the pointer is nil. +func ptrToInt(i *int) int { + if i != nil { + return *i + } + return 0 +} + +// intToPtr returns a pointer to the passed int. +func intToPtr(i int) *int { + return &i +} diff --git a/skytap/convert_test.go b/skytap/convert_test.go new file mode 100644 index 0000000..ed8e91c --- /dev/null +++ b/skytap/convert_test.go @@ -0,0 +1,37 @@ +package skytap + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestString(t *testing.T) { + v := "" + + assert.Equal(t, v, ptrToStr(&v)) +} + +func TestStringWithNil(t *testing.T) { + assert.Equal(t, "", ptrToStr(nil)) +} + +func TestStringPtr(t *testing.T) { + v := "" + assert.Equal(t, v, *strToPtr(v)) +} + +func TestInt(t *testing.T) { + v := 1 + + assert.Equal(t, v, ptrToInt(&v)) +} + +func TestIntWithNil(t *testing.T) { + assert.Equal(t, 0, ptrToInt(nil)) +} + +func TestIntPtr(t *testing.T) { + v := 2 + assert.Equal(t, v, *intToPtr(v)) +} diff --git a/skytap/credentials.go b/skytap/credentials.go new file mode 100644 index 0000000..9a94de4 --- /dev/null +++ b/skytap/credentials.go @@ -0,0 +1,53 @@ +package skytap + +import ( + "context" + "encoding/base64" + "fmt" +) + +// A CredentialsProvider is the interface for any component which will provide credentials. +// A CredentialsProvider is required to manage its own state +type CredentialsProvider interface { + // Retrieve returns the authorization header value to be used in the request + // Error is returned if the value were not obtainable, or empty. + Retrieve(ctx context.Context) (string, error) +} + +// NoOpCredentials is used when no credentials are required +type NoOpCredentials struct{} + +// Retrieve the credentials +func (c *NoOpCredentials) Retrieve(ctx context.Context) (string, error) { + return "", nil +} + +// NewNoOpCredentials creates a new no op credentials instance +func NewNoOpCredentials() *NoOpCredentials { + return &NoOpCredentials{} +} + +// APITokenCredentials is ued when the credentials used are the username and api token data +type APITokenCredentials struct { + Username string + APIToken string +} + +// Retrieve the username and api token data +func (c *APITokenCredentials) Retrieve(ctx context.Context) (string, error) { + return buildBasicAuth(c.Username, c.APIToken), nil +} + +// NewAPITokenCredentials creates a new username and api token instance +func NewAPITokenCredentials(username, apiToken string) *APITokenCredentials { + return &APITokenCredentials{ + Username: username, + APIToken: apiToken, + } +} + +// Helper functions +func buildBasicAuth(username, secret string) string { + auth := username + ":" + secret + return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(auth))) +} diff --git a/skytap/credentials_test.go b/skytap/credentials_test.go new file mode 100644 index 0000000..b42203c --- /dev/null +++ b/skytap/credentials_test.go @@ -0,0 +1,33 @@ +package skytap + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNoOpCredentials(t *testing.T) { + cred := NewNoOpCredentials() + + result, err := cred.Retrieve(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "", result) +} + +func TestApiTokenCredentials(t *testing.T) { + username := "user" + token := "token" + header := "Basic dXNlcjp0b2tlbg==" + + cred := NewAPITokenCredentials(username, token) + + assert.Equal(t, username, cred.Username) + assert.Equal(t, token, cred.APIToken) + + result, err := cred.Retrieve(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, header, result) +} diff --git a/skytap/environment.go b/skytap/environment.go new file mode 100644 index 0000000..772bea2 --- /dev/null +++ b/skytap/environment.go @@ -0,0 +1,509 @@ +package skytap + +import ( + "context" + "fmt" +) + +// Default URL paths +const ( + environmentLegacyBasePath = "/configurations" + environmentBasePath = "/v2/configurations" +) + +// EnvironmentsService is the contract for the services provided on the Skytap Environment resource +type EnvironmentsService interface { + List(ctx context.Context) (*EnvironmentListResult, error) + Get(ctx context.Context, id string) (*Environment, error) + Create(ctx context.Context, createEnvironmentRequest *CreateEnvironmentRequest) (*Environment, error) + Update(ctx context.Context, id string, updateEnvironmentRequest *UpdateEnvironmentRequest) (*Environment, error) + Delete(ctx context.Context, id string) error +} + +// EnvironmentsServiceClient is the EnvironmentsService implementation +type EnvironmentsServiceClient struct { + client *Client +} + +// Environment resource struct definitions. +type Environment struct { + ID *string `json:"id"` + URL *string `json:"url"` + Name *string `json:"name"` + Description *string `json:"description"` + Errors []string `json:"errors"` + ErrorDetails []string `json:"error_details"` + Runstate *EnvironmentRunstate `json:"runstate"` + RateLimited *bool `json:"rate_limited"` + LastRun *string `json:"last_run"` + SuspendOnIdle *int `json:"suspend_on_idle"` + SuspendAtTime *string `json:"suspend_at_time"` + OwnerURL *string `json:"owner_url"` + OwnerName *string `json:"owner_name"` + OwnerID *string `json:"owner_id"` + VMCount *int `json:"vm_count"` + Storage *int `json:"storage"` + NetworkCount *int `json:"network_count"` + CreatedAt *string `json:"created_at"` + Region *string `json:"region"` + RegionBackend *string `json:"region_backend"` + SVMs *int `json:"svms"` + CanSaveAsTemplate *bool `json:"can_save_as_template"` + CanCopy *bool `json:"can_copy"` + CanDelete *bool `json:"can_delete"` + CanChangeState *bool `json:"can_change_state"` + CanShare *bool `json:"can_share"` + CanEdit *bool `json:"can_edit"` + LabelCount *int `json:"label_count"` + LabelCategoryCount *int `json:"label_category_count"` + CanTag *bool `json:"can_tag"` + Tags []Tag `json:"tags"` + TagList *string `json:"tag_list"` + Alerts []string `json:"alerts"` + PublishedServiceCount *int `json:"published_service_count"` + PublicIPCount *int `json:"public_ip_count"` + AutoSuspendDescription *string `json:"auto_suspend_description"` + Stages []Stage `json:"stages"` + StagedExecution *StagedExecution `json:"staged_execution"` + SequencingEnabled *bool `json:"sequencing_enabled"` + NoteCount *int `json:"note_count"` + ProjectCountForUser *int `json:"project_count_for_user"` + ProjectCount *int `json:"project_count"` + PublishSetCount *int `json:"publish_set_count"` + ScheduleCount *int `json:"schedule_count"` + VpnCount *int `json:"vpn_count"` + OutboundTraffic *bool `json:"outbound_traffic"` + Routable *bool `json:"routable"` + VMs []VM `json:"vms"` + Networks []Network `json:"networks"` + ContainersCount *int `json:"containers_count"` + ContainerHostsCount *int `json:"container_hosts_count"` + PlatformErrors []string `json:"platform_errors"` + SVMsByArchitecture *SVMsByArchitecture `json:"svms_by_architecture"` + AllVmsSupportSuspend *bool `json:"all_vms_support_suspend"` + ShutdownOnIdle *int `json:"shutdown_on_idle"` + ShutdownAtTime *string `json:"shutdown_at_time"` + AutoShutdownDescription *string `json:"auto_shutdown_description"` +} + +// Tag describes environment tag data +type Tag struct { + ID *string `json:"id"` + Value *string `json:"value"` +} + +// Stage describes the VM stage sequence +type Stage struct { + DelayAfterFinishSeconds *int `json:"delay_after_finish_seconds"` + Index *int `json:"index"` + VMIDs []string `json:"vm_ids"` +} + +// StagedExecution describes the status of a running VM sequence +type StagedExecution struct { + ActionType *string `json:"action_type"` + CurrentStageDelayAfterFinishSeconds *int `json:"current_stage_delay_after_finish_seconds"` + CurrentStageIndex *int `json:"current_stage_index"` + CurrentStageFinishedAt *string `json:"current_stage_finished_at"` + VMIDs []string `json:"vm_ids"` +} + +// VM describes a virtual machines in the environment. It is legal to have 0 entries in this array +type VM struct { + ID *string `json:"id"` + Name *string `json:"name"` + Runstate *VMRunstate `json:"runstate"` + RateLimited *bool `json:"rate_limited"` + Hardware *Hardware `json:"hardware"` + Error *bool `json:"error"` + ErrorDetails *bool `json:"error_details"` + AssetID *string `json:"asset_id"` + HardwareVersion *int `json:"hardware_version"` + MaxHardwareVersion *int `json:"max_hardware_version"` + Interfaces []Interface `json:"interfaces"` + Notes []Note `json:"notes"` + Labels []Label `json:"labels"` + Credentials []Credential `json:"credentials"` + DesktopResizable *bool `json:"desktop_resizable"` + LocalMouseCursor *bool `json:"local_mouse_cursor"` + MaintenanceLockEngaged *bool `json:"maintenance_lock_engaged"` + RegionBackend *string `json:"region_backend"` + CreatedAt *string `json:"created_at"` + SupportsSuspend *bool `json:"supports_suspend"` + CanChangeObjectState *bool `json:"can_change_object_state"` + Containers []Container `json:"containers"` + ConfigurationURL *string `json:"configuration_url"` +} + +// Hardware describes the VM's hardware configuration +type Hardware struct { + CPUs *int `json:"cpus"` + SupportsMulticore *bool `json:"supports_multicore"` + CpusPerSocket *int `json:"cpus_per_socket"` + RAM *int `json:"ram"` + SVMs *int `json:"svms"` + GuestOS *string `json:"guestOS"` + MaxCPUs *int `json:"max_cpus"` + MinRAM *int `json:"min_ram"` + MaxRAM *int `json:"max_ram"` + VncKeymap *string `json:"vnc_keymap"` + UUID *int `json:"uuid"` + Disks []Disk `json:"disks"` + Storage *int `json:"storage"` + Upgradable *bool `json:"upgradable"` + InstanceType *string `json:"instance_type"` + TimeSyncEnabled *bool `json:"time_sync_enabled"` + RTCStartTime *string `json:"rtc_start_time"` + CopyPasteEnabled *bool `json:"copy_paste_enabled"` + NestedVirtualization *bool `json:"nested_virtualization"` + Architecture *string `json:"architecture"` +} + +// Disk describes the VM's hard drive configuration +type Disk struct { + ID *string `json:"id"` + Size *int `json:"size"` + Type *string `json:"type"` + Controller *string `json:"controller"` + LUN *string `json:"lun"` +} + +// Interface describes the VM's virtual network interface configuration +type Interface struct { + ID *string `json:"id"` + IP *string `json:"ip"` + Hostname *string `json:"hostname"` + MAC *string `json:"mac"` + ServicesCount *int `json:"services_count"` + Services []Service `json:"services"` + PublicIPsCount *int `json:"public_ips_count"` + PublicIPs []map[string]string `json:"public_ips"` + VMID *string `json:"vm_id"` + VMName *string `json:"vm_name"` + Status *string `json:"status"` + NetworkID *string `json:"network_id"` + NetworkName *string `json:"network_name"` + NetworkURL *string `json:"network_url"` + NetworkType *string `json:"network_type"` + NetworkSubnet *string `json:"network_subnet"` + NICType *string `json:"nic_type"` + SecondaryIPs []SecondaryIP `json:"secondary_ips"` + PublicIPAttachments []PublicIPAttachment `json:"public_ip_attachments"` +} + +// Service describes a service provided on the connected network +type Service struct { + ID *string `json:"id"` + InternalPort *int `json:"internal_port"` + ExternalIP *string `json:"external_ip"` + ExternalPort *int `json:"external_port"` +} + +// SecondaryIP holds secondary IP address data +type SecondaryIP struct { + ID *string `json:"id"` + Address *string `json:"address"` +} + +// PublicIPAttachment describes the public IP address data +type PublicIPAttachment struct { + ID *int `json:"id"` + PublicIPAttachmentKey *int `json:"public_ip_attachment_key"` + Address *string `json:"address"` + ConnectType *int `json:"connect_type"` + Hostname *string `json:"hostname"` + DNSName *string `json:"dns_name"` + PublicIPKey *string `json:"public_ip_key"` +} + +// Note describes a note on the VM +type Note struct { + ID *string `json:"id"` + UserID *int `json:"user_id"` + User *User `json:"user"` + CreatedAt *string `json:"created_at"` + UpdatedAt *string `json:"updated_at"` + Text *string `json:"text"` +} + +// User describes the user who made the note +type User struct { + ID *string `json:"id"` + URL *string `json:"url"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + LoginName *string `json:"login_name"` + Email *string `json:"email"` + Title *string `json:"title"` + Deleted *bool `json:"deleted"` +} + +// Label describes a label attached to the VM +type Label struct { + ID *string `json:"id"` + Value *string `json:"value"` + LabelCategory *string `json:"label_category"` + LabelCategoryID *string `json:"label_category_id"` + LabelCategorySingleValue *bool `json:"label_category_single_value"` +} + +// Credential describes credentials stored on the VM and available from the Credentials page in the UI +type Credential struct { + ID *string `json:"id"` + Text *string `json:"text"` +} + +// Container describes the containers running on the VM. If null, the VM is not a container host. +// To make the VM a container host, see Make the VM a container host. +// If the VM is a container host, this object contains the following fields: +type Container struct { + ID *int `json:"id"` + CID *string `json:"cid"` + Name *string `json:"name"` + Image *string `json:"image"` + CreatedAt *string `json:"created_at"` + LastRun *string `json:"last_run"` + CanChangeState *bool `json:"can_change_state"` + CanDelete *bool `json:"can_delete"` + Status *string `json:"status"` + Privileged *bool `json:"privileged"` + VMID *int `json:"vm_id"` + VMName *string `json:"vm_name"` + VMRunstate *VMRunstate `json:"vm_runstate"` + ConfigurationID *int `json:"configuration_id"` +} + +// Network is a network in the environment. +// Every environment can have multiple networks; +// the number of total networks that can be created is restricted by your customer account’s network quota. +type Network struct { + ID *string `json:"id"` + URL *string `json:"url"` + Name *string `json:"name"` + NetworkType *string `json:"network_type"` + Subnet *string `json:"subnet"` + SubnetAddr *string `json:"subnet_addr"` + SubnetSize *int `json:"subnet_size"` + Gateway *string `json:"gateway"` + PrimaryNameserver *string `json:"primary_nameserver"` + SecondaryNameserver *string `json:"secondary_nameserver"` + Region *string `json:"region"` + Domain *string `json:"domain"` + VPNAttachments []VPNAttachment `json:"vpn_attachments"` + Tunnelable *bool `json:"tunnelable"` + Tunnels []Tunnel `json:"tunnels"` +} + +// VPNAttachment are representations of the relationships between this network +// and any VPN or Private Network Connections it is attached to, including whether the network is currently connected. +type VPNAttachment struct { + ID *string `json:"id"` + Connected *bool `json:"connected"` + Network *VpnAttachmentNetwork `json:"network"` + VPN *VPN `json:"vpn"` +} + +// VpnAttachmentNetwork describes the attachment network +type VpnAttachmentNetwork struct { + ID *string `json:"id"` + Subnet *string `json:"subnet"` + NetworkName *string `json:"network_name"` + ConfigurationID *string `json:"configuration_id"` +} + +// VPN described a virtual machine attached to an environment. +type VPN struct { + ID *string `json:"id"` + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + NatEnabled *bool `json:"nat_enabled"` + RemoteSubnets *string `json:"remote_subnets"` + RemotePeerIP *string `json:"remote_peer_ip"` + CanReconnect *bool `json:"can_reconnect"` +} + +// Tunnel is a list of connections between this network and other networks +type Tunnel struct { + ID *string `json:"id"` + Status *string `json:"status"` + Error *string `json:"error"` + SourceNetwork *Network `json:"source_network"` + TargetNetwork *Network `json:"target_network"` +} + +// SVMsByArchitecture lists the number of x86 and power SVMs consumed by VMs in the environment +type SVMsByArchitecture struct { + X86 *int `json:"x86"` + Power *int `json:"power"` +} + +// EnvironmentRunstate enumerates the possible environment running states +type EnvironmentRunstate string + +// The environment running states +const ( + EnvironmentRunstateStopped EnvironmentRunstate = "stopped" + EnvironmentRunstateSuspended EnvironmentRunstate = "suspended" + EnvironmentRunstateRunning EnvironmentRunstate = "running" +) + +// VMRunstate enumerates the possible VM running states +type VMRunstate string + +// The VM running states +const ( + VMRunstateStopped VMRunstate = "stopped" + VMRunstateSuspended VMRunstate = "suspended" + VMRunstateRunning VMRunstate = "running" + VMRunstateReset VMRunstate = "reset" + VMRunstateHalted VMRunstate = "halted" +) + +// Architecture is the system architecture +type Architecture int + +// The architecture types +const ( + ArchitectureX86 Architecture = 0 + ArchitecturePower Architecture = 1 +) + +// EnvironmentListResult is the list request specific struct +type EnvironmentListResult struct { + Value []Environment +} + +// CreateEnvironmentRequest describes the update the environment data +type CreateEnvironmentRequest struct { + TemplateID *string `json:"template_id,omitempty"` + ProjectID *string `json:"project_id,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Owner *string `json:"owner,omitempty"` + OutboundTraffic *bool `json:"outbound_traffic,omitempty"` + Routable *bool `json:"routable,omitempty"` + SuspendOnIdle *int `json:"suspend_on_idle,omitempty"` + SuspendAtTime *string `json:"suspend_at_time,omitempty"` + ShutdownOnIdle *int `json:"shutdown_on_idle,omitempty"` + ShutdownAtTime *string `json:"shutdown_at_time,omitempty"` +} + +// UpdateEnvironmentRequest describes the update the environment data +type UpdateEnvironmentRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Owner *string `json:"owner,omitempty"` + OutboundTraffic *bool `json:"outbound_traffic,omitempty"` + Routable *bool `json:"routable,omitempty"` + SuspendOnIdle *int `json:"suspend_on_idle,omitempty"` + SuspendAtTime *string `json:"suspend_at_time,omitempty"` + ShutdownOnIdle *int `json:"shutdown_on_idle,omitempty"` + ShutdownAtTime *string `json:"shutdown_at_time,omitempty"` +} + +// List the environments +func (s *EnvironmentsServiceClient) List(ctx context.Context) (*EnvironmentListResult, error) { + req, err := s.client.newRequest(ctx, "GET", environmentBasePath, nil) + if err != nil { + return nil, err + } + + err = s.client.setRequestListParameters(req, nil) + if err != nil { + return nil, err + } + + var environmentsListResponse EnvironmentListResult + _, err = s.client.do(ctx, req, &environmentsListResponse.Value) + if err != nil { + return nil, err + } + + return &environmentsListResponse, nil +} + +// Get an environment +func (s *EnvironmentsServiceClient) Get(ctx context.Context, id string) (*Environment, error) { + path := fmt.Sprintf("%s/%s", environmentBasePath, id) + + req, err := s.client.newRequest(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + + var environment Environment + _, err = s.client.do(ctx, req, &environment) + if err != nil { + return nil, err + } + + return &environment, nil +} + +// Create an environment +func (s *EnvironmentsServiceClient) Create(ctx context.Context, request *CreateEnvironmentRequest) (*Environment, error) { + req, err := s.client.newRequest(ctx, "POST", environmentLegacyBasePath, request) + if err != nil { + return nil, err + } + + var createdEnvironment Environment + _, err = s.client.do(ctx, req, &createdEnvironment) + if err != nil { + return nil, err + } + + updateOpts := &UpdateEnvironmentRequest{ + Name: request.Name, + Description: request.Description, + Owner: request.Owner, + OutboundTraffic: request.OutboundTraffic, + Routable: request.Routable, + SuspendOnIdle: request.SuspendOnIdle, + SuspendAtTime: request.SuspendAtTime, + ShutdownOnIdle: request.ShutdownOnIdle, + ShutdownAtTime: request.ShutdownAtTime, + } + + // update environment after creation to establish the resource information. + environment, err := s.Update(ctx, ptrToStr(createdEnvironment.ID), updateOpts) + if err != nil { + return nil, err + } + + return environment, nil +} + +// Update an environment +func (s *EnvironmentsServiceClient) Update(ctx context.Context, id string, updateEnvironment *UpdateEnvironmentRequest) (*Environment, error) { + path := fmt.Sprintf("%s/%s", environmentBasePath, id) + + req, err := s.client.newRequest(ctx, "PUT", path, updateEnvironment) + if err != nil { + return nil, err + } + + var environment Environment + _, err = s.client.do(ctx, req, &environment) + if err != nil { + return nil, err + } + + return &environment, nil +} + +// Delete an environment +func (s *EnvironmentsServiceClient) Delete(ctx context.Context, id string) error { + path := fmt.Sprintf("%s/%s", environmentLegacyBasePath, id) + + req, err := s.client.newRequest(ctx, "DELETE", path, nil) + if err != nil { + return err + } + _, err = s.client.do(ctx, req, nil) + if err != nil { + return err + } + + return nil +} diff --git a/skytap/environment_test.go b/skytap/environment_test.go new file mode 100644 index 0000000..63c16ff --- /dev/null +++ b/skytap/environment_test.go @@ -0,0 +1,510 @@ +package skytap + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +const exampleEnvironment = `{ + "id": "456", + "url": "https://cloud.skytap.com/v2/configurations/456", + "name": "No VM", + "description": "test environment", + "errors": [ + "error1" + ], + "error_details": [ + "error1 details" + ], + "runstate": "stopped", + "rate_limited": false, + "last_run": "2018/10/11 15:42:23 +0100", + "suspend_on_idle": 1, + "suspend_at_time": "2018/10/11 15:42:23 +0100", + "owner_url": "https://cloud.skytap.com/v2/users/1", + "owner_name": "Joe Bloggs", + "owner_id": "1", + "vm_count": 1, + "storage": 30720, + "network_count": 1, + "created_at": "2018/10/11 15:42:23 +0100", + "region": "US-West", + "region_backend": "skytap", + "svms": 1, + "can_save_as_template": true, + "can_copy": true, + "can_delete": true, + "can_change_state": true, + "can_share": true, + "can_edit": true, + "label_count": 1, + "label_category_count": 1, + "can_tag": true, + "tags": [ + { + "id": "43894", + "value": "tag1" + }, + { + "id": "43896", + "value": "tag2" + } + ], + "tag_list": "tag1,tag2", + "alerts": [ + "alert1" + ], + "published_service_count": 0, + "public_ip_count": 0, + "auto_suspend_description": null, + "stages": [ + { + "delay_after_finish_seconds": 300, + "index": 0, + "vm_ids": [ + "123456", + "123457" + ] + } + ], + "staged_execution": { + "action_type": "suspend", + "current_stage_delay_after_finish_seconds": 300, + "current_stage_index": 1, + "current_stage_finished_at": "2018/10/11 15:42:23 +0100", + "vm_ids": [ + "123453", + "123454" + ] + }, + "sequencing_enabled": false, + "note_count": 1, + "project_count_for_user": 0, + "project_count": 0, + "publish_set_count": 0, + "schedule_count": 0, + "vpn_count": 0, + "outbound_traffic": false, + "routable": false, + "vms": [ + { + "id": "36858580", + "name": "CentOS 7 Server x64", + "runstate": "stopped", + "rate_limited": false, + "hardware": { + "cpus": 1, + "supports_multicore": true, + "cpus_per_socket": 1, + "ram": 1024, + "svms": 1, + "guestOS": "centos-64", + "max_cpus": 12, + "min_ram": 256, + "max_ram": 262144, + "vnc_keymap": null, + "uuid": null, + "disks": [ + { + "id": "disk-19861359-37668995-scsi-0-0", + "size": 30720, + "type": "SCSI", + "controller": "0", + "lun": "0" + } + ], + "storage": 30720, + "upgradable": false, + "instance_type": null, + "time_sync_enabled": true, + "rtc_start_time": null, + "copy_paste_enabled": true, + "nested_virtualization": false, + "architecture": "x86" + }, + "error": false, + "error_details": false, + "asset_id": "1", + "hardware_version": 11, + "max_hardware_version": 11, + "interfaces": [ + { + "id": "nic-19861359-37668995-0", + "ip": "10.0.0.1", + "hostname": "centos7sx64", + "mac": "00:50:56:2B:87:F5", + "services_count": 0, + "services": [ + { + "id": "3389", + "internal_port": 3389, + "external_ip": "76.191.118.29", + "external_port": 12345 + } + ], + "public_ips_count": 0, + "public_ips": [ + { + "1.2.3.4": "5.6.7.8" + } + ], + "vm_id": "36858580", + "vm_name": "CentOS 7 Server x64", + "status": "Powered off", + "network_id": "23429874", + "network_name": "Default Network", + "network_url": "https://cloud.skytap.com/v2/configurations/456/networks/23429874", + "network_type": "automatic", + "network_subnet": "10.0.0.0/24", + "nic_type": "vmxnet3", + "secondary_ips": [ + { + "id": "10.0.2.2", + "address": "10.0.2.2" + } + ], + "public_ip_attachments": [ + { + "id": 1, + "public_ip_attachment_key": 2, + "address": "1.2.3.4", + "connect_type": 1, + "hostname": "host1", + "dns_name": "host.com", + "public_ip_key": "5.6.7.8" + } + ] + } + ], + "notes": [ + { + "id": "5377708", + "user_id": 1, + "user": { + "id": "1", + "url": "https://cloud.skytap.com/v2/users/1", + "first_name": "Joe", + "last_name": "Bloggs", + "login_name": "Joe.Bloggs@opencredo", + "email": "Joe.Bloggs@opencredo.com", + "title": "", + "deleted": false + }, + "created_at": "2018/10/11 15:27:45 +0100", + "updated_at": "2018/10/11 15:27:45 +0100", + "text": "a note" + } + ], + "labels": [ + { + "id": "43892", + "value": "test vm", + "label_category": "test multi", + "label_category_id": "7704", + "label_category_single_value": false + } + ], + "credentials": [ + { + "id": "35158632", + "text": "user/pass" + } + ], + "desktop_resizable": true, + "local_mouse_cursor": true, + "maintenance_lock_engaged": false, + "region_backend": "skytap", + "created_at": "2018/10/11 15:42:26 +0100", + "supports_suspend": true, + "can_change_object_state": true, + "containers": [ + { + "id": 1122, + "cid": "123456789abcdefghijk123456789abcdefghijk123456789abcdefghijk", + "name": "nginxtest1", + "image": "nginx:latest", + "created_at": "2016/06/16 11:58:50 -0700", + "last_run": "2016/06/16 11:58:51 -0700", + "can_change_state": true, + "can_delete": true, + "status": "running", + "privileged": false, + "vm_id": 111000, + "vm_name": "Docker VM1", + "vm_runstate": "running", + "configuration_id": 123456 + } + ], + "configuration_url": "https://cloud.skytap.com/v2/configurations/456" + } + ], + "networks": [ + { + "id": "1234567", + "url": "https://cloud.skytap.com/configurations/1111111/networks/123467", + "name": "Network 1", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": "8.8.8.8", + "secondary_nameserver": "8.8.8.9", + "region": "US-West", + "domain": "sampledomain.com", + "vpn_attachments": [ + { + "id": "111111-vpn-1234567", + "connected": false, + "network": { + "id": "1111111", + "subnet": "10.0.0.0/24", + "network_name": "Network 1", + "configuration_id": "1212121" + }, + "vpn": { + "id": "vpn-1234567", + "name": "CorpNet", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.10.0.0/24, 10.10.1.0/24, 10.10.2.0/24, 10.10.4.0/24", + "remote_peer_ip": "199.199.199.199", + "can_reconnect": true + } + }, + { + "id": "111111-vpn-1234555", + "connected": false, + "network": { + "id": "1111111", + "subnet": "10.0.0.0/24", + "network_name": "Network 1", + "configuration_id": "1212121" + }, + "vpn": { + "id": "vpn-1234555", + "name": "Offsite DC", + "enabled": true, + "nat_enabled": true, + "remote_subnets": "10.10.0.0/24, 10.10.1.0/24, 10.10.2.0/24, 10.10.4.0/24", + "remote_peer_ip": "188.188.188.188", + "can_reconnect": true + } + } + ], + "tunnelable": false, + "tunnels": [ + { + "id": "tunnel-123456-789011", + "status": "not_busy", + "error": null, + "source_network": { + "id": "000000", + "url": "https://cloud.skytap.com/configurations/249424/networks/0000000", + "name": "Network 1", + "network_type": "automatic", + "subnet": "10.0.0.0/24", + "subnet_addr": "10.0.0.0", + "subnet_size": 24, + "gateway": "10.0.0.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "domain": "skytap.example", + "vpn_attachments": [] + }, + "target_network": { + "id": "111111", + "url": "https://cloud.skytap.com/configurations/808216/networks/111111", + "name": "Network 2", + "network_type": "automatic", + "subnet": "10.0.2.0/24", + "subnet_addr": "10.0.2.0", + "subnet_size": 24, + "gateway": "10.0.2.254", + "primary_nameserver": null, + "secondary_nameserver": null, + "region": "US-West", + "domain": "test.net", + "vpn_attachments": [] + } + } + ] + } + ], + "containers_count": 0, + "container_hosts_count": 0, + "platform_errors": [ + "platform error1" + ], + "svms_by_architecture": { + "x86": 1, + "power": 0 + }, + "all_vms_support_suspend": true, + "shutdown_on_idle": null, + "shutdown_at_time": null, + "auto_shutdown_description": "Shutting down!" +}` + +func TestCreateEnvironment(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + var createPhase = true + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if createPhase { + if req.URL.Path != "/configurations" { + t.Error("Bad path") + } + if req.Method != "POST" { + t.Error("Bad method") + } + body, err := ioutil.ReadAll(req.Body) + assert.Nil(t, err) + assert.JSONEq(t, fmt.Sprintf(`{"template_id":%q, "description":"test environment"}`, "12345"), string(body)) + io.WriteString(rw, `{"id": "456"}`) + createPhase = false + } else { + if req.URL.Path != "/v2/configurations/456" { + t.Error("Bad path") + } + if req.Method != "PUT" { + t.Error("Bad method") + } + body, err := ioutil.ReadAll(req.Body) + assert.Nil(t, err) + assert.JSONEq(t, `{"description": "test environment"}`, string(body)) + + io.WriteString(rw, exampleEnvironment) + } + } + + opts := &CreateEnvironmentRequest{ + TemplateID: strToPtr("12345"), + Description: strToPtr("test environment"), + } + + environment, err := skytap.Environments.Create(context.Background(), opts) + + assert.Nil(t, err) + + var environmentExpected Environment + + err = json.Unmarshal([]byte(exampleEnvironment), &environmentExpected) + + assert.Equal(t, environmentExpected, *environment) +} + +func TestReadEnvironment(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/v2/configurations/456" { + t.Error("Bad path") + } + if req.Method != "GET" { + t.Error("Bad method") + } + io.WriteString(rw, exampleEnvironment) + } + + environment, err := skytap.Environments.Get(context.Background(), "456") + + assert.Nil(t, err) + var environmentExpected Environment + + err = json.Unmarshal([]byte(exampleEnvironment), &environmentExpected) + + assert.Equal(t, environmentExpected, *environment) +} + +func TestUpdateEnvironment(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + var environment Environment + json.Unmarshal([]byte(exampleEnvironment), &environment) + *environment.Description = "updated environment" + + bytes, err := json.Marshal(&environment) + assert.Nil(t, err) + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/v2/configurations/456" { + t.Error("Bad path") + } + if req.Method != "PUT" { + t.Error("Bad method") + } + body, err := ioutil.ReadAll(req.Body) + assert.Nil(t, err) + assert.JSONEq(t, `{"description": "updated environment"}`, string(body)) + + io.WriteString(rw, string(bytes)) + } + + opts := &UpdateEnvironmentRequest{ + Description: strToPtr(*environment.Description), + } + + environmentUpdate, err := skytap.Environments.Update(context.Background(), "456", opts) + + assert.Nil(t, err) + assert.Equal(t, environment, *environmentUpdate) +} + +func TestDeleteEnvironment(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/configurations/456" { + t.Error("Bad path") + } + if req.Method != "DELETE" { + t.Error("Bad method") + } + } + + err := skytap.Environments.Delete(context.Background(), "456") + assert.Nil(t, err) +} + +func TestListEnvironments(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/v2/configurations" { + t.Error("Bad path") + } + if req.Method != "GET" { + t.Error("Bad method") + } + io.WriteString(rw, fmt.Sprintf(`[%+v]`, exampleEnvironment)) + } + + result, err := skytap.Environments.List(context.Background()) + + assert.Nil(t, err) + + var found = false + for _, environment := range result.Value { + if *environment.Description == "test environment" { + found = true + break + } + } + + assert.True(t, found) +} diff --git a/skytap/project.go b/skytap/project.go new file mode 100644 index 0000000..89ea025 --- /dev/null +++ b/skytap/project.go @@ -0,0 +1,149 @@ +package skytap + +import ( + "context" + "fmt" +) + +// Default URL paths +const ( + projectsLegacyBasePath = "/projects" + projectsBasePath = "/v2/projects" +) + +// ProjectsService is the contract for the services provided on the Skytap Project resource +type ProjectsService interface { + List(ctx context.Context) (*ProjectListResult, error) + Get(ctx context.Context, id string) (*Project, error) + Create(ctx context.Context, project *Project) (*Project, error) + Update(ctx context.Context, id string, project *Project) (*Project, error) + Delete(ctx context.Context, id string) error +} + +// ProjectsServiceClient is the ProjectsService implementation +type ProjectsServiceClient struct { + client *Client +} + +// Project resource struct definitions +type Project struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Summary *string `json:"summary,omitempty"` + AutoAddRoleName *ProjectRole `json:"auto_add_role_name,omitempty"` + ShowProjectMembers *bool `json:"show_project_members,omitempty"` +} + +// ProjectRole is the enumeration of the different possible project roles +type ProjectRole string + +// The different project roles +const ( + ProjectRoleViewer ProjectRole = "viewer" + ProjectRoleParticipant ProjectRole = "participant" + ProjectRoleEditor ProjectRole = "editor" + ProjectRoleManager ProjectRole = "manager" +) + +// ProjectListResult is the listing request specific struct +type ProjectListResult struct { + Value []Project +} + +// List the projects +func (s *ProjectsServiceClient) List(ctx context.Context) (*ProjectListResult, error) { + req, err := s.client.newRequest(ctx, "GET", projectsBasePath, nil) + if err != nil { + return nil, err + } + + err = s.client.setRequestListParameters(req, nil) + if err != nil { + return nil, err + } + + var projectListResponse ProjectListResult + _, err = s.client.do(ctx, req, &projectListResponse.Value) + if err != nil { + return nil, err + } + + return &projectListResponse, nil +} + +// Get a project +func (s *ProjectsServiceClient) Get(ctx context.Context, id string) (*Project, error) { + path := fmt.Sprintf("%s/%s", projectsBasePath, id) + + req, err := s.client.newRequest(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + + var project Project + _, err = s.client.do(ctx, req, &project) + if err != nil { + return nil, err + } + + return &project, nil +} + +// Create a project +func (s *ProjectsServiceClient) Create(ctx context.Context, project *Project) (*Project, error) { + req, err := s.client.newRequest(ctx, "POST", projectsLegacyBasePath, project) + if err != nil { + return nil, err + } + + var createdProject Project + _, err = s.client.do(ctx, req, &createdProject) + if err != nil { + return nil, err + } + + createdProject.Summary = project.Summary + + // update project after creation to establish the resource information. + updatedProject, err := s.Update(ctx, *createdProject.ID, &createdProject) + if err != nil { + return nil, err + } + + return updatedProject, nil +} + +// Update a project +func (s *ProjectsServiceClient) Update(ctx context.Context, id string, project *Project) (*Project, error) { + path := fmt.Sprintf("%s/%s", projectsLegacyBasePath, id) + + req, err := s.client.newRequest(ctx, "PUT", path, project) + if err != nil { + return nil, err + } + + var updatedProject Project + _, err = s.client.do(ctx, req, &updatedProject) + if err != nil { + return nil, err + } + + return &updatedProject, nil +} + +// Delete a project +func (s *ProjectsServiceClient) Delete(ctx context.Context, id string) error { + path := fmt.Sprintf("%s/%s", projectsLegacyBasePath, id) + + req, err := s.client.newRequest(ctx, "DELETE", path, nil) + if err != nil { + return err + } + + _, err = s.client.do(ctx, req, nil) + if err != nil { + return err + } + + return nil +} diff --git a/skytap/project_test.go b/skytap/project_test.go new file mode 100644 index 0000000..3209fcc --- /dev/null +++ b/skytap/project_test.go @@ -0,0 +1,178 @@ +package skytap + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func createClient(t *testing.T) (*Client, *httptest.Server, *func(rw http.ResponseWriter, req *http.Request)) { + handler := http.NotFound + hs := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + handler(rw, req) + })) + + var user = "SKYTAP_USER" + var token = "SKYTAP_ACCESS_TOKEN" + + settings := NewDefaultSettings(WithBaseURL(hs.URL), WithCredentialsProvider(NewAPITokenCredentials(user, token))) + + skytap, err := NewClient(settings) + + assert.Nil(t, err) + assert.NotNil(t, skytap) + return skytap, hs, &handler +} + +func TestCreateProject(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + var createPhase = true + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if createPhase { + if req.URL.Path != "/projects" { + t.Error("Bad path:", req.URL.Path) + } + if req.Method != "POST" { + t.Error("Bad method") + } + body, err := ioutil.ReadAll(req.Body) + assert.Nil(t, err) + assert.JSONEq(t, `{"name":"test-project","summary":"test project"}`, string(body)) + io.WriteString(rw, `{"id": "12345", "name": "test-project"}`) + createPhase = false + } else { + if req.URL.Path != "/projects/12345" { + t.Error("Bad path:", req.URL.Path) + } + if req.Method != "PUT" { + t.Error("Bad method") + } + body, err := ioutil.ReadAll(req.Body) + assert.Nil(t, err) + assert.JSONEq(t, `{"id": "12345","name":"test-project","summary":"test project"}`, string(body)) + io.WriteString(rw, `{"id": "12345", "name": "test-project", "summary": "test project"}`) + } + } + + opts := Project{ + Name: strToPtr("test-project"), + Summary: strToPtr("test project"), + } + + project, err := skytap.Projects.Create(context.Background(), &opts) + + assert.Nil(t, err) + assert.Equal(t, &Project{ID: project.ID, Name: strToPtr("test-project"), Summary: strToPtr("test project")}, project) +} + +func TestReadProject(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/v2/projects/12345" { + t.Error("Bad path:", req.URL.Path) + } + if req.Method != "GET" { + t.Error("Bad method") + } + io.WriteString(rw, `{"id": "12345", "name": "test-project", "summary": "test project"}`) + } + + projectRead, err := skytap.Projects.Get(context.Background(), "12345") + + assert.Nil(t, err) + assert.Equal(t, &Project{ID: strToPtr("12345"), Name: strToPtr("test-project"), Summary: strToPtr("test project")}, projectRead) +} + +func TestUpdateProject(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/projects/12345" { + t.Error("Bad path:", req.URL.Path) + } + if req.Method != "PUT" { + t.Error("Bad method") + } + body, err := ioutil.ReadAll(req.Body) + assert.Nil(t, err) + assert.JSONEq(t, `{"id": "12345","name":"updated name","summary":"updated summary"}`, string(body)) + io.WriteString(rw, `{"id": "12345", "name": "updated name", "summary": "updated summary"}`) + } + + opts := &Project{ + ID: strToPtr("12345"), + Name: strToPtr("updated name"), + Summary: strToPtr("updated summary"), + } + + projectUpdate, err := skytap.Projects.Update(context.Background(), "12345", opts) + + expectedResult := &Project{ID: strToPtr("12345"), Name: strToPtr("updated name"), Summary: strToPtr("updated summary")} + + assert.Nil(t, err) + assert.Equal(t, expectedResult, projectUpdate) +} + +func TestDeleteProject(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/projects/12345" { + t.Error("Bad path:", req.URL.Path) + } + if req.Method != "DELETE" { + t.Error("Bad method") + } + } + + err := skytap.Projects.Delete(context.Background(), "12345") + assert.Nil(t, err) +} + +func TestListProjects(t *testing.T) { + skytap, hs, handler := createClient(t) + defer hs.Close() + + *handler = func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/v2/projects" { + t.Error("Bad path:", req.URL.Path) + } + if req.Method != "GET" { + t.Error("Bad method") + } + io.WriteString(rw, `[{ + "id": "12345", + "url": "https://cloud.skytap.com/projects/12345", + "name": "updated name", + "summary": "updated summary", + "show_project_members": true, + "auto_add_role_name": null + }]`) + } + + result, err := skytap.Projects.List(context.Background()) + + assert.Nil(t, err) + + var found = false + for _, project := range result.Value { + if *project.Name == "updated name" { + found = true + break + } + } + + assert.True(t, found) +} diff --git a/skytap/settings.go b/skytap/settings.go new file mode 100644 index 0000000..176b3aa --- /dev/null +++ b/skytap/settings.go @@ -0,0 +1,87 @@ +package skytap + +import ( + "fmt" +) + +const ( + // DefaultBaseURL is the base URL if not explicitly set + DefaultBaseURL = "https://cloud.skytap.com/" + // DefaultUserAgent is the default user agent if not explicitly set + DefaultUserAgent = "skytap-sdk-go/" + version +) + +// Settings holds the base URL, user agent and credential data +type Settings struct { + baseURL string + userAgent string + + credentials CredentialsProvider +} + +// Validate the settings +func (s *Settings) Validate() error { + if s.baseURL == "" { + return fmt.Errorf("the base URL must be provided") + } + if s.userAgent == "" { + return fmt.Errorf("the user agent must be provided") + } + if s.credentials == nil { + return fmt.Errorf("the credential provider must be provided") + } + + return nil +} + +// NewDefaultSettings creates a new Settings based upon the input clientSettings +func NewDefaultSettings(clientSettings ...ClientSetting) Settings { + settings := Settings{ + baseURL: DefaultBaseURL, + userAgent: DefaultUserAgent, + credentials: NewNoOpCredentials(), + } + + // Apply any custom settings + for _, c := range clientSettings { + c.Apply(&settings) + } + + return settings +} + +// ClientSetting abstracts an individual setting +type ClientSetting interface { + Apply(*Settings) +} + +type withBaseURL string +type withUserAgent string +type withCredentialsProvider struct{ cp CredentialsProvider } + +func (w withBaseURL) Apply(s *Settings) { + s.baseURL = string(w) +} + +func (w withUserAgent) Apply(s *Settings) { + s.userAgent = string(w) +} + +func (w withCredentialsProvider) Apply(s *Settings) { + s.credentials = w.cp +} + +// WithBaseURL accepts a base URL +func WithBaseURL(BaseURL string) ClientSetting { + return withBaseURL(BaseURL) +} + +// WithUserAgent accepts a user agent +func WithUserAgent(UserAgent string) ClientSetting { + return withUserAgent(UserAgent) +} + +// WithCredentialsProvider accepts an abstracted set of credentials +func WithCredentialsProvider(credentialsProvider CredentialsProvider) ClientSetting { + return withCredentialsProvider{credentialsProvider} +} diff --git a/skytap/settings_test.go b/skytap/settings_test.go new file mode 100644 index 0000000..c546ff1 --- /dev/null +++ b/skytap/settings_test.go @@ -0,0 +1,45 @@ +package skytap + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewDefaultSettings(t *testing.T) { + settings := NewDefaultSettings() + + assert.Equal(t, DefaultBaseURL, settings.baseURL) + assert.Equal(t, DefaultUserAgent, settings.userAgent) + + if assert.NotNil(t, settings.credentials) { + assert.IsType(t, &NoOpCredentials{}, settings.credentials) + } +} + +func TestNewDefaultSettingsWithOpts(t *testing.T) { + baseURL := "https://url.com" + userAgent := "testclient/1.0.0" + username := "user" + token := "token" + + settings := NewDefaultSettings( + WithBaseURL(baseURL), + WithCredentialsProvider(NewAPITokenCredentials(username, token))) + + assert.Equal(t, baseURL, settings.baseURL) + assert.Equal(t, DefaultUserAgent, settings.userAgent) + + if assert.NotNil(t, settings.credentials) { + assert.IsType(t, &APITokenCredentials{}, settings.credentials) + } + + settings = NewDefaultSettings(WithUserAgent(userAgent)) + + assert.Equal(t, DefaultBaseURL, settings.baseURL) + assert.Equal(t, userAgent, settings.userAgent) + + if assert.NotNil(t, settings.credentials) { + assert.IsType(t, &NoOpCredentials{}, settings.credentials) + } +}