Skip to content

Commit

Permalink
Merge pull request #1540 from chenz4027/magchen-OCM-3663
Browse files Browse the repository at this point in the history
OCM-3663 | feat: AWS billing support
  • Loading branch information
openshift-ci[bot] authored Oct 16, 2023
2 parents 03600a8 + 04867d8 commit 3e56ce5
Show file tree
Hide file tree
Showing 5 changed files with 279 additions and 10 deletions.
99 changes: 97 additions & 2 deletions cmd/create/cluster/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
clustervalidations "github.com/openshift-online/ocm-common/pkg/cluster/validations"
passwordValidator "github.com/openshift-online/ocm-common/pkg/idp/validations"
diskValidator "github.com/openshift-online/ocm-common/pkg/machinepool/validations"
accountsv1 "github.com/openshift-online/ocm-sdk-go/accountsmgmt/v1"
v1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1"
"github.com/openshift/rosa/cmd/create/admin"
"github.com/openshift/rosa/cmd/create/idp"
Expand Down Expand Up @@ -663,7 +664,6 @@ func init() {
"",
"Account used for billing subscriptions purchased via the AWS marketplace",
)
flags.MarkHidden("billing-account")

flags.BoolVar(
&args.createAdminUser,
Expand Down Expand Up @@ -987,7 +987,7 @@ func run(cmd *cobra.Command, _ []string) {
}

if billingAccount != "" && !ocm.IsValidAWSAccount(billingAccount) {
r.Reporter.Errorf("Expected a valid billing account")
r.Reporter.Errorf("Billing account is invalid. Run the command again with a valid billing account.")
os.Exit(1)
}

Expand All @@ -996,6 +996,67 @@ func run(cmd *cobra.Command, _ []string) {
os.Exit(1)
}

cloudAccounts, err := r.OCMClient.GetBillingAccounts()
if err != nil {
r.Reporter.Errorf("%s", err)
os.Exit(1)
}

billingAccounts := ocm.GenerateBillingAccountsList(cloudAccounts)

if billingAccount == "" && !interactive.Enabled() {
// if a billing account is not provided we will try to use the infrastructure account as default
if helper.ContainsPrefix(billingAccounts, awsCreator.AccountID) {
billingAccount = awsCreator.AccountID
r.Reporter.Infof("Using '%s' as billing account", billingAccount)
r.Reporter.Infof("To use a different billing account, add --billing-account xxxxxxxxxx to previous command")
} else {
r.Reporter.Errorf("A billing account is required for Hosted Control Plane clusters.")
}
}

if interactive.Enabled() {
if len(billingAccounts) > 0 {
billingAccount, err = interactive.GetOption(interactive.Input{
Question: "Billing Account",
Help: cmd.Flags().Lookup("billing-account").Usage,
Default: billingAccount,
Required: true,
Options: billingAccounts,
})

if err != nil {
r.Reporter.Errorf("Expected a valid billing account: '%s'", err)
os.Exit(1)
}

billingAccount = aws.ParseOption(billingAccount)
}

if billingAccount == "" || !ocm.IsValidAWSAccount(billingAccount) {
r.Reporter.Errorf("Expected a valid billing account")
os.Exit(1)
}

// Get contract info
contracts, isContractEnabled := GetBillingAccountContracts(cloudAccounts, billingAccount)

if billingAccount != awsCreator.AccountID {
r.Reporter.Infof("The selected AWS billing account is a different account than your AWS infrastructure account." +
"The AWS billing account will be charged for subscription usage. " +
"The AWS infrastructure account will be used for managing the cluster.")
} else {
r.Reporter.Infof("Using '%s' as billing account.",
billingAccount)
}

if isContractEnabled && len(contracts) > 0 {
//currently, an AWS account will have only one ROSA HCP active contract at a time
contractDisplay := GenerateContractDisplay(contracts[0])
r.Reporter.Infof(contractDisplay)
}
}

etcdEncryptionKmsARN := args.etcdEncryptionKmsARN

if etcdEncryptionKmsARN != "" && !isHostedCP {
Expand Down Expand Up @@ -3033,6 +3094,40 @@ func run(cmd *cobra.Command, _ []string) {
}
}

func GetBillingAccountContracts(cloudAccounts []*accountsv1.CloudAccount,
billingAccount string) ([]*accountsv1.Contract, bool) {
var contracts []*accountsv1.Contract
for _, account := range cloudAccounts {
if account.CloudAccountID() == billingAccount {
contracts = account.Contracts()
if ocm.HasValidContracts(account) {
return contracts, true
}
}
}
return contracts, false
}

func GenerateContractDisplay(contract *accountsv1.Contract) string {
format := "Jan 02, 2006"
dimensions := contract.Dimensions()

numberOfVCPUs, numberOfClusters := ocm.GetNumsOfVCPUsAndClusters(dimensions)
prePurchaseInfo := fmt.Sprintf(" | Number of vCPUs: |'%s' | \n"+
" | Number of clusters: |'%s' | \n",
strconv.Itoa(numberOfVCPUs), strconv.Itoa(numberOfClusters))

contractDisplay := "\n" +
" +---------------------+----------------+ \n" +
" | Start Date |" + contract.StartDate().Format(format) + " | \n" +
" | End Date |" + contract.EndDate().Format(format) + " | \n" +
prePurchaseInfo +
" +---------------------+----------------+ \n"

return contractDisplay

}

func validateOperatorRolesAvailabilityUnderUserAwsAccount(awsClient aws.Client,
operatorIAMRoleList []ocm.OperatorIAMRole) error {
for _, role := range operatorIAMRoleList {
Expand Down
41 changes: 41 additions & 0 deletions cmd/create/cluster/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package cluster
import (
"fmt"
"strings"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
v1 "github.com/openshift-online/ocm-sdk-go/accountsmgmt/v1"
"github.com/openshift/rosa/pkg/logging"
"github.com/openshift/rosa/pkg/ocm"
)
Expand Down Expand Up @@ -136,3 +138,42 @@ var _ = Describe("Validates OCP version", func() {
})
})
})

