Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AWS CLI to system install tools #993

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Or get a copy of his eBook on Go so you can learn how to build tools like k3sup,
> [Ivan Velichko](https://twitter.com/iximiuz/status/1422605221226860548?s=20), SRE @ Booking.com

> Before arkade whenever I used to spin up an instance, I used to go to multiple sites and download the binary. Arkade is one of my favourite tools.
>
>
> [Kumar Anurag](https://kubesimplify.com/arkade) - Cloud Native Enthusiast

> It's hard to use K8s without Arkade these days.
Expand All @@ -88,7 +88,7 @@ Or get a copy of his eBook on Go so you can learn how to build tools like k3sup,
> [@Yankexe](https://twitter.com/yankexe/status/1305427718050250754?s=20)

> arkade is really a great tool to install CLI tools, and system packages, check this blog on how to get started with arkade it's a time saver.
>
>
> [Kiran Satya Raj](https://twitter.com/jksrtwt/status/1556592117627047936?s=20&t=g0gnSP98jg3ZwU7sQqUrLw)

> This is real magic get #kubernetes up and going in a second; then launch #openfaas a free better than lambda solution that uses docker images.
Expand All @@ -103,7 +103,7 @@ Or get a copy of his eBook on Go so you can learn how to build tools like k3sup,
> I finally got around to installing Arkade, super simple!
> quicker to install this than the argocli standalone commands, but there are lots of handy little tools in there.
> also, the neat little part about arkade, not only does it make it easy to install a ton of different apps and CLIs you can also get the info on them as well pretty quickly.
>
>
> [Michael Cade @ Kasten](https://twitter.com/MichaelCade1/status/1390403831167700995?s=20)

> You've to install latest and greatest tools for your daily @kubernetesio tasks? No problem, check out #arkade the open source #kubernetes marketplace 👍
Expand Down Expand Up @@ -241,6 +241,7 @@ Run the following to see what's available `arkade system install`:

```
actions-runner Install GitHub Actions Runner
aws-cli Install AWS CLI
buildkitd Install Buildkitd
cni Install CNI plugins
containerd Install containerd
Expand Down Expand Up @@ -287,12 +288,12 @@ If you just need system applications, you could also try "setup-arkade":

There are two commands built into arkade designed for software vendors and open source maintainers.

* `arkade helm chart upgrade` - run this command to scan for container images and update them automatically by querying a remote registry.
* `arkade helm chart upgrade` - run this command to scan for container images and update them automatically by querying a remote registry.
* `arkade helm chart verify` - after changing the contents of a values.yaml or docker-compose.yaml file, this command will check each image exists on a remote registry

Whilst end-users may use a GitOps-style tool to deploy charts and update their versions, maintainers need to make conscious decisions about when and which images to change within a Helm chart or compose file.

These two features are used by OpenFaaS Ltd on projects and products like OpenFaaS CE/Pro (Serverless platform) and faasd (docker-compose file).
These two features are used by OpenFaaS Ltd on projects and products like OpenFaaS CE/Pro (Serverless platform) and faasd (docker-compose file).

### Upgrade images within a Helm chart

Expand Down
329 changes: 329 additions & 0 deletions cmd/system/aws_cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
package system

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
"time"

"golang.org/x/exp/slices"

"github.com/Masterminds/semver"
"github.com/alexellis/arkade/pkg/archive"
"github.com/alexellis/arkade/pkg/env"
"github.com/alexellis/arkade/pkg/get"
execute "github.com/alexellis/go-execute/v2"
"github.com/spf13/cobra"
)

type ReferenceObject struct {
Type string `json:"tag,omitempty"`
}

type Reference struct {
Ref string `json:"ref,omitempty"`
Url string `json:"url,omitempty"`
Object ReferenceObject `json:"object,omitempty"`
}

func MakeInstallAWSCLI() *cobra.Command {
command := &cobra.Command{
Use: "aws-cli",
Short: "Install AWS CLI",
Long: `Install AWS CLI for interacting with Amazon Web Services APIs.`,
Example: ` arkade system install aws-cli
arkade system install aws-cli --version <version>`,
SilenceUsage: true,
}

command.Flags().StringP("version", "v", githubLatest, "The version or leave blank to determine the latest available version")
command.Flags().String("path", "/usr/local/bin", "Installation path, where a aws cli subfolder will be created")
command.Flags().Bool("progress", true, "Show download progress")
command.Flags().StringP("work-dir", "w", "", "Working directory that installer files should be copied to (current directory if not supplied)")
command.Flags().Bool("run-installer", true, "Whether or not arkade should run the downloaded installer")

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")
progress, _ := cmd.Flags().GetBool("progress")
workDir, _ := cmd.Flags().GetString("work-dir")
runInstaller, _ := cmd.Flags().GetBool("run-installer")
fmt.Printf("Installing AWS CLI to %s\n", installPath)

installPath = strings.ReplaceAll(installPath, "$HOME", os.Getenv("HOME"))

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 cmd.Flags().Changed("arch") {
arch, _ = cmd.Flags().GetString("arch")
}

if version == githubLatest {
v, err := getAWSCLIVersion("aws", "aws-cli")
VariableExp0rt marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return err
}
version = v
}

fmt.Printf("Installing version: %s for: %s\n", version, arch)

awsCliTool := get.Tool{
Owner: "amazonaws",
Repo: "awscli",
Name: "awscli",
Version: version,
URLTemplate: `
{{$version := .Version}}
{{$base := printf "https://%s.%s.com" .Repo .Owner}}

{{$ext := "zip"}}
{{$uri := printf "%s-exe-linux-%s" .Repo .Arch}}

{{ if HasPrefix .OS "Ming" }}
{{$uri = "AWSCLIV2"}}
{{$ext = "msi"}}
{{ else if eq .OS "Darwin" -}}
{{$uri = "AWSCLIV2"}}
{{$ext = "pkg"}}
{{ end -}}

{{$base}}/{{$uri}}-{{$version}}.{{$ext}}
`,
}

dUrl, err := awsCliTool.GetURL(osVer, arch, version, !progress)
if err != nil {
return err
}
fmt.Printf("Downloading from: %s\n", dUrl)

outPath, err := get.DownloadFileP(dUrl, 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()

if workDir == "" {
cwd, err := os.Getwd()
if err != nil {
return err
}
workDir = cwd
}

fmt.Printf("Copying file to: %s\n", workDir)

filename := filepath.Base(outPath)
if _, err = get.CopyFileP(
outPath,
fmt.Sprintf("%s/%s", workDir, filename), readWriteExecuteEveryone,
); err != nil {
return err
}

isArchive, err := awsCliTool.IsArchive(true)
if err != nil {
return err
}

if isArchive {
unpackPath := fmt.Sprintf("%s/awscli", workDir)
fmt.Printf("Unpacking AWS CLI to: %s\n", unpackPath)

fInfo, err := f.Stat()
if err != nil {
return err
}
if err := archive.Unzip(f, fInfo.Size(), unpackPath, true); err != nil {
return err
}

workDir = unpackPath
}

if runInstaller {
if err := runBundledInstaller(osVer, workDir, filename, installPath); err != nil {
return err
}
} else {
tpl, err := installationInstructions(osVer, workDir, filename, installPath)
if err != nil {
return err
}
fmt.Printf("\n%s", tpl)
}

return nil
}

return command
}

func getAWSCLIVersion(owner, repo string) (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/refs/tags", owner, repo)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot use the API because it's rate-limited.

I'm curious, why did you add this when we already have code in the project (everywhere) that already determines the version?

Copy link
Author

@VariableExp0rt VariableExp0rt Feb 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for this is that tags exist that are for all releases, but there are not releases to accompany them. I might be wrong, but I couldn't see specifically anywhere that only looks at the refs/tags (which is all that AWS provide). More info here #993 (comment)


client := http.Client{Timeout: time.Second * 10}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}

req, err := http.NewRequest(
http.MethodGet,
url,
nil,
)
if err != nil {
return "", err
}

var references []Reference
response, err := client.Do(req)
if err != nil {
return "", err
}

defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}

if err := json.Unmarshal(body, &references); err != nil {
return "", err
}

tags := make([]*semver.Version, 0)
for _, reference := range references {
if reference.Object.Type == "tag" {
trimmed := strings.TrimPrefix(reference.Ref, "refs/tags/")
v, err := semver.NewVersion(trimmed)
if err != nil && errors.Is(err, semver.ErrInvalidSemVer) {
continue
}

tags = append(tags, v)
}
}

var comparer = func(a, b *semver.Version) int {
return a.Compare(b)
}

slices.SortFunc(tags, comparer)
latest := tags[len(tags)-1]

return latest.String(), nil
}

func runBundledInstaller(osVer string, workDir string, filename string, installPath string) error {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why this option is being included?

What format is the AWS CLI in?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI comes in different formats for different platforms, for instance OSx is a .pkg, Linux is a .zip, and similar for Windows. There isn't just a binary.

fmt.Printf("Running bundled installer from download URL\n")

cmd := ""
args := make([]string, 0)

switch osVer {
case "Darwin":
cmd = "installer"
pkgDir := fmt.Sprintf(" -pkg %s/%s", workDir, filename)
args = append(args, pkgDir, "-target /")
case "Linux":
cmd = fmt.Sprintf(".%s/install", workDir)
args = append(args, fmt.Sprintf("--bin-dir %s", installPath))
default:
if strings.HasPrefix(osVer, "Ming") {
cmd = "msiexec"
msiDir := fmt.Sprintf("%s/%s", workDir, filename)
args = append(args, msiDir, fmt.Sprintf("INSTALLDIR=%s", installPath))
}
}

installTask := execute.ExecTask{
Command: cmd,
Args: args,
StreamStdio: false,
}

result, err := installTask.Execute(context.Background())
if err != nil {
return err
}

if result.ExitCode != 0 {
return fmt.Errorf("error running bundled installer for platform, stderr: %s", result.Stderr)
}

return nil
}

func installationInstructions(osVer string, workDir string, filename string, installPath string) ([]byte, error) {
t := template.New("Installation Instructions")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no binary package for the aws cli?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is not, just zip, pkg, or the msi installer for Windows.


switch osVer {
case "Darwin":
t.Parse(`# Run the downloaded .pkg installer, you will be prompted for authorisation

sudo installer -pkg {{.WorkDir}}/{{.Filename}} -target /

# Test the binary:
aws --version
`)
case "Linux":
t.Parse(`# Run the downloaded script installer, you will be prompted for authorisation

sudo ./{{.WorkDir}}/awscli/install --bin-dir {{.InstallPath}}

# Test the binary:
aws --version
`)
default:
if strings.HasPrefix(osVer, "Ming") {
t.Parse(`# Run the downloaded .msi installer

msiexec {{.WorkDir}}/{{.Filename}} INSTALLDIR={{.InstallPath}}

# Test the binary:
aws --version
`)
}
}

var tpl bytes.Buffer
var data = struct {
Filename string
WorkDir string
InstallPath string
}{
Filename: filename,
WorkDir: workDir,
InstallPath: installPath,
}

if err := t.Execute(&tpl, data); err != nil {
return nil, err
}

return tpl.Bytes(), nil
}
1 change: 1 addition & 0 deletions cmd/system/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func MakeInstall() *cobra.Command {
command.AddCommand(MakeInstallRegistry())
command.AddCommand(MakeInstallGitLabRunner())
command.AddCommand((MakeInstallBuildkitd()))
command.AddCommand(MakeInstallAWSCLI())

return command
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0-rc5 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/vbatts/tar-split v0.11.5 // indirect
Expand Down
Loading