Repository: ariga/atlas
Pull request #1170 (2022-10-02 19:05:37)

schemahcl: support dynamic resource blocks


Patch:
diff --git a/cmd/atlas/internal/cmdapi/cmdapi.go b/cmd/atlas/internal/cmdapi/cmdapi.go
index ce98fbae..6e82df0b 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi.go
@@ -8,11 +8,8 @@ package cmdapi
 
 import (
 	"fmt"
-	"os"
 	"strings"
 
-	"ariga.io/atlas/cmd/atlas/internal/update"
-
 	"github.com/spf13/cobra"
 	"golang.org/x/mod/semver"
 )
@@ -49,28 +46,6 @@ var (
 		},
 	}
 
-	// EnvCmd represents the subcommand 'atlas env'.
-	EnvCmd = &cobra.Command{
-		Use:   "env",
-		Short: "Print atlas environment variables.",
-		Long: `'atlas env' prints atlas environment information.
-
-Every set environment param will be printed in the form of NAME=VALUE.
-
-List of supported environment parameters:
-* ATLAS_NO_UPDATE_NOTIFIER: On any command, the CLI will check for new releases using the GitHub API.
-  This check will happen at most once every 24 hours. To cancel this behavior, set the environment 
-  variable "ATLAS_NO_UPDATE_NOTIFIER".`,
-		Run: func(cmd *cobra.Command, args []string) {
-			keys := []string{update.AtlasNoUpdateNotifier}
-			for _, k := range keys {
-				if v, ok := os.LookupEnv(k); ok {
-					cmd.Println(fmt.Sprintf("%s=%s", k, v))
-				}
-			}
-		},
-	}
-
 	// license holds Atlas license. When built with cloud packages
 	// should be set by build flag. "-X 'ariga.io/atlas/cmd/atlas/internal/cmdapi.license=${license}'"
 	license = `LICENSE
@@ -84,13 +59,7 @@ Atlas is licensed under Apache 2.0 as found in https://github.com/ariga/atlas/bl
 	}
 )
 
-// CheckForUpdate exposes internal update logic to CLI.
-func CheckForUpdate() {
-	update.Check(version, Root.PrintErrln)
-}
-
 func init() {
-	Root.AddCommand(EnvCmd)
 	Root.AddCommand(schemaCmd)
 	Root.AddCommand(versionCmd)
 	Root.AddCommand(licenseCmd)
@@ -144,3 +113,8 @@ func parse(version string) (string, string) {
 	}
 	return version, u
 }
+
+// Version returns the current Atlas binary version.
+func Version() string {
+	return version
+}
diff --git a/cmd/atlas/internal/cmdapi/vercheck/notification.tmpl b/cmd/atlas/internal/cmdapi/vercheck/notification.tmpl
new file mode 100644
index 00000000..56e1a607
--- /dev/null
+++ b/cmd/atlas/internal/cmdapi/vercheck/notification.tmpl
@@ -0,0 +1,10 @@
+{{- with .Advisory -}}
+SECURITY ADVISORY
+{{ .Text }}
+{{- end }}
+{{- with .Latest -}}
+A new version of Atlas is available ({{ .Version }}){{ with .Link }}: {{ . }}{{ end }}
+{{- with .Summary }}
+{{ . }}
+{{- end }}
+{{- end }}
\ No newline at end of file
diff --git a/cmd/atlas/internal/cmdapi/vercheck/vercheck.go b/cmd/atlas/internal/cmdapi/vercheck/vercheck.go
new file mode 100644
index 00000000..325fd3d3
--- /dev/null
+++ b/cmd/atlas/internal/cmdapi/vercheck/vercheck.go
@@ -0,0 +1,133 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package vercheck
+
+import (
+	_ "embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"text/template"
+	"time"
+)
+
+// New returns a new VerChecker for the endpoint.
+func New(endpoint, statePath string) *VerChecker {
+	return &VerChecker{endpoint: endpoint, statePath: statePath}
+}
+
+type (
+	// Latest contains information about the most recent version.
+	Latest struct {
+		// Version is the new version name.
+		Version string
+		// Summary contains a brief description of the new version.
+		Summary string
+		// Link is a URL to a web page describing the new version.
+		Link string
+	}
+	// Advisory contains contents of security advisories.
+	Advisory struct {
+		Text string `json:"text"`
+	}
+	// Payload returns information to the client about their existing version of a component.
+	Payload struct {
+		// Latest is set if there is a newer version to upgrade to.
+		Latest *Latest `json:"latest"`
+		// Advisory is set if security advisories exist for the current version.
+		Advisory *Advisory `json:"advisory"`
+	}
+	// VerChecker retrieves version information from the vercheck service.
+	VerChecker struct {
+		endpoint  string
+		statePath string
+	}
+	// State stores information about local runs of VerChecker to limit the
+	// frequency in which clients poll the service for information.
+	State struct {
+		CheckedAt time.Time `json:"checkedat"`
+	}
+)
+
+var (
+	// errSkip is returned when check isn't run because 24 hours haven't passed from the previous run.
+	errSkip = errors.New("skip vercheck")
+	// Notify is the template for displaying the payload to the user.
+	Notify *template.Template
+)
+
+// Check makes an HTTP request to endpoint to check if a new version or security advisories
+// exist for the current version. Check tries to read the latest time it was run from the
+// statePath, if found and 24 hours have not passed the check is skipped. When done, the latest
+// time is updated in statePath.
+func (v *VerChecker) Check(ver string) (*Payload, error) {
+	if err := v.verifyTime(); err != nil {
+		return nil, err
+	}
+	endpoint, err := url.JoinPath(v.endpoint, "atlas", ver)
+	if err != nil {
+		return nil, err
+	}
+	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "Ariga-Atlas-CLI")
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("status: %s", resp.Status)
+	}
+	var p Payload
+	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
+		return nil, err
+	}
+	if v.statePath != "" {
+		s := State{CheckedAt: time.Now()}
+		st, err := json.Marshal(s)
+		if err != nil {
+			return nil, err
+		}
+		if err := os.WriteFile(v.statePath, st, os.ModePerm); err != nil {
+			return nil, err
+		}
+	}
+	return &p, nil
+}
+
+func (v *VerChecker) verifyTime() error {
+	// Skip check if path to state file isn't configured.
+	if v.statePath == "" {
+		return nil
+	}
+	var s State
+	f, err := os.Open(v.statePath)
+	if err != nil {
+		return nil
+	}
+	if err := json.NewDecoder(f).Decode(&s); err != nil {
+		return nil
+	}
+	if time.Since(s.CheckedAt) >= (time.Hour * 24) {
+		return nil
+	}
+	return errSkip
+}
+
+//go:embed notification.tmpl
+var notifyTmpl string
+
+func init() {
+	var err error
+	Notify, err = template.New("").Parse(notifyTmpl)
+	if err != nil {
+		panic(err)
+	}
+}
diff --git a/cmd/atlas/internal/update/update.go b/cmd/atlas/internal/update/update.go
deleted file mode 100644
index e21fb10a..00000000
--- a/cmd/atlas/internal/update/update.go
+++ /dev/null
@@ -1,166 +0,0 @@
-// Copyright 2021-present The Atlas Authors. All rights reserved.
-// This source code is licensed under the Apache 2.0 license found
-// in the LICENSE file in the root directory of this source tree.
-
-package update
-
-import (
-	"encoding/json"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"net/http"
-	"os"
-	"path/filepath"
-	"time"
-
-	"github.com/mitchellh/go-homedir"
-	"golang.org/x/mod/semver"
-)
-
-type (
-	// Store is where the latest release data is stored on CLI host.
-	Store struct {
-		Version   string    `json:"version"`
-		URL       string    `json:"url"`
-		CheckedAt time.Time `json:"checkedat"`
-	}
-
-	// LatestRelease holds the required fields from https://api.github.com/repos/ariga/atlas/releases/latest.
-	LatestRelease struct {
-		Version string `json:"tag_name"`
-		URL     string `json:"html_url"`
-	}
-)
-
-const (
-	// AtlasNoUpdateNotifier when enabled it cancels checking for update
-	AtlasNoUpdateNotifier = "ATLAS_NO_UPDATE_NOTIFIER"
-)
-
-// Check implements a notification to the user when a later release is available
-// 1. Check release file ~/.atlas/release.json for latest known release and poll time
-// 2. If last poll was more than 24h, poll GitHub public API https://docs.github.com/en/rest/reference/releases#get-the-latest-release
-// 3. Store the latest release metadata
-// 4. If current build Version, that is not development, is lower than the latest release, notify user
-func Check(version string, logF func(i ...any)) {
-	if !enabled(version) {
-		return
-	}
-	p, err := homedir.Expand("~/.atlas")
-	if err != nil {
-		return
-	}
-	ok, message, err := shouldUpdate(version, p, latestReleaseFromGithub)
-	if err != nil || !ok {
-		return
-	}
-	logF(message)
-}
-
-func enabled(version string) bool {
-	if _, ok := os.LookupEnv(AtlasNoUpdateNotifier); ok {
-		return false
-	}
-	if _, ok := os.LookupEnv("GITHUB_ACTIONS"); ok {
-		return false
-	}
-	if ok := semver.IsValid(version); !ok {
-		return false
-	}
-	return true
-}
-
-func shouldUpdate(version string, path string, latestReleaseF func() (*LatestRelease, error)) (bool, string, error) {
-	r := localStore(path)
-	if shouldSkip(r) {
-		return false, "", nil
-	}
-	l, err := latestReleaseF()
-	if err != nil {
-		return false, "", err
-	}
-	if err := saveStore(path, l, time.Now()); err != nil {
-		return false, "", err
-	}
-	if semver.Compare(version, l.Version) != -1 {
-		return false, "", nil
-	}
-	return true, fmt.Sprintf("A new version of Atlas is available (%s):\n%s", l.Version, l.URL), nil
-}
-
-func latestReleaseFromGithub() (*LatestRelease, error) {
-	req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/ariga/atlas/tags", nil)
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Set("Content-Type", "application/json; charset=utf-8")
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
-	// https://docs.github.com/en/rest/overview/resources-in-the-rest-api#user-agent-required
-	req.Header.Set("User-Agent", "Ariga-Atlas-CLI")
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	b, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-	var tags []tag
-	if err := json.Unmarshal(b, &tags); err != nil {
-		return nil, err
-	}
-	var max tag
-	for _, tag := range tags {
-		if !semver.IsValid(tag.Name) {
-			continue
-		}
-		if semver.Compare(tag.Name, max.Name) > 0 {
-			max = tag
-		}
-	}
-	return &LatestRelease{
-		Version: max.Name,
-		URL:     fmt.Sprintf("https://github.com/ariga/atlas/releases/tag/%s", max.Name),
-	}, nil
-}
-
-func shouldSkip(r *Store) bool {
-	if r == nil {
-		return false
-	}
-	return time.Since(r.CheckedAt).Hours() < 24
-}
-
-func localStore(path string) *Store {
-	b, err := ioutil.ReadFile(fileLocation(path))
-	if err != nil {
-		return nil
-	}
-	var s Store
-	if err := json.Unmarshal(b, &s); err != nil {
-		return nil
-	}
-	return &s
-}
-
-func saveStore(path string, l *LatestRelease, t time.Time) error {
-	s := Store{Version: l.Version, URL: l.URL, CheckedAt: t}
-	err := os.MkdirAll(path, 0755)
-	if err != nil {
-		return err
-	}
-	b, err := json.Marshal(&s)
-	if err != nil {
-		return err
-	}
-	return ioutil.WriteFile(fileLocation(path), b, 0600)
-}
-
-func fileLocation(p string) string {
-	return filepath.Join(p, "release.json")
-}
-
-type tag struct {
-	Name string `json:"name"`
-}
diff --git a/cmd/atlas/main.go b/cmd/atlas/main.go
index a68c5578..b144ca72 100644
--- a/cmd/atlas/main.go
+++ b/cmd/atlas/main.go
@@ -5,11 +5,15 @@
 package main
 
 import (
+	"bytes"
 	"context"
+	"fmt"
 	"os"
 	"os/signal"
+	"time"
 
 	"ariga.io/atlas/cmd/atlas/internal/cmdapi"
+	"ariga.io/atlas/cmd/atlas/internal/cmdapi/vercheck"
 	_ "ariga.io/atlas/cmd/atlas/internal/docker"
 	_ "ariga.io/atlas/sql/mysql"
 	_ "ariga.io/atlas/sql/mysql/mysqlcheck"
@@ -17,6 +21,8 @@ import (
 	_ "ariga.io/atlas/sql/postgres/postgrescheck"
 	_ "ariga.io/atlas/sql/sqlite"
 	_ "ariga.io/atlas/sql/sqlite/sqlitecheck"
+	"github.com/mitchellh/go-homedir"
+	"golang.org/x/mod/semver"
 
 	_ "github.com/go-sql-driver/mysql"
 	_ "github.com/lib/pq"
@@ -26,9 +32,60 @@ import (
 func main() {
 	ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
 	cmdapi.Root.SetOut(os.Stdout)
+	update := checkForUpdate()
 	err := cmdapi.Root.ExecuteContext(ctx)
-	cmdapi.CheckForUpdate()
+	if u := update(); u != "" {
+		fmt.Println(u)
+	}
 	if err != nil {
 		os.Exit(1)
 	}
 }
+
+const (
+	// envNoUpdate when enabled it cancels checking for update
+	envNoUpdate = "ATLAS_NO_UPDATE_NOTIFIER"
+	vercheckURL = "https://vercheck.ariga.io"
+	versionFile = "~/.atlas/release.json"
+)
+
+func noText() string { return "" }
+
+// checkForUpdate checks for version updates and security advisories for Atlas.
+func checkForUpdate() func() string {
+	done := make(chan struct{})
+	version := cmdapi.Version()
+	// Users may skip update checking behavior.
+	if v := os.Getenv(envNoUpdate); v != "" {
+		return noText
+	}
+	// Skip if the current binary version isn't set (dev mode).
+	if !semver.IsValid(version) {
+		return noText
+	}
+	path, err := homedir.Expand(versionFile)
+	if err != nil {
+		return noText
+	}
+	var message string
+	go func() {
+		defer close(done)
+		vc := vercheck.New(vercheckURL, path)
+		payload, err := vc.Check(version)
+		if err != nil {
+			return
+		}
+		var b bytes.Buffer
+		if err := vercheck.Notify.Execute(&b, payload); err != nil {
+			return
+		}
+		message = b.String()
+	}()
+	return func() string {
+		select {
+		case <-done:
+		case <-time.After(time.Millisecond * 500):
+		}
+		return message
+	}
+}
diff --git a/doc/md/reference.md b/doc/md/reference.md
index 3a100ff4..ef075fbb 100644
--- a/doc/md/reference.md
+++ b/doc/md/reference.md
@@ -24,26 +24,6 @@ If you would like to build Atlas from source without the UI code run:
 go get ariga.io/atlas/cmd/atlas
 ```
 
