diff --git a/docs/resources/template.md b/docs/resources/template.md new file mode 100644 index 0000000..924cd44 --- /dev/null +++ b/docs/resources/template.md @@ -0,0 +1,99 @@ +--- +# 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 + +- `acl` (Attributes) Access control list for the template. (see [below for nested schema](#nestedatt--acl)) +- `name` (String) The name of the template. +- `versions` (Attributes List) (see [below for nested schema](#nestedatt--versions)) + +### Optional + +- `allow_user_auto_start` (Boolean) +- `allow_user_auto_stop` (Boolean) +- `description` (String) A description of the template. +- `display_name` (String) The display name of the template. Defaults to the template name. +- `icon` (String) Relative path or external URL that specifes an icon to be displayed in the dashboard. +- `organization_id` (String) The ID of the organization. Defaults to the provider's default organization + +### Read-Only + +- `id` (String) The ID of the template. + + +### Nested Schema for `acl` + +Required: + +- `groups` (Attributes Set) (see [below for nested schema](#nestedatt--acl--groups)) +- `users` (Attributes Set) (see [below for nested schema](#nestedatt--acl--users)) + + +### Nested Schema for `acl.groups` + +Required: + +- `id` (String) +- `role` (String) + + + +### Nested Schema for `acl.users` + +Required: + +- `id` (String) +- `role` (String) + + + + +### Nested Schema for `versions` + +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. +- `provisioner_tags` (Attributes Set) Provisioner tags for the template version. (see [below for nested schema](#nestedatt--versions--provisioner_tags)) +- `tf_vars` (Attributes Set) Terraform variables for the template version. (see [below for nested schema](#nestedatt--versions--tf_vars)) + +Read-Only: + +- `directory_hash` (String) +- `id` (String) + + +### Nested Schema for `versions.provisioner_tags` + +Required: + +- `name` (String) +- `value` (String) + + + +### Nested Schema for `versions.tf_vars` + +Required: + +- `name` (String) +- `value` (String) diff --git a/go.mod b/go.mod index c3fb310..2d090bd 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 55d5f08..8155435 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= @@ -81,8 +81,6 @@ github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= -github.com/coder/coder/v2 v2.13.0 h1:MlkRGqQcCAdwIkLc9iV8sQfT4jB3EThHopG0jF3BuFE= -github.com/coder/coder/v2 v2.13.0/go.mod h1:Gxc79InMB6b+sncuDUORtFLWi7aKshvis3QrMUhpq5Q= github.com/coder/coder/v2 v2.13.1 h1:tCd8ljqIAufbVcBr8ODS1QbsrjJbmOIvgDkvdd/JMXc= github.com/coder/coder/v2 v2.13.1/go.mod h1:Gxc79InMB6b+sncuDUORtFLWi7aKshvis3QrMUhpq5Q= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= @@ -134,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= @@ -390,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 abcd8f2..084149a 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -105,6 +105,45 @@ 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) { + defaultOrg, err := c.OrganizationByName(ctx, "first-organization") + assert.NoError(t, err) + user, err := c.User(ctx, "ethan") + require.NoError(t, err) + + // Check template metadata + templates, err := c.Templates(ctx) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, "example-template", templates[0].Name) + require.False(t, templates[0].AllowUserAutostart) + require.False(t, templates[0].AllowUserAutostop) + + // Check versions + versions, err := c.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{ + TemplateID: templates[0].ID, + }) + require.NoError(t, err) + require.Len(t, versions, 2) + require.Equal(t, "latest", versions[0].Name) + require.NotEmpty(t, versions[0].ID) + require.Equal(t, templates[0].ID, *versions[0].TemplateID) + require.Equal(t, templates[0].ActiveVersionID, versions[0].ID) + + // Check ACL + acl, err := c.TemplateACL(ctx, templates[0].ID) + require.NoError(t, err) + require.Len(t, acl.Groups, 1) + require.Equal(t, codersdk.TemplateRoleUse, acl.Groups[0].Role) + require.Equal(t, defaultOrg.ID, acl.Groups[0].ID) + require.Len(t, acl.Users, 1) + require.Equal(t, codersdk.TemplateRoleAdmin, acl.Users[0].Role) + require.Equal(t, user.ID, acl.Users[0].ID) + }, + }, } { t.Run(tt.name, func(t *testing.T) { client := StartCoder(ctx, t, tt.name, true) diff --git a/integration/template-test/example-template-2/main.tf b/integration/template-test/example-template-2/main.tf new file mode 100644 index 0000000..c607b38 --- /dev/null +++ b/integration/template-test/example-template-2/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/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..9bafe8a --- /dev/null +++ b/integration/template-test/main.tf @@ -0,0 +1,67 @@ +terraform { + required_providers { + coderd = { + source = "coder/coderd" + version = ">=0.0.0" + } + } +} + +resource "coderd_user" "ethan" { + username = "ethan" + name = "Ethan Coolguy" + email = "test@coder.com" + roles = ["owner", "template-admin"] + login_type = "password" + password = "SomeSecurePassword!" + suspended = false +} + + +data "coderd_organization" "default" { + is_default = true +} + +resource "coderd_template" "sample" { + name = "example-template" + allow_user_auto_stop = false + allow_user_auto_start = false + acl = { + groups = [ + { + id = data.coderd_organization.default.id + role = "use" + } + ] + users = [ + { + id = resource.coderd_user.ethan.id + role = "admin" + } + ] + } + versions = [ + { + name = "latest" + directory = "./example-template" + active = true + tf_vars = [ + { + name = "name" + value = "world" + }, + ] + }, + { + name = "legacy" + directory = "./example-template-2" + active = false + tf_vars = [ + { + name = "name" + value = "ethan" + }, + ] + } + ] +} \ 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/organization_data_source_test.go b/internal/provider/organization_data_source_test.go index d4865c6..35cb2f5 100644 --- a/internal/provider/organization_data_source_test.go +++ b/internal/provider/organization_data_source_test.go @@ -19,7 +19,7 @@ func TestAccOrganizationDataSource(t *testing.T) { t.Skip("Acceptance tests are disabled.") } ctx := context.Background() - client := integration.StartCoder(ctx, t, "group_acc", true) + client := integration.StartCoder(ctx, t, "org_data_acc", true) firstUser, err := client.User(ctx, codersdk.Me) require.NoError(t, err) 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..ac4a0fb --- /dev/null +++ b/internal/provider/template_resource.go @@ -0,0 +1,817 @@ +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/listvalidator" + "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/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "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"` + Icon types.String `tfsdk:"icon"` + AllowUserAutoStart types.Bool `tfsdk:"allow_user_auto_start"` + AllowUserAutoStop types.Bool `tfsdk:"allow_user_auto_stop"` + + ACL *ACL `tfsdk:"acl"` + Versions Versions `tfsdk:"versions"` +} + +// EqualTemplateMetadata returns true if two templates have identical metadata & ACL. +func (m TemplateResourceModel) EqualTemplateMetadata(other TemplateResourceModel) bool { + return m.Name.Equal(other.Name) && + m.DisplayName.Equal(other.DisplayName) && + m.Description.Equal(other.Description) && + m.OrganizationID.Equal(other.OrganizationID) && + m.Icon.Equal(other.Icon) && + m.AllowUserAutoStart.Equal(other.AllowUserAutoStart) && + m.AllowUserAutoStop.Equal(other.AllowUserAutoStop) && + m.ACL.Equal(other.ACL) +} + +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"` + TerraformVariables []Variable `tfsdk:"tf_vars"` + ProvisionerTags []Variable `tfsdk:"provisioner_tags"` +} + +type Versions []TemplateVersion + +func (v Versions) ByID(id types.String) *TemplateVersion { + for _, m := range v { + if m.ID.Equal(id) { + return &m + } + } + return nil +} + +type Variable struct { + Name types.String `tfsdk:"name"` + Value types.String `tfsdk:"value"` +} + +var variableNestedObject = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Required: true, + }, + "value": schema.StringAttribute{ + Required: true, + }, + }, +} + +type ACL struct { + UserPermissions []Permission `tfsdk:"users"` + GroupPermissions []Permission `tfsdk:"groups"` +} + +func (a *ACL) Equal(other *ACL) bool { + if len(a.UserPermissions) != len(other.UserPermissions) { + return false + } + if len(a.GroupPermissions) != len(other.GroupPermissions) { + return false + } + for _, e1 := range a.UserPermissions { + found := false + for _, e2 := range other.UserPermissions { + if e1.Equal(&e2) { + found = true + break + } + } + if !found { + return false + } + } + for _, e1 := range a.GroupPermissions { + found := false + for _, e2 := range other.GroupPermissions { + if e1.Equal(&e2) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +type Permission struct { + ID types.String `tfsdk:"id"` + Role types.String `tfsdk:"role"` +} + +func (p *Permission) Equal(other *Permission) bool { + return p.ID.Equal(other.ID) && p.Role.Equal(other.Role) +} + +// permissionsAttribute is the attribute schema for an instance of `[]Permission`. +var permissionsAttribute = schema.SetNestedAttribute{ + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Required: true, + }, + "role": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("admin", "use", ""), + }, + }, + }, + }, +} + +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.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString(""), + }, + "organization_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the organization. Defaults to the provider's default organization", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIfConfigured(), + }, + }, + "icon": schema.StringAttribute{ + MarkdownDescription: "Relative path or external URL that specifes an icon to be displayed in the dashboard.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "allow_user_auto_start": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "allow_user_auto_stop": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "acl": schema.SingleNestedAttribute{ + MarkdownDescription: "Access control list for the template.", + Required: true, + Attributes: map[string]schema.Attribute{ + "users": permissionsAttribute, + "groups": permissionsAttribute, + }, + }, + "versions": schema.ListNestedAttribute{ + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + NewActiveVersionValidator(), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "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, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "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.", + Computed: true, + Optional: true, + Default: booldefault.StaticBool(false), + }, + "tf_vars": schema.SetNestedAttribute{ + MarkdownDescription: "Terraform variables for the template version.", + Optional: true, + NestedObject: variableNestedObject, + }, + "provisioner_tags": schema.SetNestedAttribute{ + MarkdownDescription: "Provisioner tags for the template version.", + Optional: true, + NestedObject: variableNestedObject, + }, + }, + PlanModifiers: []planmodifier.Object{ + NewDirectoryHashPlanModifier(), + objectplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, + } +} + +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) + } + + if data.DisplayName.IsUnknown() { + data.DisplayName = data.Name + } + + 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 { + newVersionRequest := newVersionRequest{ + Version: &version, + OrganizationID: orgID, + } + if idx > 0 { + newVersionRequest.TemplateID = &templateResp.ID + } + versionResp, err := newVersion(ctx, client, newVersionRequest) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + if idx == 0 { + templateResp, err = client.CreateTemplate(ctx, orgID, codersdk.CreateTemplateRequest{ + Name: data.Name.ValueString(), + DisplayName: data.DisplayName.ValueString(), + Description: data.Description.ValueString(), + VersionID: versionResp.ID, + AllowUserAutostart: data.AllowUserAutoStart.ValueBoolPointer(), + AllowUserAutostop: data.AllowUserAutoStop.ValueBoolPointer(), + Icon: data.Icon.ValueString(), + DisableEveryoneGroupAccess: true, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to create template: %s", err)) + return + } + + err = client.UpdateTemplateACL(ctx, templateResp.ID, convertACLToRequest(data.ACL)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update template ACL: %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 set 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 + + templateID, err := uuid.Parse(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied template ID as UUID, got error: %s", err)) + return + } + + template, err := client.Template(ctx, templateID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err)) + return + } + + data.Name = types.StringValue(template.Name) + data.DisplayName = types.StringValue(template.DisplayName) + data.Description = types.StringValue(template.Description) + data.OrganizationID = types.StringValue(template.OrganizationID.String()) + data.Icon = types.StringValue(template.Icon) + data.AllowUserAutoStart = types.BoolValue(template.AllowUserAutostart) + data.AllowUserAutoStop = types.BoolValue(template.AllowUserAutostop) + + acl, err := client.TemplateACL(ctx, templateID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template ACL: %s", err)) + return + } + data.ACL = convertResponseToACL(acl) + + for idx, version := range data.Versions { + versionID, err := uuid.Parse(version.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied version ID as UUID, got error: %s", err)) + return + } + versionResp, err := client.TemplateVersion(ctx, versionID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template version: %s", err)) + return + } + data.Versions[idx].Name = types.StringValue(versionResp.Name) + data.Versions[idx].Message = types.StringValue(versionResp.Message) + active := false + if versionResp.ID == template.ActiveVersionID { + active = true + } + data.Versions[idx].Active = types.BoolValue(active) + } + + // 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 planState TemplateResourceModel + var curState TemplateResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planState)...) + + if resp.Diagnostics.HasError() { + return + } + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &curState)...) + if resp.Diagnostics.HasError() { + return + } + + if planState.OrganizationID.IsUnknown() { + planState.OrganizationID = types.StringValue(r.data.DefaultOrganizationID) + } + + if planState.DisplayName.IsUnknown() { + planState.DisplayName = planState.Name + } + + orgID, err := uuid.Parse(planState.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 + } + + templateID, err := uuid.Parse(planState.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied template ID as UUID, got error: %s", err)) + return + } + + client := r.data.Client + + if !planState.EqualTemplateMetadata(curState) { + _, err := client.UpdateTemplateMeta(ctx, templateID, codersdk.UpdateTemplateMeta{ + Name: planState.Name.ValueString(), + DisplayName: planState.DisplayName.ValueString(), + Description: planState.Description.ValueString(), + AllowUserAutostart: planState.AllowUserAutoStart.ValueBool(), + AllowUserAutostop: planState.AllowUserAutoStop.ValueBool(), + Icon: planState.Icon.ValueString(), + DisableEveryoneGroupAccess: true, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update template: %s", err)) + return + } + err = client.UpdateTemplateACL(ctx, templateID, convertACLToRequest(planState.ACL)) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update template ACL: %s", err)) + return + } + } + + for idx, plannedVersion := range planState.Versions { + var curVersionID uuid.UUID + // All versions in the state are guaranteed to have known IDs + foundVersion := curState.Versions.ByID(plannedVersion.ID) + // If the version is new, or if the directory hash has changed, create a new version + if foundVersion == nil || foundVersion.DirectoryHash != plannedVersion.DirectoryHash { + versionResp, err := newVersion(ctx, client, newVersionRequest{ + Version: &plannedVersion, + OrganizationID: orgID, + TemplateID: &templateID, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + curVersionID = versionResp.ID + } else { + // Or if it's an existing version, get the ID + curVersionID, err = uuid.Parse(plannedVersion.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse version ID stored in state as UUID, got error: %s", err)) + return + } + } + versionResp, err := client.TemplateVersion(ctx, curVersionID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template version: %s", err)) + return + } + if plannedVersion.Active.ValueBool() { + err := client.UpdateActiveTemplateVersion(ctx, templateID, codersdk.UpdateActiveTemplateVersion{ + ID: versionResp.ID, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update active template version: %s", err)) + return + } + } + planState.Versions[idx].ID = types.StringValue(versionResp.ID.String()) + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &planState)...) +} + +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 + + templateID, err := uuid.Parse(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied template ID as UUID, got error: %s", err)) + return + } + + err = client.DeleteTemplate(ctx, templateID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to delete template: %s", err)) + return + } +} + +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{} + +func NewActiveVersionValidator() validator.List { + return &activeVersionValidator{} +} + +// Description implements validator.List. +func (a *activeVersionValidator) Description(ctx context.Context) string { + return a.MarkdownDescription(ctx) +} + +// MarkdownDescription implements validator.List. +func (a *activeVersionValidator) MarkdownDescription(context.Context) string { + return "Validate that exactly one template version has active set to true." +} + +// ValidateList implements validator.List. +func (a *activeVersionValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) { + var data []TemplateVersion + resp.Diagnostics.Append(req.ConfigValue.ElementsAs(ctx, &data, false)...) + if resp.Diagnostics.HasError() { + return + } + + // Check if only one item in Version has active set to true + active := false + for _, version := range data { + if version.Active.ValueBool() { + if active { + resp.Diagnostics.AddError("Client Error", "Only one template version can be active at a time.") + return + } + active = true + } + } + if !active { + resp.Diagnostics.AddError("Client Error", "At least one template version must be active.") + } +} + +var _ validator.List = &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, ok := attributes["directory"].(types.String) + if !ok { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("unexpected type for directory, got: %T", directory)) + return + } + + hash, err := computeDirectoryHash(directory.ValueString()) + 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 { + const maxRetries = 3 + for retries := 0; retries < maxRetries; retries++ { + 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 + } + 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.Active() { + tflog.Warn(ctx, fmt.Sprintf("provisioner job still active, continuing to wait...: %s", latestResp.Job.Status)) + continue + } + if latestResp.Job.Status != codersdk.ProvisionerJobSucceeded { + return fmt.Errorf("provisioner job did not succeed: %s (%s)", latestResp.Job.Status, latestResp.Job.Error) + } + return nil + } + return fmt.Errorf("provisioner job did not complete after %d retries", maxRetries) +} + +type newVersionRequest struct { + OrganizationID uuid.UUID + Version *TemplateVersion + TemplateID *uuid.UUID +} + +func newVersion(ctx context.Context, client *codersdk.Client, req newVersionRequest) (*codersdk.TemplateVersion, error) { + directory := req.Version.Directory.ValueString() + uploadResp, err := uploadDirectory(ctx, client, slog.Make(newTFLogSink(ctx)), directory) + if err != nil { + return nil, fmt.Errorf("failed to upload directory: %s", err) + } + // TODO(ethanndickson): Uncomment when a released `codersdk` exports template variable parsing + // varFiles, err := codersdk.DiscoverVarsFiles(directory) + // if err != nil { + // return nil, fmt.Errorf("failed to discover vars files: %s", err) + // } + // vars, err := codersdk.ParseUserVariableValues(varFiles, "", []string{}) + // if err != nil { + // return nil, fmt.Errorf("failed to parse user variable values: %s", err) + // } + vars := make([]codersdk.VariableValue, 0, len(req.Version.TerraformVariables)) + for _, variable := range req.Version.TerraformVariables { + vars = append(vars, codersdk.VariableValue{ + Name: variable.Name.ValueString(), + Value: variable.Value.ValueString(), + }) + } + tmplVerReq := codersdk.CreateTemplateVersionRequest{ + Name: req.Version.Name.ValueString(), + Message: req.Version.Message.ValueString(), + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeTerraform, + FileID: uploadResp.ID, + UserVariableValues: vars, + } + if req.TemplateID != nil { + tmplVerReq.TemplateID = *req.TemplateID + } + versionResp, err := client.CreateTemplateVersion(ctx, req.OrganizationID, tmplVerReq) + if err != nil { + return nil, fmt.Errorf("failed to create template version: %s", err) + } + err = waitForJob(ctx, client, &versionResp) + if err != nil { + return nil, fmt.Errorf("failed to wait for job: %s", err) + } + return &versionResp, nil +} + +func convertACLToRequest(permissions *ACL) codersdk.UpdateTemplateACL { + if permissions == nil { + return codersdk.UpdateTemplateACL{} + } + var userPerms = make(map[string]codersdk.TemplateRole) + for _, perm := range permissions.UserPermissions { + userPerms[perm.ID.ValueString()] = codersdk.TemplateRole(perm.Role.ValueString()) + } + var groupPerms = make(map[string]codersdk.TemplateRole) + for _, perm := range permissions.GroupPermissions { + groupPerms[perm.ID.ValueString()] = codersdk.TemplateRole(perm.Role.ValueString()) + } + return codersdk.UpdateTemplateACL{ + UserPerms: userPerms, + GroupPerms: groupPerms, + } +} + +func convertResponseToACL(acl codersdk.TemplateACL) *ACL { + userPerms := make([]Permission, 0, len(acl.Users)) + for _, user := range acl.Users { + userPerms = append(userPerms, Permission{ + ID: types.StringValue(user.ID.String()), + Role: types.StringValue(string(user.Role)), + }) + } + groupPerms := make([]Permission, 0, len(acl.Groups)) + for _, group := range acl.Groups { + groupPerms = append(groupPerms, Permission{ + ID: types.StringValue(group.ID.String()), + Role: types.StringValue(string(group.Role)), + }) + } + return &ACL{ + UserPermissions: userPerms, + GroupPermissions: groupPerms, + } +} diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go new file mode 100644 index 0000000..50b0e24 --- /dev/null +++ b/internal/provider/template_resource_test.go @@ -0,0 +1,276 @@ +package provider + +import ( + "context" + "regexp" + "slices" + "strings" + "testing" + "text/template" + + "github.com/coder/coder/v2/codersdk" + "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, "template_acc", true) + firstUser, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + cfg1 := testAccTemplateResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: PtrTo("example-template"), + Versions: []testAccTemplateVersionConfig{ + { + Name: PtrTo("main"), + Directory: PtrTo("../../integration/template-test/example-template/"), + Active: PtrTo(true), + // TODO(ethanndickson): Remove this when we add in `*.tfvars` parsing + TerraformVariables: []testAccTemplateKeyValueConfig{ + { + Key: PtrTo("name"), + Value: PtrTo("world"), + }, + }, + }, + }, + GroupACL: []testAccTemplateKeyValueConfig{ + { + Key: PtrTo(firstUser.OrganizationIDs[0].String()), + Value: PtrTo("use"), + }, + }, + } + + cfg2 := cfg1 + cfg2.Versions = slices.Clone(cfg2.Versions) + cfg2.Name = PtrTo("example-template-new") + cfg2.Versions[0].Directory = PtrTo("../../integration/template-test/example-template-2/") + cfg2.Versions[0].Name = PtrTo("new") + cfg2.UserACL = []testAccTemplateKeyValueConfig{ + { + Key: PtrTo(firstUser.ID.String()), + Value: PtrTo("admin"), + }, + } + + cfg3 := cfg2 + cfg3.Versions = slices.Clone(cfg3.Versions) + cfg3.Versions = append(cfg3.Versions, testAccTemplateVersionConfig{ + Name: PtrTo("legacy-template"), + Directory: PtrTo("../../integration/template-test/example-template/"), + Active: PtrTo(false), + TerraformVariables: []testAccTemplateKeyValueConfig{ + { + Key: PtrTo("name"), + Value: PtrTo("world"), + }, + }, + }) + + cfg4 := cfg3 + cfg4.Versions = slices.Clone(cfg4.Versions) + cfg4.Versions[0].Active = PtrTo(false) + cfg4.Versions[1].Active = PtrTo(true) + + cfg5 := cfg4 + cfg5.Versions = slices.Clone(cfg5.Versions) + cfg5.Versions[0], cfg5.Versions[1] = cfg5.Versions[1], cfg5.Versions[0] + + cfg6 := cfg4 + cfg6.Versions = slices.Clone(cfg6.Versions[1:]) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1.String(t), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("coderd_template.test", "id"), + resource.TestCheckResourceAttr("coderd_template.test", "display_name", "example-template"), + resource.TestCheckResourceAttr("coderd_template.test", "description", ""), + resource.TestCheckResourceAttr("coderd_template.test", "organization_id", firstUser.OrganizationIDs[0].String()), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("main"), + "id": regexp.MustCompile(".*"), + "directory_hash": regexp.MustCompile(".+"), + "message": regexp.MustCompile(""), + }), + ), + }, + // Import + { + Config: cfg1.String(t), + ResourceName: "coderd_template.test", + ImportState: true, + ImportStateVerify: true, + // In the real world, `versions` needs to be added to the configuration after importing + ImportStateVerifyIgnore: []string{"versions"}, + }, + // Update existing version & metadata + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("coderd_template.test", "id"), + resource.TestCheckResourceAttr("coderd_template.test", "name", "example-template-new"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("new"), + }), + ), + }, + // Append version + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("legacy-template"), + }), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "name": regexp.MustCompile("new"), + }), + ), + }, + // Change active version + { + Config: cfg4.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("true"), + "name": regexp.MustCompile("legacy-template"), + }), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("false"), + "name": regexp.MustCompile("new"), + }), + ), + }, + // Swap versions + { + Config: cfg5.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "2"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("true"), + "name": regexp.MustCompile("legacy-template"), + }), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("false"), + "name": regexp.MustCompile("new"), + }), + ), + }, + // Delete version at index 0 + { + Config: cfg6.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_template.test", "versions.#", "1"), + resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{ + "active": regexp.MustCompile("true"), + "name": regexp.MustCompile("legacy-template"), + }), + ), + }, + }, + }) +} + +type testAccTemplateResourceConfig struct { + URL string + Token string + + Name *string + DisplayName *string + Description *string + OrganizationID *string + Versions []testAccTemplateVersionConfig + GroupACL []testAccTemplateKeyValueConfig + UserACL []testAccTemplateKeyValueConfig +} + +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}} + + acl = { + groups = [ + {{- range .GroupACL}} + { + id = {{orNull .Key}} + role = {{orNull .Value}} + }, + {{- end}} + ] + users = [ + {{- range .UserACL}} + { + id = {{orNull .Key}} + role = {{orNull .Value}} + }, + {{- end}} + ] + } + + versions = [ + {{- range .Versions }} + { + name = {{orNull .Name}} + directory = {{orNull .Directory}} + active = {{orNull .Active}} + + tf_vars = [ + {{- range .TerraformVariables }} + { + name = {{orNull .Key}} + 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 + TerraformVariables []testAccTemplateKeyValueConfig +} + +type testAccTemplateKeyValueConfig struct { + Key *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 +}