diff --git a/docs/resources/template.md b/docs/resources/template.md new file mode 100644 index 0000000..d8c9d4a --- /dev/null +++ b/docs/resources/template.md @@ -0,0 +1,48 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_template Resource - coderd" +subcategory: "" +description: |- + A Coder template +--- + +# coderd_template (Resource) + +A Coder template + + + + +## Schema + +### Required + +- `name` (String) The name of the template. + +### Optional + +- `description` (String) A description of the template. +- `display_name` (String) The display name of the template. Defaults to the template name. +- `organization_id` (String) The ID of the organization. Defaults to the provider's default organization +- `version` (Block List) (see [below for nested schema](#nestedblock--version)) + +### Read-Only + +- `id` (String) The ID of the template. + + +### Nested Schema for `version` + +Required: + +- `directory` (String) A path to the directory to create the template version from. Changes in the directory contents will trigger the creation of a new template version. + +Optional: + +- `active` (Boolean) Whether this version is the active version of the template. Only one version can be active at a time. +- `message` (String) A message describing the changes in this version of the template. Messages longer than 72 characters will be truncated.. +- `name` (String) The name of the template version. Automatically generated if not provided. + +Read-Only: + +- `directory_hash` (String) diff --git a/go.mod b/go.mod index 5ed8dc2..709e227 100644 --- a/go.mod +++ b/go.mod @@ -120,6 +120,7 @@ require ( github.com/secure-systems-lab/go-securesystemslib v0.7.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tinylib/msgp v1.1.8 // indirect diff --git a/go.sum b/go.sum index beb75bb..92a60fd 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 h1:KHblWIE/KHOwQ6lEbMZt6YpcGve2FEZ1sDtrW1Am5UI= cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ= -cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o= +cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y= cloud.google.com/go/compute v1.25.1 h1:ZRpHJedLtTpKgr3RV1Fx23NuaAEN1Zfx9hw1u4aJdjU= cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= @@ -132,6 +132,8 @@ github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= +github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= @@ -388,6 +390,8 @@ github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= diff --git a/integration/integration_test.go b/integration/integration_test.go index 2d843fa..f3bbbb9 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -101,6 +101,11 @@ func TestIntegration(t *testing.T) { assert.Equal(t, group.QuotaAllowance, 100) }, }, + { + name: "template-test", + preF: func(t testing.TB, c *codersdk.Client) {}, + assertF: func(t testing.TB, c *codersdk.Client) {}, + }, } { t.Run(tt.name, func(t *testing.T) { client := StartCoder(ctx, t, tt.name, true) diff --git a/integration/template-test/example-template-2/new b/integration/template-test/example-template-2/new new file mode 100644 index 0000000..2e65efe --- /dev/null +++ b/integration/template-test/example-template-2/new @@ -0,0 +1 @@ +a \ No newline at end of file diff --git a/integration/template-test/example-template/main.tf b/integration/template-test/example-template/main.tf new file mode 100644 index 0000000..c607b38 --- /dev/null +++ b/integration/template-test/example-template/main.tf @@ -0,0 +1,12 @@ +variable "name" { + type = string +} + +resource "local_file" "a" { + filename = "${path.module}/a.txt" + content = "hello ${var.name}" +} + +output "a" { + value = local_file.a.content +} \ No newline at end of file diff --git a/integration/template-test/example-template/terraform.tfvars b/integration/template-test/example-template/terraform.tfvars new file mode 100644 index 0000000..92949ac --- /dev/null +++ b/integration/template-test/example-template/terraform.tfvars @@ -0,0 +1 @@ +name = "world" \ No newline at end of file diff --git a/integration/template-test/main.tf b/integration/template-test/main.tf new file mode 100644 index 0000000..7b50a67 --- /dev/null +++ b/integration/template-test/main.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + coderd = { + source = "coder/coderd" + version = ">=0.0.0" + } + } +} + +resource "coderd_template" "sample" { + name = "example-template" + versions = [ + { + name = "v1" + directory = "./example-template" + } + ] +} \ No newline at end of file diff --git a/internal/provider/logger.go b/internal/provider/logger.go new file mode 100644 index 0000000..a17fc65 --- /dev/null +++ b/internal/provider/logger.go @@ -0,0 +1,45 @@ +package provider + +import ( + "context" + + "cdr.dev/slog" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ slog.Sink = &tfLogSink{} + +type tfLogSink struct { + tfCtx context.Context +} + +func newTFLogSink(tfCtx context.Context) *tfLogSink { + return &tfLogSink{ + tfCtx: tfCtx, + } +} + +func (s *tfLogSink) LogEntry(ctx context.Context, e slog.SinkEntry) { + var logFn func(ctx context.Context, msg string, additionalFields ...map[string]interface{}) + switch e.Level { + case slog.LevelDebug: + logFn = tflog.Debug + case slog.LevelInfo: + logFn = tflog.Info + case slog.LevelWarn: + logFn = tflog.Warn + default: + logFn = tflog.Error + } + logFn(s.tfCtx, e.Message, mapToFields(e.Fields)) +} + +func (s *tfLogSink) Sync() {} + +func mapToFields(m slog.Map) map[string]interface{} { + fields := make(map[string]interface{}, len(m)) + for _, v := range m { + fields[v.Name] = v.Value + } + return fields +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1b67191..2c63823 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -123,6 +123,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour return []func() resource.Resource{ NewUserResource, NewGroupResource, + NewTemplateResource, } } diff --git a/internal/provider/template_resource.go b/internal/provider/template_resource.go new file mode 100644 index 0000000..fad81eb --- /dev/null +++ b/internal/provider/template_resource.go @@ -0,0 +1,433 @@ +package provider + +import ( + "bufio" + "context" + "fmt" + "io" + + "cdr.dev/slog" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &TemplateResource{} +var _ resource.ResourceWithImportState = &TemplateResource{} +var _ resource.ResourceWithConfigValidators = &TemplateResource{} + +func NewTemplateResource() resource.Resource { + return &TemplateResource{} +} + +// TemplateResource defines the resource implementation. +type TemplateResource struct { + data *CoderdProviderData +} + +// TemplateResourceModel describes the resource data model. +type TemplateResourceModel struct { + ID types.String `tfsdk:"id"` + + Name types.String `tfsdk:"name"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + OrganizationID types.String `tfsdk:"organization_id"` + + Versions []TemplateVersion `tfsdk:"versions"` +} + +type TemplateVersion struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Message types.String `tfsdk:"message"` + Directory types.String `tfsdk:"directory"` + DirectoryHash types.String `tfsdk:"directory_hash"` + Active types.Bool `tfsdk:"active"` + Variables []Variable `tfsdk:"vars"` +} + +type Variable struct { + Name types.String `tfsdk:"name"` + Value types.String `tfsdk:"value"` +} + +func (r *TemplateResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_template" +} + +func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "A Coder template", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The ID of the template.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the template.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 32), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "The display name of the template. Defaults to the template name.", + Optional: true, + Computed: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "A description of the template.", + Optional: true, + }, + "organization_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the organization. Defaults to the provider's default organization", + Optional: true, + Computed: true, + }, + "versions": schema.SetNestedAttribute{ + Computed: true, + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the template version. Automatically generated if not provided.", + Optional: true, + Computed: true, + }, + "message": schema.StringAttribute{ + MarkdownDescription: "A message describing the changes in this version of the template. Messages longer than 72 characters will be truncated..", + Optional: true, + }, + "directory": schema.StringAttribute{ + MarkdownDescription: "A path to the directory to create the template version from. Changes in the directory contents will trigger the creation of a new template version.", + Required: true, + }, + "directory_hash": schema.StringAttribute{ + Computed: true, + }, + "active": schema.BoolAttribute{ + MarkdownDescription: "Whether this version is the active version of the template. Only one version can be active at a time.", + Optional: true, + }, + "vars": schema.SetNestedAttribute{ + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Required: true, + }, + "value": schema.StringAttribute{ + Required: true, + }, + }, + }, + }, + }, + PlanModifiers: []planmodifier.Object{ + NewDirectoryHashPlanModifier(), + }, + }, + }, + // TODO: Rest of the fields + }, + } +} + +func (r *TemplateResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + data, ok := req.ProviderData.(*CoderdProviderData) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.data = data +} + +func (r *TemplateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data TemplateResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if data.OrganizationID.IsUnknown() { + data.OrganizationID = types.StringValue(r.data.DefaultOrganizationID) + } + + logger := slog.Make(newTFLogSink(ctx)) + + client := r.data.Client + orgID, err := uuid.Parse(data.OrganizationID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err)) + return + } + var templateResp codersdk.Template + for idx, version := range data.Versions { + uploadResp, err := uploadDirectory(ctx, client, logger, version.Directory.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to upload directory: %s", err)) + return + } + // TODO: Parse TFVars from file(?) + var values []codersdk.VariableValue + for _, variable := range version.Variables { + values = append(values, codersdk.VariableValue{ + Name: variable.Name.ValueString(), + Value: variable.Value.ValueString(), + }) + } + tmplVerReq := codersdk.CreateTemplateVersionRequest{ + Name: version.Name.ValueString(), + Message: version.Message.ValueString(), + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeTerraform, + FileID: uploadResp.ID, + UserVariableValues: values, + } + versionResp, err := client.CreateTemplateVersion(ctx, orgID, tmplVerReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to create template version: %s", err)) + return + } + waitForJob(ctx, client, &versionResp) + if idx == 0 { + templateResp, err = client.CreateTemplate(ctx, orgID, codersdk.CreateTemplateRequest{ + Name: data.Name.ValueString(), + VersionID: versionResp.ID, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to create template: %s", err)) + return + } + } + if version.Active.ValueBool() { + err := client.UpdateActiveTemplateVersion(ctx, templateResp.ID, codersdk.UpdateActiveTemplateVersion{ + ID: versionResp.ID, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update active template version: %s", err)) + return + } + } + data.Versions[idx].ID = types.StringValue(versionResp.ID.String()) + data.Versions[idx].Name = types.StringValue(versionResp.Name) + } + data.ID = types.StringValue(templateResp.ID.String()) + data.DisplayName = types.StringValue(templateResp.DisplayName) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data TemplateResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // client := r.data.Client + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data TemplateResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // client := r.data.Client + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *TemplateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data TemplateResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // client := r.data.Client +} + +func (r *TemplateResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +// ConfigValidators implements resource.ResourceWithConfigValidators. +func (r *TemplateResource) ConfigValidators(context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{} +} + +type activeVersionValidator struct{} + +// Description implements resource.ConfigValidator. +func (a *activeVersionValidator) Description(ctx context.Context) string { + return a.MarkdownDescription(ctx) +} + +// MarkdownDescription implements resource.ConfigValidator. +func (a *activeVersionValidator) MarkdownDescription(context.Context) string { + return "Validates that only one template version is active at a time." +} + +// ValidateResource implements resource.ConfigValidator. +func (a *activeVersionValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var data TemplateResourceModel + // Read Terraform config data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + // Check if only one item in Version has active set to true + active := true + for _, version := range data.Versions { + if version.Active.ValueBool() { + if active { + resp.Diagnostics.AddError("Client Error", "Only one template version can be active at a time.") + return + } + active = true + } + } +} + +var _ resource.ConfigValidator = &activeVersionValidator{} + +type directoryHashPlanModifier struct{} + +// Description implements planmodifier.Object. +func (d *directoryHashPlanModifier) Description(ctx context.Context) string { + return d.MarkdownDescription(ctx) +} + +// MarkdownDescription implements planmodifier.Object. +func (d *directoryHashPlanModifier) MarkdownDescription(context.Context) string { + return "Compute the hash of a directory." +} + +// PlanModifyObject implements planmodifier.Object. +func (d *directoryHashPlanModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { + attributes := req.PlanValue.Attributes() + directory := attributes["directory"].(types.String).ValueString() + + if resp.Diagnostics.HasError() { + return + } + + hash, err := computeDirectoryHash(directory) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to compute directory hash: %s", err)) + return + } + attributes["directory_hash"] = types.StringValue(hash) + out, diag := types.ObjectValue(req.PlanValue.AttributeTypes(ctx), attributes) + if diag.HasError() { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to create plan object: %s", diag)) + return + } + resp.PlanValue = out +} + +func NewDirectoryHashPlanModifier() planmodifier.Object { + return &directoryHashPlanModifier{} +} + +var _ planmodifier.Object = &directoryHashPlanModifier{} + +func uploadDirectory(ctx context.Context, client *codersdk.Client, logger slog.Logger, directory string) (*codersdk.UploadResponse, error) { + pipeReader, pipeWriter := io.Pipe() + go func() { + err := provisionersdk.Tar(pipeWriter, logger, directory, provisionersdk.TemplateArchiveLimit) + _ = pipeWriter.CloseWithError(err) + }() + defer pipeReader.Close() + content := pipeReader + resp, err := client.Upload(ctx, codersdk.ContentTypeTar, bufio.NewReader(content)) + if err != nil { + return nil, err + } + return &resp, nil +} + +func waitForJob(ctx context.Context, client *codersdk.Client, version *codersdk.TemplateVersion) error { + logs, closer, err := client.TemplateVersionLogsAfter(ctx, version.ID, 0) + defer closer.Close() + if err != nil { + return fmt.Errorf("begin streaming logs: %w", err) + } + for { + logs, ok := <-logs + if !ok { + break + } + // TODO: All trace logs for now + tflog.Trace(ctx, logs.Output, map[string]interface{}{ + "job_id": logs.ID, + "job_stage": logs.Stage, + "log_source": logs.Source, + "level": logs.Level, + "created_at": logs.CreatedAt, + }) + } + latestResp, err := client.TemplateVersion(ctx, version.ID) + if err != nil { + return err + } + if latestResp.Job.Status == codersdk.ProvisionerJobFailed { + return fmt.Errorf("provisioner job failed: %s", latestResp.Job.Error) + } + if latestResp.Job.Status == codersdk.ProvisionerJobFailed { + return fmt.Errorf("provisioner job cancelled") + } + + return nil +} diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go new file mode 100644 index 0000000..4e1ce33 --- /dev/null +++ b/internal/provider/template_resource_test.go @@ -0,0 +1,127 @@ +package provider + +import ( + "context" + "regexp" + "strings" + "testing" + "text/template" + + "github.com/coder/terraform-provider-coderd/integration" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stretchr/testify/require" +) + +func TestAccTemplateResource(t *testing.T) { + ctx := context.Background() + client := integration.StartCoder(ctx, t, "group_acc", false) + cfg := testAccTemplateResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: PtrTo("example-template"), + Versions: []testAccTemplateVersionConfig{ + { + Name: PtrTo("main"), + Directory: PtrTo("../../integration/template-test/example-template/"), + Variables: []testAccTemplateVariableConfig{ + { + Name: PtrTo("var1"), + Value: PtrTo("value1"), + }, + }, + }, + }, + } + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg.String(t), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "name", "example-template"), + resource.TestCheckResourceAttrSet("coderd_template.test", "id"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("main"), + "id": regexp.MustCompile(".*"), + }), + ), + }, + }, + }) +} + +type testAccTemplateResourceConfig struct { + URL string + Token string + + Name *string + DisplayName *string + Description *string + OrganizationID *string + Versions []testAccTemplateVersionConfig +} + +func (c testAccTemplateResourceConfig) String(t *testing.T) string { + t.Helper() + tpl := ` +provider coderd { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_template" "test" { + name = {{orNull .Name}} + display_name = {{orNull .DisplayName}} + description = {{orNull .Description}} + organization_id = {{orNull .OrganizationID}} + + versions = [ + {{- range .Versions }} + { + name = {{orNull .Name}} + directory = {{orNull .Directory}} + active = {{orNull .Active}} + + var = [ + {{- range .Variables }} + { + name = {{orNull .Name}} + value = {{orNull .Value}} + }, + {{- end}} + ] + + }, + {{- end}} + ] +} +` + + funcMap := template.FuncMap{ + "orNull": PrintOrNull, + } + + buf := strings.Builder{} + tmpl, err := template.New("test").Funcs(funcMap).Parse(tpl) + require.NoError(t, err) + + err = tmpl.Execute(&buf, c) + require.NoError(t, err) + + return buf.String() +} + +type testAccTemplateVersionConfig struct { + Name *string + Message *string + Directory *string + Active *bool + Variables []testAccTemplateVariableConfig +} + +type testAccTemplateVariableConfig struct { + Name *string + Value *string +} diff --git a/internal/provider/util.go b/internal/provider/util.go index c0c8161..75f5196 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -1,7 +1,11 @@ package provider import ( + "crypto/sha256" + "encoding/hex" "fmt" + "os" + "path/filepath" ) func PtrTo[T any](v T) *T { @@ -46,3 +50,29 @@ func PrintOrNull(v any) string { panic(fmt.Errorf("unknown type in template: %T", value)) } } + +func computeDirectoryHash(directory string) (string, error) { + var files []string + err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + files = append(files, path) + } + return nil + }) + if err != nil { + return "", err + } + + hash := sha256.New() + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + return "", err + } + hash.Write(data) + } + return hex.EncodeToString(hash.Sum(nil)), nil +}