Skip to content

Commit

Permalink
feat: always load a default workspace (#119)
Browse files Browse the repository at this point in the history
  • Loading branch information
leg100 authored Aug 19, 2024
1 parent 8977e45 commit c21a7d4
Show file tree
Hide file tree
Showing 21 changed files with 256 additions and 118 deletions.
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func New(cfg Config) (*App, error) {
Modules: modules,
Logger: logger,
})
modules.WorkspaceLoader = workspaces
states := state.NewService(state.ServiceOptions{
Modules: modules,
Workspaces: workspaces,
Expand All @@ -76,6 +77,7 @@ func New(cfg Config) (*App, error) {
Workspaces: workspaces,
States: states,
DataDir: cfg.DataDir,
Workdir: cfg.Workdir,
Logger: logger,
Terragrunt: cfg.Terragrunt,
})
Expand Down
12 changes: 6 additions & 6 deletions internal/integration/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestTaskList_Split(t *testing.T) {
// Expect tasks that are automatically triggered when a module is loaded
waitFor(t, tm, func(s string) bool {
return strings.Contains(s, "Tasks") &&
strings.Contains(s, "1-4 of 4") &&
strings.Contains(s, "1-5 of 5") &&
matchPattern(t, `modules/a.*init.*exited`, s) &&
matchPattern(t, `modules/a.*workspace list.*exited`, s) &&
matchPattern(t, `modules/a.*default.*state pull.*exited`, s)
Expand All @@ -38,16 +38,16 @@ func TestTaskList_Split(t *testing.T) {

waitFor(t, tm, func(s string) bool {
return strings.Contains(s, "Tasks") &&
strings.Contains(s, "1-3 of 4")
strings.Contains(s, "1-3 of 5")
})

// Increase the split until all 4 tasks are visible. That means the split
// needs to be increased once.
tm.Type(strings.Repeat("+", 1))
// Increase the split until all 5 tasks are visible. That means the split
// needs to be increased twice.
tm.Type(strings.Repeat("+", 2))

waitFor(t, tm, func(s string) bool {
return strings.Contains(s, "Tasks") &&
strings.Contains(s, "1-4 of 4")
strings.Contains(s, "1-5 of 5")
})

}
Expand Down
55 changes: 39 additions & 16 deletions internal/module/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,56 +6,71 @@ import (
"log/slog"
"path/filepath"
"sync"
"testing"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/leg100/pug/internal"
"github.com/leg100/pug/internal/resource"
"github.com/stretchr/testify/require"
)

// Module is a terraform root module.
type Module struct {
resource.Common

// Pug working directory
Workdir internal.Workdir

// Path relative to pug working directory
Path string

// The module's current workspace.
CurrentWorkspaceID *resource.ID

CurrentWorkspaceID resource.ID
// Whether workspaces have been successfully loaded.
LoadedWorkspaces bool
// The module's backend type
Backend string
}

// Options for constructing a module.
type Options struct {
// Path is the module path relative to the working directory.
Path string
// Backend is the type of terraform backend
Backend string
}

// New constructs a module. Workdir is the pug working directory, and path is
// the module path relative to the working directory.
func New(workdir internal.Workdir, opts Options) *Module {
return &Module{
type WorkspaceLoader interface {
LoadWorkspaces(mod *Module) (resource.ID, error)
}

type factory struct {
WorkspaceLoader
}

// newModule constructs a module.
func (f *factory) newModule(opts Options) (*Module, error) {
mod := &Module{
Common: resource.New(resource.Module, resource.GlobalResource),
Workdir: workdir,
Path: opts.Path,
Backend: opts.Backend,
}
// A module always has a current workspace.
currentWorkspaceID, err := f.LoadWorkspaces(mod)
if err != nil {
return nil, err
}
mod.CurrentWorkspaceID = currentWorkspaceID
return mod, nil
}

func (m *Module) String() string {
return m.Path
func NewTestModule(t *testing.T, opts Options) *Module {
factory := &factory{&fakeWorkspaceLoader{}}
mod, err := factory.newModule(opts)
require.NoError(t, err)
return mod
}

// FullPath returns the absolute path to the module.
func (m *Module) FullPath() string {
return filepath.Join(m.Workdir.String(), m.Path)
func (m *Module) String() string {
return m.Path
}

func (m *Module) LogValue() slog.Value {
Expand Down Expand Up @@ -213,3 +228,11 @@ func detectBackend(path string) (string, bool, error) {
}
return "", false, nil
}

type fakeWorkspaceLoader struct {
workspaceID resource.ID
}

func (f *fakeWorkspaceLoader) LoadWorkspaces(*Module) (resource.ID, error) {
return f.workspaceID, nil
}
6 changes: 4 additions & 2 deletions internal/module/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import (

"github.com/leg100/pug/internal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNew(t *testing.T) {
os.MkdirAll("./testdata/modules/with_both_s3_backend_and_dot_terraform_dir/.terraform", 0o755)

workdir, _ := internal.NewWorkdir("./testdata/modules")
factory := &factory{&fakeWorkspaceLoader{}}

got := New(workdir, Options{Path: "with_s3_backend", Backend: "s3"})
got, err := factory.newModule(Options{Path: "with_s3_backend", Backend: "s3"})
require.NoError(t, err)
assert.Equal(t, "with_s3_backend", got.Path)
}

Expand Down
32 changes: 22 additions & 10 deletions internal/module/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Service struct {
logger logging.Interface
terragrunt bool

*factory
*pubsub.Broker[*Module]
}

Expand Down Expand Up @@ -60,12 +61,15 @@ func NewService(opts ServiceOptions) *Service {
pluginCache: opts.PluginCache,
logger: opts.Logger,
terragrunt: opts.Terragrunt,
factory: &factory{},
}
}

// Reload searches the working directory recursively for modules and adds them
// to the store before pruning those that are currently stored but can no longer
// be found.
//
// TODO: separate into Load and Reload
func (s *Service) Reload() (added []string, removed []string, err error) {
ch, errc := find(context.TODO(), s.workdir)
var found []string
Expand All @@ -80,7 +84,11 @@ func (s *Service) Reload() (added []string, removed []string, err error) {
// handle found module
if mod, err := s.GetByPath(opts.Path); errors.Is(err, resource.ErrNotFound) {
// Not found, so add to pug
mod := New(s.workdir, opts)
mod, err := s.newModule(opts)
if err != nil {
s.logger.Error("reloading modules", "error", err)
continue
}
s.table.Add(mod.ID, mod)
added = append(added, opts.Path)
} else if err != nil {
Expand Down Expand Up @@ -212,17 +220,13 @@ func (s *Service) Init(moduleID resource.ID) (task.Spec, error) {
// The terraform plugin cache is not concurrency-safe, so only allow one
// init task to run at any given time.
Exclusive: s.pluginCache,
AfterCreate: func(task *task.Task) {
// Trigger a workspace reload if the module doesn't yet have a
// current workspace
mod := task.Module().(*Module)
if mod.CurrentWorkspaceID == nil {
s.Publish(resource.UpdatedEvent, mod)
}
},
})
}

func IsInitTask(t *task.Task) bool {
return len(t.Command) > 0 && t.Command[0] == "init"
}

func (s *Service) Format(moduleID resource.ID) (task.Spec, error) {
return s.updateSpec(moduleID, task.Spec{
Command: []string{"fmt"},
Expand Down Expand Up @@ -252,10 +256,18 @@ func (s *Service) GetByPath(path string) (*Module, error) {
return nil, fmt.Errorf("%s: %w", path, resource.ErrNotFound)
}

func (s *Service) SetLoadedWorkspaces(moduleID resource.ID) error {
_, err := s.table.Update(moduleID, func(existing *Module) error {
existing.LoadedWorkspaces = true
return nil
})
return err
}

// SetCurrent sets the current workspace for the module.
func (s *Service) SetCurrent(moduleID, workspaceID resource.ID) error {
_, err := s.table.Update(moduleID, func(existing *Module) error {
existing.CurrentWorkspaceID = &workspaceID
existing.CurrentWorkspaceID = workspaceID
return nil
})
return err
Expand Down
10 changes: 5 additions & 5 deletions internal/module/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import (
func TestLoadTerragruntDependenciesFromDigraph(t *testing.T) {
// Setup modules and load into table
workdir := internal.NewTestWorkdir(t)
vpc := New(workdir, Options{Path: "root/vpc"})
redis := New(workdir, Options{Path: "root/redis"})
mysql := New(workdir, Options{Path: "root/mysql"})
frontend := New(workdir, Options{Path: "root/frontend-app"})
backend := New(workdir, Options{Path: "root/backend-app"})
vpc := NewTestModule(t, Options{Path: "root/vpc"})
redis := NewTestModule(t, Options{Path: "root/redis"})
mysql := NewTestModule(t, Options{Path: "root/mysql"})
frontend := NewTestModule(t, Options{Path: "root/frontend-app"})
backend := NewTestModule(t, Options{Path: "root/backend-app"})
svc := &Service{
table: &fakeModuleTable{modules: []*Module{vpc, redis, mysql, backend, frontend}},
workdir: workdir,
Expand Down
4 changes: 3 additions & 1 deletion internal/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"

"github.com/leg100/pug/internal"
"github.com/leg100/pug/internal/logging"
"github.com/leg100/pug/internal/pubsub"
"github.com/leg100/pug/internal/resource"
Expand Down Expand Up @@ -45,6 +46,7 @@ type CreateOptions struct {

type factory struct {
dataDir string
workdir internal.Workdir
workspaces workspaceGetter
broker *pubsub.Broker[*plan]
terragrunt bool
Expand All @@ -71,7 +73,7 @@ func (f *factory) newPlan(workspaceID resource.ID, opts CreateOptions) (*plan, e
for _, addr := range plan.TargetAddrs {
plan.targetArgs = append(plan.targetArgs, fmt.Sprintf("-target=%s", addr))
}
if fname, ok := ws.VarsFile(); ok {
if fname, ok := ws.VarsFile(f.workdir); ok {
flag := fmt.Sprintf("-var-file=%s", fname)
plan.varsFileArg = &flag
}
Expand Down
8 changes: 5 additions & 3 deletions internal/plan/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ func TestPlan_VarsFile(t *testing.T) {
f, mod, ws := setupTest(t)

// Create a workspace tfvars file for dev
os.MkdirAll(mod.FullPath(), 0o755)
_, err := os.Create(filepath.Join(mod.FullPath(), "dev.tfvars"))
path := f.workdir.Join(mod.Path, "dev.tfvars")
os.MkdirAll(filepath.Dir(path), 0o755)
_, err := os.Create(path)
require.NoError(t, err)

run, err := f.newPlan(ws.ID, CreateOptions{})
Expand All @@ -44,12 +45,13 @@ func setupTest(t *testing.T) (*factory, *module.Module, *workspace.Workspace) {
workdir := internal.NewTestWorkdir(t)
testutils.ChTempDir(t, workdir.String())

mod := module.New(workdir, module.Options{Path: "a/b/c"})
mod := module.NewTestModule(t, module.Options{Path: "a/b/c"})
ws, err := workspace.New(mod, "dev")
require.NoError(t, err)
factory := factory{
workspaces: &fakeWorkspaceGetter{ws: ws},
dataDir: t.TempDir(),
workdir: workdir,
}
return &factory, mod, ws
}
Expand Down
3 changes: 3 additions & 0 deletions internal/plan/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package plan
import (
"fmt"

"github.com/leg100/pug/internal"
"github.com/leg100/pug/internal/logging"
"github.com/leg100/pug/internal/module"
"github.com/leg100/pug/internal/pubsub"
Expand Down Expand Up @@ -31,6 +32,7 @@ type ServiceOptions struct {
Workspaces *workspace.Service
States *state.Service
DataDir string
Workdir internal.Workdir
Logger logging.Interface
Terragrunt bool
}
Expand All @@ -55,6 +57,7 @@ func NewService(opts ServiceOptions) *Service {
logger: opts.Logger,
factory: &factory{
dataDir: opts.DataDir,
workdir: opts.Workdir,
workspaces: opts.Workspaces,
broker: broker,
terragrunt: opts.Terragrunt,
Expand Down
33 changes: 33 additions & 0 deletions internal/state/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,39 @@ func NewService(opts ServiceOptions) *Service {
return s
}

// LoadStateUponWorkspaceLoad automatically loads state for a workspace when it
// is loaded into pug for the first time.
func (s *Service) LoadStateUponWorkspaceLoad(sub <-chan resource.Event[*workspace.Workspace]) {
for event := range sub {
if event.Type == resource.CreatedEvent {
_, _ = s.CreateReloadTask(event.Payload.ID)
}
}
}

// LoadStateUponInit automatically loads state whenever a module is successfully
// initialised, and the state for a workspace belonging to the module has not
// been loaded yet.
func (s *Service) LoadStateUponInit(sub <-chan resource.Event[*task.Task]) {
for event := range sub {
if !module.IsInitTask(event.Payload) {
continue
}
if event.Payload.State != task.Exited {
continue
}
opts := workspace.ListOptions{ModuleID: event.Payload.Module().GetID()}
workspaces := s.workspaces.List(opts)
for _, ws := range workspaces {
if _, err := s.cache.Get(ws.ID); err == nil {
// State already loaded
continue
}
_, _ = s.CreateReloadTask(ws.ID)
}
}
}

// Get retrieves the state for a workspace.
func (s *Service) Get(workspaceID resource.ID) (*State, error) {
return s.cache.Get(workspaceID)
Expand Down
3 changes: 1 addition & 2 deletions internal/state/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import (
"os"
"testing"

"github.com/leg100/pug/internal"
"github.com/leg100/pug/internal/module"
"github.com/leg100/pug/internal/workspace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestState(t *testing.T) {
mod := module.New(internal.NewTestWorkdir(t), module.Options{Path: "a/b/c"})
mod := module.NewTestModule(t, module.Options{Path: "a/b/c"})
ws, err := workspace.New(mod, "dev")
require.NoError(t, err)

Expand Down
Loading

0 comments on commit c21a7d4

Please sign in to comment.