├── mockmirror ├── .gitignore └── mockmirror.go ├── .gitignore ├── branding ├── README.md ├── branding_nonwindows.go ├── branding_windows.go ├── cli.go ├── branding.go └── gpg_key.go ├── generate.go ├── internal ├── tools │ ├── README.md │ ├── lint │ │ ├── README.md │ │ └── main.go │ ├── bundle-gpg-key │ │ ├── README.md │ │ └── main.go │ └── license-headers │ │ ├── README.md │ │ └── main.go └── helloworld │ └── fake.go ├── cmd └── tofudl │ ├── README.md │ └── main.go ├── cli ├── README.md ├── options.go └── cli.go ├── mirror_download.go ├── mirror_download_nightly.go ├── mirror_download_version.go ├── go.mod ├── mirror_verify_artifact.go ├── downloader_test.go ├── api.schema.json ├── http.go ├── nightly_id.go ├── mirror_storage.go ├── .golangci.yml ├── mirror_prewarm.go ├── downloader_download_artifact.go ├── .github └── workflows │ └── verify.yml ├── mirror_create_version.go ├── mirror_create_version_asset.go ├── mirror_download_artifact.go ├── mirror_list_versions.go ├── stability.go ├── downloader_verify_artifact.go ├── architecture.go ├── downloader_nightly_test.go ├── platform.go ├── downloader_list_versions.go ├── mirror_serve_http.go ├── downloader.go ├── mirror_storage_filesystem.go ├── mirror.go ├── mirror_test.go ├── version.go ├── MIRROR-SPECIFICATION.md ├── downloader_download_version.go ├── downloader_download.go ├── downloader_download_nightly.go ├── config.go ├── README.md ├── go.sum ├── errors.go ├── release_builder.go └── LICENSE /mockmirror/.gitignore: -------------------------------------------------------------------------------- 1 | fake -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *~ 3 | tofu 4 | tofu.exe 5 | fake 6 | fake.exe -------------------------------------------------------------------------------- /branding/README.md: -------------------------------------------------------------------------------- 1 | # Branding settings 2 | 3 | This package contains a set of constants to change the built-in behavior and configuration of `tofudl`, such as the bundled GPG key, etc. -------------------------------------------------------------------------------- /generate.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | //go:generate go run github.com/opentofu/tofudl/internal/tools/license-headers 7 | -------------------------------------------------------------------------------- /internal/tools/README.md: -------------------------------------------------------------------------------- 1 | # Tools 2 | 3 | This directory contains the tools needed to build and run `tofudl` on any platform. Most of these tools are called automatically if you run `go generate ./...` in the root directory. -------------------------------------------------------------------------------- /cmd/tofudl/README.md: -------------------------------------------------------------------------------- 1 | # `tofudl` CLI demonstration 2 | 3 | This directory contains the outer shell of a `tofudl` CLI tool to serve as a demonstration how a CLI tool can be implemented to download OpenTofu. The core of this tool is located in [cli](../../cli). -------------------------------------------------------------------------------- /branding/branding_nonwindows.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build !windows 5 | 6 | package branding 7 | 8 | // PlatformBinaryName is the platform-dependent binary name for the current platform. 9 | const PlatformBinaryName = BinaryName 10 | -------------------------------------------------------------------------------- /branding/branding_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | //go:build windows 5 | 6 | package branding 7 | 8 | // PlatformBinaryName is the platform-dependent binary name for the current platform. 9 | const PlatformBinaryName = BinaryName + ".exe" 10 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # `tofudl` CLI demonstration 2 | 3 | This directory contains a demonstration CLI implementation with `tofudl`. This is not meant to be used as a production tool and implements its own CLI parsing to minimize dependencies of this library. The outer shell of this library is located in [cmd/tofudl](../cmd/tofudl). 4 | -------------------------------------------------------------------------------- /cmd/tofudl/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | 9 | "github.com/opentofu/tofudl/cli" 10 | ) 11 | 12 | func main() { 13 | c := cli.New() 14 | os.Exit(c.Run(os.Args, os.Environ(), os.Stdout, os.Stderr)) 15 | } 16 | -------------------------------------------------------------------------------- /internal/tools/lint/README.md: -------------------------------------------------------------------------------- 1 | # golangci-lint wrapper 2 | 3 | In order to make build tools runnable on any platform, this directory contains a thin wrapper that calls `golangci-lint run` without adding it to the package dependencies. You can run it by typing `go run github.com/opentofu/tofudl/internal/tools/lint` in the root directory. -------------------------------------------------------------------------------- /mirror_download.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | ) 9 | 10 | func (m *mirror) Download(ctx context.Context, opts ...DownloadOpt) ([]byte, error) { 11 | return download(ctx, opts, m.ListVersions, m.DownloadVersion) 12 | } 13 | -------------------------------------------------------------------------------- /branding/cli.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package branding 5 | 6 | // CLIEnvPrefix holds the prefix for all environment variables for the CLI. 7 | const CLIEnvPrefix = "TOFUDL_" 8 | 9 | // CLIBinaryName holds the name of the CLI binary. 10 | const CLIBinaryName = "tofudl" 11 | -------------------------------------------------------------------------------- /internal/tools/bundle-gpg-key/README.md: -------------------------------------------------------------------------------- 1 | # GPG key downloader 2 | 3 | This tool downloads the GPG key into [branding/gpg_key.go](../../branding/gpg_key.go). You can run it by calling `go generate ./...` in the root directory or by running `go run github.com/opentofu/tofudl/internal/tools/bundle-gpg-key` in the [branding](../../branding) directory. 4 | -------------------------------------------------------------------------------- /mirror_download_nightly.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | ) 10 | 11 | func (m *mirror) DownloadNightly(_ context.Context, _ ...DownloadOpt) ([]byte, error) { 12 | return nil, fmt.Errorf("downloading nightly builds is not supported through mirrors") 13 | } 14 | -------------------------------------------------------------------------------- /internal/tools/license-headers/README.md: -------------------------------------------------------------------------------- 1 | # License headers checker 2 | 3 | This tool checks all `.go` files to contain the right copyright headers and adds them if needed. You can run it to update the headers by running `go generate` or `go run github.com/opentofu/tofudl/internal/tools/license-headers` in the root directory. To run the tool in check-only mode for CI, use the `-check` option. 4 | -------------------------------------------------------------------------------- /mirror_download_version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | ) 9 | 10 | func (m *mirror) DownloadVersion(ctx context.Context, version VersionWithArtifacts, platform Platform, architecture Architecture) ([]byte, error) { 11 | return downloadVersion(ctx, version, platform, architecture, m.DownloadArtifact, m.VerifyArtifact) 12 | } 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/opentofu/tofudl 2 | 3 | go 1.22 4 | 5 | require github.com/ProtonMail/gopenpgp/v2 v2.7.5 6 | 7 | require ( 8 | github.com/ProtonMail/go-crypto v1.0.0 // indirect 9 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect 10 | github.com/cloudflare/circl v1.3.9 // indirect 11 | github.com/pkg/errors v0.9.1 // indirect 12 | golang.org/x/crypto v0.31.0 // indirect 13 | golang.org/x/sys v0.28.0 // indirect 14 | golang.org/x/text v0.21.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /mirror_verify_artifact.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | func (m *mirror) VerifyArtifact(artifactName string, artifactContents []byte, sumsFileContents []byte, signatureFileContent []byte) error { 7 | if m.pullThroughDownloader != nil { 8 | return m.pullThroughDownloader.VerifyArtifact(artifactName, artifactContents, sumsFileContents, signatureFileContent) 9 | } 10 | return verifyArtifact(m.keyRing, artifactName, artifactContents, sumsFileContents, signatureFileContent) 11 | } 12 | -------------------------------------------------------------------------------- /internal/tools/lint/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "errors" 8 | "log" 9 | "os" 10 | "os/exec" 11 | ) 12 | 13 | func main() { 14 | cmd := exec.Command("go", "run", "github.com/golangci/golangci-lint/cmd/golangci-lint@v1.59.1", "run") 15 | cmd.Stdout = os.Stdout 16 | cmd.Stderr = os.Stderr 17 | if err := cmd.Run(); err != nil { 18 | var exitError *exec.ExitError 19 | if errors.As(err, &exitError) { 20 | os.Exit(exitError.ExitCode()) 21 | } 22 | 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /downloader_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl_test 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/opentofu/tofudl" 11 | "github.com/opentofu/tofudl/mockmirror" 12 | ) 13 | 14 | func TestE2E(t *testing.T) { 15 | mirror := mockmirror.New(t) 16 | 17 | dl, err := tofudl.New( 18 | tofudl.ConfigGPGKey(mirror.GPGKey()), 19 | tofudl.ConfigAPIURL(mirror.APIURL()), 20 | tofudl.ConfigDownloadMirrorURLTemplate(mirror.DownloadMirrorURLTemplate()), 21 | ) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | binary, err := dl.Download(context.Background()) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | logTofuVersion(t, binary) 32 | } 33 | -------------------------------------------------------------------------------- /api.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/opentofu/tofudl/blob/main/tofudl.schema.json", 4 | "title": "TofuDL API", 5 | "description": "Schema information for TofuDL-compatible mirrors", 6 | "type": "object", 7 | "properties": { 8 | "versions": { 9 | "type": "array", 10 | "items": { 11 | "type": "object", 12 | "properties": { 13 | "id": { 14 | "type": "string", 15 | "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(|-alpha[0-9]+|-beta[0-9]+|-rc[0-9]+)$" 16 | }, 17 | "files": { 18 | "type": "array", 19 | "items": { 20 | "type": "string", 21 | "pattern": "^[a-zA-Z0-9._\\-]+$" 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | ) 12 | 13 | func (d *downloader) getRequest(ctx context.Context, url string, authorization string) (io.ReadCloser, error) { 14 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 15 | if err != nil { 16 | return nil, fmt.Errorf("failed to construct HTTP request (%w)", err) 17 | } 18 | if authorization != "" { 19 | req.Header.Set("Authorization", authorization) 20 | } 21 | resp, err := d.config.HTTPClient.Do(req) 22 | if err != nil { 23 | return nil, fmt.Errorf("request failed (%w)", err) 24 | } 25 | if resp.StatusCode != http.StatusOK { 26 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 27 | } 28 | return resp.Body, nil 29 | } 30 | -------------------------------------------------------------------------------- /nightly_id.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "fmt" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | type NightlyID string 13 | 14 | var nightlyIDRegex = regexp.MustCompile(`^\d{8}-[a-fA-F0-9]{10}$`) 15 | 16 | func newNightlyID(buildDate, hash string) (NightlyID, error) { 17 | nightlyID := NightlyID(fmt.Sprintf("%s-%s", buildDate, hash)) 18 | if err := nightlyID.Validate(); err != nil { 19 | return "", err 20 | } 21 | return nightlyID, nil 22 | } 23 | 24 | func (id NightlyID) Validate() error { 25 | if !nightlyIDRegex.MatchString(string(id)) { 26 | return &InvalidOptionsError{ 27 | fmt.Errorf("nightly build id %q does not match required format YYYYMMDD-XXXXXXXXXX", id), 28 | } 29 | } 30 | return nil 31 | } 32 | 33 | // GetDate returns date part of the nightly ID 34 | // It is assumed that the id is in correct format and validate is already called 35 | func (id NightlyID) GetDate() string { 36 | splits := strings.Split(string(id), "-") 37 | return splits[0] 38 | } 39 | -------------------------------------------------------------------------------- /mirror_storage.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "io" 8 | "time" 9 | ) 10 | 11 | // MirrorStorage is responsible for handling the low-level storage of caches. 12 | type MirrorStorage interface { 13 | // ReadAPIFile reads the API file cache and returns a reader for it. It also returns the time when the cached 14 | // response was written. It will return a CacheMissError if the API response is not cached. 15 | ReadAPIFile() (io.ReadCloser, time.Time, error) 16 | // StoreAPIFile stores the API file in the cache. 17 | StoreAPIFile(apiFile []byte) error 18 | 19 | // ReadArtifact reads a binary artifact from the cache for a specific version and returns a reader to it. 20 | // It also returns the time the artifact was stored as the second parameter. It will return a CacheMissError if 21 | // there is no such artifact in the cache. 22 | ReadArtifact(version Version, artifactName string) (io.ReadCloser, time.Time, error) 23 | // StoreArtifact stores a binary artifact in the cache for a specific version. 24 | StoreArtifact(version Version, artifactName string, contents []byte) error 25 | } 26 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - asasalint 5 | - asciicheck 6 | - bidichk 7 | - bodyclose 8 | - dupl 9 | - durationcheck 10 | - errcheck 11 | - errname 12 | - errorlint 13 | - exhaustive 14 | - exportloopref 15 | - forbidigo 16 | - gocheckcompilerdirectives 17 | - gochecknoinits 18 | - goconst 19 | - gocritic 20 | - goimports 21 | - gomoddirectives 22 | - gomodguard 23 | - goprintffuncname 24 | - gosec 25 | - gosimple 26 | - govet 27 | - ineffassign 28 | - loggercheck 29 | - makezero 30 | - mirror 31 | - musttag 32 | - nakedret 33 | - nestif 34 | - nilerr 35 | - nilnil 36 | - noctx 37 | - nolintlint 38 | - nonamedreturns 39 | - nosprintfhostport 40 | - predeclared 41 | - promlinter 42 | - reassign 43 | - revive 44 | - rowserrcheck 45 | - sqlclosecheck 46 | - staticcheck 47 | - stylecheck 48 | - tenv 49 | - testableexamples 50 | - tparallel 51 | - typecheck 52 | - unconvert 53 | - unparam 54 | - unused 55 | - usestdlibvars 56 | - wastedassign 57 | - whitespace 58 | -------------------------------------------------------------------------------- /mirror_prewarm.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | ) 10 | 11 | func (m *mirror) PreWarm(ctx context.Context, versionCount int, progress func(pct int8)) error { 12 | if m.storage == nil { 13 | return nil 14 | } 15 | 16 | versions, err := m.ListVersions(ctx) 17 | if err != nil { 18 | return err 19 | } 20 | if versionCount > 0 { 21 | versions = versions[:versionCount] 22 | } 23 | totalArtifacts := 0 24 | for _, version := range versions { 25 | totalArtifacts += len(version.Files) 26 | } 27 | downloadedArtifacts := 0 28 | for _, version := range versions { 29 | for _, artifact := range version.Files { 30 | _, err = m.DownloadArtifact(ctx, version, artifact) 31 | if err != nil { 32 | return fmt.Errorf("failed to download artifact %s for version %s (%w)", artifact, version.ID, err) 33 | } 34 | downloadedArtifacts++ 35 | if progress != nil { 36 | progress(int8(100 * float64(downloadedArtifacts) / float64(totalArtifacts))) 37 | } 38 | select { 39 | case <-ctx.Done(): 40 | return ctx.Err() 41 | default: 42 | } 43 | } 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /downloader_download_artifact.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "fmt" 10 | "io" 11 | "regexp" 12 | ) 13 | 14 | var artifactRe = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`) 15 | 16 | func (d *downloader) DownloadArtifact(ctx context.Context, version VersionWithArtifacts, artifactName string) ([]byte, error) { 17 | found := false 18 | for _, file := range version.Files { 19 | if file == artifactName { 20 | found = true 21 | } 22 | } 23 | if !found { 24 | return nil, &NoSuchArtifactError{ 25 | artifactName, 26 | } 27 | } 28 | 29 | if !artifactRe.MatchString(artifactName) { 30 | return nil, &InvalidOptionsError{ 31 | Cause: fmt.Errorf("invalid artifact name: " + artifactName), 32 | } 33 | } 34 | 35 | wr := &bytes.Buffer{} 36 | if err := d.downloadMirrorURLTemplate.Execute(wr, &MirrorURLTemplateParameters{ 37 | Version: version.ID, 38 | Artifact: artifactName, 39 | }); err != nil { 40 | return nil, &InvalidConfigurationError{ 41 | Message: "Failed to construct mirror URL", 42 | Cause: err, 43 | } 44 | } 45 | 46 | reader, err := d.getRequest(ctx, wr.String(), d.config.DownloadMirrorAuthorization) 47 | if err != nil { 48 | return nil, err 49 | } 50 | b, err := io.ReadAll(reader) 51 | if err != nil { 52 | return nil, &RequestFailedError{Cause: err} 53 | } 54 | return b, nil 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) The OpenTofu Authors 2 | # SPDX-License-Identifier: MPL-2.0 3 | name: Verify 4 | permissions: {} 5 | on: 6 | pull_request: 7 | push: 8 | jobs: 9 | generate: 10 | name: Go generate 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Setup go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version-file: 'go.mod' 19 | - name: Check license headers 20 | run: go run github.com/opentofu/tofudl/internal/tools/license-headers -check-only 21 | - name: Check GPG key 22 | working-directory: branding 23 | run: go run github.com/opentofu/tofudl/internal/tools/bundle-gpg-key -check-only 24 | lint: 25 | name: Lint 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Setup go 31 | uses: actions/setup-go@v5 32 | with: 33 | go-version-file: 'go.mod' 34 | - name: Lint 35 | run: go run github.com/opentofu/tofudl/internal/tools/lint 36 | tests: 37 | name: Tests 38 | strategy: 39 | matrix: 40 | os: [ubuntu, windows, macos] 41 | runs-on: ${{ matrix.os }}-latest 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | - name: Setup go 46 | uses: actions/setup-go@v5 47 | with: 48 | go-version-file: 'go.mod' 49 | - name: Run tests 50 | run: | 51 | go test ./... 52 | -------------------------------------------------------------------------------- /internal/helloworld/fake.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package helloworld 5 | 6 | import ( 7 | "errors" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "runtime" 12 | "testing" 13 | ) 14 | 15 | // Build creates a hello-world binary for the current platform you can use to test. 16 | func Build(t *testing.T) []byte { 17 | fakeName := "fake" 18 | if runtime.GOOS == "windows" { 19 | fakeName += ".exe" 20 | } 21 | 22 | dir := path.Join(os.TempDir(), fakeName) 23 | if err := os.MkdirAll(dir, 0700); err != nil { 24 | t.Fatal(err) 25 | } 26 | defer func() { 27 | _ = os.RemoveAll(dir) 28 | }() 29 | if err := os.WriteFile(path.Join(dir, "go.mod"), []byte(gomod), 0600); err != nil { 30 | t.Fatal(err) 31 | } 32 | if err := os.WriteFile(path.Join(dir, "main.go"), []byte(code), 0600); err != nil { 33 | t.Fatal() 34 | } 35 | 36 | cmd := exec.Command("go", "build", "-ldflags", "-s -w", "-o", fakeName) 37 | cmd.Stdout = os.Stdout 38 | cmd.Stderr = os.Stderr 39 | cmd.Dir = dir 40 | if err := cmd.Run(); err != nil { 41 | var exitErr *exec.ExitError 42 | if errors.As(err, &exitErr) && exitErr.ExitCode() != 0 { 43 | t.Fatalf("Build failed (exit code %d)", exitErr.ExitCode()) 44 | } else { 45 | t.Fatalf("Build failed (%v)", err) 46 | } 47 | } 48 | 49 | contents, err := os.ReadFile(path.Join(dir, fakeName)) 50 | if err != nil { 51 | t.Fatalf("Failed to read compiled fake (%v)", err) 52 | } 53 | return contents 54 | } 55 | 56 | var code = `package main 57 | 58 | func main() { 59 | print("Hello world!") 60 | } 61 | ` 62 | 63 | var gomod = `module fake 64 | 65 | go 1.21` 66 | -------------------------------------------------------------------------------- /mirror_create_version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | ) 12 | 13 | func (m *mirror) CreateVersion(_ context.Context, version Version) error { 14 | if m.pullThroughDownloader != nil { 15 | return fmt.Errorf("cannot use CreateVersionAsset when a pull-through mirror is configured") 16 | } 17 | if err := version.Validate(); err != nil { 18 | return err 19 | } 20 | 21 | responseData := APIResponse{} 22 | 23 | reader, _, err := m.storage.ReadAPIFile() 24 | if err != nil { 25 | var notFound *CacheMissError 26 | if !errors.As(err, ¬Found) { 27 | return fmt.Errorf("cannot read api.json from mirror storage (%w)", err) 28 | } 29 | } else { 30 | decoder := json.NewDecoder(reader) 31 | if err := decoder.Decode(&responseData); err != nil { 32 | return fmt.Errorf("api.json corrupt in mirror storage (%w)", err) 33 | } 34 | } 35 | 36 | for _, foundVersion := range responseData.Versions { 37 | if foundVersion.ID == version { 38 | return fmt.Errorf("version %s already exists", version) 39 | } 40 | } 41 | responseData.Versions = append([]VersionWithArtifacts{ 42 | { 43 | ID: version, 44 | Files: []string{}, 45 | }, 46 | }, responseData.Versions...) 47 | 48 | marshalled, err := json.Marshal(responseData) 49 | if err != nil { 50 | return fmt.Errorf("failed to re-encode api.json (%w)", err) 51 | } 52 | 53 | if err := m.storage.StoreAPIFile(marshalled); err != nil { 54 | return fmt.Errorf("failed to store api.json (%w)", err) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /mirror_create_version_asset.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | ) 11 | 12 | func (m *mirror) CreateVersionAsset(_ context.Context, version Version, assetName string, assetData []byte) error { 13 | if m.pullThroughDownloader != nil { 14 | return fmt.Errorf("cannot use CreateVersionAsset when a pull-through mirror is configured") 15 | } 16 | if err := version.Validate(); err != nil { 17 | return err 18 | } 19 | 20 | responseData := APIResponse{} 21 | 22 | reader, _, err := m.storage.ReadAPIFile() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | decoder := json.NewDecoder(reader) 28 | if err := decoder.Decode(&responseData); err != nil { 29 | return fmt.Errorf("api.json corrupt in mirror storage (%w)", err) 30 | } 31 | 32 | foundIndex := -1 33 | for i, foundVersion := range responseData.Versions { 34 | if foundVersion.ID == version { 35 | foundIndex = i 36 | } 37 | } 38 | if foundIndex == -1 { 39 | return fmt.Errorf("version does not exist: %s", version) 40 | } 41 | 42 | responseData.Versions[foundIndex].Files = append(responseData.Versions[foundIndex].Files, assetName) 43 | 44 | if err := m.storage.StoreArtifact(version, assetName, assetData); err != nil { 45 | return fmt.Errorf("cannot store asset %s (%w)", assetName, err) 46 | } 47 | 48 | marshalled, err := json.Marshal(responseData) 49 | if err != nil { 50 | return fmt.Errorf("failed to re-encode api.json (%w)", err) 51 | } 52 | 53 | if err := m.storage.StoreAPIFile(marshalled); err != nil { 54 | return fmt.Errorf("failed to store api.json (%w)", err) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /branding/branding.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package branding 5 | 6 | // ProductName describes the name of the product being downloaded. 7 | const ProductName = "OpenTofu" 8 | 9 | // DefaultDownloadAPIURL describes the API serving the version and file information. 10 | const DefaultDownloadAPIURL = "https://get.opentofu.org/tofu/api.json" 11 | 12 | // DefaultMirrorURLTemplate is a Go template that describes the download URL with the {{ .Version }} and {{ .Artifact }} 13 | // embedded into the URL. 14 | const DefaultMirrorURLTemplate = "https://github.com/opentofu/opentofu/releases/download/v{{ .Version }}/{{ .Artifact }}" 15 | 16 | // BinaryName holds the name of the binary in the artifact. This may be suffixed .exe on Windows. 17 | const BinaryName = "tofu" 18 | 19 | // ArtifactPrefix is the prefix for the artifact names. 20 | const ArtifactPrefix = "tofu_" 21 | 22 | // GPGKeyURL describes the URL to download the bundled GPG key from. The GPG key bundler uses this to download the 23 | // GPG key for verification. 24 | const GPGKeyURL = "https://get.opentofu.org/opentofu.asc" 25 | 26 | // GPGKeyFingerprint is the GPG key fingerprint the bundler should expect to find when downloading the key. 27 | const GPGKeyFingerprint = "E3E6E43D84CB852EADB0051D0C0AF313E5FD9F80" 28 | 29 | // SPDXAuthorsName describes the name of the authors to be attributed in copyright notices in this repository. 30 | const SPDXAuthorsName = "The OpenTofu Authors" 31 | 32 | // SPDXLicense describes the license for copyright attribution in this repository. 33 | const SPDXLicense = "MPL-2.0" 34 | 35 | // MaximumUncompressedFileSize indicates the maximum file size when uncompressed. 36 | const MaximumUncompressedFileSize = 1024 * 1024 * 1024 37 | -------------------------------------------------------------------------------- /mirror_download_artifact.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "time" 10 | ) 11 | 12 | func (m *mirror) DownloadArtifact(ctx context.Context, version VersionWithArtifacts, artifactName string) ([]byte, error) { 13 | if m.pullThroughDownloader == nil { 14 | return m.tryReadArtifactCache(m.storage, version.ID, artifactName, true) 15 | } 16 | 17 | if m.storage == nil || m.config.ArtifactCacheTimeout == 0 { 18 | return m.pullThroughDownloader.DownloadArtifact(ctx, version, artifactName) 19 | } 20 | 21 | cachedArtifact, err := m.tryReadArtifactCache(m.storage, version.ID, artifactName, false) 22 | if err == nil { 23 | return cachedArtifact, nil 24 | } 25 | 26 | artifact, onlineErr := m.pullThroughDownloader.DownloadArtifact(ctx, version, artifactName) 27 | if onlineErr == nil { 28 | _ = m.storage.StoreArtifact(version.ID, artifactName, artifact) 29 | return artifact, nil 30 | } 31 | 32 | cachedArtifact, err = m.tryReadArtifactCache(m.storage, version.ID, artifactName, true) 33 | if err == nil { 34 | return cachedArtifact, nil 35 | } 36 | return nil, onlineErr 37 | } 38 | 39 | func (m *mirror) tryReadArtifactCache(storage MirrorStorage, version Version, artifact string, allowStale bool) ([]byte, error) { 40 | cacheReader, storeTime, err := storage.ReadArtifact(version, artifact) 41 | if err != nil { 42 | return nil, err 43 | } 44 | defer func() { 45 | _ = cacheReader.Close() 46 | }() 47 | if !allowStale && m.config.ArtifactCacheTimeout > 0 && storeTime.Add(m.config.ArtifactCacheTimeout).Before(time.Now()) { 48 | return nil, &CachedArtifactStaleError{Version: version, Artifact: artifact} 49 | } 50 | return io.ReadAll(cacheReader) 51 | } 52 | -------------------------------------------------------------------------------- /mirror_list_versions.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "io" 10 | "time" 11 | ) 12 | 13 | func (m *mirror) ListVersions(ctx context.Context, opts ...ListVersionOpt) ([]VersionWithArtifacts, error) { 14 | if m.pullThroughDownloader == nil { 15 | return m.tryReadVersionCache(m.storage, opts, true) 16 | } 17 | if m.storage == nil || m.config.APICacheTimeout == 0 { 18 | return m.pullThroughDownloader.ListVersions(ctx, opts...) 19 | } 20 | 21 | // Fetch non-stale cached version: 22 | cachedVersions, err := m.tryReadVersionCache(m.storage, opts, false) 23 | if err == nil { 24 | return cachedVersions, nil 25 | } 26 | 27 | // Fetch online version: 28 | versions, onlineErr := m.pullThroughDownloader.ListVersions(ctx, opts...) 29 | if onlineErr == nil { 30 | marshalledVersions, err := json.Marshal(APIResponse{versions}) 31 | if err == nil { 32 | _ = m.storage.StoreAPIFile(marshalledVersions) 33 | } 34 | return versions, nil 35 | } 36 | 37 | // Fetch stale cached version: 38 | cachedVersions, err = m.tryReadVersionCache(m.storage, opts, true) 39 | if err == nil { 40 | return cachedVersions, nil 41 | } 42 | return nil, onlineErr 43 | } 44 | 45 | func (m *mirror) tryReadVersionCache(storage MirrorStorage, opts []ListVersionOpt, allowStale bool) ([]VersionWithArtifacts, error) { 46 | cacheReader, storeTime, err := storage.ReadAPIFile() 47 | if err != nil { 48 | return nil, err 49 | } 50 | defer func() { 51 | _ = cacheReader.Close() 52 | }() 53 | if !allowStale && m.config.ArtifactCacheTimeout > 0 && storeTime.Add(m.config.ArtifactCacheTimeout).Before(time.Now()) { 54 | return nil, &CachedAPIResponseStaleError{} 55 | } 56 | return fetchVersions(opts, func() (io.ReadCloser, error) { 57 | return cacheReader, nil 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /stability.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | // Stability describes the minimum stability to download. 11 | type Stability string 12 | 13 | const ( 14 | // StabilityAlpha accepts any stability. 15 | StabilityAlpha Stability = "alpha" 16 | // StabilityBeta accepts beta, release candidate and stable versions. 17 | StabilityBeta Stability = "beta" 18 | // StabilityRC accepts release candidate and stable versions. 19 | StabilityRC Stability = "rc" 20 | // StabilityStable accepts only stable versions. 21 | StabilityStable Stability = "" 22 | ) 23 | 24 | // AsInt returns a numeric representation of the stability for easier comparison. 25 | func (s Stability) AsInt() int { 26 | switch s { 27 | case StabilityStable: 28 | return 0 29 | case StabilityRC: 30 | return -1 31 | case StabilityBeta: 32 | return -2 33 | case StabilityAlpha: 34 | return -3 35 | default: 36 | panic(s.Validate()) 37 | } 38 | } 39 | 40 | // Matches returns true if the provided version matches the current stability or higher. 41 | func (s Stability) Matches(version Version) bool { 42 | return version.Stability().AsInt() >= s.AsInt() 43 | } 44 | 45 | // Validate returns an error if the stability is not one of the listed stabilities. 46 | func (s Stability) Validate() error { 47 | switch s { 48 | case StabilityStable: 49 | return nil 50 | case StabilityRC: 51 | return nil 52 | case StabilityBeta: 53 | return nil 54 | case StabilityAlpha: 55 | return nil 56 | default: 57 | return fmt.Errorf("invalid stability value: %s", s) 58 | } 59 | } 60 | 61 | // StabilityValues returns all supported values for Stability excluding StabilityStable. 62 | func StabilityValues() []Stability { 63 | return []Stability{ 64 | StabilityRC, 65 | StabilityBeta, 66 | StabilityAlpha, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /downloader_verify_artifact.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "crypto/sha256" 8 | "encoding/hex" 9 | "fmt" 10 | "strings" 11 | 12 | "github.com/ProtonMail/gopenpgp/v2/crypto" 13 | ) 14 | 15 | func (d *downloader) VerifyArtifact(artifactName string, artifactContents []byte, sumsFileContents []byte, signatureFileContent []byte) error { 16 | return verifyArtifact(d.keyRing, artifactName, artifactContents, sumsFileContents, signatureFileContent) 17 | } 18 | 19 | func verifyArtifact(keyRing *crypto.KeyRing, artifactName string, artifactContents []byte, sumsFileContents []byte, signatureFileContent []byte) error { 20 | if err := keyRing.VerifyDetached( 21 | crypto.NewPlainMessage(sumsFileContents), 22 | crypto.NewPGPSignature(signatureFileContent), 23 | crypto.GetUnixTime(), 24 | ); err != nil { 25 | return &SignatureError{ 26 | "Signature verification failed", 27 | err, 28 | } 29 | } 30 | 31 | return verifyArtifactSHAOnly(artifactName, artifactContents, sumsFileContents) 32 | } 33 | 34 | func verifyArtifactSHAOnly(artifactName string, artifactContents []byte, sumsFileContents []byte) error { 35 | hash := sha256.New() 36 | hash.Write(artifactContents) 37 | sum := hex.EncodeToString(hash.Sum(nil)) 38 | 39 | found := false 40 | for _, line := range strings.Split(string(sumsFileContents), "\n") { 41 | if strings.HasSuffix(strings.TrimSpace(line), " "+artifactName) { 42 | parts := strings.Split(strings.TrimSpace(line), " ") 43 | expectedSum := parts[0] 44 | found = true 45 | if expectedSum != sum { 46 | return &ArtifactCorruptedError{ 47 | artifactName, 48 | fmt.Errorf( 49 | "invalid checksum, expected %s found %s", 50 | expectedSum, 51 | sum, 52 | ), 53 | } 54 | } 55 | } 56 | } 57 | if !found { 58 | return &SignatureError{ 59 | Message: fmt.Sprintf( 60 | "No checksum found for artifact %s", 61 | artifactName, 62 | ), 63 | Cause: nil, 64 | } 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/tools/license-headers/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "flag" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/opentofu/tofudl/branding" 14 | ) 15 | 16 | func main() { 17 | header := `// Copyright (c) ` + branding.SPDXAuthorsName + ` 18 | // SPDX-License-Identifier: ` + branding.SPDXLicense + ` 19 | 20 | ` 21 | checkOnly := false 22 | flag.BoolVar(&checkOnly, "check-only", checkOnly, "Only check if the license headers are correct.") 23 | flag.Parse() 24 | 25 | var files []string 26 | if err := filepath.Walk(".", func(filePath string, info os.FileInfo, err error) error { 27 | if err == nil && strings.HasSuffix(info.Name(), ".go") { 28 | files = append(files, filePath) 29 | } 30 | return nil 31 | }); err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | hasError := false 36 | checkFailed := false 37 | for _, file := range files { 38 | fileContents, err := os.ReadFile(file) 39 | if err != nil { 40 | log.Printf("Failed to read file %s (%v)", file, err) 41 | hasError = true 42 | continue 43 | } 44 | if strings.HasPrefix(string(fileContents), header) { 45 | continue 46 | } 47 | if checkOnly { 48 | log.Printf("%s does not have the correct license headers.", file) 49 | checkFailed = true 50 | continue 51 | } 52 | log.Printf("Updating license headers in %s...", file) 53 | tempFile := file + "~" 54 | if err := os.WriteFile(tempFile, []byte(header+string(fileContents)), 0644); err != nil { //nolint:gosec //The permissions are ok here. 55 | log.Printf("Failed to write file %s (%v)", tempFile, err) 56 | hasError = true 57 | continue 58 | } 59 | if err := os.Rename(tempFile, file); err != nil { 60 | log.Printf("Failed to move temporary file %s to %s (%v)", tempFile, file, err) 61 | hasError = true 62 | } 63 | } 64 | if hasError { 65 | log.Fatal("One or more files have failed processing.") 66 | } 67 | if checkFailed { 68 | log.Fatalf("One or more files don't contain the correct license headers, please run go generate.") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /architecture.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "regexp" 8 | "runtime" 9 | ) 10 | 11 | // Architecture describes the architecture to download OpenTofu for. It defaults to the current system architecture. 12 | type Architecture string 13 | 14 | const ( 15 | // ArchitectureAuto is the default value and defaults to downloading OpenTofu for the current architecture. 16 | ArchitectureAuto Architecture = "" 17 | // Architecture386 describes the 32-bit Intel CPU architecture. 18 | Architecture386 Architecture = "386" 19 | // ArchitectureAMD64 describes the 64-bit Intel/AMD CPU architecture. 20 | ArchitectureAMD64 Architecture = "amd64" 21 | // ArchitectureARM describes the 32-bit ARM (v7) architecture. 22 | ArchitectureARM Architecture = "arm" 23 | // ArchitectureARM64 describes the 64-bit ARM (v8) architecture. 24 | ArchitectureARM64 Architecture = "arm64" 25 | ) 26 | 27 | var architectureRe = regexp.MustCompile("^[a-z0-9]*$") 28 | 29 | // Validate returns an error if the platform is not a valid platform descriptor. 30 | func (a Architecture) Validate() error { 31 | if !architectureRe.MatchString(string(a)) { 32 | return &InvalidArchitectureError{a} 33 | } 34 | return nil 35 | } 36 | 37 | // ResolveAuto resolves the value of ArchitectureAuto if needed based on the current runtime.GOARCH. 38 | func (a Architecture) ResolveAuto() (Architecture, error) { 39 | if a != ArchitectureAuto { 40 | return a, nil 41 | } 42 | switch runtime.GOARCH { 43 | case "386": 44 | return Architecture386, nil 45 | case "amd64": 46 | return ArchitectureAMD64, nil 47 | case "arm": 48 | return ArchitectureARM, nil 49 | case "arm64": 50 | return ArchitectureARM64, nil 51 | default: 52 | return ArchitectureAuto, UnsupportedArchitectureError{ 53 | Architecture(runtime.GOARCH), 54 | } 55 | } 56 | } 57 | 58 | // ArchitectureValues returns all supported values for Architecture excluding ArchitectureAuto. 59 | func ArchitectureValues() []Architecture { 60 | return []Architecture{ 61 | Architecture386, 62 | ArchitectureAMD64, 63 | ArchitectureARM, 64 | ArchitectureARM64, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /downloader_nightly_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl_test 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "os" 10 | "os/exec" 11 | "path" 12 | "runtime" 13 | "testing" 14 | 15 | "github.com/opentofu/tofudl" 16 | "github.com/opentofu/tofudl/branding" 17 | ) 18 | 19 | // Given a tofu binary, calls "version" subcommand and logs the output 20 | // This also calls runtime.GC() to cleanup any file handle still held after the test is done (THANKS WINDOWS!) 21 | func logTofuVersion(t *testing.T, binary []byte) { 22 | t.Helper() 23 | tmp := t.TempDir() 24 | fileName := branding.PlatformBinaryName 25 | fullPath := path.Join(tmp, fileName) 26 | if err := os.WriteFile(fullPath, binary, 0755); err != nil { //nolint:gosec //We want the binary to be executable. 27 | t.Fatal(err) 28 | } 29 | stdout := bytes.Buffer{} 30 | 31 | cmd := exec.Command(fullPath, "version") 32 | cmd.Stdout = &stdout 33 | cmd.Stderr = &stdout 34 | if err := cmd.Run(); err != nil { 35 | t.Fatal(err) 36 | } 37 | t.Log(stdout.String()) 38 | runtime.GC() 39 | } 40 | 41 | // Default options, no options passed in 42 | func TestNightlyDownload(t *testing.T) { 43 | if testing.Short() { 44 | t.Skip("Skipping nightly download test in short mode") 45 | } 46 | 47 | dl, err := tofudl.New() 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | binary, err := dl.DownloadNightly(context.Background()) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | if len(binary) == 0 { 58 | t.Fatal("Downloaded binary is empty") 59 | } 60 | 61 | logTofuVersion(t, binary) 62 | } 63 | 64 | // TestNightlyDownloadWithOptions tests downloading with specific platform/architecture 65 | // We are not testing specific build ID, since those are cleaned up often 66 | func TestNightlyDownloadWithHostOptions(t *testing.T) { 67 | if testing.Short() { 68 | t.Skip("Skipping nightly download test in short mode") 69 | } 70 | 71 | dl, err := tofudl.New() 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | 76 | // Test downloading for linux/amd64 specifically 77 | binary, err := dl.DownloadNightly( 78 | context.Background(), 79 | tofudl.DownloadOptPlatform(tofudl.PlatformLinux), 80 | tofudl.DownloadOptArchitecture(tofudl.ArchitectureAMD64), 81 | ) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | if len(binary) == 0 { 87 | t.Fatal("Downloaded binary is empty") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /platform.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "regexp" 8 | "runtime" 9 | ) 10 | 11 | // Platform describes the operating system to download OpenTofu for. Defaults to the current operating system. 12 | type Platform string 13 | 14 | const ( 15 | // PlatformAuto is the default value and describes the current operating system. 16 | PlatformAuto Platform = "" 17 | // PlatformWindows describes the Windows platform. 18 | PlatformWindows Platform = "windows" 19 | // PlatformLinux describes the Linux platform. 20 | PlatformLinux Platform = "linux" 21 | // PlatformMacOS describes the macOS (Darwin) platform. 22 | PlatformMacOS Platform = "darwin" 23 | // PlatformSolaris describes the Solaris platform. (Note: this is currently only supported on AMD64.) 24 | PlatformSolaris Platform = "solaris" 25 | // PlatformOpenBSD describes the OpenBSD platform. (Note: this is currently only supported on 386 and AMD64.) 26 | PlatformOpenBSD Platform = "openbsd" 27 | // PlatformFreeBSD describes the FreeBSD platform. (Note: this is currently not supported on ARM64) 28 | PlatformFreeBSD Platform = "freebsd" 29 | ) 30 | 31 | var platformRe = regexp.MustCompile("^[a-z]*$") 32 | 33 | // Validate returns an error if the platform is not a valid platform descriptor. 34 | func (p Platform) Validate() error { 35 | if !platformRe.MatchString(string(p)) { 36 | return &InvalidPlatformError{p} 37 | } 38 | return nil 39 | } 40 | 41 | // ResolveAuto resolves the value of PlatformAuto if needed based on the current runtime.GOOS. 42 | func (p Platform) ResolveAuto() (Platform, error) { 43 | if p != PlatformAuto { 44 | return p, nil 45 | } 46 | switch runtime.GOOS { 47 | case "windows": 48 | return PlatformWindows, nil 49 | case "linux": 50 | return PlatformLinux, nil 51 | case "darwin": 52 | return PlatformMacOS, nil 53 | case "solaris": 54 | return PlatformSolaris, nil 55 | case "openbsd": 56 | return PlatformOpenBSD, nil 57 | case "freebsd": 58 | return PlatformFreeBSD, nil 59 | default: 60 | return PlatformAuto, UnsupportedPlatformError{ 61 | Platform(runtime.GOOS), 62 | } 63 | } 64 | } 65 | 66 | // PlatformValues returns all supported values for Platform excluding PlatformAuto. 67 | func PlatformValues() []Platform { 68 | return []Platform{ 69 | PlatformWindows, 70 | PlatformLinux, 71 | PlatformMacOS, 72 | PlatformSolaris, 73 | PlatformOpenBSD, 74 | PlatformFreeBSD, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /downloader_list_versions.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "slices" 12 | ) 13 | 14 | // ListVersionsOptions are the options for listing versions. 15 | type ListVersionsOptions struct { 16 | Stability *Stability 17 | } 18 | 19 | // ListVersionOpt is an option for the ListVersions call. 20 | type ListVersionOpt func(options *ListVersionsOptions) error 21 | 22 | // ListVersionOptMinimumStability sets the minimum stability for listing versions. 23 | func ListVersionOptMinimumStability(stability Stability) ListVersionOpt { 24 | return func(options *ListVersionsOptions) error { 25 | options.Stability = &stability 26 | return nil 27 | } 28 | } 29 | 30 | // APIResponse is the JSON response from the API URL. 31 | type APIResponse struct { 32 | // Versions is the list of versions from the API. 33 | Versions []VersionWithArtifacts `json:"versions"` 34 | } 35 | 36 | func (d *downloader) ListVersions(ctx context.Context, opts ...ListVersionOpt) ([]VersionWithArtifacts, error) { 37 | fetchVersionsFile := func() (io.ReadCloser, error) { 38 | body, err := d.getRequest(ctx, d.config.APIURL, d.config.APIURLAuthorization) 39 | if err != nil { 40 | return nil, &RequestFailedError{ 41 | err, 42 | } 43 | } 44 | return body, nil 45 | } 46 | 47 | return fetchVersions(opts, fetchVersionsFile) 48 | } 49 | 50 | func fetchVersions(opts []ListVersionOpt, fetchVersionsFileFunc func() (io.ReadCloser, error)) ([]VersionWithArtifacts, error) { 51 | options := ListVersionsOptions{} 52 | for _, opt := range opts { 53 | if err := opt(&options); err != nil { 54 | return nil, &InvalidOptionsError{err} 55 | } 56 | } 57 | 58 | body, err := fetchVersionsFileFunc() 59 | if err != nil { 60 | return nil, err 61 | } 62 | defer func() { 63 | _ = body.Close() 64 | }() 65 | 66 | responseData := APIResponse{} 67 | decoder := json.NewDecoder(body) 68 | if err := decoder.Decode(&responseData); err != nil { 69 | return nil, &RequestFailedError{ 70 | fmt.Errorf("failed to decode JSON response from API endpoint (%w)", err), 71 | } 72 | } 73 | 74 | var versions []VersionWithArtifacts 75 | for _, version := range responseData.Versions { 76 | if options.Stability == nil || options.Stability.Matches(version.ID) { 77 | versions = append(versions, version) 78 | } 79 | } 80 | 81 | slices.SortStableFunc(versions, func(a, b VersionWithArtifacts) int { 82 | return -1 * a.ID.Compare(b.ID) 83 | }) 84 | 85 | return versions, nil 86 | } 87 | -------------------------------------------------------------------------------- /mirror_serve_http.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | func (m *mirror) ServeHTTP(writer http.ResponseWriter, request *http.Request) { 14 | ctx := context.Background() 15 | if request.RequestURI == "/api.json" { 16 | m.serveAPI(ctx, writer) 17 | return 18 | } 19 | m.serveAsset(ctx, writer, request) 20 | } 21 | 22 | func (m *mirror) serveAPI(ctx context.Context, writer http.ResponseWriter) { 23 | versionList, err := m.ListVersions(ctx) 24 | if err != nil { 25 | m.badGateway(writer) 26 | return 27 | } 28 | response := APIResponse{ 29 | Versions: versionList, 30 | } 31 | encoded, err := json.Marshal(response) 32 | if err != nil { 33 | m.badGateway(writer) 34 | return 35 | } 36 | writer.WriteHeader(http.StatusOK) 37 | writer.Header().Set("Content-Type", "application/json") 38 | _, _ = writer.Write(encoded) 39 | } 40 | 41 | func (m *mirror) serveAsset(ctx context.Context, writer http.ResponseWriter, request *http.Request) { 42 | if !strings.HasPrefix(request.RequestURI, "/") { 43 | m.badRequest(writer) 44 | return 45 | } 46 | parts := strings.Split(request.RequestURI, "/") 47 | if len(parts) != 3 { 48 | m.notFound(writer) 49 | return 50 | } 51 | version := Version(strings.TrimPrefix(parts[1], "v")) 52 | if err := version.Validate(); err != nil { 53 | m.notFound(writer) 54 | return 55 | } 56 | versions, err := m.ListVersions(ctx) 57 | if err != nil { 58 | m.badGateway(writer) 59 | return 60 | } 61 | var foundVersion *VersionWithArtifacts 62 | for _, ver := range versions { 63 | ver := ver 64 | if ver.ID == version { 65 | foundVersion = &ver 66 | break 67 | } 68 | } 69 | if foundVersion == nil { 70 | m.notFound(writer) 71 | return 72 | } 73 | // TODO implement stream reading 74 | contents, err := m.DownloadArtifact(ctx, *foundVersion, parts[2]) 75 | if err != nil { 76 | m.badGateway(writer) 77 | return 78 | } 79 | writer.WriteHeader(http.StatusOK) 80 | writer.Header().Set("Content-Type", "application/octet-stream") 81 | _, _ = writer.Write(contents) 82 | } 83 | 84 | func (m *mirror) badRequest(writer http.ResponseWriter) { 85 | writer.WriteHeader(http.StatusBadRequest) 86 | writer.Header().Set("Content-Type", "text/html") 87 | _, _ = writer.Write([]byte("

