diff --git a/README.md b/README.md index caa866932..dc42f13a5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ With over 52 helm charts and apps available for Kubernetes, gone are the days of - [Getting arkade](#getting-arkade) - [Usage overview](#usage-overview) - [Download CLI tools with arkade](#download-cli-tools-with-arkade) + - [Install System packages](#install-system-packages) - [Installing apps with arkade](#installing-apps-with-arkade) - [Community & contributing](#community--contributing) - [Sponsored apps](#sponsored-apps) @@ -154,6 +155,26 @@ Adding a new tool for download is as simple as editing [tools.go](https://github [Click here for the full catalog of CLIs](#catalog-of-apps) +## Install System packages + +System packages, or "system apps" are tools designed for installation on a Linux workstation, server or CI runner. + +These are a more limited group of applications designed for quick setup, scripting and CI, and generally do not fit into the `arkade get` pattern, due to additional installation steps or system configuration. + +```bash +arkade system install --help + +# Install latest version of Go to /usr/local/bin/go +arkade system install go + +# Install Go 1.18 to /tmp/go +arkade system install go \ + --version 1.18 \ + --path /tmp/ +``` + +System apps are in preview, see more details in the proposal: [Feature: system packages for Linux servers, CI and workstations #654](https://github.com/alexellis/arkade/issues/654) + ## Installing apps with arkade You'll need a Kubernetes cluster to arkade. Unlike cloud-based marketplaces, arkade doesn't have any special pre-requirements and can be used with any private or public cluster. diff --git a/cmd/system/go.go b/cmd/system/go.go new file mode 100644 index 000000000..b1b543522 --- /dev/null +++ b/cmd/system/go.go @@ -0,0 +1,130 @@ +// Copyright (c) arkade author(s) 2022. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +package system + +import ( + "fmt" + "io" + "net/http" + "os" + "path" + "strings" + + "github.com/alexellis/arkade/pkg/archive" + "github.com/alexellis/arkade/pkg/env" + "github.com/alexellis/arkade/pkg/get" + "github.com/spf13/cobra" +) + +func MakeInstallGo() *cobra.Command { + + command := &cobra.Command{ + Use: "go", + Short: "Install Go", + Long: `Install Go programming language and SDK.`, + Example: ` arkade system install go + arkade system install go --version v1.18.1`, + SilenceUsage: true, + } + + command.Flags().StringP("version", "v", "", "The version for Go, or leave blank for pinned version") + command.Flags().String("path", "/usr/local/", "Installation path, where a go subfolder will be created") + command.Flags().Bool("progress", true, "Show download progress") + + command.PreRunE = func(cmd *cobra.Command, args []string) error { + + return nil + } + + command.RunE = func(cmd *cobra.Command, args []string) error { + installPath, _ := cmd.Flags().GetString("path") + version, _ := cmd.Flags().GetString("version") + fmt.Printf("Installing Go to %s\n", installPath) + + if err := os.MkdirAll(installPath, 0755); err != nil && !os.IsExist(err) { + fmt.Printf("Error creating directory %s, error: %s\n", installPath, err.Error()) + } + + arch, osVer := env.GetClientArch() + + if strings.ToLower(osVer) != "linux" { + return fmt.Errorf("this app only supports Linux") + } + + dlArch := arch + if arch == "x86_64" { + dlArch = "amd64" + } else if arch == "aarch64" { + dlArch = "arm64" + } else if arch == "armv7" || arch == "armv7l" { + dlArch = "armv6l" + } + + if len(version) == 0 { + v, err := getGoVersion() + if err != nil { + return err + } + + version = v + } else if !strings.HasPrefix(version, "go") { + version = "go" + version + } + + fmt.Printf("Installing version: %s for: %s\n", version, dlArch) + + dlURL := fmt.Sprintf("https://go.dev/dl/%s.%s-%s.tar.gz", version, strings.ToLower(osVer), dlArch) + fmt.Printf("Downloading from: %s\n", dlURL) + + progress := true + outPath, err := get.DownloadFileP(dlURL, progress) + if err != nil { + return err + } + fmt.Printf("Downloaded to: %s\n", outPath) + + f, err := os.OpenFile(outPath, os.O_RDONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + fmt.Printf("Unpacking Go to: %s\n", path.Join(installPath, "go")) + + if err := archive.UntarNested(f, installPath); err != nil { + return err + } + + fmt.Printf("\nexport PATH=$PATH:%s:$HOME/go/bin\n"+ + "export GOPATH=$HOME/go/\n", path.Join(installPath, "go", "bin")) + + return nil + } + + return command +} + +func getGoVersion() (string, error) { + req, err := http.NewRequest(http.MethodGet, "https://go.dev/VERSION?m=text", nil) + if err != nil { + return "", err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", res.StatusCode) + } + if res.Body == nil { + return "", fmt.Errorf("unexpected empty body") + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + return strings.TrimSpace(string(body)), nil +} diff --git a/cmd/system/install.go b/cmd/system/install.go new file mode 100644 index 000000000..93a7ce7ba --- /dev/null +++ b/cmd/system/install.go @@ -0,0 +1,27 @@ +// Copyright (c) arkade author(s) 2022. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +package system + +import "github.com/spf13/cobra" + +func MakeInstall() *cobra.Command { + + command := &cobra.Command{ + Use: "install", + Short: "Install system apps", + Long: `Install system apps for Linux hosts`, + Aliases: []string{"i"}, + Example: ` arkade system install [APP] + arkade system install --help`, + SilenceUsage: true, + } + + command.RunE = func(cmd *cobra.Command, args []string) error { + return cmd.Usage() + } + + command.AddCommand(MakeInstallGo()) + + return command +} diff --git a/cmd/system/system.go b/cmd/system/system.go new file mode 100644 index 000000000..95c313aa1 --- /dev/null +++ b/cmd/system/system.go @@ -0,0 +1,30 @@ +// Copyright (c) arkade author(s) 2020. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// system contains a suite of Sponsored Apps for arkade +package system + +import ( + "github.com/spf13/cobra" +) + +func MakeSystem() *cobra.Command { + + command := &cobra.Command{ + Use: "system", + Short: "System apps", + Long: `Apps for systems.`, + Aliases: []string{"s"}, + Example: ` arkade system install [APP] + arkade s i [APP]`, + SilenceUsage: true, + } + + command.RunE = func(cmd *cobra.Command, args []string) error { + return cmd.Usage() + } + + command.AddCommand(MakeInstall()) + + return command +} diff --git a/main.go b/main.go index 16665d961..c58c5abd2 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "github.com/alexellis/arkade/cmd" "github.com/alexellis/arkade/cmd/kasten" + "github.com/alexellis/arkade/cmd/system" "github.com/alexellis/arkade/cmd/venafi" "github.com/spf13/cobra" ) @@ -33,6 +34,7 @@ func main() { rootCmd.AddCommand(venafi.MakeVenafi()) rootCmd.AddCommand(kasten.MakeK10()) + rootCmd.AddCommand(system.MakeSystem()) if err := rootCmd.Execute(); err != nil { os.Exit(1) diff --git a/pkg/archive/untar_nested.go b/pkg/archive/untar_nested.go new file mode 100644 index 000000000..c2d085a16 --- /dev/null +++ b/pkg/archive/untar_nested.go @@ -0,0 +1,129 @@ +package archive + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "log" + "os" + "path/filepath" + "time" +) + +// Untar reads the gzip-compressed tar file from r and writes it into dir. +func UntarNested(r io.Reader, dir string) error { + return untarNested(r, dir) +} + +func untarNested(r io.Reader, dir string) (err error) { + t0 := time.Now() + nFiles := 0 + madeDir := map[string]bool{} + defer func() { + td := time.Since(t0) + if err == nil { + log.Printf("extracted tarball into %s: %d files, %d dirs (%v)", dir, nFiles, len(madeDir), td) + } else { + log.Printf("error extracting tarball into %s after %d files, %d dirs, %v: %v", dir, nFiles, len(madeDir), td, err) + } + }() + zr, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("requires gzip-compressed body: %v", err) + } + tr := tar.NewReader(zr) + loggedChtimesError := false + for { + f, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + log.Printf("tar reading error: %v", err) + return fmt.Errorf("tar error: %v", err) + } + if !validRelPath(f.Name) { + return fmt.Errorf("tar contained invalid name error %q", f.Name) + } + rel := filepath.FromSlash(f.Name) + abs := filepath.Join(dir, rel) + + fi := f.FileInfo() + mode := fi.Mode() + switch { + case mode.IsRegular(): + // Make the directory. This is redundant because it should + // already be made by a directory entry in the tar + // beforehand. Thus, don't check for errors; the next + // write will fail with the same error. + dir := filepath.Dir(abs) + if !madeDir[dir] { + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + return err + } + madeDir[dir] = true + } + wf, err := os.OpenFile(abs, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + n, err := io.Copy(wf, tr) + if closeErr := wf.Close(); closeErr != nil && err == nil { + err = closeErr + } + if err != nil { + return fmt.Errorf("error writing to %s: %v", abs, err) + } + if n != f.Size { + return fmt.Errorf("only wrote %d bytes to %s; expected %d", n, abs, f.Size) + } + modTime := f.ModTime + if modTime.After(t0) { + // Clamp modtimes at system time. See + // golang.org/issue/19062 when clock on + // buildlet was behind the gitmirror server + // doing the git-archive. + modTime = t0 + } + if !modTime.IsZero() { + if err := os.Chtimes(abs, modTime, modTime); err != nil && !loggedChtimesError { + // benign error. Gerrit doesn't even set the + // modtime in these, and we don't end up relying + // on it anywhere (the gomote push command relies + // on digests only), so this is a little pointless + // for now. + log.Printf("error changing modtime: %v (further Chtimes errors suppressed)", err) + loggedChtimesError = true // once is enough + } + } + nFiles++ + case mode.IsDir(): + if err := os.MkdirAll(abs, 0755); err != nil { + return err + } + madeDir[abs] = true + default: + return fmt.Errorf("tar file entry %s contained unsupported file type %v", f.Name, mode) + } + } + return nil +} + +// func validRelativeDir(dir string) bool { +// if strings.Contains(dir, `\`) || path.IsAbs(dir) { +// return false +// } +// dir = path.Clean(dir) +// if strings.HasPrefix(dir, "../") || strings.HasSuffix(dir, "/..") || dir == ".." { +// return false +// } +// return true +// } + +// func validRelPath(p string) bool { +// if p == "" || strings.Contains(p, `\`) || strings.HasPrefix(p, "/") || strings.Contains(p, "../") { +// return false +// } +// return true +// } diff --git a/pkg/get/download.go b/pkg/get/download.go index 735e2674f..c908c0346 100644 --- a/pkg/get/download.go +++ b/pkg/get/download.go @@ -86,6 +86,10 @@ func Download(tool *Tool, arch, operatingSystem, version string, downloadMode in return outFilePath, finalName, nil } +func DownloadFileP(downloadURL string, displayProgress bool) (string, error) { + return downloadFile(downloadURL, displayProgress) +} + func downloadFile(downloadURL string, displayProgress bool) (string, error) { res, err := http.DefaultClient.Get(downloadURL) if err != nil {