diff --git a/README.md b/README.md index 2d4c3360..9ca8d36d 100644 --- a/README.md +++ b/README.md @@ -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. + +
Offline Release Lists (file:// URLs)
+ +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. + +
+ diff --git a/pkg/download/download.go b/pkg/download/download.go index 3a4bef50..7d17a7aa 100644 --- a/pkg/download/download.go +++ b/pkg/download/download.go @@ -21,9 +21,12 @@ package download import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/url" + "os" + "strings" ) type RequestOption = func(*http.Request) @@ -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, "://") + 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 } @@ -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 } @@ -110,6 +120,19 @@ func NoTransform(value string) (string, error) { return value, nil } +func bytesFromFile(fileURL string) ([]byte, error) { + //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 } diff --git a/pkg/download/download_test.go b/pkg/download/download_test.go index 771b2245..3e1a8c2d 100644 --- a/pkg/download/download_test.go +++ b/pkg/download/download_test.go @@ -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) { @@ -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") + } +} diff --git a/versionmanager/retriever/terraform/terraformretriever.go b/versionmanager/retriever/terraform/terraformretriever.go index dfaba472..1a3f1b5c 100644 --- a/versionmanager/retriever/terraform/terraformretriever.go +++ b/versionmanager/retriever/terraform/terraformretriever.go @@ -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 ( @@ -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://") { + // 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) @@ -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://") { + 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)