Skip to content

Commit a51bbe7

Browse files
Racer159NoxsiosLucas Rodriguez
authored
chore: migrate exec behavior for maru (#80)
## 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]>
1 parent 549be52 commit a51bbe7

File tree

9 files changed

+583
-0
lines changed

9 files changed

+583
-0
lines changed

.github/workflows/release-exec.yaml

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Release Exec
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- "exec/**"
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
bump-version-and-release-notes:
15+
runs-on: ubuntu-latest
16+
outputs:
17+
new-version: ${{ steps.bump-version.outputs.new-version }}
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
21+
with:
22+
fetch-depth: 0
23+
24+
- name: Bump Version and Generate Release Notes
25+
uses: ./.github/actions/bump-and-notes
26+
id: bump-version
27+
with:
28+
module: "exec"
29+
30+
release:
31+
runs-on: ubuntu-latest
32+
needs: bump-version-and-release-notes
33+
# contents: write via the GH app
34+
environment: release
35+
steps:
36+
- name: Checkout
37+
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
38+
with:
39+
fetch-depth: 0
40+
41+
- name: Download Release Notes
42+
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
43+
with:
44+
name: release-notes
45+
46+
- name: Get pkg app token
47+
id: pkg-app-token
48+
uses: actions/create-github-app-token@a0de6af83968303c8c955486bf9739a57d23c7f1 # v1.10.0
49+
with:
50+
app-id: ${{ vars.PKG_WORKFLOW_GITHUB_APP_ID }}
51+
private-key: ${{ secrets.PKG_WORKFLOW_GITHUB_APP_SECRET }}
52+
owner: defenseunicorns
53+
repositories: pkg
54+
55+
- name: Release
56+
env:
57+
GH_TOKEN: ${{ steps.pkg-app-token.outputs.token }}
58+
NEW_VERSION: ${{ needs.bump-version-and-release-notes.outputs.new-version }}
59+
run: |
60+
gh release create "$NEW_VERSION" --title "$NEW_VERSION" --notes-file notes.md

exec/exec.go

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2024-Present Defense Unicorns
3+
4+
// Package exec provides a wrapper around the os/exec package
5+
package exec
6+
7+
import (
8+
"bytes"
9+
"context"
10+
"errors"
11+
"fmt"
12+
"io"
13+
"os"
14+
"os/exec"
15+
"runtime"
16+
"strings"
17+
18+
"golang.org/x/sync/errgroup"
19+
)
20+
21+
// Config is a struct for configuring the Cmd function.
22+
type Config struct {
23+
Print bool
24+
Dir string
25+
Env []string
26+
CommandPrinter func(format string, a ...any)
27+
Stdout io.Writer
28+
Stderr io.Writer
29+
}
30+
31+
// Cmd executes a given command with given config.
32+
func Cmd(config Config, command string, args ...string) (string, string, error) {
33+
return CmdWithContext(context.TODO(), config, command, args...)
34+
}
35+
36+
// CmdWithContext executes a given command with given config.
37+
func CmdWithContext(ctx context.Context, config Config, command string, args ...string) (string, string, error) {
38+
if command == "" {
39+
return "", "", errors.New("command is required")
40+
}
41+
42+
// Set up the command.
43+
cmd := exec.CommandContext(ctx, command, args...)
44+
cmd.Dir = config.Dir
45+
cmd.Env = append(os.Environ(), config.Env...)
46+
47+
// Capture the command outputs.
48+
cmdStdout, _ := cmd.StdoutPipe()
49+
cmdStderr, _ := cmd.StderrPipe()
50+
51+
var (
52+
stdoutBuf, stderrBuf bytes.Buffer
53+
)
54+
55+
stdoutWriters := []io.Writer{
56+
&stdoutBuf,
57+
}
58+
59+
stdErrWriters := []io.Writer{
60+
&stderrBuf,
61+
}
62+
63+
// Add the writers if requested.
64+
if config.Stdout != nil {
65+
stdoutWriters = append(stdoutWriters, config.Stdout)
66+
}
67+
68+
if config.Stderr != nil {
69+
stdErrWriters = append(stdErrWriters, config.Stderr)
70+
}
71+
72+
// Print to stdout if requested.
73+
if config.Print {
74+
stdoutWriters = append(stdoutWriters, os.Stdout)
75+
stdErrWriters = append(stdErrWriters, os.Stderr)
76+
}
77+
78+
// Bind all the writers.
79+
stdout := io.MultiWriter(stdoutWriters...)
80+
stderr := io.MultiWriter(stdErrWriters...)
81+
82+
// If a CommandPrinter was provided print the command.
83+
if config.CommandPrinter != nil {
84+
config.CommandPrinter("%s %s", command, strings.Join(args, " "))
85+
}
86+
87+
// Start the command.
88+
if err := cmd.Start(); err != nil {
89+
return "", "", err
90+
}
91+
92+
// Add to waitgroup for each goroutine.
93+
g := new(errgroup.Group)
94+
95+
// Run a goroutine to capture the command's stdout live.
96+
g.Go(func() error {
97+
_, err := io.Copy(stdout, cmdStdout)
98+
return err
99+
})
100+
101+
// Run a goroutine to capture the command's stderr live.
102+
g.Go(func() error {
103+
_, err := io.Copy(stderr, cmdStderr)
104+
return err
105+
})
106+
107+
// Wait for the goroutines to finish and abort if there was an error capturing the command's outputs.
108+
if err := g.Wait(); err != nil {
109+
return "", "", fmt.Errorf("failed to capture the command output: %w", err)
110+
}
111+
112+
// Return the buffered outputs, regardless of whether we printed them.
113+
return stdoutBuf.String(), stderrBuf.String(), cmd.Wait()
114+
}
115+
116+
// LaunchURL opens a URL in the default browser.
117+
func LaunchURL(url string) error {
118+
switch runtime.GOOS {
119+
case "linux":
120+
return exec.Command("xdg-open", url).Start()
121+
case "windows":
122+
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
123+
case "darwin":
124+
return exec.Command("open", url).Start()
125+
}
126+
127+
return nil
128+
}

exec/exec_test.go

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2024-Present Defense Unicorns
3+
4+
package exec
5+
6+
import (
7+
"bytes"
8+
"errors"
9+
"testing"
10+
)
11+
12+
func TestCmd(t *testing.T) {
13+
type test struct {
14+
config Config
15+
command string
16+
args []string
17+
wantStdOut string
18+
wantStdErr string
19+
wantErr error
20+
}
21+
22+
var stdOutBuff bytes.Buffer
23+
var stdErrBuff bytes.Buffer
24+
25+
tests := []test{
26+
{wantErr: errors.New("command is required")},
27+
{config: Config{}, command: "echo", args: []string{"hello kitteh"}, wantStdOut: "hello kitteh\n"},
28+
{config: Config{Env: []string{"ARCH=amd64"}}, command: "printenv", args: []string{"ARCH"}, wantStdOut: "amd64\n"},
29+
{config: Config{Dir: "/"}, command: "pwd", wantStdOut: "/\n"},
30+
{config: Config{Stdout: &stdOutBuff}, command: "sh", args: []string{"-c", "echo \"hello kitteh out\""}, wantStdOut: "hello kitteh out\n"},
31+
{config: Config{Stderr: &stdErrBuff}, command: "sh", args: []string{"-c", "echo \"hello kitteh err\" >&2"}, wantStdErr: "hello kitteh err\n"},
32+
}
33+
34+
// Run tests without registering command mutations
35+
for _, tc := range tests {
36+
gotStdOut, gotStdErr, gotErr := Cmd(tc.config, tc.command, tc.args...)
37+
if gotStdOut != tc.wantStdOut {
38+
t.Fatalf("wanted std out: %s, got std out: %s", tc.wantStdOut, gotStdOut)
39+
}
40+
if gotStdErr != tc.wantStdErr {
41+
t.Fatalf("wanted std err: %s, got std err: %s", tc.wantStdErr, gotStdErr)
42+
}
43+
if gotErr != nil && tc.wantErr != nil {
44+
if gotErr.Error() != tc.wantErr.Error() {
45+
t.Fatalf("wanted err: %s, got err: %s", tc.wantErr, gotErr)
46+
}
47+
} else if gotErr != nil {
48+
t.Fatalf("got unexpected err: %s", gotErr)
49+
}
50+
}
51+
52+
stdOutBufferString := stdOutBuff.String()
53+
if stdOutBufferString != "hello kitteh out\n" {
54+
t.Fatalf("wanted std out buffer: hello kitteh out\n got std out buffer: %s", stdOutBufferString)
55+
}
56+
57+
stdErrBufferString := stdErrBuff.String()
58+
if stdErrBufferString != "hello kitteh err\n" {
59+
t.Fatalf("wanted std err buffer: hello kitteh err\n got std err buffer: %s", stdErrBufferString)
60+
}
61+
}

exec/go.mod

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module github.com/defenseunicorns/pkg/exec
2+
3+
go 1.21.8
4+
5+
replace github.com/defenseunicorns/pkg/helpers => ../helpers
6+
7+
require (
8+
github.com/defenseunicorns/pkg/helpers v1.1.1
9+
golang.org/x/sync v0.6.0
10+
)
11+
12+
require (
13+
github.com/otiai10/copy v1.14.0 // indirect
14+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
15+
oras.land/oras-go/v2 v2.5.0 // indirect
16+
)

exec/go.sum

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
4+
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
5+
github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
6+
github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
7+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
8+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
9+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
10+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
11+
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
12+
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
13+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
14+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
15+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
16+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
17+
oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=
18+
oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg=

exec/shell.go

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2024-Present Defense Unicorns
3+
4+
package exec
5+
6+
import "runtime"
7+
8+
// ShellPreference represents the desired shell to use for a given command
9+
type ShellPreference struct {
10+
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"`
11+
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"`
12+
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"`
13+
}
14+
15+
// IsPowerShell returns whether a shell name is PowerShell
16+
func IsPowerShell(shellName string) bool {
17+
return shellName == "powershell" || shellName == "pwsh"
18+
}
19+
20+
// GetOSShell returns the shell and shellArgs based on the current OS
21+
func GetOSShell(shellPref ShellPreference) (string, []string) {
22+
return getOSShellForOS(shellPref, runtime.GOOS)
23+
}
24+
25+
func getOSShellForOS(shellPref ShellPreference, operatingSystem string) (string, []string) {
26+
var shell string
27+
var shellArgs []string
28+
powershellShellArgs := []string{"-Command", "$ErrorActionPreference = 'Stop';"}
29+
shShellArgs := []string{"-e", "-c"}
30+
31+
switch operatingSystem {
32+
case "windows":
33+
shell = "powershell"
34+
if shellPref.Windows != "" {
35+
shell = shellPref.Windows
36+
}
37+
38+
shellArgs = powershellShellArgs
39+
if shell == "cmd" {
40+
// Change shellArgs to /c if cmd is chosen
41+
shellArgs = []string{"/c"}
42+
} else if !IsPowerShell(shell) {
43+
// Change shellArgs to -c if a real shell is chosen
44+
shellArgs = shShellArgs
45+
}
46+
case "darwin":
47+
shell = "sh"
48+
if shellPref.Darwin != "" {
49+
shell = shellPref.Darwin
50+
}
51+
52+
shellArgs = shShellArgs
53+
if IsPowerShell(shell) {
54+
// Change shellArgs to -Command if pwsh is chosen
55+
shellArgs = powershellShellArgs
56+
}
57+
case "linux":
58+
shell = "sh"
59+
if shellPref.Linux != "" {
60+
shell = shellPref.Linux
61+
}
62+
63+
shellArgs = shShellArgs
64+
if IsPowerShell(shell) {
65+
// Change shellArgs to -Command if pwsh is chosen
66+
shellArgs = powershellShellArgs
67+
}
68+
default:
69+
shell = "sh"
70+
shellArgs = shShellArgs
71+
}
72+
73+
return shell, shellArgs
74+
}

0 commit comments

Comments
 (0)