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
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ archives:
- CONTRIBUTING.md
- SECURITY.md
- docs/telemetry.md
- docs/updates.md

checksum:
name_template: checksums.txt
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Install Terraform only when you are ready to validate and import the generated f

To build from source, see [Contributing](CONTRIBUTING.md).

Interactive commands check GitHub Releases for updates. A notice links to the newer release.
Set `DATATF_NO_UPDATE_NOTIFIER=1` to disable checks. See [upgrade instructions](docs/updates.md).

## Quick start

Use the [Databricks CLI](https://learn.microsoft.com/en-us/azure/databricks/dev-tools/cli/install)
Expand Down
1 change: 1 addition & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Optional telemetry

Telemetry is off by default. DataTF exports work without telemetry.
Automatic [update checks](updates.md) are separate from telemetry and have their own control.
536 Technologies uses optional command metrics to choose platform support, resource coverage,
and reliability work.

Expand Down
59 changes: 59 additions & 0 deletions docs/updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Upgrade DataTF

DataTF shows an update notice when a newer stable release is available.
It does not install updates or change your Terraform files.

1. Open the release link in the notice.
2. Download the archive for your system and processor.
3. Compare its SHA-256 checksum with the value in `checksums.txt` from the same release.
4. Extract the archive.
5. Replace your installed `datatf` binary, or `datatf.exe` on Windows.
6. Run `datatf version` to check the installed version.

Use `command -v datatf` on macOS or Linux to find the installed binary.
Use `where.exe datatf` in PowerShell on Windows.
If your company manages the installation, ask its administrator to approve the update.

## Check behavior

DataTF checks after a successful interactive command, help output, or a launch without a command.
It writes the notice to stderr. It does not change stdout, the exit code, or export files.
Only stable builds check for updates. Development builds and prereleases do not check.

Checks require terminal output on both stdout and stderr.
JSON, plain, quiet, completion, and telemetry commands do not check.
Detected CI and agent sessions do not check.
DataTF uses the same CI and agent indicators listed in the [telemetry notice](telemetry.md).

Results and failed attempts stay in a local cache for 24 hours.
The cache contains only a check time and a release version.
DataTF stores it in `datatf/update.json` under the operating system's user cache directory.
If that directory is unavailable, DataTF skips the check.

Each check has a 500-millisecond network limit. Network failures produce no notice.
There are no retries or redirects. Normal HTTPS certificate checks and proxy settings apply.

## Network access and controls

Update checks are separate from optional telemetry.
They request public release metadata from `api.github.com` without authentication.
They send no installed version, workspace data, profile, command arguments, or credentials.
GitHub receives the request's network metadata, including an IP address.

To disable checks in the current shell:

```sh
export DATATF_NO_UPDATE_NOTIFIER=1
```

In PowerShell:

```powershell
$env:DATATF_NO_UPDATE_NOTIFIER = '1'
```

Any nonempty `DATATF_NO_UPDATE_NOTIFIER` value disables the check and the notice.
`DO_NOT_TRACK=1` or `DO_NOT_TRACK=true` also disables both update checks and telemetry.
`DATATF_TELEMETRY=0` disables usage events only. It does not control update checks.

You can always check [GitHub Releases](https://github.com/536tech/datatf/releases/latest) yourself.
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ require (
github.com/hashicorp/hcl/v2 v2.24.0
github.com/spf13/cobra v1.10.2
github.com/zclconf/go-cty v1.19.0
golang.org/x/mod v0.30.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
)

require (
Expand Down Expand Up @@ -40,10 +42,9 @@ require (
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.39.0 // indirect
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,10 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ func Execute(ctx context.Context, args []string, stdin io.Reader, stdout, stderr
code = rc.writeError(executed, err)
}
rc.finishUsage(err != nil)
if err == nil || len(args) == 0 {
rc.updateNotice(executed)
}
return code
}

Expand Down Expand Up @@ -116,7 +119,7 @@ visible resources without local files. Neither proves full resource visibility.`
flags.BoolVar(&rc.g.asJSON, "json", false,
"emit JSON results to stdout and errors to stderr; no progress")
flags.BoolVar(&rc.g.plain, "plain", false, "emit stable plain text where available")
flags.BoolVarP(&rc.g.quiet, "quiet", "q", false, "suppress progress output on stderr")
flags.BoolVarP(&rc.g.quiet, "quiet", "q", false, "suppress progress and update notices on stderr")
flags.BoolVar(&rc.g.noColor, "no-color", false, "disable color")
flags.BoolVar(&rc.g.showVersion, "version", false, "print version and exit")

Expand Down
79 changes: 79 additions & 0 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cli

import (
"fmt"
"io"
"os"
"path/filepath"
goruntime "runtime"
"strings"

"github.com/spf13/cobra"
"golang.org/x/term"

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

var checkForUpdate = update.Check
var updateCacheDir = os.UserCacheDir
var terminalOutput = isTerminalOutput

func (rc *runtime) updateNotice(cmd *cobra.Command) {
if !rc.updateOutputAllowed() || updateDisabled() {
return
}
for current := cmd; current != nil; current = current.Parent() {
switch current.Name() {
case "telemetry", "completion", "__complete", "__completeNoDesc", "help":
return
}
}
rc.writeUpdateNotice()
}

func (rc *runtime) updateOutputAllowed() bool {
return !rc.g.asJSON && !rc.g.plain && !rc.g.quiet &&
terminalOutput(rc.stdout) && terminalOutput(rc.stderr)
}

func updateDisabled() bool {
if os.Getenv("DATATF_NO_UPDATE_NOTIFIER") != "" {
return true
}
if value := os.Getenv("DO_NOT_TRACK"); value == "1" || strings.EqualFold(value, "true") {
return true
}
for _, key := range []string{
"CI", "GITHUB_ACTIONS", "TF_BUILD", "GITLAB_CI", "JENKINS_URL",
"CODEX_THREAD_ID", "CODEX_CI", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
} {
if value := os.Getenv(key); value != "" && value != "0" && value != "false" {
return true
}
}
return false
}

func (rc *runtime) writeUpdateNotice() {
dir, err := updateCacheDir()
if err != nil {
return
}
latest := checkForUpdate(rc.ctx, version, filepath.Join(dir, "datatf", "update.json"), now())
if latest == "" {
return
}
binary := "datatf"
if goruntime.GOOS == "windows" {
binary = "datatf.exe"
}
_, _ = fmt.Fprintf(rc.stderr,
"\nDataTF %s is available (installed: %s).\nUpgrade: %s%s\n"+
"Download the archive for your system. Verify its checksum. Replace %s on PATH.\n",
latest, version, update.Releases, latest, binary)
}

func isTerminalOutput(writer io.Writer) bool {
file, ok := writer.(*os.File)
return ok && term.IsTerminal(int(file.Fd()))
}
153 changes: 153 additions & 0 deletions internal/cli/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package cli

import (
"context"
"io"
"os"
"reflect"
"strings"
"testing"
"time"

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

func captureUpdateCheck(t *testing.T) *int {
t.Helper()
oldCheck, oldDir := checkForUpdate, updateCacheDir
oldTerminal, oldVersion := terminalOutput, version
t.Cleanup(func() {
checkForUpdate, updateCacheDir = oldCheck, oldDir
terminalOutput, version = oldTerminal, oldVersion
})
for _, key := range []string{
"DATATF_NO_UPDATE_NOTIFIER", "DO_NOT_TRACK", "CI", "GITHUB_ACTIONS", "TF_BUILD",
"GITLAB_CI", "JENKINS_URL", "CODEX_THREAD_ID", "CODEX_CI", "CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
} {
t.Setenv(key, "")
}
dir := t.TempDir()
updateCacheDir = func() (string, error) { return dir, nil }
terminalOutput = func(io.Writer) bool { return true }
version = "0.2.0"
calls := 0
checkForUpdate = func(context.Context, string, string, time.Time) string {
calls++
return "v0.3.0"
}
return &calls
}

func TestUpdateNoticeForInteractiveLaunch(t *testing.T) {
calls := captureUpdateCheck(t)
for _, args := range [][]string{{}, {"--help"}, {"version"}, {"--version"}} {
code, stdout, stderr := run(t, args...)
want := exitOK
if len(args) == 0 {
want = exitUsage
}
if code != want || !strings.Contains(stderr, "DataTF v0.3.0 is available") {
t.Fatalf("%v: %d %s", args, code, stderr)
}
if strings.Contains(stdout, "Upgrade:") {
t.Fatal("notice changed stdout")
}
if !strings.Contains(stderr, "https://github.com/536tech/datatf/releases/tag/v0.3.0") {
t.Fatal("missing release link")
}
}
if *calls != 4 {
t.Fatalf("checks: %d", *calls)
}
}

func TestUpdateNoticeExclusions(t *testing.T) {
calls := captureUpdateCheck(t)
for _, args := range [][]string{
{"version", "--json"}, {"version", "--plain"}, {"version", "--quiet"},
{"telemetry", "status"}, {"telemetry", "preview"}, {"help", "telemetry"},
{"completion", "bash"}, {"__complete", "export", ""},
{"invalid-command"}, {"export", "--scope", "invalid"},
} {
_, _, stderr := run(t, args...)
if strings.Contains(stderr, "Upgrade:") {
t.Fatalf("notice for %v", args)
}
}
if *calls != 0 {
t.Fatalf("excluded commands made %d checks", *calls)
}
}

func TestUpdateNoticeEnvironmentOptOut(t *testing.T) {
calls := captureUpdateCheck(t)
for _, key := range []string{
"DATATF_NO_UPDATE_NOTIFIER", "DO_NOT_TRACK", "CI", "GITHUB_ACTIONS", "TF_BUILD",
"GITLAB_CI", "JENKINS_URL", "CODEX_THREAD_ID", "CODEX_CI", "CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
} {
t.Run(key, func(t *testing.T) {
t.Setenv(key, "1")
_, _, stderr := run(t, "version")
if strings.Contains(stderr, "Upgrade:") {
t.Fatal(stderr)
}
})
}
if *calls != 0 {
t.Fatalf("opt-out made %d checks", *calls)
}
}

func TestUpdateNoticeRequiresBothTerminals(t *testing.T) {
calls := captureUpdateCheck(t)
for _, failed := range []int{1, 2} {
checks := 0
terminalOutput = func(io.Writer) bool { checks++; return checks != failed }
run(t, "version")
}
if *calls != 0 {
t.Fatal("redirected output made a check")
}
file, err := os.Open(os.DevNull)
if err != nil {
t.Fatal(err)
}
defer file.Close()
if isTerminalOutput(file) || isTerminalOutput(io.Discard) {
t.Fatal("not a terminal")
}
}

func TestUpdateFailureDoesNotChangeExport(t *testing.T) {
calls := captureUpdateCheck(t)
isolateAuth(t, fakews.New(t))
oldNow := now
now = func() time.Time { return time.Unix(0, 0) }
t.Cleanup(func() { now = oldNow })
checkForUpdate = func(context.Context, string, string, time.Time) string {
(*calls)++
return ""
}
var baselineFiles [][]byte
var baselineOutput string
for _, disabled := range []string{"1", ""} {
t.Setenv("DATATF_NO_UPDATE_NOTIFIER", disabled)
dir := t.TempDir()
code, stdout, stderr := run(t, "export", "--resources", "warehouses", "--out", dir)
if code != exitOK {
t.Fatalf("%d %s", code, stderr)
}
files := readExportFiles(t, dir)
output := strings.ReplaceAll(stdout+stderr, dir, "<output-directory>")
if disabled == "1" {
baselineFiles, baselineOutput = files, output
} else if !reflect.DeepEqual(files, baselineFiles) || output != baselineOutput {
t.Fatal("failed update check changed export files or output")
}
}
if *calls != 1 {
t.Fatalf("checks: %d", *calls)
}
}
Loading