Skip to content

Commit 595ba9c

Browse files
authored
[pull] Implement pull tar (#1043)
## Summary This implements: ```bash devbox global pull https://example.com/file.tar.gz ``` Since this can generate conflicts (because of existing files), there's a new `--force` flag that automatically overwrites. If flag is not specified and there's a conflict, we ask the user if they wan to overwrite. TODO: - [x] We should do an install after a successful pull TODO in follow up: - [ ] Currently we always ask the survey question because `devbox.json` is created by default. So ideally if the devbox.json is empty, we always overwrite. This requires some refactoring that I would prefer not to include in this PR. cc: @bketelsen ## How was it tested? ```bash devbox global pull https://fleekgen.fly.dev/high devbox global pull https://fleekgen.fly.dev/high --force ```
1 parent b35250d commit 595ba9c

File tree

7 files changed

+215
-13
lines changed

7 files changed

+215
-13
lines changed

devbox.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ type Devbox interface {
3333
PrintEnv(ctx context.Context, includeHooks bool) (string, error)
3434
PrintGlobalList() error
3535
PrintEnvrcContent(w io.Writer) error
36-
PullGlobal(ctx context.Context, path string) error
36+
PullGlobal(ctx context.Context, overwrite bool, path string) error
3737
// Remove removes Nix packages from the config so that it no longer exists in
3838
// the devbox environment.
3939
Remove(ctx context.Context, pkgs ...string) error

devbox.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@
2323
"nixpkgs": {
2424
"commit": "3364b5b117f65fe1ce65a3cdd5612a078a3b31e3"
2525
}
26-
}
26+
}

internal/boxcli/global.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ package boxcli
55

66
import (
77
"fmt"
8+
"io/fs"
89

10+
"github.com/AlecAivazis/survey/v2"
911
"github.com/pkg/errors"
1012
"github.com/spf13/cobra"
11-
1213
"go.jetpack.io/devbox"
1314
"go.jetpack.io/devbox/internal/ux"
1415
)
1516

