internal/clients/api_client.go (511 lines of code) (raw):
package clients
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/disaster37/go-kibana-rest/v8"
"github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/terraform-provider-elasticstack/generated/alerting"
"github.com/elastic/terraform-provider-elasticstack/generated/connectors"
"github.com/elastic/terraform-provider-elasticstack/generated/slo"
"github.com/elastic/terraform-provider-elasticstack/internal/clients/config"
"github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet"
"github.com/elastic/terraform-provider-elasticstack/internal/clients/kibana_oapi"
"github.com/elastic/terraform-provider-elasticstack/internal/models"
"github.com/elastic/terraform-provider-elasticstack/internal/utils"
"github.com/hashicorp/go-version"
fwdiags "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider"
)
type CompositeId struct {
ClusterId string
ResourceId string
}
func CompositeIdFromStr(id string) (*CompositeId, diag.Diagnostics) {
var diags diag.Diagnostics
idParts := strings.Split(id, "/")
if len(idParts) != 2 {
diags = append(diags, diag.Diagnostic{
Severity: diag.Error,
Summary: "Wrong resource ID.",
Detail: "Resource ID must have following format: <cluster_uuid>/<resource identifier>",
})
return nil, diags
}
return &CompositeId{
ClusterId: idParts[0],
ResourceId: idParts[1],
},
diags
}
func CompositeIdFromStrFw(id string) (*CompositeId, fwdiags.Diagnostics) {
composite, diags := CompositeIdFromStr(id)
return composite, utils.ConvertSDKDiagnosticsToFramework(diags)
}
func ResourceIDFromStr(id string) (string, diag.Diagnostics) {
compID, diags := CompositeIdFromStr(id)
if diags.HasError() {
return "", diags
}
return compID.ResourceId, nil
}
func (c *CompositeId) String() string {
return fmt.Sprintf("%s/%s", c.ClusterId, c.ResourceId)
}
type ApiClient struct {
elasticsearch *elasticsearch.Client
elasticsearchClusterInfo *models.ClusterInfo
kibana *kibana.Client
kibanaOapi *kibana_oapi.Client
alerting alerting.AlertingAPI
connectors *connectors.Client
slo slo.SloAPI
kibanaConfig kibana.Config
fleet *fleet.Client
version string
}
func NewApiClientFuncFromSDK(version string) func(context.Context, *schema.ResourceData) (interface{}, diag.Diagnostics) {
return func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
return newApiClientFromSDK(d, version)
}
}
func NewAcceptanceTestingClient() (*ApiClient, error) {
version := "tf-acceptance-testing"
cfg := config.NewFromEnv(version)
es, err := elasticsearch.NewClient(*cfg.Elasticsearch)
if err != nil {
return nil, err
}
kib, err := kibana.NewClient(*cfg.Kibana)
if err != nil {
return nil, err
}
kibanaHttpClient := kib.Client.GetClient()
actionConnectors, err := buildConnectorsClient(cfg, kibanaHttpClient)
if err != nil {
return nil, fmt.Errorf("cannot create Kibana action connectors client: [%w]", err)
}
kibOapi, err := kibana_oapi.NewClient(*cfg.KibanaOapi)
if err != nil {
return nil, err
}
fleetClient, err := fleet.NewClient(*cfg.Fleet)
if err != nil {
return nil, err
}
return &ApiClient{
elasticsearch: es,
kibana: kib,
kibanaOapi: kibOapi,
alerting: buildAlertingClient(cfg, kibanaHttpClient).AlertingAPI,
slo: buildSloClient(cfg, kibanaHttpClient).SloAPI,
connectors: actionConnectors,
kibanaConfig: *cfg.Kibana,
fleet: fleetClient,
version: version,
},
nil
}
func NewApiClientFromFramework(ctx context.Context, cfg config.ProviderConfiguration, version string) (*ApiClient, fwdiags.Diagnostics) {
clientCfg, diags := config.NewFromFramework(ctx, cfg, version)
if diags.HasError() {
return nil, diags
}
client, err := newApiClientFromConfig(clientCfg, version)
if err != nil {
return nil, fwdiags.Diagnostics{
fwdiags.NewErrorDiagnostic("Failed to create API client", err.Error()),
}
}
return client, nil
}
func ConvertProviderData(providerData any) (*ApiClient, fwdiags.Diagnostics) {
var diags fwdiags.Diagnostics
if providerData == nil {
return nil, diags
}
client, ok := providerData.(*ApiClient)
if !ok {
diags.AddError(
"Unexpected Provider Data",
fmt.Sprintf("Expected *ApiClient, got: %T. Please report this issue to the provider developers.", providerData),
)
return nil, diags
}
if client == nil {
diags.AddError(
"Unconfigured Client",
"Expected configured client. Please report this issue to the provider developers.",
)
}
return client, diags
}
func MaybeNewApiClientFromFrameworkResource(ctx context.Context, esConnList types.List, defaultClient *ApiClient) (*ApiClient, fwdiags.Diagnostics) {
var esConns []config.ElasticsearchConnection
if diags := esConnList.ElementsAs(ctx, &esConns, true); diags.HasError() {
return nil, diags
}
if len(esConns) == 0 {
return defaultClient, nil
}
cfg, diags := config.NewFromFramework(ctx, config.ProviderConfiguration{Elasticsearch: esConns}, defaultClient.version)
if diags.HasError() {
return nil, diags
}
esClient, err := buildEsClient(cfg)
if err != nil {
return nil, fwdiags.Diagnostics{fwdiags.NewErrorDiagnostic(err.Error(), err.Error())}
}
return &ApiClient{
elasticsearch: esClient,
elasticsearchClusterInfo: defaultClient.elasticsearchClusterInfo,
kibana: defaultClient.kibana,
fleet: defaultClient.fleet,
version: defaultClient.version,
}, diags
}
func NewApiClientFromSDKResource(d *schema.ResourceData, meta interface{}) (*ApiClient, diag.Diagnostics) {
defaultClient := meta.(*ApiClient)
version := defaultClient.version
resourceConfig, diags := config.NewFromSDKResource(d, version)
if diags.HasError() {
return nil, diags
}
if resourceConfig == nil {
return defaultClient, nil
}
esClient, err := buildEsClient(*resourceConfig)
if err != nil {
return nil, diag.FromErr(err)
}
return &ApiClient{
elasticsearch: esClient,
elasticsearchClusterInfo: defaultClient.elasticsearchClusterInfo,
kibana: defaultClient.kibana,
fleet: defaultClient.fleet,
version: version,
}, diags
}
func (a *ApiClient) GetESClient() (*elasticsearch.Client, error) {
if a.elasticsearch == nil {
return nil, errors.New("elasticsearch client not found")
}
return a.elasticsearch, nil
}
func (a *ApiClient) GetKibanaClient() (*kibana.Client, error) {
if a.kibana == nil {
return nil, errors.New("kibana client not found")
}
return a.kibana, nil
}
func (a *ApiClient) GetKibanaOapiClient() (*kibana_oapi.Client, error) {
if a.kibanaOapi == nil {
return nil, errors.New("kibana_oapi client not found")
}
return a.kibanaOapi, nil
}
func (a *ApiClient) GetAlertingClient() (alerting.AlertingAPI, error) {
if a.alerting == nil {
return nil, errors.New("alerting client not found")
}
return a.alerting, nil
}
func (a *ApiClient) GetKibanaConnectorsClient(ctx context.Context) (*connectors.Client, error) {
if a.connectors == nil {
return nil, errors.New("kibana action connector client not found")
}
return a.connectors, nil
}
func (a *ApiClient) GetSloClient() (slo.SloAPI, error) {
if a.slo == nil {
return nil, errors.New("slo client not found")
}
return a.slo, nil
}
func (a *ApiClient) GetFleetClient() (*fleet.Client, error) {
if a.fleet == nil {
return nil, errors.New("fleet client not found")
}
return a.fleet, nil
}
func (a *ApiClient) SetSloAuthContext(ctx context.Context) context.Context {
if a.kibanaConfig.ApiKey != "" {
return context.WithValue(ctx, slo.ContextAPIKeys, map[string]slo.APIKey{
"apiKeyAuth": {
Prefix: "ApiKey",
Key: a.kibanaConfig.ApiKey,
}})
} else {
return context.WithValue(ctx, slo.ContextBasicAuth, slo.BasicAuth{
UserName: a.kibanaConfig.Username,
Password: a.kibanaConfig.Password,
})
}
}
func (a *ApiClient) SetAlertingAuthContext(ctx context.Context) context.Context {
if a.kibanaConfig.ApiKey != "" {
return context.WithValue(ctx, alerting.ContextAPIKeys, map[string]alerting.APIKey{
"apiKeyAuth": {
Prefix: "ApiKey",
Key: a.kibanaConfig.ApiKey,
}})
} else {
return context.WithValue(ctx, alerting.ContextBasicAuth, alerting.BasicAuth{
UserName: a.kibanaConfig.Username,
Password: a.kibanaConfig.Password,
})
}
}
func (a *ApiClient) ID(ctx context.Context, resourceId string) (*CompositeId, diag.Diagnostics) {
var diags diag.Diagnostics
clusterId, diags := a.ClusterID(ctx)
if diags.HasError() {
return nil, diags
}
return &CompositeId{*clusterId, resourceId}, diags
}
func (a *ApiClient) serverInfo(ctx context.Context) (*models.ClusterInfo, diag.Diagnostics) {
if a.elasticsearchClusterInfo != nil {
return a.elasticsearchClusterInfo, nil
}
var diags diag.Diagnostics
esClient, err := a.GetESClient()
if err != nil {
return nil, diag.FromErr(err)
}
res, err := esClient.Info(esClient.Info.WithContext(ctx))
if err != nil {
return nil, diag.FromErr(err)
}
defer res.Body.Close()
if diags := utils.CheckError(res, "Unable to connect to the Elasticsearch cluster"); diags.HasError() {
return nil, diags
}
info := models.ClusterInfo{}
if err := json.NewDecoder(res.Body).Decode(&info); err != nil {
return nil, diag.FromErr(err)
}
// cache info
a.elasticsearchClusterInfo = &info
return &info, diags
}
func (a *ApiClient) ServerVersion(ctx context.Context) (*version.Version, diag.Diagnostics) {
if a.elasticsearch != nil {
return a.versionFromElasticsearch(ctx)
}
return a.versionFromKibana()
}
func (a *ApiClient) versionFromKibana() (*version.Version, diag.Diagnostics) {
kibClient, err := a.GetKibanaClient()
if err != nil {
return nil, diag.Errorf("failed to get version from Kibana API: %s, "+
"please ensure a working 'kibana' endpoint is configured", err.Error())
}
status, err := kibClient.KibanaStatus.Get()
if err != nil {
return nil, diag.Errorf("failed to get version from Kibana API: %s, "+
"Please ensure a working 'kibana' endpoint is configured", err.Error())
}
vMap, ok := status["version"].(map[string]interface{})
if !ok {
return nil, diag.Errorf("failed to get version from Kibana API")
}
rawVersion, ok := vMap["number"].(string)
if !ok {
return nil, diag.Errorf("failed to get version number from Kibana status")
}
serverVersion, err := version.NewVersion(rawVersion)
if err != nil {
return nil, diag.FromErr(err)
}
return serverVersion, nil
}
func (a *ApiClient) versionFromElasticsearch(ctx context.Context) (*version.Version, diag.Diagnostics) {
info, diags := a.serverInfo(ctx)
if diags.HasError() {
return nil, diags
}
rawVersion := info.Version.Number
serverVersion, err := version.NewVersion(rawVersion)
if err != nil {
return nil, diag.FromErr(err)
}
return serverVersion, nil
}
func (a *ApiClient) ServerFlavor(ctx context.Context) (string, diag.Diagnostics) {
info, diags := a.serverInfo(ctx)
if diags.HasError() {
return "", diags
}
return info.Version.BuildFlavor, nil
}
func (a *ApiClient) ClusterID(ctx context.Context) (*string, diag.Diagnostics) {
info, diags := a.serverInfo(ctx)
if diags.HasError() {
return nil, diags
}
if uuid := info.ClusterUUID; uuid != "" && uuid != "_na_" {
tflog.Trace(ctx, fmt.Sprintf("cluster UUID: %s", uuid))
return &uuid, diags
}
diags = append(diags, diag.Diagnostic{
Severity: diag.Error,
Summary: "Unable to get cluster UUID",
Detail: `Unable to get cluster UUID.
There might be a problem with permissions or cluster is still starting up and UUID has not been populated yet.`,
})
return nil, diags
}
func buildEsClient(cfg config.Client) (*elasticsearch.Client, error) {
if cfg.Elasticsearch == nil {
return nil, nil
}
es, err := elasticsearch.NewClient(*cfg.Elasticsearch)
if err != nil {
return nil, fmt.Errorf("unable to create Elasticsearch client: %w", err)
}
return es, nil
}
func buildKibanaClient(cfg config.Client) (*kibana.Client, error) {
if cfg.Kibana == nil {
return nil, nil
}
kib, err := kibana.NewClient(*cfg.Kibana)
if err != nil {
return nil, err
}
if logging.IsDebugOrHigher() {
// It is required to set debug mode even if we re-use the http client within the OpenAPI generated clients
// some of the clients are not relying on the OpenAPI generated clients and are using the http client directly
kib.Client.SetDebug(true)
transport, err := kib.Client.Transport()
if err != nil {
return nil, err
}
var roundTripper http.RoundTripper = utils.NewDebugTransport("Kibana", transport)
kib.Client.SetTransport(roundTripper)
}
return kib, nil
}
func buildKibanaOapiClient(cfg config.Client) (*kibana_oapi.Client, error) {
client, err := kibana_oapi.NewClient(*cfg.KibanaOapi)
if err != nil {
return nil, fmt.Errorf("unable to create KibanaOapi client: %w", err)
}
return client, nil
}
func buildAlertingClient(cfg config.Client, httpClient *http.Client) *alerting.APIClient {
alertingConfig := alerting.Configuration{
Debug: logging.IsDebugOrHigher(),
UserAgent: cfg.UserAgent,
Servers: alerting.ServerConfigurations{
{
URL: cfg.Kibana.Address,
},
},
HTTPClient: httpClient,
}
return alerting.NewAPIClient(&alertingConfig)
}
func buildConnectorsClient(cfg config.Client, httpClient *http.Client) (*connectors.Client, error) {
var authInterceptor connectors.ClientOption
if cfg.Kibana.ApiKey != "" {
apiKeyProvider, err := securityprovider.NewSecurityProviderApiKey(
"header",
"Authorization",
"ApiKey "+cfg.Kibana.ApiKey,
)
if err != nil {
return nil, fmt.Errorf("unable to create api key auth provider: %w", err)
}
authInterceptor = connectors.WithRequestEditorFn(apiKeyProvider.Intercept)
} else {
basicAuthProvider, err := securityprovider.NewSecurityProviderBasicAuth(cfg.Kibana.Username, cfg.Kibana.Password)
if err != nil {
return nil, fmt.Errorf("unable to create basic auth provider: %w", err)
}
authInterceptor = connectors.WithRequestEditorFn(basicAuthProvider.Intercept)
}
return connectors.NewClient(
cfg.Kibana.Address,
authInterceptor,
connectors.WithHTTPClient(httpClient),
)
}
func buildSloClient(cfg config.Client, httpClient *http.Client) *slo.APIClient {
sloConfig := slo.Configuration{
Debug: logging.IsDebugOrHigher(),
UserAgent: cfg.UserAgent,
Servers: slo.ServerConfigurations{
{
URL: cfg.Kibana.Address,
},
},
HTTPClient: httpClient,
}
return slo.NewAPIClient(&sloConfig)
}
func buildFleetClient(cfg config.Client) (*fleet.Client, error) {
client, err := fleet.NewClient(*cfg.Fleet)
if err != nil {
return nil, fmt.Errorf("unable to create Fleet client: %w", err)
}
return client, nil
}
func newApiClientFromSDK(d *schema.ResourceData, version string) (*ApiClient, diag.Diagnostics) {
cfg, diags := config.NewFromSDK(d, version)
if diags.HasError() {
return nil, diags
}
client, err := newApiClientFromConfig(cfg, version)
if err != nil {
return nil, diag.FromErr(err)
}
return client, nil
}
func newApiClientFromConfig(cfg config.Client, version string) (*ApiClient, error) {
client := &ApiClient{
kibanaConfig: *cfg.Kibana,
version: version,
}
if cfg.Elasticsearch != nil {
esClient, err := buildEsClient(cfg)
if err != nil {
return nil, err
}
client.elasticsearch = esClient
}
if cfg.Kibana != nil {
kibanaClient, err := buildKibanaClient(cfg)
if err != nil {
return nil, err
}
client.kibana = kibanaClient
kibanaOapiClient, err := buildKibanaOapiClient(cfg)
if err != nil {
return nil, err
}
client.kibanaOapi = kibanaOapiClient
kibanaHttpClient := kibanaClient.Client.GetClient()
connectorsClient, err := buildConnectorsClient(cfg, kibanaHttpClient)
if err != nil {
return nil, fmt.Errorf("cannot create Kibana connectors client: [%w]", err)
}
client.alerting = buildAlertingClient(cfg, kibanaHttpClient).AlertingAPI
client.slo = buildSloClient(cfg, kibanaHttpClient).SloAPI
client.connectors = connectorsClient
}
if cfg.Fleet != nil {
fleetClient, err := buildFleetClient(cfg)
if err != nil {
return nil, err
}
client.fleet = fleetClient
}
return client, nil
}