From 294b9ae045bb9dfa90d3900a4def3ce1c35a96d0 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 17 Oct 2019 11:59:47 -0700 Subject: [PATCH] projects: Load individual workspaces, evaluating their configuration --- command/meta_project.go | 22 +- command/workspace_show.go | 66 ++++-- projects/eval_data_dynamic.go | 53 +++++ projects/project_mgr.go | 42 ++++ projects/projectlang/eval_dynamic.go | 276 ++++++++++++++++++++++ projects/projectlang/eval_static.go | 4 +- projects/projects.go | 29 +++ projects/workspaces.go | 334 +++++++++++++++++++++++++++ 8 files changed, 803 insertions(+), 23 deletions(-) create mode 100644 projects/eval_data_dynamic.go create mode 100644 projects/project_mgr.go create mode 100644 projects/projectlang/eval_dynamic.go diff --git a/command/meta_project.go b/command/meta_project.go index 46548b8adc..f523ee9518 100644 --- a/command/meta_project.go +++ b/command/meta_project.go @@ -12,7 +12,13 @@ import ( // belongs to, or an error if the given directory does not seem to belong to // a project. func (m *Meta) findProjectForDir(dir string) (*projects.Project, tfdiags.Diagnostics) { - return projects.FindProject(dir) + project, diags := projects.FindProject(dir) + if project != nil { + // Make project configuration source code available for diagnostic + // messages, in case diags contains any configuration errors/warnings. + m.configLoader.ImportSources(project.ConfigSources()) + } + return project, diags } // findCurrentProject finds the project that the current working directory @@ -35,3 +41,17 @@ func (m *Meta) findCurrentProject() (*projects.Project, tfdiags.Diagnostics) { } return projects.FindProject(dir) } + +// findCurrentProjectManager wraps findCurrentProject and annotates the +// resulting project with the current set of project context variables to +// produce a ProjectManager object. +func (m *Meta) findCurrentProjectManager() (*projects.ProjectManager, tfdiags.Diagnostics) { + project, diags := m.findCurrentProject() + if project == nil { + return nil, diags + } + // TODO: Once we have updated "terraform init" to be able to accept + // context values and stash them somewhere, we'll load them here and + // pass them in to NewManager. For now we just assume no context values. + return project.NewManager(nil), diags +} diff --git a/command/workspace_show.go b/command/workspace_show.go index 30c5c5f26c..3cb9540988 100644 --- a/command/workspace_show.go +++ b/command/workspace_show.go @@ -1,8 +1,10 @@ package command import ( + "fmt" "strings" + "github.com/hashicorp/terraform/tfdiags" "github.com/posener/complete" ) @@ -11,26 +13,50 @@ type WorkspaceShowCommand struct { } func (c *WorkspaceShowCommand) Run(args []string) int { - c.Ui.Error("not yet updated for workspaces2 prototype") - return 1 - /* - args, err := c.Meta.process(args, true) - if err != nil { - return 1 - } - - cmdFlags := c.Meta.extendedFlagSet("workspace show") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) - return 1 - } - - workspace := c.Workspace() - c.Ui.Output(workspace) - - return 0 - */ + args, err := c.Meta.process(args, true) + if err != nil { + return 1 + } + + cmdFlags := c.Meta.defaultFlagSet("workspace show") + cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } + if err := cmdFlags.Parse(args); err != nil { + c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) + return 1 + } + + var diags tfdiags.Diagnostics + + projectMgr, moreDiags := c.findCurrentProjectManager() + diags = diags.Append(moreDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + workspaceAddr := c.Workspace() + workspace, moreDiags := projectMgr.LoadWorkspace(workspaceAddr) + diags = diags.Append(moreDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + depAddrs := projectMgr.Project().WorkspaceDependencies(workspaceAddr) + + fmt.Printf("Workspace %s\n\n", workspaceAddr.StringCompact()) + fmt.Printf("Configuration Root: %s\n", workspace.ConfigDir()) + fmt.Printf("Input Variables:\n") + for addr, val := range workspace.InputVariables() { + fmt.Printf(" %s = %#v\n", addr.Name, val) + } + fmt.Printf("Dependencies:\n") + for _, addr := range depAddrs { + fmt.Printf(" %s\n", addr) + } + fmt.Println("") + + return 0 } func (c *WorkspaceShowCommand) AutocompleteArgs() complete.Predictor { diff --git a/projects/eval_data_dynamic.go b/projects/eval_data_dynamic.go new file mode 100644 index 0000000000..1dd6950466 --- /dev/null +++ b/projects/eval_data_dynamic.go @@ -0,0 +1,53 @@ +package projects + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/projects/projectconfigs" + "github.com/hashicorp/terraform/projects/projectlang" +) + +type dynamicEvalData struct { + config *projectconfigs.Config + workspaceOutputs map[addrs.ProjectWorkspace]map[addrs.OutputValue]cty.Value + contextValues map[addrs.ProjectContextValue]cty.Value +} + +var _ projectlang.DynamicEvaluateData = (*dynamicEvalData)(nil) + +func (d *dynamicEvalData) BaseDir() string { + return d.config.ProjectRoot +} + +func (d *dynamicEvalData) LocalValueExpr(addr addrs.LocalValue) hcl.Expression { + return d.config.Locals[addr.Name].Value +} + +func (d *dynamicEvalData) ContextValue(addr addrs.ProjectContextValue) cty.Value { + return d.contextValues[addr] +} + +func (d *dynamicEvalData) WorkspaceConfigValue(addr addrs.ProjectWorkspaceConfig) cty.Value { + noKeyAddr := addr.Instance(addrs.NoKey) + if noKeyOutputs, exists := d.workspaceOutputs[noKeyAddr]; exists { + attrs := make(map[string]cty.Value, len(noKeyOutputs)) + for outputAddr, v := range noKeyOutputs { + attrs[outputAddr.Name] = v + } + return cty.ObjectVal(attrs) + } + objs := make(map[string]cty.Value) + for instAddr, outputs := range d.workspaceOutputs { + if instAddr.Config() != addr { + continue + } + attrs := make(map[string]cty.Value, len(outputs)) + for outputAddr, v := range outputs { + attrs[outputAddr.Name] = v + } + objs[string(instAddr.Key.(addrs.StringKey))] = cty.ObjectVal(attrs) + } + return cty.ObjectVal(objs) +} diff --git a/projects/project_mgr.go b/projects/project_mgr.go new file mode 100644 index 0000000000..a6dbb14352 --- /dev/null +++ b/projects/project_mgr.go @@ -0,0 +1,42 @@ +package projects + +import ( + "github.com/hashicorp/terraform/addrs" + "github.com/zclconf/go-cty/cty" +) + +// ProjectManager is a wrapper around Project that associates a project with +// the context it is being run in. +// +// A Project object represents the project itself, while a ProjectManager +// represents that project being used in a particular context: a specific set +// of context values, a means to access output values from upstream projects, +// and any other similar context-specific annotations that are required to +// run operations against a particular project. +type ProjectManager struct { + project *Project + contextValues map[addrs.ProjectContextValue]cty.Value +} + +// NewManager creates a ProjectManager object that binds the recieving project +// to a particular set of context values and other contextual context that +// will allow running operations against workspaces in the project. +func (p *Project) NewManager(contextValues map[addrs.ProjectContextValue]cty.Value) *ProjectManager { + return &ProjectManager{ + project: p, + contextValues: contextValues, + } +} + +// Project returns the project that the receiving ProjectManager is managing. +func (m *ProjectManager) Project() *Project { + return m.project +} + +// ContextValues returns the configured context values for this project manager. +// +// The caller must treat the returned map as immutable, even though the Go +// type system cannot enforce that. +func (m *ProjectManager) ContextValues() map[addrs.ProjectContextValue]cty.Value { + return m.contextValues +} diff --git a/projects/projectlang/eval_dynamic.go b/projects/projectlang/eval_dynamic.go new file mode 100644 index 0000000000..1f64f6cb61 --- /dev/null +++ b/projects/projectlang/eval_dynamic.go @@ -0,0 +1,276 @@ +package projectlang + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/lang" + "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// DynamicEvaluateData is an interface used during dynamic evaluation operations +// to interrogate information from the configuration and other value sources. +// +// DynamicEvaluateData is used only in situations where it's assume that +// references in the expressions were pre-validated to ensure that they +// refer to items in the configuration, so DynamicEvaluateData implementations +// can assume that all requested objects should exist in configuration, and +// panic if that does not hold in practice. +type DynamicEvaluateData interface { + // BaseDir returns the directory that should be considered as the base + // directory for any relative filesystem paths that appear in expressions. + BaseDir() string + + // LocalValueExpr returns the expression associated with the given named + // local value. + // + // While the caller is responsible for determining final values for most + // other referenceable objects exposed from DynamicEvaluateData, local + // values are treated as part of the language itself and their expressions + // are evaluated by the language runtime in the projectlang package to + // ensure that each one is only evaluated once while processing a single + // expression evaluation call. + LocalValueExpr(addrs.LocalValue) hcl.Expression + + // ContextValue returns the value associated with the given context key + // in the current project execution context. + ContextValue(addrs.ProjectContextValue) cty.Value + + // WorkspaceConfigValue returns a value representing a particular workspace + // configuration when accessed in expressions. + // + // For a workspace configuration block that does not have for_each set, + // the return value is an object whose attributes are the output values + // of the workspace in question. + // + // For a workspace configuration block that does have for_each set, there + // result has an extra nesting level where the top-level object attributes + // are the workspace instance keys and each instance's output values + // appear in nested objects. + WorkspaceConfigValue(addrs.ProjectWorkspaceConfig) cty.Value +} + +// DynamicEvaluateEach represents the current "each" repetition when evaluating +// expressions. +// +// If evaluating in a context where no "for_each" is active, use +// projectlang.NoEach as a placeholder value. +type DynamicEvaluateEach struct { + Key addrs.InstanceKey + Value cty.Value +} + +// ValueObj returns the "each" object value that should represent the reciever +// in HCL expression evaluation. +func (e DynamicEvaluateEach) ValueObj() cty.Value { + switch k := e.Key.(type) { + case nil: + return cty.NullVal(cty.Object(map[string]cty.Type{ + "key": cty.DynamicPseudoType, + "value": cty.DynamicPseudoType, + })) + case addrs.StringKey: + return cty.ObjectVal(map[string]cty.Value{ + "key": cty.StringVal(string(k)), + "value": e.Value, + }) + case addrs.IntKey: + return cty.ObjectVal(map[string]cty.Value{ + "key": cty.NumberIntVal(int64(k)), + "value": e.Value, + }) + default: + panic(fmt.Sprintf("unsupported key type %T", e.Key)) + } +} + +// NoEach is the zero value of DynamicEvaluateEach and is used to represent +// situations where no "each" object is available. +var NoEach = DynamicEvaluateEach{ + Key: addrs.NoKey, + Value: cty.NilVal, +} + +// DynamicEvaluateExprs is the full expression evaluation pass that supports +// references to any of the object types in the project configuration language. +// +// The given DynamicEvaluateData and DynamicEvaluateEach values together +// provide the data that will be available for use in reference expressions. +// For expressions where the "each" object should not be available, set +// that argument to projectlang.NoEach. +// +// Grouping multiple evaluations together both allows us to avoid re-evaluating +// common local values multiple times and, more importantly, ensures that we'll +// only report errors for each expression once rather than repeating them once +// per expression. Callers should therefore prefer to gather together all of +// their dynamic evaluation expressions into a single call and avoid combining +// diagnostics from separate calls to DynamicEvaluateExprs in the same output. +func DynamicEvaluateExprs(exprs []hcl.Expression, data DynamicEvaluateData, each DynamicEvaluateEach) ([]cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + dependents := make(map[addrs.LocalValue][]addrs.LocalValue) + inDegree := make(map[addrs.LocalValue]int) + var queue []addrs.LocalValue + + // We'll seed our queue with the references in the given expressions themselves. + for _, expr := range exprs { + for _, traversal := range expr.Variables() { + ref, moreDiags := addrs.ParseProjectConfigRef(traversal) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + continue + } + addr, ok := ref.Subject.(addrs.LocalValue) + if ok { + queue = append(queue, addr) + dependents[addr] = nil + inDegree[addr] = 0 + } + } + } + + // Now we'll work our way through the graph of locals to find all of + // the ones we directly and indirectly depend on. + localValues := make(map[string]cty.Value) + for i := 0; i < len(queue); i++ { // queue length will grow during iteration + addr := queue[i] + if _, exists := localValues[addr.Name]; exists { + // We already dealt with this one via another path through the graph. + continue + } + // We'll make a placeholder element for now, just so we know we've visited + // this one, and then overwrite it with a real value later. + localValues[addr.Name] = cty.NilVal + + expr := data.LocalValueExpr(addr) + if expr == nil { + // Should never happen because references should be validated by our caller. + panic(fmt.Sprintf("no expression available for %s", addr)) + } + for _, traversal := range expr.Variables() { + ref, moreDiags := addrs.ParseProjectConfigRef(traversal) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + continue + } + + refAddr, ok := ref.Subject.(addrs.LocalValue) + if !ok { + // If it refers to anything other than local values then + // it's a dynamic local value and so we can't use it + // in static evaluation. + localValues[addr.Name] = cty.DynamicVal + continue + } + inDegree[refAddr]++ + dependents[addr] = append(dependents[addr], refAddr) + queue = append(queue, refAddr) + } + } + + // We now need to re-visit all of the addresses in topological order, + // evaluating the locals as we go. We'll re-use the backing buffer of + // our queue above, since we know it has sufficient capacity for all + // of the local values involved. + queue = queue[:0] + for addr := range dependents { // Seed queue with locals that have no dependencies + if inDegree[addr] == 0 { + queue = append(queue, addr) + } + } + for len(queue) > 0 { + var addr addrs.LocalValue + addr, queue = queue[0], queue[1:] // dequeue next item + if val := localValues[addr.Name]; val != cty.NilVal { + continue // Already dealt with this one + } + delete(inDegree, addr) + expr := data.LocalValueExpr(addr) + if expr == nil { + // Should never happen because references should be validated by our caller. + panic(fmt.Sprintf("no expression available for %s", addr)) + } + val, moreDiags := expr.Value(staticEvalContext(data.BaseDir(), localValues)) + diags = diags.Append(moreDiags) + localValues[addr.Name] = val + + for _, referrerAddr := range dependents[addr] { + inDegree[referrerAddr]-- + if inDegree[referrerAddr] < 1 { + queue = append(queue, referrerAddr) + } + } + } + + if len(inDegree) > 0 { + // TODO: This error needs to be _much_ better + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Dependency cycle in project configuration", + Detail: "There is at least one dependency cycle between the local values in the project configuration.", + }) + return nil, diags + } + + // Finally, with all of the local values evaluated, we can evaluate the + // expressions we were given. + ret := make([]cty.Value, len(exprs)) + for i, expr := range exprs { + refs := findReferencesInExpr(expr) + ctx, moreDiags := dynamicEvalContext(refs, data, each, localValues) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + ret[i] = cty.DynamicVal + continue + } + val, hclDiags := expr.Value(ctx) + diags = diags.Append(hclDiags) + ret[i] = val + } + + return ret, diags +} + +func dynamicEvalContext(refs []*addrs.ProjectConfigReference, data DynamicEvaluateData, each DynamicEvaluateEach, localValues map[string]cty.Value) (*hcl.EvalContext, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + currentWorkspaces := map[string]cty.Value{} + upstreamWorkspaces := map[string]cty.Value{} + contextValues := map[string]cty.Value{} + eachVal := each.ValueObj() + + for _, ref := range refs { + switch addr := ref.Subject.(type) { + case addrs.ProjectWorkspaceConfig: + obj := data.WorkspaceConfigValue(addr) + switch addr.Rel { + case addrs.ProjectWorkspaceCurrent: + currentWorkspaces[addr.Name] = obj + case addrs.ProjectWorkspaceUpstream: + upstreamWorkspaces[addr.Name] = obj + } + case addrs.ProjectContextValue: + contextValues[addr.Name] = data.ContextValue(addr) + case addrs.LocalValue: + // Nothing to do for these because they should already be in + // localValues. + case addrs.ForEachAttr: + // Nothing to do for these because we've already populated + // eachVal above. + default: + panic(fmt.Sprintf("unsupported reference type %T", addr)) + } + } + + return &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "local": cty.ObjectVal(localValues), + "workspace": cty.ObjectVal(currentWorkspaces), + "upstream": cty.ObjectVal(upstreamWorkspaces), + "context": cty.ObjectVal(contextValues), + "each": eachVal, + }, + Functions: lang.Functions(false, data.BaseDir()), + }, diags +} diff --git a/projects/projectlang/eval_static.go b/projects/projectlang/eval_static.go index 9c661b48da..4fc0a48b73 100644 --- a/projects/projectlang/eval_static.go +++ b/projects/projectlang/eval_static.go @@ -185,7 +185,7 @@ func StaticEvaluateExprs(exprs []hcl.Expression, data StaticEvaluateData) ([]cty func staticEvalContext(baseDir string, localValues map[string]cty.Value) *hcl.EvalContext { return &hcl.EvalContext{ Variables: map[string]cty.Value{ - "local": cty.MapVal(localValues), + "local": cty.ObjectVal(localValues), // All of the other top-level objects are just placeholders here // so we can still do partial type checking of derived expressions. @@ -193,6 +193,6 @@ func staticEvalContext(baseDir string, localValues map[string]cty.Value) *hcl.Ev "upstream": cty.DynamicVal, "context": cty.DynamicVal, }, - Functions: lang.Functions(true, "."), + Functions: lang.Functions(true, baseDir), } } diff --git a/projects/projects.go b/projects/projects.go index 1fcda35a6c..18f4238a67 100644 --- a/projects/projects.go +++ b/projects/projects.go @@ -2,6 +2,8 @@ package projects import ( "fmt" + "os" + "path/filepath" "sort" "github.com/hashicorp/hcl/v2" @@ -153,6 +155,33 @@ Vals: }, diags } +// Config returns te configuration that was used to define this project. +// +// Callers must treat this value as immutable, even though the Go type system +// cannot enforce that. +func (p *Project) Config() *projectconfigs.Config { + return p.config +} + +// ConfigSources returns a map from filenames to source buffers for the +// configuration files that were used in the definition of this project. +// +// This is exposed so that callers can register the source buffers in a +// source registry for use to create snippets in future diagnostics messages. +func (p *Project) ConfigSources() map[string][]byte { + fn := p.config.ConfigFile + // Config sources are conventionally given relative to the current working + // directory, so we'll make a best-effort attempt to transform it as such. + if cwd, err := os.Getwd(); err == nil { + if relFn, err := filepath.Rel(cwd, fn); err == nil { + fn = relFn + } + } + return map[string][]byte{ + fn: p.config.Source, + } +} + // AllWorkspaceAddrs returns the addresses of all of the workspaces defined // in the project. // diff --git a/projects/workspaces.go b/projects/workspaces.go index ddd69173ae..59942460a4 100644 --- a/projects/workspaces.go +++ b/projects/workspaces.go @@ -1,11 +1,345 @@ package projects import ( + "fmt" + "log" "sort" + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/projects/projectconfigs" + "github.com/hashicorp/terraform/projects/projectlang" + "github.com/hashicorp/terraform/states/statemgr" + "github.com/hashicorp/terraform/tfdiags" ) +// Workspace represents a single selectable workspace, each of which has its +// own configuration directory, state, and input variables. +type Workspace struct { + project *Project + addr addrs.ProjectWorkspace + configDir string + variables map[addrs.InputVariable]cty.Value + + // TODO: Also the remote config or state storage config, but for now + // we're just forcing local state as a prototype. +} + +// LoadWorkspace instantiates a specific workspace from the receiving project. +// +// In order to do so, it resolves any references in the workspace configuration +// that the workspace belongs to, which might involve retrieving the output +// values from other workspaces, evaluating local values, etc. +// +// This can be a relatively expensive operation in a project that has lots of +// remote workspaces, so it should be used carefully. A caller that needs to +// work with many workspaces at once might be better off using +// LoadAllWorkspaces, which is able to optimize its work to ensure that each +// workspace is only accessed once, and also avoids producing the same errors +// multiple times if a given expression in the configuration contributes to +// multiple workspaces. +func (m *ProjectManager) LoadWorkspace(addr addrs.ProjectWorkspace) (*Workspace, tfdiags.Diagnostics) { + wss, diags := m.loadWorkspaces([]addrs.ProjectWorkspace{addr}) + return wss[addr], diags +} + +// LoadAllWorkspaces is like LoadWorkspace but loads all of the project's +// workspaces at once, as efficiently as possible. +// +// This is a better alternative to LoadWorkspace for callers that need to work +// with all or most of the workspaces in a project at once, because it's able +// to optimize its work and avoid duplicate calls. However, it's not suitable +// for callers that intend to work only with a single workspace because it is +// likely to fetch more data than necessary and will fail if the current user +// does not have access to any of the workspace outputs, even if those +// outputs would not normally be needed to process a particular selected +// workspace. +func (m *ProjectManager) LoadAllWorkspaces() (map[addrs.ProjectWorkspace]*Workspace, tfdiags.Diagnostics) { + return m.loadWorkspaces(m.project.AllWorkspaceAddrs()) +} + +// loadWorkspaces is the common implementation of both LoadWorkspace and +// LoadAllWorkspaces, which loads all of the workspaces requested in the +// given address slice, and as a side-effect fetches outputs for any other +// workspaces that the given ones depend on, fetching each workspace at most +// once. +// +// The resulting map includes Workspace objects only for the requested +// addresses, even if others needed to be fetched in order to complete the +// operation. +func (m *ProjectManager) loadWorkspaces(wantedAddrs []addrs.ProjectWorkspace) (map[addrs.ProjectWorkspace]*Workspace, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + if len(wantedAddrs) == 0 { + return nil, nil + } + ret := make(map[addrs.ProjectWorkspace]*Workspace, len(wantedAddrs)) + + // First we'll follow the dependencies of what's requested to expand out + // our full list of workspaces to fetch. Along the way we'll keep track + // of the dependencies we found so we can use them for a topological sort + // below. The keys in "deps" after this loop represent our full set of + // needed workspaces. + deps := make(map[addrs.ProjectWorkspace][]addrs.ProjectWorkspace) + for _, needAddr := range wantedAddrs { + deps[needAddr] = m.project.WorkspaceDependencies(needAddr) + } + for { + // We'll keep iterating until we stop finding new workspaces. + // This must converge eventually because the number of workspaces + // is finite itself, and so the worst case is that we load all of + // the workspaces. + new := false + for _, needAddrs := range deps { + for _, needAddr := range needAddrs { + if _, exists := deps[needAddr]; exists { + continue + } + new = true + deps[needAddr] = m.project.WorkspaceDependencies(needAddr) + } + } + + if !new { + break + } + } + + // NOTE: Strictly speaking we only need to fetch the outputs for the + // workspaces that are referenced by the ones requested, not for the + // ones requested directly. However, for the sake of simplicity we'll + // fetch all of them here. In a real implementation we'd likely want to + // optimize this in a number of ways, including avoiding fetching things + // we don't need to fetch _and_ doing our fetches as concurrently as + // possible. + + workspaces := make(map[addrs.ProjectWorkspace]*Workspace) + workspaceOutputs := make(map[addrs.ProjectWorkspace]map[addrs.OutputValue]cty.Value) + dependents := make(map[addrs.ProjectWorkspace][]addrs.ProjectWorkspace) + inDegree := make(map[addrs.ProjectWorkspace]int) + for addr, needAddrs := range deps { + for _, needAddr := range needAddrs { + inDegree[addr]++ + dependents[needAddr] = append(dependents[needAddr], addr) + } + } + var queue []addrs.ProjectWorkspace + for addr := range deps { + if inDegree[addr] == 0 { + queue = append(queue, addr) + } + } + for len(queue) > 0 { + var addr addrs.ProjectWorkspace + addr, queue = queue[0], queue[1:] // dequeue next item + if val := workspaces[addr]; val != nil { + continue // Already dealt with this one + } + delete(inDegree, addr) + log.Printf("[TRACE] projects.ProjectManager.loadWorkspaces: evaluating configuration for workspace %s", addr) + switch addr.Rel { + case addrs.ProjectWorkspaceCurrent: + cfg := m.project.config.Workspaces[addr.Name] + if cfg == nil { + panic(fmt.Sprintf("no config for %s", addr)) + } + workspace, moreDiags := m.initCurrentProjectWorkspace(addr, cfg, workspaceOutputs) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + // Downstream references are likely to fail too if we failed + // to init this project, so we'll just bail out early. + return nil, diags + } + workspaces[addr] = workspace + outputs, moreDiags := workspace.LatestOutputValues() + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return nil, diags + } + workspaceOutputs[addr] = outputs + case addrs.ProjectWorkspaceUpstream: + // TODO: Skipping this for now, since we're just prototyping. + panic("upstream workspaces not yet supported") + default: + panic("unsupported workspace relationship") + } + + for _, referrerAddr := range dependents[addr] { + inDegree[referrerAddr]-- + if inDegree[referrerAddr] < 1 { + queue = append(queue, referrerAddr) + } + } + } + + for _, wantAddr := range wantedAddrs { + ret[wantAddr] = workspaces[wantAddr] + } + + return ret, diags +} + +func (m *ProjectManager) initCurrentProjectWorkspace(addr addrs.ProjectWorkspace, cfg *projectconfigs.Workspace, otherWorkspaceOutputs map[addrs.ProjectWorkspace]map[addrs.OutputValue]cty.Value) (*Workspace, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + ret := &Workspace{ + addr: addr, + project: m.project, + } + + // This is a little more convoluted than we'd ideally like because we want + // to evaluate all of our expressions in a single call into projectlang so + // that we don't produce redundant diagnostic messages, but we don't always + // have explicit values for all of the arguments. + const configExpr = 0 + const variablesExpr = 1 + exprs := []hcl.Expression{ + configExpr: hcl.StaticExpr(cty.NullVal(cty.String), cfg.DeclRange.ToHCL()), + variablesExpr: hcl.StaticExpr(cty.EmptyObjectVal, cfg.DeclRange.ToHCL()), + } + + if cfg.ConfigSource != nil { + exprs[configExpr] = cfg.ConfigSource + } + if cfg.Variables != nil { + exprs[variablesExpr] = cfg.Variables + } + + data := &dynamicEvalData{ + config: m.project.config, + workspaceOutputs: otherWorkspaceOutputs, + contextValues: m.ContextValues(), + } + each := projectlang.NoEach + if addr.Key != addrs.NoKey { + each.Key = addr.Key + each.Value = m.project.workspaceEachVals[addr] + } + + vals, moreDiags := projectlang.DynamicEvaluateExprs( + exprs, + data, each, + ) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return nil, diags + } + + if !vals[configExpr].IsNull() { + err := gocty.FromCtyValue(vals[configExpr], &ret.configDir) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid workspace configuration root", + Detail: fmt.Sprintf("Invalid root module path for workspace: %s.", tfdiags.FormatError(err)), + Subject: exprs[configExpr].Range().Ptr(), + }) + } + } else { + ret.configDir = "." + } + + if obj := vals[variablesExpr]; !obj.IsNull() { + if !(obj.Type().IsObjectType() || obj.Type().IsMapType()) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid workspace variables", + Detail: "Invalid value for \"variables\" argument: must be a mapping from variable name to corresponding variable value.", + Subject: exprs[variablesExpr].Range().Ptr(), + }) + } else { + raw := obj.AsValueMap() + ret.variables = make(map[addrs.InputVariable]cty.Value, len(raw)) + for n, v := range raw { + ret.variables[addrs.InputVariable{Name: n}] = v + } + } + } + + // TODO: Also the remote state storage config + + return ret, diags +} + +// Addr returns the address of the workspace represented by the reciever. +func (w *Workspace) Addr() addrs.ProjectWorkspace { + return w.addr +} + +// Project returns the project object that this workspace belongs to. +func (w *Workspace) Project() *Project { + return w.project +} + +// ConfigDir returns the path to the directory containing the root module +// for this workspace, relative to the project's root directory. +func (w *Workspace) ConfigDir() string { + return w.configDir +} + +// InputVariables returns the configured input variable values for the +// workspace. +func (w *Workspace) InputVariables() map[addrs.InputVariable]cty.Value { + return w.variables +} + +// StateMgr returns a configured state manager object for this workspace, +// or returns user-oriented diagnistics messages explaining why it cannot. +// +// The configuration for the state storage is validated for syntax as part of +// instantiating the workspace, so errors from this method will generally +// describe "dynamic" problems, such as being unable to connect to a remote +// server identified in the configuration. +func (w *Workspace) StateMgr() (statemgr.Full, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "StateMgr not yet implemented", + "Workspace.StateMgr isn't implemented yet", + )) + return nil, diags +} + +// LatestOutputValues returns the output values recorded at the end of the +// most recent operation against this workspace. +// +// The returned diagnostics might contain errors if the storage for the output +// values is currently unreachable for some reason. In that case, the +// returned map is invalid and must not be used. +func (w *Workspace) LatestOutputValues() (map[addrs.OutputValue]cty.Value, tfdiags.Diagnostics) { + // For the moment we're still getting outputs from the state directly. + // Ideally we'd switch to using a specialized API for this when the + // state is mastered in Terraform Cloud or Enterprise, so that we can + // apply separate access controls to whole state vs. outputs. + + var diags tfdiags.Diagnostics + stateMgr, moreDiags := w.StateMgr() + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return nil, diags + } + + err := stateMgr.RefreshState() + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to fetch workspace state", + fmt.Sprintf("Could not retrieve the latest state snapshot for workspace %s in order to determine its latest output values: %s.", w.addr.StringCompact(), err), + )) + return nil, diags + } + + state := stateMgr.State() + raw := state.RootModule().OutputValues + ret := make(map[addrs.OutputValue]cty.Value, len(raw)) + for k, v := range raw { + ret[addrs.OutputValue{Name: k}] = v.Value + } + + return ret, nil +} + type sortWorkspaceAddrs []addrs.ProjectWorkspace var _ sort.Interface = sortWorkspaceAddrs(nil)