17+
type globalPullCmdFlags struct {
18+
force bool
19+
}
20+
1621
func globalCmd() *cobra.Command {
1722

1823
globalCmd := &cobra.Command{}
@@ -52,14 +57,24 @@ func globalListCmd() *cobra.Command {
5257
}
5358

5459
func globalPullCmd() *cobra.Command {
55-
return &cobra.Command{
60+
flags := globalPullCmdFlags{}
61+
cmd := &cobra.Command{
5662
Use: "pull <file> | <url>",
5763
Short: "Pull a global config from a file or URL",
5864
Long: "Pull a global config from a file or URL. URLs must be prefixed with 'http://' or 'https://'.",
5965
PreRunE: ensureNixInstalled,
60-
RunE: pullGlobalCmdFunc,
61-
Args: cobra.ExactArgs(1),
66+
RunE: func(cmd *cobra.Command, args []string) error {
67+
return pullGlobalCmdFunc(cmd, args, flags.force)
68+
},
69+
Args: cobra.ExactArgs(1),
6270
}
71+
72+
cmd.Flags().BoolVarP(
73+
&flags.force, "force", "f", false,
74+
"Force overwrite of existing global config files",
75+
)
76+
77+
return cmd
6378
}
6479

6580
func listGlobalCmdFunc(cmd *cobra.Command, args []string) error {
@@ -75,7 +90,11 @@ func listGlobalCmdFunc(cmd *cobra.Command, args []string) error {
7590
return box.PrintGlobalList()
7691
}
7792

78-
func pullGlobalCmdFunc(cmd *cobra.Command, args []string) error {
93+
func pullGlobalCmdFunc(
94+
cmd *cobra.Command,
95+
args []string,
96+
overwrite bool,
97+
) error {
7998
path, err := ensureGlobalConfig(cmd)
8099
if err != nil {
81100
return errors.WithStack(err)
@@ -85,7 +104,23 @@ func pullGlobalCmdFunc(cmd *cobra.Command, args []string) error {
85104
if err != nil {
86105
return errors.WithStack(err)
87106
}
88-
return box.PullGlobal(cmd.Context(), args[0])
107+
err = box.PullGlobal(cmd.Context(), overwrite, args[0])
108+
if errors.Is(err, fs.ErrExist) {
109+
prompt := &survey.Confirm{
110+
Message: "File(s) already exists. Overwrite?",
111+
}
112+
if err = survey.AskOne(prompt, &overwrite); err != nil {
113+
return errors.WithStack(err)
114+
}
115+
if overwrite {
116+
err = box.PullGlobal(cmd.Context(), overwrite, args[0])
117+
}
118+
}
119+
if err != nil {
120+
return err
121+
}
122+
123+
return installCmdFunc(cmd, runCmdFlags{config: configFlags{path: path}})
89124
}
90125

91126
var globalConfigPath string

internal/impl/global.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,21 @@ import (
1515
"github.com/pkg/errors"
1616
"github.com/samber/lo"
1717

18+
"go.jetpack.io/devbox/internal/pullbox"
1819
"go.jetpack.io/devbox/internal/xdg"
1920
)
2021

2122
// In the future we will support multiple global profiles
2223
const currentGlobalProfile = "default"
2324

24-
func (d *Devbox) PullGlobal(ctx context.Context, path string) error {
25+
func (d *Devbox) PullGlobal(
26+
ctx context.Context,
27+
force bool,
28+
path string,
29+
) error {
2530
u, err := url.Parse(path)
2631
if err == nil && u.Scheme != "" {
27-
return d.pullGlobalFromURL(ctx, u)
32+
return d.pullGlobalFromURL(ctx, force, u)
2833
}
2934
return d.pullGlobalFromPath(ctx, path)
3035
}
@@ -36,9 +41,25 @@ func (d *Devbox) PrintGlobalList() error {
3641
return nil
3742
}
3843

39-
func (d *Devbox) pullGlobalFromURL(ctx context.Context, u *url.URL) error {
40-
fmt.Fprintf(d.writer, "Pulling global config from %s\n", u)
41-
cfg, err := readConfigFromURL(u)
44+
func (d *Devbox) pullGlobalFromURL(
45+
ctx context.Context,
46+
overwrite bool,
47+
configURL *url.URL,
48+
) error {
49+
fmt.Fprintf(d.writer, "Pulling global config from %s\n", configURL)
50+
puller := pullbox.New()
51+
if ok, err := puller.URLIsArchive(configURL.String()); ok {
52+
fmt.Fprintf(
53+
d.writer,
54+
"%s is an archive, extracting to %s\n",
55+
configURL,
56+
d.ProjectDir(),
57+
)
58+
return puller.DownloadAndExtract(overwrite, configURL.String(), d.projectDir)
59+
} else if err != nil {
60+
return err
61+
}
62+
cfg, err := readConfigFromURL(configURL)
4263
if err != nil {
4364
return err
4465
}

internal/pullbox/download.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright 2023 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
package pullbox
5+
6+
import (
7+
"fmt"
8+
"io"
9+
"net/http"
10+
)
11+
12+
// Download downloads a file from the specified URL
13+
func download(url string) ([]byte, error) {
14+
response, err := http.Get(url)
15+
if err != nil {
16+
return nil, err
17+
}
18+
defer response.Body.Close()
19+
20+
if response.StatusCode != http.StatusOK {
21+
return nil, fmt.Errorf("failed to download file: %s", response.Status)
22+
}
23+
24+
data, err := io.ReadAll(response.Body)
25+
if err != nil {
26+
return nil, err
27+
}
28+
29+
return data, nil
30+
}

internal/pullbox/pullbox.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2023 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
package pullbox
5+
6+
import (
7+
"net/http"
8+
"strings"
9+
)
10+
11+
type pullbox struct {
12+
}
13+
14+
func New() *pullbox {
15+
return &pullbox{}
16+
}
17+
18+
func (p *pullbox) DownloadAndExtract(overwrite bool, url, target string) error {
19+
data, err := download(url)
20+
if err != nil {
21+
return err
22+
}
23+
tmpDir, err := extract(data)
24+
if err != nil {
25+
return err
26+
}
27+
28+
return p.copy(overwrite, tmpDir, target)
29+
}
30+
31+
// URLIsArchive checks if a file URL points to an archive file
32+
func (p *pullbox) URLIsArchive(url string) (bool, error) {
33+
response, err := http.Head(url)
34+
if err != nil {
35+
return false, err
36+
}
37+
defer response.Body.Close()
38+
contentType := response.Header.Get("Content-Type")
39+
return strings.Contains(contentType, "tar") ||
40+
strings.Contains(contentType, "zip") ||
41+
strings.Contains(contentType, "octet-stream"), nil
42+
}

internal/pullbox/tar.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2023 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
package pullbox
5+
6+
import (
7+
"fmt"
8+
"io/fs"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"syscall"
13+
14+
"github.com/pkg/errors"
15+
)
16+
17+
// extract decompresses a tar file and saves it to a tmp directory
18+
func extract(data []byte) (string, error) {
19+
tempFile, err := os.CreateTemp("", "temp.tar.gz")
20+
if err != nil {
21+
return "", err
22+
}
23+
defer os.Remove(tempFile.Name())
24+
defer tempFile.Close()
25+
26+
_, err = tempFile.Write(data)
27+
if err != nil {
28+
return "", err
29+
}
30+
31+
tempDir, err := os.MkdirTemp("", "temp")
32+
if err != nil {
33+
return "", err
34+
}
35+
36+
cmd := exec.Command("tar", "-xf", tempFile.Name(), "-C", tempDir)
37+
38+
if err = cmd.Run(); err != nil {
39+
var exitErr *exec.ExitError
40+
if errors.As(err, &exitErr) {
41+
waitStatus := exitErr.Sys().(syscall.WaitStatus)
42+
return "", fmt.Errorf(
43+
"tar extraction failed with exit code: %d",
44+
waitStatus.ExitStatus(),
45+
)
46+
}
47+
return "", err
48+
}
49+
50+
return tempDir, nil
51+
}
52+
53+
func (p *pullbox) copy(overwrite bool, src, dst string) error {
54+
srcFiles, err := os.ReadDir(src)
55+
if err != nil {
56+
return errors.WithStack(err)
57+
}
58+
59+
if !overwrite {
60+
for _, srcFile := range srcFiles {
61+
if _, err := os.Stat(filepath.Join(dst, srcFile.Name())); err == nil {
62+
return fs.ErrExist
63+
}
64+
}
65+
}
66+
67+
for _, srcFile := range srcFiles {
68+
srcPath := filepath.Join(src, srcFile.Name())
69+
if err := exec.Command("cp", "-rf", srcPath, dst).Run(); err != nil {
70+
return err
71+
}
72+
}
73+
return nil
74+
}

0 commit comments

Comments
 (0)