Skip to content

Commit 301c3fa

Browse files
authored
feat: Add "coder_metadata" resource (#34)
1 parent 87135a9 commit 301c3fa

File tree

5 files changed

+317
-5
lines changed

5 files changed

+317
-5
lines changed

docs/resources/app.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ EOF
2626
}
2727
2828
resource "coder_app" "code-server" {
29-
agent_id = coder_agent.dev.id
30-
name = "VS Code"
31-
icon = data.coder_workspace.me.access_url + "/icons/vscode.svg"
32-
url = "http://localhost:13337"
33-
path = true
29+
agent_id = coder_agent.dev.id
30+
name = "VS Code"
31+
icon = data.coder_workspace.me.access_url + "/icons/vscode.svg"
32+
url = "http://localhost:13337"
33+
relative_path = true
3434
}
3535
3636
resource "coder_app" "vim" {

docs/resources/metadata.md

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "coder_metadata Resource - terraform-provider-coder"
4+
subcategory: ""
5+
description: |-
6+
Use this resource to attach key/value pairs to a resource. They will be displayed in the Coder dashboard.
7+
---
8+
9+
# coder_metadata (Resource)
10+
11+
Use this resource to attach key/value pairs to a resource. They will be displayed in the Coder dashboard.
12+
13+
## Example Usage
14+
15+
```terraform
16+
data "coder_workspace" "me" {
17+
}
18+
19+
resource "kubernetes_pod" "dev" {
20+
count = data.coder_workspace.me.start_count
21+
}
22+
23+
resource "tls_private_key" "example_key_pair" {
24+
algorithm = "ECDSA"
25+
ecdsa_curve = "P256"
26+
}
27+
28+
resource "coder_metadata" "pod_info" {
29+
count = data.coder_workspace.me.start_count
30+
resource_id = kubernetes_pod.dev[0].id
31+
item {
32+
key = "description"
33+
value = "This description will show up in the Coder dashboard."
34+
}
35+
item {
36+
key = "pod_uid"
37+
value = kubernetes_pod.dev[0].uid
38+
}
39+
item {
40+
key = "public_key"
41+
value = tls_private_key.example_key_pair.public_key_openssh
42+
# The value of this item will be hidden from view by default
43+
sensitive = true
44+
}
45+
}
46+
```
47+
48+
<!-- schema generated by tfplugindocs -->
49+
## Schema
50+
51+
### Required
52+
53+
- `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))
54+
- `resource_id` (String) The "id" property of another resource that metadata should be attached to.
55+
56+
### Read-Only
57+
58+
- `id` (String) The ID of this resource.
59+
60+
<a id="nestedblock--item"></a>
61+
### Nested Schema for `item`
62+
63+
Required:
64+
65+
- `key` (String) The key of this metadata item.
66+
67+
Optional:
68+
69+
- `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.
70+
- `value` (String) The value of this metadata item.
71+
72+
Read-Only:
73+
74+
- `is_null` (Boolean)
75+
76+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
data "coder_workspace" "me" {
2+
}
3+
4+
resource "kubernetes_pod" "dev" {
5+
count = data.coder_workspace.me.start_count
6+
}
7+
8+
resource "tls_private_key" "example_key_pair" {
9+
algorithm = "ECDSA"
10+
ecdsa_curve = "P256"
11+
}
12+
13+
resource "coder_metadata" "pod_info" {
14+
count = data.coder_workspace.me.start_count
15+
resource_id = kubernetes_pod.dev[0].id
16+
item {
17+
key = "description"
18+
value = "This description will show up in the Coder dashboard."
19+
}
20+
item {
21+
key = "pod_uid"
22+
value = kubernetes_pod.dev[0].uid
23+
}
24+
item {
25+
key = "public_key"
26+
value = tls_private_key.example_key_pair.public_key_openssh
27+
# The value of this item will be hidden from view by default
28+
sensitive = true
29+
}
30+
}

internal/provider/provider.go

+130
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package provider
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net/url"
78
"os"
89
"reflect"
910
"strings"
1011

1112
"github.com/google/uuid"
13+
"github.com/hashicorp/go-cty/cty"
1214
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1315
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1416
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
@@ -318,6 +320,75 @@ func New() *schema.Provider {
318320
},
319321
},
320322
},
323+
"coder_metadata": {
324+
Description: "Use this resource to attach key/value pairs to a resource. They will be " +
325+
"displayed in the Coder dashboard.",
326+
CreateContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
327+
resourceData.SetId(uuid.NewString())
328+
329+
items, err := populateIsNull(resourceData)
330+
if err != nil {
331+
return errorAsDiagnostics(err)
332+
}
333+
err = resourceData.Set("item", items)
334+
if err != nil {
335+
return errorAsDiagnostics(err)
336+
}
337+
338+
return nil
339+
},
340+
ReadContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
341+
return nil
342+
},
343+
DeleteContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
344+
return nil
345+
},
346+
Schema: map[string]*schema.Schema{
347+
"resource_id": {
348+
Type: schema.TypeString,
349+
Description: "The \"id\" property of another resource that metadata should be attached to.",
350+
ForceNew: true,
351+
Required: true,
352+
},
353+
"item": {
354+
Type: schema.TypeList,
355+
Description: "Each \"item\" block defines a single metadata item consisting of a key/value pair.",
356+
ForceNew: true,
357+
Required: true,
358+
Elem: &schema.Resource{
359+
Schema: map[string]*schema.Schema{
360+
"key": {
361+
Type: schema.TypeString,
362+
Description: "The key of this metadata item.",
363+
ForceNew: true,
364+
Required: true,
365+
},
366+
"value": {
367+
Type: schema.TypeString,
368+
Description: "The value of this metadata item.",
369+
ForceNew: true,
370+
Optional: true,
371+
},
372+
"sensitive": {
373+
Type: schema.TypeBool,
374+
Description: "Set to \"true\" to for items such as API keys whose values should be " +
375+
"hidden from view by default. Note that this does not prevent metadata from " +
376+
"being retrieved using the API, so it is not suitable for secrets that should " +
377+
"not be exposed to workspace users.",
378+
ForceNew: true,
379+
Optional: true,
380+
Default: false,
381+
},
382+
"is_null": {
383+
Type: schema.TypeBool,
384+
ForceNew: true,
385+
Computed: true,
386+
},
387+
},
388+
},
389+
},
390+
},
391+
},
321392
},
322393
}
323394
}
@@ -356,3 +427,62 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia
356427
}
357428
return nil
358429
}
430+
431+
// populateIsNull reads the raw plan for a coder_metadata resource being created,
432+
// figures out which items have null "value"s, and augments them by setting the
433+
// "is_null" field to true. This ugly hack is necessary because terraform-plugin-sdk
434+
// is designed around a old version of Terraform that didn't support nullable fields,
435+
// and it doesn't correctly propagate null values for primitive types.
436+
// Returns an interface{} representing the new value of the "item" field, or an error.
437+
func populateIsNull(resourceData *schema.ResourceData) (result interface{}, err error) {
438+
// The cty package reports type mismatches by panicking
439+
defer func() {
440+
if r := recover(); r != nil {
441+
err = errors.New(fmt.Sprintf("panic while handling coder_metadata: %#v", r))
442+
}
443+
}()
444+
445+
rawPlan := resourceData.GetRawPlan()
446+
items := rawPlan.GetAttr("item").AsValueSlice()
447+
448+
var resultItems []interface{}
449+
for _, item := range items {
450+
resultItem := map[string]interface{}{
451+
"key": valueAsString(item.GetAttr("key")),
452+
"value": valueAsString(item.GetAttr("value")),
453+
"sensitive": valueAsBool(item.GetAttr("sensitive")),
454+
}
455+
if item.GetAttr("value").IsNull() {
456+
resultItem["is_null"] = true
457+
}
458+
resultItems = append(resultItems, resultItem)
459+
}
460+
461+
return resultItems, nil
462+
}
463+
464+
// valueAsString takes a cty.Value that may be a string or null, and converts it to either a Go string
465+
// or a nil interface{}
466+
func valueAsString(value cty.Value) interface{} {
467+
if value.IsNull() {
468+
return ""
469+
}
470+
return value.AsString()
471+
}
472+
473+
// valueAsString takes a cty.Value that may be a boolean or null, and converts it to either a Go bool
474+
// or a nil interface{}
475+
func valueAsBool(value cty.Value) interface{} {
476+
if value.IsNull() {
477+
return nil
478+
}
479+
return value.True()
480+
}
481+
482+
// errorAsDiagnostic transforms a Go error to a diag.Diagnostics object representing a fatal error.
483+
func errorAsDiagnostics(err error) diag.Diagnostics {
484+
return []diag.Diagnostic{{
485+
Severity: diag.Error,
486+
Summary: err.Error(),
487+
}}
488+
}

