Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mgrctl/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/api"
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/cp"
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/exec"
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/logs"
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/proxy"
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/term"
"github.com/uyuni-project/uyuni-tools/shared/completion"
Expand Down Expand Up @@ -55,6 +56,7 @@ func NewUyunictlCommand() *cobra.Command {
rootCmd.AddCommand(cp.NewCommand(globalFlags))
rootCmd.AddCommand(completion.NewCommand(globalFlags))
rootCmd.AddCommand(proxy.NewCommand(globalFlags))
rootCmd.AddCommand(logs.NewCommand(globalFlags))

rootCmd.AddCommand(utils.GetConfigHelpCommand())

Expand Down
147 changes: 147 additions & 0 deletions mgrctl/cmd/logs/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package logs

import (
"errors"
"fmt"
"os"
os_exec "os/exec"
"strings"

"github.com/spf13/cobra"
"github.com/uyuni-project/uyuni-tools/shared"
"github.com/uyuni-project/uyuni-tools/shared/kubernetes"
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
"github.com/uyuni-project/uyuni-tools/shared/podman"
"github.com/uyuni-project/uyuni-tools/shared/types"
"github.com/uyuni-project/uyuni-tools/shared/utils"
)

type flagpole struct {
Taskomatic bool
Web bool
Salt bool
Reposync bool
Files string
Follow bool
Backend string
}

// runCmd allows unit tests to mock or skip the main execution loop.
var runCmd = run

// NewCommand returns a new cobra.Command for logs.
func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command {
var flags flagpole

logsCmd := &cobra.Command{
Use: "logs",
Short: L("Show or follow logs of Uyuni services inside the container"),
RunE: func(cmd *cobra.Command, args []string) error {
return utils.CommandHelper(globalFlags, cmd, args, &flags, nil, runCmd)
},
}

logsCmd.Flags().Bool("taskomatic", false, L("Show Taskomatic logs"))
logsCmd.Flags().Bool("web", false, L("Show Web UI logs"))
logsCmd.Flags().Bool("salt", false, L("Show Salt logs"))
logsCmd.Flags().Bool("reposync", false, L("Show Reposync logs"))
logsCmd.Flags().String("files", "",
L("Regular expression to filter reposync log files (matches against the full file path)"))
logsCmd.Flags().BoolP("follow", "f", false, L("Follow log output"))
Comment on lines +51 to +54

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--files is silently ignored unless --reposync is also set, which is confusing given the flag help text. It would be better to validate this combination (e.g., return an error if --files is provided without --reposync).

Copilot uses AI. Check for mistakes.

utils.AddBackendFlag(logsCmd)
return logsCmd
}

func validateFlags(flags *flagpole) error {
if flags.Files != "" && !flags.Reposync {
return errors.New(L("--files flag can only be used with --reposync"))
}
return nil
}

func getLogPaths(flags *flagpole) []string {
var paths []string
if flags.Taskomatic {
paths = append(paths, "/var/log/rhn/rhn_tasko*.log")
}
if flags.Web {
paths = append(paths, "/var/log/rhn/rhn_web*.log")
}
if flags.Salt {
// Minion does not run inside the Uyuni server container
paths = append(paths, "/var/log/salt/api", "/var/log/salt/master")
}
if flags.Reposync {
if flags.Files != "" {
script := "files=$(find /var/log/rhn/reposync/ -type f | grep -E %q); " +
"if [ -z \"$files\" ]; then "
// Use literal path on follow so tail can wait for file creation
if flags.Follow {
script += "echo \"/var/log/rhn/reposync/*.log\"; else echo $files; fi"
} else {
script += "echo 'No matching reposync logs found.' >&2; exit 1; fi; echo $files"
}
paths = append(paths, fmt.Sprintf("$(%s)", fmt.Sprintf(script, flags.Files)))
} else {
paths = append(paths, "/var/log/rhn/reposync/*.log")
}
}
return paths
}

func run(_ *types.GlobalFlags, flags *flagpole, _ *cobra.Command, _ []string) error {
if err := validateFlags(flags); err != nil {
return err
}

paths := getLogPaths(flags)
if len(paths) == 0 {
return errors.New(L("please specify at least one service to get logs for (e.g., --web, --salt)"))
}
Comment on lines +36 to +105

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are unit tests for other mgrctl subcommands (e.g., mgrctl/cmd/term/term_test.go, mgrctl/cmd/api/api_test.go), but this new command has none. Adding tests for flag validation (no service selected, --files without --reposync) and for the assembled exec arguments would help prevent regressions, especially around the reposync filtering behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, unit tests would be needed. We at least want to be sure that the flags logic is correct.


cnx := shared.NewConnection(flags.Backend, podman.ServerContainerName, kubernetes.ServerFilter)
podName, err := cnx.GetPodName()
if err != nil {
return err
}

command, err := cnx.GetCommand()
if err != nil {
return err
}

tailCmd := "tail"
if flags.Follow {
tailCmd += " -F"
}

shCmd := fmt.Sprintf("%s %s", tailCmd, strings.Join(paths, " "))
commandArgs := []string{"exec", "-i", "-t"}

if command == "kubectl" {
namespace, err := cnx.GetNamespace("")
if err != nil {
return err
}
commandArgs = append(commandArgs, "-n", namespace, "-c", "uyuni", podName, "--")
} else {
commandArgs = append(commandArgs, podName)
}

commandArgs = append(commandArgs, "bash", "-c", shCmd)

_, err = utils.NewRunner(command, commandArgs...).Exec()
if err != nil {
var exitErr *os_exec.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.ExitCode())
}
return err
}
return nil
}
50 changes: 50 additions & 0 deletions mgrctl/cmd/logs/logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package logs

import (
"strings"
"testing"
)

func TestValidateFlags(t *testing.T) {
// Test --files without --reposync (Should Fail)
invalidFlags := &flagpole{Files: "myregex"}
err := validateFlags(invalidFlags)
if err == nil {
t.Errorf("Expected error when using --files without --reposync, got nil")
}

// Test --files with --reposync (Should Pass)
validFlags := &flagpole{Reposync: true, Files: "myregex"}
err = validateFlags(validFlags)
if err != nil {
t.Errorf("Expected no error when using --files with --reposync, got %v", err)
}
}

func TestGetLogPaths(t *testing.T) {
flags := &flagpole{
Salt: true,
}

paths := getLogPaths(flags)

// Test 1: Ensure salt paths exist but minion is absent
pathStr := strings.Join(paths, " ")
if !strings.Contains(pathStr, "/var/log/salt/api") || !strings.Contains(pathStr, "/var/log/salt/master") {
t.Errorf("Expected salt api and master paths, got: %v", paths)
}
if strings.Contains(pathStr, "/var/log/salt/minion") {
t.Errorf("Did not expect minion path in salt logs, got: %v", paths)
}

// Test 2: Ensure empty paths if no flags are set
emptyFlags := &flagpole{}
emptyPaths := getLogPaths(emptyFlags)
if len(emptyPaths) != 0 {
t.Errorf("Expected 0 paths for empty flags, got: %v", emptyPaths)
}
}