diff --git a/internal/defaults/env.go b/internal/defaults/env.go new file mode 100644 index 0000000..7f237e0 --- /dev/null +++ b/internal/defaults/env.go @@ -0,0 +1,34 @@ +package defaults + +import ( + "context" + "fmt" + "os" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func Env[T any, R any](envVar string, defaultValue T) R { + return envDefaultValue{ + envVar: envVar, + defaultValue: defaultValue, + } +} + +type envDefaultValue struct { + envVar string + defaultValue string +} + +func (d envDefaultValue) Description(_ context.Context) string { + return fmt.Sprintf("If value is not configured, defaults to a string representation of the provided env variable") +} + +func (d envDefaultValue) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("If value is not configured, defaults to a string representation of the provided env variable") +} + +func (d envDefaultValue) DefaultString(_ context.Context, req defaults.StringRequest, resp *defaults.StringResponse) { + resp.PlanValue = types.StringValue(os.Getenv(d.envVar)) +} diff --git a/internal/model/model.go b/internal/model/model.go index 1221d61..35226b6 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -12,3 +12,28 @@ type Cluster struct { Protect types.Bool `tfsdk:"protect"` Tags types.Map `tfsdk:"tags"` } + +type Kubeconfig struct { + Host types.String `tfsdk:"host"` + Username types.String `tfsdk:"username"` + Password types.String `tfsdk:"password"` + Inscure types.String `tfsdk:"insecure"` + TlsServerName types.String `tfsdk:"tls_server_name"` + ClientCertificate types.String `tfsdk:"client_certificate"` + ClientKey types.String `tfsdk:"client_key"` + ClusterCACertificate types.String `tfsdk:"cluster_ca_certificate"` + ConfigPath types.String `tfsdk:"config_path"` + ConfigContext types.String `tfsdk:"config_context"` + ConfigContextAuthInfo types.String `tfsdk:"config_context_auth_info"` + ConfigContextCluster types.String `tfsdk:"config_context_cluster"` + Token types.String `tfsdk:"token"` + ProxyURL types.String `tfsdk:"proxy_url"` + Exec types.Object `tfsdk:"exec"` +} + +type KubeconfigExec struct { + Command types.String `tfsdk:"command"` + Args types.String `tfsdk:"args"` + Env types.String `tfsdk:"env"` + APIVersion types.String `tfsdk:"api_version"` +} diff --git a/internal/resource/cluster_kubeconfig.go b/internal/resource/cluster_kubeconfig.go index 80c6d11..42aff3f 100644 --- a/internal/resource/cluster_kubeconfig.go +++ b/internal/resource/cluster_kubeconfig.go @@ -1,10 +1,23 @@ package resource import ( + "bytes" + "fmt" + "os" + "sync" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mitchellh/go-homedir" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + + "terraform-provider-plural/internal/model" ) func kubeconfigAttribute() schema.SingleNestedAttribute { @@ -13,10 +26,12 @@ func kubeconfigAttribute() schema.SingleNestedAttribute { Attributes: map[string]schema.Attribute{ "host": schema.StringAttribute{ Optional: true, + //Default: defaults.EnvString("PLURAL_KUBE_HOST", ""), MarkdownDescription: "The address of the Kubernetes clusters. Can be sourced from `PLURAL_KUBE_HOST`.", }, "username": schema.StringAttribute{ Optional: true, + //Default: defaults.EnvString("PLURAL_KUBE_HOST", ""), MarkdownDescription: "The username for basic authentication to the Kubernetes cluster. Can be sourced from `PLURAL_KUBE_USER`.", }, "password": schema.StringAttribute{ @@ -101,3 +116,175 @@ func kubeconfigAttribute() schema.SingleNestedAttribute { }, } } + +// KubeConfig is a RESTClientGetter interface implementation +type KubeConfig struct { + ClientConfig clientcmd.ClientConfig + + Burst int + + sync.Mutex +} + +// ToRESTConfig implemented interface method +func (k *KubeConfig) ToRESTConfig() (*rest.Config, error) { + config, err := k.ToRawKubeConfigLoader().ClientConfig() + return config, err +} + +// ToDiscoveryClient implemented interface method +func (k *KubeConfig) ToDiscoveryClient() (discovery.DiscoveryInterface, error) { + config, err := k.ToRESTConfig() + if err != nil { + return nil, err + } + + // The more groups you have, the more discovery requests you need to make. + // given 25 groups (our groups + a few custom resources) with one-ish version each, discovery needs to make 50 requests + // double it just so we don't end up here again for a while. This config is only used for discovery. + config.Burst = k.Burst + + return discovery.NewDiscoveryClientForConfigOrDie(config), nil +} + +// ToRESTMapper implemented interface method +func (k *KubeConfig) ToRESTMapper() (meta.RESTMapper, error) { + discoveryClient, err := k.ToDiscoveryClient() + if err != nil { + return nil, err + } + + mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient) + expander := restmapper.NewShortcutExpander(mapper, discoveryClient) + return expander, nil +} + +// ToRawKubeConfigLoader implemented interface method +func (k *KubeConfig) ToRawKubeConfigLoader() clientcmd.ClientConfig { + return k.ClientConfig +} + +func newKubeConfig(configData *model.Kubeconfig, namespace *string) (*KubeConfig, error) { + overrides := &clientcmd.ConfigOverrides{} + loader := &clientcmd.ClientConfigLoadingRules{} + + if v := os.Getenv("PLURAL_KUBE_CONFIG_PATH"); v != "" { + configData.ConfigPath = types.StringValue(v) + } + + if len(configPaths) > 0 { + expandedPaths := []string{} + for _, p := range configPaths { + path, err := homedir.Expand(p) + if err != nil { + return nil, err + } + + log.Printf("[DEBUG] Using kubeconfig: %s", path) + expandedPaths = append(expandedPaths, path) + } + + if len(expandedPaths) == 1 { + loader.ExplicitPath = expandedPaths[0] + } else { + loader.Precedence = expandedPaths + } + + ctx, ctxOk := k8sGetOk(configData, "config_context") + authInfo, authInfoOk := k8sGetOk(configData, "config_context_auth_info") + cluster, clusterOk := k8sGetOk(configData, "config_context_cluster") + if ctxOk || authInfoOk || clusterOk { + if ctxOk { + overrides.CurrentContext = ctx.(string) + log.Printf("[DEBUG] Using custom current context: %q", overrides.CurrentContext) + } + + overrides.Context = clientcmdapi.Context{} + if authInfoOk { + overrides.Context.AuthInfo = authInfo.(string) + } + if clusterOk { + overrides.Context.Cluster = cluster.(string) + } + log.Printf("[DEBUG] Using overidden context: %#v", overrides.Context) + } + } + + // Overriding with static configuration + if v, ok := k8sGetOk(configData, "insecure"); ok { + overrides.ClusterInfo.InsecureSkipTLSVerify = v.(bool) + } + if v, ok := k8sGetOk(configData, "tls_server_name"); ok { + overrides.ClusterInfo.TLSServerName = v.(string) + } + if v, ok := k8sGetOk(configData, "cluster_ca_certificate"); ok { + overrides.ClusterInfo.CertificateAuthorityData = bytes.NewBufferString(v.(string)).Bytes() + } + if v, ok := k8sGetOk(configData, "client_certificate"); ok { + overrides.AuthInfo.ClientCertificateData = bytes.NewBufferString(v.(string)).Bytes() + } + if v, ok := k8sGetOk(configData, "host"); ok { + // Server has to be the complete address of the kubernetes cluster (scheme://hostname:port), not just the hostname, + // because `overrides` are processed too late to be taken into account by `defaultServerUrlFor()`. + // This basically replicates what defaultServerUrlFor() does with config but for overrides, + // see https://github.com/kubernetes/client-go/blob/v12.0.0/rest/url_utils.go#L85-L87 + hasCA := len(overrides.ClusterInfo.CertificateAuthorityData) != 0 + hasCert := len(overrides.AuthInfo.ClientCertificateData) != 0 + defaultTLS := hasCA || hasCert || overrides.ClusterInfo.InsecureSkipTLSVerify + host, _, err := rest.DefaultServerURL(v.(string), "", apimachineryschema.GroupVersion{}, defaultTLS) + if err != nil { + return nil, err + } + + overrides.ClusterInfo.Server = host.String() + } + if v, ok := k8sGetOk(configData, "username"); ok { + overrides.AuthInfo.Username = v.(string) + } + if v, ok := k8sGetOk(configData, "password"); ok { + overrides.AuthInfo.Password = v.(string) + } + if v, ok := k8sGetOk(configData, "client_key"); ok { + overrides.AuthInfo.ClientKeyData = bytes.NewBufferString(v.(string)).Bytes() + } + if v, ok := k8sGetOk(configData, "token"); ok { + overrides.AuthInfo.Token = v.(string) + } + + if v, ok := k8sGetOk(configData, "proxy_url"); ok { + overrides.ClusterDefaults.ProxyURL = v.(string) + } + + if v, ok := k8sGetOk(configData, "exec"); ok { + exec := &clientcmdapi.ExecConfig{} + if spec, ok := v.([]interface{})[0].(map[string]interface{}); ok { + exec.InteractiveMode = clientcmdapi.IfAvailableExecInteractiveMode + exec.APIVersion = spec["api_version"].(string) + exec.Command = spec["command"].(string) + exec.Args = expandStringSlice(spec["args"].([]interface{})) + for kk, vv := range spec["env"].(map[string]interface{}) { + exec.Env = append(exec.Env, clientcmdapi.ExecEnvVar{Name: kk, Value: vv.(string)}) + } + } else { + log.Printf("[ERROR] Failed to parse exec") + return nil, fmt.Errorf("failed to parse exec") + } + overrides.AuthInfo.Exec = exec + } + + overrides.Context.Namespace = "default" + + if namespace != nil { + overrides.Context.Namespace = *namespace + } + burstLimit := configData.Get("burst_limit").(int) + + client := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loader, overrides) + if client == nil { + log.Printf("[ERROR] Failed to initialize kubernetes config") + return nil, nil + } + log.Printf("[INFO] Successfully initialized kubernetes config") + + return &KubeConfig{ClientConfig: client, Burst: burstLimit}, nil +}