cmd/hub/lifecycle/requirement.go (468 lines of code) (raw):
// Copyright (c) 2023 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/.
package lifecycle
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"regexp"
"strings"
"github.com/epam/hubctl/cmd/hub/config"
"github.com/epam/hubctl/cmd/hub/kube"
"github.com/epam/hubctl/cmd/hub/manifest"
"github.com/epam/hubctl/cmd/hub/parameters"
"github.com/epam/hubctl/cmd/hub/util"
version "github.com/hashicorp/go-version"
)
const (
providedByEnv = "*environment*"
gcpServiceAccountsHelp = "https://cloud.google.com/docs/authentication/provide-credentials-adc"
azureGoSdkAuthHelp = "https://docs.microsoft.com/en-us/go/azure/azure-sdk-go-authorization"
)
var (
supportedCloudRequires = []string{"aws", "azure", "gcp", "gcs"}
guessedEnabledClouds []string
)
func prepareComponentRequires(provided map[string][]string, componentManifest *manifest.Manifest,
parameters parameters.LockedParameters, outputs parameters.CapturedOutputs,
maybeOptional map[string][]string, enabledClouds []string) ([]string, error) {
componentRequires := maybeOmitCloudRequires(componentManifest.Requires, enabledClouds)
setups := make([]util.Tuple2, 0, len(componentRequires))
optionalNotProvided := make([]string, 0)
componentName := manifest.ComponentQualifiedNameFromMeta(&componentManifest.Meta)
for _, req := range componentRequires {
by, exist := provided[req]
if !exist || len(by) == 0 {
if optionalFor, exist := maybeOptional[req]; exist &&
(util.Contains(optionalFor, componentName) || util.Contains(optionalFor, "*")) {
optionalNotProvided = append(optionalNotProvided, req)
if config.Verbose {
log.Printf("Optional requirement `%s` is not provided", req)
}
continue
}
err := fmt.Errorf("Component `%s` requires `%s` but only following provides are currently known:\n%s",
componentName, strings.Join(componentRequires, ", "), util.SprintDeps(provided))
return optionalNotProvided, err
}
if config.Debug && len(by) == 1 {
log.Printf("Requirement `%s` provided by `%s`", req, by[0])
}
provider := by[len(by)-1]
if config.Verbose && len(by) > 1 {
log.Printf("Requirement `%s` provided by multiple components `%s`, only `%s` will be used",
req, strings.Join(by, ", "), provider)
}
setups = append(setups, util.Tuple2{req, provider})
}
if len(optionalNotProvided) == 0 {
for _, setup := range setups {
setupRequirement(setup.S1, setup.S2, parameters, outputs)
}
}
return optionalNotProvided, nil
}
func maybeOmitCloudRequires(requires, enabledClouds []string) []string {
if !util.ContainsAny(supportedCloudRequires, requires) {
return requires
}
if len(enabledClouds) == 0 {
enabledClouds = guessEnabledClouds()
}
if len(enabledClouds) == 0 {
util.WarnOnce("Unable to autodetect enabled clouds, try `--clouds` if cloud access is failing")
return requires
}
if util.Contains(enabledClouds, "gcp") {
enabledClouds = append(enabledClouds, "gcs")
}
modified := make([]string, 0, len(requires))
for _, r := range requires {
if !util.Contains(supportedCloudRequires, r) || util.Contains(enabledClouds, r) {
modified = append(modified, r)
}
}
return modified
}
func guessEnabledClouds() []string {
if guessedEnabledClouds != nil {
return guessedEnabledClouds
}
var clouds []string
// TODO probe meta-data server
if util.MaybeEnv([]string{"AWS_PROFILE", "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID"}) {
clouds = append(clouds, "aws")
}
if util.MaybeEnv([]string{"AZURE_AUTH_LOCATION", "AZURE_TENANT_ID", "AZURE_RESOURCE_GROUP_NAME"}) {
clouds = append(clouds, "azure")
}
if util.MaybeEnv([]string{"GOOGLE_APPLICATION_CREDENTIALS"}) {
clouds = append(clouds, "gcp")
}
if config.Debug {
detected := "(none)"
if len(clouds) > 0 {
detected = strings.Join(clouds, ", ")
}
log.Printf("Autodetected clouds: %s", detected)
}
guessedEnabledClouds = clouds
return clouds
}
func setupRequirement(requirement string, provider string,
parameters parameters.LockedParameters, outputs parameters.CapturedOutputs) {
switch requirement {
case "kubectl", "kubernetes":
kube.SetupKubernetes(parameters, provider, outputs, "", false, false)
case "aws", "azure", "arm", "gcp", "gcs",
"tiller", "external-dns", "cert-manager",
"helm", "terraform", "etcd", "vault", "ingress", "tls-ingress", "istio":
wellKnown, err := checkRequire(requirement)
if wellKnown {
if err != nil {
log.Fatalf("`%s` requirement cannot be satisfied: %v", requirement, err)
}
} else {
if config.Verbose {
log.Printf("Assuming `%s` requirement is setup", requirement)
}
}
default:
if config.Verbose {
log.Printf("Don't know how to setup requirement `%s`", requirement)
}
}
}
var bins = map[string][]string{
"aws": {"aws", "--version"},
"azure": {"az", "version"},
"gcp": {"gcloud", "version"},
"gcs": {"gsutil", "version"},
"kubectl": {"kubectl", "version", "--client", "--output=json"},
"kubernetes": {"kubectl", "version", "--client", "--output=json"},
"vault": {"vault", "version"},
"helm": {"helm", "version"},
"terraform": {"terraform", "version"},
}
type BinVersion struct {
minVersion *version.Version
versionRegexp *regexp.Regexp
}
func semver(s string) *version.Version {
v, err := version.NewVersion(s)
if err != nil {
log.Printf("Invalid version: %s", s)
}
return v
}
var binVersion = map[string]*BinVersion{
"aws": {semver("2.10.0"), regexp.MustCompile(`aws-cli/([\d.]+)`)},
"az": {semver("2.40.0"), regexp.MustCompile(`"azure-cli": "([\d.]+)"`)},
"gcloud": {semver("400.0.0"), regexp.MustCompile(`Google Cloud SDK ([\d.]+)`)},
"gsutil": {semver("5.0"), regexp.MustCompile(`version: ([\d.]+)`)},
"vault": {semver("1.10"), regexp.MustCompile(`Vault v([\d.]+)`)},
"kubectl": {semver("1.19"), regexp.MustCompile(`"gitVersion": "v([\d.]+)"`)},
"helm": {semver("3.11"), regexp.MustCompile(`(?:SemVer|Version):"v([\d.]+)"`)},
"terraform": {semver("1.0"), regexp.MustCompile(`Terraform v([\d.]+)`)},
}
func checkStackRequires(requires []string, optional, requiresOfOptionalComponents map[string][]string) map[string][]string {
provided := make(map[string][]string, len(requires))
for _, require := range requires {
_, err := checkRequire(require)
if err == nil {
provided[require] = []string{providedByEnv}
continue
}
if config.Verbose {
log.Printf("`%s` requirement cannot be satisfied: %v", require, err)
}
if requiredByOptional, exist := requiresOfOptionalComponents[require]; exist {
if config.Verbose {
log.Printf("Skipping requirement `%s` requested by optional components %v", require, requiredByOptional)
}
continue
}
if optionalFor, exist := optional[require]; exist {
if config.Verbose {
log.Printf("Skipping requirement `%s` as it is optional for %v", require, optionalFor)
}
continue
}
os.Exit(1)
}
return provided
}
var requirementsVerified = make(map[string]struct{})
func checkRequire(require string) (bool, error) {
if _, exist := requirementsVerified[require]; exist {
return true, nil
}
switch require {
case "azure", "arm":
err := checkRequiresAzure()
if err != nil {
return true, err
}
bin := bins["azure"]
_, err = checkRequiresBin(bin...)
if err != nil {
util.WarnOnce("Error accessing `%s` binary: %v", bin[0], err)
}
setupTerraformAzureOsEnv()
case "aws", "gcp", "gcs", "kubectl", "kubernetes", "helm", "terraform", "vault": // "etcd"
bin, exist := bins[require]
if !exist {
bin = []string{require, "version"}
}
out, err := checkRequiresBin(bin...)
if err != nil {
return true, err
}
verReq, exist := binVersion[bin[0]]
if exist {
err := checkRequiresBinVersion(verReq, out)
if err != nil {
util.WarnOnce("`%s` version requirement cannot be satisfied: %s: %v; update `%[1]s` binary?",
bin[0], require, err)
}
}
if require == "gcp" || require == "gcs" {
err := checkRequiresGcp()
if err != nil {
return true, err
}
}
default:
return false, errors.New("no implementation")
}
requirementsVerified[require] = struct{}{}
return true, nil
}
func checkRequiresBin(bin ...string) ([]byte, error) {
if config.Debug {
printCmd(bin)
}
cmd := exec.Command(bin[0], bin[1:]...)
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
err = fmt.Errorf("%v: %v", bin, err)
}
if config.Trace && len(out) > 0 {
fmt.Printf("%s", out)
}
return out, err
}
// validates binary version against minimum required version
//
// reqVer: required version and regexp to extract version string
// out: raw output from binary
//
// returns error version is not valid
func checkRequiresBinVersion(reqVer *BinVersion, out []byte) error {
if len(out) == 0 {
return errors.New("no output")
}
match := reqVer.versionRegexp.FindSubmatch(out)
if len(match) != 2 {
return errors.New("no version string found")
}
currVer, err := version.NewVersion(string(match[1]))
if err != nil {
return err
}
if currVer.LessThan(reqVer.minVersion) {
return fmt.Errorf("`%s` version detected; should have at least version `%s`", currVer, reqVer.minVersion.String())
}
return nil
}
func checkRequiresAzure() error {
out, err := checkRequiresBin("az", "storage", "account", "list", "-o", "table")
if err == nil {
return nil
}
if !bytes.Contains(out, []byte("az login")) {
return err
}
tenantId := os.Getenv("AZURE_TENANT_ID")
if tenantId == "" {
return fmt.Errorf("AZURE_TENANT_ID is not set, see %s", azureGoSdkAuthHelp)
}
clientId := os.Getenv("AZURE_CLIENT_ID")
if clientId == "" {
return fmt.Errorf("AZURE_CLIENT_ID is not set, see %s", azureGoSdkAuthHelp)
}
clientSecret := os.Getenv("AZURE_CLIENT_SECRET")
if clientSecret == "" {
clientSecret = os.Getenv("AZURE_CERTIFICATE_PATH")
if clientSecret == "" {
return fmt.Errorf("No AZURE_CLIENT_SECRET, nor AZURE_CERTIFICATE_PATH is set, see %s", azureGoSdkAuthHelp)
}
}
_, err = checkRequiresBin("az", "login", "--service-principal",
"--tenant", tenantId, "--username", clientId, "--password", clientSecret)
return err
// TODO az login --identity
// https://docs.microsoft.com/en-us/go/azure/azure-sdk-go-authorization
// Also, SDK supports AZURE_AUTH_LOCATION
}
func setupTerraformAzureOsEnv() {
if os.Getenv("ARM_CLIENT_ID") != "" {
return
}
if config.Debug {
log.Print("Setting Terraform ARM_* variables for Azure provider")
}
if os.Getenv("ARM_ACCESS_KEY") == "" {
vars := []string{"AZURE_STORAGE_ACCESS_KEY", "AZURE_STORAGE_KEY"}
for _, v := range vars {
key := os.Getenv(v)
if key != "" {
if config.Trace {
log.Printf("Setting ARM_ACCESS_KEY=%s", key)
}
os.Setenv("ARM_ACCESS_KEY", key)
break
}
}
}
for _, v := range []string{"ARM_SUBSCRIPTION_ID", "ARM_CLIENT_ID", "ARM_CLIENT_SECRET", "ARM_SUBSCRIPTION_ID", "ARM_TENANT_ID"} {
src := "AZURE" + v[3:]
if value := os.Getenv(src); value != "" {
if config.Trace {
log.Printf("Setting %s=%s", v, value)
}
os.Setenv(v, value)
}
}
// TODO ARM_USE_MSI ARM_ENVIRONMENT?
// https://www.terraform.io/docs/backends/types/azurerm.html
}
func checkRequiresGcp() error {
out, err := checkRequiresBin("gcloud", "auth", "list")
if err != nil {
return err
}
if !bytes.Contains(out, []byte("gcloud auth login")) {
return nil
}
credsFile := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
if credsFile == "" {
return fmt.Errorf("GOOGLE_APPLICATION_CREDENTIALS is not set, see %s", gcpServiceAccountsHelp)
}
_, err = checkRequiresBin("gcloud", "auth", "activate-service-account", "--key-file", credsFile)
if err != nil {
return err
}
jsonData, err := os.ReadFile(credsFile)
if err != nil {
return fmt.Errorf("Unable to read `%s`: %v", credsFile, err)
}
var creds map[string]string
err = json.Unmarshal(jsonData, &creds)
if err != nil {
return fmt.Errorf("Unable to unmarshall `%s`: %v", credsFile, err)
}
credsProject, existInCredsFile := creds["project_id"]
out, err = checkRequiresBin("gcloud", "config", "get-value", "project")
if err != nil {
return err
}
gcloudProject := strings.Trim(string(out), " \r\n")
projectUnset := "(unset)"
if !existInCredsFile {
if gcloudProject == projectUnset {
util.WarnOnce("No `project_id` found in `%s` and no gcloud project is set in config - gcloud will fail until --project is specifed inline",
credsFile)
} else {
if config.Debug {
log.Printf("Using gcloud pre-configured `%s` project id", gcloudProject)
}
}
return nil
}
if gcloudProject != credsProject {
if gcloudProject == projectUnset || config.Force {
if config.Force && gcloudProject != projectUnset {
util.Warn("Setting gcloud project to `%s`; was `%s`", credsProject, gcloudProject)
}
_, err = checkRequiresBin("gcloud", "config", "set", "project", credsProject)
if err != nil {
return err
}
} else {
util.WarnOnce("Using gcloud pre-configured `%s` project id that is different from service account credentials `%s` project id (%s)",
gcloudProject, credsProject, credsFile)
}
}
return nil
}
func noEnvironmentProvides(provides map[string][]string) map[string][]string {
filtered := make(map[string][]string)
for p, by := range provides {
by = util.Omit(by, providedByEnv)
if len(by) > 0 {
filtered[p] = by
}
}
return filtered
}
func parseRequiresTunning(requires manifest.RequiresTuning) map[string][]string {
optional := make(map[string][]string)
for _, req := range requires.Optional {
i := strings.Index(req, ":")
if i > 0 && i < len(req)-1 {
component := req[i+1:]
req = req[:i]
util.AppendMapList(optional, req, component)
} else if i == -1 {
util.AppendMapList(optional, req, "*")
}
}
return optional
}
var falseParameterValues = []string{"", "false", "0", "no", "(unknown)"}
func calculateOptionalFalseParameters(componentName string, params parameters.LockedParameters, optionalRequires map[string][]string) []string {
falseParameters := make([]string, 0)
for term, optionalForList := range optionalRequires {
if strings.Contains(term, ".") { // looks like a parameter
for _, optionalFor := range optionalForList {
if optionalFor == "*" || optionalFor == componentName {
parameterExists := false
for _, p := range params {
if p.Name == term && (p.Component == "" || p.Component == componentName) {
parameterExists = true
if util.Contains(falseParameterValues, util.String(p.Value)) {
falseParameters = append(falseParameters, p.QName())
if optionalFor == "*" {
util.WarnOnce("Optional parameter `lifecycle.requires.optional = %s` targets all components as wildcard;\n\tYou may want to narrow specification to `%[1]s:component`",
term)
}
}
}
}
if !parameterExists && optionalFor != "*" {
falseParameters = append(falseParameters, term)
}
}
}
}
}
return falseParameters
}
func calculateRequiresOfOptionalComponents(componentManifests []manifest.Manifest, lifecycle *manifest.Lifecycle, requires []string) map[string][]string {
var optionalRequirements []string
for _, requirement := range requires {
optional := true
for _, componentManifest := range componentManifests {
if util.Contains(componentManifest.Requires, requirement) {
componentName := manifest.ComponentQualifiedNameFromMeta(&componentManifest.Meta)
if !optionalComponent(lifecycle, componentName) {
optional = false
break
}
}
}
if optional {
optionalRequirements = append(optionalRequirements, requirement)
}
}
requiredBy := make(map[string][]string)
for _, componentManifest := range componentManifests {
for _, requirement := range util.Union(optionalRequirements, componentManifest.Requires) {
componentName := manifest.ComponentQualifiedNameFromMeta(&componentManifest.Meta)
util.AppendMapList(requiredBy, requirement, componentName)
}
}
return requiredBy
}