Skip to content

Commit c2fa0a7

Browse files
authored
feat: add support for terraform_remote_state (#550)
Fixes #480.
1 parent 19148a6 commit c2fa0a7

File tree

11 files changed

+176
-34
lines changed

11 files changed

+176
-34
lines changed

internal/authz.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ type WorkspacePolicy struct {
3232
Organization string
3333
WorkspaceID string
3434
Permissions []WorkspacePermission
35+
36+
// Whether workspace permits its state to be consumed by all workspaces in
37+
// the organization.
38+
GlobalRemoteState bool
3539
}
3640

3741
// WorkspacePermission binds a role to a team.

internal/http/html/static/templates/content/workspace_edit.tmpl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@
158158
</fieldset>
159159
{{ end }}
160160

161+
<div class="flex flex-col gap-2">
162+
<div class="flex gap-2">
163+
<input class="" type="checkbox" name="global_remote_state" id="global-remote-state" {{ checked .Workspace.GlobalRemoteState }}>
164+
<label class="font-semibold" for="global-remote-state">Remote state sharing</label>
165+
</div>
166+
<span class="description">Share this workspace's state with all workspaces in this organization. The <span class="bg-gray-200 font-mono">terraform_remote_state</span> data source relies on state sharing to access workspace outputs.</span>
167+
</div>
168+
161169
<div class="field">
162170
<button class="btn w-40">Save changes</button>
163171
</div>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package integration
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/leg100/otf/internal"
10+
"github.com/leg100/otf/internal/run"
11+
"github.com/leg100/otf/internal/workspace"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// TestRemoteStateSharing demonstrates the use of terraform_remote_state, and
17+
// permitting or denying its use.
18+
func TestRemoteStateSharing(t *testing.T) {
19+
integrationTest(t)
20+
21+
daemon, org, ctx := setup(t, nil)
22+
// producer is the workspace sharing its state
23+
producer, err := daemon.CreateWorkspace(ctx, workspace.CreateOptions{
24+
Name: internal.String("producer"),
25+
Organization: internal.String(org.Name),
26+
GlobalRemoteState: internal.Bool(true),
27+
})
28+
require.NoError(t, err)
29+
// consumer is the workspace consuming the state of the producer
30+
consumer := daemon.createWorkspace(t, ctx, org)
31+
32+
// populate producer with state
33+
producerRoot := t.TempDir()
34+
producerConfig := `output "foo" { value = "bar" }`
35+
err = os.WriteFile(filepath.Join(producerRoot, "main.tf"), []byte(producerConfig), 0o777)
36+
require.NoError(t, err)
37+
tarball, err := internal.Pack(producerRoot)
38+
require.NoError(t, err)
39+
producerCV := daemon.createConfigurationVersion(t, ctx, producer, nil)
40+
err = daemon.UploadConfig(ctx, producerCV.ID, tarball)
41+
require.NoError(t, err)
42+
// create run and apply
43+
_ = daemon.createRun(t, ctx, producer, producerCV)
44+
applied:
45+
for event := range daemon.sub {
46+
if r, ok := event.Payload.(*run.Run); ok {
47+
switch r.Status {
48+
case internal.RunPlanned:
49+
err := daemon.Apply(ctx, r.ID)
50+
require.NoError(t, err)
51+
case internal.RunApplied:
52+
break applied
53+
case internal.RunErrored:
54+
t.Fatalf("run unexpectedly errored")
55+
}
56+
}
57+
}
58+
59+
// consume state from a run in the consumer workspace
60+
consumerRoot := t.TempDir()
61+
consumerConfig := fmt.Sprintf(`
62+
data "terraform_remote_state" "producer" {
63+
backend = "remote"
64+
65+
config = {
66+
hostname = "%s"
67+
organization = "%s"
68+
workspaces = {
69+
name = "%s"
70+
}
71+
}
72+
}
73+
74+
output "remote_foo" {
75+
value = data.terraform_remote_state.producer.outputs.foo
76+
}
77+
`, daemon.Hostname(), org.Name, producer.Name)
78+
err = os.WriteFile(filepath.Join(consumerRoot, "main.tf"), []byte(consumerConfig), 0o777)
79+
require.NoError(t, err)
80+
tarball, err = internal.Pack(consumerRoot)
81+
require.NoError(t, err)
82+
consumerCV := daemon.createConfigurationVersion(t, ctx, consumer, nil)
83+
err = daemon.UploadConfig(ctx, consumerCV.ID, tarball)
84+
require.NoError(t, err)
85+
86+
// create run and apply
87+
_ = daemon.createRun(t, ctx, consumer, consumerCV)
88+
for event := range daemon.sub {
89+
if r, ok := event.Payload.(*run.Run); ok {
90+
switch r.Status {
91+
case internal.RunPlanned:
92+
err := daemon.Apply(ctx, r.ID)
93+
require.NoError(t, err)
94+
case internal.RunApplied:
95+
return
96+
case internal.RunErrored:
97+
t.Fatalf("run unexpectedly errored")
98+
}
99+
}
100+
}
101+
102+
got := daemon.getCurrentState(t, ctx, consumer.ID)
103+
if assert.Contains(t, got.Outputs, "foo") {
104+
assert.Equal(t, "bar", got.Outputs["foo"])
105+
}
106+
}

internal/sql/pggen/workspace.sql.go

Lines changed: 15 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/sql/queries/workspace.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ SET
244244
branch = pggen.arg('branch'),
245245
description = pggen.arg('description'),
246246
execution_mode = pggen.arg('execution_mode'),
247+
global_remote_state = pggen.arg('global_remote_state'),
247248
name = pggen.arg('name'),
248249
queue_all_runs = pggen.arg('queue_all_runs'),
249250
speculative_enabled = pggen.arg('speculative_enabled'),

internal/tokens/run_token.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const (
1616

1717
type (
1818
// RunToken is a short-lived token providing a terraform run with access to
19-
// resources, in particular access to the registry to retrieve modules.
19+
// resources, for example, to access the registry to retrieve modules, or to
20+
// retrieve the state of other workspaces when using `terraform_remote_state`.
2021
RunToken struct {
2122
Organization string
2223
}
@@ -53,16 +54,25 @@ func (t *RunToken) CanAccessSite(action rbac.Action) bool {
5354
}
5455

5556
func (t *RunToken) CanAccessOrganization(action rbac.Action, name string) bool {
56-
// run token is only allowed read-access to its organization's module registry
5757
switch action {
58-
case rbac.GetModuleAction, rbac.ListModulesAction:
58+
case rbac.GetOrganizationAction, rbac.GetEntitlementsAction, rbac.GetModuleAction, rbac.ListModulesAction:
5959
return t.Organization == name
6060
default:
6161
return false
6262
}
6363
}
6464

6565
func (t *RunToken) CanAccessWorkspace(action rbac.Action, policy internal.WorkspacePolicy) bool {
66+
// run token is allowed the retrieve the state of the workspace only if:
67+
// (a) workspace is in the same organization as run token
68+
// (b) workspace has enabled global remote state (permitting organization-wide
69+
// state sharing).
70+
switch action {
71+
case rbac.GetWorkspaceAction, rbac.GetStateVersionAction, rbac.DownloadStateAction:
72+
if t.Organization == policy.Organization && policy.GlobalRemoteState {
73+
return true
74+
}
75+
}
6676
return false
6777
}
6878

internal/workspace/authorizer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@ func (a *authorizer) CanAccess(ctx context.Context, action rbac.Action, workspac
2727
if subj.CanAccessWorkspace(action, policy) {
2828
return subj, nil
2929
}
30-
a.Error(nil, "unauthorized action", "workspace", workspaceID, "organization", policy.Organization, "action", action, "subject", subj)
30+
a.Error(nil, "unauthorized action", "workspace_id", workspaceID, "organization", policy.Organization, "action", action, "subject", subj)
3131
return nil, internal.ErrAccessNotPermitted
3232
}

internal/workspace/db.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ func (db *pgdb) update(ctx context.Context, workspaceID string, fn func(*Workspa
178178
AutoApply: ws.AutoApply,
179179
Description: sql.String(ws.Description),
180180
ExecutionMode: sql.String(string(ws.ExecutionMode)),
181+
GlobalRemoteState: ws.GlobalRemoteState,
181182
Name: sql.String(ws.Name),
182183
QueueAllRuns: ws.QueueAllRuns,
183184
SpeculativeEnabled: ws.SpeculativeEnabled,

internal/workspace/permissions_db.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ func (db *pgdb) GetWorkspacePolicy(ctx context.Context, workspaceID string) (int
4444
}
4545

4646
policy := internal.WorkspacePolicy{
47-
Organization: ws.OrganizationName.String,
48-
WorkspaceID: workspaceID,
47+
Organization: ws.OrganizationName.String,
48+
WorkspaceID: workspaceID,
49+
GlobalRemoteState: ws.GlobalRemoteState,
4950
}
5051
for _, perm := range perms {
5152
role, err := rbac.WorkspaceRoleFromString(perm.Role.String)

internal/workspace/web.go

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -409,14 +409,16 @@ func (h *webHandlers) editWorkspace(w http.ResponseWriter, r *http.Request) {
409409

410410
func (h *webHandlers) updateWorkspace(w http.ResponseWriter, r *http.Request) {
411411
var params struct {
412-
AutoApply bool `schema:"auto_apply"`
413-
Name *string
414-
Description *string
415-
ExecutionMode *ExecutionMode `schema:"execution_mode"`
416-
TerraformVersion *string `schema:"terraform_version"`
417-
WorkingDirectory *string `schema:"working_directory"`
418-
WorkspaceID string `schema:"workspace_id,required"`
419-
412+
AutoApply bool `schema:"auto_apply"`
413+
Name *string
414+
Description *string
415+
ExecutionMode *ExecutionMode `schema:"execution_mode"`
416+
TerraformVersion *string `schema:"terraform_version"`
417+
WorkingDirectory *string `schema:"working_directory"`
418+
WorkspaceID string `schema:"workspace_id,required"`
419+
GlobalRemoteState bool `schema:"global_remote_state"`
420+
421+
// VCS connection
420422
VCSTriggerStrategy string `schema:"vcs_trigger"`
421423
TriggerPatternsJSON string `schema:"trigger_patterns"`
422424
VCSBranch string `schema:"vcs_branch"`
@@ -436,12 +438,13 @@ func (h *webHandlers) updateWorkspace(w http.ResponseWriter, r *http.Request) {
436438
}
437439

438440
opts := UpdateOptions{
439-
AutoApply: &params.AutoApply,
440-
Name: params.Name,
441-
Description: params.Description,
442-
ExecutionMode: params.ExecutionMode,
443-
TerraformVersion: params.TerraformVersion,
444-
WorkingDirectory: params.WorkingDirectory,
441+
AutoApply: &params.AutoApply,
442+
Name: params.Name,
443+
Description: params.Description,
444+
ExecutionMode: params.ExecutionMode,
445+
TerraformVersion: params.TerraformVersion,
446+
WorkingDirectory: params.WorkingDirectory,
447+
GlobalRemoteState: &params.GlobalRemoteState,
445448
}
446449
if ws.Connection != nil {
447450
// workspace is connected, so set connection fields

internal/workspace/workspace.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,6 @@ func NewWorkspace(opts CreateOptions) (*Workspace, error) {
192192
UpdatedAt: internal.CurrentTimestamp(),
193193
AllowDestroyPlan: DefaultAllowDestroyPlan,
194194
ExecutionMode: RemoteExecutionMode,
195-
GlobalRemoteState: true, // Only global remote state is supported
196195
TerraformVersion: DefaultTerraformVersion,
197196
SpeculativeEnabled: true,
198197
Organization: *opts.Organization,
@@ -215,6 +214,9 @@ func NewWorkspace(opts CreateOptions) (*Workspace, error) {
215214
if opts.Description != nil {
216215
ws.Description = *opts.Description
217216
}
217+
if opts.GlobalRemoteState != nil {
218+
ws.GlobalRemoteState = *opts.GlobalRemoteState
219+
}
218220
if opts.QueueAllRuns != nil {
219221
ws.QueueAllRuns = *opts.QueueAllRuns
220222
}
@@ -328,6 +330,10 @@ func (ws *Workspace) Update(opts UpdateOptions) (*bool, error) {
328330
}
329331
updated = true
330332
}
333+
if opts.GlobalRemoteState != nil {
334+
ws.GlobalRemoteState = *opts.GlobalRemoteState
335+
updated = true
336+
}
331337
if opts.QueueAllRuns != nil {
332338
ws.QueueAllRuns = *opts.QueueAllRuns
333339
updated = true

0 commit comments

Comments
 (0)