From 27c727d854f458105f4e33dcc71d756c3368f997 Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Thu, 21 Nov 2024 13:27:07 +0100 Subject: [PATCH] WIP: maniputale TF stae based on FMC job history --- docs/resources/deployment.md | 4 +- .../fmc_deployment/data-source.tf | 3 + examples/resources/fmc_deployment/resource.tf | 2 +- gen/definitions/deploy_device.yaml | 21 +- .../provider/data_source_fmc_deployment.go | 138 +++++++++++++ .../data_source_fmc_deployment_test.go | 80 ++++++++ internal/provider/resource_fmc_deployment.go | 183 ++++++++++++++++-- .../provider/resource_fmc_deployment_test.go | 27 ++- main.go | 7 + 9 files changed, 437 insertions(+), 28 deletions(-) create mode 100644 examples/data-sources/fmc_deployment/data-source.tf create mode 100644 internal/provider/data_source_fmc_deployment.go create mode 100644 internal/provider/data_source_fmc_deployment_test.go diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md index 1f2e87e6..2a17b224 100644 --- a/docs/resources/deployment.md +++ b/docs/resources/deployment.md @@ -15,7 +15,7 @@ This resource can manage a Deployment. ```terraform resource "fmc_deployment" "example" { version = "1457566762351" - device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"] + device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] } ``` @@ -25,7 +25,7 @@ resource "fmc_deployment" "example" { ### Required - `device_list` (Set of String) List of device ids to be deployed. -- `version` (String) Epoch unix time stamp. +- `version` (String) Epoch unix time stamp (13 digits). ### Optional diff --git a/examples/data-sources/fmc_deployment/data-source.tf b/examples/data-sources/fmc_deployment/data-source.tf new file mode 100644 index 00000000..4c6e8ffa --- /dev/null +++ b/examples/data-sources/fmc_deployment/data-source.tf @@ -0,0 +1,3 @@ +data "fmc_deployment" "example" { + id = "76d24097-41c4-4558-a4d0-a8c07ac08470" +} diff --git a/examples/resources/fmc_deployment/resource.tf b/examples/resources/fmc_deployment/resource.tf index 71ceba64..74afe93f 100644 --- a/examples/resources/fmc_deployment/resource.tf +++ b/examples/resources/fmc_deployment/resource.tf @@ -1,4 +1,4 @@ resource "fmc_deployment" "example" { version = "1457566762351" - device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"] + device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] } diff --git a/gen/definitions/deploy_device.yaml b/gen/definitions/deploy_device.yaml index 4190f490..942ca17c 100644 --- a/gen/definitions/deploy_device.yaml +++ b/gen/definitions/deploy_device.yaml @@ -6,37 +6,52 @@ no_update: true no_data_source: true no_import: true doc_category: Deployment +test_tags: [TF_VAR_timestamp,TF_VAR_device_id_list] attributes: - model_name: type type: String value: "DeploymentRequest" - model_name: version type: String - description: Epoch unix time stamp. + description: Epoch unix time stamp (13 digits). mandatory: true example: "1457566762351" + test_value: var.timestamp - model_name: ForceDeploy + tf_name: force_deploy type: Bool description: Force deployment (even if there are no configuration changes). mandatory: false exclude_example: true exclude_test: true - model_name: ignoreWarning + tf_name: ignore_warning type: Bool description: Ignore warnings during deployment. mandatory: false exclude_example: true - exclude_test: true + test_value: true + minimum_test_value: true - model_name: deviceList + tf_name: device_list type: Set description: List of device ids to be deployed. mandatory: true element_type: String - example: d94f7ada-d141-11e5-acf3-c41f7e67fb1b + example: 2fe9063e-8bd5-11ef-9475-e4aeac78cf37 + test_value: var.device_id_list - model_name: deploymentNote + tf_name: deployment_note type: String description: User note related to deployment. mandatory: false example: "yournotescomehere" exclude_example: true exclude_test: true + +test_prerequisites: |- + variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp + variable "device_id_list" { // tests will set $TF_VAR_device_id_list + type = list(string) + default = null + } \ No newline at end of file diff --git a/internal/provider/data_source_fmc_deployment.go b/internal/provider/data_source_fmc_deployment.go new file mode 100644 index 00000000..21ba11e7 --- /dev/null +++ b/internal/provider/data_source_fmc_deployment.go @@ -0,0 +1,138 @@ +// Copyright © 2023 Cisco Systems, Inc. and its affiliates. +// All rights reserved. +// +// Licensed under the Mozilla Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://mozilla.org/MPL/2.0/ +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: MPL-2.0 + +package provider + +// Section below is generated&owned by "gen/generator.go". //template:begin imports +import ( + "context" + "fmt" + "net/url" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/netascode/go-fmc" +) + +// End of section. //template:end imports + +// Section below is generated&owned by "gen/generator.go". //template:begin model + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &DeploymentDataSource{} + _ datasource.DataSourceWithConfigure = &DeploymentDataSource{} +) + +func NewDeploymentDataSource() datasource.DataSource { + return &DeploymentDataSource{} +} + +type DeploymentDataSource struct { + client *fmc.Client +} + +func (d *DeploymentDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_deployment" +} + +func (d *DeploymentDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "This data source can read the Deployment.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The id of the object", + Required: true, + }, + "domain": schema.StringAttribute{ + MarkdownDescription: "The name of the FMC domain", + Optional: true, + }, + "version": schema.StringAttribute{ + MarkdownDescription: "Epoch unix time stamp (13 digits).", + Computed: true, + }, + "force_deploy": schema.BoolAttribute{ + MarkdownDescription: "Force deployment (even if there are no configuration changes).", + Computed: true, + }, + "ignore_warning": schema.BoolAttribute{ + MarkdownDescription: "Ignore warnings during deployment.", + Computed: true, + }, + "device_list": schema.SetAttribute{ + MarkdownDescription: "List of device ids to be deployed.", + ElementType: types.StringType, + Computed: true, + }, + "deployment_note": schema.StringAttribute{ + MarkdownDescription: "User note related to deployment.", + Computed: true, + }, + }, + } +} + +func (d *DeploymentDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + d.client = req.ProviderData.(*FmcProviderData).Client +} + +// End of section. //template:end model + +// Section below is generated&owned by "gen/generator.go". //template:begin read + +func (d *DeploymentDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config Deployment + + // Read config + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Set request domain if provided + reqMods := [](func(*fmc.Req)){} + if !config.Domain.IsNull() && config.Domain.ValueString() != "" { + reqMods = append(reqMods, fmc.DomainName(config.Domain.ValueString())) + } + + tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Read", config.Id.String())) + urlPath := config.getPath() + "/" + url.QueryEscape(config.Id.ValueString()) + res, err := d.client.Get(urlPath, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to retrieve object, got error: %s", err)) + return + } + + config.fromBody(ctx, res) + + tflog.Debug(ctx, fmt.Sprintf("%s: Read finished successfully", config.Id.ValueString())) + + diags = resp.State.Set(ctx, &config) + resp.Diagnostics.Append(diags...) +} + +// End of section. //template:end read diff --git a/internal/provider/data_source_fmc_deployment_test.go b/internal/provider/data_source_fmc_deployment_test.go new file mode 100644 index 00000000..212ff0e5 --- /dev/null +++ b/internal/provider/data_source_fmc_deployment_test.go @@ -0,0 +1,80 @@ +// Copyright © 2023 Cisco Systems, Inc. and its affiliates. +// All rights reserved. +// +// Licensed under the Mozilla Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://mozilla.org/MPL/2.0/ +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: MPL-2.0 + +package provider + +// Section below is generated&owned by "gen/generator.go". //template:begin imports +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// End of section. //template:end imports + +// Section below is generated&owned by "gen/generator.go". //template:begin testAccDataSource + +func TestAccDataSourceFmcDeployment(t *testing.T) { + if os.Getenv("TF_VAR_timestamp") == "" && os.Getenv("TF_VAR_device_id_list") == "" { + t.Skip("skipping test, set environment variable TF_VAR_timestamp or TF_VAR_device_id_list") + } + var checks []resource.TestCheckFunc + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceFmcDeploymentPrerequisitesConfig + testAccDataSourceFmcDeploymentConfig(), + Check: resource.ComposeTestCheckFunc(checks...), + }, + }, + }) +} + +// End of section. //template:end testAccDataSource + +// Section below is generated&owned by "gen/generator.go". //template:begin testPrerequisites + +const testAccDataSourceFmcDeploymentPrerequisitesConfig = ` +variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp +variable "device_id_list" { // tests will set $TF_VAR_device_id_list + type = list(string) + default = null +} +` + +// End of section. //template:end testPrerequisites + +// Section below is generated&owned by "gen/generator.go". //template:begin testAccDataSourceConfig + +func testAccDataSourceFmcDeploymentConfig() string { + config := `resource "fmc_deployment" "test" {` + "\n" + config += ` version = var.timestamp` + "\n" + config += ` ignore_warning = true` + "\n" + config += ` device_list = var.device_id_list` + "\n" + config += `}` + "\n" + + config += ` + data "fmc_deployment" "test" { + id = fmc_deployment.test.id + } + ` + return config +} + +// End of section. //template:end testAccDataSourceConfig diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index 2a0a885a..81789c96 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -17,13 +17,14 @@ package provider -// Section below is generated&owned by "gen/generator.go". //template:begin imports import ( "context" + "encoding/json" "fmt" - "net/url" "strings" + "github.com/tidwall/gjson" + "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -34,8 +35,6 @@ import ( "github.com/netascode/terraform-provider-fmc/internal/provider/helpers" ) -// End of section. //template:end imports - // Section below is generated&owned by "gen/generator.go". //template:begin model // Ensure provider defined types fully satisfy framework interfaces @@ -76,7 +75,7 @@ func (r *DeploymentResource) Schema(ctx context.Context, req resource.SchemaRequ }, }, "version": schema.StringAttribute{ - MarkdownDescription: helpers.NewAttributeDescription("Epoch unix time stamp.").String, + MarkdownDescription: helpers.NewAttributeDescription("Epoch unix time stamp (13 digits).").String, Required: true, }, "force_deploy": schema.BoolAttribute{ @@ -110,8 +109,6 @@ func (r *DeploymentResource) Configure(_ context.Context, req resource.Configure // End of section. //template:end model -// Section below is generated&owned by "gen/generator.go". //template:begin create - func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan Deployment @@ -121,6 +118,8 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ return } + myInterfaceUUID := strings.ReplaceAll(plan.DeviceList.Elements()[0].String(), `"`, "") + // Set request domain if provided reqMods := [](func(*fmc.Req)){} if !plan.Domain.IsNull() && plan.Domain.ValueString() != "" { @@ -136,7 +135,96 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) return } - plan.Id = types.StringValue(res.Get("id").String()) + + // BoCODE + // As there are no GET api call for /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests + // We need to create abstraction state which reflects deployemnt + // We can use /api/fmc_config/v1/domain/{domainUUID}/deployment/jobhistories API + // and /api/fmc_config/v1/domain/{domainUUID}/job/taskstatuses/{objectId} (optionally, no jobID in taskstauses) + // First after POST /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests + // we have to moinitor task status which available in response body of POST api call + // then, when deployment task is finished we need to store Deployment job into tf state + + urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" + res, err = r.client.Get(urlPath, reqMods...) + jsonString := res.String() + var resMap map[string]interface{} + err = json.Unmarshal([]byte(jsonString), &resMap) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) + } + + // Variable to store the matching item + var matchingItem map[string]interface{} + // Slice to collect matching data + var matchingData []interface{} + + // Access "items" + items, ok := resMap["items"].([]interface{}) + if !ok { + tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) + } + + // Iterate over items to find the JobID based on devices UUID +JonbIdLookup: + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + // // Check if the ID matches + // if id, ok := itemMap["id"].(string); ok && id == my_job_id { + // matchingItem = itemMap + // break // Exit the loop as we found the match + // } + + if itemMap["jobType"].(interface{}).(string) != "DEPLOYMENT" { + continue + } + + deviceList, ok := itemMap["deviceList"].([]interface{}) + + jobId, ok := itemMap["id"].(string) + if !ok { + continue + } + println(jobId) + + // Iterate over deviceData to check deviceUUID + for _, device := range deviceList { + deviceMap, ok := device.(map[string]interface{}) + if !ok { + continue + } + + // deviceDetails, ok := deviceMap["data"].(map[string]interface{}) + // if !ok { + // continue + // } + deviceUUID, ok := deviceMap["deviceUUID"].(interface{}).(string) + if !ok { + continue + } + + if deviceUUID == myInterfaceUUID { + plan.Id = types.StringValue(jobId) + matchingData = append(matchingData, deviceMap) + break JonbIdLookup + } + } + } + + // Print the matching item + if matchingItem != nil { + tflog.Debug(ctx, fmt.Sprintf("Matching item: %+v\n", matchingItem)) + } else { + tflog.Debug(ctx, fmt.Sprintf("No matching item found. %+v\n", "")) + } + + // EoCODE + + // plan.Id = types.StringValue(res.Get("id").String()) tflog.Debug(ctx, fmt.Sprintf("%s: Create finished successfully", plan.Id.ValueString())) @@ -146,10 +234,6 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics) } -// End of section. //template:end create - -// Section below is generated&owned by "gen/generator.go". //template:begin read - func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state Deployment @@ -167,8 +251,79 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Read", state.Id.String())) - urlPath := state.getPath() + "/" + url.QueryEscape(state.Id.ValueString()) + // BoCODE + + // Target job ID to match + // my_job_id := "0050568A-2561-0ed3-0000-004294994451" + my_job_id := state.Id.ValueString() + + // https://10.50.202.94/api/fmc_config/v1/domain/e276abec-e0f2-11e3-8169-6d9ed49b625f/deployment/jobhistories?expanded=true + // urlPath := state.getPath() + "/" + url.QueryEscape(state.Id.ValueString()) + + // urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" + urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories/" + my_job_id + res, err := r.client.Get(urlPath, reqMods...) + jsonString := res.String() + var resMap map[string]interface{} + err = json.Unmarshal([]byte(jsonString), &resMap) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) + } + + // Get resource details from job history + resDeviceList := state.DeviceList.Elements() + // Modify matchingItem to be in resource format + resMap["deviceList"] = resDeviceList + + // Convert the modified resMap back to JSON + updatedJSON, err := json.Marshal(resMap) + if err != nil { + fmt.Printf("Error converting map to JSON: %v\n", err) + return + } + + res = gjson.Parse(string(updatedJSON)) + + matchingItem := resMap + + // // Variable to store the matching item + // var matchingItem map[string]interface{} + + // // Access "items" + // items, ok := resMap["items"].([]interface{}) + // if !ok { + // tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) + // } + + // // Iterate over items to find the matching ID + // for _, item := range items { + // itemMap, ok := item.(map[string]interface{}) + // if !ok { + // continue + // } + + // // // Navigate to data + // // data, ok := itemMap["data"].(map[string]interface{}) + // // if !ok { + // // continue + // // } + + // // Check if the ID matches + // if id, ok := itemMap["id"].(string); ok && id == my_job_id { + // matchingItem = itemMap + // break // Exit the loop as we found the match + // } + // } + + // Print the matching item + if matchingItem != nil { + tflog.Debug(ctx, fmt.Sprintf("Matching item: %+v\n", matchingItem)) + } else { + tflog.Debug(ctx, fmt.Sprintf("No matching item found. %+v\n", "")) + } + + // EoCODE if err != nil && strings.Contains(err.Error(), "StatusCode 404") { resp.State.RemoveResource(ctx) @@ -198,8 +353,6 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics) } -// End of section. //template:end read - // Section below is generated&owned by "gen/generator.go". //template:begin update func (r *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { diff --git a/internal/provider/resource_fmc_deployment_test.go b/internal/provider/resource_fmc_deployment_test.go index 30f1ceca..ce038c7f 100644 --- a/internal/provider/resource_fmc_deployment_test.go +++ b/internal/provider/resource_fmc_deployment_test.go @@ -30,17 +30,19 @@ import ( // Section below is generated&owned by "gen/generator.go". //template:begin testAcc func TestAccFmcDeployment(t *testing.T) { + if os.Getenv("TF_VAR_timestamp") == "" && os.Getenv("TF_VAR_device_id_list") == "" { + t.Skip("skipping test, set environment variable TF_VAR_timestamp or TF_VAR_device_id_list") + } var checks []resource.TestCheckFunc - checks = append(checks, resource.TestCheckResourceAttr("fmc_deployment.test", "version", "1457566762351")) var steps []resource.TestStep if os.Getenv("SKIP_MINIMUM_TEST") == "" { steps = append(steps, resource.TestStep{ - Config: testAccFmcDeploymentConfig_minimum(), + Config: testAccFmcDeploymentPrerequisitesConfig + testAccFmcDeploymentConfig_minimum(), }) } steps = append(steps, resource.TestStep{ - Config: testAccFmcDeploymentConfig_all(), + Config: testAccFmcDeploymentPrerequisitesConfig + testAccFmcDeploymentConfig_all(), Check: resource.ComposeTestCheckFunc(checks...), }) @@ -54,14 +56,24 @@ func TestAccFmcDeployment(t *testing.T) { // End of section. //template:end testAcc // Section below is generated&owned by "gen/generator.go". //template:begin testPrerequisites + +const testAccFmcDeploymentPrerequisitesConfig = ` +variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp +variable "device_id_list" { // tests will set $TF_VAR_device_id_list + type = list(string) + default = null +} +` + // End of section. //template:end testPrerequisites // Section below is generated&owned by "gen/generator.go". //template:begin testAccConfigMinimal func testAccFmcDeploymentConfig_minimum() string { config := `resource "fmc_deployment" "test" {` + "\n" - config += ` version = "1457566762351"` + "\n" - config += ` device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"]` + "\n" + config += ` version = var.timestamp` + "\n" + config += ` ignore_warning = true` + "\n" + config += ` device_list = var.device_id_list` + "\n" config += `}` + "\n" return config } @@ -72,8 +84,9 @@ func testAccFmcDeploymentConfig_minimum() string { func testAccFmcDeploymentConfig_all() string { config := `resource "fmc_deployment" "test" {` + "\n" - config += ` version = "1457566762351"` + "\n" - config += ` device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"]` + "\n" + config += ` version = var.timestamp` + "\n" + config += ` ignore_warning = true` + "\n" + config += ` device_list = var.device_id_list` + "\n" config += `}` + "\n" return config } diff --git a/main.go b/main.go index 91a53da9..7daef432 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ package main import ( "context" + "flag" "log" "github.com/hashicorp/terraform-plugin-framework/providerserver" @@ -54,8 +55,14 @@ var ( ) func main() { + var debug bool + + flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.Parse() + opts := providerserver.ServeOpts{ Address: "registry.terraform.io/netascode/fmc", + Debug: debug, } err := providerserver.Serve(context.Background(), provider.New(version), opts)