Skip to content

Commit

Permalink
chore: migrate exec behavior for maru (#80)
Browse files Browse the repository at this point in the history
## Description

This migrates `exec` here in preparation of `maru` disconnecting itself
from `zarf`.

## Related Issue

Fixes #N/A

---------

Co-authored-by: razzle <[email protected]>
Co-authored-by: Lucas Rodriguez <[email protected]>
  • Loading branch information
3 people authored May 21, 2024
1 parent 549be52 commit a51bbe7
Show file tree
Hide file tree
Showing 9 changed files with 583 additions and 0 deletions.
60 changes: 60 additions & 0 deletions .github/workflows/release-exec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Release Exec

on:
push:
branches:
- main
paths:
- "exec/**"

permissions:
contents: read

jobs:
bump-version-and-release-notes:
runs-on: ubuntu-latest
outputs:
new-version: ${{ steps.bump-version.outputs.new-version }}
steps:
- name: Checkout
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
with:
fetch-depth: 0

- name: Bump Version and Generate Release Notes
uses: ./.github/actions/bump-and-notes
id: bump-version
with:
module: "exec"

release:
runs-on: ubuntu-latest
needs: bump-version-and-release-notes
# contents: write via the GH app
environment: release
steps:
- name: Checkout
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
with:
fetch-depth: 0

- name: Download Release Notes
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: release-notes

- name: Get pkg app token
id: pkg-app-token
uses: actions/create-github-app-token@a0de6af83968303c8c955486bf9739a57d23c7f1 # v1.10.0
with:
app-id: ${{ vars.PKG_WORKFLOW_GITHUB_APP_ID }}
private-key: ${{ secrets.PKG_WORKFLOW_GITHUB_APP_SECRET }}
owner: defenseunicorns
repositories: pkg

- name: Release
env:
GH_TOKEN: ${{ steps.pkg-app-token.outputs.token }}
NEW_VERSION: ${{ needs.bump-version-and-release-notes.outputs.new-version }}
run: |
gh release create "$NEW_VERSION" --title "$NEW_VERSION" --notes-file notes.md
128 changes: 128 additions & 0 deletions exec/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024-Present Defense Unicorns

// Package exec provides a wrapper around the os/exec package
package exec

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"

"golang.org/x/sync/errgroup"
)

// Config is a struct for configuring the Cmd function.
type Config struct {
Print bool
Dir string
Env []string
CommandPrinter func(format string, a ...any)
Stdout io.Writer
Stderr io.Writer
}

// Cmd executes a given command with given config.
func Cmd(config Config, command string, args ...string) (string, string, error) {
return CmdWithContext(context.TODO(), config, command, args...)
}

// CmdWithContext executes a given command with given config.
func CmdWithContext(ctx context.Context, config Config, command string, args ...string) (string, string, error) {
if command == "" {
return "", "", errors.New("command is required")
}

// Set up the command.
cmd := exec.CommandContext(ctx, command, args...)
cmd.Dir = config.Dir
cmd.Env = append(os.Environ(), config.Env...)

// Capture the command outputs.
cmdStdout, _ := cmd.StdoutPipe()
cmdStderr, _ := cmd.StderrPipe()

var (
stdoutBuf, stderrBuf bytes.Buffer
)

stdoutWriters := []io.Writer{
&stdoutBuf,
}

stdErrWriters := []io.Writer{
&stderrBuf,
}

// Add the writers if requested.
if config.Stdout != nil {
stdoutWriters = append(stdoutWriters, config.Stdout)
}

if config.Stderr != nil {
stdErrWriters = append(stdErrWriters, config.Stderr)
}

// Print to stdout if requested.
if config.Print {
stdoutWriters = append(stdoutWriters, os.Stdout)
stdErrWriters = append(stdErrWriters, os.Stderr)
}

// Bind all the writers.
stdout := io.MultiWriter(stdoutWriters...)
stderr := io.MultiWriter(stdErrWriters...)

// If a CommandPrinter was provided print the command.
if config.CommandPrinter != nil {
config.CommandPrinter("%s %s", command, strings.Join(args, " "))
}

// Start the command.
if err := cmd.Start(); err != nil {
return "", "", err
}

// Add to waitgroup for each goroutine.
g := new(errgroup.Group)

// Run a goroutine to capture the command's stdout live.
g.Go(func() error {
_, err := io.Copy(stdout, cmdStdout)
return err
})

// Run a goroutine to capture the command's stderr live.
g.Go(func() error {
_, err := io.Copy(stderr, cmdStderr)
return err
})

// Wait for the goroutines to finish and abort if there was an error capturing the command's outputs.
if err := g.Wait(); err != nil {
return "", "", fmt.Errorf("failed to capture the command output: %w", err)
}

// Return the buffered outputs, regardless of whether we printed them.
return stdoutBuf.String(), stderrBuf.String(), cmd.Wait()
}