var _ = Describe("Validate cloud accounts", func() {

Context("build billing accounts", func() {
When("return the result", func() {
It("OK: Successfully gets contracts from cloudAccounts", func() {
mockCloudAccount := v1.NewCloudAccount().CloudAccountID("1234567").
Contracts(v1.NewContract().StartDate(time.Now()).EndDate(time.Now().Add(2)).
Dimensions(v1.NewContractDimension().Name("control_plane").Value("4")))
cloudAccount, err := mockCloudAccount.Build()
Expect(err).NotTo(HaveOccurred())
_, isContractEnabled := GetBillingAccountContracts([]*v1.CloudAccount{cloudAccount}, "1234567")
Expect(isContractEnabled).To(Equal(true))
})

It("OK: Successfully print contract details", func() {
t, err := time.Parse(time.RFC3339, "2023-10-12T15:22:00Z")
Expect(err).To(BeNil())
mockContract, err := v1.NewContract().StartDate(t).
EndDate(t).
Dimensions(v1.NewContractDimension().Name("control_plane").Value("4"),
v1.NewContractDimension().Name("four_vcpu_hour").Value("5")).Build()
Expect(err).NotTo(HaveOccurred())

expected := "\n" +
" +---------------------+----------------+ \n" +
" | Start Date |Oct 12, 2023 | \n" +
" | End Date |Oct 12, 2023 | \n" +
" | Number of vCPUs: |'5' | \n" +
" | Number of clusters: |'4' | \n" +
" +---------------------+----------------+ \n"

contractDisplay := GenerateContractDisplay(mockContract)

Expect(contractDisplay).To(Equal(expected))
})
})
})
})
9 changes: 9 additions & 0 deletions pkg/helper/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ func Contains[T comparable](slice []T, element T) bool {
return false
}

func ContainsPrefix(slice []string, prefix string) bool {
for _, sliceElement := range slice {
if strings.HasPrefix(sliceElement, prefix) {
return true
}
}
return false
}

