diff --git a/internal/app/app.go b/internal/app/app.go index e03f95f..830d62d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, @@ -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, }) diff --git a/internal/integration/task_test.go b/internal/integration/task_test.go index d493c51..1956b44 100644 --- a/internal/integration/task_test.go +++ b/internal/integration/task_test.go @@ -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) @@ -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") }) } diff --git a/internal/module/module.go b/internal/module/module.go index fcfc39b..8794415 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -6,31 +6,31 @@ 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 @@ -38,24 +38,39 @@ type Options struct { 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 { @@ -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 +} diff --git a/internal/module/module_test.go b/internal/module/module_test.go index 8369594..a59f2e4 100644 --- a/internal/module/module_test.go +++ b/internal/module/module_test.go @@ -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) } diff --git a/internal/module/service.go b/internal/module/service.go index de283e5..a346cd6 100644 --- a/internal/module/service.go +++ b/internal/module/service.go @@ -23,6 +23,7 @@ type Service struct { logger logging.Interface terragrunt bool + *factory *pubsub.Broker[*Module] } @@ -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 @@ -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 { @@ -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"}, @@ -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 diff --git a/internal/module/service_test.go b/internal/module/service_test.go index cbc9485..231fc31 100644 --- a/internal/module/service_test.go +++ b/internal/module/service_test.go @@ -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, diff --git a/internal/plan/plan.go b/internal/plan/plan.go index ae0bf87..fcf0cf1 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -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" @@ -45,6 +46,7 @@ type CreateOptions struct { type factory struct { dataDir string + workdir internal.Workdir workspaces workspaceGetter broker *pubsub.Broker[*plan] terragrunt bool @@ -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 } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index e45ae19..65abfb2 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -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{}) @@ -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 } diff --git a/internal/plan/service.go b/internal/plan/service.go index 62e424e..9d1b1e4 100644 --- a/internal/plan/service.go +++ b/internal/plan/service.go @@ -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" @@ -31,6 +32,7 @@ type ServiceOptions struct { Workspaces *workspace.Service States *state.Service DataDir string + Workdir internal.Workdir Logger logging.Interface Terragrunt bool } @@ -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, diff --git a/internal/state/service.go b/internal/state/service.go index 51b7cb3..6be1ca0 100644 --- a/internal/state/service.go +++ b/internal/state/service.go @@ -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) diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 11b6796..29b7fa5 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -5,7 +5,6 @@ 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" @@ -13,7 +12,7 @@ import ( ) 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) diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index d904653..32b76c4 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -48,11 +48,7 @@ func (h *Helpers) WorkspaceName(res resource.Resource) string { } func (h *Helpers) ModuleCurrentWorkspace(mod *module.Module) *workspace.Workspace { - if mod.CurrentWorkspaceID == nil { - h.Logger.Error("module does not have a current workspace", "module", mod) - return nil - } - ws, err := h.Workspaces.Get(*mod.CurrentWorkspaceID) + ws, err := h.Workspaces.Get(mod.CurrentWorkspaceID) if err != nil { h.Logger.Error("retrieving current workspace for module", "error", err, "module", mod) return nil @@ -72,11 +68,8 @@ func (h *Helpers) Module(res resource.Resource) *module.Module { return mod } -func (h *Helpers) CurrentWorkspaceName(workspaceID *resource.ID) string { - if workspaceID == nil { - return "-" - } - ws, err := h.Workspaces.Get(*workspaceID) +func (h *Helpers) CurrentWorkspaceName(workspaceID resource.ID) string { + ws, err := h.Workspaces.Get(workspaceID) if err != nil { h.Logger.Error("rendering current workspace name", "error", err) return "" @@ -85,10 +78,7 @@ func (h *Helpers) CurrentWorkspaceName(workspaceID *resource.ID) string { } func (h *Helpers) ModuleCurrentResourceCount(mod *module.Module) string { - if mod.CurrentWorkspaceID == nil { - return "" - } - ws, err := h.Workspaces.Get(*mod.CurrentWorkspaceID) + ws, err := h.Workspaces.Get(mod.CurrentWorkspaceID) if err != nil { h.Logger.Error("rendering module current workspace resource count", "error", err) return "" @@ -104,7 +94,7 @@ func (h *Helpers) WorkspaceCurrentCheckmark(ws *workspace.Workspace) string { h.Logger.Error("rendering current workspace checkmark", "error", err) return "" } - if mod.CurrentWorkspaceID != nil && *mod.CurrentWorkspaceID == ws.ID { + if mod.CurrentWorkspaceID == ws.ID { return "✓" } return "" diff --git a/internal/tui/module/list.go b/internal/tui/module/list.go index 56d2407..2dc33d7 100644 --- a/internal/tui/module/list.go +++ b/internal/tui/module/list.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/leg100/pug/internal" "github.com/leg100/pug/internal/module" "github.com/leg100/pug/internal/plan" "github.com/leg100/pug/internal/resource" @@ -41,7 +42,7 @@ type ListMaker struct { Workspaces *workspace.Service Plans *plan.Service Spinner *spinner.Model - Workdir string + Workdir internal.Workdir Helpers *tui.Helpers Terragrunt bool } @@ -102,7 +103,7 @@ type list struct { table table.Model[*module.Module] spinner *spinner.Model - workdir string + workdir internal.Workdir helpers *tui.Helpers } @@ -133,7 +134,8 @@ func (m list) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, ReloadModules(false, m.Modules) case key.Matches(msg, keys.Common.Edit): if row, ok := m.table.CurrentRow(); ok { - return m, tui.OpenEditor(row.Value.FullPath()) + path := m.workdir.Join(row.Value.Path) + return m, tui.OpenEditor(path) } case key.Matches(msg, keys.Common.Init): cmd := m.helpers.CreateTasks(m.Modules.Init, m.table.SelectedOrCurrentIDs()...) @@ -160,11 +162,7 @@ func (m list) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Create specs here, de-selecting any modules where an error is // returned. specs, err := m.table.Prune(func(mod *module.Module) (task.Spec, error) { - if workspaceID := mod.CurrentWorkspaceID; workspaceID == nil { - return task.Spec{}, fmt.Errorf("module %s does not have a current workspace", mod) - } else { - return m.Plans.Plan(*workspaceID, createPlanOpts) - } + return m.Plans.Plan(mod.CurrentWorkspaceID, createPlanOpts) }) if err != nil { // Modules were de-selected, so report error and give user @@ -180,11 +178,7 @@ func (m list) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Create specs here, de-selecting any modules where an error is // returned. specs, err := m.table.Prune(func(mod *module.Module) (task.Spec, error) { - if workspaceID := mod.CurrentWorkspaceID; workspaceID == nil { - return task.Spec{}, fmt.Errorf("module %s does not have a current workspace", mod) - } else { - return m.Plans.Apply(*workspaceID, createPlanOpts) - } + return m.Plans.Apply(mod.CurrentWorkspaceID, createPlanOpts) }) if err != nil { // Modules were de-selected, so report error and give user diff --git a/internal/tui/top/makers.go b/internal/tui/top/makers.go index 68e6043..beb5f7d 100644 --- a/internal/tui/top/makers.go +++ b/internal/tui/top/makers.go @@ -54,7 +54,7 @@ func makeMakers(cfg app.Config, app *app.App, spinner *spinner.Model) map[tui.Ki Workspaces: app.Workspaces, Plans: app.Plans, Spinner: spinner, - Workdir: cfg.Workdir.PrettyString(), + Workdir: cfg.Workdir, Helpers: helpers, Terragrunt: cfg.Terragrunt, }, diff --git a/internal/tui/top/start.go b/internal/tui/top/start.go index db33a75..bbfcd9b 100644 --- a/internal/tui/top/start.go +++ b/internal/tui/top/start.go @@ -8,7 +8,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" "github.com/leg100/pug/internal/app" - "github.com/leg100/pug/internal/resource" "github.com/stretchr/testify/require" ) @@ -167,16 +166,22 @@ func setupSubscriptions(app *app.App, cfg app.Config) (chan tea.Msg, func()) { sub := app.Modules.Subscribe(ctx) go app.Workspaces.LoadWorkspacesUponModuleLoad(sub) } + // Automatically load workspaces whenever init is run and workspaces have + // not yet been loaded. + { + sub := app.Tasks.TaskBroker.Subscribe(ctx) + go app.Workspaces.LoadWorkspacesUponInit(sub) + } // Whenever a workspace is loaded, pull its state { sub := app.Workspaces.Subscribe(ctx) - go func() { - for event := range sub { - if event.Type == resource.CreatedEvent { - _, _ = app.States.CreateReloadTask(event.Payload.ID) - } - } - }() + go app.States.LoadStateUponWorkspaceLoad(sub) + } + // Whenever a module is initialised, ensure each workspace belonging to the + // module has its state loaded into pug. + { + sub := app.Tasks.TaskBroker.Subscribe(ctx) + go app.States.LoadStateUponInit(sub) } // Whenever an apply is successful, pull workspace state if !cfg.DisableReloadAfterApply { diff --git a/internal/workdir.go b/internal/workdir.go index 897fe21..4dd162f 100644 --- a/internal/workdir.go +++ b/internal/workdir.go @@ -43,6 +43,11 @@ func NewWorkdir(path string) (Workdir, error) { return wd, nil } +func (wd Workdir) Join(paths ...string) string { + paths = append([]string{wd.path}, paths...) + return filepath.Join(paths...) +} + func (wd Workdir) String() string { return wd.path } diff --git a/internal/workspace/reloader.go b/internal/workspace/reloader.go index 11d1e79..d02fa86 100644 --- a/internal/workspace/reloader.go +++ b/internal/workspace/reloader.go @@ -33,9 +33,20 @@ func (s ReloadSummary) LogValue() slog.Value { ) } +func (r *reloader) createReloadTask(moduleID resource.ID) error { + spec, err := r.Reload(moduleID) + if err != nil { + return err + } + _, err = r.tasks.Create(spec) + return err +} + // Reload returns a task spec that runs `terraform workspace list` on a // module and updates pug with the results, adding any newly discovered // workspaces and pruning any workspaces no longer found to exist. +// +// TODO: separate into Load and Reload func (r *reloader) Reload(moduleID resource.ID) (task.Spec, error) { mod, err := r.modules.Get(moduleID) if err != nil { @@ -54,6 +65,9 @@ func (r *reloader) Reload(moduleID resource.ID) (task.Spec, error) { if err != nil { return nil, err } + if err := r.modules.SetLoadedWorkspaces(mod.ID); err != nil { + return nil, err + } return ReloadSummary{Added: added, Removed: removed}, nil }, }, nil diff --git a/internal/workspace/reloader_test.go b/internal/workspace/reloader_test.go index 83feac6..2a132ce 100644 --- a/internal/workspace/reloader_test.go +++ b/internal/workspace/reloader_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - "github.com/leg100/pug/internal" "github.com/leg100/pug/internal/module" "github.com/leg100/pug/internal/resource" "github.com/stretchr/testify/assert" @@ -30,7 +29,7 @@ func TestWorkspace_parseList(t *testing.T) { } func TestWorkspace_resetWorkspaces(t *testing.T) { - mod := module.New(internal.NewTestWorkdir(t), module.Options{Path: "a/b/c"}) + mod := &module.Module{Path: "a/b/c"} dev, err := New(mod, "dev") require.NoError(t, err) staging, err := New(mod, "staging") diff --git a/internal/workspace/service.go b/internal/workspace/service.go index 37aaa31..6c309e0 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -1,8 +1,13 @@ package workspace import ( + "bytes" + "errors" "fmt" + "io/fs" + "os" + "github.com/leg100/pug/internal" "github.com/leg100/pug/internal/logging" "github.com/leg100/pug/internal/module" "github.com/leg100/pug/internal/pubsub" @@ -16,6 +21,7 @@ type Service struct { modules modules tasks *task.Service + workdir internal.Workdir *pubsub.Broker[*Workspace] *reloader @@ -25,6 +31,7 @@ type ServiceOptions struct { Tasks *task.Service Modules *module.Service Logger logging.Interface + Workdir internal.Workdir } type workspaceTable interface { @@ -39,6 +46,7 @@ type modules interface { Get(id resource.ID) (*module.Module, error) GetByPath(path string) (*module.Module, error) SetCurrent(moduleID, workspaceID resource.ID) error + SetLoadedWorkspaces(moduleID resource.ID) error Reload() ([]string, []string, error) List() []*module.Module } @@ -55,43 +63,86 @@ func NewService(opts ServiceOptions) *Service { modules: opts.Modules, tasks: opts.Tasks, logger: opts.Logger, + workdir: opts.Workdir, } s.reloader = &reloader{s} return s } +// LoadWorkspaces is called by the module constructor to load its +// initial workspaces including a default workspace as well its +// current workspace, the ID of which is returned. +func (s *Service) LoadWorkspaces(mod *module.Module) (resource.ID, error) { + // Load default workspace + defaultWorkspace, err := New(mod, "default") + if err != nil { + return resource.ID{}, fmt.Errorf("loading default workspace: %w", err) + } + s.table.Add(defaultWorkspace.ID, defaultWorkspace) + + // Determine current workspace. If a .terraform/environment file exists then + // read current workspace from there. + envfile, err := os.ReadFile(s.workdir.Join(".terraform", "environment")) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + s.logger.Error("reading current workspace from file", "error", err) + } + // Current workspace is the default workspace if there is any error + // reading the environment file. + return defaultWorkspace.ID, nil + } + current := string(bytes.TrimSpace(envfile)) + if current == "default" { + // Nothing more to be done. + return defaultWorkspace.ID, nil + } + + // Load non-default workspace and return it as the current worskpace. + nonDefaultWorkspace, err := New(mod, current) + if err != nil { + return resource.ID{}, fmt.Errorf("loading current workspace: %w", err) + } + s.table.Add(nonDefaultWorkspace.ID, nonDefaultWorkspace) + return nonDefaultWorkspace.ID, nil +} + // LoadWorkspacesUponModuleLoad automatically loads workspaces for a module -// whenever: -// * a new module is loaded into pug for the first time -// * an existing module is updated and does not yet have a current workspace. -// -// TODO: "load" is ambiguous, it often means the opposite of save, i.e. read -// from a system, whereas what is intended is to save or add workspaces to pug. +// that has been newly loaded into pug. func (s *Service) LoadWorkspacesUponModuleLoad(sub <-chan resource.Event[*module.Module]) { - reload := func(moduleID resource.ID) error { - spec, err := s.Reload(moduleID) - if err != nil { - return err + for event := range sub { + if event.Type != resource.CreatedEvent { + continue + } + // Should be false on a new module. + if event.Payload.LoadedWorkspaces { + continue + } + if err := s.createReloadTask(event.Payload.ID); err != nil { + s.logger.Error("reloading workspaces", "module", event.Payload) } - _, err = s.tasks.Create(spec) - return err } +} +// LoadWorkspacesUponInit automatically loads workspaces for a module whenever +// it is successfully initialized and the module has not yet had its workspaces +// loaded. +func (s *Service) LoadWorkspacesUponInit(sub <-chan resource.Event[*task.Task]) { for event := range sub { - switch event.Type { - case resource.CreatedEvent: - if err := reload(event.Payload.ID); err != nil { - s.logger.Error("reloading workspaces", "module", event.Payload) - } - case resource.UpdatedEvent: - if event.Payload.CurrentWorkspaceID != nil { - // Module already has a current workspace; no need to reload - // workspaces - continue - } - if err := reload(event.Payload.ID); err != nil { - s.logger.Error("reloading workspaces", "module", event.Payload) - } + if !module.IsInitTask(event.Payload) { + continue + } + if event.Payload.State != task.Exited { + continue + } + mod, err := s.modules.Get(event.Payload.Module().GetID()) + if err != nil { + continue + } + if mod.LoadedWorkspaces { + continue + } + if err := s.createReloadTask(mod.ID); err != nil { + s.logger.Error("reloading workspaces", "module", event.Payload) } } } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index d7ff692..7ba2972 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" + "github.com/leg100/pug/internal" "github.com/leg100/pug/internal/module" "github.com/leg100/pug/internal/resource" ) @@ -51,10 +52,10 @@ func (ws *Workspace) LogValue() slog.Value { // VarsFile returns the filename of the workspace's terraform variables file // and whether it exists or not. -func (ws *Workspace) VarsFile() (string, bool) { +func (ws *Workspace) VarsFile(workdir internal.Workdir) (string, bool) { fname := fmt.Sprintf("%s.tfvars", ws.Name) mod := ws.Module().(*module.Module) - path := filepath.Join(mod.FullPath(), fname) + path := filepath.Join(workdir.String(), mod.Path, fname) _, err := os.Stat(path) return fname, err == nil } diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index a8b20d3..e747b4f 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -12,24 +12,25 @@ import ( ) func TestWorkspace_TerraformEnv(t *testing.T) { - mod := module.New(internal.NewTestWorkdir(t), module.Options{Path: "a/b/c"}) - ws, err := New(mod, "dev") + ws, err := New(&module.Module{}, "dev") require.NoError(t, err) assert.Equal(t, "TF_WORKSPACE=dev", ws.TerraformEnv()) } func TestWorkspace_VarsFile(t *testing.T) { - mod := module.New(internal.NewTestWorkdir(t), module.Options{Path: "a/b/c"}) + workdir := internal.NewTestWorkdir(t) + mod := module.NewTestModule(t, module.Options{Path: "a/b/c"}) ws, err := New(mod, "dev") require.NoError(t, err) // Create a workspace tfvars file for dev - os.MkdirAll(mod.FullPath(), 0o755) - _, err = os.Create(filepath.Join(mod.FullPath(), "dev.tfvars")) + path := workdir.Join(mod.Path, "dev.tfvars") + os.MkdirAll(filepath.Dir(path), 0o755) + _, err = os.Create(path) require.NoError(t, err) - got, ok := ws.VarsFile() + got, ok := ws.VarsFile(workdir) require.True(t, ok) assert.Equal(t, "dev.tfvars", got) }