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
+}