diff --git a/docs/resources/job.md b/docs/resources/job.md index 1395be4..9a89f4f 100644 --- a/docs/resources/job.md +++ b/docs/resources/job.md @@ -4,6 +4,8 @@ description: |- Launches an AAP job. A job is launched only when the resource is first created or when the resource is changed. The triggers argument can be used to launch a new job based on any arbitrary value. This resource always creates a new job in AAP. A destroy will not delete a job created by this resource, it will only remove the resource from the state. + Moreover, you can set wait_for_completion to true, then Terraform will wait until this job is created and reaches any final state before continuing. This parameter works in both create and update operations. + You can also tweak wait_for_completion_timeout_seconds to control the timeout limit. --- # aap_job (Resource) @@ -14,7 +16,11 @@ A job is launched only when the resource is first created or when the resource i This resource always creates a new job in AAP. A destroy will not delete a job created by this resource, it will only remove the resource from the state. --> **Note** To pass an inventory to an aap_job resource, the underlying job template *must* have been conigured to prompt for the inventory on launch. +Moreover, you can set `wait_for_completion` to true, then Terraform will wait until this job is created and reaches any final state before continuing. This parameter works in both create and update operations. + +You can also tweak `wait_for_completion_timeout_seconds` to control the timeout limit. + +-> **Note** To pass an inventory to an aap_job resource, the underlying job template *must* have been configured to prompt for the inventory on launch. !> **Warning** If an AAP Job launched by this resource is deleted from AAP, the resource will be removed from the state and a new job will be created to replace it. @@ -86,6 +92,13 @@ resource "aap_job" "sample_xyz" { extra_vars = "os: Linux\nautomation: ansible-devel" } +resource "aap_job" "sample_wait_for_completion" { + job_template_id = 9 + inventory_id = aap_inventory.my_inventory.id + wait_for_completion = true + wait_for_completion_timeout_seconds = 120 +} + output "job_foo" { value = aap_job.sample_foo } @@ -120,6 +133,8 @@ output "job_xyz" { - `extra_vars` (String) Extra Variables. Must be provided as either a JSON or YAML string. - `inventory_id` (Number) Identifier for the inventory where job should be created in. If not provided, the job will be created in the default inventory. - `triggers` (Map of String) Map of arbitrary keys and values that, when changed, will trigger a creation of a new Job on AAP. Use 'terraform taint' if you want to force the creation of a new job without changing this value. +- `wait_for_completion` (Boolean) When this is set to `true`, Terraform will wait until this aap_job resource is created, reaches any final status and then, proceeds with the following resource operation +- `wait_for_completion_timeout_seconds` (Number) Sets the maximum amount of seconds Terraform will wait before timing out the updates, and the job creation will fail. Default value of `120` ### Read-Only diff --git a/examples/resources/aap_job/resource.tf b/examples/resources/aap_job/resource.tf index 340d504..01270b8 100644 --- a/examples/resources/aap_job/resource.tf +++ b/examples/resources/aap_job/resource.tf @@ -62,6 +62,13 @@ resource "aap_job" "sample_xyz" { extra_vars = "os: Linux\nautomation: ansible-devel" } +resource "aap_job" "sample_wait_for_completion" { + job_template_id = 9 + inventory_id = aap_inventory.my_inventory.id + wait_for_completion = true + wait_for_completion_timeout_seconds = 120 +} + output "job_foo" { value = aap_job.sample_foo } diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go index 7d802b6..c90c10b 100644 --- a/internal/provider/job_resource.go +++ b/internal/provider/job_resource.go @@ -7,17 +7,24 @@ import ( "fmt" "net/http" "path" + "time" "github.com/ansible/terraform-provider-aap/internal/provider/customtypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" ) +// Default value for the wait_for_completion timeout, so the linter doesn't complain. +const waitForCompletionTimeoutDefault int64 = 120 + // Job AAP API model type JobAPIModel struct { TemplateID int64 `json:"job_template,omitempty"` @@ -31,14 +38,16 @@ type JobAPIModel struct { // JobResourceModel maps the resource schema data. type JobResourceModel struct { - TemplateID types.Int64 `tfsdk:"job_template_id"` - Type types.String `tfsdk:"job_type"` - URL types.String `tfsdk:"url"` - Status types.String `tfsdk:"status"` - InventoryID types.Int64 `tfsdk:"inventory_id"` - ExtraVars customtypes.AAPCustomStringValue `tfsdk:"extra_vars"` - IgnoredFields types.List `tfsdk:"ignored_fields"` - Triggers types.Map `tfsdk:"triggers"` + TemplateID types.Int64 `tfsdk:"job_template_id"` + Type types.String `tfsdk:"job_type"` + URL types.String `tfsdk:"url"` + Status types.String `tfsdk:"status"` + InventoryID types.Int64 `tfsdk:"inventory_id"` + ExtraVars customtypes.AAPCustomStringValue `tfsdk:"extra_vars"` + IgnoredFields types.List `tfsdk:"ignored_fields"` + Triggers types.Map `tfsdk:"triggers"` + WaitForCompletion types.Bool `tfsdk:"wait_for_completion"` + WaitForCompletionTimeout types.Int64 `tfsdk:"wait_for_completion_timeout_seconds"` } // JobResource is the resource implementation. @@ -61,6 +70,40 @@ func NewJobResource() resource.Resource { return &JobResource{} } +// Given a string with the name of an AAP Job state, this function returns `true` +// if such state is final and cannot transition further; a.k.a, the job is completed. +func IsFinalStateAAPJob(state string) bool { + finalStates := map[string]bool{ + "new": false, + "pending": false, + "waiting": false, + "running": false, + "successful": true, + "failed": true, + "error": true, + "canceled": true, + } + result, isPresent := finalStates[state] + return isPresent && result +} + +func retryUntilAAPJobReachesAnyFinalState(client ProviderHTTPClient, model JobResourceModel, diagnostics diag.Diagnostics) retry.RetryFunc { + return func() *retry.RetryError { + responseBody, err := client.Get(model.URL.ValueString()) + diagnostics.Append(model.ParseHttpResponse(responseBody)...) + if err != nil { + return retry.RetryableError(fmt.Errorf("error fetching job status: %s", err)) + } + fmt.Printf("Job ID: %s, Current Status: %s\n", model.TemplateID, model.Status.ValueString()) + + if !IsFinalStateAAPJob(model.Status.ValueString()) { + return retry.RetryableError(fmt.Errorf("job at: %s hasn't yet reached a final state. Current state: %s", model.URL, model.Status.ValueString())) + } else { + return nil + } + } +} + // Metadata returns the resource type name. func (r *JobResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_job" @@ -132,6 +175,20 @@ func (r *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * Computed: true, Description: "The list of properties set by the user but ignored on server side.", }, + "wait_for_completion": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "When this is set to `true`, Terraform will wait until this aap_job resource is created, reaches " + + "any final status and then, proceeds with the following resource operation", + }, + "wait_for_completion_timeout_seconds": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(waitForCompletionTimeoutDefault), + Description: "Sets the maximum amount of seconds Terraform will wait before timing out the updates, " + + "and the job creation will fail. Default value of `120`", + }, }, MarkdownDescription: "Launches an AAP job.\n\n" + "A job is launched only when the resource is first created or when the " + @@ -139,7 +196,11 @@ func (r *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * "launch a new job based on any arbitrary value.\n\n" + "This resource always creates a new job in AAP. A destroy will not " + "delete a job created by this resource, it will only remove the resource " + - "from the state.", + "from the state.\n\n" + + "Moreover, you can set `wait_for_completion` to true, then Terraform will " + + "wait until this job is created and reaches any final state before continuing. " + + "This parameter works in both create and update operations.\n\n" + + "You can also tweak `wait_for_completion_timeout_seconds` to control the timeout limit.", } } @@ -157,6 +218,19 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re return } + // If the job was configured to wait for completion, start polling the job status + // and wait for it to complete before marking the resource as created + if data.WaitForCompletion.ValueBool() { + timeout := time.Duration(data.WaitForCompletionTimeout.ValueInt64()) * time.Second + err := retry.RetryContext(ctx, timeout, retryUntilAAPJobReachesAnyFinalState(r.client, data, resp.Diagnostics)) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic("error when waiting for AAP job to complete", err.Error())) + } + if resp.Diagnostics.HasError() { + return + } + } + // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) if resp.Diagnostics.HasError() { @@ -220,6 +294,19 @@ func (r *JobResource) Update(ctx context.Context, req resource.UpdateRequest, re return } + // If the job was configured to wait for completion, start polling the job status + // and wait for it to complete before marking the resource as created + if data.WaitForCompletion.ValueBool() { + timeout := time.Duration(data.WaitForCompletionTimeout.ValueInt64()) * time.Second + err := retry.RetryContext(ctx, timeout, retryUntilAAPJobReachesAnyFinalState(r.client, data, resp.Diagnostics)) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic("error when waiting for AAP job to complete", err.Error())) + } + if resp.Diagnostics.HasError() { + return + } + } + // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) if resp.Diagnostics.HasError() { diff --git a/internal/provider/job_resource_test.go b/internal/provider/job_resource_test.go index 364b14e..a5679f8 100644 --- a/internal/provider/job_resource_test.go +++ b/internal/provider/job_resource_test.go @@ -99,6 +99,16 @@ func TestJobResourceCreateRequestBody(t *testing.T) { }, expected: []byte(`{"inventory": 3}`), }, + { + name: "wait_for_completed parameters", + input: JobResourceModel{ + InventoryID: basetypes.NewInt64Value(3), + TemplateID: types.Int64Value(1), + WaitForCompletion: basetypes.NewBoolValue(true), + WaitForCompletionTimeout: basetypes.NewInt64Value(60), + }, + expected: []byte(`{"inventory":3}`), + }, } for _, tc := range testTable { diff --git a/templates/resources/job.md.tmpl b/templates/resources/job.md.tmpl index aa6291c..60747c1 100644 --- a/templates/resources/job.md.tmpl +++ b/templates/resources/job.md.tmpl @@ -8,7 +8,7 @@ description: |- {{ .Description | trimspace }} --> **Note** To pass an inventory to an aap_job resource, the underlying job template *must* have been conigured to prompt for the inventory on launch. +-> **Note** To pass an inventory to an aap_job resource, the underlying job template *must* have been configured to prompt for the inventory on launch. !> **Warning** If an AAP Job launched by this resource is deleted from AAP, the resource will be removed from the state and a new job will be created to replace it.