Bad request

")) 88 | } 89 | 90 | func (m *mirror) badGateway(writer http.ResponseWriter) { 91 | writer.WriteHeader(http.StatusBadGateway) 92 | writer.Header().Set("Content-Type", "text/html") 93 | _, _ = writer.Write([]byte("

Bad gateway

")) 94 | } 95 | 96 | func (m *mirror) notFound(writer http.ResponseWriter) { 97 | writer.WriteHeader(http.StatusNotFound) 98 | writer.Header().Set("Content-Type", "text/html") 99 | _, _ = writer.Write([]byte("

Bad gateway

")) 100 | } 101 | -------------------------------------------------------------------------------- /downloader.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "text/template" 9 | 10 | "github.com/ProtonMail/gopenpgp/v2/crypto" 11 | ) 12 | 13 | // Downloader describes the functions the downloader provides. 14 | type Downloader interface { 15 | // ListVersions lists all versions matching the filter options in descending order. 16 | ListVersions(ctx context.Context, opts ...ListVersionOpt) ([]VersionWithArtifacts, error) 17 | 18 | // DownloadArtifact downloads an artifact for a version. 19 | DownloadArtifact(ctx context.Context, version VersionWithArtifacts, artifactName string) ([]byte, error) 20 | 21 | // VerifyArtifact verifies a named artifact against a checksum file with SHA256 hashes and the checksum file against a GPG signature file. 22 | VerifyArtifact(artifactName string, artifactContents []byte, sumsFileContents []byte, signatureFileContent []byte) error 23 | 24 | // DownloadVersion downloads the OpenTofu binary from a specific artifact obtained from ListVersions. 25 | DownloadVersion(ctx context.Context, version VersionWithArtifacts, platform Platform, architecture Architecture) ([]byte, error) 26 | 27 | // Download downloads the OpenTofu binary and provides it as a byte slice. 28 | Download(ctx context.Context, opts ...DownloadOpt) ([]byte, error) 29 | 30 | // DownloadNightly downloads the latest nightly build of OpenTofu from the R2 bucket. 31 | DownloadNightly(ctx context.Context, opts ...DownloadOpt) ([]byte, error) 32 | } 33 | 34 | func New(opts ...ConfigOpt) (Downloader, error) { 35 | cfg := Config{} 36 | for _, opt := range opts { 37 | if err := opt(&cfg); err != nil { 38 | return nil, err 39 | } 40 | } 41 | cfg.ApplyDefaults() 42 | 43 | tpl := template.New("url") 44 | tpl, err := tpl.Parse(cfg.DownloadMirrorURLTemplate) 45 | if err != nil { 46 | return nil, &InvalidConfigurationError{ 47 | Message: "Cannot parse download mirror URL template", 48 | Cause: err, 49 | } 50 | } 51 | 52 | keyRing, err := createKeyRing(cfg.GPGKey) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return &downloader{ 58 | cfg, 59 | tpl, 60 | keyRing, 61 | }, nil 62 | } 63 | 64 | func createKeyRing(armoredKey string) (*crypto.KeyRing, error) { 65 | key, err := crypto.NewKeyFromArmored(armoredKey) 66 | if err != nil { 67 | return nil, &InvalidConfigurationError{ 68 | Message: "Failed to decode GPG key", 69 | Cause: err, 70 | } 71 | } 72 | if !key.CanVerify() { 73 | return nil, &InvalidConfigurationError{Message: "The provided key cannot be used for verification."} 74 | } 75 | 76 | keyRing, err := crypto.NewKeyRing(key) 77 | if err != nil { 78 | return nil, &InvalidConfigurationError{Message: "Cannot create keyring", Cause: err} 79 | } 80 | return keyRing, nil 81 | } 82 | 83 | type downloader struct { 84 | config Config 85 | downloadMirrorURLTemplate *template.Template 86 | keyRing *crypto.KeyRing 87 | } 88 | -------------------------------------------------------------------------------- /mockmirror/mockmirror.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package mockmirror 5 | 6 | import ( 7 | "context" 8 | "net" 9 | "net/http" 10 | "strconv" 11 | "testing" 12 | "time" 13 | 14 | "github.com/ProtonMail/gopenpgp/v2/crypto" 15 | "github.com/opentofu/tofudl" 16 | "github.com/opentofu/tofudl/branding" 17 | "github.com/opentofu/tofudl/internal/helloworld" 18 | ) 19 | 20 | // New returns a mirror serving a fake archive signed with a GPG key for testing purposes. 21 | func New( 22 | t *testing.T, 23 | ) Mirror { 24 | return NewFromBinary(t, helloworld.Build(t)) 25 | } 26 | 27 | // NewFromBinary returns a mirror serving a binary passed and signed with a GPG key for testing purposes. 28 | func NewFromBinary( 29 | t *testing.T, 30 | binary []byte, 31 | ) Mirror { 32 | key, err := crypto.GenerateKey(branding.ProductName+" Test", "noreply@example.org", "rsa", 2048) 33 | if err != nil { 34 | panic(err) 35 | } 36 | pubKey, err := key.GetArmoredPublicKey() 37 | if err != nil { 38 | t.Fatalf("Failed to get public key (%v)", err) 39 | } 40 | 41 | builder, err := tofudl.NewReleaseBuilder(key) 42 | if err != nil { 43 | t.Fatalf("Failed to create release builder (%v)", err) 44 | } 45 | if err := builder.PackageBinary(tofudl.PlatformAuto, tofudl.ArchitectureAuto, binary, map[string][]byte{}); err != nil { 46 | t.Fatalf("Failed to package binary (%v)", err) 47 | } 48 | 49 | storage, err := tofudl.NewFilesystemStorage(t.TempDir()) 50 | if err != nil { 51 | t.Fatalf("Failed to create storage (%v)", err) 52 | } 53 | 54 | tofudlMirror, err := tofudl.NewMirror(tofudl.MirrorConfig{}, storage, nil) 55 | if err != nil { 56 | t.Fatalf("Failed to create mirror (%v)", err) 57 | } 58 | 59 | if err := builder.Build(context.Background(), "1.0.0", tofudlMirror); err != nil { 60 | t.Fatalf("Failed to build release (%v)", err) 61 | } 62 | 63 | ln, err := net.Listen("tcp", "127.0.0.1:0") 64 | if err != nil { 65 | t.Fatalf("Failed to open listen socket for mock mirror (%v)", err) 66 | } 67 | srv := &http.Server{ 68 | ReadTimeout: 5 * time.Second, 69 | WriteTimeout: 10 * time.Second, 70 | Handler: tofudlMirror, 71 | } 72 | go func() { 73 | _ = srv.Serve(ln) 74 | }() 75 | t.Cleanup(func() { 76 | _ = srv.Shutdown(context.Background()) 77 | _ = ln.Close() 78 | }) 79 | return &mirror{ 80 | addr: ln.Addr().(*net.TCPAddr), 81 | pubKey: pubKey, 82 | } 83 | } 84 | 85 | // Mirror is a mock mirror for testing purposes holding a single version with a single binary for the current platform 86 | // and architecture. 87 | type Mirror interface { 88 | GPGKey() string 89 | APIURL() string 90 | DownloadMirrorURLTemplate() string 91 | } 92 | 93 | type mirror struct { 94 | addr *net.TCPAddr 95 | pubKey string 96 | } 97 | 98 | func (m mirror) GPGKey() string { 99 | return m.pubKey 100 | } 101 | 102 | func (m mirror) APIURL() string { 103 | return "http://127.0.0.1:" + strconv.Itoa(m.addr.Port) + "/api.json" 104 | } 105 | 106 | func (m mirror) DownloadMirrorURLTemplate() string { 107 | return "http://127.0.0.1:" + strconv.Itoa(m.addr.Port) + "/v{{ .Version }}/{{ .Artifact }}" 108 | } 109 | -------------------------------------------------------------------------------- /mirror_storage_filesystem.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "os" 10 | "path" 11 | "time" 12 | ) 13 | 14 | // NewFilesystemStorage returns a mirror storage that relies on files and modification timestamps. This storage 15 | // can also be used to mirror the artifacts for air-gapped usage. The filesystem layout is as follows: 16 | // 17 | // - api.json 18 | // - v1.2.3/artifact.name 19 | // 20 | // Note: when used as a pull-through cache, the underlying filesystem must support modification timestamps or the 21 | // cache timeout must be set to -1 to prevent the mirror from re-fetching every time. 22 | func NewFilesystemStorage(directory string) (MirrorStorage, error) { 23 | if err := os.MkdirAll(directory, 0755); err != nil { 24 | return nil, fmt.Errorf("failed to create cache directory %s (%w)", directory, err) 25 | } 26 | 27 | return &filesystemStorage{ 28 | directory, 29 | }, nil 30 | } 31 | 32 | type filesystemStorage struct { 33 | directory string 34 | } 35 | 36 | func (c filesystemStorage) ReadAPIFile() (io.ReadCloser, time.Time, error) { 37 | apiFile := c.getAPIFileName() 38 | return c.readCacheFile(apiFile) 39 | } 40 | 41 | func (c filesystemStorage) readCacheFile(cacheFile string) (io.ReadCloser, time.Time, error) { 42 | if c.directory == "" { 43 | return nil, time.Time{}, &CacheMissError{cacheFile, nil} 44 | } 45 | 46 | stat, err := os.Stat(cacheFile) 47 | if err != nil { 48 | if os.IsNotExist(err) { 49 | return nil, time.Time{}, &CacheMissError{cacheFile, err} 50 | } 51 | return nil, time.Time{}, err 52 | } 53 | fh, err := os.OpenFile(cacheFile, os.O_RDONLY, 0644) 54 | if err != nil { 55 | return nil, stat.ModTime(), &CacheMissError{cacheFile, err} 56 | } 57 | return fh, stat.ModTime(), nil 58 | } 59 | 60 | func (c filesystemStorage) getAPIFileName() string { 61 | apiFile := path.Join(c.directory, "api.json") 62 | return apiFile 63 | } 64 | 65 | func (c filesystemStorage) StoreAPIFile(data []byte) error { 66 | apiFile := c.getAPIFileName() 67 | return os.WriteFile(apiFile, data, 0644) //nolint:gosec //This is not sensitive 68 | } 69 | 70 | func (c filesystemStorage) ReadArtifact(version Version, artifact string) (io.ReadCloser, time.Time, error) { 71 | cacheFile := c.getArtifactCacheFileName(c.getArtifactCacheDirectory(version), artifact) 72 | return c.readCacheFile(cacheFile) 73 | } 74 | 75 | func (c filesystemStorage) StoreArtifact(version Version, artifact string, contents []byte) error { 76 | cacheDirectory := c.getArtifactCacheDirectory(version) 77 | if err := os.MkdirAll(cacheDirectory, 0755); err != nil { 78 | return fmt.Errorf("failed to create cache directory %s (%w)", cacheDirectory, err) 79 | } 80 | cacheFile := c.getArtifactCacheFileName(cacheDirectory, artifact) 81 | if err := os.WriteFile(cacheFile, contents, 0644); err != nil { //nolint:gosec // This is not sensitive 82 | return fmt.Errorf("failed to write cache file %s (%w)", cacheFile, err) 83 | } 84 | return nil 85 | } 86 | 87 | func (c filesystemStorage) getArtifactCacheFileName(cacheDirectory string, artifact string) string { 88 | return path.Join(cacheDirectory, artifact) 89 | } 90 | 91 | func (c filesystemStorage) getArtifactCacheDirectory(version Version) string { 92 | cacheDirectory := path.Join(c.directory, "v"+string(version)) 93 | return cacheDirectory 94 | } 95 | -------------------------------------------------------------------------------- /mirror.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/ProtonMail/gopenpgp/v2/crypto" 13 | "github.com/opentofu/tofudl/branding" 14 | ) 15 | 16 | // NewMirror creates a new mirror, optionally acting as a pull-through cache when passing a pullThroughDownloader. 17 | func NewMirror(config MirrorConfig, storage MirrorStorage, pullThroughDownloader Downloader) (Mirror, error) { 18 | if storage == nil && pullThroughDownloader == nil { 19 | return nil, fmt.Errorf( 20 | "no storage and no pull-through downloader passed to NewMirror, cannot create a working mirror", 21 | ) 22 | } 23 | if config.GPGKey == "" { 24 | config.GPGKey = branding.DefaultGPGKey 25 | } 26 | 27 | keyRing, err := createKeyRing(config.GPGKey) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return &mirror{ 33 | storage, 34 | pullThroughDownloader, 35 | config, 36 | keyRing, 37 | }, nil 38 | } 39 | 40 | // Mirror is a downloader that caches artifacts. It also supports pre-warming caches by calling the 41 | // PreWarm function. You can use this as a handler for an HTTP server in order to act as a mirror to a regular 42 | // Downloader. 43 | type Mirror interface { 44 | Downloader 45 | http.Handler 46 | 47 | // PreWarm downloads the last specified number of versions into the storage directory from the pull-through 48 | // downloader if present. If versions is negative, all versions are downloaded. Note: the versions include alpha, 49 | // beta and release candidate versions. Make sure you pre-warm with enough versions for your use case. 50 | // 51 | // If no pull-through downloader is configured, this function does not do anything. 52 | PreWarm(ctx context.Context, versionCount int, progress func(pct int8)) error 53 | 54 | // CreateVersion creates a new version in the cache, adding it to the version index. Note that this is not supported 55 | // when working in pull-through cache mode. 56 | CreateVersion(ctx context.Context, version Version) error 57 | 58 | // CreateVersionAsset creates a new asset for a version, storing it in the storage and adding it to the version 59 | // list. Note that this is not supported when working in pull-through cache mode. 60 | CreateVersionAsset(ctx context.Context, version Version, assetName string, assetData []byte) error 61 | } 62 | 63 | // MirrorConfig is the configuration structure for the caching downloader. 64 | type MirrorConfig struct { 65 | // AllowStale enables using stale cached resources if the download fails. 66 | AllowStale bool `json:"allow_stale"` 67 | // APICacheTimeout is the time the cached API JSON should be considered valid. A duration of 0 means the API 68 | // responses should not be cached. A duration of -1 means the API responses should be cached indefinitely. 69 | APICacheTimeout time.Duration `json:"api_cache_timeout"` 70 | // ArtifactCacheTimeout is the time the cached artifacts should be considered valid. A duration of 0 means that 71 | // artifacts should not be cached. A duration of -1 means that artifacts should be cached indefinitely. 72 | ArtifactCacheTimeout time.Duration `json:"artifact_cache_timeout"` 73 | 74 | // GPGKey is the ASCII-armored key to verify downloaded artifacts against. This is only needed in standalone mode. 75 | GPGKey string `json:"gpg_key"` 76 | } 77 | 78 | type mirror struct { 79 | storage MirrorStorage 80 | pullThroughDownloader Downloader 81 | config MirrorConfig 82 | keyRing *crypto.KeyRing 83 | } 84 | -------------------------------------------------------------------------------- /branding/gpg_key.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // Code generated by tools/bundle-gpg-key/main.go. DO NOT EDIT. 5 | 6 | package branding 7 | 8 | //go:generate go run github.com/opentofu/tofudl/internal/tools/bundle-gpg-key -file gpg_key.go -url https://get.opentofu.org/opentofu.asc 9 | 10 | // DefaultGPGKey holds the default GPG key bundled with the downloader. This key was downloaded from 11 | // https://get.opentofu.org/opentofu.asc 12 | const DefaultGPGKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- 13 | 14 | xsFNBGVUyIwBEADPg6jUJm5liMTiDndyprnwXQ23GdyQm/kW9MFOhYDRksmmbsz0 15 | DCfqntFpuoKxPXzA+JTrZlWZONtU+leZjIOlAVZiz0rwz5EJq7uIrkueWtUk6AYk 16 | BLN+zMtbui0z3HCPVNnR5BlVNyXQeW3jlrQtzuKevjZWzI0gbQGgEKNpj+lfyRFu 17 | 6q3u/T0o3p/6bOOlQHwCMtnFlWpjr6f/J2EdUVO/6NYHQzImPj4LINXF/+eqo7v6 18 | svFtaVTtREG2V2V7We7bu/cJ+NgJYH7ro7UhB1RQH2k09NdpSCt9F60PVERnORpx 19 | GBkM/VKZzgMSzRvdpxUWwrLxfAxinu5ddbBm3y0bzaU80OT3i1qrWIqW73fmdGHQ 20 | 71gbJxRrroyLMWehjcJ/9WJDxkHqsfPKqBifYsp6/J9npczDfSU+zYBVGpR73a4E 21 | dbeIRWqwbH0LWhlbi1IM5aFDaZMFNkY+AWyP+OHn8Kehu6DOIh1AVM7v7vLxaX9h 22 | t1jVJbswjvPFYquv1DvUdc7VP2QHz3xctQS1GZJQ1ekcgTv9rRYXUOOwknInjtkM 23 | 9kQDtyBkVLcEc8ha3Cfh6PJscIP5VHwaNMgAPr9tsl3xqdz56l5UPjFSFuel98jS 24 | Bqn83VrT0uKwM0PnDVHd/7q8+Dg1EtOggMwZ830KORFNdjfv6ydsBvl7fwARAQAB 25 | zUpPcGVuVG9mdSAoVGhpcyBrZXkgaXMgdXNlZCB0byBzaWduIG9wZW50b2Z1IHBy 26 | b3ZpZGVycykgPGNvcmVAb3BlbnRvZnUub3JnPsLBjAQTAQgAQQUCZVTIjAkQDArz 27 | E+X9n4AWIQTj5uQ9hMuFLq2wBR0MCvMT5f2fgAIbAwIeAQIZAQMLCQcCFQgDFgAC 28 | BScJAgcCAABwAg/1HZnTvPHZDWf5OluYOaQ7ADX/oyjUO85VNUmKhmBZkLr5mTqr 29 | LO72k9fg+101hbggbhtK431z3Ca6ZqDAG/3DBi0BC1ag0rw83TEApkPGYnfX1DWS 30 | 1ZvyH1PkV0aqCkXAtMrte2PlUiieaKAsiYOIXqfZwszd07gch14wxMOw1B6Au/Xz 31 | Nrv2omnWSgGIyR6WOsG4QQ8R5AMVz3K8Ftzl6520wBgtr3osA3uM/xconnGVukMn 32 | 9NLQqKx5oeaJwONZpyZL5bg2ke9MVZM2+bG30UGZKoxrzOtQ//OTOYlhPCqm1ffR 33 | hYrUytwsWzDnJvXJF1QhnDu8whP3tSrcHyKxYZ9xUNzeu2AmjYfvkKHSdK2DFmOf 34 | DafaRs3c1VYnC7J7aRi6kVF/t+vWeOEVpPylyK7vSbPFc6XVoQrsE07hbN/BjWjm 35 | s8voK5U6oJRgEugXtSQKFypfOq8R99nXwbMHdhqY8aGyOCj++cuvRCUBDZAQqPEW 36 | AuD0X7+9Trnfin47MK+n18wsTAL4w6PJhtCrwK4e0cVuQ5u4M/PMid5W6hEA27PX 37 | x506Jpe8iRmcIP/cCR6pvhgOUMC36bIkAqZ5dJ545kDQju0lf8gLdVIQpig45udn 38 | ZM2KgyApGqhsS7yCUrbLDrtNmQ31TSYdKc8IU+/jXkfy2RYbZ+wNgfloKM7BTQRl 39 | VMiMARAAwRZUyMIc5TNbcFg3WGKxhaNC9hDZ4zBfXlb5jONzZOx3rDi2lD4UQOH+ 40 | NpG7CF98co//kryS/4AsDdp2jzhh+VMgyx6KJIhSkBP6kqhriy9eWRmgfrnLbUf4 41 | 6kkTkzLVkjYnMNeyHt+mi9I7EKtsDuF/EvjlwF5E81+DEOteCO/un/Qt1q3e1Slf 42 | vTpLkPvr1FiQ3VqzaBeBBI3MAMb/ycwL6hQE1l4Lg34T43Zu+9zkE1uzvjeNIlIW 43 | ucjB4q1htEjJl2CLAv+8cGHdmCcV2ZO3WM8M9Omq1CE7jhak4NE/YuGylJYCBd+B 44 | S7tuDPDu6+o4Nx+axxcwMvgyfr07FteEr1Lopaw2ci8b/xzQie/gkI0CByQMwD5V 45 | gnJpiMBnjP4d6UF6HEVldCQ7a3T1T80bKj5JjtFbR9P85Qntuheqn3Pge89YexMc 46 | E/00VA3blrj+GeYpO9ZGFu7DR/x4sjnTEhfjXEoLv1C4AdgGHCIjW9wU6HkcWnla 47 | X7akKlwIWEUP/BFLkcWPpmUrtClhWx9wq1GHFvKAN/qp//VWnv4IfRU6RjmVPOWB 48 | efvTu/cpsfBHLyp15goOYPboahIdTUTNQIXh4Vid7E1NoKnWZUMu50n3/zAbjSds 49 | mNmifi4g01MYJ3TVoU2Q01P7NiD3IRmaw72nLmf9cM9/7QMdGn0AEQEAAcLBdgQY 50 | AQgAKgUCZVTIjAkQDArzE+X9n4AWIQTj5uQ9hMuFLq2wBR0MCvMT5f2fgAIbDAAA 51 | SUoP/2ExsUoGbxjuZ76QUnYtfzDoz+o218UWd3gZCsBQ6/hGam5kMq+EUEabF3lV 52 | 7QLDyn/1v5sqrkmYg0u5cfjtY3oimCPvr6E0WTuqMIwYl0fdlkmdNttDpMqvCazq 53 | bzLK5dDVWbh/EYTiEN1xKXM6rlAquYv8I16uWL8QHanMb6yexNmDYhC4fXWqCi+s 54 | 5sXxWrPrd+fGz8CR/fEYahPXj8uY6dwN9DlWyek9QtKW2PsqrkBn5vCOm2IyZW6d 55 | t/Kn70tYtxMxJND2otk47mpG/Fv3sYK2bTGJ+k/5+E5IrjWqIX2lVB3G1+TCoZ5s 56 | cc16zls32mOlRh81fTAqcwkDFxICxcOeNHGLt3N+UvoPSUafYKD96rn5mWFao4xb 57 | cFniaYv2PdqH8HDjvXZXqHypRMXvYMbXXOgydLL+tSUSBpMTd4afjq8x2gNSWOEL 58 | I1jT5FWbKTKan0ycKi37bSqGHhDjlg4HRGvC3IK0EuVjdX3r+8uIVgFbqLwNhXk4 59 | GAIL03vl689TQ7/oPW75XCQIevFai0kcJPl6qIRvi9/S/v5EPRy9UDCGY/MPmc5f 60 | H1an0ebU4I4TlYfBoEUkYYqBDxvxWW0I/Q01rDebcd6mrGw8lW1EiNZlClLwx9Bv 61 | /+MNnIT9m1f8KeqmweoAgbIQRUI7EkJSzxYN4DNuy2XoKmF9 62 | =VhyH 63 | -----END PGP PUBLIC KEY BLOCK-----` 64 | -------------------------------------------------------------------------------- /mirror_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl_test 5 | 6 | import ( 7 | "context" 8 | "runtime" 9 | "testing" 10 | "time" 11 | 12 | "github.com/ProtonMail/gopenpgp/v2/crypto" 13 | "github.com/opentofu/tofudl" 14 | "github.com/opentofu/tofudl/branding" 15 | "github.com/opentofu/tofudl/internal/helloworld" 16 | "github.com/opentofu/tofudl/mockmirror" 17 | ) 18 | 19 | func TestMirroringE2E(t *testing.T) { 20 | mirror := mockmirror.New(t) 21 | 22 | dl1, err := tofudl.New( 23 | tofudl.ConfigGPGKey(mirror.GPGKey()), 24 | tofudl.ConfigAPIURL(mirror.APIURL()), 25 | tofudl.ConfigDownloadMirrorURLTemplate(mirror.DownloadMirrorURLTemplate()), 26 | ) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | cacheDir := t.TempDir() 32 | storage, err := tofudl.NewFilesystemStorage(cacheDir) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | cache1, err := tofudl.NewMirror( 38 | tofudl.MirrorConfig{ 39 | AllowStale: false, 40 | APICacheTimeout: time.Minute * 30, 41 | ArtifactCacheTimeout: time.Minute * 30, 42 | }, 43 | storage, 44 | dl1, 45 | ) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | ctx := context.Background() 51 | 52 | t.Logf("Pre-warming caches...") 53 | if err := cache1.PreWarm(ctx, 1, func(pct int8) { 54 | t.Logf("Pre-warming caches %d%% complete.", pct) 55 | }); err != nil { 56 | t.Fatal(err) 57 | } 58 | t.Logf("Pre-warming caches complete.") 59 | 60 | // Configure an invalid API and mirror URL and see if the cache works. 61 | dl2, err := tofudl.New( 62 | tofudl.ConfigAPIURL("http://127.0.0.1:9999/"), 63 | tofudl.ConfigDownloadMirrorURLTemplate("http://127.0.0.1:9999/{{ .Version }}/{{ .Artifact }}"), 64 | tofudl.ConfigGPGKey(mirror.GPGKey()), 65 | ) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | cache2, err := tofudl.NewMirror( 71 | tofudl.MirrorConfig{ 72 | AllowStale: false, 73 | APICacheTimeout: -1, 74 | ArtifactCacheTimeout: -1, 75 | }, 76 | storage, 77 | dl2, 78 | ) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | 83 | versions, err := cache2.ListVersions(ctx) 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | 88 | lastVersion := versions[0] 89 | 90 | binary, err := cache2.DownloadVersion(ctx, lastVersion, "", "") 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | 95 | if len(binary) == 0 { 96 | t.Fatal("Empty artifact!") 97 | } 98 | } 99 | 100 | func TestMirrorStandalone(t *testing.T) { 101 | binaryContents := helloworld.Build(t) 102 | 103 | ctx := context.Background() 104 | key, err := crypto.GenerateKey(branding.ProductName+" Test", "noreply@example.org", "rsa", 2048) 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | pubKey, err := key.GetArmoredPublicKey() 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | builder, err := tofudl.NewReleaseBuilder(key) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | if err := builder.PackageBinary(tofudl.PlatformAuto, tofudl.ArchitectureAuto, binaryContents, nil); err != nil { 117 | t.Fatalf("failed to package binary (%v)", err) 118 | } 119 | 120 | mirrorStorage, err := tofudl.NewFilesystemStorage(t.TempDir()) 121 | if err != nil { 122 | t.Fatalf("failed to set up TofuDL mirror") 123 | } 124 | downloader, err := tofudl.NewMirror( 125 | tofudl.MirrorConfig{ 126 | GPGKey: pubKey, 127 | }, 128 | mirrorStorage, 129 | nil, 130 | ) 131 | if err != nil { 132 | t.Fatal(err) 133 | } 134 | if err := builder.Build(ctx, "1.9.0", downloader); err != nil { 135 | t.Fatal(err) 136 | } 137 | _, err = downloader.Download(ctx) 138 | if err != nil { 139 | t.Fatal(err) 140 | } 141 | // Make sure all file handles are closed. 142 | if runtime.GOOS == "windows" { 143 | runtime.GC() 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "fmt" 8 | "regexp" 9 | "strconv" 10 | ) 11 | 12 | // VersionWithArtifacts is a version and the list of artifacts belonging to that version. 13 | type VersionWithArtifacts struct { 14 | ID Version `json:"id"` 15 | Files []string `json:"files"` 16 | } 17 | 18 | // Version describes a version number with this project's version and stability understanding. 19 | type Version string 20 | 21 | var versionRe = regexp.MustCompile(`^(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)(|-(?Palpha|beta|rc)(?P[0-9]+))$`) 22 | 23 | // Validate checks if the version is valid 24 | func (v Version) Validate() error { 25 | if !versionRe.MatchString(string(v)) { 26 | return &InvalidVersionError{v} 27 | } 28 | return nil 29 | } 30 | 31 | // Major returns the major version. The version must be valid or this function will panic. 32 | func (v Version) Major() int { 33 | return v.parse().major 34 | } 35 | 36 | // Minor returns the minor version. The version must be valid or this function will panic. 37 | func (v Version) Minor() int { 38 | return v.parse().minor 39 | } 40 | 41 | // Patch returns the patch version. The version must be valid or this function will panic. 42 | func (v Version) Patch() int { 43 | return v.parse().patch 44 | } 45 | 46 | // Stability returns the stability string for the version. The version must be valid or this function will panic. 47 | func (v Version) Stability() Stability { 48 | return v.parse().stability 49 | } 50 | 51 | // StabilityVer returns the stability version number for the version. The version must be valid or this function will 52 | // panic. 53 | func (v Version) StabilityVer() int { 54 | return v.parse().stabilityVer 55 | } 56 | 57 | // Compare returns 1 if the current version is larger than the other, -1 if it is smaller, 0 otherwise. 58 | func (v Version) Compare(other Version) int { 59 | parsedThis := v.parse() 60 | parsedOther := other.parse() 61 | thisStabilityInt := parsedThis.stability.AsInt() 62 | otherStabilityInt := parsedOther.stability.AsInt() 63 | 64 | if parsedThis.major > parsedOther.major { 65 | return 1 66 | } else if parsedThis.major < parsedOther.major { 67 | return -1 68 | } 69 | if parsedThis.minor > parsedOther.minor { 70 | return 1 71 | } else if parsedThis.minor < parsedOther.minor { 72 | return -1 73 | } 74 | if parsedThis.patch > parsedOther.patch { 75 | return 1 76 | } else if parsedThis.patch < parsedOther.patch { 77 | return -1 78 | } 79 | if thisStabilityInt > otherStabilityInt { 80 | return 1 81 | } else if thisStabilityInt < otherStabilityInt { 82 | return -1 83 | } 84 | if parsedThis.stabilityVer > parsedOther.stabilityVer { 85 | return 1 86 | } else if parsedThis.stabilityVer < parsedOther.stabilityVer { 87 | return -1 88 | } 89 | return 0 90 | } 91 | 92 | func (v Version) parse() parsedVersion { 93 | subMatch := versionRe.FindStringSubmatch(string(v)) 94 | if len(subMatch) == 0 { 95 | panic(fmt.Errorf("invalid version: %v", v)) 96 | } 97 | result := map[string]any{} 98 | for i, name := range versionRe.SubexpNames() { 99 | result[name] = subMatch[i] 100 | } 101 | var err error 102 | for _, name := range []string{"major", "minor", "patch"} { 103 | result[name], err = strconv.Atoi(result[name].(string)) 104 | if err != nil { 105 | panic(fmt.Errorf("invalid version: %w", err)) 106 | } 107 | } 108 | 109 | stabilityVer := -1 110 | if result["stabilityver"] != "" { 111 | stabilityVer, err = strconv.Atoi(result["stabilityver"].(string)) 112 | if err != nil { 113 | panic(fmt.Errorf("invalid version: %w", err)) 114 | } 115 | } 116 | 117 | return parsedVersion{ 118 | major: result["major"].(int), 119 | minor: result["minor"].(int), 120 | patch: result["patch"].(int), 121 | stability: Stability(result["stability"].(string)), 122 | stabilityVer: stabilityVer, 123 | } 124 | } 125 | 126 | type parsedVersion struct { 127 | major int 128 | minor int 129 | patch int 130 | stability Stability 131 | stabilityVer int 132 | } 133 | -------------------------------------------------------------------------------- /MIRROR-SPECIFICATION.md: -------------------------------------------------------------------------------- 1 | # Specification for TofuDL mirrors 2 | 3 | Version: pending 4 | 5 | ## Abstract 6 | 7 | This document describes the minimum requirements to host a mirror that is compatible with TofuDL. We also intend this document to be a guide for other OpenTofu downloading tools. 8 | 9 | ## The API endpoint 10 | 11 | The API endpoint is a JSON file, by default hosted at https://get.opentofu.org/tofu/api.json, containing all versions of OpenTofu and a list of all artifacts uploaded for each version. An example file would look as follows: 12 | 13 | ```json 14 | { 15 | "versions": [ 16 | { 17 | "id": "1.8.0", 18 | "files": [ 19 | "file1.ext", 20 | "file2.ext", 21 | "..." 22 | ] 23 | } 24 | ] 25 | } 26 | ``` 27 | 28 | A JSON schema document is included [in this repository](https://github.com/opentofu/tofudl/blob/main/api.schema.json). When using Go, you may want to use the `github.com/opentofu/tofudl.APIResponse` struct. 29 | 30 | Note that versions are included *without* the `v` prefix in the version listing and *may* contain the suffixes `-alphaX`, `-betaX`, `-rcX`. The response **must** sort version in reverse order according to semantic versioning. The filenames in the response should *not* include a path. 31 | 32 | Mirror implementations *may* restrict access to the API endpoint by means of the `Authorization` HTTP header and *should* use encrypted connections (`https://`). 33 | 34 | ## Download mirror 35 | 36 | The files contained in the API endpoint response lead to a download mirror for the artifacts. The implementation *may* choose a URL structure freely and the URL *may* contain the version number. Client implementations *should* make the version URL configurable/templatable. The default URL template for the download mirror, expressed as a Go template, is: `https://github.com/opentofu/opentofu/releases/download/v{{ .Version }}/{{ .Artifact }}` 37 | 38 | Mirror implementations *may* use HTTP redirects and *may* include authentication requirements by means of the `Authorization` HTTP header and *should* use encrypted connections (`https://`). 39 | 40 | ### Downloading OpenTofu 41 | 42 | The artifact containing OpenTofu is named `tofu_{{ .Version }}_{{ .Platform }}_{{ .Architecture }}.tar.gz`. While the official mirror contains more files, TofuDL implementations *should* not rely on other files being present to limit the scope of necessary mirroring. The archive will contain a file called `tofu`/`tofu.exe`, along with supplemental files, such as the license file. 43 | 44 | The platform may contain the following values: 45 | 46 | - `windows` 47 | - `linux` 48 | - `darwin` (MacOS) 49 | - `freebsd` 50 | - `openbsd` 51 | - `solaris` 52 | 53 | The architecture may contain the following values: 54 | 55 | - `386` 56 | - `amd64` 57 | - `arm` 58 | - `arm64` 59 | 60 | Note: not all platform/architecture combinations lead to valid artifacts. Also note that future versions of OpenTofu may introduce more platforms or architectures. 61 | 62 | ### Verifying artifacts 63 | 64 | Verifying the integrity of mirrored files should be performed for every download to ensure that no malicious binaries have been introduced to the mirror. The verification should be performed in the following two steps: 65 | 66 | 1. Verify the SHA256 checksum of the downloaded artifact against the file called `tofu_{{ .Version }}_SHA256SUMS`. This file has lines in the following format. The entries are separated by two spaces and end with a newline (`\n`), but no carriage return (`\r`). Implementations SHOULD nevertheless strip extra whitespace characters and disregard empty newlines. 67 | ``` 68 | HEX-ENCODED-SHA256-CHECKSUM FILENAME 69 | ``` 70 | For example, consider the following line: 71 | ``` 72 | f6f7c0a8cefde6e750d0755fb2de9f69272698fa6fd8cfe02f15d19911beef84 tofu_1.8.0_linux_arm.tar.gz 73 | ``` 74 | 2. Once the checksum is verified, the `SHA256SUMS` file should be verified using a GPG key against the `tofu_{{ .Version }}_SHA256SUMS.gpgsig`. This file contains a non-armored OpenPGP/GnuPG signature with a corresponding signing key. The signing key defaults to the one found at https://get.opentofu.org/opentofu.asc, fingerprint `E3E6E43D84CB852EADB0051D0C0AF313E5FD9F80`. Implementations *should not* attempt to use a GnuPG keyserver to obtain this key and *should* allow for configurable signing keys for self-built binaries. 75 | 76 | Note: TofuDL currently doesn't use Cosign/SigStore signatures because they are not available as lightweight libraries yet. 77 | -------------------------------------------------------------------------------- /downloader_download_version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "archive/tar" 8 | "bytes" 9 | "compress/gzip" 10 | "context" 11 | "errors" 12 | "fmt" 13 | "io" 14 | 15 | "github.com/opentofu/tofudl/branding" 16 | ) 17 | 18 | func (d *downloader) DownloadVersion(ctx context.Context, version VersionWithArtifacts, platform Platform, architecture Architecture) ([]byte, error) { 19 | return downloadVersion(ctx, version, platform, architecture, d.DownloadArtifact, d.VerifyArtifact) 20 | } 21 | 22 | func downloadVersion( 23 | ctx context.Context, 24 | version VersionWithArtifacts, 25 | platform Platform, 26 | architecture Architecture, 27 | downloadArtifactFunc func(ctx context.Context, version VersionWithArtifacts, artifactName string) ([]byte, error), 28 | verifyArtifactFunc func(artifactName string, artifactContents []byte, sumsFileContents []byte, signatureFileContent []byte) error, 29 | ) ([]byte, error) { 30 | sumsFileName := branding.ArtifactPrefix + string(version.ID) + "_SHA256SUMS" 31 | sumsBody, err := downloadArtifactFunc(ctx, version, sumsFileName) 32 | if err != nil { 33 | return nil, &RequestFailedError{Cause: fmt.Errorf("failed to download %s (%w)", sumsFileName, err)} 34 | } 35 | 36 | sumsSigFileName := branding.ArtifactPrefix + string(version.ID) + "_SHA256SUMS.gpgsig" 37 | sumsSig, err := downloadArtifactFunc(ctx, version, sumsSigFileName) 38 | if err != nil { 39 | return nil, &RequestFailedError{Cause: fmt.Errorf("failed to download %s (%w)", sumsSigFileName, err)} 40 | } 41 | 42 | platform, err = platform.ResolveAuto() 43 | if err != nil { 44 | return nil, err 45 | } 46 | architecture, err = architecture.ResolveAuto() 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | archiveName := branding.ArtifactPrefix + string(version.ID) + "_" + string(platform) + "_" + string(architecture) + ".tar.gz" 52 | archive, err := downloadArtifactFunc(ctx, version, archiveName) 53 | if err != nil { 54 | var noSuchArtifact *NoSuchArtifactError 55 | if errors.As(err, &noSuchArtifact) { 56 | return nil, &UnsupportedPlatformOrArchitectureError{ 57 | Platform: platform, 58 | Architecture: architecture, 59 | Version: version.ID, 60 | } 61 | } 62 | } 63 | 64 | if err := verifyArtifactFunc(archiveName, archive, sumsBody, sumsSig); err != nil { 65 | return nil, err 66 | } 67 | 68 | return extractBinaryFromTarGz(archiveName, archive, platform) 69 | } 70 | 71 | // extractBinaryFromTarGz extracts the OpenTofu binary from a tar.gz archive 72 | // takes platform as an argument, to determine if we should look for "tofu" or "tofu.exe" 73 | // since it is possible to download for other patforms/archs from a different one 74 | func extractBinaryFromTarGz(archiveName string, archive []byte, platform Platform) ([]byte, error) { 75 | gz, err := gzip.NewReader(bytes.NewReader(archive)) 76 | if err != nil { 77 | return nil, &ArtifactCorruptedError{ 78 | Artifact: archiveName, 79 | Cause: err, 80 | } 81 | } 82 | defer func() { 83 | _ = gz.Close() 84 | }() 85 | 86 | binaryName := "tofu" 87 | if platform == PlatformWindows { 88 | binaryName = "tofu.exe" 89 | } 90 | tarFile := tar.NewReader(gz) 91 | for { 92 | current, err := tarFile.Next() 93 | if errors.Is(err, io.EOF) { 94 | break 95 | } else if err != nil { 96 | return nil, &ArtifactCorruptedError{ 97 | Artifact: archiveName, 98 | Cause: err, 99 | } 100 | } 101 | 102 | if current.Name != binaryName || current.Typeflag != tar.TypeReg { 103 | continue 104 | } 105 | buf := &bytes.Buffer{} 106 | // Protect against a DoS vulnerability by limiting the maximum size of the binary. 107 | if _, err := io.CopyN(buf, tarFile, branding.MaximumUncompressedFileSize); err != nil { 108 | if !errors.Is(err, io.EOF) { 109 | return nil, &ArtifactCorruptedError{ 110 | Artifact: archiveName, 111 | Cause: err, 112 | } 113 | } 114 | } 115 | if buf.Len() == branding.MaximumUncompressedFileSize { 116 | return nil, &ArtifactCorruptedError{ 117 | Artifact: archiveName, 118 | Cause: fmt.Errorf("artifact too large (larger than %d bytes)", branding.MaximumUncompressedFileSize), 119 | } 120 | } 121 | return buf.Bytes(), nil 122 | } 123 | return nil, &ArtifactCorruptedError{ 124 | Artifact: archiveName, 125 | Cause: fmt.Errorf("file named %s not found", binaryName), 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /internal/tools/bundle-gpg-key/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "crypto/tls" 8 | "flag" 9 | "fmt" 10 | "io" 11 | "log" 12 | "net/http" 13 | "os" 14 | "strings" 15 | "text/template" 16 | 17 | "github.com/ProtonMail/gopenpgp/v2/crypto" 18 | "github.com/opentofu/tofudl/branding" 19 | ) 20 | 21 | var templateText = `// Copyright (c) {{ .Authors }} 22 | // SPDX-License-Identifier: {{ .License }} 23 | 24 | // Code generated by tools/bundle-gpg-key/main.go. DO NOT EDIT. 25 | 26 | package branding 27 | 28 | //go:` + `generate go run github.com/opentofu/tofudl/internal/tools/bundle-gpg-key -file {{ .File }} -url {{ .URL }} 29 | 30 | // DefaultGPGKey holds the default GPG key bundled with the downloader. This key was downloaded from 31 | // {{ .URL }} 32 | const DefaultGPGKey = ` + "`{{ .GPGKey }}`\n" 33 | 34 | type templateParams struct { 35 | Authors string 36 | License string 37 | File string 38 | URL string 39 | GPGKey string 40 | } 41 | 42 | func main() { 43 | targetFile := "gpg_key.go" 44 | gpgKeyURL := branding.GPGKeyURL 45 | checkOnly := false 46 | 47 | flag.BoolVar(&checkOnly, "check-only", checkOnly, "Only check if the file contents are correct.") 48 | flag.StringVar(&targetFile, "file", targetFile, "Target file to write to.") 49 | flag.StringVar(&gpgKeyURL, "url", gpgKeyURL, "URL to download the ASCII-armored GPG key from.") 50 | flag.Parse() 51 | 52 | req, err := http.NewRequest(http.MethodGet, gpgKeyURL, nil) //nolint:noctx //This is a go:generate tool 53 | if err != nil { 54 | log.Fatalf("Failed to create HTTP request (%v)", err) 55 | } 56 | transport := http.DefaultTransport.(*http.Transport) 57 | transport.TLSClientConfig = &tls.Config{ 58 | MinVersion: tls.VersionTLS13, 59 | } 60 | client := http.Client{ 61 | Transport: transport, 62 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 63 | return fmt.Errorf("no redirects allowed") 64 | }, 65 | } 66 | 67 | resp, err := client.Do(req) 68 | if err != nil { 69 | log.Fatalf("Failed to send HTTP request (%v)", err) 70 | } 71 | if resp.StatusCode != http.StatusOK { 72 | _ = resp.Body.Close() 73 | log.Fatalf("Invalid status code returned: %d", resp.StatusCode) 74 | } 75 | body, err := io.ReadAll(resp.Body) 76 | if err != nil { 77 | _ = resp.Body.Close() 78 | log.Fatalf("Failed to read HTTP body (%v)", err) 79 | } 80 | _ = resp.Body.Close() 81 | 82 | key, err := crypto.NewKeyFromArmored(string(body)) 83 | if err != nil { 84 | log.Fatalf("Failed to decode downloaded key (%v).", err) 85 | } 86 | gotFingerprint := strings.ToUpper(key.GetFingerprint()) 87 | expectedFingerprint := strings.ToUpper(branding.GPGKeyFingerprint) 88 | if gotFingerprint != expectedFingerprint { 89 | log.Fatalf( 90 | "Incorrect key fingerprint for downloaded key: %s (expected: %s)", 91 | gotFingerprint, 92 | expectedFingerprint, 93 | ) 94 | } 95 | if !key.CanVerify() { 96 | log.Fatalf("The downloaded key is not suitable for verification.") 97 | } 98 | 99 | tpl := template.New(targetFile + ".tpl") 100 | tpl, err = tpl.Parse(templateText) 101 | if err != nil { 102 | log.Fatalf("Failed to parse template file (%v)", err) 103 | } 104 | tempFile := targetFile + "~" 105 | fh, err := os.OpenFile(tempFile, os.O_CREATE|os.O_WRONLY, 0644) 106 | if err != nil { 107 | log.Fatalf("Failed to create temporary file %s (%v)", tempFile, err) 108 | } 109 | closeFile := func() { 110 | err := fh.Close() 111 | if err != nil { 112 | log.Fatalf("Failed to write temporary file %s (%v)", tempFile, err) 113 | } 114 | } 115 | if err := tpl.Execute(fh, templateParams{ 116 | Authors: branding.SPDXAuthorsName, 117 | License: branding.SPDXLicense, 118 | File: targetFile, 119 | URL: gpgKeyURL, 120 | GPGKey: string(body), 121 | }); err != nil { 122 | closeFile() 123 | log.Fatalf("Failed to execute template (%v)", err) 124 | } 125 | closeFile() 126 | 127 | if checkOnly { 128 | check(tempFile, targetFile) 129 | } else { 130 | if err := os.Rename(tempFile, targetFile); err != nil { 131 | log.Fatalf("Failed to rename temp file %s to %s (%v)", tempFile, targetFile, err) 132 | } 133 | } 134 | } 135 | 136 | func check(tempFile string, targetFile string) { 137 | tempFileContents, err := os.ReadFile(tempFile) 138 | if err != nil { 139 | log.Fatalf("Cannot read temp file %s (%v)", tempFileContents, err) 140 | } 141 | fileContents, err := os.ReadFile(targetFile) 142 | if err != nil { 143 | log.Fatalf("Cannot read file %s (%v)", targetFile, err) 144 | } 145 | if string(fileContents) != string(tempFileContents) { 146 | log.Fatalf("The %s file does not match the expected contents, please run go generate.", targetFile) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /downloader_download.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | ) 10 | 11 | // DownloadOptions describes the settings for downloading. They default to the current architecture and platform. 12 | type DownloadOptions struct { 13 | Platform Platform 14 | Architecture Architecture 15 | Version Version 16 | NightlyID NightlyID 17 | MinimumStability *Stability 18 | } 19 | 20 | // DownloadOpt is a function that modifies the download options. 21 | type DownloadOpt func(spec *DownloadOptions) error 22 | 23 | // DownloadOptPlatform specifies the platform to download for. This defaults to the current platform. 24 | func DownloadOptPlatform(platform Platform) DownloadOpt { 25 | return func(spec *DownloadOptions) error { 26 | if err := platform.Validate(); err != nil { 27 | return err 28 | } 29 | spec.Platform = platform 30 | return nil 31 | } 32 | } 33 | 34 | // DownloadOptArchitecture specifies the architecture to download for. This defaults to the current architecture. 35 | func DownloadOptArchitecture(architecture Architecture) DownloadOpt { 36 | return func(spec *DownloadOptions) error { 37 | if err := architecture.Validate(); err != nil { 38 | return err 39 | } 40 | spec.Architecture = architecture 41 | return nil 42 | } 43 | } 44 | 45 | // DownloadOptVersion specifies the version to download. Defaults to the latest version with the specified minimum 46 | // stability. 47 | func DownloadOptVersion(version Version) DownloadOpt { 48 | return func(spec *DownloadOptions) error { 49 | if err := version.Validate(); err != nil { 50 | return err 51 | } 52 | if spec.MinimumStability != nil { 53 | return &InvalidOptionsError{ 54 | fmt.Errorf("the stability and version constraints for download are mutually exclusive"), 55 | } 56 | } 57 | spec.Version = version 58 | return nil 59 | } 60 | } 61 | 62 | // DownloadOptNightlyBuildID specified id of the nightly build in format "${build_date}-${commit_hash}" (20251006-f839281c15) 63 | // If the id isn't in the correct regex format we return error. This option is specifically for nightly download and does not interfere with other version options. 64 | func DownloadOptNightlyBuildID(id string) DownloadOpt { 65 | return func(spec *DownloadOptions) error { 66 | nighlyID := NightlyID(id) 67 | if err := nighlyID.Validate(); err != nil { 68 | return err 69 | } 70 | spec.NightlyID = nighlyID 71 | return nil 72 | } 73 | } 74 | 75 | // DownloadOptMinimumStability specifies the minimum stability of the version to download. This is mutually exclusive 76 | // with setting the Version. 77 | func DownloadOptMinimumStability(stability Stability) DownloadOpt { 78 | return func(spec *DownloadOptions) error { 79 | if err := stability.Validate(); err != nil { 80 | return err 81 | } 82 | if spec.Version != "" { 83 | return &InvalidOptionsError{ 84 | fmt.Errorf("the stability and version constraints for download are mutually exclusive"), 85 | } 86 | } 87 | spec.MinimumStability = &stability 88 | return nil 89 | } 90 | } 91 | 92 | func (d *downloader) Download(ctx context.Context, opts ...DownloadOpt) ([]byte, error) { 93 | return download(ctx, opts, d.ListVersions, d.DownloadVersion) 94 | } 95 | 96 | func download(ctx context.Context, opts []DownloadOpt, listVersionsFunc func(ctx context.Context, opts ...ListVersionOpt) ([]VersionWithArtifacts, error), downloadVersionFunc func(ctx context.Context, version VersionWithArtifacts, platform Platform, architecture Architecture) ([]byte, error)) ([]byte, error) { 97 | downloadOpts := DownloadOptions{} 98 | for _, opt := range opts { 99 | if err := opt(&downloadOpts); err != nil { 100 | return nil, err 101 | } 102 | } 103 | var listOptions []ListVersionOpt 104 | if downloadOpts.MinimumStability != nil { 105 | listOptions = append(listOptions, ListVersionOptMinimumStability(*downloadOpts.MinimumStability)) 106 | } 107 | listResult, err := listVersionsFunc(ctx, listOptions...) 108 | if err != nil { 109 | return nil, err 110 | } 111 | if len(listResult) == 0 { 112 | return nil, &RequestFailedError{ 113 | Cause: fmt.Errorf("the API request returned no versions"), 114 | } 115 | } 116 | 117 | var foundVer *VersionWithArtifacts 118 | if downloadOpts.Version != "" { 119 | for _, ver := range listResult { 120 | if ver.ID == downloadOpts.Version { 121 | ver := ver 122 | foundVer = &ver 123 | break 124 | } 125 | } 126 | if foundVer == nil { 127 | return nil, &NoSuchVersionError{downloadOpts.Version} 128 | } 129 | } else { 130 | foundVer = &listResult[0] 131 | } 132 | return downloadVersionFunc(ctx, *foundVer, downloadOpts.Platform, downloadOpts.Architecture) 133 | } 134 | -------------------------------------------------------------------------------- /downloader_download_nightly.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | ) 13 | 14 | func (d *downloader) DownloadNightly(ctx context.Context, opts ...DownloadOpt) ([]byte, error) { 15 | return downloadLatestNightly(ctx, opts, d.config.HTTPClient) 16 | } 17 | 18 | func downloadLatestNightly(ctx context.Context, opts []DownloadOpt, httpClient *http.Client) ([]byte, error) { 19 | downloadOpts := DownloadOptions{} 20 | for _, opt := range opts { 21 | if err := opt(&downloadOpts); err != nil { 22 | return nil, err 23 | } 24 | } 25 | 26 | platform, err := downloadOpts.Platform.ResolveAuto() 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | architecture, err := downloadOpts.Architecture.ResolveAuto() 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | // 37 | nightlyID := downloadOpts.NightlyID 38 | if nightlyID == "" { 39 | metadata, err := fetchLatestNightlyMetadata(ctx, httpClient) 40 | if err != nil { 41 | return nil, err 42 | } 43 | nightlyID, err = newNightlyID(metadata.Date, metadata.Commit) 44 | if err != nil { 45 | return nil, err 46 | } 47 | } 48 | 49 | artifactName := fmt.Sprintf("tofu_nightly-%s_%s_%s.tar.gz", 50 | nightlyID, 51 | string(platform), 52 | string(architecture), 53 | ) 54 | path := fmt.Sprintf("/nightlies/%s/", nightlyID.GetDate()) 55 | 56 | // Download the artifact 57 | artifactURL := fmt.Sprintf("https://nightlies.opentofu.org%s%s", path, artifactName) 58 | artifact, err := downloadNightlyFile(ctx, httpClient, artifactURL) 59 | if err != nil { 60 | return nil, &RequestFailedError{Cause: fmt.Errorf("failed to download artifact %s (%w)", artifactName, err)} 61 | } 62 | 63 | // Download SHA256SUMS file 64 | sumsFileName := fmt.Sprintf("tofu_nightly-%s_SHA256SUMS", nightlyID) 65 | sumsURL := fmt.Sprintf("https://nightlies.opentofu.org%s%s", path, sumsFileName) 66 | sumsBody, err := downloadNightlyFile(ctx, httpClient, sumsURL) 67 | if err != nil { 68 | return nil, &RequestFailedError{Cause: fmt.Errorf("failed to download %s (%w)", sumsFileName, err)} 69 | } 70 | 71 | // Verify artifact checksum 72 | if err := verifyArtifactSHAOnly(artifactName, artifact, sumsBody); err != nil { 73 | return nil, err 74 | } 75 | 76 | // Extract binary from tar.gz 77 | binary, err := extractBinaryFromTarGz(artifactName, artifact, platform) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | return binary, nil 83 | } 84 | 85 | type nightlyMetadata struct { 86 | Version string `json:"version"` 87 | Date string `json:"date"` 88 | Commit string `json:"commit"` 89 | Path string `json:"path"` 90 | Artifacts []string `json:"artifacts"` 91 | } 92 | 93 | // fetchLatestNightlyMetadata fetches and parses the latest nightly metadata from the https://nightlies.opentofu.org/nightlies/latest.json 94 | func fetchLatestNightlyMetadata(ctx context.Context, httpClient *http.Client) (*nightlyMetadata, error) { 95 | const metadataURL = "https://nightlies.opentofu.org/nightlies/latest.json" 96 | 97 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) 98 | if err != nil { 99 | return nil, fmt.Errorf("failed to construct HTTP request (%w)", err) 100 | } 101 | 102 | resp, err := httpClient.Do(req) 103 | if err != nil { 104 | return nil, fmt.Errorf("request failed (%w)", err) 105 | } 106 | defer resp.Body.Close() 107 | 108 | if resp.StatusCode != http.StatusOK { 109 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 110 | } 111 | 112 | body, err := io.ReadAll(resp.Body) 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to read response body (%w)", err) 115 | } 116 | 117 | var metadata nightlyMetadata 118 | if err := json.Unmarshal(body, &metadata); err != nil { 119 | return nil, fmt.Errorf("failed to parse nightly metadata (%w)", err) 120 | } 121 | 122 | return &metadata, nil 123 | } 124 | 125 | func downloadNightlyFile(ctx context.Context, httpClient *http.Client, url string) ([]byte, error) { 126 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 127 | if err != nil { 128 | return nil, fmt.Errorf("failed to construct HTTP request (%w)", err) 129 | } 130 | 131 | resp, err := httpClient.Do(req) 132 | if err != nil { 133 | return nil, fmt.Errorf("request failed (%w)", err) 134 | } 135 | defer resp.Body.Close() 136 | 137 | if resp.StatusCode != http.StatusOK { 138 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 139 | } 140 | 141 | b, err := io.ReadAll(resp.Body) 142 | if err != nil { 143 | return nil, fmt.Errorf("failed to read response body (%w)", err) 144 | } 145 | 146 | return b, nil 147 | } 148 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "crypto/tls" 8 | "net/http" 9 | 10 | "github.com/opentofu/tofudl/branding" 11 | ) 12 | 13 | // Config describes the base configuration for the downloader. 14 | type Config struct { 15 | // GPGKey holds the ASCII-armored GPG key to verify the binaries against. Defaults to the bundled 16 | // signing key. 17 | GPGKey string 18 | // APIURL describes the URL to the JSON API listing the versions and artifacts. Defaults to branding.DownloadAPIURL. 19 | APIURL string 20 | // APIURLAuthorization is an optional Authorization header to add to all request to the API URL. For requests 21 | // to the default API URL leave this empty. 22 | APIURLAuthorization string 23 | // DownloadMirrorAuthorization is an optional Authorization header to add to all requests to the download mirror. 24 | // Typically, you'll want to set this to "Bearer YOUR-GITHUB-TOKEN". 25 | DownloadMirrorAuthorization string 26 | // DownloadMirrorURLTemplate is a Go text template containing a URL with MirrorURLTemplateParameters embedded to 27 | // generate the download URL. Defaults to branding.DefaultMirrorURLTemplate. 28 | DownloadMirrorURLTemplate string 29 | // HTTPClient holds an HTTP client to use for requests. Defaults to the standard HTTP client with hardened TLS 30 | // settings. 31 | HTTPClient *http.Client 32 | } 33 | 34 | // ApplyDefaults applies defaults for all fields that are not set. 35 | func (c *Config) ApplyDefaults() { 36 | if c.GPGKey == "" { 37 | c.GPGKey = branding.DefaultGPGKey 38 | } 39 | if c.APIURL == "" { 40 | c.APIURL = branding.DefaultDownloadAPIURL 41 | } 42 | if c.DownloadMirrorURLTemplate == "" { 43 | c.DownloadMirrorURLTemplate = branding.DefaultMirrorURLTemplate 44 | } 45 | if c.HTTPClient == nil { 46 | client := &http.Client{} 47 | client.Transport = http.DefaultTransport 48 | client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{ 49 | MinVersion: tls.VersionTLS13, 50 | } 51 | c.HTTPClient = client 52 | } 53 | } 54 | 55 | // MirrorURLTemplateParameters describes the parameters to a URL template for mirrors. 56 | type MirrorURLTemplateParameters struct { 57 | Version Version 58 | Artifact string 59 | } 60 | 61 | // ConfigOpt is a function that modifies the config. 62 | type ConfigOpt func(config *Config) error 63 | 64 | // ConfigGPGKey is a config option to set an ASCII-armored GPG key. 65 | func ConfigGPGKey(gpgKey string) ConfigOpt { 66 | return func(config *Config) error { 67 | if config.GPGKey != "" { 68 | return &InvalidConfigurationError{Message: "Duplicate options for GPG key."} 69 | } 70 | config.GPGKey = gpgKey 71 | return nil 72 | } 73 | } 74 | 75 | // ConfigAPIURL adds an API URL for the version listing. Defaults to branding.DownloadAPIURL. 76 | func ConfigAPIURL(url string) ConfigOpt { 77 | return func(config *Config) error { 78 | if config.APIURL != "" { 79 | return &InvalidConfigurationError{Message: "Duplicate options for API URL."} 80 | } 81 | config.APIURL = url 82 | return nil 83 | } 84 | } 85 | 86 | // ConfigAPIAuthorization adds an authorization header to any request sent to the API server. This is not needed 87 | // for the default API, but may be needed for private mirrors. 88 | func ConfigAPIAuthorization(authorization string) ConfigOpt { 89 | return func(config *Config) error { 90 | if config.APIURLAuthorization != "" { 91 | return &InvalidConfigurationError{Message: "Duplicate options for API authorization.."} 92 | } 93 | config.APIURLAuthorization = authorization 94 | return nil 95 | } 96 | } 97 | 98 | // ConfigDownloadMirrorAuthorization adds the specified value to any request when connecting the download mirror. 99 | // For example, you can add your GitHub token by specifying "Bearer YOUR-TOKEN-HERE". 100 | func ConfigDownloadMirrorAuthorization(authorization string) ConfigOpt { 101 | return func(config *Config) error { 102 | if config.DownloadMirrorAuthorization != "" { 103 | return &InvalidConfigurationError{Message: "Duplicate options for download mirror authorization."} 104 | } 105 | config.DownloadMirrorAuthorization = authorization 106 | return nil 107 | } 108 | } 109 | 110 | // ConfigDownloadMirrorURLTemplate adds a Go text template containing a URL with MirrorURLTemplateParameters embedded to 111 | // generate the download URL. Defaults to branding.DefaultMirrorURLTemplate. 112 | func ConfigDownloadMirrorURLTemplate(urlTemplate string) ConfigOpt { 113 | return func(config *Config) error { 114 | if config.DownloadMirrorURLTemplate != "" { 115 | return &InvalidConfigurationError{Message: "Duplicate options for download mirror URL template."} 116 | } 117 | config.DownloadMirrorURLTemplate = urlTemplate 118 | return nil 119 | } 120 | } 121 | 122 | // ConfigHTTPClient adds a customized HTTP client to the downloader. 123 | func ConfigHTTPClient(client *http.Client) ConfigOpt { 124 | return func(config *Config) error { 125 | if config.HTTPClient != nil { 126 | return &InvalidConfigurationError{Message: "Duplicate options for the HTTP client."} 127 | } 128 | config.HTTPClient = client 129 | return nil 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TofuDL: OpenTofu downloader library for Go with minimal dependencies 2 | 3 | This library provides an easy way to download, verify, and unpack OpenTofu binaries for local use in Go. It has a minimal set of dependencies and is easy to integrate. 4 | 5 | **Note:** This is not a standalone tool to download OpenTofu, it is purely meant to be used as Go library in support of other tools that need to run `tofu`. Please check the [installation instructions](https://opentofu.org/docs/intro/install/) for supported ways to perform an OpenTofu installation. 6 | 7 | ## Basic usage 8 | 9 | The downloader will work without any extra configuration out of the box: 10 | 11 | ```go 12 | package main 13 | 14 | import ( 15 | "context" 16 | "os" 17 | "os/exec" 18 | "runtime" 19 | 20 | "github.com/opentofu/tofudl" 21 | ) 22 | 23 | func main() { 24 | // Initialize the downloader: 25 | dl, err := tofudl.New() 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | // Download the latest stable version 31 | // for the current architecture and platform: 32 | binary, err := dl.Download(context.TODO()) 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | // Write out the tofu binary to the disk: 38 | file := "tofu" 39 | if runtime.GOOS == "windows" { 40 | file += ".exe" 41 | } 42 | if err := os.WriteFile(file, binary, 0755); err != nil { 43 | panic(err) 44 | } 45 | 46 | // Run tofu: 47 | cmd := exec.Command("./"+file, "init") 48 | if err := cmd.Run(); err != nil { 49 | panic(err) 50 | } 51 | } 52 | ``` 53 | 54 | ## Caching 55 | 56 | This library also supports caching using the mirror tool: 57 | 58 | ```go 59 | package main 60 | 61 | import ( 62 | "context" 63 | "os" 64 | "os/exec" 65 | "runtime" 66 | "time" 67 | 68 | "github.com/opentofu/tofudl" 69 | ) 70 | 71 | func main() { 72 | // Initialize the downloader: 73 | dl, err := tofudl.New() 74 | if err != nil { 75 | panic(err) 76 | } 77 | 78 | // Set up the caching layer: 79 | storage, err := tofudl.NewFilesystemStorage("/tmp") 80 | if err != nil { 81 | panic(err) 82 | } 83 | mirror, err := tofudl.NewMirror( 84 | tofudl.MirrorConfig{ 85 | AllowStale: false, 86 | APICacheTimeout: time.Minute * 10, 87 | ArtifactCacheTimeout: time.Hour * 24, 88 | }, 89 | storage, 90 | dl, 91 | ) 92 | if err != nil { 93 | panic(err) 94 | } 95 | 96 | // Download the latest stable version 97 | // for the current architecture and platform: 98 | binary, err := mirror.Download(context.TODO()) 99 | if err != nil { 100 | panic(err) 101 | } 102 | 103 | // Write out the tofu binary to the disk: 104 | file := "tofu" 105 | if runtime.GOOS == "windows" { 106 | file += ".exe" 107 | } 108 | if err := os.WriteFile(file, binary, 0755); err != nil { 109 | panic(err) 110 | } 111 | 112 | // Run tofu: 113 | cmd := exec.Command("./"+file, "init") 114 | if err := cmd.Run(); err != nil { 115 | panic(err) 116 | } 117 | } 118 | ``` 119 | 120 | You can also use the `mirror` variable as an `http.Handler`. Additionally, you can also call `PreWarm` on the caching layer in order to pre-warm your local caches. (Be careful, this may take a long time!) 121 | 122 | ## Standalone mirror 123 | 124 | The example above showed a cache/mirror that acts as a pull-through cache to upstream. You can alternatively also use the mirror as a stand-alone mirror and publish your own binaries. The mirror has functions to facilitate uploading basic artifacts, but you can also use the `ReleaseBuilder` to make building releases easier. (Note: the `ReleaseBuilder` only builds artifacts needed for TofuDL, not all artifacts OpenTofu typically publishes.) 125 | 126 | ## Advanced usage 127 | 128 | Both `New()` and `Download()` accept a number of options. You can find the detailed documentation [here](https://pkg.go.dev/github.com/opentofu/tofudl). 129 | 130 | ## Nightly Download 131 | 132 | You can download the latest or a specified nightly build of OpenTofu using the new `DownloadNightly` method: 133 | 134 | ```go 135 | package main 136 | 137 | import ( 138 | "context" 139 | "os" 140 | "runtime" 141 | 142 | "github.com/opentofu/tofudl" 143 | ) 144 | 145 | func main() { 146 | dl, err := tofudl.New() 147 | if err != nil { 148 | panic(err) 149 | } 150 | 151 | // Download the nightly build with ID 20251018-dc9bec611c for the current platform/architecture 152 | // You can pass platform and architecture options like usual. For the latest build, you can omit build ID. 153 | binary, err := dl.DownloadNightly(context.TODO(), dl.DownloadOptNightlyBuildID("20251018-dc9bec611c")) 154 | if err != nil { 155 | panic(err) 156 | } 157 | 158 | file := "tofu" 159 | if runtime.GOOS == "windows" { 160 | file += ".exe" 161 | } 162 | if err := os.WriteFile(file, binary, 0755); err != nil { 163 | panic(err) 164 | } 165 | } 166 | ``` 167 | 168 | **Note:** Nightly downloads are not supported via the mirror/caching layer. You must use the downloader directly for nightly builds. 169 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 2 | github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= 3 | github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 4 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= 5 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= 6 | github.com/ProtonMail/gopenpgp/v2 v2.7.5 h1:STOY3vgES59gNgoOt2w0nyHBjKViB/qSg7NjbQWPJkA= 7 | github.com/ProtonMail/gopenpgp/v2 v2.7.5/go.mod h1:IhkNEDaxec6NyzSI0PlxapinnwPVIESk8/76da3Ct3g= 8 | github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 9 | github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= 10 | github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE= 11 | github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 16 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 21 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 22 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 23 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 24 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 25 | golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= 26 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 27 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 28 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 29 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 30 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 31 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 32 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 33 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 34 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 35 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 36 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 37 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 38 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 41 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 50 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 52 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 53 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 54 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 55 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 56 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 57 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 58 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 59 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 60 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 61 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 62 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 63 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 64 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 65 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 66 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 67 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 68 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 69 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 70 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 71 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 72 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/opentofu/tofudl/branding" 10 | ) 11 | 12 | // InvalidPlatformError describes an error where a platform name was found to be invalid. 13 | type InvalidPlatformError struct { 14 | Platform Platform 15 | } 16 | 17 | // Error returns the error message. 18 | func (e InvalidPlatformError) Error() string { 19 | return fmt.Sprintf("Invalid platform: %s", e.Platform) 20 | } 21 | 22 | // UnsupportedPlatformError indicates that the given runtime.GOOS platform is not supported and cannot automatically 23 | // resolve to a build artifact. 24 | type UnsupportedPlatformError struct { 25 | Platform Platform 26 | } 27 | 28 | // Error returns the error message. 29 | func (e UnsupportedPlatformError) Error() string { 30 | return fmt.Sprintf("Unsupported platform: %s", e.Platform) 31 | } 32 | 33 | // UnsupportedArchitectureError indicates that the given runtime.GOARCH architecture is not supported and cannot 34 | // automatically resolve to a build artifact. 35 | type UnsupportedArchitectureError struct { 36 | Architecture Architecture 37 | } 38 | 39 | // Error returns the error message. 40 | func (e UnsupportedArchitectureError) Error() string { 41 | return fmt.Sprintf("Unsupported architecture: %s", e.Architecture) 42 | } 43 | 44 | // InvalidArchitectureError describes an error where an architecture name was found to be invalid. 45 | type InvalidArchitectureError struct { 46 | Architecture Architecture 47 | } 48 | 49 | // Error returns the error message. 50 | func (e InvalidArchitectureError) Error() string { 51 | return fmt.Sprintf("Invalid architecture: %s", e.Architecture) 52 | } 53 | 54 | // InvalidVersionError describes an error where the version string is invalid. 55 | type InvalidVersionError struct { 56 | Version Version 57 | } 58 | 59 | // Error returns the error message. 60 | func (e InvalidVersionError) Error() string { 61 | return fmt.Sprintf("Invalid version: %s", e.Version) 62 | } 63 | 64 | // NoSuchVersionError indicates that the given version does not exist on the API endpoint. 65 | type NoSuchVersionError struct { 66 | Version Version 67 | } 68 | 69 | // Error returns the error message. 70 | func (e NoSuchVersionError) Error() string { 71 | return fmt.Sprintf("No such version: %s", e.Version) 72 | } 73 | 74 | // UnsupportedPlatformOrArchitectureError describes an error where the platform name and architecture are syntactically 75 | // valid, but no release artifact was found matching that name. 76 | type UnsupportedPlatformOrArchitectureError struct { 77 | Platform Platform 78 | Architecture Architecture 79 | Version Version 80 | } 81 | 82 | func (e UnsupportedPlatformOrArchitectureError) Error() string { 83 | return fmt.Sprintf( 84 | "Unsupported platform (%s) or architecture (%s) for %s version %s.", 85 | e.Platform, 86 | e.Architecture, 87 | branding.ProductName, 88 | e.Version, 89 | ) 90 | } 91 | 92 | // InvalidConfigurationError indicates that the base configuration for the downloader is invalid. 93 | type InvalidConfigurationError struct { 94 | Message string 95 | Cause error 96 | } 97 | 98 | // Error returns the error message. 99 | func (e InvalidConfigurationError) Error() string { 100 | if e.Cause != nil { 101 | return "Invalid configuration: " + e.Message + " (" + e.Cause.Error() + ")" 102 | } 103 | return "Invalid configuration: " + e.Message 104 | } 105 | 106 | func (e InvalidConfigurationError) Unwrap() error { 107 | return e.Cause 108 | } 109 | 110 | // SignatureError indicates that the signature verification failed. 111 | type SignatureError struct { 112 | Message string 113 | Cause error 114 | } 115 | 116 | // Error returns the error message. 117 | func (e SignatureError) Error() string { 118 | if e.Cause != nil { 119 | return "Invalid signature: " + e.Message + " (" + e.Cause.Error() + ")" 120 | } 121 | return "Invalid signature: " + e.Message 122 | } 123 | 124 | func (e SignatureError) Unwrap() error { 125 | return e.Cause 126 | } 127 | 128 | // InvalidOptionsError indicates that the request options are invalid. 129 | type InvalidOptionsError struct { 130 | Cause error 131 | } 132 | 133 | // Error returns the error message. 134 | func (e InvalidOptionsError) Error() string { 135 | return "Invalid options: " + e.Cause.Error() 136 | } 137 | 138 | // Unwrap returns the original error. 139 | func (e InvalidOptionsError) Unwrap() error { 140 | return e.Cause 141 | } 142 | 143 | // NoSuchArtifactError indicates that there is no artifact for the given version with the given name. 144 | type NoSuchArtifactError struct { 145 | ArtifactName string 146 | } 147 | 148 | // Error returns the error message. 149 | func (e NoSuchArtifactError) Error() string { 150 | return "No such artifact: " + e.ArtifactName 151 | } 152 | 153 | // RequestFailedError indicates that a request to an API or the download mirror failed. 154 | type RequestFailedError struct { 155 | Cause error 156 | } 157 | 158 | // Error returns the error message. 159 | func (e RequestFailedError) Error() string { 160 | return fmt.Sprintf("Request failed (%v)", e.Cause) 161 | } 162 | 163 | // Unwrap returns the original error. 164 | func (e RequestFailedError) Unwrap() error { 165 | return e.Cause 166 | } 167 | 168 | // ArtifactCorruptedError indicates that the downloaded artifact is corrupt. 169 | type ArtifactCorruptedError struct { 170 | Artifact string 171 | Cause error 172 | } 173 | 174 | // Error returns the error message. 175 | func (e ArtifactCorruptedError) Error() string { 176 | return fmt.Sprintf("Corrupted artifact %s (%v)", e.Artifact, e.Cause) 177 | } 178 | 179 | // Unwrap returns the original error. 180 | func (e ArtifactCorruptedError) Unwrap() error { 181 | return e.Cause 182 | } 183 | 184 | // CacheMissError indicates that the artifact or file is not cached. 185 | type CacheMissError struct { 186 | File string 187 | Cause error 188 | } 189 | 190 | // Error returns the error message. 191 | func (e CacheMissError) Error() string { 192 | if e.Cause != nil { 193 | return "Cache miss for " + e.File + " (" + e.Cause.Error() + ")" 194 | } 195 | return "Cache miss for " + e.File 196 | } 197 | 198 | // Unwrap returns the original error. 199 | func (e CacheMissError) Unwrap() error { 200 | return e.Cause 201 | } 202 | 203 | // CachedArtifactStaleError indicates that the file is cached, but stale. 204 | type CachedArtifactStaleError struct { 205 | Version Version 206 | Artifact string 207 | } 208 | 209 | // Error returns the error message. 210 | func (e CachedArtifactStaleError) Error() string { 211 | return "Cache is stale for v" + string(e.Version) + "/" + e.Artifact 212 | } 213 | 214 | // CachedAPIResponseStaleError indicates that the API response is cached, but stale. 215 | type CachedAPIResponseStaleError struct { 216 | } 217 | 218 | // Error returns the error message. 219 | func (e CachedAPIResponseStaleError) Error() string { 220 | return "Cache is stale for API response" 221 | } 222 | -------------------------------------------------------------------------------- /cli/options.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package cli 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/opentofu/tofudl" 13 | "github.com/opentofu/tofudl/branding" 14 | ) 15 | 16 | type option struct { 17 | cliFlagName string 18 | envVarName string 19 | description string 20 | defaultValue string 21 | defaultDescription string 22 | 23 | validate func(value string) error 24 | applyConfig func(value string) (tofudl.ConfigOpt, error) 25 | applyDownloadOption func(value string) (tofudl.DownloadOpt, error) 26 | } 27 | 28 | var optionAPIURL = option{ 29 | cliFlagName: "api-url", 30 | envVarName: branding.CLIEnvPrefix + "API_URL", 31 | description: "URL to fetch the version information from.", 32 | defaultValue: branding.DefaultDownloadAPIURL, 33 | applyConfig: func(value string) (tofudl.ConfigOpt, error) { 34 | return tofudl.ConfigAPIURL(value), nil 35 | }, 36 | } 37 | 38 | var optionDownloadMirrorURLTemplate = option{ 39 | cliFlagName: "download-mirror-url-template", 40 | envVarName: branding.CLIEnvPrefix + "DOWNLOAD_MIRROR_URL_TEMPLATE", 41 | description: "URL template for the artifact mirror. May contain {{ .Version }} for the " + branding.ProductName + " version and {{ .Artifact }} for the artifact name.", 42 | defaultValue: branding.DefaultMirrorURLTemplate, 43 | applyConfig: func(value string) (tofudl.ConfigOpt, error) { 44 | return tofudl.ConfigDownloadMirrorURLTemplate(value), nil 45 | }, 46 | } 47 | 48 | var optionGPGKeyFile = option{ 49 | cliFlagName: "gpg-key-file", 50 | envVarName: branding.CLIEnvPrefix + "GPG_KEY_FILE", 51 | description: "GPG key file to verify downloaded artifacts against.", 52 | defaultDescription: fmt.Sprintf("bundled key, fingerprint %s", branding.GPGKeyFingerprint), 53 | applyConfig: func(value string) (tofudl.ConfigOpt, error) { 54 | gpgKey, err := os.ReadFile(value) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to read GPG key file %s (%w)", value, err) 57 | } 58 | return tofudl.ConfigGPGKey(string(gpgKey)), nil 59 | }, 60 | } 61 | 62 | var optionAPIAuthorization = option{ 63 | cliFlagName: "api-authorization", 64 | envVarName: branding.CLIEnvPrefix + "API_AUTHORIZATION", 65 | description: "Use the provided value as an 'Authorization' header when requesting data from the API server. This is not needed for the default settings, but may be needed for private mirrors.", 66 | applyConfig: func(value string) (tofudl.ConfigOpt, error) { 67 | return tofudl.ConfigAPIAuthorization(value), nil 68 | }, 69 | } 70 | 71 | var optionDownloadMirrorAuthorization = option{ 72 | cliFlagName: "download-mirror-authorization", 73 | envVarName: branding.CLIEnvPrefix + "DOWNLOAD_MIRROR_AUTHORIZATION", 74 | description: "Use the provided value as an 'Authorization' header when requesting data from the downloar mirror. You can set your GitHub token by specifying 'Bearer GITHUB_TOKEN' here to work around rate limits.", 75 | applyConfig: func(value string) (tofudl.ConfigOpt, error) { 76 | return tofudl.ConfigDownloadMirrorAuthorization(value), nil 77 | }, 78 | } 79 | 80 | var optionPlatform = option{ 81 | cliFlagName: "platform", 82 | envVarName: branding.CLIEnvPrefix + "PLATFORM", 83 | description: "Platform to download the binary for. Possible values are: " + getPlatformValues() + ", or a custom value.", 84 | defaultDescription: "current platform", 85 | applyDownloadOption: func(value string) (tofudl.DownloadOpt, error) { 86 | platform := tofudl.Platform(value) 87 | if err := platform.Validate(); err != nil { 88 | return nil, err 89 | } 90 | return tofudl.DownloadOptPlatform(platform), nil 91 | }, 92 | } 93 | 94 | func getPlatformValues() string { 95 | values := tofudl.PlatformValues() 96 | result := make([]string, len(values)) 97 | for i, value := range values { 98 | result[i] = string(value) 99 | } 100 | return strings.Join(result, ", ") 101 | } 102 | 103 | var optionArchitecture = option{ 104 | cliFlagName: "architecture", 105 | envVarName: branding.CLIEnvPrefix + "ARCHITECTURE", 106 | description: "Architecture to download the binary for. Possible values are: " + getArchitectureValues() + ", or a custom value.", 107 | defaultDescription: "current platform", 108 | applyDownloadOption: func(value string) (tofudl.DownloadOpt, error) { 109 | architecture := tofudl.Architecture(value) 110 | if err := architecture.Validate(); err != nil { 111 | return nil, err 112 | } 113 | return tofudl.DownloadOptArchitecture(architecture), nil 114 | }, 115 | } 116 | 117 | func getArchitectureValues() string { 118 | values := tofudl.ArchitectureValues() 119 | result := make([]string, len(values)) 120 | for i, value := range values { 121 | result[i] = string(value) 122 | } 123 | return strings.Join(result, ", ") 124 | } 125 | 126 | var optionVersion = option{ 127 | cliFlagName: "version", 128 | envVarName: branding.CLIEnvPrefix + "VERSION", 129 | description: "Exact version to download.", 130 | defaultDescription: "latest version matching the minimum stability", 131 | applyDownloadOption: func(value string) (tofudl.DownloadOpt, error) { 132 | version := tofudl.Version(value) 133 | if err := version.Validate(); err != nil { 134 | return nil, err 135 | } 136 | return tofudl.DownloadOptVersion(version), nil 137 | }, 138 | } 139 | 140 | var optionStability = option{ 141 | cliFlagName: "minimum-stability", 142 | envVarName: branding.CLIEnvPrefix + "MINIMUM_STABILITY", 143 | description: "Minimum stability to download for. Possible values are: " + getStabilityValues() + "", 144 | defaultDescription: "stable", 145 | applyDownloadOption: func(value string) (tofudl.DownloadOpt, error) { 146 | stability := tofudl.Stability(value) 147 | if err := stability.Validate(); err != nil { 148 | return nil, err 149 | } 150 | return tofudl.DownloadOptMinimumStability(stability), nil 151 | }, 152 | } 153 | 154 | func getStabilityValues() string { 155 | values := tofudl.StabilityValues() 156 | result := make([]string, len(values)) 157 | for i, value := range values { 158 | result[i] = string(value) 159 | } 160 | return strings.Join(result, ", ") 161 | } 162 | 163 | var optionTimeout = option{ 164 | cliFlagName: "timeout", 165 | envVarName: branding.CLIEnvPrefix + "TIMEOUT", 166 | description: "Download timeout in seconds.", 167 | defaultValue: "300", 168 | validate: func(value string) error { 169 | v, err := strconv.Atoi(value) 170 | if err != nil { 171 | return fmt.Errorf("invalid integer: %s", value) 172 | } 173 | if v < 1 { 174 | return fmt.Errorf("timeout must be positive: %s", value) 175 | } 176 | return nil 177 | }, 178 | } 179 | 180 | var optionOutput = option{ 181 | cliFlagName: "output", 182 | envVarName: branding.CLIEnvPrefix + "OUTPUT", 183 | description: "Write the " + branding.BinaryName + " to this file.", 184 | defaultValue: getDefaultFile(), 185 | } 186 | 187 | func getDefaultFile() string { 188 | defaultFile := branding.BinaryName 189 | if isWindows { 190 | defaultFile += ".exe" 191 | } 192 | return defaultFile 193 | } 194 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | // Package cli is a demonstration how a CLI downloader can be implemented with this library. In order to not include 5 | // additional dependencies, it implements CLI argument parsing and environment variable handling. 6 | package cli 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "io" 12 | "os" 13 | "runtime" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "github.com/opentofu/tofudl" 19 | "github.com/opentofu/tofudl/branding" 20 | ) 21 | 22 | // New creates a new CLI interface to run the downloader on. For API usage please refer to tofudl.New. 23 | func New() CLI { 24 | return &cli{ 25 | configOptions: []option{ 26 | optionAPIURL, 27 | optionDownloadMirrorURLTemplate, 28 | optionGPGKeyFile, 29 | optionAPIAuthorization, 30 | optionDownloadMirrorAuthorization, 31 | optionPlatform, 32 | optionArchitecture, 33 | optionVersion, 34 | optionStability, 35 | optionTimeout, 36 | optionOutput, 37 | }, 38 | outputFileWriter: os.WriteFile, 39 | } 40 | } 41 | 42 | // CLI is a command-line downloader. This is for CLI use only. For API usage please refer to tofudl.Downloader. 43 | type CLI interface { 44 | // Run executes the downloader non-interactively with the given options and returns the exit code. 45 | Run(argv []string, env []string, stdout io.Writer, stderr io.Writer) int 46 | } 47 | 48 | const isWindows = runtime.GOOS == "windows" 49 | 50 | type cli struct { 51 | configOptions []option 52 | outputFileWriter func(fileName string, bytes []byte, mode os.FileMode) error 53 | } 54 | 55 | func (c cli) Run( 56 | argv []string, 57 | env []string, 58 | stdout io.Writer, 59 | stderr io.Writer, 60 | ) int { 61 | for _, arg := range argv { 62 | if arg == "-h" || arg == "--help" { 63 | c.Usage(stdout) 64 | return 0 65 | } 66 | } 67 | 68 | args, err := argvToMap(argv[1:]) 69 | if err != nil { 70 | _, _ = stderr.Write([]byte(fmt.Sprintf("Failed to parse command line arguments: %s", err.Error()))) 71 | c.Usage(stdout) 72 | return 1 73 | } 74 | 75 | envVars, err := envToMap(env) 76 | if err != nil { 77 | _, _ = stderr.Write([]byte(fmt.Sprintf("Failed to parse environment variables: %s", err.Error()))) 78 | c.Usage(stdout) 79 | return 1 80 | } 81 | 82 | var configOpts []tofudl.ConfigOpt 83 | var downloadOpts []tofudl.DownloadOpt 84 | storedConfigs := map[string]string{} 85 | for _, cliOpt := range c.configOptions { 86 | value := "" 87 | optName := "" 88 | if cliOpt.envVarName != "" { 89 | if envValue, ok := envVars[cliOpt.envVarName]; ok { 90 | value = envValue 91 | optName = "environment variable " + cliOpt.envVarName 92 | } 93 | } 94 | if cliOpt.cliFlagName != "" { 95 | if cliValue, ok := args[cliOpt.cliFlagName]; ok { 96 | value = cliValue 97 | delete(args, cliOpt.cliFlagName) 98 | optName = "command line option " + cliOpt.cliFlagName 99 | } 100 | } 101 | if value == "" { 102 | value = cliOpt.defaultValue 103 | var parts []string 104 | if cliOpt.cliFlagName != "" { 105 | parts = append(parts, "--"+cliOpt.cliFlagName) 106 | } 107 | if cliOpt.envVarName != "" { 108 | parts = append(parts, cliOpt.envVarName) 109 | } 110 | if len(parts) == 0 { 111 | parts = append(parts, "unnamed option") 112 | } 113 | optName = "default value for " + strings.Join(parts, "/") 114 | } 115 | if value != "" { //nolint:nestif // Slightly complex, but easier to keep in one function. 116 | if cliOpt.validate != nil { 117 | if err := cliOpt.validate(value); err != nil { 118 | _, _ = stderr.Write([]byte(fmt.Sprintf("Failed to parse %s (%v)", optName, err))) 119 | c.Usage(stdout) 120 | return 1 121 | } 122 | } 123 | 124 | if cliOpt.applyConfig != nil { 125 | opt, err := cliOpt.applyConfig(value) 126 | if err != nil { 127 | _, _ = stderr.Write([]byte(fmt.Sprintf("Failed to parse %s (%v)", optName, err))) 128 | c.Usage(stdout) 129 | return 1 130 | } 131 | configOpts = append(configOpts, opt) 132 | } 133 | 134 | if cliOpt.applyDownloadOption != nil { 135 | opt, err := cliOpt.applyDownloadOption(value) 136 | if err != nil { 137 | _, _ = stderr.Write([]byte(fmt.Sprintf("Failed to parse %s (%v)", optName, err))) 138 | c.Usage(stdout) 139 | return 1 140 | } 141 | downloadOpts = append(downloadOpts, opt) 142 | } 143 | 144 | if cliOpt.cliFlagName != "" { 145 | storedConfigs[cliOpt.cliFlagName] = value 146 | } 147 | } 148 | } 149 | 150 | if len(args) != 0 { 151 | for arg := range args { 152 | _, _ = stderr.Write([]byte(fmt.Sprintf("Invalid command line option: %s", arg))) 153 | } 154 | c.Usage(stdout) 155 | return 1 156 | } 157 | 158 | dl, err := tofudl.New(configOpts...) 159 | if err != nil { 160 | _, _ = stderr.Write([]byte(err.Error())) 161 | c.Usage(stdout) 162 | return 1 163 | } 164 | 165 | timeout, _ := strconv.Atoi(storedConfigs[optionTimeout.cliFlagName]) 166 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(timeout)) 167 | defer cancel() 168 | 169 | binaryContents, err := dl.Download(ctx, downloadOpts...) 170 | if err != nil { 171 | _, _ = stderr.Write([]byte(err.Error())) 172 | return 1 173 | } 174 | if err := c.outputFileWriter(storedConfigs[optionOutput.cliFlagName], binaryContents, 0755); err != nil { 175 | _, _ = stderr.Write([]byte( 176 | fmt.Sprintf("Failed to write output file: %s", storedConfigs[optionOutput.cliFlagName]), 177 | )) 178 | return 1 179 | } 180 | return 0 181 | } 182 | 183 | func (c cli) Usage(stdout io.Writer) { 184 | binaryName := branding.CLIBinaryName 185 | if isWindows { 186 | binaryName += ".exe" 187 | } 188 | 189 | _, _ = stdout.Write([]byte("Usage: " + binaryName + " [OPTIONS]\n")) 190 | _, _ = stdout.Write([]byte("\nOPTIONS:\n\n")) 191 | 192 | for _, opt := range c.configOptions { 193 | var parts []string 194 | if opt.cliFlagName != "" { 195 | parts = append(parts, "--"+opt.cliFlagName) 196 | } 197 | if opt.envVarName != "" { 198 | if isWindows { 199 | parts = append(parts, "$Env:"+opt.envVarName) 200 | } else { 201 | parts = append(parts, "$"+opt.envVarName) 202 | } 203 | } 204 | firstLine := strings.Join(parts, " / ") 205 | if opt.defaultValue != "" { 206 | firstLine += " (Default: " + opt.defaultValue + ")" 207 | } else if opt.defaultDescription != "" { 208 | firstLine += " (Default: " + opt.defaultDescription + ")" 209 | } 210 | _, _ = stdout.Write([]byte(firstLine)) 211 | _, _ = stdout.Write([]byte("\n\n")) 212 | _, _ = stdout.Write([]byte(" " + opt.description)) 213 | _, _ = stdout.Write([]byte("\n\n")) 214 | } 215 | } 216 | 217 | func argvToMap(argv []string) (map[string]string, error) { 218 | result := map[string]string{} 219 | for { 220 | if len(argv) == 0 { 221 | return result, nil 222 | } 223 | if len(argv) == 1 { 224 | return result, fmt.Errorf("unexpected argument or value missing: %s", argv[0]) 225 | } 226 | if !strings.HasPrefix(argv[0], "--") { 227 | return nil, fmt.Errorf("unexpected argument: %s", argv[0]) 228 | } 229 | result[argv[0][2:]] = argv[1] 230 | argv = argv[2:] 231 | } 232 | } 233 | func envToMap(env []string) (map[string]string, error) { 234 | result := map[string]string{} 235 | for _, e := range env { 236 | parts := strings.SplitN(e, "=", 2) 237 | if len(parts) != 2 { 238 | return nil, fmt.Errorf("invalid environment variable: %s", e) 239 | } 240 | result[parts[0]] = parts[1] 241 | } 242 | return result, nil 243 | } 244 | -------------------------------------------------------------------------------- /release_builder.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) The OpenTofu Authors 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package tofudl 5 | 6 | import ( 7 | "archive/tar" 8 | "bytes" 9 | "compress/gzip" 10 | "context" 11 | "crypto/sha256" 12 | "encoding/hex" 13 | "fmt" 14 | "io/fs" 15 | "time" 16 | 17 | "github.com/ProtonMail/gopenpgp/v2/crypto" 18 | "github.com/opentofu/tofudl/branding" 19 | ) 20 | 21 | // ReleaseBuilder is a tool to build a release and add it to a mirror. Note that this does not (yet) produce a full 22 | // release suitable for end users as this does not support signing with cosign and does not produce other artifacts. 23 | type ReleaseBuilder interface { 24 | // PackageBinary creates a .tar.gz file for the specific platform and architecture based on the binary contents. 25 | // You may pass extra files to package, such as LICENSE, etc. in extraFiles. 26 | PackageBinary(platform Platform, architecture Architecture, contents []byte, extraFiles map[string][]byte) error 27 | 28 | // AddArtifact adds an artifact to the release, adds it to the checksum file and signs the checksum file. 29 | AddArtifact(artifactName string, data []byte) error 30 | 31 | // Build builds the release and adds it to the specified mirror. Note that the ReleaseBuilder should not be 32 | // reused after calling Build. 33 | Build(ctx context.Context, version Version, mirror Mirror) error 34 | } 35 | 36 | // NewReleaseBuilder creates a new ReleaseBuilder with the given gpgKey to sign the release. 37 | func NewReleaseBuilder(gpgKey *crypto.Key) (ReleaseBuilder, error) { 38 | return &releaseBuilder{ 39 | gpgKey: gpgKey, 40 | binaries: nil, 41 | artifacts: map[string][]byte{}, 42 | }, nil 43 | } 44 | 45 | type releaseBinary struct { 46 | platform Platform 47 | architecture Architecture 48 | contents []byte 49 | extraFiles map[string][]byte 50 | } 51 | 52 | type releaseBuilder struct { 53 | gpgKey *crypto.Key 54 | binaries []releaseBinary 55 | artifacts map[string][]byte 56 | } 57 | 58 | func (r *releaseBuilder) PackageBinary(platform Platform, architecture Architecture, contents []byte, extraFiles map[string][]byte) error { 59 | var err error 60 | platform, err = platform.ResolveAuto() 61 | if err != nil { 62 | return err 63 | } 64 | architecture, err = architecture.ResolveAuto() 65 | if err != nil { 66 | return err 67 | } 68 | r.binaries = append(r.binaries, releaseBinary{ 69 | platform: platform, 70 | architecture: architecture, 71 | contents: contents, 72 | extraFiles: extraFiles, 73 | }) 74 | return nil 75 | } 76 | 77 | func (r *releaseBuilder) AddArtifact(artifactName string, data []byte) error { 78 | r.artifacts[artifactName] = data 79 | return nil 80 | } 81 | 82 | func (r *releaseBuilder) Build(ctx context.Context, version Version, mirror Mirror) error { 83 | if err := version.Validate(); err != nil { 84 | return err 85 | } 86 | for _, binary := range r.binaries { 87 | tarFile, err := buildTarFile(binary.contents, binary.extraFiles) 88 | if err != nil { 89 | return fmt.Errorf("failed to build archive for %s / %s (%w)", binary.platform, binary.architecture, err) 90 | } 91 | if err := r.AddArtifact(branding.ArtifactPrefix+string(version)+"_"+string(binary.platform)+"_"+string(binary.architecture)+".tar.gz", tarFile); err != nil { 92 | return fmt.Errorf("failed to add tar file as artifact (%w)", err) 93 | } 94 | } 95 | 96 | sums := r.buildSumsFile() 97 | sumsSig, err := r.signFile(sums) 98 | if err != nil { 99 | return fmt.Errorf("cannot sign checksum file (%w)", err) 100 | } 101 | if err := r.AddArtifact(branding.ArtifactPrefix+string(version)+"_SHA256SUMS", sums); err != nil { 102 | return fmt.Errorf("failed to add checksum file (%w)", err) 103 | } 104 | if err := r.AddArtifact(branding.ArtifactPrefix+string(version)+"_SHA256SUMS.gpgsig", sumsSig); err != nil { 105 | return fmt.Errorf("failed to add checksum signature file (%w)", err) 106 | } 107 | 108 | if err := mirror.CreateVersion(ctx, version); err != nil { 109 | return fmt.Errorf("failed to create version on mirror (%w)", err) 110 | } 111 | for artifactName, artifact := range r.artifacts { 112 | if err := mirror.CreateVersionAsset(ctx, version, artifactName, artifact); err != nil { 113 | return fmt.Errorf("cannot create version asset %s in mirror (%w)", artifactName, err) 114 | } 115 | } 116 | return nil 117 | } 118 | 119 | func (r *releaseBuilder) buildSumsFile() []byte { 120 | result := "" 121 | for filename, contents := range r.artifacts { 122 | hash := sha256.New() 123 | hash.Write(contents) 124 | checksum := hex.EncodeToString(hash.Sum(nil)) 125 | result += checksum + " " + filename + "\n" 126 | } 127 | return []byte(result) 128 | } 129 | 130 | func (r *releaseBuilder) signFile(contents []byte) ([]byte, error) { 131 | msg := crypto.NewPlainMessage(contents) 132 | keyring, err := crypto.NewKeyRing(r.gpgKey) 133 | if err != nil { 134 | return nil, fmt.Errorf("failed to construct keyring (%w)", err) 135 | } 136 | signature, err := keyring.SignDetached(msg) 137 | if err != nil { 138 | return nil, fmt.Errorf("failed to sign data (%w)", err) 139 | } 140 | return signature.GetBinary(), nil 141 | } 142 | 143 | func buildTarFile(binary []byte, extraFiles map[string][]byte) ([]byte, error) { 144 | buf := &bytes.Buffer{} 145 | gzipWriter := gzip.NewWriter(buf) 146 | 147 | tarWriter := tar.NewWriter(gzipWriter) 148 | 149 | header, err := tar.FileInfoHeader(&fileInfo{ 150 | name: branding.PlatformBinaryName, 151 | size: int64(len(binary)), 152 | mode: 0755, 153 | modTime: time.Now(), 154 | isDir: false, 155 | }, branding.PlatformBinaryName) 156 | if err != nil { 157 | return nil, fmt.Errorf("failed to construct tar file header (%w)", err) 158 | } 159 | if err := tarWriter.WriteHeader(header); err != nil { 160 | return nil, fmt.Errorf("failed to write file header to tar file (%w)", err) 161 | } 162 | if _, err := tarWriter.Write(binary); err != nil { 163 | return nil, fmt.Errorf("failed to write binary to tar file (%w)", err) 164 | } 165 | 166 | for file, contents := range extraFiles { 167 | header, err = tar.FileInfoHeader( 168 | &fileInfo{ 169 | name: file, 170 | size: int64(len(contents)), 171 | mode: 0644, 172 | modTime: time.Now(), 173 | isDir: false, 174 | }, file) 175 | if err != nil { 176 | return nil, fmt.Errorf("failed to construct tar file header (%w)", err) 177 | } 178 | if err := tarWriter.WriteHeader(header); err != nil { 179 | return nil, fmt.Errorf("failed to write file header to tar file (%w)", err) 180 | } 181 | if _, err := tarWriter.Write(binary); err != nil { 182 | return nil, fmt.Errorf("failed to write binary to tar file (%w)", err) 183 | } 184 | } 185 | 186 | if err := tarWriter.Close(); err != nil { 187 | return nil, fmt.Errorf("failed to close tar writer (%w)", err) 188 | } 189 | if err := gzipWriter.Close(); err != nil { 190 | return nil, fmt.Errorf("failed to close gzip writer (%w)", err) 191 | } 192 | return buf.Bytes(), nil 193 | } 194 | 195 | type fileInfo struct { 196 | name string 197 | size int64 198 | mode fs.FileMode 199 | modTime time.Time 200 | isDir bool 201 | } 202 | 203 | func (f fileInfo) Name() string { 204 | return f.name 205 | } 206 | 207 | func (f fileInfo) Size() int64 { 208 | return f.size 209 | } 210 | 211 | func (f fileInfo) Mode() fs.FileMode { 212 | return f.mode 213 | } 214 | 215 | func (f fileInfo) ModTime() time.Time { 216 | return f.modTime 217 | } 218 | 219 | func (f fileInfo) IsDir() bool { 220 | return f.isDir 221 | } 222 | 223 | func (f fileInfo) Sys() any { 224 | return nil 225 | } 226 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) The OpenTofu Authors 2 | 3 | Mozilla Public License, version 2.0 4 | 5 | 1. Definitions 6 | 7 | 1.1. “Contributor” 8 | 9 | means each individual or legal entity that creates, contributes to the 10 | creation of, or owns Covered Software. 11 | 12 | 1.2. “Contributor Version” 13 | 14 | means the combination of the Contributions of others (if any) used by a 15 | Contributor and that particular Contributor’s Contribution. 16 | 17 | 1.3. “Contribution” 18 | 19 | means Covered Software of a particular Contributor. 20 | 21 | 1.4. “Covered Software” 22 | 23 | means Source Code Form to which the initial Contributor has attached the 24 | notice in Exhibit A, the Executable Form of such Source Code Form, and 25 | Modifications of such Source Code Form, in each case including portions 26 | thereof. 27 | 28 | 1.5. “Incompatible With Secondary Licenses” 29 | means 30 | 31 | a. that the initial Contributor has attached the notice described in 32 | Exhibit B to the Covered Software; or 33 | 34 | b. that the Covered Software was made available under the terms of version 35 | 1.1 or earlier of the License, but not also under the terms of a 36 | Secondary License. 37 | 38 | 1.6. “Executable Form” 39 | 40 | means any form of the work other than Source Code Form. 41 | 42 | 1.7. “Larger Work” 43 | 44 | means a work that combines Covered Software with other material, in a separate 45 | file or files, that is not Covered Software. 46 | 47 | 1.8. “License” 48 | 49 | means this document. 50 | 51 | 1.9. “Licensable” 52 | 53 | means having the right to grant, to the maximum extent possible, whether at the 54 | time of the initial grant or subsequently, any and all of the rights conveyed by 55 | this License. 56 | 57 | 1.10. “Modifications” 58 | 59 | means any of the following: 60 | 61 | a. any file in Source Code Form that results from an addition to, deletion 62 | from, or modification of the contents of Covered Software; or 63 | 64 | b. any new file in Source Code Form that contains any Covered Software. 65 | 66 | 1.11. “Patent Claims” of a Contributor 67 | 68 | means any patent claim(s), including without limitation, method, process, 69 | and apparatus claims, in any patent Licensable by such Contributor that 70 | would be infringed, but for the grant of the License, by the making, 71 | using, selling, offering for sale, having made, import, or transfer of 72 | either its Contributions or its Contributor Version. 73 | 74 | 1.12. “Secondary License” 75 | 76 | means either the GNU General Public License, Version 2.0, the GNU Lesser 77 | General Public License, Version 2.1, the GNU Affero General Public 78 | License, Version 3.0, or any later versions of those licenses. 79 | 80 | 1.13. “Source Code Form” 81 | 82 | means the form of the work preferred for making modifications. 83 | 84 | 1.14. “You” (or “Your”) 85 | 86 | means an individual or a legal entity exercising rights under this 87 | License. For legal entities, “You” includes any entity that controls, is 88 | controlled by, or is under common control with You. For purposes of this 89 | definition, “control” means (a) the power, direct or indirect, to cause 90 | the direction or management of such entity, whether by contract or 91 | otherwise, or (b) ownership of more than fifty percent (50%) of the 92 | outstanding shares or beneficial ownership of such entity. 93 | 94 | 95 | 2. License Grants and Conditions 96 | 97 | 2.1. Grants 98 | 99 | Each Contributor hereby grants You a world-wide, royalty-free, 100 | non-exclusive license: 101 | 102 | a. under intellectual property rights (other than patent or trademark) 103 | Licensable by such Contributor to use, reproduce, make available, 104 | modify, display, perform, distribute, and otherwise exploit its 105 | Contributions, either on an unmodified basis, with Modifications, or as 106 | part of a Larger Work; and 107 | 108 | b. under Patent Claims of such Contributor to make, use, sell, offer for 109 | sale, have made, import, and otherwise transfer either its Contributions 110 | or its Contributor Version. 111 | 112 | 2.2. Effective Date 113 | 114 | The licenses granted in Section 2.1 with respect to any Contribution become 115 | effective for each Contribution on the date the Contributor first distributes 116 | such Contribution. 117 | 118 | 2.3. Limitations on Grant Scope 119 | 120 | The licenses granted in this Section 2 are the only rights granted under this 121 | License. No additional rights or licenses will be implied from the distribution 122 | or licensing of Covered Software under this License. Notwithstanding Section 123 | 2.1(b) above, no patent license is granted by a Contributor: 124 | 125 | a. for any code that a Contributor has removed from Covered Software; or 126 | 127 | b. for infringements caused by: (i) Your and any other third party’s 128 | modifications of Covered Software, or (ii) the combination of its 129 | Contributions with other software (except as part of its Contributor 130 | Version); or 131 | 132 | c. under Patent Claims infringed by Covered Software in the absence of its 133 | Contributions. 134 | 135 | This License does not grant any rights in the trademarks, service marks, or 136 | logos of any Contributor (except as may be necessary to comply with the 137 | notice requirements in Section 3.4). 138 | 139 | 2.4. Subsequent Licenses 140 | 141 | No Contributor makes additional grants as a result of Your choice to 142 | distribute the Covered Software under a subsequent version of this License 143 | (see Section 10.2) or under the terms of a Secondary License (if permitted 144 | under the terms of Section 3.3). 145 | 146 | 2.5. Representation 147 | 148 | Each Contributor represents that the Contributor believes its Contributions 149 | are its original creation(s) or it has sufficient rights to grant the 150 | rights to its Contributions conveyed by this License. 151 | 152 | 2.6. Fair Use 153 | 154 | This License is not intended to limit any rights You have under applicable 155 | copyright doctrines of fair use, fair dealing, or other equivalents. 156 | 157 | 2.7. Conditions 158 | 159 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 160 | Section 2.1. 161 | 162 | 163 | 3. Responsibilities 164 | 165 | 3.1. Distribution of Source Form 166 | 167 | All distribution of Covered Software in Source Code Form, including any 168 | Modifications that You create or to which You contribute, must be under the 169 | terms of this License. You must inform recipients that the Source Code Form 170 | of the Covered Software is governed by the terms of this License, and how 171 | they can obtain a copy of this License. You may not attempt to alter or 172 | restrict the recipients’ rights in the Source Code Form. 173 | 174 | 3.2. Distribution of Executable Form 175 | 176 | If You distribute Covered Software in Executable Form then: 177 | 178 | a. such Covered Software must also be made available in Source Code Form, 179 | as described in Section 3.1, and You must inform recipients of the 180 | Executable Form how they can obtain a copy of such Source Code Form by 181 | reasonable means in a timely manner, at a charge no more than the cost 182 | of distribution to the recipient; and 183 | 184 | b. You may distribute such Executable Form under the terms of this License, 185 | or sublicense it under different terms, provided that the license for 186 | the Executable Form does not attempt to limit or alter the recipients’ 187 | rights in the Source Code Form under this License. 188 | 189 | 3.3. Distribution of a Larger Work 190 | 191 | You may create and distribute a Larger Work under terms of Your choice, 192 | provided that You also comply with the requirements of this License for the 193 | Covered Software. If the Larger Work is a combination of Covered Software 194 | with a work governed by one or more Secondary Licenses, and the Covered 195 | Software is not Incompatible With Secondary Licenses, this License permits 196 | You to additionally distribute such Covered Software under the terms of 197 | such Secondary License(s), so that the recipient of the Larger Work may, at 198 | their option, further distribute the Covered Software under the terms of 199 | either this License or such Secondary License(s). 200 | 201 | 3.4. Notices 202 | 203 | You may not remove or alter the substance of any license notices (including 204 | copyright notices, patent notices, disclaimers of warranty, or limitations 205 | of liability) contained within the Source Code Form of the Covered 206 | Software, except that You may alter any license notices to the extent 207 | required to remedy known factual inaccuracies. 208 | 209 | 3.5. Application of Additional Terms 210 | 211 | You may choose to offer, and to charge a fee for, warranty, support, 212 | indemnity or liability obligations to one or more recipients of Covered 213 | Software. However, You may do so only on Your own behalf, and not on behalf 214 | of any Contributor. You must make it absolutely clear that any such 215 | warranty, support, indemnity, or liability obligation is offered by You 216 | alone, and You hereby agree to indemnify every Contributor for any 217 | liability incurred by such Contributor as a result of warranty, support, 218 | indemnity or liability terms You offer. You may include additional 219 | disclaimers of warranty and limitations of liability specific to any 220 | jurisdiction. 221 | 222 | 4. Inability to Comply Due to Statute or Regulation 223 | 224 | If it is impossible for You to comply with any of the terms of this License 225 | with respect to some or all of the Covered Software due to statute, judicial 226 | order, or regulation then You must: (a) comply with the terms of this License 227 | to the maximum extent possible; and (b) describe the limitations and the code 228 | they affect. Such description must be placed in a text file included with all 229 | distributions of the Covered Software under this License. Except to the 230 | extent prohibited by statute or regulation, such description must be 231 | sufficiently detailed for a recipient of ordinary skill to be able to 232 | understand it. 233 | 234 | 5. Termination 235 | 236 | 5.1. The rights granted under this License will terminate automatically if You 237 | fail to comply with any of its terms. However, if You become compliant, 238 | then the rights granted under this License from a particular Contributor 239 | are reinstated (a) provisionally, unless and until such Contributor 240 | explicitly and finally terminates Your grants, and (b) on an ongoing basis, 241 | if such Contributor fails to notify You of the non-compliance by some 242 | reasonable means prior to 60 days after You have come back into compliance. 243 | Moreover, Your grants from a particular Contributor are reinstated on an 244 | ongoing basis if such Contributor notifies You of the non-compliance by 245 | some reasonable means, this is the first time You have received notice of 246 | non-compliance with this License from such Contributor, and You become 247 | compliant prior to 30 days after Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, counter-claims, 251 | and cross-claims) alleging that a Contributor Version directly or 252 | indirectly infringes any patent, then the rights granted to You by any and 253 | all Contributors for the Covered Software under Section 2.1 of this License 254 | shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 257 | license agreements (excluding distributors and resellers) which have been 258 | validly granted by You or Your distributors under this License prior to 259 | termination shall survive termination. 260 | 261 | 6. Disclaimer of Warranty 262 | 263 | Covered Software is provided under this License on an “as is” basis, without 264 | warranty of any kind, either expressed, implied, or statutory, including, 265 | without limitation, warranties that the Covered Software is free of defects, 266 | merchantable, fit for a particular purpose or non-infringing. The entire 267 | risk as to the quality and performance of the Covered Software is with You. 268 | Should any Covered Software prove defective in any respect, You (not any 269 | Contributor) assume the cost of any necessary servicing, repair, or 270 | correction. This disclaimer of warranty constitutes an essential part of this 271 | License. No use of any Covered Software is authorized under this License 272 | except under this disclaimer. 273 | 274 | 7. Limitation of Liability 275 | 276 | Under no circumstances and under no legal theory, whether tort (including 277 | negligence), contract, or otherwise, shall any Contributor, or anyone who 278 | distributes Covered Software as permitted above, be liable to You for any 279 | direct, indirect, special, incidental, or consequential damages of any 280 | character including, without limitation, damages for lost profits, loss of 281 | goodwill, work stoppage, computer failure or malfunction, or any and all 282 | other commercial damages or losses, even if such party shall have been 283 | informed of the possibility of such damages. This limitation of liability 284 | shall not apply to liability for death or personal injury resulting from such 285 | party’s negligence to the extent applicable law prohibits such limitation. 286 | Some jurisdictions do not allow the exclusion or limitation of incidental or 287 | consequential damages, so this exclusion and limitation may not apply to You. 288 | 289 | 8. Litigation 290 | 291 | Any litigation relating to this License may be brought only in the courts of 292 | a jurisdiction where the defendant maintains its principal place of business 293 | and such litigation shall be governed by laws of that jurisdiction, without 294 | reference to its conflict-of-law provisions. Nothing in this Section shall 295 | prevent a party’s ability to bring cross-claims or counter-claims. 296 | 297 | 9. Miscellaneous 298 | 299 | This License represents the complete agreement concerning the subject matter 300 | hereof. If any provision of this License is held to be unenforceable, such 301 | provision shall be reformed only to the extent necessary to make it 302 | enforceable. Any law or regulation which provides that the language of a 303 | contract shall be construed against the drafter shall not be used to construe 304 | this License against a Contributor. 305 | 306 | 307 | 10. Versions of the License 308 | 309 | 10.1. New Versions 310 | 311 | Mozilla Foundation is the license steward. Except as provided in Section 312 | 10.3, no one other than the license steward has the right to modify or 313 | publish new versions of this License. Each version will be given a 314 | distinguishing version number. 315 | 316 | 10.2. Effect of New Versions 317 | 318 | You may distribute the Covered Software under the terms of the version of 319 | the License under which You originally received the Covered Software, or 320 | under the terms of any subsequent version published by the license 321 | steward. 322 | 323 | 10.3. Modified Versions 324 | 325 | If you create software not governed by this License, and you want to 326 | create a new license for such software, you may create and use a modified 327 | version of this License if you rename the license and remove any 328 | references to the name of the license steward (except to note that such 329 | modified license differs from this License). 330 | 331 | 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 332 | If You choose to distribute Source Code Form that is Incompatible With 333 | Secondary Licenses under the terms of this version of the License, the 334 | notice described in Exhibit B of this License must be attached. 335 | 336 | Exhibit A - Source Code Form License Notice 337 | 338 | This Source Code Form is subject to the 339 | terms of the Mozilla Public License, v. 340 | 2.0. If a copy of the MPL was not 341 | distributed with this file, You can 342 | obtain one at 343 | http://mozilla.org/MPL/2.0/. 344 | 345 | If it is not possible or desirable to put the notice in a particular file, then 346 | You may include the notice in a location (such as a LICENSE file in a relevant 347 | directory) where a recipient would be likely to look for such a notice. 348 | 349 | You may add additional accurate notices of copyright ownership. 350 | 351 | Exhibit B - “Incompatible With Secondary Licenses” Notice 352 | 353 | This Source Code Form is “Incompatible 354 | With Secondary Licenses”, as defined by 355 | the Mozilla Public License, v. 2.0. 356 | 357 | --------------------------------------------------------------------------------