Repository: ariga/atlas
Pull request #2212 (2023-10-24 15:47:17)

cmd/atlas/internal/cmdext: add support for 'extenral' data source
Unlike TF, query is omitted and the result is returned as-is. A string that can be parsed and decoded by the jsondecode function


Patch:
diff --git a/cmd/atlas/internal/cmdext/cmdext.go b/cmd/atlas/internal/cmdext/cmdext.go
index faec74e3..21704bd0 100644
--- a/cmd/atlas/internal/cmdext/cmdext.go
+++ b/cmd/atlas/internal/cmdext/cmdext.go
@@ -57,6 +57,7 @@ import (
 // DataSources exposes the data sources provided by this package.
 var DataSources = []schemahcl.Option{
 	schemahcl.WithDataSource("sql", Query),
+	schemahcl.WithDataSource("external", External),
 	schemahcl.WithDataSource("runtimevar", RuntimeVar),
 	schemahcl.WithDataSource("template_dir", TemplateDir),
 	schemahcl.WithDataSource("remote_dir", RemoteDir),
@@ -271,6 +272,59 @@ func Query(ctx *hcl.EvalContext, block *hclsyntax.Block) (cty.Value, error) {
 	return cty.ObjectVal(obj), nil
 }
 
+// External allows loading data using external program execution.
+//
+//	data "external" "env1" {
+//	  program = [
+//	    "node",
+//	    loadenv.js",
+//	  ]
+//	}
+//
+//	data "external" "env2" {
+//	  program = [
+//	    "bash",
+//	    "-c",
+//	    "env_to_json --file=${var.envfile} | jq '...' ",
+//	  ]
+//	}
+func External(ctx *hcl.EvalContext, block *hclsyntax.Block) (cty.Value, error) {
+	var (
+		args struct {
+			Program []string `hcl:"program"`
+			Dir     string   `hcl:"working_dir,optional"`
+			Remain  hcl.Body `hcl:",remain"`
+		}
+		errorf = blockError("data.external", block)
+	)
+	if diags := gohcl.DecodeBody(block.Body, ctx, &args); diags.HasErrors() {
+		return cty.NilVal, errorf("decoding body: %v", diags)
+	}
+	attrs, diags := args.Remain.JustAttributes()
+	if diags.HasErrors() {
+		return cty.NilVal, errorf("getting attributes: %v", diags)
+	}
+	if len(attrs) > 0 {
+		return cty.NilVal, errorf("unexpected attributes: %v", attrs)
+	}
+	if len(args.Program) == 0 {
+		return cty.NilVal, errorf("program cannot be empty")
+	}
+	cmd := exec.Command(args.Program[0], args.Program[1:]...)
+	if args.Dir != "" {
+		cmd.Dir = args.Dir
+	}
+	out, err := cmd.Output()
+	if err != nil {
+		msg := err.Error()
+		if err1 := (*exec.ExitError)(nil); errors.As(err, &err1) && len(err1.Stderr) > 0 {
+			msg = string(err1.Stderr)
+		}
+		return cty.NilVal, errorf("running program %v: %v", cmd.Path, msg)
+	}
+	return cty.StringVal(string(out)), nil
+}
+
 // TemplateDir implements migrate.Dir interface for template directories.
 //
 //	data "template_dir" "name" {
@@ -580,8 +634,8 @@ func SchemaExternal(ctx *hcl.EvalContext, block *hclsyntax.Block) (cty.Value, er
 	out, err := cmd.Output()
 	if err != nil {
 		msg := err.Error()
-		if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
-			msg = string(ee.Stderr)
+		if err1 := (*exec.ExitError)(nil); errors.As(err, &err1) && len(err1.Stderr) > 0 {
+			msg = string(err1.Stderr)
 		}
 		return cty.NilVal, errorf("running program %v: %v", cmd.Path, msg)
 	}
diff --git a/doc/website/docusaurus.config.js b/doc/website/docusaurus.config.js
index 736367f0..a6baae67 100644
--- a/doc/website/docusaurus.config.js
+++ b/doc/website/docusaurus.config.js
@@ -141,7 +141,7 @@ module.exports = {
     },
     announcementBar: {
       id: 'announcementBar-7', // Increment on change
-      content: `<strong>Announcing Atlas v0.14: Checkpoints, Push to Cloud, and JetBrains support. </strong><a class="cta" href="https://atlasgo.io/blog/2023/08/31/atlas-v-0-14">Read it here</a>`,
+      content: `<strong>Announcing Atlas v0.15: Interactive Declarative Migrations, Functions, Procedures and Domains. </strong><a class="cta" href="https://atlasgo.io/blog/2023/10/19/atlas-v-0-15">Read it here</a>`,
       isCloseable: true,
       backgroundColor: '#008CFA',
       textColor: '#fff',
diff --git a/schemahcl/schemahcl.go b/schemahcl/schemahcl.go
index b80ae31b..1b16979d 100644
--- a/schemahcl/schemahcl.go
+++ b/schemahcl/schemahcl.go
@@ -542,11 +542,27 @@ func (s *State) typeError(diag hcl.Diagnostics, scope []string) error {
 			if d.Summary != "Unknown variable" {
 				continue
 			}
-			if t, ok := s.findTypeSpec(e.Traversal.RootName()); ok && len(t.Attributes) > 0 {
-				d.Detail = fmt.Sprintf("Type %q requires at least 1 argument", t.Name)
-			} else if n := len(scope); n > 1 && (s.config.pathVars[path] != nil || s.config.pathFuncs[path] != nil) {
-				d.Summary = strings.Replace(d.Summary, "variable", fmt.Sprintf("%s.%s", scope[n-2], scope[n-1]), 1)
-				d.Detail = strings.Replace(d.Detail, "variable", scope[n-1], 1)
+			switch root := e.Traversal.RootName(); root {
+			case RefData:
+				var b strings.Builder
+				b.WriteString(root)
+				for _, t := range e.Traversal[1:] {
+					if v, ok := t.(hcl.TraverseAttr); ok {
+						b.WriteString(".")
+						b.WriteString(v.Name)
+					}
+				}
+				d.Summary = "Unknown data source"
+				d.Detail = fmt.Sprintf("%s does not exist", b.String())
+			case RefLocal:
+				d.Summary = "Unknown local"
+			default:
+				if t, ok := s.findTypeSpec(root); ok && len(t.Attributes) > 0 {
+					d.Detail = fmt.Sprintf("Type %q requires at least 1 argument", t.Name)
+				} else if n := len(scope); n > 1 && (s.config.pathVars[path] != nil || s.config.pathFuncs[path] != nil) {
+					d.Summary = strings.Replace(d.Summary, "variable", fmt.Sprintf("%s.%s", scope[n-2], scope[n-1]), 1)
+					d.Detail = strings.Replace(d.Detail, "variable", scope[n-1], 1)
+				}
 			}
 		}
 	}


Test patch:
diff --git a/cmd/atlas/internal/cmdext/cmdext_test.go b/cmd/atlas/internal/cmdext/cmdext_test.go
index 82b96aa0..7bde2ac4 100644
--- a/cmd/atlas/internal/cmdext/cmdext_test.go
+++ b/cmd/atlas/internal/cmdext/cmdext_test.go
@@ -474,6 +474,40 @@ schema "main" {
 `, string(buf))
 }
 
+func TestExternal(t *testing.T) {
+	var (
+		v struct {
+			Output string `spec:"output"`
+		}
+		state = schemahcl.New(cmdext.DataSources...)
+	)
+	err := state.EvalBytes([]byte(`
+data "external" "program" {
+  program = [
+    "echo",
+    "value",
+  ]
+}
+
+output = trimspace(data.external.program)
+`), &v, nil)
+	require.NoError(t, err)
+	require.Equal(t, "value", v.Output)
+
+	err = state.EvalBytes([]byte(`
+data "external" "program" {
+  program = [
+    "echo",
+    "{\"hello\": \"world\"}",
+  ]
+}
+
+output = jsondecode(data.external.program).hello
+`), &v, nil)
+	require.NoError(t, err)
+	require.Equal(t, "world", v.Output)
+}
+
 func TestAtlasConfig(t *testing.T) {
 	var (
 		v struct {
diff --git a/schemahcl/schemahcl_test.go b/schemahcl/schemahcl_test.go
index 2420f584..29adaab9 100644
--- a/schemahcl/schemahcl_test.go
+++ b/schemahcl/schemahcl_test.go
@@ -638,6 +638,11 @@ locals {
 }
 `)
 	require.Error(t, New(opts...).EvalBytes(b, &doc, nil), `cyclic reference to "data.text.a"`)
+
+	b = []byte(`
+out = data.unknown.a.output
+`)
+	require.EqualError(t, New(opts...).EvalBytes(b, &doc, nil), `:2,7-11: Unknown data source; data.unknown.a.output does not exist`)
 }
 
 func TestSkippedDataSrc(t *testing.T) {