internal/provider/provider_test.go

+76
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,79 @@ func TestApp(t *testing.T) {
186186
}},
187187
})
188188
}
189+
190+
func TestMetadata(t *testing.T) {
191+
t.Parallel()
192+
prov := provider.New()
193+
resource.Test(t, resource.TestCase{
194+
Providers: map[string]*schema.Provider{
195+
"coder": prov,
196+
},
197+
IsUnitTest: true,
198+
Steps: []resource.TestStep{{
199+
Config: `
200+
provider "coder" {
201+
}
202+
resource "coder_agent" "dev" {
203+
os = "linux"
204+
arch = "amd64"
205+
}
206+
resource "coder_metadata" "agent" {
207+
resource_id = coder_agent.dev.id
208+
item {
209+
key = "foo"
210+
value = "bar"
211+
}
212+
item {
213+
key = "secret"
214+
value = "squirrel"
215+
sensitive = true
216+
}
217+
item {
218+
key = "implicit_null"
219+
}
220+
item {
221+
key = "explicit_null"
222+
value = null
223+
}
224+
item {
225+
key = "empty"
226+
value = ""
227+
}
228+
}
229+
`,
230+
Check: func(state *terraform.State) error {
231+
require.Len(t, state.Modules, 1)
232+
require.Len(t, state.Modules[0].Resources, 2)
233+
agent := state.Modules[0].Resources["coder_agent.dev"]
234+
require.NotNil(t, agent)
235+
metadata := state.Modules[0].Resources["coder_metadata.agent"]
236+
require.NotNil(t, metadata)
237+
t.Logf("metadata attributes: %#v", metadata.Primary.Attributes)
238+
for key, expected := range map[string]string{
239+
"resource_id": agent.Primary.Attributes["id"],
240+
"item.#": "5",
241+
"item.0.key": "foo",
242+
"item.0.value": "bar",
243+
"item.0.sensitive": "false",
244+
"item.1.key": "secret",
245+
"item.1.value": "squirrel",
246+
"item.1.sensitive": "true",
247+
"item.2.key": "implicit_null",
248+
"item.2.is_null": "true",
249+
"item.2.sensitive": "false",
250+
"item.3.key": "explicit_null",
251+
"item.3.is_null": "true",
252+
"item.3.sensitive": "false",
253+
"item.4.key": "empty",
254+
"item.4.value": "",
255+
"item.4.is_null": "false",
256+
"item.4.sensitive": "false",
257+
} {
258+
require.Equal(t, expected, metadata.Primary.Attributes[key])
259+
}
260+
return nil
261+
},
262+
}},
263+
})
264+
}

0 commit comments

Comments
 (0)