func SliceToMap(s []string) map[string]bool {
m := make(map[string]bool)

Expand Down
60 changes: 52 additions & 8 deletions pkg/ocm/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import (
"errors"
"fmt"
"regexp"
"strconv"

v1 "github.com/openshift-online/ocm-sdk-go/accountsmgmt/v1"
"github.com/openshift/rosa/pkg/helper"
)

var ROSAHypershiftQuota = "cluster|byoc|moa|marketplace"

var awsAccountRegexp = regexp.MustCompile(`^[0-9]{12}$`)

func (c *Client) GetBillingAccounts() ([]string, error) {
func (c *Client) GetBillingAccounts() ([]*v1.CloudAccount, error) {
acctResponse, err := c.ocm.AccountsMgmt().V1().CurrentAccount().
Get().
Send()
Expand All @@ -34,22 +36,64 @@ func (c *Client) GetBillingAccounts() ([]string, error) {
return nil, handleErr(quotaCostResponse.Error(), err)
}

var billingAccounts []string
var billingAccounts []*v1.CloudAccount
for _, item := range quotaCostResponse.Items().Slice() {
for _, account := range item.CloudAccounts() {
if account.CloudProviderID() == "aws" && !helper.Contains(billingAccounts, account.CloudAccountID()) {
billingAccounts = append(billingAccounts, account.CloudAccountID())
}
}
billingAccounts = append(billingAccounts, item.CloudAccounts()...)
}

if len(billingAccounts) == 0 {
return billingAccounts, errors.New("no billing accounts found")
return billingAccounts, errors.New("No billing account associated. " +
"Go to https://docs.openshift.com/rosa/rosa_architecture/rosa-understanding.html" +
" to learn how to associate your billing account." +
" You must associate a billing account to continue.")
}

return billingAccounts, nil
}

func GenerateBillingAccountsList(cloudAccounts []*v1.CloudAccount) []string {
var billingAccounts []string
for _, account := range cloudAccounts {
if account.CloudProviderID() == "aws" && !helper.ContainsPrefix(billingAccounts, account.CloudAccountID()) {
var contractString string
if HasValidContracts(account) {
contractString = " [Contract enabled]"
}
contractEnabledBillingAccount := account.CloudAccountID() + contractString
billingAccounts = append(billingAccounts, contractEnabledBillingAccount)
}
}
return billingAccounts

}

func GetNumsOfVCPUsAndClusters(dimensions []*v1.ContractDimension) (int, int) {
numOfVCPUs := 0
numOfClusters := 0
for _, dimension := range dimensions {
switch dimension.Name() {
case "four_vcpu_hour":
numOfVCPUs, _ = strconv.Atoi(dimension.Value())
case "control_plane":
numOfClusters, _ = strconv.Atoi(dimension.Value())
}
}
return numOfVCPUs, numOfClusters
}

func HasValidContracts(cloudAccount *v1.CloudAccount) bool {
// A valid contract is where either numberOfVCPUs or numberOfClusters has a value that is greater than 0
contracts := cloudAccount.Contracts()
if len(contracts) > 0 {
//currently, an AWS account will have only one ROSA HCP active contract at a time
contract := contracts[0]
numberOfVCPUs, numberOfClusters := GetNumsOfVCPUsAndClusters(contract.Dimensions())
return numberOfVCPUs > 0 ||
numberOfClusters > 0
}
return false
}

func IsValidAWSAccount(account string) bool {
return awsAccountRegexp.MatchString(account)
}
80 changes: 80 additions & 0 deletions pkg/ocm/billing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
Copyright (c) 2023 Red Hat, 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 ocm

import (
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
v1 "github.com/openshift-online/ocm-sdk-go/accountsmgmt/v1"
)

var _ = Describe("Billing Account", func() {
Context("Functions should return the correct output", func() {
It("KO: GenerateBillingAccountsList should return empty array if no CloudAccount is provided", func() {
mockCloudAccounts := append([]*v1.CloudAccount{}, &v1.CloudAccount{})
billingAccounts := GenerateBillingAccountsList(mockCloudAccounts)
Expect(billingAccounts).To(BeNil())
})

It("OK: Successfully returns a list of billing accounts", func() {
mockCloudAccount1 := v1.NewCloudAccount().CloudAccountID("1234567").CloudProviderID("aws").
Contracts(v1.NewContract().StartDate(time.Now()).EndDate(time.Now().Add(2)).
Dimensions(v1.NewContractDimension().Name("control_plane").Value("4")))
cloudAccount1, err := mockCloudAccount1.Build()
Expect(err).NotTo(HaveOccurred())

mockCloudAccount2 := v1.NewCloudAccount().CloudAccountID("12345679").CloudProviderID("aws").
Contracts(v1.NewContract().StartDate(time.Now()).EndDate(time.Now().Add(2)))
cloudAccount2, err := mockCloudAccount2.Build()
Expect(err).NotTo(HaveOccurred())

billingAccountNames := GenerateBillingAccountsList([]*v1.CloudAccount{
cloudAccount1,
cloudAccount2,
})

expected := []string{"1234567 [Contract enabled]", "12345679"}
Expect(billingAccountNames).To(Equal(expected))
})

It("OK: Successfully generates numbers of vCPUs and clusters", func() {
t, err := time.Parse(time.RFC3339, "2023-08-07T15:22:00Z")
Expect(err).To(BeNil())
mockContract, err := v1.NewContract().StartDate(t).
EndDate(t).
Dimensions(v1.NewContractDimension().Name("control_plane").Value("4"),
v1.NewContractDimension().Name("four_vcpu_hour").Value("5")).Build()
Expect(err).NotTo(HaveOccurred())

numberOfVCPUs, numberOfClusters := GetNumsOfVCPUsAndClusters(mockContract.Dimensions())
Expect(numberOfVCPUs).To(Equal(5))
Expect(numberOfClusters).To(Equal(4))
})

It("OK: Successfully verify valid contracts", func() {
mockCloudAccount := v1.NewCloudAccount().CloudAccountID("1234567").
Contracts(v1.NewContract().StartDate(time.Now()).EndDate(time.Now().Add(2)).
Dimensions(v1.NewContractDimension().Name("control_plane").Value("4")))
cloudAccount, err := mockCloudAccount.Build()
Expect(err).NotTo(HaveOccurred())
result := HasValidContracts(cloudAccount)
Expect(result).To(Equal(true))
})
})
})

0 comments on commit 3e56ce5

Please sign in to comment.