// LaunchURL opens a URL in the default browser.
func LaunchURL(url string) error {
switch runtime.GOOS {
case "linux":
return exec.Command("xdg-open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
}

return nil
}
61 changes: 61 additions & 0 deletions exec/exec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024-Present Defense Unicorns

package exec

import (
"bytes"
"errors"
"testing"
)

func TestCmd(t *testing.T) {
type test struct {
config Config
command string
args []string
wantStdOut string
wantStdErr string
wantErr error
}

var stdOutBuff bytes.Buffer
var stdErrBuff bytes.Buffer

tests := []test{
{wantErr: errors.New("command is required")},
{config: Config{}, command: "echo", args: []string{"hello kitteh"}, wantStdOut: "hello kitteh\n"},
{config: Config{Env: []string{"ARCH=amd64"}}, command: "printenv", args: []string{"ARCH"}, wantStdOut: "amd64\n"},
{config: Config{Dir: "/"}, command: "pwd", wantStdOut: "/\n"},
{config: Config{Stdout: &stdOutBuff}, command: "sh", args: []string{"-c", "echo \"hello kitteh out\""}, wantStdOut: "hello kitteh out\n"},
{config: Config{Stderr: &stdErrBuff}, command: "sh", args: []string{"-c", "echo \"hello kitteh err\" >&2"}, wantStdErr: "hello kitteh err\n"},
}

// Run tests without registering command mutations
for _, tc := range tests {
gotStdOut, gotStdErr, gotErr := Cmd(tc.config, tc.command, tc.args...)
if gotStdOut != tc.wantStdOut {
t.Fatalf("wanted std out: %s, got std out: %s", tc.wantStdOut, gotStdOut)
}
if gotStdErr != tc.wantStdErr {
t.Fatalf("wanted std err: %s, got std err: %s", tc.wantStdErr, gotStdErr)
}
if gotErr != nil && tc.wantErr != nil {
if gotErr.Error() != tc.wantErr.Error() {
t.Fatalf("wanted err: %s, got err: %s", tc.wantErr, gotErr)
}
} else if gotErr != nil {
t.Fatalf("got unexpected err: %s", gotErr)
}
}

stdOutBufferString := stdOutBuff.String()
if stdOutBufferString != "hello kitteh out\n" {
t.Fatalf("wanted std out buffer: hello kitteh out\n got std out buffer: %s", stdOutBufferString)
}

stdErrBufferString := stdErrBuff.String()
if stdErrBufferString != "hello kitteh err\n" {
t.Fatalf("wanted std err buffer: hello kitteh err\n got std err buffer: %s", stdErrBufferString)
}
}
16 changes: 16 additions & 0 deletions exec/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/defenseunicorns/pkg/exec

go 1.21.8

replace github.com/defenseunicorns/pkg/helpers => ../helpers

require (
github.com/defenseunicorns/pkg/helpers v1.1.1
golang.org/x/sync v0.6.0
)

require (
github.com/otiai10/copy v1.14.0 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
oras.land/oras-go/v2 v2.5.0 // indirect
)
18 changes: 18 additions & 0 deletions exec/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=
oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg=
74 changes: 74 additions & 0 deletions exec/shell.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024-Present Defense Unicorns

package exec

import "runtime"

// ShellPreference represents the desired shell to use for a given command
type ShellPreference struct {
Windows string `json:"windows,omitempty" jsonschema:"description=(default 'powershell') Indicates a preference for the shell to use on Windows systems (note that choosing 'cmd' will turn off migrations like touch -> New-Item),example=powershell,example=cmd,example=pwsh,example=sh,example=bash,example=gsh"`
Linux string `json:"linux,omitempty" jsonschema:"description=(default 'sh') Indicates a preference for the shell to use on Linux systems,example=sh,example=bash,example=fish,example=zsh,example=pwsh"`
Darwin string `json:"darwin,omitempty" jsonschema:"description=(default 'sh') Indicates a preference for the shell to use on macOS systems,example=sh,example=bash,example=fish,example=zsh,example=pwsh"`
}

// IsPowerShell returns whether a shell name is PowerShell
func IsPowerShell(shellName string) bool {
return shellName == "powershell" || shellName == "pwsh"
}

// GetOSShell returns the shell and shellArgs based on the current OS
func GetOSShell(shellPref ShellPreference) (string, []string) {
return getOSShellForOS(shellPref, runtime.GOOS)
}

func getOSShellForOS(shellPref ShellPreference, operatingSystem string) (string, []string) {
var shell string
var shellArgs []string
powershellShellArgs := []string{"-Command", "$ErrorActionPreference = 'Stop';"}
shShellArgs := []string{"-e", "-c"}

switch operatingSystem {
case "windows":
shell = "powershell"
if shellPref.Windows != "" {
shell = shellPref.Windows
}

shellArgs = powershellShellArgs
if shell == "cmd" {
// Change shellArgs to /c if cmd is chosen
shellArgs = []string{"/c"}
} else if !IsPowerShell(shell) {
// Change shellArgs to -c if a real shell is chosen
shellArgs = shShellArgs
}
case "darwin":
shell = "sh"
if shellPref.Darwin != "" {
shell = shellPref.Darwin
}

shellArgs = shShellArgs
if IsPowerShell(shell) {
// Change shellArgs to -Command if pwsh is chosen
shellArgs = powershellShellArgs
}
case "linux":
shell = "sh"
if shellPref.Linux != "" {
shell = shellPref.Linux
}

shellArgs = shShellArgs
if IsPowerShell(shell) {
// Change shellArgs to -Command if pwsh is chosen
shellArgs = powershellShellArgs
}
default:
shell = "sh"
shellArgs = shShellArgs
}

return shell, shellArgs
}
Loading

0 comments on commit a51bbe7

Please sign in to comment.