Skip to content
Draft
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
14 changes: 8 additions & 6 deletions mgradm/cmd/distro/distro.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2025 SUSE LLC
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

Expand Down Expand Up @@ -107,11 +107,12 @@ func newCmd(globalFlags *types.GlobalFlags, run utils.CommandFunc[flagpole]) (*c
var flags flagpole

distroCmd := &cobra.Command{
Use: "distribution",
GroupID: "tool",
Short: L("Distributions management"),
Long: L("Tools for autoinstallation distributions management"),
Aliases: []string{"distro"},
Use: "distribution",
GroupID: "tool",
Short: L("Distributions management"),
Long: L("Tools for autoinstallation distributions management"),
Aliases: []string{"distro"},
Deprecated: "please use `mgrctl distro` instead",
}

cpCmd := &cobra.Command{
Expand All @@ -130,6 +131,7 @@ Note: API details are required for auto registration.`),
RunE: func(cmd *cobra.Command, args []string) error {
return utils.CommandHelper(globalFlags, cmd, args, &flags, nil, run)
},
Deprecated: "please use `mgrctl distro upload` instead",
}
cpCmd.Flags().String("channel", "", L("Set parent channel for the distribution."))

Expand Down
2 changes: 2 additions & 0 deletions mgrctl/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/spf13/cobra"
"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/distro"
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/exec"
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/proxy"
"github.com/uyuni-project/uyuni-tools/mgrctl/cmd/ssh"
Expand Down Expand Up @@ -57,6 +58,7 @@ func NewUyunictlCommand() *cobra.Command {
rootCmd.AddCommand(completion.NewCommand(globalFlags))
rootCmd.AddCommand(proxy.NewCommand(globalFlags))
rootCmd.AddCommand(ssh.NewCommand(globalFlags))
rootCmd.AddCommand(distro.NewCommand(globalFlags))

rootCmd.AddCommand(utils.GetConfigHelpCommand())

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

package distro

import (
"github.com/spf13/cobra"
"github.com/uyuni-project/uyuni-tools/shared/api"
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
"github.com/uyuni-project/uyuni-tools/shared/types"
"github.com/uyuni-project/uyuni-tools/shared/utils"
)

type apiFlags struct {
api.ConnectionDetails `mapstructure:"api"`
}

func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command {
var flags apiFlags

distroCmd := &cobra.Command{
Use: "distro",
Short: L("Distro management commands"),
}

distroUploadCmd := &cobra.Command{
Use: "upload [path or URL]",
Short: L("Upload a distro ISO to the server"),
Long: L(`Uploads a distro ISO to the server from a local file or a remote URL.`),
RunE: func(cmd *cobra.Command, args []string) error {
return utils.CommandHelper(globalFlags, cmd, args, &flags, nil, runDistroUpload)
},
Args: cobra.ExactArgs(1),
}

distroCmd.AddCommand(distroUploadCmd)
api.AddAPIFlags(distroCmd)

return distroCmd
}
114 changes: 114 additions & 0 deletions mgrctl/cmd/distro/upload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package distro

import (
"bytes"
"fmt"
"mime/multipart"
"net/url"
"os"
"path"
"path/filepath"
"strings"

"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/uyuni-project/uyuni-tools/shared/api"
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
"github.com/uyuni-project/uyuni-tools/shared/types"
"github.com/uyuni-project/uyuni-tools/shared/utils"
)

func distroUpload(client *api.APIClient, filename string, distro []byte) error {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if err := writer.WriteField("filename", filename); err != nil {
return utils.Errorf(err, L("error creating distro upload request"))
}
part, err := writer.CreateFormFile("distro", filename)
if err != nil {
return utils.Errorf(err, L("error creating distro upload request"))
}
if _, err = part.Write(distro); err != nil {
return utils.Errorf(err, L("error creating distro upload request"))
}
if err = writer.Close(); err != nil {
return utils.Errorf(err, L("error creating distro upload request"))
}

response, err := api.PostRaw[float64](client, "admin/distro/uploadDistro", body, writer.FormDataContentType())
if err != nil {
return utils.Errorf(err, L("error uploading distro"))
}

if !response.Success {
return fmt.Errorf(L("failed to upload distro: %s"), response.Message)
}

if int(response.Result) == 1 {
fmt.Println(L("Distro successfully uploaded"))
} else {
fmt.Println(L("unable to upload distro, server returned an error"))
}

return nil
}

func getFilenameFromSource(source string) string {
if parsedURL, err := url.Parse(source); err == nil && parsedURL.Scheme != "" && parsedURL.Host != "" {
filename := path.Base(parsedURL.Path)
if filename != "." && filename != "/" {
return filename
}
return ""
}
return filepath.Base(source)
}

func readDistro(source string) ([]byte, string, error) {
var data []byte
var err error

filename := strings.TrimSpace(getFilenameFromSource(source))
if filename == "" || filename == "." || filename == "/" {
return nil, "", fmt.Errorf(L("unable to determine distro ISO filename from %s"), source)
}

if _, err = os.Stat(source); err == nil {
log.Debug().Msgf("Reading distro ISO from file %s", source)
data, err = os.ReadFile(source)
if err != nil {
return nil, "", utils.Errorf(err, L("failed to read distro ISO file %s"), source)
}
} else {
log.Debug().Msgf("Downloading distro ISO from %s", source)
data, err = utils.GetURLBody(source)
if err != nil {
return nil, "", utils.Errorf(err, L("failed to download distro ISO from %s"), source)
}
}

return data, filename, nil
}

func runDistroUpload(_ *types.GlobalFlags, flags *apiFlags, _ *cobra.Command, args []string) error {
source := args[0]
distro, filename, err := readDistro(source)
if err != nil {
return err
}

log.Debug().Msgf("Uploading ISO...")
client, err := api.Init(&flags.ConnectionDetails)
if err == nil {
err = client.Login()
}
if err != nil {
return utils.Errorf(err, L("unable to login to the server"))
}

return distroUpload(client, filename, distro)
}
159 changes: 159 additions & 0 deletions mgrctl/cmd/distro/upload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package distro

import (
"errors"
"io"
"net/http"
"strings"
"testing"

"github.com/uyuni-project/uyuni-tools/shared/api"
"github.com/uyuni-project/uyuni-tools/shared/api/mocks"
"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

const user = "testUser"
const password = "testPwd"
const server = "testServer"

var connectionDetails = &api.ConnectionDetails{User: user, Password: password, Server: server}

type distroUploadRequest struct {
Filename string
Distro []byte
}

func readDistroUploadRequest(t *testing.T, req *http.Request) distroUploadRequest {
t.Helper()

reader, err := req.MultipartReader()
if err != nil {
t.Fatalf("Failed to create multipart reader: %v", err)
}

var data distroUploadRequest
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Failed to read multipart part: %v", err)
}

partData, err := io.ReadAll(part)
if err != nil {
t.Fatalf("Failed to read multipart data: %v", err)
}

switch part.FormName() {
case "filename":
data.Filename = string(partData)
case "distro":
data.Distro = partData
testutils.AssertEquals(t, "The form file name is not properly passed", data.Filename, part.FileName())
}
}

return data
}

func TestDistroUpload(t *testing.T) {
tests := []struct {
name string
filename string
distro []byte
statusCode int
body string
expectedError string
}{
{
name: "Test uploading a distro ISO",
filename: "test.iso",
distro: []byte("test distro ISO content"),
statusCode: 200,
body: `{"success":true,"result":1}`,
expectedError: "",
},
{
name: "Test server returns error status",
filename: "test.iso",
distro: []byte("test distro ISO content"),
statusCode: 500,
body: ``,
expectedError: "error uploading distro: 500:",
},
{
name: "Test server reports upload failure",
filename: "test.iso",
distro: []byte("test distro ISO content"),
statusCode: 200,
body: `{"success":false,"message":"invalid distro format"}`,
expectedError: "failed to upload distro: invalid distro format",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := api.Init(connectionDetails)
if err != nil {
t.FailNow()
}

client.Client = &mocks.MockClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
testutils.AssertEquals(t, "Wrong URL called", req.URL.Path, "/rhn/manager/api/admin/distro/uploadDistro")
testutils.AssertTrue(t, "Wrong content type", strings.HasPrefix(req.Header.Get("Content-Type"),
"multipart/form-data; boundary="))

data := readDistroUploadRequest(t, req)
testutils.AssertEquals(t, "The filename is not properly passed", tt.filename, data.Filename)
testutils.AssertEquals(t, "The distro is not properly passed", tt.distro, data.Distro)

return testutils.GetResponse(tt.statusCode, tt.body)
},
}

errorMessage := ""
if err := distroUpload(client, tt.filename, tt.distro); err != nil {
errorMessage = err.Error()
}
testutils.AssertStringContains(t, "Unexpected error message", errorMessage, tt.expectedError)
})
}
}

func TestGetFilenameFromSource(t *testing.T) {
tests := []struct {
name string
source string
expected string
}{
{
name: "Test local file",
source: "/tmp/test.iso",
expected: "test.iso",
},
{
name: "Test URL",
source: "https://example.com/images/test.iso",
expected: "test.iso",
},
{
name: "Test URL without filename",
source: "https://example.com/",
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := getFilenameFromSource(tt.source)
testutils.AssertEquals(t, "Unexpected filename", tt.expected, actual)
})
}
}
Loading
Loading