diff --git a/docs/resources/app.md b/docs/resources/app.md index 2bcfeb70..698f52a5 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -26,11 +26,11 @@ EOF } resource "coder_app" "code-server" { - agent_id = coder_agent.dev.id - name = "VS Code" - icon = data.coder_workspace.me.access_url + "/icons/vscode.svg" - url = "http://localhost:13337" - path = true + agent_id = coder_agent.dev.id + name = "VS Code" + icon = data.coder_workspace.me.access_url + "/icons/vscode.svg" + url = "http://localhost:13337" + relative_path = true } resource "coder_app" "vim" { diff --git a/docs/resources/metadata.md b/docs/resources/metadata.md new file mode 100644 index 00000000..493c7323 --- /dev/null +++ b/docs/resources/metadata.md @@ -0,0 +1,76 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coder_metadata Resource - terraform-provider-coder" +subcategory: "" +description: |- + Use this resource to attach key/value pairs to a resource. They will be displayed in the Coder dashboard. +--- + +# coder_metadata (Resource) + +Use this resource to attach key/value pairs to a resource. They will be displayed in the Coder dashboard. + +## Example Usage + +```terraform +data "coder_workspace" "me" { +} + +resource "kubernetes_pod" "dev" { + count = data.coder_workspace.me.start_count +} + +resource "tls_private_key" "example_key_pair" { + algorithm = "ECDSA" + ecdsa_curve = "P256" +} + +resource "coder_metadata" "pod_info" { + count = data.coder_workspace.me.start_count + resource_id = kubernetes_pod.dev[0].id + item { + key = "description" + value = "This description will show up in the Coder dashboard." + } + item { + key = "pod_uid" + value = kubernetes_pod.dev[0].uid + } + item { + key = "public_key" + value = tls_private_key.example_key_pair.public_key_openssh + # The value of this item will be hidden from view by default + sensitive = true + } +} +``` + + +## Schema + +### Required + +- `item` (Block List, Min: 1) Each "item" block defines a single metadata item consisting of a key/value pair. (see [below for nested schema](#nestedblock--item)) +- `resource_id` (String) The "id" property of another resource that metadata should be attached to. + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `item` + +Required: + +- `key` (String) The key of this metadata item. + +Optional: + +- `sensitive` (Boolean) Set to "true" to for items such as API keys whose values should be hidden from view by default. Note that this does not prevent metadata from being retrieved using the API, so it is not suitable for secrets that should not be exposed to workspace users. +- `value` (String) The value of this metadata item. + +Read-Only: + +- `is_null` (Boolean) + + diff --git a/examples/resources/coder_metadata/resource.tf b/examples/resources/coder_metadata/resource.tf new file mode 100644 index 00000000..73222c8b --- /dev/null +++ b/examples/resources/coder_metadata/resource.tf @@ -0,0 +1,30 @@ +data "coder_workspace" "me" { +} + +resource "kubernetes_pod" "dev" { + count = data.coder_workspace.me.start_count +} + +resource "tls_private_key" "example_key_pair" { + algorithm = "ECDSA" + ecdsa_curve = "P256" +} + +resource "coder_metadata" "pod_info" { + count = data.coder_workspace.me.start_count + resource_id = kubernetes_pod.dev[0].id + item { + key = "description" + value = "This description will show up in the Coder dashboard." + } + item { + key = "pod_uid" + value = kubernetes_pod.dev[0].uid + } + item { + key = "public_key" + value = tls_private_key.example_key_pair.public_key_openssh + # The value of this item will be hidden from view by default + sensitive = true + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index ac8b67f0..ef6dc1fe 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2,6 +2,7 @@ package provider import ( "context" + "errors" "fmt" "net/url" "os" @@ -9,6 +10,7 @@ import ( "strings" "github.com/google/uuid" + "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -318,6 +320,75 @@ func New() *schema.Provider { }, }, }, + "coder_metadata": { + Description: "Use this resource to attach key/value pairs to a resource. They will be " + + "displayed in the Coder dashboard.", + CreateContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { + resourceData.SetId(uuid.NewString()) + + items, err := populateIsNull(resourceData) + if err != nil { + return errorAsDiagnostics(err) + } + err = resourceData.Set("item", items) + if err != nil { + return errorAsDiagnostics(err) + } + + return nil + }, + ReadContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { + return nil + }, + DeleteContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "resource_id": { + Type: schema.TypeString, + Description: "The \"id\" property of another resource that metadata should be attached to.", + ForceNew: true, + Required: true, + }, + "item": { + Type: schema.TypeList, + Description: "Each \"item\" block defines a single metadata item consisting of a key/value pair.", + ForceNew: true, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Description: "The key of this metadata item.", + ForceNew: true, + Required: true, + }, + "value": { + Type: schema.TypeString, + Description: "The value of this metadata item.", + ForceNew: true, + Optional: true, + }, + "sensitive": { + Type: schema.TypeBool, + Description: "Set to \"true\" to for items such as API keys whose values should be " + + "hidden from view by default. Note that this does not prevent metadata from " + + "being retrieved using the API, so it is not suitable for secrets that should " + + "not be exposed to workspace users.", + ForceNew: true, + Optional: true, + Default: false, + }, + "is_null": { + Type: schema.TypeBool, + ForceNew: true, + Computed: true, + }, + }, + }, + }, + }, + }, }, } } @@ -356,3 +427,62 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia } return nil } + +// populateIsNull reads the raw plan for a coder_metadata resource being created, +// figures out which items have null "value"s, and augments them by setting the +// "is_null" field to true. This ugly hack is necessary because terraform-plugin-sdk +// is designed around a old version of Terraform that didn't support nullable fields, +// and it doesn't correctly propagate null values for primitive types. +// Returns an interface{} representing the new value of the "item" field, or an error. +func populateIsNull(resourceData *schema.ResourceData) (result interface{}, err error) { + // The cty package reports type mismatches by panicking + defer func() { + if r := recover(); r != nil { + err = errors.New(fmt.Sprintf("panic while handling coder_metadata: %#v", r)) + } + }() + + rawPlan := resourceData.GetRawPlan() + items := rawPlan.GetAttr("item").AsValueSlice() + + var resultItems []interface{} + for _, item := range items { + resultItem := map[string]interface{}{ + "key": valueAsString(item.GetAttr("key")), + "value": valueAsString(item.GetAttr("value")), + "sensitive": valueAsBool(item.GetAttr("sensitive")), + } + if item.GetAttr("value").IsNull() { + resultItem["is_null"] = true + } + resultItems = append(resultItems, resultItem) + } + + return resultItems, nil +} + +// valueAsString takes a cty.Value that may be a string or null, and converts it to either a Go string +// or a nil interface{} +func valueAsString(value cty.Value) interface{} { + if value.IsNull() { + return "" + } + return value.AsString() +} + +// valueAsString takes a cty.Value that may be a boolean or null, and converts it to either a Go bool +// or a nil interface{} +func valueAsBool(value cty.Value) interface{} { + if value.IsNull() { + return nil + } + return value.True() +} + +// errorAsDiagnostic transforms a Go error to a diag.Diagnostics object representing a fatal error. +func errorAsDiagnostics(err error) diag.Diagnostics { + return []diag.Diagnostic{{ + Severity: diag.Error, + Summary: err.Error(), + }} +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 0f2d45e0..e82c5ad7 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -186,3 +186,79 @@ func TestApp(t *testing.T) { }}, }) } + +func TestMetadata(t *testing.T) { + t.Parallel() + prov := provider.New() + resource.Test(t, resource.TestCase{ + Providers: map[string]*schema.Provider{ + "coder": prov, + }, + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` + provider "coder" { + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + } + resource "coder_metadata" "agent" { + resource_id = coder_agent.dev.id + item { + key = "foo" + value = "bar" + } + item { + key = "secret" + value = "squirrel" + sensitive = true + } + item { + key = "implicit_null" + } + item { + key = "explicit_null" + value = null + } + item { + key = "empty" + value = "" + } + } + `, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 2) + agent := state.Modules[0].Resources["coder_agent.dev"] + require.NotNil(t, agent) + metadata := state.Modules[0].Resources["coder_metadata.agent"] + require.NotNil(t, metadata) + t.Logf("metadata attributes: %#v", metadata.Primary.Attributes) + for key, expected := range map[string]string{ + "resource_id": agent.Primary.Attributes["id"], + "item.#": "5", + "item.0.key": "foo", + "item.0.value": "bar", + "item.0.sensitive": "false", + "item.1.key": "secret", + "item.1.value": "squirrel", + "item.1.sensitive": "true", + "item.2.key": "implicit_null", + "item.2.is_null": "true", + "item.2.sensitive": "false", + "item.3.key": "explicit_null", + "item.3.is_null": "true", + "item.3.sensitive": "false", + "item.4.key": "empty", + "item.4.value": "", + "item.4.is_null": "false", + "item.4.sensitive": "false", + } { + require.Equal(t, expected, metadata.Primary.Attributes[key]) + } + return nil + }, + }}, + }) +}