Skip to content

Commit

Permalink
feat: add support for terraform_remote_state (#550)
Browse files Browse the repository at this point in the history
Fixes #480.
  • Loading branch information
leg100 authored Aug 1, 2023
1 parent 19148a6 commit c2fa0a7
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 34 deletions.
4 changes: 4 additions & 0 deletions internal/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ type WorkspacePolicy struct {
Organization string
WorkspaceID string
Permissions []WorkspacePermission

// Whether workspace permits its state to be consumed by all workspaces in
// the organization.
GlobalRemoteState bool
}

// WorkspacePermission binds a role to a team.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@
</fieldset>
{{ end }}

<div class="flex flex-col gap-2">
<div class="flex gap-2">
<input class="" type="checkbox" name="global_remote_state" id="global-remote-state" {{ checked .Workspace.GlobalRemoteState }}>
<label class="font-semibold" for="global-remote-state">Remote state sharing</label>
</div>
<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>
</div>

<div class="field">
<button class="btn w-40">Save changes</button>
</div>
Expand Down
106 changes: 106 additions & 0 deletions internal/integration/remote_state_sharing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package integration

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/leg100/otf/internal"
"github.com/leg100/otf/internal/run"
"github.com/leg100/otf/internal/workspace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestRemoteStateSharing demonstrates the use of terraform_remote_state, and
// permitting or denying its use.
func TestRemoteStateSharing(t *testing.T) {
integrationTest(t)

daemon, org, ctx := setup(t, nil)
// producer is the workspace sharing its state
producer, err := daemon.CreateWorkspace(ctx, workspace.CreateOptions{
Name: internal.String("producer"),
Organization: internal.String(org.Name),
GlobalRemoteState: internal.Bool(true),
})
require.NoError(t, err)
// consumer is the workspace consuming the state of the producer
consumer := daemon.createWorkspace(t, ctx, org)

// populate producer with state
producerRoot := t.TempDir()
producerConfig := `output "foo" { value = "bar" }`
err = os.WriteFile(filepath.Join(producerRoot, "main.tf"), []byte(producerConfig), 0o777)
require.NoError(t, err)
tarball, err := internal.Pack(producerRoot)
require.NoError(t, err)
producerCV := daemon.createConfigurationVersion(t, ctx, producer, nil)
err = daemon.UploadConfig(ctx, producerCV.ID, tarball)
require.NoError(t, err)
// create run and apply
_ = daemon.createRun(t, ctx, producer, producerCV)
applied:
for event := range daemon.sub {
if r, ok := event.Payload.(*run.Run); ok {
switch r.Status {
case internal.RunPlanned:
err := daemon.Apply(ctx, r.ID)
require.NoError(t, err)
case internal.RunApplied:
break applied
case internal.RunErrored:
t.Fatalf("run unexpectedly errored")
}
}
}

// consume state from a run in the consumer workspace
consumerRoot := t.TempDir()
consumerConfig := fmt.Sprintf(`
data "terraform_remote_state" "producer" {
backend = "remote"
config = {
hostname = "%s"
organization = "%s"
workspaces = {
name = "%s"
}
}
}
output "remote_foo" {
value = data.terraform_remote_state.producer.outputs.foo
}
`, daemon.Hostname(), org.Name, producer.Name)
err = os.WriteFile(filepath.Join(consumerRoot, "main.tf"), []byte(consumerConfig), 0o777)
require.NoError(t, err)
tarball, err = internal.Pack(consumerRoot)
require.NoError(t, err)
consumerCV := daemon.createConfigurationVersion(t, ctx, consumer, nil)
err = daemon.UploadConfig(ctx, consumerCV.ID, tarball)
require.NoError(t, err)

// create run and apply
_ = daemon.createRun(t, ctx, consumer, consumerCV)
for event := range daemon.sub {
if r, ok := event.Payload.(*run.Run); ok {
switch r.Status {
case internal.RunPlanned:
err := daemon.Apply(ctx, r.ID)
require.NoError(t, err)
case internal.RunApplied:
return
case internal.RunErrored:
t.Fatalf("run unexpectedly errored")
}
}
}

got := daemon.getCurrentState(t, ctx, consumer.ID)
if assert.Contains(t, got.Outputs, "foo") {
assert.Equal(t, "bar", got.Outputs["foo"])
}
}
28 changes: 15 additions & 13 deletions internal/sql/pggen/workspace.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/sql/queries/workspace.sql
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ SET
branch = pggen.arg('branch'),
description = pggen.arg('description'),
execution_mode = pggen.arg('execution_mode'),
global_remote_state = pggen.arg('global_remote_state'),
name = pggen.arg('name'),
queue_all_runs = pggen.arg('queue_all_runs'),
speculative_enabled = pggen.arg('speculative_enabled'),
Expand Down
16 changes: 13 additions & 3 deletions internal/tokens/run_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const (

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

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

func (t *RunToken) CanAccessWorkspace(action rbac.Action, policy internal.WorkspacePolicy) bool {
// run token is allowed the retrieve the state of the workspace only if:
// (a) workspace is in the same organization as run token
// (b) workspace has enabled global remote state (permitting organization-wide
// state sharing).
switch action {
case rbac.GetWorkspaceAction, rbac.GetStateVersionAction, rbac.DownloadStateAction:
if t.Organization == policy.Organization && policy.GlobalRemoteState {
return true
}
}
return false
}

Expand Down
2 changes: 1 addition & 1 deletion internal/workspace/authorizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ func (a *authorizer) CanAccess(ctx context.Context, action rbac.Action, workspac
if subj.CanAccessWorkspace(action, policy) {
return subj, nil
}
a.Error(nil, "unauthorized action", "workspace", workspaceID, "organization", policy.Organization, "action", action, "subject", subj)
a.Error(nil, "unauthorized action", "workspace_id", workspaceID, "organization", policy.Organization, "action", action, "subject", subj)
return nil, internal.ErrAccessNotPermitted
}
1 change: 1 addition & 0 deletions internal/workspace/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ func (db *pgdb) update(ctx context.Context, workspaceID string, fn func(*Workspa
AutoApply: ws.AutoApply,
Description: sql.String(ws.Description),
ExecutionMode: sql.String(string(ws.ExecutionMode)),
GlobalRemoteState: ws.GlobalRemoteState,
Name: sql.String(ws.Name),
QueueAllRuns: ws.QueueAllRuns,
SpeculativeEnabled: ws.SpeculativeEnabled,
Expand Down
5 changes: 3 additions & 2 deletions internal/workspace/permissions_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ func (db *pgdb) GetWorkspacePolicy(ctx context.Context, workspaceID string) (int
}

policy := internal.WorkspacePolicy{
Organization: ws.OrganizationName.String,
WorkspaceID: workspaceID,
Organization: ws.OrganizationName.String,
WorkspaceID: workspaceID,
GlobalRemoteState: ws.GlobalRemoteState,
}
for _, perm := range perms {
role, err := rbac.WorkspaceRoleFromString(perm.Role.String)
Expand Down
31 changes: 17 additions & 14 deletions internal/workspace/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,14 +409,16 @@ func (h *webHandlers) editWorkspace(w http.ResponseWriter, r *http.Request) {

func (h *webHandlers) updateWorkspace(w http.ResponseWriter, r *http.Request) {
var params struct {
AutoApply bool `schema:"auto_apply"`
Name *string
Description *string
ExecutionMode *ExecutionMode `schema:"execution_mode"`
TerraformVersion *string `schema:"terraform_version"`
WorkingDirectory *string `schema:"working_directory"`
WorkspaceID string `schema:"workspace_id,required"`

AutoApply bool `schema:"auto_apply"`
Name *string
Description *string
ExecutionMode *ExecutionMode `schema:"execution_mode"`
TerraformVersion *string `schema:"terraform_version"`
WorkingDirectory *string `schema:"working_directory"`
WorkspaceID string `schema:"workspace_id,required"`
GlobalRemoteState bool `schema:"global_remote_state"`

// VCS connection
VCSTriggerStrategy string `schema:"vcs_trigger"`
TriggerPatternsJSON string `schema:"trigger_patterns"`
VCSBranch string `schema:"vcs_branch"`
Expand All @@ -436,12 +438,13 @@ func (h *webHandlers) updateWorkspace(w http.ResponseWriter, r *http.Request) {
}

opts := UpdateOptions{
AutoApply: &params.AutoApply,
Name: params.Name,
Description: params.Description,
ExecutionMode: params.ExecutionMode,
TerraformVersion: params.TerraformVersion,
WorkingDirectory: params.WorkingDirectory,
AutoApply: &params.AutoApply,
Name: params.Name,
Description: params.Description,
ExecutionMode: params.ExecutionMode,
TerraformVersion: params.TerraformVersion,
WorkingDirectory: params.WorkingDirectory,
GlobalRemoteState: &params.GlobalRemoteState,
}
if ws.Connection != nil {
// workspace is connected, so set connection fields
Expand Down
8 changes: 7 additions & 1 deletion internal/workspace/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,6 @@ func NewWorkspace(opts CreateOptions) (*Workspace, error) {
UpdatedAt: internal.CurrentTimestamp(),
AllowDestroyPlan: DefaultAllowDestroyPlan,
ExecutionMode: RemoteExecutionMode,
GlobalRemoteState: true, // Only global remote state is supported
TerraformVersion: DefaultTerraformVersion,
SpeculativeEnabled: true,
Organization: *opts.Organization,
Expand All @@ -215,6 +214,9 @@ func NewWorkspace(opts CreateOptions) (*Workspace, error) {
if opts.Description != nil {
ws.Description = *opts.Description
}
if opts.GlobalRemoteState != nil {
ws.GlobalRemoteState = *opts.GlobalRemoteState
}
if opts.QueueAllRuns != nil {
ws.QueueAllRuns = *opts.QueueAllRuns
}
Expand Down Expand Up @@ -328,6 +330,10 @@ func (ws *Workspace) Update(opts UpdateOptions) (*bool, error) {
}
updated = true
}
if opts.GlobalRemoteState != nil {
ws.GlobalRemoteState = *opts.GlobalRemoteState
updated = true
}
if opts.QueueAllRuns != nil {
ws.QueueAllRuns = *opts.QueueAllRuns
updated = true
Expand Down

0 comments on commit c2fa0a7

Please # to comment.