cmd/hub/api/cluster.go (594 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 (
"bytes"
"embed"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"net"
"os"
"strings"
"github.com/epam/hubctl/cmd/hub/aws"
"github.com/epam/hubctl/cmd/hub/config"
"github.com/epam/hubctl/cmd/hub/util"
)
type ClusterOptions struct {
InstanceType string
Count int
MaxCount int
SpotPrice float32
PreemptibleVMs bool
VolumeSize int
Acm bool
CertManager bool
Autoscaler bool
KubeDashboard bool
KubeDashboardMode string
}
type ImportConfig struct {
TemplateNameFormat string
SecretsOrder []Secret
}
//go:embed requests/*-cluster-template.json.template
var clusterTemplateFiles embed.FS
//go:embed requests/*-adapter-template.json.template
var adapterTemplateFiles embed.FS
//go:embed requests/*-cluster-instance.json.template
var clusterInstanceFiles embed.FS
//go:embed requests/*-adapter-instance.json.template
var adapterInstanceFiles embed.FS
var importConfigs = map[string]ImportConfig{
"k8s-aws": {
"K8S AWS Adapter in %s",
[]Secret{
{"kubernetes.api.clientCert", "certificate", nil},
{"kubernetes.api.clientKey", "privateKey", nil},
{"kubernetes.api.caCert", "certificate", nil},
},
},
"metal": {
"Bare-metal Adapter in %s",
[]Secret{
{"kubernetes.api.clientCert", "certificate", nil},
{"kubernetes.api.clientKey", "privateKey", nil},
{"kubernetes.api.caCert", "certificate", nil},
},
},
"hybrid": {
"Hybrid bare-metal Adapter in %s",
[]Secret{
{"kubernetes.api.clientCert", "certificate", nil},
{"kubernetes.api.clientKey", "privateKey", nil},
{"kubernetes.api.caCert", "certificate", nil},
},
},
"eks": {
"EKS Adapter in %s",
[]Secret{
{"kubernetes.api.caCert", "certificate", nil}, // discovered by CLI via API
},
},
"gke": {
"GKE Adapter in %s",
nil, // discovered by import component
},
"aks": {
"AKS Adapter in %s",
nil, // discovered by import component
},
"openshift": {
"OpenShift Adapter in %s",
[]Secret{
{"kubernetes.api.caCert", "certificate", nil}, // optional
},
},
}
func CreateKubernetes(kind, name, environment, template string,
autoCreateTemplate, createNewTemplate, waitAndTailDeployLogs, dryRun bool,
nativeRegion, nativeZone, nativeClusterName,
eksAdmin, azureResourceGroup string,
options ClusterOptions) {
err := createK8s(kind, name, environment, template,
autoCreateTemplate, createNewTemplate, waitAndTailDeployLogs, dryRun,
nativeRegion, nativeZone, nativeClusterName,
eksAdmin, azureResourceGroup,
options)
if err != nil {
log.Fatalf("Unable to create `%s` Kubernetes: %v", kind, err)
}
}
func createK8s(kind, name, environmentSelector, templateSelector string,
autoCreateTemplate, createNewTemplate, waitAndTailDeployLogs, dryRun bool,
nativeRegion, nativeZone, nativeClusterName,
eksAdmin, azureResourceGroup string,
options ClusterOptions) error {
environment, err := environmentBy(environmentSelector)
if err != nil {
return fmt.Errorf("Unable to retrieve Environment: %v", err)
}
cloudAccount, err := cloudAccountById(environment.CloudAccount, false)
if err != nil {
return fmt.Errorf("Unable to retrieve Cloud Account: %v", err)
}
err = verifyClusterCloudAccountKind(kind, cloudAccount)
if err != nil {
return err
}
name, fqdn, err := verifyClusterBaseDomain(name, cloudAccount)
if err != nil {
return err
}
platformTag := "platform=" + kind
if templateSelector == "" && autoCreateTemplate {
templateSelector = fmt.Sprintf("%s in %s", strings.ToUpper(kind), environment.Name)
}
var template *StackTemplate
if templateSelector != "" && !createNewTemplate {
template, err = templateBy(templateSelector)
if err != nil && !strings.HasSuffix(err.Error(), " found") { // TODO proper 404 handling
return fmt.Errorf("Unable to retrieve cluster Template: %v", err)
}
if template != nil && !util.Contains(template.Tags, platformTag) {
util.Warn("Template `%s` [%s] contain no `%s` tag", template.Name, template.Id, platformTag)
}
}
if template == nil {
if !autoCreateTemplate {
return fmt.Errorf("No cluster Template found by `%s`", templateSelector)
}
asset := fmt.Sprintf("%s/%s-cluster-template.json.template", requestsBindata, kind)
templateBytes, err := clusterTemplateFiles.ReadFile(asset)
if err != nil {
return fmt.Errorf("No %s embedded: %v", asset, err)
}
var templateRequest StackTemplateRequest
err = json.Unmarshal(templateBytes, &templateRequest)
if err != nil {
return fmt.Errorf("Unable to unmarshall JSON into Template request: %v", err)
}
// TODO with createNewTemplate = true we can get a 400 HTTP
// due to duplicate Template name which should be unique across organization
templateRequest.Name = templateSelector // let use user-supplied selector as Template name, hope it's not id
templateRequest.Tags = []string{platformTag}
templateRequest.TeamsPermissions = environment.TeamsPermissions // copy permissions from Environment
templateRequest.ComponentsEnabled = clusterComponents(options)
template, err = createTemplate(templateRequest)
if err != nil {
return fmt.Errorf("Unable to create cluster Template: %v", err)
}
err = initTemplate(template.Id)
if err != nil {
return fmt.Errorf("Unable to initialize cluster Template: %v", err)
}
if config.Verbose {
log.Printf("Created %s cluster template `%s`", kind, template.Name)
}
}
asset := fmt.Sprintf("%s/%s-cluster-instance.json.template", requestsBindata, kind)
instanceBytes, err := clusterInstanceFiles.ReadFile(asset)
if err != nil {
return fmt.Errorf("No %s embedded: %v", asset, err)
}
var instanceRequest StackInstanceRequest
err = json.Unmarshal(instanceBytes, &instanceRequest)
if err != nil {
return fmt.Errorf("Unable to unmarshall JSON into Stack Instance request: %v", err)
}
instanceRequest.Name = name
instanceRequest.Tags = template.Tags
instanceRequest.Environment = environment.Id
instanceRequest.Template = template.Id
parameters := make([]Parameter, 0, len(instanceRequest.Parameters))
for _, p := range instanceRequest.Parameters {
rm := false
switch p.Name {
case "dns.name":
p.Value = name
case "dns.domain":
p.Value = fqdn
case "cloud.region":
if nativeRegion != "" {
p.Value = nativeRegion
} else {
rm = true
}
case "cloud.availabilityZone":
if nativeZone != "" && !strings.Contains(nativeZone, ",") {
p.Value = nativeZone
} else {
rm = true
}
case "cloud.availabilityZones":
if nativeZone != "" && strings.Contains(nativeZone, ",") {
p.Value = nativeZone
} else {
rm = true
}
case "component.kubernetes.eks.admin":
p.Value = eksAdmin
case "component.kubernetes.eks.cluster", "component.kubernetes.gke.cluster", "component.kubernetes.aks.cluster":
p.Value = nativeClusterName
case "cloud.azureResourceGroupName":
if azureResourceGroup != "" {
p.Value = azureResourceGroup
} else {
rm = true
}
case "component.kubernetes.worker.size", "component.kubernetes.gke.nodeMachineType": // TODO worker.instance.size
p.Value = options.InstanceType
case "component.kubernetes.worker.count", "component.kubernetes.gke.minNodeCount":
p.Value = options.Count
case "component.kubernetes.worker.maxCount", "component.kubernetes.gke.maxNodeCount":
if options.MaxCount != 0 {
p.Value = options.MaxCount
} else {
rm = true
}
case "component.kubernetes.worker.volume.size":
if options.VolumeSize != 0 {
p.Value = options.VolumeSize
} else {
rm = true
}
case "component.kubernetes.worker.spotPrice": // TODO worker.aws.spotPrice
if options.SpotPrice > 0 {
p.Value = options.SpotPrice
}
case "component.kubernetes.gke.preemptibleNodes": // TODO worker.gcp.preemptible.enabled
p.Value = options.PreemptibleVMs
}
p = clusterOptions(p, options)
if !rm {
parameters = append(parameters, p)
}
}
instanceRequest.Parameters = parameters
instance, err := createStackInstance(instanceRequest)
if err != nil {
return fmt.Errorf("Unable to create cluster Stack Instance: %v", err)
}
_, err = commandStackInstance(instance.Id, "deploy", nil, waitAndTailDeployLogs, dryRun)
if err != nil {
return fmt.Errorf("Unable to deploy cluster Stack Instance: %v", err)
}
return nil
}
func ImportKubernetes(kind, name, environment, template string,
autoCreateTemplate, createNewTemplate, waitAndTailDeployLogs, dryRun bool,
pems io.Reader, clusterBearerToken,
nativeRegion, nativeZone, nativeEndpoint, nativeClusterName,
ingressIpOrHost, azureResourceGroup string,
options ClusterOptions) {
err := errors.New("Not implemented")
if importConfig, exist := importConfigs[kind]; exist {
err = importK8s(importConfig, kind, name, environment, template,
autoCreateTemplate, createNewTemplate, waitAndTailDeployLogs, dryRun,
pems, clusterBearerToken,
nativeRegion, nativeZone, nativeEndpoint, nativeClusterName,
ingressIpOrHost, azureResourceGroup,
options)
}
if err != nil {
log.Fatalf("Unable to import `%s` Kubernetes: %v", kind, err)
}
}
func importK8s(importConfig ImportConfig, kind, name, environmentSelector, templateSelector string,
autoCreateTemplate, createNewTemplate, waitAndTailDeployLogs, dryRun bool,
pems io.Reader, clusterBearerToken,
nativeRegion, nativeZone, nativeEndpoint, nativeClusterName,
ingressIpOrHost, azureResourceGroup string,
options ClusterOptions) error {
environment, err := environmentBy(environmentSelector)
if err != nil {
return fmt.Errorf("Unable to retrieve Environment: %v", err)
}
cloudAccount, err := cloudAccountById(environment.CloudAccount, false)
if err != nil {
return fmt.Errorf("Unable to retrieve Cloud Account: %v", err)
}
err = verifyClusterCloudAccountKind(kind, cloudAccount)
if err != nil {
return err
}
name, fqdn, err := verifyClusterBaseDomain(name, cloudAccount)
if err != nil {
return err
}
ingressIp := ""
ingressHost := ""
if kind == "eks" {
if nativeEndpoint == "" {
cac, err := awsCloudAccountCredentials(cloudAccount.Id)
if err != nil {
return err
}
regions := make([]string, 0, 2)
caRegion := cloudAccountRegion(cloudAccount)
if caRegion != "" {
if config.Debug {
log.Printf("Cloud Account `%s` default region is `%s`", cloudAccount.Id, caRegion)
}
regions = append(regions, caRegion)
}
if config.AwsRegion != "" && !util.Contains(regions, config.AwsRegion) {
regions = append(regions, config.AwsRegion)
}
for _, region := range regions {
endpoint, ca, err := aws.DescribeEKSClusterWithStaticCredentials(region, nativeClusterName,
cac.AccessKey, cac.SecretKey, cac.SessionToken)
if err != nil {
util.Warn("Unable to retrieve EKS cluster `%s` info in `%s` region: %v",
nativeClusterName, region, err)
continue
}
if endpoint != "" {
if config.Verbose {
log.Printf("Found EKS cluster `%s` in %s region with endpoint %s",
nativeClusterName, region, endpoint)
}
if strings.HasPrefix(endpoint, "https://") && len(endpoint) > 8 {
endpoint = endpoint[8:]
}
nativeEndpoint = endpoint
if len(ca) > 0 {
pems = bytes.NewReader(ca)
}
break
}
}
if nativeEndpoint == "" {
log.Fatal("EKS cluster endpoint (--eks-endpoint) must be provided")
}
}
} else if kind == "hybrid" {
if ingressIpOrHost == "" {
parts := strings.Split(nativeEndpoint, ":")
if len(parts) > 0 {
ingressIpOrHost = parts[0]
}
if ingressIpOrHost == "" {
log.Fatalf("Cannot determine ingress IP/hostname from API endpoint `%s`", nativeEndpoint)
}
}
if net.ParseIP(ingressIpOrHost) != nil {
ingressIp = ingressIpOrHost
} else {
ingressHost = ingressIpOrHost
}
}
secrets, err := readImportSecrets(importConfig.SecretsOrder, pems)
if err != nil {
if !(kind == "openshift" && secrets != nil) { // OpenShift CA is optional
return fmt.Errorf("Unable to read auth secrets: %v", err)
}
}
hasCaCert := false
for _, s := range secrets {
if s.Name == "kubernetes.api.caCert" {
hasCaCert = true
break
}
}
adapterTag := "adapter=" + kind
if templateSelector == "" && autoCreateTemplate {
templateSelector = fmt.Sprintf(importConfig.TemplateNameFormat, environment.Name)
}
var template *StackTemplate
if templateSelector != "" && !createNewTemplate {
template, err = templateBy(templateSelector)
if err != nil && !strings.HasSuffix(err.Error(), " found") {
return fmt.Errorf("Unable to retrieve adapter Template: %v", err)
}
if template != nil && !util.Contains(template.Tags, adapterTag) {
util.Warn("Template `%s` [%s] contain no `%s` tag", template.Name, template.Id, adapterTag)
}
}
if template == nil {
if !autoCreateTemplate {
return fmt.Errorf("No adapter Template found by `%s`", templateSelector)
}
asset := fmt.Sprintf("%s/%s-adapter-template.json.template", requestsBindata, kind)
templateBytes, err := adapterTemplateFiles.ReadFile(asset)
if err != nil {
return fmt.Errorf("No %s embedded: %v", asset, err)
}
var templateRequest StackTemplateRequest
err = json.Unmarshal(templateBytes, &templateRequest)
if err != nil {
return fmt.Errorf("Unable to unmarshall JSON into Template request: %v", err)
}
// TODO with createNewTemplate = true we can get a 400 HTTP
// due to duplicate Template name which should be unique across organization
templateRequest.Name = templateSelector // let use user-supplied selector as Template name, hope it's not id
templateRequest.Tags = []string{adapterTag}
templateRequest.TeamsPermissions = environment.TeamsPermissions // copy permissions from Environment
templateRequest.ComponentsEnabled = clusterComponents(options)
template, err = createTemplate(templateRequest)
if err != nil {
return fmt.Errorf("Unable to create adapter Template: %v", err)
}
err = initTemplate(template.Id)
if err != nil {
return fmt.Errorf("Unable to initialize adapter Template: %v", err)
}
if config.Verbose {
log.Printf("Created %s adapter template `%s`", kind, template.Name)
}
}
asset := fmt.Sprintf("%s/%s-adapter-instance.json.template", requestsBindata, kind)
instanceBytes, err := adapterInstanceFiles.ReadFile(asset)
if err != nil {
return fmt.Errorf("No %s embedded: %v", asset, err)
}
var instanceRequest StackInstanceRequest
err = json.Unmarshal(instanceBytes, &instanceRequest)
if err != nil {
return fmt.Errorf("Unable to unmarshall JSON into Stack Instance request: %v", err)
}
instanceRequest.Name = name
instanceRequest.Tags = template.Tags
instanceRequest.Environment = environment.Id
instanceRequest.Template = template.Id
parameters := make([]Parameter, 0, len(instanceRequest.Parameters))
for _, p := range instanceRequest.Parameters {
rm := false
switch p.Name {
case "dns.domain":
p.Value = fqdn
case "cloud.region":
if nativeRegion != "" {
p.Value = nativeRegion
} else {
rm = true
}
case "cloud.availabilityZone":
if nativeZone != "" {
p.Value = nativeZone
} else {
rm = true
}
case "kubernetes.api.endpoint":
if nativeEndpoint != "" {
p.Value = nativeEndpoint
} else {
rm = true
}
case "kubernetes.api.caCert":
if !hasCaCert {
rm = true
}
case "kubernetes.eks.cluster", "kubernetes.gke.cluster", "kubernetes.aks.cluster":
p.Value = nativeClusterName
case "component.ingress.staticIp":
p.Value = ingressIp
case "component.ingress.staticHost":
p.Value = ingressHost
case "cloud.azureResourceGroupName", "component.kubernetes.aks.resourceGroupName":
if azureResourceGroup != "" {
p.Value = azureResourceGroup
} else {
rm = true
}
}
p = clusterOptions(p, options)
if !rm {
parameters = append(parameters, p)
}
}
instanceRequest.Parameters = parameters
instance, err := createStackInstance(instanceRequest)
if err != nil {
return fmt.Errorf("Unable to create adapter Stack Instance: %v", err)
}
if kind == "openshift" && clusterBearerToken != "" {
secrets = append(secrets,
Secret{"kubernetes.api.token", "bearerToken", map[string]string{"bearerToken": clusterBearerToken}})
}
for _, secret := range secrets {
id, err := createSecret(stackInstancesResource, instance.Id, secret.Name, "", secret.Kind, secret.Values)
if err != nil {
return err
}
if config.Verbose {
log.Printf("Created %s secret with id %s", secret.Name, id)
}
}
_, err = commandStackInstance(instance.Id, "deploy", nil, waitAndTailDeployLogs, dryRun)
if err != nil {
return fmt.Errorf("Unable to deploy adapter Stack Instance: %v", err)
}
return nil
}
func verifyClusterCloudAccountKind(clusterKind string, cloudAccount *CloudAccount) error {
mustBeCloudKind := ""
switch clusterKind {
case "k8s-aws", "eks", "metal", "hybrid", "openshift":
if !strings.HasPrefix(cloudAccount.Kind, "aws") {
mustBeCloudKind = "AWS"
}
case "gke":
if cloudAccount.Kind != "gcp" {
mustBeCloudKind = "GCP"
}
case "aks":
if cloudAccount.Kind != "azure" {
mustBeCloudKind = "Azure"
}
}
if mustBeCloudKind != "" {
return fmt.Errorf("Cloud Account %s is not %s but `%s`", cloudAccount.BaseDomain, mustBeCloudKind, cloudAccount.Kind)
}
return nil
}
func verifyClusterBaseDomain(name string, cloudAccount *CloudAccount) (string, string, error) {
if strings.Contains(name, ".") {
suffix := "." + cloudAccount.BaseDomain
i := strings.LastIndex(name, suffix)
if !strings.HasSuffix(name, suffix) || i < 1 {
return "", "", fmt.Errorf("`%s` looks like FQDN, but Cloud Account base domain is `%s`", name, cloudAccount.BaseDomain)
}
name = name[:i]
}
fqdn := fmt.Sprintf("%s.%s", name, cloudAccount.BaseDomain)
return name, fqdn, nil
}
var pemBlockTypeToSecretKind = map[string]string{
"CERTIFICATE": "certificate",
"RSA PRIVATE KEY": "privateKey",
}
func readImportSecrets(secretsOrder []Secret, pems io.Reader) ([]Secret, error) {
if secretsOrder == nil {
return nil, nil
}
if config.Verbose {
stdin := ""
if pems == os.Stdin {
stdin = " from stdin"
}
log.Printf("Reading TLS auth certs and key%s", stdin)
}
pemBytes, err := io.ReadAll(pems)
if err != nil {
return nil, err
}
var block *pem.Block
secrets := make([]Secret, 0, len(secretsOrder))
for _, secret := range secretsOrder {
block, pemBytes = pem.Decode(pemBytes)
if block == nil {
break
}
if blockKind, exist := pemBlockTypeToSecretKind[block.Type]; !exist || blockKind != secret.Kind {
return nil, fmt.Errorf("Unexpected PEM block `%s` while reading %s %s ",
block.Type, secret.Kind, secret.Name)
}
secret.Values = make(map[string]string)
secret.Values[secret.Kind] = string(pem.EncodeToMemory(block))
secrets = append(secrets, secret)
if len(pemBytes) == 0 {
break
}
}
if len(secrets) < len(secretsOrder)-1 {
err = fmt.Errorf("Expected at least %d secrets, read %d", len(secretsOrder)-1, len(secrets))
}
return secrets, err
}
func clusterComponents(options ClusterOptions) []string {
var components []string
if options.Acm {
components = append(components, "acm")
}
if options.CertManager {
components = append(components, "cert-manager")
}
if options.Autoscaler {
components = append(components, "cluster-autoscaler")
}
if options.KubeDashboard {
components = append(components, "kube-dashboard")
}
return components
}
func clusterOptions(p Parameter, options ClusterOptions) Parameter {
switch p.Name {
case "component.kubernetes-dashboard.rbac.kind":
p.Value = options.KubeDashboardMode
}
return p
}