From d933a7149b87898dba196342be5dd9df3fc07bcf Mon Sep 17 00:00:00 2001 From: Vincent Vielle Date: Thu, 19 Dec 2024 16:35:25 +0100 Subject: [PATCH] add open_in option to coder_app (#321) * add open_in option to coder_app * work on tests * add missing example * rename test * lint * generate docs --- docs/resources/app.md | 2 + examples/resources/coder_app/resource.tf | 1 + integration/coder-app-open-in/main.tf | 62 +++++++++++++ integration/integration_test.go | 9 ++ provider/app.go | 22 +++++ provider/app_test.go | 111 +++++++++++++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 integration/coder-app-open-in/main.tf diff --git a/docs/resources/app.md b/docs/resources/app.md index 6b8e99f4..e2bbe435 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -33,6 +33,7 @@ resource "coder_app" "code-server" { url = "http://localhost:13337" share = "owner" subdomain = false + open_in = "window" healthcheck { url = "http://localhost:13337/healthz" interval = 5 @@ -65,6 +66,7 @@ resource "coder_app" "vim" { - `healthcheck` (Block Set, Max: 1) HTTP health checking to determine the application readiness. (see [below for nested schema](#nestedblock--healthcheck)) - `hidden` (Boolean) Determines if the app is visible in the UI (minimum Coder version: v2.16). - `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `"${data.coder_workspace.me.access_url}/icon/"`. +- `open_in` (String) Determines where the app will be opened. Valid values are `"tab"`, `"window"`, and `"slim-window" (default)`. `"tab"` opens in a new tab in the same browser window. `"window"` opens a fresh browser window with navigation options. `"slim-window"` opens a new browser window without navigation controls. - `order` (Number) The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order). - `share` (String) Determines the level which the application is shared at. Valid levels are `"owner"` (default), `"authenticated"` and `"public"`. Level `"owner"` disables sharing on the app, so only the workspace owner can access it. Level `"authenticated"` shares the app with all authenticated users. Level `"public"` shares it with any user, including unauthenticated users. Permitted application sharing levels can be configured site-wide via a flag on `coder server` (Enterprise only). - `subdomain` (Boolean) Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. If wildcards have not been setup by the administrator then apps with `subdomain` set to `true` will not be accessible. Defaults to `false`. diff --git a/examples/resources/coder_app/resource.tf b/examples/resources/coder_app/resource.tf index 9345dfc5..8aea7b99 100644 --- a/examples/resources/coder_app/resource.tf +++ b/examples/resources/coder_app/resource.tf @@ -18,6 +18,7 @@ resource "coder_app" "code-server" { url = "http://localhost:13337" share = "owner" subdomain = false + open_in = "window" healthcheck { url = "http://localhost:13337/healthz" interval = 5 diff --git a/integration/coder-app-open-in/main.tf b/integration/coder-app-open-in/main.tf new file mode 100644 index 00000000..529d9bef --- /dev/null +++ b/integration/coder-app-open-in/main.tf @@ -0,0 +1,62 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + local = { + source = "hashicorp/local" + } + } +} + +data "coder_workspace" "me" {} + +resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + dir = "/workspace" +} + +resource "coder_app" "window" { + agent_id = coder_agent.dev.id + slug = "window" + share = "owner" + open_in = "window" +} + +resource "coder_app" "slim-window" { + agent_id = coder_agent.dev.id + slug = "slim-window" + share = "owner" + open_in = "slim-window" +} + +resource "coder_app" "defaulted" { + agent_id = coder_agent.dev.id + slug = "defaulted" + share = "owner" +} + +locals { + # NOTE: these must all be strings in the output + output = { + "coder_app.window.open_in" = tostring(coder_app.window.open_in) + "coder_app.slim-window.open_in" = tostring(coder_app.slim-window.open_in) + "coder_app.defaulted.open_in" = tostring(coder_app.defaulted.open_in) + } +} + +variable "output_path" { + type = string +} + +resource "local_file" "output" { + filename = var.output_path + content = jsonencode(local.output) +} + +output "output" { + value = local.output + sensitive = true +} + diff --git a/integration/integration_test.go b/integration/integration_test.go index f1596eee..50ef71b5 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -143,6 +143,15 @@ func TestIntegration(t *testing.T) { "workspace_owner.login_type": `password`, }, }, + { + name: "coder-app-open-in", + minVersion: "v2.19.0", + expectedOutput: map[string]string{ + "coder_app.window.open_in": "window", + "coder_app.slim-window.open_in": "slim-window", + "coder_app.defaulted.open_in": "slim-window", + }, + }, { name: "coder-app-hidden", minVersion: "v0.0.0", diff --git a/provider/app.go b/provider/app.go index 3fd71692..391bdfc3 100644 --- a/provider/app.go +++ b/provider/app.go @@ -223,6 +223,28 @@ func appResource() *schema.Resource { ForceNew: true, Optional: true, }, + "open_in": { + Type: schema.TypeString, + Description: "Determines where the app will be opened. Valid values are `\"tab\"`, `\"window\"`, and `\"slim-window\" (default)`. " + + "`\"tab\"` opens in a new tab in the same browser window. `\"window\"` opens a fresh browser window with navigation options. " + + "`\"slim-window\"` opens a new browser window without navigation controls.", + ForceNew: true, + Optional: true, + Default: "slim-window", + ValidateDiagFunc: func(val interface{}, c cty.Path) diag.Diagnostics { + valStr, ok := val.(string) + if !ok { + return diag.Errorf("expected string, got %T", val) + } + + switch valStr { + case "tab", "window", "slim-window": + return nil + } + + return diag.Errorf(`invalid "coder_app" open_in value, must be one of "tab", "window", "slim-window": %q`, valStr) + }, + }, }, } } diff --git a/provider/app_test.go b/provider/app_test.go index 6a17ca0c..aaa4f631 100644 --- a/provider/app_test.go +++ b/provider/app_test.go @@ -42,6 +42,7 @@ func TestApp(t *testing.T) { } order = 4 hidden = false + open_in = "slim-window" } `, Check: func(state *terraform.State) error { @@ -64,6 +65,7 @@ func TestApp(t *testing.T) { "healthcheck.0.threshold", "order", "hidden", + "open_in", } { value := resource.Primary.Attributes[key] t.Logf("%q = %q", key, value) @@ -98,6 +100,7 @@ func TestApp(t *testing.T) { display_name = "Testing" url = "https://google.com" external = true + open_in = "slim-window" } `, external: true, @@ -116,6 +119,7 @@ func TestApp(t *testing.T) { url = "https://google.com" external = true subdomain = true + open_in = "slim-window" } `, expectError: regexp.MustCompile("conflicts with subdomain"), @@ -209,6 +213,7 @@ func TestApp(t *testing.T) { interval = 5 threshold = 6 } + open_in = "slim-window" } `, sharingLine) @@ -241,6 +246,106 @@ func TestApp(t *testing.T) { } }) + t.Run("OpenIn", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + value string + expectValue string + expectError *regexp.Regexp + }{ + { + name: "default", + value: "", // default + expectValue: "slim-window", + }, + { + name: "InvalidValue", + value: "nonsense", + expectError: regexp.MustCompile(`invalid "coder_app" open_in value, must be one of "tab", "window", "slim-window": "nonsense"`), + }, + { + name: "ExplicitWindow", + value: "window", + expectValue: "window", + }, + { + name: "ExplicitSlimWindow", + value: "slim-window", + expectValue: "slim-window", + }, + { + name: "ExplicitTab", + value: "tab", + expectValue: "tab", + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + config := ` + provider "coder" { + } + resource "coder_agent" "dev" { + os = "linux" + arch = "amd64" + } + resource "coder_app" "code-server" { + agent_id = coder_agent.dev.id + slug = "code-server" + display_name = "code-server" + icon = "builtin:vim" + url = "http://localhost:13337" + healthcheck { + url = "http://localhost:13337/healthz" + interval = 5 + threshold = 6 + }` + + if c.value != "" { + config += fmt.Sprintf(` + open_in = %q + `, c.value) + } + + config += ` + } + ` + + checkFn := func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 2) + resource := state.Modules[0].Resources["coder_app.code-server"] + require.NotNil(t, resource) + + // Read share and ensure it matches the expected + // value. + value := resource.Primary.Attributes["open_in"] + require.Equal(t, c.expectValue, value) + return nil + } + if c.expectError != nil { + checkFn = nil + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: config, + Check: checkFn, + ExpectError: c.expectError, + }}, + }) + }) + } + }) + t.Run("Hidden", func(t *testing.T) { t.Parallel() @@ -248,6 +353,7 @@ func TestApp(t *testing.T) { name string config string hidden bool + openIn string }{{ name: "Is Hidden", config: ` @@ -263,9 +369,11 @@ func TestApp(t *testing.T) { url = "https://google.com" external = true hidden = true + open_in = "slim-window" } `, hidden: true, + openIn: "slim-window", }, { name: "Is Not Hidden", config: ` @@ -281,9 +389,11 @@ func TestApp(t *testing.T) { url = "https://google.com" external = true hidden = false + open_in = "window" } `, hidden: false, + openIn: "window", }} for _, tc := range cases { tc := tc @@ -300,6 +410,7 @@ func TestApp(t *testing.T) { resource := state.Modules[0].Resources["coder_app.test"] require.NotNil(t, resource) require.Equal(t, strconv.FormatBool(tc.hidden), resource.Primary.Attributes["hidden"]) + require.Equal(t, tc.openIn, resource.Primary.Attributes["open_in"]) return nil }, ExpectError: nil,