Skip to content

Commit 3aa6349

Browse files
authored
[command] Add a way to run commands as a different user without changing the command definition (#333)
<!-- Copyright (C) 2020-2022 Arm Limited or its affiliates and Contributors. All rights reserved. SPDX-License-Identifier: Apache-2.0 --> ### Description this is mostly for `posix` platforms where some commands need to be run as `sudo` or as a different user. ### Test Coverage <!-- Please put an `x` in the correct box e.g. `[x]` to indicate the testing coverage of this change. --> - [x] This change is covered by existing or additional automated tests. - [ ] Manual testing has been performed (and evidence provided) as automated testing was not feasible. - [ ] Additional tests are not required for this change (e.g. documentation update).
1 parent 8332a4f commit 3aa6349

File tree

9 files changed

+190
-44
lines changed

9 files changed

+190
-44
lines changed

changes/20231013112742.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: `[platform]` Add way to run commands as a user with privileges on posix systems

changes/20231013122936.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: `[command]` Add utilities to translate commands so that they are run as a separate user

utils/platform/cmd_posix.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//go:build linux || unix || (js && wasm) || darwin || aix || dragonfly || freebsd || nacl || netbsd || openbsd || solaris
2+
// +build linux unix js,wasm darwin aix dragonfly freebsd nacl netbsd openbsd solaris
3+
4+
package platform
5+
6+
import "github.com/ARM-software/golang-utils/utils/subprocess/command"
7+
8+
var (
9+
// sudoCommand describes the command to use to execute command as root
10+
// when running in Docker, change to [gosu root](https://github.com/tianon/gosu)
11+
sudoCommand = command.Sudo()
12+
)
13+
14+
// DefineSudoCommand defines the command to run to be `root` or a user with enough privileges to manage accounts.
15+
// e.g.
16+
// - args="sudo" to run commands as `root`
17+
// - args="su", "tom" if `tom` has enough privileges to run the command
18+
// - args="gosu", "tom" if `tom` has enough privileges to run the command in a container and `gosu` is installed
19+
func DefineSudoCommand(args ...string) {
20+
sudoCommand = command.NewCommandAsDifferentUser(args...)
21+
}
22+
23+
func defineCommandWithPrivileges(args ...string) (string, []string) {
24+
return sudoCommand.RedefineCommand(args...)
25+
}

utils/platform/users_posix.go

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,6 @@ import (
1111
"github.com/ARM-software/golang-utils/utils/commonerrors"
1212
)
1313

14-
var (
15-
// sudoCommand describes the command to use to execute command as root
16-
// when running in Docker, change to [gosu root](https://github.com/tianon/gosu)
17-
sudoCommand = []string{"sudo"}
18-
)
19-
20-
// DefineSudoCommand defines the command to run to be `root` or a user with enough privileges to manage accounts.
21-
func DefineSudoCommand(args ...string) {
22-
sudoCommand = args
23-
}
24-
2514
func addUser(ctx context.Context, username, fullname, password string) (err error) {
2615
pwd := password
2716
if pwd == "" {
@@ -69,28 +58,11 @@ func executeCommand(ctx context.Context, args ...string) error {
6958
if len(args) == 0 {
7059
return fmt.Errorf("%w: missing command to execute", commonerrors.ErrUndefined)
7160
}
72-
cmd := defineCommand(ctx, args...)
61+
cmdName, cmdArgs := defineCommandWithPrivileges(args...)
62+
cmd := exec.CommandContext(ctx, cmdName, cmdArgs...)
7363
return runCommand(args[0], cmd)
7464
}
7565

76-
func defineCommand(ctx context.Context, args ...string) *exec.Cmd {
77-
var cmdName string
78-
var cmdArgs []string
79-
if len(sudoCommand) > 0 {
80-
cmdName = sudoCommand[0]
81-
for i := 1; i < len(sudoCommand); i++ {
82-
cmdArgs = append(cmdArgs, sudoCommand[i])
83-
}
84-
cmdArgs = append(cmdArgs, args...)
85-
} else {
86-
cmdName = args[0]
87-
for i := 1; i < len(args); i++ {
88-
cmdArgs = append(cmdArgs, args[i])
89-
}
90-
}
91-
return exec.CommandContext(ctx, cmdName, cmdArgs...)
92-
}
93-
9466
func runCommand(cmdDescription string, cmd *exec.Cmd) error {
9567
_, err := cmd.Output()
9668
if err == nil {

utils/subprocess/command/cmd.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package command
2+
3+
import "strings"
4+
5+
// CommandAsDifferentUser helps redefining commands so that they are run as a different user or with more privileges.
6+
type CommandAsDifferentUser struct {
7+
// changeUserCmd describes the command to use to execute any command as a different user
8+
// e.g. it can be set as "sudo" to run commands as `root` or as "su","tom" or "gosu","jack"
9+
changeUserCmd []string
10+
}
11+
12+
// Redefine redefines a command so that it will be run as a different user.
13+
func (c *CommandAsDifferentUser) Redefine(cmd string, args ...string) (cmdName string, cmdArgs []string) {
14+
newArgs := []string{cmd}
15+
newArgs = append(newArgs, args...)
16+
cmdName, cmdArgs = c.RedefineCommand(newArgs...)
17+
return
18+
}
19+
20+
// RedefineCommand is the same as Redefine but with no separation between the command and its arguments (like the command in Docker)
21+
func (c *CommandAsDifferentUser) RedefineCommand(args ...string) (cmdName string, cmdArgs []string) {
22+
if len(c.changeUserCmd) > 0 {
23+
cmdName = c.changeUserCmd[0]
24+
for i := 1; i < len(c.changeUserCmd); i++ {
25+
cmdArgs = append(cmdArgs, c.changeUserCmd[i])
26+
}
27+
cmdArgs = append(cmdArgs, args...)
28+
} else {
29+
cmdName = args[0]
30+
for i := 1; i < len(args); i++ {
31+
cmdArgs = append(cmdArgs, args[i])
32+
}
33+
}
34+
return
35+
}
36+
37+
// RedefineInShellForm returns the new command defined in shell form.
38+
func (c *CommandAsDifferentUser) RedefineInShellForm(cmd string, args ...string) string {
39+
ncmd, nargs := c.Redefine(cmd, args...)
40+
return AsShellForm(ncmd, nargs...)
41+
}
42+
43+
// NewCommandAsDifferentUser defines a command wrapper which helps redefining commands so that they are run as a different user.
44+
// e.g.
45+
// - switchUserCmd="sudo" to run commands as `root`
46+
// - switchUserCmd="su", "tom" if `tom` has enough privileges to run the command
47+
// - switchUserCmd="gosu", "tom" if `tom` has enough privileges to run the command in a container and `gosu` is installed
48+
func NewCommandAsDifferentUser(switchUserCmd ...string) *CommandAsDifferentUser {
49+
return &CommandAsDifferentUser{changeUserCmd: switchUserCmd}
50+
}
51+
52+
// NewCommandAsRoot will create a command translator which will run command with `sudo`
53+
func NewCommandAsRoot() *CommandAsDifferentUser {
54+
return NewCommandAsDifferentUser("sudo")
55+
}
56+
57+
// Sudo will call commands with `sudo`. Similar to NewCommandAsRoot
58+
func Sudo() *CommandAsDifferentUser {
59+
return NewCommandAsRoot()
60+
}
61+
62+
// NewCommandInContainerAs will redefine commands to be run in containers as `username`. It will expect [gosu](https://github.com/tianon/gosu) to be installed and the user to have been defined.
63+
func NewCommandInContainerAs(username string) *CommandAsDifferentUser {
64+
return NewCommandAsDifferentUser("gosu", username)
65+
}
66+
67+
// Gosu is similar to NewCommandInContainerAs.
68+
func Gosu(username string) *CommandAsDifferentUser {
69+
return NewCommandInContainerAs(username)
70+
}
71+
72+
// Su will run commands as the user username using [su](https://www.unix.com/man-page/posix/1/su/)
73+
func Su(username string) *CommandAsDifferentUser {
74+
return NewCommandAsDifferentUser("su", username)
75+
}
76+
77+
// Me will run the commands without switching user. It is a no operation wrapper.
78+
func Me() *CommandAsDifferentUser {
79+
return NewCommandAsDifferentUser()
80+
}
81+
82+
// AsShellForm returns a command in its shell form.
83+
func AsShellForm(cmd string, args ...string) string {
84+
newCmd := []string{cmd}
85+
newCmd = append(newCmd, args...)
86+
return strings.Join(newCmd, " ")
87+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package command
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/bxcodec/faker/v3"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestCommandAsDifferentUser_Redefine(t *testing.T) {
12+
assert.Equal(t, "sudo test 1 2 3", Sudo().RedefineInShellForm("test", "1", "2", "3"))
13+
name := faker.Username()
14+
assert.Equal(t, fmt.Sprintf("su %v test 1 2 3", name), Su(name).RedefineInShellForm("test", "1", "2", "3"))
15+
name = faker.Username()
16+
assert.Equal(t, fmt.Sprintf("gosu %v test 1 2 3", name), Gosu(name).RedefineInShellForm("test", "1", "2", "3"))
17+
assert.Equal(t, "test 1 2 3", NewCommandAsDifferentUser().RedefineInShellForm("test", "1", "2", "3"))
18+
assert.Equal(t, "test", Me().RedefineInShellForm("test"))
19+
assert.Empty(t, Me().RedefineInShellForm(""))
20+
}

utils/subprocess/command_wrapper.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/ARM-software/golang-utils/utils/commonerrors"
1717
"github.com/ARM-software/golang-utils/utils/logs"
1818
"github.com/ARM-software/golang-utils/utils/parallelisation"
19+
commandUtils "github.com/ARM-software/golang-utils/utils/subprocess/command"
1920
)
2021

2122
// INTERNAL
@@ -100,12 +101,14 @@ func (c *cmdWrapper) Pid() (pid int, err error) {
100101
type command struct {
101102
cmd string
102103
args []string
104+
as *commandUtils.CommandAsDifferentUser
103105
loggers logs.Loggers
104106
cmdWrapper cmdWrapper
105107
}
106108

107109
func (c *command) createCommand(cmdCtx context.Context) *exec.Cmd {
108-
cmd := exec.CommandContext(cmdCtx, c.cmd, c.args...) //nolint:gosec
110+
newCmd, newArgs := c.as.Redefine(c.cmd, c.args...)
111+
cmd := exec.CommandContext(cmdCtx, newCmd, newArgs...) //nolint:gosec
109112
cmd.Stdout = newOutStreamer(c.loggers)
110113
cmd.Stderr = newErrLogStreamer(c.loggers)
111114
return cmd
@@ -129,18 +132,23 @@ func (c *command) Check() (err error) {
129132
err = fmt.Errorf("missing command: %w", commonerrors.ErrUndefined)
130133
return
131134
}
135+
if c.as == nil {
136+
err = fmt.Errorf("missing command translator: %w", commonerrors.ErrUndefined)
137+
return
138+
}
132139
if c.loggers == nil {
133140
err = commonerrors.ErrNoLogger
134141
return
135142
}
136143
return
137144
}
138145

139-
func newCommand(loggers logs.Loggers, cmd string, args ...string) (osCmd *command) {
146+
func newCommand(loggers logs.Loggers, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (osCmd *command) {
140147
osCmd = &command{
141148
cmd: cmd,
142149
args: args,
143150
loggers: loggers,
151+
as: as,
144152
cmdWrapper: cmdWrapper{},
145153
}
146154
return

utils/subprocess/command_wrapper_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/ARM-software/golang-utils/utils/logs"
1818
"github.com/ARM-software/golang-utils/utils/logs/logstest"
1919
"github.com/ARM-software/golang-utils/utils/platform"
20+
commandUtils "github.com/ARM-software/golang-utils/utils/subprocess/command"
2021
)
2122

2223
func TestCmdRun(t *testing.T) {
@@ -54,9 +55,9 @@ func TestCmdRun(t *testing.T) {
5455
loggers, err := logs.NewLogrLogger(logstest.NewTestLogger(t), "test")
5556
require.NoError(t, err)
5657
if platform.IsWindows() {
57-
cmd = newCommand(loggers, test.cmdWindows, test.argWindows...)
58+
cmd = newCommand(loggers, commandUtils.Me(), test.cmdWindows, test.argWindows...)
5859
} else {
59-
cmd = newCommand(loggers, test.cmdOther, test.argOther...)
60+
cmd = newCommand(loggers, commandUtils.Me(), test.cmdOther, test.argOther...)
6061
}
6162
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
6263
defer cancel()
@@ -109,9 +110,9 @@ func TestCmdStartStop(t *testing.T) {
109110
require.NoError(t, err)
110111

111112
if platform.IsWindows() {
112-
cmd = newCommand(loggers, test.cmdWindows, test.argWindows...)
113+
cmd = newCommand(loggers, commandUtils.Me(), test.cmdWindows, test.argWindows...)
113114
} else {
114-
cmd = newCommand(loggers, test.cmdOther, test.argOther...)
115+
cmd = newCommand(loggers, commandUtils.Me(), test.cmdOther, test.argOther...)
115116
}
116117
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
117118
defer cancel()

utils/subprocess/executor.go

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/ARM-software/golang-utils/utils/commonerrors"
1717
"github.com/ARM-software/golang-utils/utils/logs"
18+
commandUtils "github.com/ARM-software/golang-utils/utils/subprocess/command"
1819
)
1920

2021
// Subprocess describes what a subproccess is as well as any monitoring it may need.
@@ -28,14 +29,20 @@ type Subprocess struct {
2829

2930
// New creates a subprocess description.
3031
func New(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (p *Subprocess, err error) {
32+
p, err = newSubProcess(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Me(), cmd, args...)
33+
return
34+
}
35+
36+
// newSubProcess creates a subprocess description.
37+
func newSubProcess(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (p *Subprocess, err error) {
3138
p = new(Subprocess)
32-
err = p.Setup(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...)
39+
err = p.SetupAs(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
3340
return
3441
}
3542

36-
func newPlainSubProcess(ctx context.Context, loggers logs.Loggers, cmd string, args ...string) (p *Subprocess, err error) {
43+
func newPlainSubProcess(ctx context.Context, loggers logs.Loggers, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (p *Subprocess, err error) {
3744
p = new(Subprocess)
38-
err = p.setup(ctx, loggers, false, "", "", "", cmd, args...)
45+
err = p.setup(ctx, loggers, false, "", "", "", as, cmd, args...)
3946
return
4047
}
4148

@@ -48,8 +55,27 @@ func Execute(ctx context.Context, loggers logs.Loggers, messageOnStart string, m
4855
return p.Execute()
4956
}
5057

58+
// ExecuteAs executes a command (i.e. spawns a subprocess) as a different user.
59+
func ExecuteAs(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
60+
p, err := newSubProcess(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
61+
if err != nil {
62+
return
63+
}
64+
return p.Execute()
65+
}
66+
67+
// ExecuteWithSudo executes a command (i.e. spawns a subprocess) as root.
68+
func ExecuteWithSudo(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) error {
69+
return ExecuteAs(ctx, loggers, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Sudo(), cmd, args...)
70+
}
71+
5172
// Output executes a command and returns its output (stdOutput and stdErr are merged) as string.
52-
func Output(ctx context.Context, loggers logs.Loggers, cmd string, args ...string) (output string, err error) {
73+
func Output(ctx context.Context, loggers logs.Loggers, cmd string, args ...string) (string, error) {
74+
return OutputAs(ctx, loggers, commandUtils.Me(), cmd, args...)
75+
}
76+
77+
// OutputAs executes a command as a different user and returns its output (stdOutput and stdErr are merged) as string.
78+
func OutputAs(ctx context.Context, loggers logs.Loggers, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (output string, err error) {
5379
if loggers == nil {
5480
err = commonerrors.ErrNoLogger
5581
return
@@ -63,7 +89,7 @@ func Output(ctx context.Context, loggers logs.Loggers, cmd string, args ...strin
6389
if err != nil {
6490
return
6591
}
66-
p, err := newPlainSubProcess(ctx, mLoggers, cmd, args...)
92+
p, err := newPlainSubProcess(ctx, mLoggers, as, cmd, args...)
6793
if err != nil {
6894
return
6995
}
@@ -74,11 +100,16 @@ func Output(ctx context.Context, loggers logs.Loggers, cmd string, args ...strin
74100

75101
// Setup sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure.
76102
func (s *Subprocess) Setup(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (err error) {
77-
return s.setup(ctx, loggers, true, messageOnStart, messageOnSuccess, messageOnFailure, cmd, args...)
103+
return s.setup(ctx, loggers, true, messageOnStart, messageOnSuccess, messageOnFailure, commandUtils.Me(), cmd, args...)
104+
}
105+
106+
// SetupAs is similar to Setup but allows the command to be run as a different user.
107+
func (s *Subprocess) SetupAs(ctx context.Context, loggers logs.Loggers, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
108+
return s.setup(ctx, loggers, true, messageOnStart, messageOnSuccess, messageOnFailure, as, cmd, args...)
78109
}
79110

80111
// Setup sets up a sub-process i.e. defines the command cmd and the messages on start, success and failure.
81-
func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, withAdditionalMessages bool, messageOnStart string, messageOnSuccess, messageOnFailure string, cmd string, args ...string) (err error) {
112+
func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, withAdditionalMessages bool, messageOnStart string, messageOnSuccess, messageOnFailure string, as *commandUtils.CommandAsDifferentUser, cmd string, args ...string) (err error) {
82113
if s.IsOn() {
83114
err = s.Stop()
84115
if err != nil {
@@ -89,7 +120,7 @@ func (s *Subprocess) setup(ctx context.Context, loggers logs.Loggers, withAdditi
89120
defer s.mu.Unlock()
90121
s.isRunning.Store(false)
91122
s.processMonitoring = newSubprocessMonitoring(ctx)
92-
s.command = newCommand(loggers, cmd, args...)
123+
s.command = newCommand(loggers, as, cmd, args...)
93124
s.messsaging = newSubprocessMessaging(loggers, withAdditionalMessages, messageOnSuccess, messageOnFailure, messageOnStart, s.command.GetPath())
94125
s.reset()
95126
return s.check()

0 commit comments

Comments
 (0)