diff --git a/mock_ssh_test.go b/mock_ssh_test.go index c694621..964a1ad 100644 --- a/mock_ssh_test.go +++ b/mock_ssh_test.go @@ -1161,7 +1161,7 @@ func moduleSystemdWithClient(e *Executor, client sshRunner, args map[string]any) // --- Package module shims --- func moduleAptWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { - name := getStringArg(args, "name", "") + names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") updateCache := getBoolArg(args, "update_cache", false) @@ -1173,13 +1173,17 @@ func moduleAptWithClient(_ *Executor, client sshRunner, args map[string]any) (*T switch state { case "present", "installed": - if name != "" { - cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", name) + if len(names) > 0 { + cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", join(" ", names)) } case "absent", "removed": - cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq %s", name) + if len(names) > 0 { + cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq %s", join(" ", names)) + } case "latest": - cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", name) + if len(names) > 0 { + cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", join(" ", names)) + } } if cmd == "" { @@ -1283,7 +1287,7 @@ func moduleDnfWithClient(_ *Executor, client sshRunner, args map[string]any) (*T } func moduleRPMWithClient(client sshRunner, args map[string]any, manager string) (*TaskResult, error) { - name := getStringArg(args, "name", "") + names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") updateCache := getBoolArg(args, "update_cache", false) @@ -1294,29 +1298,29 @@ func moduleRPMWithClient(client sshRunner, args map[string]any, manager string) var cmd string switch state { case "present", "installed": - if name != "" { + if len(names) > 0 { if manager == "rpm" { - cmd = sprintf("rpm -ivh %s", name) + cmd = sprintf("rpm -ivh %s", join(" ", names)) } else { - cmd = sprintf("%s install -y -q %s", manager, name) + cmd = sprintf("%s install -y -q %s", manager, join(" ", names)) } } case "absent", "removed": - if name != "" { + if len(names) > 0 { if manager == "rpm" { - cmd = sprintf("rpm -e %s", name) + cmd = sprintf("rpm -e %s", join(" ", names)) } else { - cmd = sprintf("%s remove -y -q %s", manager, name) + cmd = sprintf("%s remove -y -q %s", manager, join(" ", names)) } } case "latest": - if name != "" { + if len(names) > 0 { if manager == "rpm" { - cmd = sprintf("rpm -Uvh %s", name) + cmd = sprintf("rpm -Uvh %s", join(" ", names)) } else if manager == "dnf" { - cmd = sprintf("%s upgrade -y -q %s", manager, name) + cmd = sprintf("%s upgrade -y -q %s", manager, join(" ", names)) } else { - cmd = sprintf("%s update -y -q %s", manager, name) + cmd = sprintf("%s update -y -q %s", manager, join(" ", names)) } } } @@ -1334,7 +1338,7 @@ func moduleRPMWithClient(client sshRunner, args map[string]any, manager string) } func modulePipWithClient(_ *Executor, client sshRunner, args map[string]any) (*TaskResult, error) { - name := getStringArg(args, "name", "") + names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") executable := getStringArg(args, "executable", "pip3") virtualenv := getStringArg(args, "virtualenv", "") @@ -1355,26 +1359,26 @@ func modulePipWithClient(_ *Executor, client sshRunner, args map[string]any) (*T switch { case requirements != "": parts = append(parts, sprintf("-r %q", requirements)) - case name != "": - parts = append(parts, name) + case len(names) > 0: + parts = append(parts, join(" ", names)) } cmd = join(" ", parts) case "absent", "removed": - if name != "" { + if len(names) > 0 { parts := []string{executable, "uninstall", "-y"} if extraArgs != "" { parts = append(parts, extraArgs) } - parts = append(parts, name) + parts = append(parts, join(" ", names)) cmd = join(" ", parts) } case "latest": - if name != "" { + if len(names) > 0 { parts := []string{executable, "install", "--upgrade"} if extraArgs != "" { parts = append(parts, extraArgs) } - parts = append(parts, name) + parts = append(parts, join(" ", names)) cmd = join(" ", parts) } } @@ -1416,8 +1420,8 @@ func moduleUserWithClient(_ *Executor, client sshRunner, args map[string]any) (* if group := getStringArg(args, "group", ""); group != "" { opts = append(opts, "-g", group) } - if groups := getStringArg(args, "groups", ""); groups != "" { - opts = append(opts, "-G", groups) + if groups := normalizeStringArgs(args["groups"]); len(groups) > 0 { + opts = append(opts, "-G", join(",", groups)) } if home := getStringArg(args, "home", ""); home != "" { opts = append(opts, "-d", home) diff --git a/modules.go b/modules.go index 2330d93..2124d33 100644 --- a/modules.go +++ b/modules.go @@ -1012,7 +1012,7 @@ func (e *Executor) moduleGetURL(ctx context.Context, client sshExecutorClient, a // --- Package Modules --- func (e *Executor) moduleApt(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { - name := getStringArg(args, "name", "") + names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") updateCache := getBoolArg(args, "update_cache", false) @@ -1024,13 +1024,17 @@ func (e *Executor) moduleApt(ctx context.Context, client sshExecutorClient, args switch state { case "present", "installed": - if name != "" { - cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", name) + if len(names) > 0 { + cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", join(" ", names)) } case "absent", "removed": - cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq %s", name) + if len(names) > 0 { + cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq %s", join(" ", names)) + } case "latest": - cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", name) + if len(names) > 0 { + cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", join(" ", names)) + } } if cmd == "" { @@ -1137,7 +1141,7 @@ func (e *Executor) moduleDnf(ctx context.Context, client sshExecutorClient, args } func (e *Executor) moduleRPM(ctx context.Context, client sshExecutorClient, args map[string]any, manager string) (*TaskResult, error) { - name := getStringArg(args, "name", "") + names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") updateCache := getBoolArg(args, "update_cache", false) @@ -1148,29 +1152,29 @@ func (e *Executor) moduleRPM(ctx context.Context, client sshExecutorClient, args var cmd string switch state { case "present", "installed": - if name != "" { + if len(names) > 0 { if manager == "rpm" { - cmd = sprintf("rpm -ivh %s", name) + cmd = sprintf("rpm -ivh %s", join(" ", names)) } else { - cmd = sprintf("%s install -y -q %s", manager, name) + cmd = sprintf("%s install -y -q %s", manager, join(" ", names)) } } case "absent", "removed": - if name != "" { + if len(names) > 0 { if manager == "rpm" { - cmd = sprintf("rpm -e %s", name) + cmd = sprintf("rpm -e %s", join(" ", names)) } else { - cmd = sprintf("%s remove -y -q %s", manager, name) + cmd = sprintf("%s remove -y -q %s", manager, join(" ", names)) } } case "latest": - if name != "" { + if len(names) > 0 { if manager == "rpm" { - cmd = sprintf("rpm -Uvh %s", name) + cmd = sprintf("rpm -Uvh %s", join(" ", names)) } else if manager == "dnf" { - cmd = sprintf("%s upgrade -y -q %s", manager, name) + cmd = sprintf("%s upgrade -y -q %s", manager, join(" ", names)) } else { - cmd = sprintf("%s update -y -q %s", manager, name) + cmd = sprintf("%s update -y -q %s", manager, join(" ", names)) } } } @@ -1188,7 +1192,7 @@ func (e *Executor) moduleRPM(ctx context.Context, client sshExecutorClient, args } func (e *Executor) modulePip(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { - name := getStringArg(args, "name", "") + names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") executable := getStringArg(args, "executable", "pip3") virtualenv := getStringArg(args, "virtualenv", "") @@ -1209,26 +1213,26 @@ func (e *Executor) modulePip(ctx context.Context, client sshExecutorClient, args switch { case requirements != "": parts = append(parts, sprintf("-r %q", requirements)) - case name != "": - parts = append(parts, name) + case len(names) > 0: + parts = append(parts, join(" ", names)) } cmd = join(" ", parts) case "absent", "removed": - if name != "" { + if len(names) > 0 { parts := []string{executable, "uninstall", "-y"} if extraArgs != "" { parts = append(parts, extraArgs) } - parts = append(parts, name) + parts = append(parts, join(" ", names)) cmd = join(" ", parts) } case "latest": - if name != "" { + if len(names) > 0 { parts := []string{executable, "install", "--upgrade"} if extraArgs != "" { parts = append(parts, extraArgs) } - parts = append(parts, name) + parts = append(parts, join(" ", names)) cmd = join(" ", parts) } } @@ -1323,8 +1327,8 @@ func (e *Executor) moduleUser(ctx context.Context, client sshExecutorClient, arg if group := getStringArg(args, "group", ""); group != "" { opts = append(opts, "-g", group) } - if groups := getStringArg(args, "groups", ""); groups != "" { - opts = append(opts, "-G", groups) + if groups := normalizeStringArgs(args["groups"]); len(groups) > 0 { + opts = append(opts, "-G", join(",", groups)) } if home := getStringArg(args, "home", ""); home != "" { opts = append(opts, "-d", home) @@ -1992,6 +1996,48 @@ func normalizeStringList(value any) []string { } } +// normalizeStringArgs collects one or more string values from a scalar or list +// input without splitting comma-separated content. +func normalizeStringArgs(value any) []string { + switch v := value.(type) { + case nil: + return nil + case string: + if trimmed := corexTrimSpace(v); trimmed != "" { + return []string{trimmed} + } + case []string: + out := make([]string, 0, len(v)) + for _, item := range v { + if trimmed := corexTrimSpace(item); trimmed != "" { + out = append(out, trimmed) + } + } + return out + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + if trimmed := corexTrimSpace(s); trimmed != "" { + out = append(out, trimmed) + } + continue + } + s := corexTrimSpace(corexSprint(item)) + if s != "" && s != "" { + out = append(out, s) + } + } + return out + default: + s := corexTrimSpace(corexSprint(v)) + if s != "" && s != "" { + return []string{s} + } + } + return nil +} + func ensureInventoryGroup(parent *InventoryGroup, name string) *InventoryGroup { if parent == nil { return nil diff --git a/modules_adv_test.go b/modules_adv_test.go index e200620..2cc0008 100644 --- a/modules_adv_test.go +++ b/modules_adv_test.go @@ -61,6 +61,22 @@ func TestModulesAdv_ModuleUser_Good_ModifyExistingUser(t *testing.T) { assert.True(t, mock.containsSubstring("-s /bin/zsh")) } +func TestModulesAdv_ModuleUser_Good_GroupListInput(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`id deploy >/dev/null 2>&1`, "", "no such user", 1) + mock.expectCommand(`useradd`, "", "", 0) + + result, err := moduleUserWithClient(e, mock, map[string]any{ + "name": "deploy", + "groups": []any{"docker", "sudo"}, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Failed) + assert.True(t, mock.containsSubstring("-G docker,sudo")) +} + func TestModulesAdv_ModuleUser_Good_RemoveUser(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.expectCommand(`userdel -r deploy`, "", "", 0) diff --git a/modules_svc_test.go b/modules_svc_test.go index e107f02..f1e0545 100644 --- a/modules_svc_test.go +++ b/modules_svc_test.go @@ -424,6 +424,19 @@ func TestModulesSvc_ModuleApt_Good_DefaultStateIsPresent(t *testing.T) { assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`)) } +func TestModulesSvc_ModuleApt_Good_InstallMultiplePackages(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`apt-get install -y -qq nginx curl`, "", "", 0) + + result, err := moduleAptWithClient(e, mock, map[string]any{ + "name": []any{"nginx", "curl"}, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`apt-get install -y -qq nginx curl`)) +} + // --- apt_key module --- func TestModulesSvc_ModuleAptKey_Good_AddWithKeyring(t *testing.T) { @@ -902,6 +915,19 @@ func TestModulesSvc_ModulePip_Good_DefaultStateIsPresent(t *testing.T) { assert.True(t, mock.hasExecuted(`pip3 install django`)) } +func TestModulesSvc_ModulePip_Good_InstallMultiplePackages(t *testing.T) { + e, mock := newTestExecutorWithMock("host1") + mock.expectCommand(`pip3 install requests flask`, "", "", 0) + + result, err := modulePipWithClient(e, mock, map[string]any{ + "name": []any{"requests", "flask"}, + }) + + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, mock.hasExecuted(`pip3 install requests flask`)) +} + func TestModulesSvc_ModulePip_Good_CommandFailure(t *testing.T) { e, mock := newTestExecutorWithMock("host1") mock.expectCommand(`pip3 install`, "", "ERROR: No matching distribution found", 1)