diff --git a/docs/resources/listening_tentacle_worker.md b/docs/resources/listening_tentacle_worker.md new file mode 100644 index 00000000..78c46b61 --- /dev/null +++ b/docs/resources/listening_tentacle_worker.md @@ -0,0 +1,62 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "octopusdeploy_listening_tentacle_worker Resource - terraform-provider-octopusdeploy" +subcategory: "" +description: |- + This resource manages a listening tentacle worker in Octopus Deploy. +--- + +# octopusdeploy_listening_tentacle_worker (Resource) + +This resource manages a listening tentacle worker in Octopus Deploy. + +## Example Usage + +```terraform +resource "octopusdeploy_listening_tentacle_worker" "minimum" { + name = "listening_worker" + machine_policy_id = "machine-policy-1" + worker_pools = ["worker-pools-1", "worker-pools-2"] + thumbprint = "96203ED84246201C26A2F4360D7CBC36AC1D232D" + uri = "https://tentacle.listening/" +} + +resource "octopusdeploy_listening_tentacle_worker" "optionals" { + name = "optional_worker" + machine_policy_id = "machine-policy-1" + worker_pools = ["worker-pools-1"] + thumbprint = "96203ED84246201C26A2F4360D7CBC36AC1D232D" + uri = "https://tentacle.listening/" + proxy_id = "proxys-1" + is_disabled = true +} +``` + + +## Schema + +### Required + +- `machine_policy_id` (String) Select the machine policy +- `name` (String) The name of this resource. +- `thumbprint` (String) The X509 certificate thumbprint that securely identifies the Tentacle +- `uri` (String) The network address at which the Tentacle can be reached +- `worker_pool_ids` (List of String) Select at least one worker pool for the worker + +### Optional + +- `is_disabled` (Boolean) When disabled, worker will not be included in any deployments +- `proxy_id` (String) Specify the connection type for the Tentacle: direct(when not set) or via a proxy server. +- `space_id` (String) The space ID associated with this Listening tentacle worker. + +### Read-Only + +- `id` (String) The unique ID for this resource. + +## Import + +Import is supported using the following syntax: + +```shell +terraform import [options] octopusdeploy_listening_tentacle_worker. +``` diff --git a/examples/resources/octopusdeploy_listening_tentacle_worker/import.sh b/examples/resources/octopusdeploy_listening_tentacle_worker/import.sh new file mode 100644 index 00000000..90d6c7c9 --- /dev/null +++ b/examples/resources/octopusdeploy_listening_tentacle_worker/import.sh @@ -0,0 +1 @@ +terraform import [options] octopusdeploy_listening_tentacle_worker. \ No newline at end of file diff --git a/examples/resources/octopusdeploy_listening_tentacle_worker/resource.tf b/examples/resources/octopusdeploy_listening_tentacle_worker/resource.tf new file mode 100644 index 00000000..5cc29945 --- /dev/null +++ b/examples/resources/octopusdeploy_listening_tentacle_worker/resource.tf @@ -0,0 +1,17 @@ +resource "octopusdeploy_listening_tentacle_worker" "minimum" { + name = "listening_worker" + machine_policy_id = "machine-policy-1" + worker_pools = ["worker-pools-1", "worker-pools-2"] + thumbprint = "96203ED84246201C26A2F4360D7CBC36AC1D232D" + uri = "https://tentacle.listening/" +} + +resource "octopusdeploy_listening_tentacle_worker" "optionals" { + name = "optional_worker" + machine_policy_id = "machine-policy-1" + worker_pools = ["worker-pools-1"] + thumbprint = "96203ED84246201C26A2F4360D7CBC36AC1D232D" + uri = "https://tentacle.listening/" + proxy_id = "proxys-1" + is_disabled = true +} diff --git a/go.mod b/go.mod index 8dcc68c2..aac36869 100644 --- a/go.mod +++ b/go.mod @@ -142,3 +142,5 @@ require ( google.golang.org/protobuf v1.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + + diff --git a/octopusdeploy_framework/framework_provider.go b/octopusdeploy_framework/framework_provider.go index 700e8c62..74b7dc24 100644 --- a/octopusdeploy_framework/framework_provider.go +++ b/octopusdeploy_framework/framework_provider.go @@ -112,6 +112,7 @@ func (p *octopusDeployFrameworkProvider) Resources(ctx context.Context) []func() NewRunbookResource, NewTenantResource, NewTentacleCertificateResource, + NewListeningTentacleWorkerResource, NewScriptModuleResource, NewUserResource, } diff --git a/octopusdeploy_framework/resource_listening_tentacle_worker.go b/octopusdeploy_framework/resource_listening_tentacle_worker.go new file mode 100644 index 00000000..fefd2db5 --- /dev/null +++ b/octopusdeploy_framework/resource_listening_tentacle_worker.go @@ -0,0 +1,174 @@ +package octopusdeploy_framework + +import ( + "context" + "fmt" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workers" + "github.com/hashicorp/terraform-plugin-framework/path" + "net/url" + + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/internal/errors" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/schemas" + "github.com/OctopusDeploy/terraform-provider-octopusdeploy/octopusdeploy_framework/util" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type listeningTentacleWorkerResource struct { + *Config +} + +func NewListeningTentacleWorkerResource() resource.Resource { + return &listeningTentacleWorkerResource{} +} + +var _ resource.ResourceWithImportState = &listeningTentacleWorkerResource{} + +func (r *listeningTentacleWorkerResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = util.GetTypeName("listening_tentacle_worker") +} + +func (r *listeningTentacleWorkerResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schemas.ListeningTentacleWorkerSchema{}.GetResourceSchema() +} + +func (r *listeningTentacleWorkerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.Config = ResourceConfiguration(req, resp) +} + +func (r *listeningTentacleWorkerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *schemas.ListeningTentacleWorkerResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + worker := createListeningTentacleWorkerResource(ctx, data) + + tflog.Info(ctx, fmt.Sprintf("creating listening tentacle worker: %s", data.Name.ValueString())) + + client := r.Config.Client + createdWorker, err := workers.Add(client, worker) + if err != nil { + resp.Diagnostics.AddError("unable to create listening tentacle worker", err.Error()) + return + } + + updateDataFromListeningTentacleWorker(ctx, data, data.SpaceID.ValueString(), createdWorker) + + tflog.Info(ctx, fmt.Sprintf("listening tentacle worker created (%s)", data.ID)) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *listeningTentacleWorkerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *schemas.ListeningTentacleWorkerResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, fmt.Sprintf("reading listening tentacle worker (%s)", data.ID)) + + client := r.Config.Client + worker, err := workers.GetByID(client, data.SpaceID.ValueString(), data.ID.ValueString()) + if err != nil { + if err := errors.ProcessApiErrorV2(ctx, resp, data, err, "listening tentacle worker"); err != nil { + resp.Diagnostics.AddError("unable to load listening tentacle worker", err.Error()) + } + return + } + + if worker.Endpoint.GetCommunicationStyle() != "TentaclePassive" { + resp.Diagnostics.AddError("unable to load listening tentacle worker", "found resource is not listening tentacle worker") + return + } + + updateDataFromListeningTentacleWorker(ctx, data, data.SpaceID.ValueString(), worker) + + tflog.Info(ctx, fmt.Sprintf("listening tentacle worker read (%s)", worker.GetID())) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *listeningTentacleWorkerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data, state *schemas.ListeningTentacleWorkerResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("updating listening tentacle worker '%s'", data.ID.ValueString())) + + worker := createListeningTentacleWorkerResource(ctx, data) + worker.ID = state.ID.ValueString() + + tflog.Info(ctx, fmt.Sprintf("updating listening tentacle worker (%s)", data.ID)) + + client := r.Config.Client + updatedWorker, err := workers.Update(client, worker) + if err != nil { + resp.Diagnostics.AddError("unable to update listening tentacle worker", err.Error()) + return + } + + updateDataFromListeningTentacleWorker(ctx, data, state.SpaceID.ValueString(), updatedWorker) + + tflog.Info(ctx, fmt.Sprintf("listening tentacle worker updated (%s)", data.ID)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *listeningTentacleWorkerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data schemas.ListeningTentacleWorkerResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := workers.DeleteByID(r.Config.Client, data.SpaceID.ValueString(), data.ID.ValueString()); err != nil { + resp.Diagnostics.AddError("unable to delete listening tentacle worker", err.Error()) + return + } +} + +func createListeningTentacleWorkerResource(ctx context.Context, data *schemas.ListeningTentacleWorkerResourceModel) *machines.Worker { + uri, _ := url.Parse(data.Uri.ValueString()) + endpoint := machines.NewListeningTentacleEndpoint(uri, data.Thumbprint.ValueString()) + endpoint.ProxyID = data.ProxyID.ValueString() + + worker := machines.NewWorker(data.Name.ValueString(), endpoint) + worker.SpaceID = data.SpaceID.ValueString() + worker.IsDisabled = data.IsDisabled.ValueBool() + worker.MachinePolicyID = data.MachinePolicyID.ValueString() + + if !data.WorkerPoolIDs.IsNull() { + var workerPools []string + data.WorkerPoolIDs.ElementsAs(ctx, &workerPools, false) + worker.WorkerPoolIDs = workerPools + } + + return worker +} + +func updateDataFromListeningTentacleWorker(ctx context.Context, data *schemas.ListeningTentacleWorkerResourceModel, spaceId string, worker *machines.Worker) { + data.ID = types.StringValue(worker.ID) + data.SpaceID = types.StringValue(spaceId) + data.Name = types.StringValue(worker.Name) + data.IsDisabled = types.BoolValue(worker.IsDisabled) + data.MachinePolicyID = types.StringValue(worker.MachinePolicyID) + data.WorkerPoolIDs, _ = types.ListValueFrom(ctx, types.StringType, worker.WorkerPoolIDs) + + endpoint := worker.Endpoint.(*machines.ListeningTentacleEndpoint) + data.Uri = types.StringValue(endpoint.URI.String()) + data.Thumbprint = types.StringValue(endpoint.Thumbprint) + if endpoint.ProxyID != "" { + data.ProxyID = types.StringValue(endpoint.ProxyID) + } +} + +func (*listeningTentacleWorkerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/octopusdeploy_framework/resource_listening_tentacle_worker_test.go b/octopusdeploy_framework/resource_listening_tentacle_worker_test.go new file mode 100644 index 00000000..108fc287 --- /dev/null +++ b/octopusdeploy_framework/resource_listening_tentacle_worker_test.go @@ -0,0 +1,150 @@ +package octopusdeploy_framework + +import ( + "fmt" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workers" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "strconv" + "strings" + "testing" +) + +type listeningTentacleWorkerTestData struct { + name string + spaceID string + isDisabled bool + workerPoolIDs []string + machinePolicyID string + uri string + thumbprint string + proxyID string +} + +func TestAccOctopusDeployListeningTentacleWorker(t *testing.T) { + localName := acctest.RandStringFromCharSet(20, acctest.CharSetAlpha) + prefix := "octopusdeploy_listening_tentacle_worker." + localName + createData := listeningTentacleWorkerTestData{ + name: acctest.RandStringFromCharSet(20, acctest.CharSetAlpha), + workerPoolIDs: []string{acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)}, + machinePolicyID: acctest.RandStringFromCharSet(8, acctest.CharSetAlpha), + uri: "https://listening.test", + thumbprint: strconv.FormatInt(int64(acctest.RandIntRange(0, 1024)), 16), + } + updateData := listeningTentacleWorkerTestData{ + name: createData.name + "-updated", + workerPoolIDs: append(createData.workerPoolIDs, acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)), + machinePolicyID: acctest.RandStringFromCharSet(8, acctest.CharSetAlpha), + uri: "https://listening.test.updated", + thumbprint: strconv.FormatInt(int64(acctest.RandIntRange(0, 1024)), 16), + isDisabled: true, + proxyID: acctest.RandStringFromCharSet(8, acctest.CharSetAlpha), + } + + resource.Test(t, resource.TestCase{ + CheckDestroy: func(s *terraform.State) error { return testListeningTentacleWorkerCheckDestroy(s) }, + PreCheck: func() { TestAccPreCheck(t) }, + ProtoV6ProviderFactories: ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: testListeningTentacleWorkerMandatory(createData, localName), + Check: testAssertListeningTentacleWorkerMandatoryAttributes(createData, prefix), + }, + { + Config: testListeningTentacleWorkerAll(updateData, localName), + Check: testAssertListeningTentacleWorkerAllAttributes(updateData, prefix), + }, + }, + }) +} + +func testListeningTentacleWorkerMandatory(data listeningTentacleWorkerTestData, localName string) string { + return fmt.Sprintf(` + resource "octopusdeploy_listening_tentacle_worker" "%s" { + name = "%s" + machine_policy_id = "%s" + worker_pool_ids = "%s" + uri = "%s" + thumbprint = "%s" + } + `, + localName, + data.name, + data.machinePolicyID, + testSerializeWorkerPoolIdsForResource(data.workerPoolIDs), + data.uri, + data.thumbprint, + ) +} + +func testListeningTentacleWorkerAll(data listeningTentacleWorkerTestData, localName string) string { + return fmt.Sprintf(` + resource "octopusdeploy_listening_tentacle_worker" "%s" { + name = "%s" + machine_policy_id = "%s" + worker_pool_ids = "%s" + uri = "%s" + thumbprint = "%s" + proxy_id = "%s" + is_disabled = "%v" + } + `, + localName, + data.name, + data.machinePolicyID, + testSerializeWorkerPoolIdsForResource(data.workerPoolIDs), + data.uri, + data.thumbprint, + data.proxyID, + data.isDisabled, + ) +} + +func testAssertListeningTentacleWorkerMandatoryAttributes(expected listeningTentacleWorkerTestData, prefix string) resource.TestCheckFunc { + return resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(prefix, "name", expected.name), + resource.TestCheckResourceAttr(prefix, "machine_policy_id", expected.machinePolicyID), + resource.TestCheckResourceAttr(prefix, "worker_pool_ids", testSerializeWorkerPoolIdsForResource(expected.workerPoolIDs)), + resource.TestCheckResourceAttr(prefix, "uri", expected.uri), + resource.TestCheckResourceAttr(prefix, "thumbprint", expected.thumbprint), + resource.TestCheckNoResourceAttr(prefix, "proxy_id"), + resource.TestCheckNoResourceAttr(prefix, "is_disabled"), + ) +} + +func testAssertListeningTentacleWorkerAllAttributes(expected listeningTentacleWorkerTestData, prefix string) resource.TestCheckFunc { + return resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(prefix, "name", expected.name), + resource.TestCheckResourceAttr(prefix, "machine_policy_id", expected.machinePolicyID), + resource.TestCheckResourceAttr(prefix, "worker_pool_ids", testSerializeWorkerPoolIdsForResource(expected.workerPoolIDs)), + resource.TestCheckResourceAttr(prefix, "uri", expected.uri), + resource.TestCheckResourceAttr(prefix, "thumbprint", expected.thumbprint), + resource.TestCheckResourceAttr(prefix, "proxy_id", expected.proxyID), + resource.TestCheckResourceAttr(prefix, "is_disabled", strconv.FormatBool(expected.isDisabled)), + ) +} + +func testListeningTentacleWorkerCheckDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "octopusdeploy_listening_tentacle_worker" { + continue + } + + feed, err := workers.GetByID(octoClient, octoClient.GetSpaceID(), rs.Primary.ID) + if err == nil && feed != nil { + return fmt.Errorf("listening tentacle worker (%s) still exists", rs.Primary.ID) + } + } + + return nil +} + +func testSerializeWorkerPoolIdsForResource(poolIds []string) string { + quotedPoolIds := make([]string, len(poolIds)) + for i, poolId := range poolIds { + quotedPoolIds[i] = fmt.Sprintf(`"%s"`, poolId) + } + + return "[" + strings.Join(quotedPoolIds, ",") + "]" +} diff --git a/octopusdeploy_framework/schemas/listening_tentacle_worker.go b/octopusdeploy_framework/schemas/listening_tentacle_worker.go new file mode 100644 index 00000000..7f01fb9d --- /dev/null +++ b/octopusdeploy_framework/schemas/listening_tentacle_worker.go @@ -0,0 +1,54 @@ +package schemas + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type ListeningTentacleWorkerSchema struct{} + +func (m ListeningTentacleWorkerSchema) GetResourceSchema() resourceSchema.Schema { + return resourceSchema.Schema{ + Description: "This resource manages a listening tentacle worker in Octopus Deploy.", + Attributes: map[string]resourceSchema.Attribute{ + "id": GetIdResourceSchema(), + "name": GetNameResourceSchema(true), + "space_id": GetSpaceIdResourceSchema("Listening tentacle worker"), + "is_disabled": GetOptionalBooleanResourceAttribute("When disabled, worker will not be included in any deployments", false), + "machine_policy_id": GetRequiredStringResourceSchema("Select the machine policy"), + "uri": GetRequiredStringResourceSchema("The network address at which the Tentacle can be reached"), + "thumbprint": GetRequiredStringResourceSchema("The X509 certificate thumbprint that securely identifies the Tentacle"), + "proxy_id": GetOptionalStringResourceSchema("Specify the connection type for the Tentacle: direct(when not set) or via a proxy server."), + "worker_pool_ids": resourceSchema.ListAttribute{ + ElementType: types.StringType, + Description: "Select at least one worker pool for the worker", + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + }, + } +} + +func (m ListeningTentacleWorkerSchema) GetDatasourceSchema() datasourceSchema.Schema { + return datasourceSchema.Schema{} +} + +var _ EntitySchema = ListeningTentacleWorkerSchema{} + +type ListeningTentacleWorkerResourceModel struct { + Name types.String `tfsdk:"name"` + SpaceID types.String `tfsdk:"space_id"` + IsDisabled types.Bool `tfsdk:"is_disabled"` + WorkerPoolIDs types.List `tfsdk:"worker_pool_ids"` + MachinePolicyID types.String `tfsdk:"machine_policy_id"` + Uri types.String `tfsdk:"uri"` + Thumbprint types.String `tfsdk:"thumbprint"` + ProxyID types.String `tfsdk:"proxy_id"` + + ResourceModel +}