diff --git a/internal/script/script.go b/internal/script/script.go index 598e9580..7f03b546 100644 --- a/internal/script/script.go +++ b/internal/script/script.go @@ -14,6 +14,7 @@ import ( "slices" "strconv" "strings" + "text/template" "perfspect/internal/cpus" "perfspect/internal/target" @@ -93,7 +94,11 @@ func RunScripts(myTarget target.Target, scripts []ScriptDefinition, ignoreScript if len(parallelScripts) > 0 { // form one master script that calls all the parallel scripts in the background masterScriptName := "parallel_master.sh" - masterScript, needsElevatedPrivileges := formMasterScript(myTarget.GetTempDirectory(), parallelScripts) + masterScript, needsElevatedPrivileges, err := formMasterScript(myTarget.GetTempDirectory(), parallelScripts) + if err != nil { + err = fmt.Errorf("error forming master script: %v", err) + return nil, err + } // write master script to local file masterScriptPath := path.Join(localTempDir, myTarget.GetName(), masterScriptName) err = os.WriteFile(masterScriptPath, []byte(masterScript), 0600) @@ -277,97 +282,126 @@ func scriptNameToFilename(name string) string { // formMasterScript forms a master script that runs all parallel scripts in the background, waits for them to finish, then prints the output of each script. // Return values are the master script and a boolean indicating whether the master script requires elevated privileges. -func formMasterScript(targetTempDirectory string, parallelScripts []ScriptDefinition) (string, bool) { - // we write the stdout and stderr from each command to temporary files and save the PID of each command - // in a variable named after the script - var masterScript strings.Builder +func formMasterScript(targetTempDirectory string, parallelScripts []ScriptDefinition) (string, bool, error) { + // data model for template + type tplScript struct { + Name string + Sanitized string + NeedsKill bool + Superuser bool + } + data := struct { + TargetTempDir string + Scripts []tplScript + }{} + data.TargetTempDir = targetTempDirectory + needsElevated := false + for _, s := range parallelScripts { + if s.Superuser { + needsElevated = true + } + data.Scripts = append(data.Scripts, tplScript{ + Name: s.Name, Sanitized: sanitizeScriptName(s.Name), NeedsKill: s.NeedsKill, Superuser: s.Superuser, + }) + } + const masterScriptTemplate = `#!/usr/bin/env bash +set -o errexit +set -o pipefail - masterScript.WriteString("#!/bin/bash\n") +script_dir={{.TargetTempDir}} +cd "$script_dir" - // set dir var and change working directory to dir in case any of the scripts write out temporary files - masterScript.WriteString(fmt.Sprintf("script_dir=%s\n", targetTempDirectory)) - masterScript.WriteString("cd $script_dir\n") +declare -a scripts=() +declare -A needs_kill=() +declare -A pids=() +declare -A exitcodes=() +declare -A orig_names=() - // function to print the output of each script - masterScript.WriteString("\nprint_output() {\n") - for _, script := range parallelScripts { - masterScript.WriteString("\techo \"<---------------------->\"\n") - masterScript.WriteString(fmt.Sprintf("\techo SCRIPT NAME: %s\n", script.Name)) - masterScript.WriteString(fmt.Sprintf("\techo STDOUT:\n\tcat %s\n", path.Join("$script_dir", sanitizeScriptName(script.Name)+".stdout"))) - masterScript.WriteString(fmt.Sprintf("\techo STDERR:\n\tcat %s\n", path.Join("$script_dir", sanitizeScriptName(script.Name)+".stderr"))) - masterScript.WriteString(fmt.Sprintf("\techo EXIT CODE: $%s_exitcode\n", sanitizeScriptName(script.Name))) - } - masterScript.WriteString("}\n") +{{- range .Scripts}} +scripts+=({{ .Sanitized }}) +needs_kill[{{ .Sanitized }}]={{ if .NeedsKill }}1{{ else }}0{{ end }} +orig_names[{{ .Sanitized }}]="{{ .Name }}" +{{ end }} - // function to handle SIGINT - masterScript.WriteString("\nhandle_sigint() {\n") - for _, script := range parallelScripts { - // send SIGINT to the child script, if it is still running - masterScript.WriteString(fmt.Sprintf("\tif ps -p \"$%s_pid\" > /dev/null; then\n", sanitizeScriptName(script.Name))) - masterScript.WriteString(fmt.Sprintf("\t\tkill -SIGINT $%s_pid\n", sanitizeScriptName(script.Name))) - masterScript.WriteString("\tfi\n") - if script.NeedsKill { // this is primarily used for scripts that start commands in the background, some of which (processwatch) doesn't respond to SIGINT as expected - // if the *cmd.pid file exists, check if the process is still running - masterScript.WriteString(fmt.Sprintf("\tif [ -f %s_cmd.pid ]; then\n", sanitizeScriptName(script.Name))) - masterScript.WriteString(fmt.Sprintf("\t\tif ps -p $(cat %s_cmd.pid) > /dev/null; then\n", sanitizeScriptName(script.Name))) - // send SIGINT to the background process first, then SIGKILL if it doesn't respond to SIGINT - masterScript.WriteString(fmt.Sprintf("\t\t\tkill -SIGINT $(cat %s_cmd.pid)\n", sanitizeScriptName(script.Name))) - // give the process a chance to respond to SIGINT - masterScript.WriteString("\t\t\tsleep 0.5\n") - // if the background process is still running, send SIGKILL - masterScript.WriteString(fmt.Sprintf("\t\t\tif ps -p $(cat %s_cmd.pid) > /dev/null; then\n", sanitizeScriptName(script.Name))) - masterScript.WriteString(fmt.Sprintf("\t\t\t\tkill -SIGKILL $(cat %s_cmd.pid)\n", sanitizeScriptName(script.Name))) - masterScript.WriteString(fmt.Sprintf("\t\t\t\t%s_exitcode=137\n", sanitizeScriptName(script.Name))) // 137 is the exit code for SIGKILL - masterScript.WriteString("\t\t\telse\n") - // if the background process has exited, set the exit code to 0 - masterScript.WriteString(fmt.Sprintf("\t\t\t\t%s_exitcode=0\n", sanitizeScriptName(script.Name))) - masterScript.WriteString("\t\t\tfi\n") - masterScript.WriteString("\t\telse\n") - // if the script itself has exited, set the exit code to 0 - masterScript.WriteString(fmt.Sprintf("\t\t\t%s_exitcode=0\n", sanitizeScriptName(script.Name))) - masterScript.WriteString("\t\tfi\n") - masterScript.WriteString("\telse\n") - // if the *cmd.pid file doesn't exist, set the exit code to 1 - masterScript.WriteString(fmt.Sprintf("\t\t%s_exitcode=0\n", sanitizeScriptName(script.Name))) - masterScript.WriteString("\tfi\n") - } else { - masterScript.WriteString(fmt.Sprintf("\twait \"$%s_pid\"\n", sanitizeScriptName(script.Name))) - masterScript.WriteString(fmt.Sprintf("\t%s_exitcode=$?\n", sanitizeScriptName(script.Name))) - } - } - masterScript.WriteString("\tprint_output\n") - masterScript.WriteString("\texit 0\n") - masterScript.WriteString("}\n") +start_scripts() { + for s in "${scripts[@]}"; do + bash "$script_dir/${s}.sh" > "$script_dir/${s}.stdout" 2> "$script_dir/${s}.stderr" & + pids[$s]=$! + done +} + +kill_script() { + local s="$1" + local pid="${pids[$s]:-}" + [[ -z "$pid" ]] && return 0 + if ! ps -p "$pid" > /dev/null 2>&1; then return 0; fi + if [[ "${needs_kill[$s]}" == "1" && -f "${s}_cmd.pid" ]]; then + local bgpid + bgpid="$(cat "${s}_cmd.pid" 2>/dev/null || true)" + if [[ -n "$bgpid" && $(ps -p "$bgpid" -o pid= 2>/dev/null) ]]; then + kill -SIGINT "$bgpid" 2>/dev/null || true + sleep 0.5 + if ps -p "$bgpid" > /dev/null 2>&1; then + kill -SIGKILL "$bgpid" 2>/dev/null || true + exitcodes[$s]=137 + else + exitcodes[$s]=0 + fi + fi + else + kill -SIGINT "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + if [[ -z "${exitcodes[$s]:-}" ]]; then exitcodes[$s]=130; fi + fi +} - // call handle_sigint func when SIGINT is received - masterScript.WriteString("\ntrap handle_sigint SIGINT\n") +wait_for_scripts() { + for s in "${scripts[@]}"; do + if wait "${pids[$s]}"; then + exitcodes[$s]=0 + else + ec=$? + exitcodes[$s]=$ec + fi + done +} - // run all parallel scripts in the background - masterScript.WriteString("\n") - needsElevatedPrivileges := false - for _, script := range parallelScripts { - if script.Superuser { - needsElevatedPrivileges = true - } - masterScript.WriteString( - fmt.Sprintf("bash %s > %s 2>%s &\n", - path.Join("$script_dir", scriptNameToFilename(script.Name)), - path.Join("$script_dir", sanitizeScriptName(script.Name)+".stdout"), - path.Join("$script_dir", sanitizeScriptName(script.Name)+".stderr"), - ), - ) - masterScript.WriteString(fmt.Sprintf("%s_pid=$!\n", sanitizeScriptName(script.Name))) - } +print_summary() { + for s in "${scripts[@]}"; do + echo "<---------------------->" + echo "SCRIPT NAME: ${orig_names[$s]}" + echo "STDOUT:"; cat "$script_dir/${s}.stdout" || true + echo "STDERR:"; cat "$script_dir/${s}.stderr" || true + echo "EXIT CODE: ${exitcodes[$s]:-1}" + done +} - // wait for all parallel scripts to finish then print their output - masterScript.WriteString("\n") - for _, script := range parallelScripts { - masterScript.WriteString(fmt.Sprintf("wait \"$%s_pid\"\n", sanitizeScriptName(script.Name))) - masterScript.WriteString(fmt.Sprintf("%s_exitcode=$?\n", sanitizeScriptName(script.Name))) - } - masterScript.WriteString("\nprint_output\n") +handle_sigint() { + echo "Received SIGINT; attempting graceful shutdown" >&2 + for s in "${scripts[@]}"; do + kill_script "$s" + done + print_summary + exit 0 +} + +trap handle_sigint SIGINT - return masterScript.String(), needsElevatedPrivileges +start_scripts +wait_for_scripts +print_summary +` + tmpl, err := template.New("master").Parse(masterScriptTemplate) + if err != nil { + slog.Error("failed to parse master script template", slog.String("error", err.Error())) + return "", needsElevated, err + } + var out strings.Builder + if err = tmpl.Execute(&out, data); err != nil { + slog.Error("failed to execute master script template", slog.String("error", err.Error())) + return "", needsElevated, err + } + return out.String(), needsElevated, nil } // parseMasterScriptOutput parses the output of the master script that runs all parallel scripts in the background. diff --git a/internal/script/script_master_test.go b/internal/script/script_master_test.go new file mode 100644 index 00000000..8116a50a --- /dev/null +++ b/internal/script/script_master_test.go @@ -0,0 +1,150 @@ +// Copyright (C) 2021-2025 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// Unit tests for formMasterScript and parseMasterScriptOutput. +// These tests validate: +// 1. Template structure contains expected sections and sanitized names. +// 2. Elevated privilege flag logic (returns true if any script is superuser). +// 3. Behavior with empty script slice. +// 4. Integration: executing generated master script with stub child scripts and parsing output. + +package script + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// minimal ScriptDefinition stub (using existing type) – fields used: Name, Superuser, NeedsKill. + +func TestFormMasterScriptTemplateStructure(t *testing.T) { + scripts := []ScriptDefinition{ + {Name: "alpha script", Superuser: false}, + {Name: "beta-script", Superuser: true}, + } + master, elevated, err := formMasterScript("/tmp/targetdir", scripts) + if err != nil { + t.Fatalf("error forming master script: %v", err) + } + if !elevated { + t.Fatalf("expected elevated=true when at least one script is superuser") + } + // Shebang + if !strings.HasPrefix(master, "#!/usr/bin/env bash") { + t.Errorf("master script missing shebang") + } + // Functions present + for _, fn := range []string{"start_scripts()", "kill_script()", "wait_for_scripts()", "print_summary()", "handle_sigint()"} { + if !strings.Contains(master, fn) { + t.Errorf("expected function %s in master script", fn) + } + } + // Sanitized names appear (spaces and hyphens replaced with underscores) + if !strings.Contains(master, "alpha_script") || !strings.Contains(master, "beta_script") { + t.Errorf("sanitized script names not found in template output") + } + // Mapping of original names present (orig_names associative array entries) + for _, mapping := range []string{"orig_names[alpha_script]=\"alpha script\"", "orig_names[beta_script]=\"beta-script\""} { + if !strings.Contains(master, mapping) { + t.Errorf("expected original name mapping %q in master script", mapping) + } + } + // Delimiter used for parsing + if !strings.Contains(master, "<---------------------->") { + t.Errorf("expected delimiter for parsing in master script") + } +} + +func TestFormMasterScriptNeedsElevatedFlag(t *testing.T) { + scripts := []ScriptDefinition{{Name: "user", Superuser: false}, {Name: "also user", Superuser: false}} + _, elevated, err := formMasterScript("/tmp/dir", scripts) + if err != nil { + t.Fatalf("error forming master script: %v", err) + } + if elevated { + t.Fatalf("expected elevated=false when no scripts require superuser") + } +} + +func TestFormMasterScriptEmptyScripts(t *testing.T) { + master, elevated, err := formMasterScript("/tmp/dir", nil) + if err != nil { + t.Fatalf("error forming master script: %v", err) + } + if elevated { + t.Fatalf("expected elevated=false with empty slice") + } + // Should still contain core function definitions even if no scripts. + if !strings.Contains(master, "start_scripts()") || !strings.Contains(master, "print_summary()") { + t.Errorf("template missing expected functions for empty slice") + } + t.Logf("MASTER SCRIPT EMPTY:\n%s", master) + // No orig_names assignments lines for empty slice. + if strings.Count(master, "orig_names[") > 0 { + for line := range strings.SplitSeq(master, "\n") { + if strings.Contains(line, "orig_names[") && strings.Contains(line, "]=") { + // assignment line detected + t.Errorf("no orig_names mappings should appear for empty slice") + } + } + } +} + +func TestFormMasterScriptExecutionIntegration(t *testing.T) { + // Integration test: create temp directory, stub two child scripts, run master script, parse output. + tmp := t.TempDir() + scripts := []ScriptDefinition{{Name: "alpha script"}, {Name: "beta-script"}} + master, elevated, err := formMasterScript(tmp, scripts) + if err != nil { + t.Fatalf("error forming master script: %v", err) + } + if elevated { // none marked superuser + t.Fatalf("did not expect elevated=true for non-superuser scripts") + } + // Create child scripts. + for _, s := range scripts { + sanitized := sanitizeScriptName(s.Name) + childPath := filepath.Join(tmp, sanitized+".sh") + content := "#!/usr/bin/env bash\n" + "echo STDOUT-" + sanitized + "\n" + "echo STDERR-" + sanitized + " 1>&2\n" + if err := os.WriteFile(childPath, []byte(content), 0o700); err != nil { + t.Fatalf("failed writing child script %s: %v", childPath, err) + } + } + // Write master script. + masterPath := filepath.Join(tmp, "parallel_master.sh") + if err := os.WriteFile(masterPath, []byte(master), 0o700); err != nil { + t.Fatalf("failed writing master script: %v", err) + } + // Run master script. + out, err := runLocalBash(masterPath) + if err != nil { + // Read master script content for debugging + content, _ := os.ReadFile(masterPath) + t.Fatalf("error executing master script: %v\nstdout+stderr: %s\nMASTER SCRIPT:\n%s", err, out, string(content)) + } + parsed := parseMasterScriptOutput(out) + if len(parsed) != 2 { + t.Fatalf("expected 2 parsed script outputs, got %d", len(parsed)) + } + // Validate each output. + for _, p := range parsed { + if p.Exitcode != 0 { // child scripts exit 0 + t.Errorf("expected exit code 0 for %s, got %d", p.Name, p.Exitcode) + } + if !strings.Contains(p.Stdout, "STDOUT-"+sanitizeScriptName(p.Name)) { + t.Errorf("stdout mismatch for %s: %q", p.Name, p.Stdout) + } + if !strings.Contains(p.Stderr, "STDERR-"+sanitizeScriptName(p.Name)) { + t.Errorf("stderr mismatch for %s: %q", p.Name, p.Stderr) + } + } +} + +// runLocalBash executes a bash script locally and returns combined stdout. +func runLocalBash(scriptPath string) (string, error) { + outBytes, err := exec.Command("bash", scriptPath).CombinedOutput() // #nosec G204 + return string(outBytes), err +}