Skip to content

Commit f2c0f74

Browse files
committed
feat: Add file:// URL scheme support for local release lists
Support offline version resolution by allowing file:// URLs in LIST_URL environment variables. This enables air-gapped installations, restricted CI runners, and enterprise networks to resolve versions from local files without network access. Changes: - pkg/download/download.go: Detect file:// URLs and read from local filesystem - pkg/download/download_test.go: Add tests for file:// URL handling - versionmanager/retriever/terraform/terraformretriever.go: Handle file:// URLs in path joining instead of url.JoinPath() - README.md: Document file:// URL scheme support with examples Fixes #601
1 parent a1a206c commit f2c0f74

4 files changed

Lines changed: 192 additions & 11 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,40 @@ Allow to override the remote url only for the releases listing.
11391139

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

1142+
1143+
<details markdown="1"><summary><b>Offline Release Lists (file:// URLs)</b></summary><br>
1144+
1145+
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.
1146+
1147+
**Supported URL schemes:**
1148+
- `https://...` - Remote HTTPS URL (default, requires network)
1149+
- `http://...` - Remote HTTP URL (requires network)
1150+
- `file:///...` - Local absolute path
1151+
- `file://./...` - Local relative path
1152+
1153+
**Example - Local file releases:**
1154+
1155+
```bash
1156+
# Create a directory with release information
1157+
mkdir -p /opt/releases/terraform
1158+
# Copy existing release index to local directory
1159+
cp /path/to/releases.json /opt/releases/terraform/index.json
1160+
1161+
# Use file:// URL for offline version resolution
1162+
export TFENV_LIST_URL=file:///opt/releases
1163+
tenv tf list-remote
1164+
1165+
# Works with all tools
1166+
export TOFUENV_LIST_URL=file:///opt/releases
1167+
export TG_LIST_URL=file:///opt/releases
1168+
tenv tofu list-remote
1169+
tenv tg list-remote
1170+
```
1171+
1172+
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.
1173+
1174+
</details>
1175+
11421176
</details>
11431177

11441178

pkg/download/download.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ package download
2121
import (
2222
"context"
2323
"encoding/json"
24+
"fmt"
2425
"io"
2526
"net/http"
2627
"net/url"
28+
"os"
29+
"strings"
2730
)
2831

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

47-
func Bytes(ctx context.Context, url string, display func(string), checker ResponseChecker, requestOptions ...RequestOption) ([]byte, error) {
48-
display("Downloading " + url)
50+
func Bytes(ctx context.Context, urlStr string, display func(string), checker ResponseChecker, requestOptions ...RequestOption) ([]byte, error) {
51+
//Handle file:// URLs
52+
//renaming urlstr to url to avoid shadowing the package name (net/url)
53+
scheme, _, ok := strings.Cut(urlStr, "://")
54+
if ok && scheme == "file" {
55+
return bytesFromFile(urlStr)
56+
}
57+
58+
display("Downloading " + urlStr)
4959

50-
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
60+
request, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, http.NoBody)
5161
if err != nil {
5262
return nil, err
5363
}
@@ -69,8 +79,8 @@ func Bytes(ctx context.Context, url string, display func(string), checker Respon
6979
return io.ReadAll(response.Body)
7080
}
7181

72-
func JSON(ctx context.Context, url string, display func(string), checker ResponseChecker, requestOptions ...RequestOption) (any, error) {
73-
data, err := Bytes(ctx, url, display, checker, requestOptions...)
82+
func JSON(ctx context.Context, urlStr string, display func(string), checker ResponseChecker, requestOptions ...RequestOption) (any, error) {
83+
data, err := Bytes(ctx, urlStr, display, checker, requestOptions...)
7484
if err != nil {
7585
return nil, err
7686
}
@@ -110,6 +120,19 @@ func NoTransform(value string) (string, error) {
110120
return value, nil
111121
}
112122

123+
func bytesFromFile(fileURL string) ([]byte, error) {
124+
//parse file:// URL to get the path
125+
//file://path/to/file -> /path/to/file
126+
//file://./relative/path/to/file -> ./relative/path/to/file
127+
128+
filePath := strings.TrimPrefix(fileURL, "file://")
129+
data, err := os.ReadFile(filePath)
130+
if err != nil {
131+
return nil, fmt.Errorf("failed to read file from %s: %w", fileURL, err)
132+
}
133+
return data, nil
134+
}
135+
113136
func NoCheck(*http.Response) error {
114137
return nil
115138
}

pkg/download/download_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ package download_test
2121
import (
2222
"testing"
2323

24+
"context"
2425
"github.com/tofuutils/tenv/v4/pkg/download"
26+
"os"
27+
"path/filepath"
2528
)
2629

2730
func TestURLTransformer(t *testing.T) {
@@ -69,3 +72,100 @@ func TestURLTransformerSlash(t *testing.T) {
6972
t.Error("Unexpected result, get :", value)
7073
}
7174
}
75+
76+
func TestJSONFromFileAbsolutePath(t *testing.T) {
77+
t.Parallel()
78+
79+
//create a temporary file with valid JSON content
80+
tmpdir := t.TempDir()
81+
tmpfile := filepath.Join(tmpdir, "test.json")
82+
testdata := []byte(`{"key": "value"}`)
83+
84+
if err := os.WriteFile(tmpfile, testdata, 0o600); err != nil {
85+
t.Fatalf("Failed to create test file: %v", err)
86+
}
87+
88+
//convert to file:// URL
89+
fileURL := "file://" + tmpfile
90+
91+
result, err := download.JSON(context.Background(), fileURL, download.NoDisplay, download.NoCheck)
92+
if err != nil {
93+
t.Fatalf("JSON() failed: %v", err)
94+
}
95+
96+
//verify it parsed correctly
97+
if result == nil {
98+
t.Error("JSON() returned nil")
99+
}
100+
}
101+
102+
func TestJSONFromFileRelativePath(t *testing.T) {
103+
t.Parallel()
104+
105+
//create a temporary file with valid JSON content
106+
tmpdir := t.TempDir()
107+
testfile := "test.json"
108+
testpath := filepath.Join(tmpdir, testfile)
109+
testdata := []byte(`{"key": "value"}`)
110+
111+
if err := os.WriteFile(testpath, testdata, 0o600); err != nil {
112+
t.Fatalf("Failed to create test file: %v", err)
113+
}
114+
115+
//change to temp directory
116+
oldCwd, err := os.Getwd()
117+
if err != nil {
118+
t.Fatalf("Failed to get current working directory: %v", err)
119+
}
120+
defer os.Chdir(oldCwd)
121+
122+
if err := os.Chdir(tmpdir); err != nil {
123+
t.Fatalf("Failed to change directory: %v", err)
124+
}
125+
126+
//test with relative path
127+
fileURL := "file://" + testfile
128+
129+
result, err := download.JSON(context.Background(), fileURL, download.NoDisplay, download.NoCheck)
130+
if err != nil {
131+
t.Fatalf("JSON() failed: %v", err)
132+
}
133+
134+
//verify it parsed correctly
135+
if result == nil {
136+
t.Error("JSON() returned nil")
137+
}
138+
}
139+
140+
func TestJSONFromFileMissing(t *testing.T) {
141+
t.Parallel()
142+
143+
//test with non-existent file
144+
fileURL := "file:///nonexistent.json"
145+
146+
_, err := download.JSON(context.Background(), fileURL, download.NoDisplay, download.NoCheck)
147+
if err == nil {
148+
t.Fatal("JSON() should fail for nonexistent file, got nil")
149+
}
150+
}
151+
152+
func TestJSONFromFileInvalidJSON(t *testing.T) {
153+
t.Parallel()
154+
155+
//create a temporary file with invalid JSON content
156+
tmpdir := t.TempDir()
157+
tmpfile := filepath.Join(tmpdir, "invalid.json")
158+
159+
// write invalid JSON
160+
if err := os.WriteFile(tmpfile, []byte(`{invalid json}`), 0o600); err != nil {
161+
t.Fatalf("Failed to create test file: %v", err)
162+
}
163+
164+
//convert to file:// URL
165+
fileURL := "file://" + tmpfile
166+
167+
_, err := download.JSON(context.Background(), fileURL, download.NoDisplay, download.NoCheck)
168+
if err == nil {
169+
t.Fatal("JSON() should fail for invalid JSON, got nil")
170+
}
171+
}

versionmanager/retriever/terraform/terraformretriever.go

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"github.com/tofuutils/tenv/v4/pkg/winbin"
3939
htmlretriever "github.com/tofuutils/tenv/v4/versionmanager/retriever/html"
4040
releaseapi "github.com/tofuutils/tenv/v4/versionmanager/retriever/terraform/api"
41+
"path/filepath"
4142
)
4243

4344
const (
@@ -140,9 +141,20 @@ func (r TerraformRetriever) ListVersions(ctx context.Context) ([]string, error)
140141
return nil, err
141142
}
142143

143-
baseURL, err := url.JoinPath(r.conf.Tf.GetListURL(), cmdconst.TerraformName)
144-
if err != nil {
145-
return nil, err
144+
listURL := r.conf.Tf.GetListURL()
145+
var baseURL string
146+
if strings.HasPrefix(listURL, "file://") {
147+
// For file:// URLs, use filepath joining
148+
filePath := strings.TrimPrefix(listURL, "file://")
149+
filePath = filepath.Join(filePath, cmdconst.TerraformName)
150+
baseURL = "file://" + filePath
151+
} else {
152+
// For HTTP URLs, use url.JoinPath
153+
var err error
154+
baseURL, err = url.JoinPath(listURL, cmdconst.TerraformName)
155+
if err != nil {
156+
return nil, err
157+
}
146158
}
147159

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

154166
return htmlretriever.ListReleases(ctx, baseURL, r.conf.Tf.Data, requestOptions)
155167
case config.ModeAPI:
156-
releasesURL, err := url.JoinPath(baseURL, indexJSON)
157-
if err != nil {
158-
return nil, err
168+
// releasesURL, err := url.JoinPath(baseURL, indexJSON)
169+
// if err != nil {
170+
// return nil, err
171+
// }
172+
var releasesURL string
173+
if strings.HasPrefix(baseURL, "file://") {
174+
filePath := strings.TrimPrefix(baseURL, "file://")
175+
filePath = filepath.Join(filePath, indexJSON)
176+
releasesURL = "file://" + filePath
177+
} else {
178+
var err error
179+
releasesURL, err = url.JoinPath(baseURL, indexJSON)
180+
if err != nil {
181+
return nil, err
182+
}
159183
}
160184

161185
r.conf.Displayer.Display(apimsg.MsgFetchAllReleases + releasesURL)

0 commit comments

Comments
 (0)