cmd/hub/api/cloudaccount.go (666 lines of code) (raw):

// Copyright (c) 2022 EPAM Systems, Inc. // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. //go:build api package api import ( "encoding/json" "errors" "fmt" "io" "log" "net/url" "os" "strings" awscredentials "github.com/aws/aws-sdk-go/aws/credentials" "github.com/epam/hubctl/cmd/hub/aws" "github.com/epam/hubctl/cmd/hub/config" "github.com/epam/hubctl/cmd/hub/util" ) const cloudAccountsResource = "hub/api/v1/cloud-accounts" var ( GovCloudRegions = []string{"us-gov-east-1", "us-gov-west-1"} //lint:ignore U1000 still needed? cloudAccountsCache = make(map[string]*CloudAccount) ) func CloudAccounts(selector string, showSecrets, showLogs, getCloudCredentials, shFormat, nativeConfigFormat, jsonFormat bool) { cloudAccounts, err := cloudAccountsBy(selector, showSecrets) if err != nil { log.Fatalf("Unable to query for Cloud Account(s): %v", err) } if getCloudCredentials && (shFormat || nativeConfigFormat) { if len(cloudAccounts) == 0 { fmt.Print("# No Cloud Accounts\n") } else { errors := make([]error, 0) for i, cloudAccount := range cloudAccounts { keys, err := cloudAccountCredentials(cloudAccount.Id, cloudAccount.Kind) if err != nil { errors = append(errors, err) } else { sh := "" if shFormat { sh, err = formatCloudAccountCredentialsSh(keys) if err != nil { errors = append(errors, err) } } nativeConfig := "" if nativeConfigFormat { nativeConfig, err = formatCloudAccountCredentialsNativeConfig(&cloudAccount, keys) if err != nil { errors = append(errors, err) } } if i > 0 { fmt.Print("\n") } header := "" if len(cloudAccounts) > 1 { header = fmt.Sprintf("# %s\n", cloudAccount.BaseDomain) } if sh != "" || nativeConfig != "" { fmt.Printf("%s%s%s", header, sh, nativeConfig) } } } if len(errors) > 0 { fmt.Print("# Errors encountered:\n") for _, err := range errors { fmt.Printf("#\t%s\n", strings.ReplaceAll(err.Error(), "\n", "\n#\t")) } } } } else if len(cloudAccounts) == 0 { if jsonFormat { log.Print("No Cloud Accounts") } else { fmt.Print("No Cloud Accounts\n") } } else { if jsonFormat { var toMarshal interface{} if len(cloudAccounts) == 1 { toMarshal = &cloudAccounts[0] } else { toMarshal = cloudAccounts } out, err := json.MarshalIndent(toMarshal, "", " ") if err != nil { log.Fatalf("Error marshalling JSON response for output: %v", err) } os.Stdout.Write(out) os.Stdout.Write([]byte("\n")) } else { fmt.Print("Cloud Accounts:\n") errors := make([]error, 0) for _, cloudAccount := range cloudAccounts { errors = formatCloudAccountEntity(&cloudAccount, getCloudCredentials, showSecrets, showLogs, errors) } if len(errors) > 0 { fmt.Print("Errors encountered:\n") for _, err := range errors { fmt.Printf("\t%v\n", err) } } } } } func formatCloudAccountEntity(cloudAccount *CloudAccount, getCloudCredentials, showSecrets, showLogs bool, errors []error) []error { fmt.Printf("\n\t%s\n", formatCloudAccountTitle(cloudAccount)) fmt.Printf("\t\tKind: %s\n", formatCloudAccountKind(cloudAccount.Kind)) fmt.Printf("\t\tStatus: %s\n", cloudAccount.Status) if getCloudCredentials { keys, err := cloudAccountCredentials(cloudAccount.Id, cloudAccount.Kind) if err != nil { errors = append(errors, err) } else { formatted, err := formatCloudAccountCredentials(keys) if err != nil { errors = append(errors, err) } else { fmt.Printf("\t\tSecurity Credentials: %s\n", formatted) } } } if len(cloudAccount.TeamsPermissions) > 0 { formatted := formatTeams(cloudAccount.TeamsPermissions) fmt.Printf("\t\tTeams: %s\n", formatted) } if len(cloudAccount.Parameters) > 0 { fmt.Print("\t\tParameters:\n") } resource := fmt.Sprintf("%s/%s", cloudAccountsResource, cloudAccount.Id) for _, param := range sortParameters(cloudAccount.Parameters) { formatted, err := formatParameter(resource, param, showSecrets) fmt.Printf("\t\t%s\n", formatted) if err != nil { errors = append(errors, err) } } if len(cloudAccount.InflightOperations) > 0 { fmt.Print("\t\tInflight Operations:\n") for _, op := range cloudAccount.InflightOperations { fmt.Print(formatInflightOperation(op, showLogs)) } } return errors } func formatCloudAccount(cloudAccount *CloudAccount) { errors := formatCloudAccountEntity(cloudAccount, false, false, false, make([]error, 0)) if len(errors) > 0 { fmt.Print("Errors encountered:\n") for _, err := range errors { fmt.Printf("\t%v\n", err) } } } //lint:ignore U1000 still needed? func cachedCloudAccountBy(selector string) (*CloudAccount, error) { cloudAccount, cached := cloudAccountsCache[selector] if !cached { var err error cloudAccount, err = cloudAccountBy(selector) if err != nil { return nil, err } cloudAccountsCache[selector] = cloudAccount } return cloudAccount, nil } func cloudAccountBy(selector string) (*CloudAccount, error) { if !util.IsUint(selector) { return cloudAccountByDomain(selector) } return cloudAccountById(selector, false) } func cloudAccountsBy(selector string, unmask bool) ([]CloudAccount, error) { if !util.IsUint(selector) { return cloudAccountsByDomain(selector) } cloudAccount, err := cloudAccountById(selector, unmask) if err != nil { return nil, err } if cloudAccount != nil { return []CloudAccount{*cloudAccount}, nil } return nil, nil } func cloudAccountById(id string, unmask bool) (*CloudAccount, error) { maybeUnmask := "" if unmask { maybeUnmask = "?unmask=true" } path := fmt.Sprintf("%s/%s%s", cloudAccountsResource, url.PathEscape(id), maybeUnmask) var jsResp CloudAccount code, err := get(hubApi(), path, &jsResp) if code == 404 { return nil, nil } if err != nil { return nil, fmt.Errorf("Error querying HubCTL Cloud Accounts: %v", err) } if code != 200 { return nil, fmt.Errorf("Got %d HTTP querying HubCTL Cloud Accounts, expected 200 HTTP", code) } return &jsResp, nil } func cloudAccountByDomain(domain string) (*CloudAccount, error) { cloudAccounts, err := cloudAccountsByDomain(domain) if err != nil { return nil, fmt.Errorf("Unable to query for Cloud Account `%s`: %v", domain, err) } if len(cloudAccounts) == 0 { return nil, fmt.Errorf("No Cloud Account `%s` found", domain) } if len(cloudAccounts) > 1 { return nil, fmt.Errorf("More than one Cloud Account returned by domain `%s`", domain) } cloudAccount := cloudAccounts[0] return &cloudAccount, nil } func cloudAccountsByDomain(domain string) ([]CloudAccount, error) { path := cloudAccountsResource if domain != "" { path += "?domain=" + url.QueryEscape(domain) } var jsResp []CloudAccount code, err := get(hubApi(), path, &jsResp) if code == 404 { return nil, nil } if err != nil { return nil, fmt.Errorf("Error querying HubCTL Cloud Accounts: %v", err) } if code != 200 { return nil, fmt.Errorf("Got %d HTTP querying HubCTL Cloud Accounts, expected 200 HTTP", code) } return jsResp, nil } func formatCloudAccountTitle(account *CloudAccount) string { return fmt.Sprintf("%s / %s [%s]", account.Name, account.BaseDomain, account.Id) } var cloudAccountKindDescription = map[string]string{ "aws": "AWS access and secret keys", "awscar": "AWS automatic cross-account IAM role", "awsarn": "AWS manually entered cross-account IAM role ARN", "azure": "Azure", "gcp": "Google Cloud Platform", } func formatCloudAccountKind(kind string) string { description := cloudAccountKindDescription[kind] if description != "" { description = fmt.Sprintf(" (%s)", description) } return fmt.Sprintf("%s%s", kind, description) } func cloudAccountRegion(account *CloudAccount) string { for _, p := range account.Parameters { if p.Name == "cloud.region" { if maybeStr, ok := p.Value.(string); ok { return maybeStr } } } return "" } func cloudAccountCredentials(id, kind string) (interface{}, error) { switch kind { case "aws", "awscar", "awsarn": return awsCloudAccountCredentials(id) case "azure", "gcp": return rawCloudAccountCredentials(id) } return nil, fmt.Errorf("Unsupported cloud account kind `%s`", kind) } func rawCloudAccountCredentials(id string) ([]byte, error) { if config.Debug { log.Printf("Getting Cloud Account `%s` credentials", id) } path := fmt.Sprintf("%s/%s/session-keys", cloudAccountsResource, url.PathEscape(id)) code, body, err := get2(hubApi(), path) if err != nil { return nil, fmt.Errorf("Error querying HubCTL Cloud Account `%s` Credentials: %v", id, err) } if code != 200 { return nil, fmt.Errorf("Got %d HTTP querying HubCTL Cloud Account `%s` Credentials, expected 200 HTTP", code, id) } return body, nil } func awsCloudAccountCredentials(id string) (*AwsSecurityCredentials, error) { if config.Debug { log.Printf("Getting Cloud Account `%s` credentials", id) } path := fmt.Sprintf("%s/%s/session-keys", cloudAccountsResource, url.PathEscape(id)) var jsResp AwsSecurityCredentials code, err := get(hubApi(), path, &jsResp) if err != nil { return nil, fmt.Errorf("Error querying HubCTL Cloud Account `%s` Credentials: %v", id, err) } if code != 200 { return nil, fmt.Errorf("Got %d HTTP querying HubCTL Cloud Account `%s` Credentials, expected 200 HTTP", code, id) } return &jsResp, nil } func formatCloudAccountCredentials(keys interface{}) (string, error) { if aws, ok := keys.(*AwsSecurityCredentials); ok { return formatAwsCloudAccountCredentials(aws) } if raw, ok := keys.([]byte); ok { return formatRawCloudAccountCredentialsSh(raw) } return "", fmt.Errorf("Unable to format credentials: %+v", keys) } func formatCloudAccountCredentialsSh(keys interface{}) (string, error) { if aws, ok := keys.(*AwsSecurityCredentials); ok { return formatAwsCloudAccountCredentialsSh(aws) } if raw, ok := keys.([]byte); ok { return formatRawCloudAccountCredentialsSh(raw) } return "", fmt.Errorf("Unable to format credentials: %+v", keys) } func formatCloudAccountCredentialsNativeConfig(account *CloudAccount, keys interface{}) (string, error) { if aws, ok := keys.(*AwsSecurityCredentials); ok { return formatAwsCloudAccountCredentialsCliConfig(account, aws) } if raw, ok := keys.([]byte); ok { return formatRawCloudAccountCredentialsSh(raw) } return "", fmt.Errorf("Unable to format credentials: %+v", keys) } func formatAwsCloudAccountCredentials(keys *AwsSecurityCredentials) (string, error) { maybeSts := "" if keys.Sts != "" { maybeSts = "; sts = " + keys.Sts } maybeRegion := "" if keys.Region != "" { maybeRegion = "; region = " + keys.Region } return fmt.Sprintf("%s ttl = %d%s%s\n\t\t\tAccess = %s\n\t\t\tSecret = %s\n\t\t\tSession = %s", keys.Cloud, keys.Ttl, maybeSts, maybeRegion, keys.AccessKey, keys.SecretKey, keys.SessionToken), nil } func formatAwsCloudAccountCredentialsSh(keys *AwsSecurityCredentials) (string, error) { maybeSts := "" if keys.Sts != "" { maybeSts = "\n# sts = " + keys.Sts } maybeRegion := "" if keys.Region != "" { maybeRegion = "\nexport AWS_DEFAULT_REGION=" + keys.Region } return fmt.Sprintf(`# eval this in your shell # ttl = %d%s%s export AWS_ACCESS_KEY_ID=%s export AWS_SECRET_ACCESS_KEY=%s export AWS_SESSION_TOKEN=%s `, keys.Ttl, maybeSts, maybeRegion, keys.AccessKey, keys.SecretKey, keys.SessionToken), nil } func formatAwsCloudAccountCredentialsCliConfig(account *CloudAccount, keys *AwsSecurityCredentials) (string, error) { return fmt.Sprintf("[%s]\naws_access_key_id = %s\naws_secret_access_key = %s\naws_session_token = %s\n", account.BaseDomain, keys.AccessKey, keys.SecretKey, keys.SessionToken), nil } func formatRawCloudAccountCredentialsSh(raw []byte) (string, error) { var kv map[string]interface{} err := json.Unmarshal(raw, &kv) if err != nil { return "", fmt.Errorf("Unable un unmarshal: %v", err) } lines := make([]string, 0, len(kv)) for k, v := range kv { line := fmt.Sprintf("%s=%v", k, v) lines = append(lines, line) } ident := "\n\t\t\t" return ident + strings.Join(lines, ident), nil } func OnboardCloudAccount(domain, kind, region string, args []string, zone, awsVpc, awsKeypair string, waitAndTailDeployLogs bool) { err := onboardCloudAccount(domain, kind, region, args, zone, awsVpc, awsKeypair, waitAndTailDeployLogs) if err != nil { log.Fatalf("Unable to onboard Cloud Account: %v", err) } } func onboardCloudAccount(domain, kind, region string, args []string, zone, awsVpc, awsKeypair string, waitAndTailDeployLogs bool) error { kind2, credentials, err := cloudSpecificCredentials(kind, region, args) if err != nil { return err } if config.Debug { log.Printf("Onboarding %s cloud account %s in %s with %v", kind, domain, region, args) } provider := kind domainParts := strings.SplitN(domain, ".", 2) if len(domainParts) != 2 { return fmt.Errorf("Domain `%s` is invalid", domain) } if zone == "" { zone = cloudFirstZoneInRegion(provider, region) } else if !strings.HasPrefix(zone, region) { return fmt.Errorf("Zone `%s` is not within `%s` region", zone, region) } parameters := []Parameter{ {Name: "dns.baseDomain", Value: domainParts[1]}, {Name: "cloud.provider", Value: provider}, {Name: "cloud.region", Value: region}, {Name: "cloud.availabilityZone", Value: zone}, } if provider == "aws" { if awsVpc != "" { parameters = append(parameters, Parameter{Name: "cloud.vpc", Value: awsVpc}) } if awsKeypair != "" { parameters = append(parameters, Parameter{Name: "cloud.sshKey", Value: awsKeypair}) } } req := &CloudAccountRequest{ Name: domainParts[0], Kind: kind2, Credentials: credentials, Parameters: parameters, } if provider == "azure" { req.Parameters = append(req.Parameters, Parameter{Name: "cloud.azureResourceGroupName", Value: "hubctl-" + region}) } account, err := createCloudAccount(req) if err != nil { return err } formatCloudAccount(account) if waitAndTailDeployLogs { if config.Verbose { log.Print("Tailing automation task logs... ^C to interrupt") } os.Exit(Logs([]string{"cloudAccount/" + domain}, true)) } return nil } func cloudSpecificCredentials(provider, region string, args []string) (string, map[string]string, error) { switch provider { case "aws": var kind string credentials := make(map[string]string) if util.Contains(GovCloudRegions, region) && len(args) >= 1 { if maybeAwsAccessKey(args[0]) && len(args) >= 2 { credentials["dnsAccessKey"] = args[0] credentials["dnsSecretKey"] = args[1] args = args[2:] } else { profile := args[0] creds, err := awsCredentials(profile) if err != nil { return "", nil, err } if creds.SessionToken != "" { return "", nil, fmt.Errorf("AWS credentials retrieved has session token set (profile `%s`)", profile) } credentials["dnsAccessKey"] = creds.AccessKeyID credentials["dnsSecretKey"] = creds.SecretAccessKey args = args[1:] } } if len(args) == 2 { kind = "awscar" credentials["accessKey"] = args[0] credentials["secretKey"] = args[1] } else if len(args) == 1 && strings.HasPrefix(args[0], "arn:aws") { kind = "awsarn" credentials["roleArn"] = args[0] } else { profile := "" if len(args) == 1 { profile = args[0] } creds, err := awsCredentials(profile) if err != nil { return "", nil, err } kind = "awscar" credentials["accessKey"] = creds.AccessKeyID credentials["secretKey"] = creds.SecretAccessKey credentials["sessionToken"] = creds.SessionToken } return kind, credentials, nil case "azure", "gcp": credentialsFile := "" if len(args) == 1 { credentialsFile = args[0] } if credentialsFile == "" { if provider == "gcp" { credentialsFile = config.GcpCredentialsFile if credentialsFile == "" { credentialsFile = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") } } else if provider == "azure" { credentialsFile = config.AzureCredentialsFile if credentialsFile == "" { credentialsFile = os.Getenv("AZURE_AUTH_LOCATION") } } } if credentialsFile == "" { return "", nil, errors.New("No credentials file specified") } file, err := os.Open(credentialsFile) if err != nil { return "", nil, fmt.Errorf("Unable to open credentials file: %v", err) } defer file.Close() data, err := io.ReadAll(file) if err != nil { return "", nil, fmt.Errorf("Unable to read credentials file `%s`: %v", credentialsFile, err) } var creds map[string]string err = json.Unmarshal(data, &creds) if err != nil { return "", nil, fmt.Errorf("Unable to unmarshall credentials file `%s`: %v", credentialsFile, err) } return provider, creds, nil } return "", nil, errors.New("Unknown cloud account provider") } // can backfire? // https://docs.aws.amazon.com/IAM/latest/APIReference/API_AccessKey.html func maybeAwsAccessKey(key string) bool { return len(key) == 20 && strings.HasPrefix(key, "AK") } func awsCredentials(profile string) (*awscredentials.Value, error) { savePref := config.AwsPreferProfileCredentials // TODO if profile != "" { config.AwsPreferProfileCredentials = true } factory := aws.ProfileCredentials(profile, "cloud account onboarding") creds, err := factory.Get() config.AwsPreferProfileCredentials = savePref if err != nil { maybeProfile := "" if profile != "" { maybeProfile = fmt.Sprintf(" (profile `%s`)", profile) } return nil, fmt.Errorf("Unable to retrieve AWS credentials%s: %v", maybeProfile, err) } return &creds, nil } func cloudFirstZoneInRegion(provider, region string) string { switch provider { case "aws": return region + "a" case "azure": return "1" case "gcp": // https://cloud.google.com/compute/docs/regions-zones/ if util.Contains([]string{"europe-west1", "us-east1"}, region) { return region + "-b" } return region + "-a" } return "" } func createCloudAccount(cloudAccount *CloudAccountRequest) (*CloudAccount, error) { var jsResp CloudAccount code, err := post(hubApi(), cloudAccountsResource, cloudAccount, &jsResp) if err != nil { return nil, err } if code != 200 && code != 201 && code != 202 { return nil, fmt.Errorf("Got %d HTTP creating HubCTL Cloud Account, expected [200, 201, 202] HTTP", code) } return &jsResp, nil } func DeleteCloudAccount(selector string, waitAndTailDeployLogs bool) { if config.Debug { log.Printf("Deleting %s cloud account", selector) } code, err := deleteCloudAccount(selector) if err != nil { log.Fatalf("Unable to delete HubCTL Cloud Account: %v", err) } if waitAndTailDeployLogs && code == 202 { if config.Verbose { log.Print("Tailing automation task logs... ^C to interrupt") } os.Exit(Logs([]string{"cloudAccount/" + selector}, true)) } } func deleteCloudAccount(selector string) (int, error) { account, err := cloudAccountBy(selector) id := "" if err != nil { str := err.Error() if util.IsUint(selector) && (strings.Contains(str, "json: cannot unmarshal") || strings.Contains(str, "cannot parse") || config.Force) { util.Warn("%v", err) id = selector } else { return 0, err } } else if account == nil { return 404, error404 } else { id = account.Id } force := "" if config.Force { force = "?force=true" } path := fmt.Sprintf("%s/%s%s", cloudAccountsResource, url.PathEscape(id), force) code, err := delete(hubApi(), path) if err != nil { return code, err } if code != 202 && code != 204 { return code, fmt.Errorf("Got %d HTTP deleting HubCTL Cloud Account, expected [202, 204] HTTP", code) } return code, nil } func CloudAccountDownloadCfTemplate(filename string, govcloud bool) { err := cloudAccountDownloadCfTemplate(filename, govcloud) if err != nil { log.Fatalf("Unable to download HubCTL Cloud Account AWS CloudFormation template: %v", err) } } func cloudAccountDownloadCfTemplate(filename string, govcloud bool) error { path := fmt.Sprintf("%s/x-account-role-template-download", cloudAccountsResource) if govcloud { path = fmt.Sprintf("%s?govcloud=true", path) } code, body, err := get2(hubApi(), path) if err != nil { return err } if code != 200 { return fmt.Errorf("Got %d HTTP fetching HubCTL Cloud Account AWS CloudFormation template, expected 200 HTTP", code) } if len(body) == 0 { return fmt.Errorf("Got empty HubCTL Cloud Account AWS CloudFormation template") } var file io.WriteCloser if filename == "-" { file = os.Stdout } else { info, _ := os.Stat(filename) if info != nil { if info.IsDir() { filename = fmt.Sprintf("%s/x-account-role.json", filename) } else { if !config.Force { log.Fatalf("File `%s` exists, use --force / -f to overwrite", filename) } } } var err error file, err = os.Create(filename) if err != nil { return fmt.Errorf("Unable to create %s: %v", filename, err) } defer file.Close() } written, err := file.Write(body) if written != len(body) { return fmt.Errorf("Unable to write %s: %v", filename, err) } if config.Verbose && filename != "-" { log.Printf("Wrote %s", filename) } return nil }