spinnaker/spinnaker.go (438 lines of code) (raw):

// Copyright 2016 Netflix, 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 spinnaker provides an interface to the Spinnaker API package spinnaker import ( "crypto/tls" "encoding/json" "encoding/pem" "fmt" "io/ioutil" "log" "net/http" "strings" "golang.org/x/crypto/pkcs12" "github.com/pkg/errors" "github.com/Netflix/chaosmonkey/v2" "github.com/Netflix/chaosmonkey/v2/config" D "github.com/Netflix/chaosmonkey/v2/deploy" "github.com/Netflix/chaosmonkey/v2/deps" ) // Spinnaker implements the deploy.Deployment interface by querying Spinnaker // and the chaosmonkey.Termination interface by terminating via Spinnaker API // calls type Spinnaker struct { endpoint string client *http.Client user string } // spinnakerClusters maps account name (e.g., "prod", "test") to a list // of cluster names type spinnakerClusters map[string][]string // spinnakerServerGroup represents an autoscaling group, also called a server group, // as represented by Spinnaker API type spinnakerServerGroup struct { Name string Region string Disabled bool Instances []spinnakerInstance } // spinnakerInstance represents an instance as represented by Spinnaker API type spinnakerInstance struct { Name string } // getClient takes PKCS#12 data (encrypted cert data in .p12 format) and the // password for the encrypted cert, and returns an http client that does TLS client auth func getClient(pfxData []byte, password string) (*http.Client, error) { blocks, err := pkcs12.ToPEM(pfxData, password) if err != nil { return nil, errors.Wrap(err, "pkcs.ToPEM failed") } // The first block is the cert and the last block is the private key certPEMBlock := pem.EncodeToMemory(blocks[0]) keyPEMBlock := pem.EncodeToMemory(blocks[len(blocks)-1]) cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) if err != nil { return nil, errors.Wrap(err, "tls.X509KeyPair failed") } tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, } transport := &http.Transport{TLSClientConfig: tlsConfig} return &http.Client{Transport: transport}, nil } // getClientX509 takes X509 data (Public and Private keys) and the // and returns an http client that does TLS client auth func getClientX509(x509Cert, x509Key string) (*http.Client, error) { cert, err := tls.LoadX509KeyPair(x509Cert, x509Key) if err != nil { return nil, errors.Wrap(err, "tls.X509KeyPair failed") } tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true, } transport := &http.Transport{TLSClientConfig: tlsConfig} return &http.Client{Transport: transport}, nil } // NewFromConfig returns a Spinnaker based on config func NewFromConfig(cfg *config.Monkey) (Spinnaker, error) { spinnakerEndpoint := cfg.SpinnakerEndpoint() certPath := cfg.SpinnakerCertificate() encryptedPassword := cfg.SpinnakerEncryptedPassword() user := cfg.SpinnakerUser() x509Cert := cfg.SpinnakerX509Cert() x509Key := cfg.SpinnakerX509Key() if spinnakerEndpoint == "" { return Spinnaker{}, errors.New("FATAL: no spinnaker endpoint specified in config") } var password string var err error var decryptor chaosmonkey.Decryptor if encryptedPassword != "" { decryptor, err = deps.GetDecryptor(cfg) if err != nil { return Spinnaker{}, err } password, err = decryptor.Decrypt(encryptedPassword) if err != nil { return Spinnaker{}, err } } return New(spinnakerEndpoint, certPath, password, x509Cert, x509Key, user) } // New returns a Spinnaker using a .p12 cert at certPath encrypted with // password or x509 cert. The user argument identifies the email address of the user which is // sent in the payload of the terminateInstances task API call func New(endpoint string, certPath string, password string, x509Cert string, x509Key string, user string) (Spinnaker, error) { var client *http.Client var err error if x509Cert != "" && certPath != "" { return Spinnaker{}, errors.New("cannot use both p12 and x509 certs, choose one") } if certPath != "" { pfxData, err := ioutil.ReadFile(certPath) if err != nil { return Spinnaker{}, errors.Wrapf(err, "failed to read file %s", certPath) } client, err = getClient(pfxData, password) if err != nil { return Spinnaker{}, err } } else if x509Cert != "" { client, err = getClientX509(x509Cert, x509Key) if err != nil { return Spinnaker{}, err } } else { client = new(http.Client) } return Spinnaker{endpoint: endpoint, client: client, user: user}, nil } // AccountID returns numerical ID associated with an AWS account func (s Spinnaker) AccountID(name string) (id string, err error) { url := s.accountURL(name) resp, err := s.client.Get(url) if err != nil { return "", errors.Wrapf(err, "could not retrieve account info for %s from spinnaker url %s", name, url) } defer func() { if cerr := resp.Body.Close(); cerr != nil && err == nil { err = errors.Wrapf(err, "failed to close response body from %s", url) } }() body, err := ioutil.ReadAll(resp.Body) if err != nil { return "", errors.Wrapf(err, "failed to read body from url %s", url) } var info struct { AccountID string `json:"accountId"` Error string `json:"error"` } err = json.Unmarshal(body, &info) if err != nil { return "", errors.Wrapf(err, "could not parse body of %s as json, body: %s, error", url, body) } if resp.StatusCode != http.StatusOK { if info.Error == "" { return "", errors.Errorf("%s returned unexpected status code: %d, body: %s", url, resp.StatusCode, body) } return "", errors.New(info.Error) } // Some backends may not have associated account ids if info.AccountID == "" { return s.alternateAccountID(name) } return info.AccountID, nil } // alternateAccountID returns an account ID for accounts that don't have their // own ids. func (s Spinnaker) alternateAccountID(name string) (string, error) { // Sanity check: this should never be called with "prod" or "test" as an // argument, since this would result in infinite recursion if name == "prod" || name == "test" { return "", fmt.Errorf("alternateAccountID called with forbidden arg: %s", name) } // Heuristic: if account name has "test" in the name, we return the "test" // account id, otherwise with we use the "prod" account id if strings.Contains(name, "test") { return s.AccountID("test") } return s.AccountID("prod") } // Apps implements deploy.Deployment.Apps func (s Spinnaker) Apps(c chan<- *D.App, appNames []string) { // Close the channel we're done defer close(c) for _, appName := range appNames { app, err := s.GetApp(appName) if err != nil { // If we have a problem with one app, we go to the next one log.Printf("WARNING: GetApp failed for %s: %v", appName, err) continue } c <- app } } // GetInstanceIDs gets the instance ids for a cluster func (s Spinnaker) GetInstanceIDs(app string, account D.AccountName, cloudProvider string, region D.RegionName, cluster D.ClusterName) (D.ASGName, []D.InstanceID, error) { url := s.activeASGURL(app, string(account), string(cluster), cloudProvider, string(region)) resp, err := s.client.Get(url) if err != nil { return "", nil, errors.Wrapf(err, "http get failed at %s", url) } defer func() { if cerr := resp.Body.Close(); cerr != nil && err == nil { err = errors.Wrapf(err, "body close failed at %s", url) } }() if resp.StatusCode != http.StatusOK { return "", nil, errors.Errorf("unexpected response code (%d) from %s", resp.StatusCode, url) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return "", nil, errors.Wrap(err, fmt.Sprintf("body read failed at %s", url)) } var data struct { Name string Instances []struct{ Name string } } err = json.Unmarshal(body, &data) if err != nil { return "", nil, errors.Wrapf(err, "failed to parse json at %s", url) } asg := D.ASGName(data.Name) instances := make([]D.InstanceID, len(data.Instances)) for i, instance := range data.Instances { instances[i] = D.InstanceID(instance.Name) } return asg, instances, nil } // GetApp implements deploy.Deployment.GetApp func (s Spinnaker) GetApp(appName string) (*D.App, error) { // data arg is a map like {accountName: {clusterName: {regionName: {asgName: [instanceId]}}}} data := make(D.AppMap) for account, clusters := range s.clusters(appName) { cloudProvider, err := s.CloudProvider(account) if err != nil { return nil, errors.Wrap(err, "retrieve cloud provider failed") } account := D.AccountName(account) data[account] = D.AccountInfo{ CloudProvider: cloudProvider, Clusters: make(map[D.ClusterName]map[D.RegionName]map[D.ASGName][]D.InstanceID), } for _, clusterName := range clusters { clusterName := D.ClusterName(clusterName) data[account].Clusters[clusterName] = make(map[D.RegionName]map[D.ASGName][]D.InstanceID) asgs, err := s.asgs(appName, string(account), string(clusterName)) if err != nil { log.Printf("WARNING: could not retrieve asgs for app:%s account:%s cluster:%s : %v", appName, account, clusterName, err) continue } for _, asg := range asgs { // We don't terminate instances in disabled ASGs if asg.Disabled { continue } region := D.RegionName(asg.Region) asgName := D.ASGName(asg.Name) _, present := data[account].Clusters[clusterName][region] if !present { data[account].Clusters[clusterName][region] = make(map[D.ASGName][]D.InstanceID) } data[account].Clusters[clusterName][region][asgName] = make([]D.InstanceID, len(asg.Instances)) for i, instance := range asg.Instances { data[account].Clusters[clusterName][region][asgName][i] = D.InstanceID(instance.Name) } } } } return D.NewApp(appName, data), nil } // AppNames returns list of names of all apps func (s Spinnaker) AppNames() (appnames []string, err error) { url := s.appsURL() resp, err := s.client.Get(url) if err != nil { return nil, fmt.Errorf("could not retrieve list of apps from spinnaker url %s: %v", url, err) } defer func() { if cerr := resp.Body.Close(); cerr != nil && err == nil { err = fmt.Errorf("failed to close response body from %s: %v", url, err) } }() body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read body when retrieving spinnaker app names from %s: %v", url, err) } var apps []spinnakerApp err = json.Unmarshal(body, &apps) if err != nil { return nil, fmt.Errorf("could not parse spinnaker apps list from %s: body: \"%s\": %v", url, string(body), err) } result := make([]string, len(apps)) for i, app := range apps { result[i] = app.Name } return result, nil } // spinnakerApp returns an app as represented by the Spinnaker API type spinnakerApp struct { Name string } // clusters returns a map from account name to list of cluster names func (s Spinnaker) clusters(appName string) spinnakerClusters { url := s.clustersURL(appName) resp, err := s.client.Get(url) if err != nil { log.Println("Error connecting to spinnaker clusters endpoint") log.Println(url) log.Fatalln(err) } defer func() { if err := resp.Body.Close(); err != nil { log.Printf("Error closing response body of %s: %v", url, err) } }() body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Println("Error retrieving spinnaker clusters for app", appName) log.Println(url) log.Println(string(body)) log.Fatalln(err) } // Example cluster output: /* { "prod": [ "abc-prod" ], "test": [ "abc-beta" ] } */ var m spinnakerClusters err = json.Unmarshal(body, &m) if err != nil { log.Println("Error parsing body when retrieving cluster info for", appName) log.Println(url) log.Println(string(body)) log.Fatalln(err) } return m } // asgs returns a slice of autoscaling groups associated with the given cluster func (s Spinnaker) asgs(appName, account, clusterName string) (result []spinnakerServerGroup, err error) { url := s.serverGroupsURL(appName, account, clusterName) resp, err := s.client.Get(url) if err != nil { return nil, fmt.Errorf("failed to retrieve server groups url (%s): %v", url, err) } defer func() { if cerr := resp.Body.Close(); cerr != nil && err == nil { err = fmt.Errorf("failed to close response body of %s: %v", url, err) } }() body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read body of server groups url (%s): body: '%s': %v", url, string(body), err) } // Example: /* [ { "name": "abc-prod-v016", "region": "us-east-1", "zones": [ "us-east-1c", "us-east-1d", "us-east-1e" ], "disabled": false, "instances": [ { "name": "i-f9ffb752", ... }, ... ] } ] */ var asgs []spinnakerServerGroup err = json.Unmarshal(body, &asgs) if err != nil { return nil, fmt.Errorf("failed to parse body of spinnaker asgs url (%s): body: '%s'. %v", url, string(body), err) } return asgs, nil } // CloudProvider returns the cloud provider for a given account name func (s Spinnaker) CloudProvider(name string) (provider string, err error) { account, err := s.account(name) if err != nil { return "", err } if account.CloudProvider == "" { return "", errors.New("no cloudProvider field in response body") } return account.CloudProvider, nil } // account represents a spinnaker account type account struct { CloudProvider string `json:"cloudProvider"` Name string `json:"name"` Error string `json:"error"` } // account returns an account by its name func (s Spinnaker) account(name string) (account, error) { url := s.accountsURL(true) resp, err := s.client.Get(url) var ac account // Usual HTTP checks if err != nil { return ac, errors.Wrapf(err, "http get failed at %s", url) } defer func() { if cerr := resp.Body.Close(); cerr != nil && err == nil { err = errors.Wrap(err, fmt.Sprintf("body close failed at %s", url)) } }() body, err := ioutil.ReadAll(resp.Body) if err != nil { return ac, errors.Wrapf(err, "body read failed at %s", url) } var accounts []account err = json.Unmarshal(body, &accounts) if err != nil { return ac, errors.Wrap(err, "json unmarshal failed") } statusKO := resp.StatusCode != http.StatusOK // Finally find account for _, a := range accounts { if a.Name != name { continue } if statusKO { if a.Error == "" { return ac, errors.Errorf("unexpected status code: %d. body: %s", resp.StatusCode, body) } return ac, errors.Errorf("unexpected status code: %d. error: %s", resp.StatusCode, a.Error) } return a, nil } return ac, errors.New("the account name doesn't exist") } // GetClusterNames returns a list of cluster names for an app func (s Spinnaker) GetClusterNames(app string, account D.AccountName) (clusters []D.ClusterName, err error) { url := s.appURL(app) resp, err := s.client.Get(url) if err != nil { return nil, errors.Wrapf(err, "http get failed at %s", url) } defer func() { if cerr := resp.Body.Close(); cerr != nil && err == nil { err = errors.Wrapf(err, "body close failed at %s", url) } }() if resp.StatusCode != http.StatusOK { return nil, errors.Errorf("unexpected response code (%d) from %s", resp.StatusCode, url) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, errors.Wrap(err, fmt.Sprintf("body read failed at %s", url)) } var pcl struct { Clusters map[D.AccountName][]struct { Name D.ClusterName } } err = json.Unmarshal(body, &pcl) if err != nil { return nil, errors.Wrapf(err, "failed to parse json at %s", url) } cls := pcl.Clusters[account] clusters = make([]D.ClusterName, len(cls)) for i, cl := range cls { clusters[i] = cl.Name } return clusters, nil } // GetRegionNames returns a list of regions that a cluster is deployed into func (s Spinnaker) GetRegionNames(app string, account D.AccountName, cluster D.ClusterName) ([]D.RegionName, error) { url := s.clusterURL(app, string(account), string(cluster)) resp, err := s.client.Get(url) if err != nil { return nil, errors.Wrapf(err, "http get failed at %s", url) } defer func() { if cerr := resp.Body.Close(); cerr != nil && err == nil { err = errors.Wrapf(err, "body close failed at %s", url) } }() if resp.StatusCode != http.StatusOK { return nil, errors.Errorf("unexpected response code (%d) from %s", resp.StatusCode, url) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, errors.Wrap(err, fmt.Sprintf("body read failed at %s", url)) } var cl struct { ServerGroups []struct{ Region D.RegionName } } err = json.Unmarshal(body, &cl) if err != nil { return nil, errors.Wrapf(err, "failed to parse json at %s", url) } set := make(map[D.RegionName]bool) for _, g := range cl.ServerGroups { set[g.Region] = true } result := make([]D.RegionName, 0, len(set)) for region := range set { result = append(result, region) } return result, nil }