Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.1.0] - 2026-09-16

### Added

- `export --scaffold --module-version latest` resolves the latest stable public Registry version
for each selected module and writes exact version pins. Lookup failures stop before file writes.

### Changed

- Default scaffold pins use module version 1.0.1, with input validation and permission checks
in each resource module and unknown grant-target checks in the workspace pattern.

## [1.0.3] - 2026-09-11

### Security
Expand Down
25 changes: 22 additions & 3 deletions docs/module-layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ Neither creates Azure workspaces or exports workloads, stored data, or secret va

| `--module-layout` | Root calls | Default module version |
| --- | --- | --- |
| `workspace` (default) | `536tech/workspace/databricks` | `1.0.0` |
| `resources` | Individual `536tech` Registry modules | `1.0.0` |
| `workspace` (default) | `536tech/workspace/databricks` | `1.0.1` |
| `resources` | Individual `536tech` Registry modules | `1.0.1` |

## Individual modules

Expand Down Expand Up @@ -44,7 +44,26 @@ The modules manage the resources in the [resource matrix](../README.md#resource-

## Versions and inputs

The resource layout pins every emitted module to an exact version. Use `--module-version 1.0.0`
The default pins the latest release tested with this DataTF build. It needs no Registry lookup.
Use `--module-version latest --scaffold` to select newer stable releases:

```sh
datatf export --profile analytics --resources warehouses --module-layout resources \
--module-version latest --scaffold --out ./warehouse-latest
```

DataTF resolves each selected module independently through the public Terraform Registry.
It writes exact versions into `main.tf`; later `terraform init` calls do not select newer module releases.
Pre-release versions are excluded. A failed lookup stops the export before any output files are written.
The lookup sends module source addresses only, with no workspace credentials or metadata.

`latest` is an explicit opt-in, not a compatibility certification. It can select a new major version.
Review the module changes and require an imports-only plan before apply.
Use the default for the tested contract. The workspace module pins its own child-module versions.
`latest` supports public Registry sources only; it rejects Git, local, and private Registry sources.


The resource layout pins every emitted module to an exact version. Use `--module-version 1.0.1`
to select that release explicitly. A different version must exist for every selected module and keep
the same inputs and resource addresses. Version ranges are not supported for this layout.

Expand Down
43 changes: 28 additions & 15 deletions internal/cli/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@ import (
)

type exportOptions struct {
scope string
outDir string
rootModule string
allowPartial bool
scaffold bool
moduleSource string
moduleVer string
moduleLayout string
resources []string
name *string
profile string
scope string
outDir string
rootModule string
allowPartial bool
scaffold bool
moduleSource string
moduleVer string
moduleVersions map[string]string
moduleLayout string
resources []string
name *string
profile string
}

func newExportCommand(rc *runtime) *cobra.Command {
Expand Down Expand Up @@ -80,7 +81,7 @@ from other groups. Use a new output directory for each export.`,
flags.StringVar(&opts.moduleSource, "module-source", scaffold.DefaultModuleSource,
"module source for --scaffold (registry address, Git URL, or local path)")
flags.StringVar(&opts.moduleVer, "module-version", scaffold.DefaultModuleVersion,
"Registry module version for --scaffold")
"Registry module version or latest for --scaffold (default: tested release)")
flags.StringSliceVar(&opts.resources, "resources", nil,
"limit reads to resource groups (comma-separated)")
flags.String("name", "", "select one exact name within one --resources group")
Expand Down Expand Up @@ -122,6 +123,15 @@ func (opts *exportOptions) validateRoot() error {
}

func (opts *exportOptions) prepareLayout(cmd *cobra.Command) error {
if opts.moduleVer == "latest" {
if !opts.scaffold {
return fmt.Errorf("%w: --module-version latest requires --scaffold", errUsage)
}
if opts.moduleLayout == "workspace" && !scaffold.PublicRegistryModule(opts.moduleSource) {
return fmt.Errorf("%w: --module-version latest requires a public Terraform Registry module source", errUsage)
}
}

switch opts.moduleLayout {
case "workspace":
return nil
Expand All @@ -136,8 +146,8 @@ func (opts *exportOptions) prepareLayout(cmd *cobra.Command) error {
return fmt.Errorf("%w: --root-module must be empty with --module-layout resources", errUsage)
}
opts.rootModule = ""
if !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`).MatchString(opts.moduleVer) {
return fmt.Errorf("%w: --module-version needs an exact release such as 1.0.0 "+
if opts.moduleVer != "latest" && !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`).MatchString(opts.moduleVer) {
return fmt.Errorf("%w: --module-version needs latest or an exact release such as 1.0.0 "+
"with --module-layout resources", errUsage)
}
return nil
Expand All @@ -158,6 +168,9 @@ func (opts *exportOptions) run(cmd *cobra.Command, rc *runtime) error {
if err := opts.checkReport(rc, rep); err != nil {
return err
}
if err := opts.resolveVersions(cmd.Context(), ex); err != nil {
return withHint(err, "module_version_error", "Retry the Registry lookup or use the tested default without --module-version latest.")
}
files, err := opts.files(ex, rep)
if err != nil {
return withHint(err, "render_error", "Check scaffold options with datatf export --help.")
Expand Down Expand Up @@ -193,7 +206,7 @@ func (opts *exportOptions) scaffoldFiles(ex *contract.Export, rep *contract.Repo
) {
options := scaffold.Options{
Scope: ex.Scope, Host: rep.Host, Profile: opts.profile, RootModule: opts.rootModule,
ModuleSource: opts.moduleSource, ModuleVersion: opts.moduleVer,
ModuleSource: opts.moduleSource, ModuleVersion: opts.moduleVer, ModuleVersions: opts.moduleVersions,
}
if opts.moduleLayout == "resources" {
return scaffold.RenderResources(ex, options, opts.scaffold)
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func TestExportScaffoldRegistryDefault(t *testing.T) {
}
content := strings.Join(strings.Fields(string(main)), " ")
for _, want := range []string{
`source = "536tech/workspace/databricks"`, `version = "1.0.0"`,
`source = "536tech/workspace/databricks"`, `version = "1.0.1"`,
} {
if !strings.Contains(content, want) {
t.Errorf("main.tf missing %q:\n%s", want, main)
Expand Down
37 changes: 37 additions & 0 deletions internal/cli/module_versions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cli

import (
"context"

"github.com/536tech/datatf/internal/contract"
"github.com/536tech/datatf/internal/scaffold"
)

var resolveModuleVersions = scaffold.LatestVersions

func (opts *exportOptions) resolveVersions(ctx context.Context, ex *contract.Export) error {
if opts.moduleVer != "latest" {
return nil
}
sources := []string{opts.moduleSource}
if opts.moduleLayout == "resources" {
modules, err := contract.ResourceModules(ex.Tfvars)
if err != nil {
return err
}
sources = nil
for _, module := range modules {
sources = append(sources, module.Source)
}
}
versions, err := resolveModuleVersions(ctx, sources)
if err != nil {
return err
}
if opts.moduleLayout == "resources" {
opts.moduleVersions = versions
} else {
opts.moduleVer = versions[opts.moduleSource]
}
return nil
}
114 changes: 114 additions & 0 deletions internal/cli/module_versions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package cli

import (
"context"
"errors"
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"os"
"path/filepath"
"strings"
"testing"

"github.com/536tech/datatf/internal/fakews"
)

func TestLatestModuleVersionRequiresScaffold(t *testing.T) {
srv := fakews.New(t)
isolateAuth(t, srv)
code, _, stderr := run(t, "export", "--module-version", "latest", "--out", filepath.Join(t.TempDir(), "out"))
if code != exitUsage {
t.Fatalf("latest without scaffold: exit %d: %s", code, stderr)
}
}

func TestLatestModuleVersionsPinSelectedSources(t *testing.T) {
for _, layout := range []string{"workspace", "resources"} {
t.Run(layout, func(t *testing.T) {
srv := fakews.New(t)
isolateAuth(t, srv)
old := resolveModuleVersions
t.Cleanup(func() { resolveModuleVersions = old })
var requested []string
resolveModuleVersions = func(ctx context.Context, sources []string) (map[string]string, error) {
requested = sources
versions := map[string]string{}
for i, source := range sources {
versions[source] = fmt.Sprintf("1.2.%d", i)
}
return versions, nil
}
out := filepath.Join(t.TempDir(), "out")
code, _, stderr := run(t, "export", "--resources", "catalogs", "--module-layout", layout, "--scaffold", "--module-version", "latest", "--out", out)
if code != exitOK {
t.Fatalf("exit %d: %s", code, stderr)
}
file, diags := hclsyntax.ParseConfig(readGenerated(t, out, "main.tf"), "main.tf", hcl.InitialPos)
if diags.HasErrors() {
t.Fatal(diags)
}
blocks := file.Body.(*hclsyntax.Body).Blocks
if len(requested) != len(blocks) || len(blocks) == 0 {
t.Fatalf("lookups %v, blocks %d", requested, len(blocks))
}
for i, block := range blocks {
value, _ := block.Body.Attributes["version"].Expr.Value(nil)
if value.AsString() != fmt.Sprintf("1.2.%d", i) {
t.Fatalf("version %v", value)
}
source, _ := block.Body.Attributes["source"].Expr.Value(nil)
if source.AsString() != requested[i] {
t.Fatalf("source %v != %s", source, requested[i])
}
}
})
}
}

func TestRegistryFailureWritesNothing(t *testing.T) {
srv := fakews.New(t)
isolateAuth(t, srv)
old := resolveModuleVersions
t.Cleanup(func() { resolveModuleVersions = old })
resolveModuleVersions = func(context.Context, []string) (map[string]string, error) {
return nil, errors.New("Registry unavailable")
}
out := filepath.Join(t.TempDir(), "out")
code, _, stderr := run(t, "export", "--json", "--scaffold", "--module-version", "latest", "--out", out)
if code != exitErr || !strings.Contains(stderr, "module_version_error") {
t.Fatalf("exit %d: %s", code, stderr)
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatal("Registry failure wrote output")
}
}

func TestDefaultAndExplicitVersionDoNotResolve(t *testing.T) {
old := resolveModuleVersions
t.Cleanup(func() { resolveModuleVersions = old })
resolveModuleVersions = func(context.Context, []string) (map[string]string, error) {
t.Fatal("unexpected Registry call")
return nil, nil
}
for _, version := range []string{"", "1.2.3"} {
srv := fakews.New(t)
isolateAuth(t, srv)
args := []string{"export", "--scaffold", "--out", filepath.Join(t.TempDir(), "out")}
if version != "" {
args = append(args, "--module-version", version)
}
if code, _, stderr := run(t, args...); code != exitOK {
t.Fatalf("exit %d: %s", code, stderr)
}
}
}

func TestLatestRejectsNonPublicSource(t *testing.T) {
for _, source := range []string{"../module", "git::https://example.com/module.git", "private.example/team/module/databricks"} {
code, _, stderr := run(t, "export", "--scaffold", "--module-source", source, "--module-version", "latest")
if code != exitUsage {
t.Fatalf("%s: exit %d: %s", source, code, stderr)
}
}
}
2 changes: 1 addition & 1 deletion internal/cli/resource_modules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func readResourceModules(t *testing.T, out string) map[string]bool {
}
for key, want := range map[string]string{
"source": "registry.terraform.io/536tech/" + registryName + "/databricks",
"version": "1.0.0",
"version": "1.0.1",
} {
value, diags := block.Body.Attributes[key].Expr.Value(nil)
if diags.HasErrors() || value.AsString() != want {
Expand Down
8 changes: 6 additions & 2 deletions internal/scaffold/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func RenderResources(ex *contract.Export, opts Options, root bool) (map[string][
if !root {
return files, nil
}
main, variables := resourceRoot(modules, opts.ModuleVersion)
main, variables := resourceRoot(modules, opts)
maps.Copy(files, map[string][]byte{
"main.tf": main, "variables.tf": variables,
"providers.tf": providerFile(opts), "versions.tf": []byte(versionsTF),
Expand All @@ -51,11 +51,15 @@ export.json retains the canonical DataTF data structure, not the resource-module
Keep this layout after import. A layout change requires a reviewed state migration.
`

func resourceRoot(modules []contract.ResourceModule, version string) ([]byte, []byte) {
func resourceRoot(modules []contract.ResourceModule, opts Options) ([]byte, []byte) {
main, variables := hclwrite.NewEmptyFile(), hclwrite.NewEmptyFile()
for _, module := range modules {
body := main.Body().AppendNewBlock("module", []string{module.Name}).Body()
body.SetAttributeValue("source", cty.StringVal(module.Source))
version := opts.ModuleVersion
if resolved, ok := opts.ModuleVersions[module.Source]; ok {
version = resolved
}
body.SetAttributeValue("version", cty.StringVal(version))
body.SetAttributeTraversal("for_each", traversal("var", module.Name))
for _, name := range resourceInputNames(module.Values) {
Expand Down
15 changes: 8 additions & 7 deletions internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ import (
const DefaultModuleSource = "536tech/workspace/databricks"

// DefaultModuleVersion pins the tested Registry release for both layouts.
const DefaultModuleVersion = "1.0.0"
const DefaultModuleVersion = "1.0.1"

// Options control the generated root.
type Options struct {
Scope contract.Scope
Host string
Profile string
RootModule string
ModuleSource string
ModuleVersion string
Scope contract.Scope
Host string
Profile string
RootModule string
ModuleSource string
ModuleVersion string
ModuleVersions map[string]string
}

var variableNames = []string{
Expand Down
Loading