├── 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 |
--------------------------------------------------------------------------------