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 +}
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: