Skip to content
Open
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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,40 @@ Allow to override the remote url only for the releases listing.

See [advanced remote configuration](#advanced-remote-configuration) for more details.


<details markdown="1"><summary><b>Offline Release Lists (file:// URLs)</b></summary><br>

The `*_LIST_URL` variables now support the `file://` scheme for reading release lists from local files. This is useful for air-gapped environments, restricted CI runners, or prebuilt container images.

**Supported URL schemes:**
- `https://...` - Remote HTTPS URL (default, requires network)
- `http://...` - Remote HTTP URL (requires network)
- `file:///...` - Local absolute path
- `file://./...` - Local relative path

**Example - Local file releases:**

```bash
# Create a directory with release information
mkdir -p /opt/releases/terraform
# Copy existing release index to local directory
cp /path/to/releases.json /opt/releases/terraform/index.json

# Use file:// URL for offline version resolution
export TFENV_LIST_URL=file:///opt/releases
tenv tf list-remote

# Works with all tools
export TOFUENV_LIST_URL=file:///opt/releases
export TG_LIST_URL=file:///opt/releases
tenv tofu list-remote
tenv tg list-remote
```

The `file://` scheme uses the same JSON structure as the remote releases API, so you can simply copy an existing `index.json` or `releases.json` from a public mirror to your local directory for offline usage.

</details>

</details>


Expand Down
33 changes: 28 additions & 5 deletions pkg/download/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ package download
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)

type RequestOption = func(*http.Request)
Expand All @@ -44,10 +47,17 @@ func ApplyURLTransformer(urlTransformer URLTransformer, baseURLs ...string) ([]s
return transformedURLs, nil
}

func Bytes(ctx context.Context, url string, display func(string), checker ResponseChecker, requestOptions ...RequestOption) ([]byte, error) {
display("Downloading " + url)
func Bytes(ctx context.Context, urlStr string, display func(string), checker ResponseChecker, requestOptions ...RequestOption) ([]byte, error) {
//Handle file:// URLs
//renaming urlstr to url to avoid shadowing the package name (net/url)
scheme, _, ok := strings.Cut(urlStr, "://")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe better to use CutPrefix

if ok && scheme == "file" {
return bytesFromFile(urlStr)
}

display("Downloading " + urlStr)

request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, http.NoBody)
if err != nil {
return nil, err
}
Expand All @@ -69,8 +79,8 @@ func Bytes(ctx context.Context, url string, display func(string), checker Respon
return io.ReadAll(response.Body)
}

func JSON(ctx context.Context, url string, display func(string), checker ResponseChecker, requestOptions ...RequestOption) (any, error) {
data, err := Bytes(ctx, url, display, checker, requestOptions...)
func JSON(ctx context.Context, urlStr string, display func(string), checker ResponseChecker, requestOptions ...RequestOption) (any, error) {
data, err := Bytes(ctx, urlStr, display, checker, requestOptions...)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -110,6 +120,19 @@ func NoTransform(value string) (string, error) {
return value, nil
}

func bytesFromFile(fileURL string) ([]byte, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you use CutPrefix in the caller, here you can have path directly as parameter and remove the TrimPrefix call

//parse file:// URL to get the path
//file://path/to/file -> /path/to/file
//file://./relative/path/to/file -> ./relative/path/to/file

filePath := strings.TrimPrefix(fileURL, "file://")
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read file from %s: %w", fileURL, err)
}
return data, nil
}

func NoCheck(*http.Response) error {
return nil
}
100 changes: 100 additions & 0 deletions pkg/download/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ package download_test
import (
"testing"

"context"
"github.com/tofuutils/tenv/v4/pkg/download"
"os"
"path/filepath"
)

func TestURLTransformer(t *testing.T) {
Expand Down Expand Up @@ -69,3 +72,100 @@ func TestURLTransformerSlash(t *testing.T) {
t.Error("Unexpected result, get :", value)
}
}

func TestJSONFromFileAbsolutePath(t *testing.T) {
t.Parallel()

//create a temporary file with valid JSON content
tmpdir := t.TempDir()
tmpfile := filepath.Join(tmpdir, "test.json")
testdata := []byte(`{"key": "value"}`)

if err := os.WriteFile(tmpfile, testdata, 0o600); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}

//convert to file:// URL
fileURL := "file://" + tmpfile

result, err := download.JSON(context.Background(), fileURL, download.NoDisplay, download.NoCheck)
if err != nil {
t.Fatalf("JSON() failed: %v", err)
}

//verify it parsed correctly
if result == nil {
t.Error("JSON() returned nil")
}
}

func TestJSONFromFileRelativePath(t *testing.T) {
t.Parallel()

//create a temporary file with valid JSON content
tmpdir := t.TempDir()
testfile := "test.json"
testpath := filepath.Join(tmpdir, testfile)
testdata := []byte(`{"key": "value"}`)

if err := os.WriteFile(testpath, testdata, 0o600); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}

//change to temp directory
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current working directory: %v", err)
}
defer os.Chdir(oldCwd)

if err := os.Chdir(tmpdir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}

//test with relative path
fileURL := "file://" + testfile

result, err := download.JSON(context.Background(), fileURL, download.NoDisplay, download.NoCheck)
if err != nil {
t.Fatalf("JSON() failed: %v", err)
}

//verify it parsed correctly
if result == nil {
t.Error("JSON() returned nil")
}
}

func TestJSONFromFileMissing(t *testing.T) {
t.Parallel()

//test with non-existent file
fileURL := "file:///nonexistent.json"

_, err := download.JSON(context.Background(), fileURL, download.NoDisplay, download.NoCheck)
if err == nil {
t.Fatal("JSON() should fail for nonexistent file, got nil")
}
}

func TestJSONFromFileInvalidJSON(t *testing.T) {
t.Parallel()

//create a temporary file with invalid JSON content
tmpdir := t.TempDir()
tmpfile := filepath.Join(tmpdir, "invalid.json")

// write invalid JSON
if err := os.WriteFile(tmpfile, []byte(`{invalid json}`), 0o600); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}

//convert to file:// URL
fileURL := "file://" + tmpfile

_, err := download.JSON(context.Background(), fileURL, download.NoDisplay, download.NoCheck)
if err == nil {
t.Fatal("JSON() should fail for invalid JSON, got nil")
}
}
36 changes: 30 additions & 6 deletions versionmanager/retriever/terraform/terraformretriever.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"github.com/tofuutils/tenv/v4/pkg/winbin"
htmlretriever "github.com/tofuutils/tenv/v4/versionmanager/retriever/html"
releaseapi "github.com/tofuutils/tenv/v4/versionmanager/retriever/terraform/api"
"path/filepath"
)

const (
Expand Down Expand Up @@ -140,9 +141,20 @@ func (r TerraformRetriever) ListVersions(ctx context.Context) ([]string, error)
return nil, err
}

baseURL, err := url.JoinPath(r.conf.Tf.GetListURL(), cmdconst.TerraformName)
if err != nil {
return nil, err
listURL := r.conf.Tf.GetListURL()
var baseURL string
if strings.HasPrefix(listURL, "file://") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here, HasPrefix and TrimPrefix can be replaced with CutPrefix too

// For file:// URLs, use filepath joining
filePath := strings.TrimPrefix(listURL, "file://")
filePath = filepath.Join(filePath, cmdconst.TerraformName)
baseURL = "file://" + filePath
} else {
// For HTTP URLs, use url.JoinPath
var err error
baseURL, err = url.JoinPath(listURL, cmdconst.TerraformName)
if err != nil {
return nil, err
}
}

requestOptions := config.GetBasicAuthOption(r.conf.Getenv, envname.TfRemoteUser, envname.TfRemotePass)
Expand All @@ -153,9 +165,21 @@ func (r TerraformRetriever) ListVersions(ctx context.Context) ([]string, error)

return htmlretriever.ListReleases(ctx, baseURL, r.conf.Tf.Data, requestOptions)
case config.ModeAPI:
releasesURL, err := url.JoinPath(baseURL, indexJSON)
if err != nil {
return nil, err
// releasesURL, err := url.JoinPath(baseURL, indexJSON)
// if err != nil {
// return nil, err
// }
var releasesURL string
if strings.HasPrefix(baseURL, "file://") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably better to store in a local var that it was a file scheme, instead of checking several times

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggested improvements :

  • you can have a local var join typed func(string, string) (string, error) (wrap filepath.Join to match the signature in file use case)
  • concatenation of "file://" prefix should be done once at the end

filePath := strings.TrimPrefix(baseURL, "file://")
filePath = filepath.Join(filePath, indexJSON)
releasesURL = "file://" + filePath
} else {
var err error
releasesURL, err = url.JoinPath(baseURL, indexJSON)
if err != nil {
return nil, err
}
}

r.conf.Displayer.Display(apimsg.MsgFetchAllReleases + releasesURL)
Expand Down