From 967f9894491bf55d5b63633bf378e354a87ee7e7 Mon Sep 17 00:00:00 2001 From: Taylor Swanson Date: Thu, 2 Nov 2023 09:08:36 -0500 Subject: [PATCH 1/3] Add data source for Fleet integration packages - Used for getting the latest version of an integration package - Add acceptance test - Add docs --- docs/data-sources/fleet_package.md | 48 ++++ .../elasticstack_fleet_package/data-source.tf | 7 + generated/fleet/fleet.gen.go | 212 ++++++++++++++++++ generated/fleet/getschema.go | 13 ++ internal/clients/fleet/fleet.go | 19 ++ internal/fleet/package_data_source.go | 74 ++++++ internal/fleet/package_data_source_test.go | 69 ++++++ provider/provider.go | 1 + templates/data-sources/fleet_package.md.tmpl | 26 +++ 9 files changed, 469 insertions(+) create mode 100644 docs/data-sources/fleet_package.md create mode 100644 examples/data-sources/elasticstack_fleet_package/data-source.tf create mode 100644 internal/fleet/package_data_source.go create mode 100644 internal/fleet/package_data_source_test.go create mode 100644 templates/data-sources/fleet_package.md.tmpl diff --git a/docs/data-sources/fleet_package.md b/docs/data-sources/fleet_package.md new file mode 100644 index 000000000..d345c94c2 --- /dev/null +++ b/docs/data-sources/fleet_package.md @@ -0,0 +1,48 @@ +--- +subcategory: "Fleet" +layout: "" +page_title: "Elasticstack: elasticstack_fleet_package Data Source" +description: |- + Gets information about a Fleet integration package. +--- + +# Data Source: elasticstack_fleet_package + +This data source provides information about a Fleet integration package. Currently, +the data source will retrieve the latest available version of the package. Version +selection is determined by the Fleet API, which is currently based on semantic +versioning. + +By default, the highest GA release version will be selected. If a +package is not GA (the version is below 1.0.0) or if a new non-GA version of the +package is to be selected (i.e., the GA version of the package is 1.5.0, but there's +a new 1.5.1-beta version available), then the `prerelease` parameter in the plan +should be set to `true`. + +## Example Usage + +```terraform +provider "elasticstack" { + kibana {} +} + +data "elasticstack_fleet_package" "test" { + name = "tcp" +} +``` + + +## Schema + +### Required + +- `name` (String) The package name. + +### Optional + +- `prerelease` (Boolean) Include prerelease packages. + +### Read-Only + +- `id` (String) The ID of this resource. +- `version` (String) The package version. diff --git a/examples/data-sources/elasticstack_fleet_package/data-source.tf b/examples/data-sources/elasticstack_fleet_package/data-source.tf new file mode 100644 index 000000000..bd28fca23 --- /dev/null +++ b/examples/data-sources/elasticstack_fleet_package/data-source.tf @@ -0,0 +1,7 @@ +provider "elasticstack" { + kibana {} +} + +data "elasticstack_fleet_package" "test" { + name = "tcp" +} diff --git a/generated/fleet/fleet.gen.go b/generated/fleet/fleet.gen.go index 761a46c9b..1f4685dd4 100644 --- a/generated/fleet/fleet.gen.go +++ b/generated/fleet/fleet.gen.go @@ -281,6 +281,13 @@ type FleetServerHost struct { Name *string `json:"name,omitempty"` } +// GetPackagesResponse defines model for get_packages_response. +type GetPackagesResponse struct { + Items []SearchResult `json:"items"` + // Deprecated: + Response *[]SearchResult `json:"response,omitempty"` +} + // KibanaSavedObjectType defines model for kibana_saved_object_type. type KibanaSavedObjectType string @@ -744,6 +751,20 @@ type PackagePolicyRequestInputStream struct { // PackageStatus defines model for package_status. type PackageStatus string +// SearchResult defines model for search_result. +type SearchResult struct { + Description string `json:"description"` + Download string `json:"download"` + Name string `json:"name"` + Path string `json:"path"` + // Deprecated: + SavedObject *map[string]interface{} `json:"savedObject,omitempty"` + Status string `json:"status"` + Title string `json:"title"` + Type string `json:"type"` + Version string `json:"version"` +} + // Format defines model for format. type Format string @@ -759,6 +780,17 @@ type DeleteAgentPolicyJSONBody struct { AgentPolicyId string `json:"agentPolicyId"` } +// ListAllPackagesParams defines parameters for ListAllPackages. +type ListAllPackagesParams struct { + // ExcludeInstallStatus Whether to exclude the install status of each package. Enabling this option will opt in to caching for the response via `cache-control` headers. If you don't need up-to-date installation info for a package, and are querying for a list of available packages, providing this flag can improve performance substantially. + ExcludeInstallStatus *bool `form:"excludeInstallStatus,omitempty" json:"excludeInstallStatus,omitempty"` + + // Prerelease Whether to return prerelease versions of packages (e.g. beta, rc, preview) + Prerelease *bool `form:"prerelease,omitempty" json:"prerelease,omitempty"` + Experimental *bool `form:"experimental,omitempty" json:"experimental,omitempty"` + Category *string `form:"category,omitempty" json:"category,omitempty"` +} + // DeletePackageJSONBody defines parameters for DeletePackage. type DeletePackageJSONBody struct { Force *bool `json:"force,omitempty"` @@ -1300,6 +1332,9 @@ type ClientInterface interface { // GetEnrollmentApiKeys request GetEnrollmentApiKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListAllPackages request + ListAllPackages(ctx context.Context, params *ListAllPackagesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeletePackageWithBody request with any body DeletePackageWithBody(ctx context.Context, pkgName string, pkgVersion string, params *DeletePackageParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1463,6 +1498,18 @@ func (c *Client) GetEnrollmentApiKeys(ctx context.Context, reqEditors ...Request return c.Client.Do(req) } +func (c *Client) ListAllPackages(ctx context.Context, params *ListAllPackagesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListAllPackagesRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) DeletePackageWithBody(ctx context.Context, pkgName string, pkgVersion string, params *DeletePackageParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDeletePackageRequestWithBody(c.Server, pkgName, pkgVersion, params, contentType, body) if err != nil { @@ -1951,6 +1998,103 @@ func NewGetEnrollmentApiKeysRequest(server string) (*http.Request, error) { return req, nil } +// NewListAllPackagesRequest generates requests for ListAllPackages +func NewListAllPackagesRequest(server string, params *ListAllPackagesParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/epm/packages") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.ExcludeInstallStatus != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "excludeInstallStatus", runtime.ParamLocationQuery, *params.ExcludeInstallStatus); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Prerelease != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "prerelease", runtime.ParamLocationQuery, *params.Prerelease); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Experimental != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "experimental", runtime.ParamLocationQuery, *params.Experimental); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Category != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "category", runtime.ParamLocationQuery, *params.Category); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewDeletePackageRequest calls the generic DeletePackage builder with application/json body func NewDeletePackageRequest(server string, pkgName string, pkgVersion string, params *DeletePackageParams, body DeletePackageJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -2987,6 +3131,9 @@ type ClientWithResponsesInterface interface { // GetEnrollmentApiKeysWithResponse request GetEnrollmentApiKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetEnrollmentApiKeysResponse, error) + // ListAllPackagesWithResponse request + ListAllPackagesWithResponse(ctx context.Context, params *ListAllPackagesParams, reqEditors ...RequestEditorFn) (*ListAllPackagesResponse, error) + // DeletePackageWithBodyWithResponse request with any body DeletePackageWithBodyWithResponse(ctx context.Context, pkgName string, pkgVersion string, params *DeletePackageParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeletePackageResponse, error) @@ -3185,6 +3332,29 @@ func (r GetEnrollmentApiKeysResponse) StatusCode() int { return 0 } +type ListAllPackagesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *GetPackagesResponse + JSON400 *Error +} + +// Status returns HTTPResponse.Status +func (r ListAllPackagesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListAllPackagesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type DeletePackageResponse struct { Body []byte HTTPResponse *http.Response @@ -3675,6 +3845,15 @@ func (c *ClientWithResponses) GetEnrollmentApiKeysWithResponse(ctx context.Conte return ParseGetEnrollmentApiKeysResponse(rsp) } +// ListAllPackagesWithResponse request returning *ListAllPackagesResponse +func (c *ClientWithResponses) ListAllPackagesWithResponse(ctx context.Context, params *ListAllPackagesParams, reqEditors ...RequestEditorFn) (*ListAllPackagesResponse, error) { + rsp, err := c.ListAllPackages(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListAllPackagesResponse(rsp) +} + // DeletePackageWithBodyWithResponse request with arbitrary body returning *DeletePackageResponse func (c *ClientWithResponses) DeletePackageWithBodyWithResponse(ctx context.Context, pkgName string, pkgVersion string, params *DeletePackageParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeletePackageResponse, error) { rsp, err := c.DeletePackageWithBody(ctx, pkgName, pkgVersion, params, contentType, body, reqEditors...) @@ -4072,6 +4251,39 @@ func ParseGetEnrollmentApiKeysResponse(rsp *http.Response) (*GetEnrollmentApiKey return response, nil } +// ParseListAllPackagesResponse parses an HTTP response from a ListAllPackagesWithResponse call +func ParseListAllPackagesResponse(rsp *http.Response) (*ListAllPackagesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListAllPackagesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest GetPackagesResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + } + + return response, nil +} + // ParseDeletePackageResponse parses an HTTP response from a DeletePackageWithResponse call func ParseDeletePackageResponse(rsp *http.Response) (*DeletePackageResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/generated/fleet/getschema.go b/generated/fleet/getschema.go index 65f331084..dd49fa7e9 100644 --- a/generated/fleet/getschema.go +++ b/generated/fleet/getschema.go @@ -71,6 +71,7 @@ var transformers = []TransformFunc{ transformSchemasInputsType, transformInlinePackageDefinitions, transformAddPackagePolicyVars, + transformFixPackageSearchResult, } // transformFilterPaths filters the paths in a schema down to @@ -88,6 +89,7 @@ func transformFilterPaths(schema *Schema) { "/package_policies": {"post"}, "/package_policies/{packagePolicyId}": {"get", "put", "delete"}, "/epm/packages/{pkgName}/{pkgVersion}": {"get", "put", "post", "delete"}, + "/epm/packages": {"get"}, } // filterKbnXsrfParameter filters out an entry if it is a kbn_xsrf parameter. @@ -331,6 +333,17 @@ func transformAddPackagePolicyVars(schema *Schema) { } } +// transformFixPackageSearchResult removes unneeded fields from the +// SearchResult struct. These fields are also causing parsing errors. +func transformFixPackageSearchResult(schema *Schema) { + properties, ok := schema.Components.GetFields("schemas.search_result.properties") + if !ok { + panic("properties not found") + } + properties.Delete("icons") + properties.Delete("installationInfo") +} + // downloadFile will download a file from url and return the // bytes. If the request fails, or a non 200 error code is // observed in the response, an error is returned instead. diff --git a/internal/clients/fleet/fleet.go b/internal/clients/fleet/fleet.go index 781dcc584..5655dd58d 100644 --- a/internal/clients/fleet/fleet.go +++ b/internal/clients/fleet/fleet.go @@ -378,6 +378,25 @@ func Uninstall(ctx context.Context, client *Client, name, version string, force } } +// AllPackages returns information about the latest packages known to Fleet. +func AllPackages(ctx context.Context, client *Client, prerelease bool) ([]fleetapi.SearchResult, diag.Diagnostics) { + params := fleetapi.ListAllPackagesParams{ + Prerelease: &prerelease, + } + + resp, err := client.API.ListAllPackagesWithResponse(ctx, ¶ms) + if err != nil { + return nil, diag.FromErr(err) + } + + switch resp.StatusCode() { + case http.StatusOK: + return resp.JSON200.Items, nil + default: + return nil, reportUnknownError(resp.StatusCode(), resp.Body) + } +} + func reportUnknownError(statusCode int, body []byte) diag.Diagnostics { return diag.Diagnostics{ diag.Diagnostic{ diff --git a/internal/fleet/package_data_source.go b/internal/fleet/package_data_source.go new file mode 100644 index 000000000..3e939b89a --- /dev/null +++ b/internal/fleet/package_data_source.go @@ -0,0 +1,74 @@ +package fleet + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients/fleet" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" +) + +func DataSourcePackage() *schema.Resource { + packageSchema := map[string]*schema.Schema{ + "name": { + Description: "The package name.", + Type: schema.TypeString, + Required: true, + }, + "prerelease": { + Description: "Include prerelease packages.", + Type: schema.TypeBool, + Optional: true, + }, + "version": { + Description: "The package version.", + Type: schema.TypeString, + Computed: true, + }, + } + + return &schema.Resource{ + Description: "Retrieves the latest version of an integration package in Fleet.", + + ReadContext: dataSourcePackageRead, + + Schema: packageSchema, + } +} + +func dataSourcePackageRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + fleetClient, diags := getFleetClient(d, meta) + if diags.HasError() { + return diags + } + + pkgName := d.Get("name").(string) + if d.Id() == "" { + hash, err := utils.StringToHash(pkgName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(*hash) + } + + prerelease := d.Get("prerelease").(bool) + allPackages, diags := fleet.AllPackages(ctx, fleetClient, prerelease) + if diags.HasError() { + return diags + } + + for _, v := range allPackages { + if v.Name != pkgName { + continue + } + + if err := d.Set("version", v.Version); err != nil { + return diag.FromErr(err) + } + break + } + + return diags +} diff --git a/internal/fleet/package_data_source_test.go b/internal/fleet/package_data_source_test.go new file mode 100644 index 000000000..120dca293 --- /dev/null +++ b/internal/fleet/package_data_source_test.go @@ -0,0 +1,69 @@ +package fleet_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/elastic/terraform-provider-elasticstack/internal/versionutils" +) + +var minVersionPackageDataSource = version.Must(version.NewVersion("8.6.0")) + +func TestAccDataSourcePackage(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV5ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionPackageDataSource), + Config: testAccDataSourcePackage, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.elasticstack_fleet_package.test", "name", "tcp"), + checkResourceAttrStringNotEmpty("data.elasticstack_fleet_package.test", "version"), + ), + }, + }, + }) +} + +const testAccDataSourcePackage = ` +provider "elasticstack" { + elasticsearch {} + kibana {} +} + +data "elasticstack_fleet_package" "test" { + name = "tcp" +} +` + +// checkResourceAttrStringNotEmpty verifies that the string value at key +// is not empty. +func checkResourceAttrStringNotEmpty(name, key string) resource.TestCheckFunc { + return func(s *terraform.State) error { + ms := s.RootModule() + rs, ok := ms.Resources[name] + if !ok { + return fmt.Errorf("not found: %s in %s", name, ms.Path) + } + is := rs.Primary + if is == nil { + return fmt.Errorf("no primary instance: %s in %s", name, ms.Path) + } + + v, ok := is.Attributes[key] + if !ok { + return fmt.Errorf("%s: Attribute '%s' not found", name, key) + } + if v == "" { + return fmt.Errorf("%s: Attribute '%s' expected non-empty string", name, key) + } + + return nil + } +} diff --git a/provider/provider.go b/provider/provider.go index 0f5d72299..59bcc4ee5 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -82,6 +82,7 @@ func New(version string) *schema.Provider { "elasticstack_kibana_security_role": kibana.DataSourceRole(), "elasticstack_fleet_enrollment_tokens": fleet.DataSourceEnrollmentTokens(), + "elasticstack_fleet_package": fleet.DataSourcePackage(), }, ResourcesMap: map[string]*schema.Resource{ "elasticstack_elasticsearch_cluster_settings": cluster.ResourceSettings(), diff --git a/templates/data-sources/fleet_package.md.tmpl b/templates/data-sources/fleet_package.md.tmpl new file mode 100644 index 000000000..ca8d14907 --- /dev/null +++ b/templates/data-sources/fleet_package.md.tmpl @@ -0,0 +1,26 @@ +--- +subcategory: "Fleet" +layout: "" +page_title: "Elasticstack: elasticstack_fleet_package Data Source" +description: |- + Gets information about a Fleet integration package. +--- + +# Data Source: elasticstack_fleet_package + +This data source provides information about a Fleet integration package. Currently, +the data source will retrieve the latest available version of the package. Version +selection is determined by the Fleet API, which is currently based on semantic +versioning. + +By default, the highest GA release version will be selected. If a +package is not GA (the version is below 1.0.0) or if a new non-GA version of the +package is to be selected (i.e., the GA version of the package is 1.5.0, but there's +a new 1.5.1-beta version available), then the `prerelease` parameter in the plan +should be set to `true`. + +## Example Usage + +{{ tffile "examples/data-sources/elasticstack_fleet_package/data-source.tf" }} + +{{ .SchemaMarkdown | trimspace }} From 49ca3020b1a65849860ea04a6728c65dfbb9e292 Mon Sep 17 00:00:00 2001 From: Taylor Swanson Date: Thu, 2 Nov 2023 09:19:43 -0500 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6796647a..eeccc7548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Added - Switch to Terraform [protocol version 6](https://developer.hashicorp.com/terraform/plugin/terraform-plugin-protocol#protocol-version-6) that is compatible with Terraform CLI version 1.0 and later. +- Add 'elasticstack_fleet_package' data source ([#469](https://github.com/elastic/terraform-provider-elasticstack/pull/469)) ## [0.10.0] - 2023-11-02 From 2f998dbb73f24eab16d0be4fe9489bb5f4703d6f Mon Sep 17 00:00:00 2001 From: Taylor Swanson Date: Thu, 2 Nov 2023 11:09:47 -0500 Subject: [PATCH 3/3] Fix test --- internal/fleet/package_data_source_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/fleet/package_data_source_test.go b/internal/fleet/package_data_source_test.go index 120dca293..20d43d569 100644 --- a/internal/fleet/package_data_source_test.go +++ b/internal/fleet/package_data_source_test.go @@ -17,7 +17,7 @@ var minVersionPackageDataSource = version.Must(version.NewVersion("8.6.0")) func TestAccDataSourcePackage(t *testing.T) { resource.Test(t, resource.TestCase{ PreCheck: func() { acctest.PreCheck(t) }, - ProtoV5ProviderFactories: acctest.Providers, + ProtoV6ProviderFactories: acctest.Providers, Steps: []resource.TestStep{ { SkipFunc: versionutils.CheckIfVersionIsUnsupported(minVersionPackageDataSource),