-## atlas env
-
-Print atlas environment variables.
-
-#### Usage
-```
-atlas env
-```
-
-#### Details
-'atlas env' prints atlas environment information.
-
-Every set environment param will be printed in the form of NAME=VALUE.
-
-List of supported environment parameters:
-* ATLAS_NO_UPDATE_NOTIFIER: On any command, the CLI will check for new releases using the GitHub API.
-  This check will happen at most once every 24 hours. To cancel this behavior, set the environment 
-  variable "ATLAS_NO_UPDATE_NOTIFIER".
-
-
 ## atlas license
 
 Display license information
diff --git a/schemahcl/context.go b/schemahcl/context.go
index 864abacb..08efedee 100644
--- a/schemahcl/context.go
+++ b/schemahcl/context.go
@@ -13,6 +13,7 @@ import (
 	"github.com/hashicorp/hcl/v2/gohcl"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
 	"github.com/zclconf/go-cty/cty"
+	"github.com/zclconf/go-cty/cty/function"
 )
 
 // varDef is an HCL resource that defines an input variable to the Atlas DDL document.
@@ -30,21 +31,36 @@ type varDef struct {
 //	  default = "rotemtam"
 //	}
 func (s *State) setInputVals(ctx *hcl.EvalContext, body hcl.Body, input map[string]string) error {
-	var c struct {
-		Vars   []*varDef `hcl:"variable,block"`
-		Remain hcl.Body  `hcl:",remain"`
-	}
-	nctx := ctx.NewChild()
+	var (
+		doc struct {
+			Vars   []*varDef `hcl:"variable,block"`
+			Remain hcl.Body  `hcl:",remain"`
+		}
+		nctx = ctx.NewChild()
+	)
 	nctx.Variables = map[string]cty.Value{
-		"string": capsuleTypeVal("string"),
-		"int":    capsuleTypeVal("int"),
-		"bool":   capsuleTypeVal("bool"),
+		"string": cty.CapsuleVal(ctyNilType, &cty.String),
+		"bool":   cty.CapsuleVal(ctyNilType, &cty.Bool),
+		"int":    cty.CapsuleVal(ctyNilType, &cty.Number),
+	}
+	nctx.Functions = map[string]function.Function{
+		"list": function.New(&function.Spec{
+			Params: []function.Parameter{
+				{Name: "type", Type: ctyNilType},
+			},
+			Type: function.StaticReturnType(ctyNilType),
+			Impl: func(args []cty.Value, _ cty.Type) (cty.Value, error) {
+				argT := args[0].EncapsulatedValue().(*cty.Type)
+				listT := cty.List(*argT)
+				return cty.CapsuleVal(ctyNilType, &listT), nil
+			},
+		}),
 	}
-	if diag := gohcl.DecodeBody(body, nctx, &c); diag.HasErrors() {
+	if diag := gohcl.DecodeBody(body, nctx, &doc); diag.HasErrors() {
 		return diag
 	}
 	ctxVars := make(map[string]cty.Value)
-	for _, v := range c.Vars {
+	for _, v := range doc.Vars {
 		inputVal, ok := input[v.Name]
 		if ok {
 			ctyVal, err := readVar(v, inputVal)
@@ -78,34 +94,30 @@ func mergeCtxVar(ctx *hcl.EvalContext, vals map[string]cty.Value) {
 // readVar reads the raw inputVal as a cty.Value using the type definition on v.
 func readVar(v *varDef, inputVal string) (cty.Value, error) {
 	et := v.Type.EncapsulatedValue()
-	typ, ok := et.(*Type)
-	if !ok {
-		return cty.NilVal, fmt.Errorf("expected schemahcl.Type got %T", et)
+	typ, ok := et.(*cty.Type)
+	if !ok || typ == nil {
+		return cty.NilVal, fmt.Errorf("expected non-nillable schemahcl.Type got %T", et)
 	}
-	switch typ.T {
-	case "string":
+	switch *typ {
+	case cty.String:
 		return cty.StringVal(inputVal), nil
-	case "int":
+	case cty.Number:
 		i, err := strconv.Atoi(inputVal)
 		if err != nil {
 			return cty.NilVal, err
 		}
 		return cty.NumberIntVal(int64(i)), nil
-	case "bool":
+	case cty.Bool:
 		b, err := strconv.ParseBool(inputVal)
 		if err != nil {
 			return cty.NilVal, err
 		}
 		return cty.BoolVal(b), nil
 	default:
-		return cty.NilVal, fmt.Errorf("unknown type: %q", typ.T)
+		return cty.NilVal, fmt.Errorf("unknown type: %q", v.Name)
 	}
 }
 
-func capsuleTypeVal(t string) cty.Value {
-	return cty.CapsuleVal(ctyTypeSpec, &Type{T: t})
-}
-
 func setBlockVars(ctx *hcl.EvalContext, b *hclsyntax.Body) (*hcl.EvalContext, error) {
 	defs := defRegistry(b)
 	vars, err := blockVars(b.Blocks, "", defs)
@@ -247,6 +259,7 @@ var (
 			}
 		},
 	})
+	ctyNilType  = cty.Capsule("type", reflect.TypeOf(cty.NilType))
 	ctyTypeSpec = cty.Capsule("type", reflect.TypeOf(Type{}))
 	ctyRawExpr  = cty.Capsule("raw_expr", reflect.TypeOf(RawExpr{}))
 )
diff --git a/schemahcl/hcl.go b/schemahcl/hcl.go
index 42285dbd..4a8c6b00 100644
--- a/schemahcl/hcl.go
+++ b/schemahcl/hcl.go
@@ -13,6 +13,8 @@ import (
 	"strings"
 
 	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/ext/dynblock"
+	"github.com/hashicorp/hcl/v2/hcldec"
 	"github.com/hashicorp/hcl/v2/hclparse"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
 	"github.com/hashicorp/hcl/v2/hclwrite"
@@ -358,16 +360,42 @@ func (s *State) toResource(ctx *hcl.EvalContext, block *hclsyntax.Block, scope [
 	}
 	spec.Attrs = attrs
 	for _, blk := range block.Body.Blocks {
-		res, err := s.toResource(ctx, blk, append(scope, blk.Type))
-		if err != nil {
-			return nil, err
+		switch blk.Type {
+		case "dynamic":
+			dec, err := dynamicSpec(blk)
+			if err != nil {
+				return nil, err
+			}
+			v, _, diag := hcldec.PartialDecode(dynblock.Expand(block.Body, ctx), dec, ctx)
+			if diag.HasErrors() {
+				return nil, diag
+			}
+			for _, e := range v.GetAttr(blk.Labels[0]).AsValueSlice() {
+				r := &Resource{Type: blk.Labels[0]}
+				for k, v := range e.AsValueMap() {
+					av, err := extractLiteralValue(v)
+					if err != nil {
+						return nil, err
+					}
+					r.Attrs = append(r.Attrs, &Attr{
+						K: k,
+						V: av,
+					})
+				}
+				spec.Children = append(spec.Children, r)
+			}
+		default:
+			r, err := s.toResource(ctx, blk, append(scope, blk.Type))
+			if err != nil {
+				return nil, err
+			}
+			spec.Children = append(spec.Children, r)
 		}
-		spec.Children = append(spec.Children, res)
 	}
 	return spec, nil
 }
 
-// encode encodes the give *schemahcl.Resource into a byte slice containing an Atlas HCL
+// encode the given *schemahcl.Resource into a byte slice containing an Atlas HCL
 // document representing it.
 func (s *State) encode(r *Resource) ([]byte, error) {
 	f := hclwrite.NewFile()
@@ -600,3 +628,28 @@ func hclList(items []hclwrite.Tokens) hclwrite.Tokens {
 func (f EvalFunc) Eval(p *hclparse.Parser, i any, input map[string]string) error {
 	return f(p, i, input)
 }
+
+func dynamicSpec(b *hclsyntax.Block) (hcldec.Spec, error) {
+	switch {
+	case len(b.Labels) != 1:
+		return nil, fmt.Errorf("unexpected number of labels for 'dynamic' block: %d", len(b.Labels))
+	case b.Body == nil || len(b.Body.Attributes) != 1 || b.Body.Attributes["for_each"] == nil:
+		return nil, fmt.Errorf("expect a single 'for_each' attribute on 'dynamic' block: %q", b.Labels[0])
+	case len(b.Body.Blocks) != 1 || b.Body.Blocks[0].Type != "content" || b.Body.Blocks[0].Body == nil:
+		return nil, fmt.Errorf("expect a non-empty 'content' block on 'dynamic' block: %q", b.Labels[0])
+	}
+	attrs := make(hcldec.ObjectSpec)
+	for name := range b.Body.Blocks[0].Body.Attributes {
+		attrs[name] = &hcldec.AttrSpec{
+			Name:     name,
+			Required: true,
+			Type:     cty.DynamicPseudoType,
+		}
+	}
+	return &hcldec.ObjectSpec{
+		b.Labels[0]: &hcldec.BlockListSpec{
+			TypeName: b.Labels[0],
+			Nested:   attrs,
+		},
+	}, nil
+}


Test patch:
diff --git a/cmd/atlas/internal/cmdapi/cmdapi_test.go b/cmd/atlas/internal/cmdapi/cmdapi_test.go
index 6ac8223c..d3a92fc2 100644
--- a/cmd/atlas/internal/cmdapi/cmdapi_test.go
+++ b/cmd/atlas/internal/cmdapi/cmdapi_test.go
@@ -10,25 +10,9 @@ import (
 	"os/exec"
 	"testing"
 
-	"ariga.io/atlas/cmd/atlas/internal/update"
-
 	"github.com/stretchr/testify/require"
 )
 
-func TestEnv(t *testing.T) {
-	out, err := runCmd(Root, "env")
-	require.NoError(t, err)
-	require.Empty(t, out)
-}
-
-func TestEnv_Set(t *testing.T) {
-	err := os.Setenv(update.AtlasNoUpdateNotifier, "test")
-	require.NoError(t, err)
-	out, err := runCmd(Root, "env")
-	require.NoError(t, err)
-	require.Equal(t, "ATLAS_NO_UPDATE_NOTIFIER=test\n", out)
-}
-
 func TestCLI_Version(t *testing.T) {
 	// Required to have a clean "stderr" while running first time.
 	tests := []struct {
@@ -69,6 +53,7 @@ func TestCLI_Version(t *testing.T) {
 			t.Setenv("ATLAS_NO_UPDATE_NOTIFIER", "true")
 			stdout := bytes.NewBuffer(nil)
 			tt.cmd.Stdout = stdout
+			tt.cmd.Stderr = os.Stderr
 			require.NoError(t, tt.cmd.Run())
 			require.Equal(t, tt.expected, stdout.String())
 		})
diff --git a/cmd/atlas/internal/cmdapi/vercheck/vercheck_test.go b/cmd/atlas/internal/cmdapi/vercheck/vercheck_test.go
new file mode 100644
index 00000000..4599c662
--- /dev/null
+++ b/cmd/atlas/internal/cmdapi/vercheck/vercheck_test.go
@@ -0,0 +1,153 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package vercheck
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestVerCheck(t *testing.T) {
+	var path, ua string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		output := `{"latest":{"Version":"v0.7.2","Summary":"","Link":"https://github.com/ariga/atlas/releases/tag/v0.7.2"},"advisory":null}`
+		path = r.URL.Path
+		ua = r.Header.Get("User-Agent")
+		_, _ = w.Write([]byte(output))
+	}))
+	defer srv.Close()
+
+	vc := New(srv.URL, "")
+	check, err := vc.Check("v0.1.2")
+
+	require.EqualValues(t, "/atlas/v0.1.2", path)
+	require.EqualValues(t, "Ariga-Atlas-CLI", ua)
+	require.NoError(t, err)
+	require.EqualValues(t, &Payload{
+		Latest: &Latest{
+			Version: "v0.7.2",
+			Summary: "",
+			Link:    "https://github.com/ariga/atlas/releases/tag/v0.7.2",
+		},
+	}, check)
+}
+
+func TestState(t *testing.T) {
+	hrAgo, err := json.Marshal(State{CheckedAt: time.Now().Add(-time.Hour)})
+	require.NoError(t, err)
+	weekAgo, err := json.Marshal(State{CheckedAt: time.Now().Add(-time.Hour * 24 * 7)})
+	require.NoError(t, err)
+	for _, tt := range []struct {
+		name        string
+		state       string
+		expectedRun bool
+	}{
+		{
+			name:        "corrupt json",
+			state:       "{",
+			expectedRun: true,
+		},
+		{
+			name:        "none",
+			state:       "", // no file
+			expectedRun: true,
+		},
+		{
+			name:        "one hr ago",
+			state:       string(hrAgo),
+			expectedRun: false,
+		},
+		{
+			name:        "one week ago",
+			state:       string(weekAgo),
+			expectedRun: true,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			var ran bool
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				ran = true
+				_, _ = w.Write([]byte(`{}`))
+			}))
+			t.Cleanup(srv.Close)
+			path := filepath.Join(t.TempDir(), "release.json")
+			if tt.state != "" {
+				err := os.WriteFile(path, []byte(tt.state), os.ModePerm)
+				require.NoError(t, err)
+			}
+			vc := New(srv.URL, path)
+			_, _ = vc.Check("v0.1.2")
+			require.EqualValues(t, tt.expectedRun, ran)
+
+			b, err := os.ReadFile(path)
+			require.NoError(t, err)
+			if tt.expectedRun {
+				require.NotEqualValues(t, tt.state, b)
+			} else {
+				require.EqualValues(t, tt.state, b)
+			}
+		})
+	}
+}
+
+func TestTemplate(t *testing.T) {
+
+	for _, tt := range []struct {
+		name    string
+		payload Payload
+		exp     string
+	}{
+		{
+			name:    "empty",
+			payload: Payload{},
+			exp:     "",
+		},
+		{
+			name: "version with summary",
+			payload: Payload{
+				Latest: &Latest{
+					Version: "v0.7.2",
+					Summary: "A great version including amazing features.",
+					Link:    "https://atlasgo.io/v0.7.2",
+				},
+			},
+			exp: `A new version of Atlas is available (v0.7.2): https://atlasgo.io/v0.7.2
+A great version including amazing features.`,
+		},
+		{
+			name: "version",
+			payload: Payload{
+				Latest: &Latest{
+					Version: "v0.7.2",
+					Link:    "https://atlasgo.io/v0.7.2",
+				},
+			},
+			exp: `A new version of Atlas is available (v0.7.2): https://atlasgo.io/v0.7.2`,
+		},
+		{
+			name: "with advisory",
+			payload: Payload{
+				Advisory: &Advisory{Text: "This version contains a vulnerability, please upgrade."},
+			},
+			exp: `SECURITY ADVISORY
+This version contains a vulnerability, please upgrade.`,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			var b bytes.Buffer
+			err := Notify.Execute(&b, tt.payload)
+			require.NoError(t, err)
+			require.EqualValues(t, tt.exp, b.String())
+		})
+	}
+}
diff --git a/cmd/atlas/internal/update/update_test.go b/cmd/atlas/internal/update/update_test.go
deleted file mode 100644
index 8e61bb51..00000000
--- a/cmd/atlas/internal/update/update_test.go
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright 2021-present The Atlas Authors. All rights reserved.
-// This source code is licensed under the Apache 2.0 license found
-// in the LICENSE file in the root directory of this source tree.
-
-package update
-
-import (
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/require"
-)
-
-func TestCheckForUpdate(t *testing.T) {
-	url := "https://github.com/ariga/atlas/releases/tag/test"
-	tests := []struct {
-		name           string
-		currentVersion string
-		latestVersion  string
-		store          *Store
-		latestRelease  *LatestRelease
-		shouldNotify   bool
-		message        string
-	}{
-		{
-			name:           "stale store fetch and update newer version",
-			currentVersion: "v1.2.3",
-			latestVersion:  "v2.2.3",
-			store:          &Store{Version: "v1.2.3", URL: url, CheckedAt: time.Now().Add(-25 * time.Hour)},
-			latestRelease:  &LatestRelease{Version: "v2.2.3", URL: url},
-			shouldNotify:   true,
-			message:        fmt.Sprintf("A new version of Atlas is available (v2.2.3):\n%s", url),
-		},
-		{
-			name:           "fresh store do nothing",
-			currentVersion: "v1.2.3",
-			latestVersion:  "v2.2.3",
-			store:          &Store{Version: "v1.2.3", URL: url, CheckedAt: time.Now().Add(-23 * time.Hour)},
-			shouldNotify:   false,
-		},
-		{
-			name:           "no store fetch and update newer version",
-			currentVersion: "v1.2.3",
-			latestVersion:  "v2.2.3",
-			latestRelease:  &LatestRelease{Version: "v2.2.3", URL: url},
-			shouldNotify:   true,
-			message:        fmt.Sprintf("A new version of Atlas is available (v2.2.3):\n%s", url),
-		},
-		{
-			name:           "no store fetch and update newer minor version",
-			currentVersion: "v1.2.3",
-			latestVersion:  "v1.3.3",
-			latestRelease:  &LatestRelease{Version: "v2.2.3", URL: url},
-			shouldNotify:   true,
-			message:        fmt.Sprintf("A new version of Atlas is available (v2.2.3):\n%s", url),
-		},
-		{
-			name:           "no store fetch and update newer patch version",
-			currentVersion: "v1.2.3",
-			latestVersion:  "v1.2.4",
-			latestRelease:  &LatestRelease{Version: "v2.2.3", URL: url},
-			shouldNotify:   true,
-			message:        fmt.Sprintf("A new version of Atlas is available (v2.2.3):\n%s", url),
-		},
-		{
-			name:           "no store fetch and update newer patch version - canary",
-			currentVersion: "v1.2.3-6539f2704b5d-canary",
-			latestVersion:  "v1.2.4",
-			latestRelease:  &LatestRelease{Version: "v2.2.3", URL: url},
-			shouldNotify:   true,
-			message:        fmt.Sprintf("A new version of Atlas is available (v2.2.3):\n%s", url),
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			p := t.TempDir()
-			if tt.store != nil {
-				b, err := json.Marshal(tt.store)
-				require.NoError(t, err)
-				require.NoError(t, ioutil.WriteFile(fileLocation(p), b, 0600))
-			}
-			var f func() (*LatestRelease, error)
-			if tt.latestRelease != nil {
-				f = func() (*LatestRelease, error) { return tt.latestRelease, nil }
-			}
-			ok, m, err := shouldUpdate(tt.currentVersion, p, f)
-			require.NoError(t, err)
-			require.Equal(t, tt.shouldNotify, ok)
-			require.Equal(t, tt.message, m)
-		})
-	}
-}
diff --git a/schemahcl/hcl_test.go b/schemahcl/hcl_test.go
index 8686a51b..35c4eccd 100644
--- a/schemahcl/hcl_test.go
+++ b/schemahcl/hcl_test.go
@@ -319,8 +319,10 @@ func TestMultiFile(t *testing.T) {
 	var test struct {
 		People []*Person `spec:"person"`
 	}
-	paths := make([]string, 0)
-	testDir := "testdata/"
+	var (
+		paths   []string
+		testDir = "testdata/"
+	)
 	dir, err := os.ReadDir(testDir)
 	require.NoError(t, err)
 	for _, file := range dir {
@@ -341,3 +343,60 @@ func TestMultiFile(t *testing.T) {
 		Parent: &Ref{V: "$person.rotemtam"},
 	}, test.People[1])
 }
+
+func TestDynamicBlocks(t *testing.T) {
+	type (
+		URL struct {
+			Value string `spec:"value"`
+		}
+		Env struct {
+			Name string `spec:",name"`
+			URLs []*URL `spec:"url"`
+		}
+	)
+	var (
+		doc struct {
+			Envs []*Env `spec:"env"`
+		}
+		b = []byte(`
+variable "tenants" {
+  type    = list(string)
+  default = ["atlas", "ent"]
+}
+
+env "prod" {
+  dynamic "url" {
+    for_each = var.tenants
+    content {
+      value = "mysql://root:pass@:3306/${url.value}"
+    }
+  }
+  migration {
+    dir = "file://migrations"
+  }
+}
+
+env "staging" {
+  dynamic "url" {
+    for_each = [for t in var.tenants: "mysql://root:pass@:3306/${t}"]
+    content {
+      value = "${url.value}"
+    }
+  }
+  migration {
+    dir = "file://migrations"
+  }
+}
+`)
+	)
+	require.NoError(t, New().EvalBytes(b, &doc, nil))
+	require.Len(t, doc.Envs, 2)
+	require.Equal(t, "prod", doc.Envs[0].Name)
+	require.Equal(t, "staging", doc.Envs[1].Name)
+	require.Len(t, doc.Envs[0].URLs, 2)
+	require.Len(t, doc.Envs[1].URLs, 2)
+	require.Equal(t, "mysql://root:pass@:3306/atlas", doc.Envs[0].URLs[0].Value)
+	require.Equal(t, "mysql://root:pass@:3306/atlas", doc.Envs[1].URLs[0].Value)
+	require.Equal(t, "mysql://root:pass@:3306/ent", doc.Envs[0].URLs[1].Value)
+	require.Equal(t, "mysql://root:pass@:3306/ent", doc.Envs[1].URLs[1].Value)
+}