From 79f183fa25de7d1a7c47cea64e34950a0a7c8161 Mon Sep 17 00:00:00 2001 From: Rohit Kulkarni Date: Thu, 15 May 2025 00:29:18 +0000 Subject: [PATCH] support for new data source for IMIs Signed-off-by: Rohit Kulkarni --- examples/datasource/imis/main.tf | 32 +++ internal/models/imis.go | 16 ++ internal/provider/filesystem_resource.go | 3 + internal/provider/iks_node_group_resource.go | 5 + internal/provider/imis_datasource.go | 243 +++++++++++++++++++ internal/provider/provider.go | 1 + pkg/itacservices/client.go | 3 +- pkg/itacservices/common/httpclient.go | 33 +++ pkg/itacservices/data_sources.go | 49 +++- pkg/itacservices/kubernetes.go | 1 + pkg/itacservices/tests/datasources_test.go | 104 ++++++++ 11 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 examples/datasource/imis/main.tf create mode 100644 internal/models/imis.go create mode 100644 internal/provider/imis_datasource.go create mode 100644 pkg/itacservices/tests/datasources_test.go diff --git a/examples/datasource/imis/main.tf b/examples/datasource/imis/main.tf new file mode 100644 index 0000000..0b1a5f7 --- /dev/null +++ b/examples/datasource/imis/main.tf @@ -0,0 +1,32 @@ +terraform { + required_providers { + intelcloud = { + source = "intel/intelcloud" + version = "0.0.19" + } + } +} + + +provider "intelcloud" { + region = "us-staging-1" + endpoints = { + api = "https://us-staging-1-sdk-api.eglb.intel.com" + auth = "https://client-token.staging.api.idcservice.net" + } +} + +// Commenting this temporarily as it is won't work without merging the PR for imis datasource +#data "intelcloud_imis" "filtered_imis" { +# clusteruuid = "" +# filters = [ +# { +# name = "instance-type" +# values = ["vm-spr-sml"] +# }, +# ] +#} +# +#output "print_images" { +# value = data.intelcloud_imis.filtered_imis +#} diff --git a/internal/models/imis.go b/internal/models/imis.go new file mode 100644 index 0000000..fbae8b6 --- /dev/null +++ b/internal/models/imis.go @@ -0,0 +1,16 @@ +package models + +import "github.com/hashicorp/terraform-plugin-framework/types" + +// model for imis +type ImisModel struct { + InstanceTypeName string `tfsdk:"instancetypename"` + WorkerImi []WorkerImi `tfsdk:"workerimi"` +} + +// model for worker imi +type WorkerImi struct { + ImiName types.String `tfsdk:"iminame"` + Info types.String `tfsdk:"info"` + IsDefaultImi types.Bool `tfsdk:"isdefaultimi"` +} diff --git a/internal/provider/filesystem_resource.go b/internal/provider/filesystem_resource.go index a8252aa..27e8fe1 100644 --- a/internal/provider/filesystem_resource.go +++ b/internal/provider/filesystem_resource.go @@ -382,6 +382,9 @@ func (r *filesystemResource) Update(ctx context.Context, req resource.UpdateRequ } currState.Spec.Size = plan.Spec.Size + // set timeout again for consistency + currState.Timeouts = plan.Timeouts + // Set refreshed state diags = resp.State.Set(ctx, currState) resp.Diagnostics.Append(diags...) diff --git a/internal/provider/iks_node_group_resource.go b/internal/provider/iks_node_group_resource.go index a0045c7..258151a 100644 --- a/internal/provider/iks_node_group_resource.go +++ b/internal/provider/iks_node_group_resource.go @@ -95,6 +95,7 @@ func (r *iksNodeGroupResource) Schema(_ context.Context, _ resource.SchemaReques }, "imiid": schema.StringAttribute{ Computed: true, + Optional: true, }, "state": schema.StringAttribute{ Computed: true, @@ -196,6 +197,10 @@ func (r *iksNodeGroupResource) Create(ctx context.Context, req resource.CreateRe NetworkInterfaceVnetName: vnetResp.Metadata.Name, }) + if !plan.IMIId.IsNull() && !plan.IMIId.IsUnknown() { + inArg.WorkerImiId = plan.IMIId.ValueString() + } + nodeGroupResp, _, err := r.client.CreateIKSNodeGroup(ctx, &inArg, plan.ClusterUUID.ValueString(), false) if err != nil { resp.Diagnostics.AddError( diff --git a/internal/provider/imis_datasource.go b/internal/provider/imis_datasource.go new file mode 100644 index 0000000..e83be4b --- /dev/null +++ b/internal/provider/imis_datasource.go @@ -0,0 +1,243 @@ +package provider + +import ( + "context" + "fmt" + "sort" + "strings" + + "terraform-provider-intelcloud/internal/models" + "terraform-provider-intelcloud/pkg/itacservices" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func NewImisDataSource() datasource.DataSource { + return &imisDataSource{} +} + +type imisDataSource struct { + client *itacservices.IDCServicesClient +} + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &imisDataSource{} + _ datasource.DataSourceWithConfigure = &imisDataSource{} +) + +// storagesDataSourceModel maps the data source schema data. +type imisDataSourceModel struct { + Latest types.Bool `tfsdk:"latest"` + ClusterUUID types.String `tfsdk:"clusteruuid"` + Filters []KVFilter `tfsdk:"filters"` + Result *models.ImisModel `tfsdk:"result"` + Imis []models.ImisModel `tfsdk:"items"` +} + +// Configure adds the provider configured client to the data source. +func (d *imisDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + fmt.Println("[DEBUG] ProviderData is nil") + return + } + client, ok := req.ProviderData.(*itacservices.IDCServicesClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *itacservices.IDCServicesClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +func (d *imisDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_imis" +} + +func (d *imisDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "clusteruuid": schema.StringAttribute{ + Required: true, + Description: "The UUID of the cluster.", + }, + "latest": schema.BoolAttribute{ + Optional: true, + Description: "If true, only the latest IMI will be returned.", + Computed: true, + }, + "filters": schema.ListNestedAttribute{ + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ // maps to KVFilter.Key + Required: true, + }, + "values": schema.ListAttribute{ // maps to KVFilter.Values + ElementType: types.StringType, + Required: true, + }, + }, + }, + }, + "result": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "instancetypename": schema.StringAttribute{ + Computed: true, + }, + "workerimi": schema.ListNestedAttribute{ + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "iminame": schema.StringAttribute{ + Computed: true, + }, + "info": schema.StringAttribute{ + Computed: true, + }, + "isdefaultimi": schema.BoolAttribute{ + Computed: true, + }, + }, + }, + }, + }, + }, + "items": schema.ListNestedAttribute{ + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "instancetypename": schema.StringAttribute{ + Computed: true, + }, + "workerimi": schema.ListNestedAttribute{ + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "iminame": schema.StringAttribute{ + Computed: true, + }, + "info": schema.StringAttribute{ + Computed: true, + }, + "isdefaultimi": schema.BoolAttribute{ + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func (d *imisDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state imisDataSourceModel + + diags := req.Config.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if state.ClusterUUID.IsUnknown() || state.ClusterUUID.IsNull() { + resp.Diagnostics.AddError("Missing clusteruuid", "The 'clusteruuid' field is required.") + return + } + + if d.client == nil { + resp.Diagnostics.AddError("client is nil", "The client is not configured. Please check your provider configuration.") + return + } + + instanceImis, err := d.client.GetImis(ctx, state.ClusterUUID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to Read ITAC Imis: %s", state.ClusterUUID.ValueString()), + err.Error(), + ) + return + } + + allImis := []models.ImisModel{} + + for _, imi := range instanceImis.InstanceTypes { + tfImi := models.ImisModel{ + InstanceTypeName: imi.Name, + WorkerImi: []models.WorkerImi{}, + } + for _, workerImis := range imi.WorkerImi { + tfImi.WorkerImi = append(tfImi.WorkerImi, models.WorkerImi{ + ImiName: types.StringValue(workerImis.ImiName), + Info: types.StringValue(workerImis.Info), + IsDefaultImi: types.BoolValue(workerImis.IsDefaultImi), + }) + } + allImis = append(allImis, tfImi) + } + filteredImages := filterImis(allImis, state.Filters, state.Latest.ValueBool()) + + state.Imis = append(state.Imis, filteredImages...) + if len(filteredImages) > 0 { + state.Imis = filteredImages + state.Result = &filteredImages[0] + } else { + state.Imis = []models.ImisModel{} + state.Result = nil + } + + // Set state + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func filterImis(allImis []models.ImisModel, filters []KVFilter, latest bool) []models.ImisModel { + filteredImages := allImis + + for _, filter := range filters { + switch filter.Key { + case "instance-type": + filteredImages = filterByInstanceType(filteredImages, filter.Values, latest) + default: + return allImis + } + } + return filteredImages +} + +func filterByInstanceType(allImis []models.ImisModel, values []string, latest bool) []models.ImisModel { + filteredImages := []models.ImisModel{} + for _, v := range values { + for _, imi := range allImis { + if strings.Contains(imi.InstanceTypeName, v) { + filteredImages = append(filteredImages, imi) + } + } + } + if latest && len(filteredImages) > 0 { + getLatestImi(filteredImages) + } + return filteredImages +} + +// getLatestImi sorts the WorkerImi slice in each ImisModel based on ImiName and returns latest IMI +func getLatestImi(imisList []models.ImisModel) { + for i := range imisList { + sort.Slice(imisList[i].WorkerImi, func(a, b int) bool { + return imisList[i].WorkerImi[a].ImiName.ValueString() < imisList[i].WorkerImi[b].ImiName.ValueString() + }) + } + imisList[0].WorkerImi = imisList[0].WorkerImi[:1] +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index c174925..1b436de 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -226,6 +226,7 @@ func (p *idcProvider) DataSources(_ context.Context) []func() datasource.DataSou NewMachineImagesDataSource, // NewKubernetesDataSource, NewKubeconfigDataSource, + NewImisDataSource, } } diff --git a/pkg/itacservices/client.go b/pkg/itacservices/client.go index afff471..8ee5653 100644 --- a/pkg/itacservices/client.go +++ b/pkg/itacservices/client.go @@ -59,7 +59,7 @@ func NewClient(ctx context.Context, host, tokenSvc, cloudaccount, clientid, clie req, err := http.NewRequest("POST", parsedURL, bytes.NewBufferString(data.Encode())) if err != nil { - return nil, fmt.Errorf("error creating ITAC Token request") + return nil, fmt.Errorf("error creating ITAC Token request: %v", err) } authStr := fmt.Sprintf("%s:%s", *clientid, *clientsecret) @@ -100,5 +100,6 @@ func NewClient(ctx context.Context, host, tokenSvc, cloudaccount, clientid, clie Region: region, Apitoken: &tokenResp.AccessToken, ExpireAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), + APIClient: common.NewAPIClient(), }, nil } diff --git a/pkg/itacservices/common/httpclient.go b/pkg/itacservices/common/httpclient.go index b77a32d..1fa7fb3 100644 --- a/pkg/itacservices/common/httpclient.go +++ b/pkg/itacservices/common/httpclient.go @@ -165,3 +165,36 @@ func printRequest(req *http.Request) { req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) } } + +type apiClientImpl struct{} + +// NewAPIClient returns a concrete implementation of the APIClient interface. +func NewAPIClient() APIClient { + return &apiClientImpl{} +} + +func (c *apiClientImpl) MakeGetAPICall(ctx context.Context, url, token string, headers map[string]string) (int, []byte, error) { + return MakeGetAPICall(ctx, url, token, nil) +} + +func (c *apiClientImpl) MakePOSTAPICall(ctx context.Context, url, token string, payload []byte) (int, []byte, error) { + return MakePOSTAPICall(ctx, url, token, payload) +} + +func (c *apiClientImpl) MakePutAPICall(ctx context.Context, url, token string, payload []byte) (int, []byte, error) { + return MakePutAPICall(ctx, url, token, payload) +} + +func (c *apiClientImpl) MakeDeleteAPICall(ctx context.Context, url, token string, headers map[string]string) (int, []byte, error) { + return MakeDeleteAPICall(ctx, url, token, nil) +} + +func (c *apiClientImpl) GenerateFilesystemLoginCredentials(ctx context.Context, resourceId string) (*string, error) { + // Placeholder: implement your actual logic here + return nil, fmt.Errorf("GenerateFilesystemLoginCredentials not implemented") +} + +func (c *apiClientImpl) ParseString(tmpl string, data any) (string, error) { + // Placeholder: implement your actual logic here + return ParseString(tmpl, data) +} diff --git a/pkg/itacservices/data_sources.go b/pkg/itacservices/data_sources.go index 0263b1b..c6f408d 100644 --- a/pkg/itacservices/data_sources.go +++ b/pkg/itacservices/data_sources.go @@ -11,8 +11,9 @@ import ( ) var ( - getAllMachineImagesURL = "{{.Host}}/v1/machineimages" - getAllInstanceTypesURL = "{{.Host}}/v1/instancetypes" + getAllMachineImagesURL = "{{.Host}}/v1/machineimages" + getAllInstanceTypesURL = "{{.Host}}/v1/instancetypes" + getPublicInstanceTypesImisURL = "{{.Host}}/v1/cloudaccounts/{{.Cloudaccount}}/iks/clusters/{{.ClusterUUID}}/metadata/imis" ) type MachineImageResponse struct { @@ -29,6 +30,17 @@ type MachineImageResponse struct { } `json:"items"` } +type ImisResponse struct { + InstanceTypes []struct { + Name string `json:"instancetypename"` + WorkerImi []struct { + ImiName string `json:"imiName"` + Info string `json:"info"` + IsDefaultImi bool `json:"isDefaultImi"` + } `json:"workerImi"` + } `json:"instanceTypes"` +} + type InstanceTypeResponse struct { Items []struct { Metadata struct { @@ -98,3 +110,36 @@ func (client *IDCServicesClient) GetInstanceTypes(ctx context.Context) (*Instanc return &instType, nil } + +func (client *IDCServicesClient) GetImis(ctx context.Context, clusterUUID string) (*ImisResponse, error) { + params := struct { + Host string + Cloudaccount string + ClusterUUID string + }{ + Host: *client.Host, + Cloudaccount: *client.Cloudaccount, + ClusterUUID: clusterUUID, + } + + // Parse the template string with the provided data + parsedURL, err := client.APIClient.ParseString(getPublicInstanceTypesImisURL, params) + if err != nil { + return nil, fmt.Errorf("error parsing the url for getting ") + } + retcode, retval, err := client.APIClient.MakeGetAPICall(ctx, parsedURL, *client.Apitoken, nil) + if err != nil { + tflog.Debug(ctx, "imis api error", map[string]any{"retcode": retcode, "err": err, "token": *client.Apitoken}) + return nil, fmt.Errorf("error reading imis") + } + tflog.Debug(ctx, "imis api", map[string]any{"retcode": retcode, "retval": string(retval), "token": *client.Apitoken}) + if retcode != http.StatusOK { + return nil, common.MapHttpError(retcode, retval) + } + + imis := ImisResponse{} + if err := json.Unmarshal(retval, &imis); err != nil { + return nil, fmt.Errorf("error parsing machine image response") + } + return &imis, nil +} diff --git a/pkg/itacservices/kubernetes.go b/pkg/itacservices/kubernetes.go index 96d9b4d..f0221f8 100644 --- a/pkg/itacservices/kubernetes.go +++ b/pkg/itacservices/kubernetes.go @@ -99,6 +99,7 @@ type IKSNodeGroupCreateRequest struct { Name string `json:"name"` ProductType string `json:"instanceType"` InstanceTypeId string `json:"instancetypeid"` + WorkerImiId string `json:"workerImi"` SSHKeyNames []SKey `json:"sshkeyname"` UserDataURL string `json:"userdataurl"` Vnets []Vnet `json:"vnets"` diff --git a/pkg/itacservices/tests/datasources_test.go b/pkg/itacservices/tests/datasources_test.go new file mode 100644 index 0000000..f571d54 --- /dev/null +++ b/pkg/itacservices/tests/datasources_test.go @@ -0,0 +1,104 @@ +package itacservices_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + "terraform-provider-intelcloud/pkg/itacservices" + "terraform-provider-intelcloud/pkg/mocks" +) + +func TestGetImis(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockAPI := mocks.NewMockAPIClient(ctrl) + + idcClient := &itacservices.IDCServicesClient{ + Host: strPtr("https://example.com"), + Cloudaccount: strPtr("my-account"), + Apitoken: strPtr("secret-token"), + APIClient: mockAPI, + } + + ctx := context.Background() + expectedURL := "https://example.com/public/cloudresources/instanceTypesImis?cloudaccount=my-account" + + // Sample ImisResponse object and corresponding JSON + expectedResponse := itacservices.ImisResponse{ + InstanceTypes: []struct { + Name string `json:"instancetypename"` + WorkerImi []struct { + ImiName string `json:"imiName"` + Info string `json:"info"` + IsDefaultImi bool `json:"isDefaultImi"` + } `json:"workerImi"` + }{ + { + Name: "vm-spr-sml", + WorkerImi: []struct { + ImiName string `json:"imiName"` + Info string `json:"info"` + IsDefaultImi bool `json:"isDefaultImi"` + }{ + { + ImiName: "iks-vm-u22-wk-1-30-3-v250330", + Info: "", + IsDefaultImi: true, + }, + { + ImiName: "iks-vm-u22-wk-1-30-3-v250404", + Info: "", + IsDefaultImi: false, + }, + }, + }, + { + Name: "bm-icx-gaudi2", + WorkerImi: []struct { + ImiName string `json:"imiName"` + Info string `json:"info"` + IsDefaultImi bool `json:"isDefaultImi"` + }{ + { + ImiName: "iks-gd-u22-cd-wk-1-30-3-habana-v1.20.1-hwe-v250403", + Info: "", + IsDefaultImi: true, + }, + }, + }, + }, + } + + // Marshal sample response to JSON + mockJSON, err := json.Marshal(expectedResponse) + require.NoError(t, err) + + // Expect ParseString to be called and return the expected URL + mockAPI.EXPECT(). + ParseString(gomock.Any(), gomock.Any()). + Return(expectedURL, nil) + + // Expect MakeGetAPICall to be called and return the sample JSON + mockAPI.EXPECT(). + MakeGetAPICall(ctx, expectedURL, "secret-token", gomock.Nil()). + Return(http.StatusOK, mockJSON, nil) + + // Call the function + resp, err := idcClient.GetImis(ctx, "clusteruuid-1") + + // Verify + require.NoError(t, err) + require.Equal(t, expectedResponse, *resp) + require.Equal(t, len(resp.InstanceTypes), 2) + require.Equal(t, resp.InstanceTypes[0].Name, "vm-spr-sml") + require.Equal(t, resp.InstanceTypes[0].WorkerImi[0].ImiName, "iks-vm-u22-wk-1-30-3-v250330") + require.Equal(t, resp.InstanceTypes[0].WorkerImi[0].IsDefaultImi, true) + require.Equal(t, resp.InstanceTypes[0].WorkerImi[1].ImiName, "iks-vm-u22-wk-1-30-3-v250404") + require.Equal(t, resp.InstanceTypes[1].Name, "bm-icx-gaudi2") +}