Skip to content

Commit 7ce37ea

Browse files
committed
Add mgrctl command for distro management
1 parent 5bd1e5c commit 7ce37ea

6 files changed

Lines changed: 368 additions & 8 deletions

File tree

mgradm/cmd/distro/distro.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// SPDX-FileCopyrightText: 2025 SUSE LLC
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

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

109109
distroCmd := &cobra.Command{
110-
Use: "distribution",
111-
GroupID: "tool",
112-
Short: L("Distributions management"),
113-
Long: L("Tools for autoinstallation distributions management"),
114-
Aliases: []string{"distro"},
110+
Use: "distribution",
111+
GroupID: "tool",
112+
Short: L("Distributions management"),
113+
Long: L("Tools for autoinstallation distributions management"),
114+
Aliases: []string{"distro"},
115+
Deprecated: "please use `mgrctl distro` instead",
115116
}
116117

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

mgrctl/cmd/distro/distro.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package distro
6+
7+
import (
8+
"github.com/spf13/cobra"
9+
"github.com/uyuni-project/uyuni-tools/shared/api"
10+
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
11+
"github.com/uyuni-project/uyuni-tools/shared/types"
12+
"github.com/uyuni-project/uyuni-tools/shared/utils"
13+
)
14+
15+
type apiFlags struct {
16+
api.ConnectionDetails `mapstructure:"api"`
17+
}
18+
19+
func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command {
20+
var flags apiFlags
21+
22+
distroCmd := &cobra.Command{
23+
Use: "distro",
24+
Short: L("Distro management commands"),
25+
}
26+
27+
distroUploadCmd := &cobra.Command{
28+
Use: "upload [path or URL]",
29+
Short: L("Upload a distro ISO to the server"),
30+
Long: L(`Uploads a distro ISO to the server from a local file or a remote URL.`),
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
return utils.CommandHelper(globalFlags, cmd, args, &flags, nil, runDistroUpload)
33+
},
34+
Args: cobra.ExactArgs(1),
35+
}
36+
37+
distroCmd.AddCommand(distroUploadCmd)
38+
api.AddAPIFlags(distroCmd)
39+
40+
return distroCmd
41+
}

mgrctl/cmd/distro/upload.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package distro
6+
7+
import (
8+
"bytes"
9+
"fmt"
10+
"mime/multipart"
11+
"net/url"
12+
"os"
13+
"path"
14+
"path/filepath"
15+
"strings"
16+
17+
"github.com/rs/zerolog/log"
18+
"github.com/spf13/cobra"
19+
"github.com/uyuni-project/uyuni-tools/shared/api"
20+
. "github.com/uyuni-project/uyuni-tools/shared/l10n"
21+
"github.com/uyuni-project/uyuni-tools/shared/types"
22+
"github.com/uyuni-project/uyuni-tools/shared/utils"
23+
)
24+
25+
func distroUpload(client *api.APIClient, filename string, distro []byte) error {
26+
body := &bytes.Buffer{}
27+
writer := multipart.NewWriter(body)
28+
if err := writer.WriteField("filename", filename); err != nil {
29+
return utils.Errorf(err, L("error creating distro upload request"))
30+
}
31+
part, err := writer.CreateFormFile("distro", filename)
32+
if err != nil {
33+
return utils.Errorf(err, L("error creating distro upload request"))
34+
}
35+
if _, err = part.Write(distro); err != nil {
36+
return utils.Errorf(err, L("error creating distro upload request"))
37+
}
38+
if err = writer.Close(); err != nil {
39+
return utils.Errorf(err, L("error creating distro upload request"))
40+
}
41+
42+
response, err := api.PostRaw[float64](client, "admin/distro/uploadDistro", body, writer.FormDataContentType())
43+
if err != nil {
44+
return utils.Errorf(err, L("error uploading distro"))
45+
}
46+
47+
if !response.Success {
48+
return fmt.Errorf(L("failed to upload distro: %s"), response.Message)
49+
}
50+
51+
if int(response.Result) == 1 {
52+
fmt.Println(L("Distro successfully uploaded"))
53+
} else {
54+
fmt.Println(L("unable to upload distro, server returned an error"))
55+
}
56+
57+
return nil
58+
}
59+
60+
func getFilenameFromSource(source string) string {
61+
if parsedURL, err := url.Parse(source); err == nil && parsedURL.Scheme != "" && parsedURL.Host != "" {
62+
filename := path.Base(parsedURL.Path)
63+
if filename != "." && filename != "/" {
64+
return filename
65+
}
66+
return ""
67+
}
68+
return filepath.Base(source)
69+
}
70+
71+
func readDistro(source string) ([]byte, string, error) {
72+
var data []byte
73+
var err error
74+
75+
filename := strings.TrimSpace(getFilenameFromSource(source))
76+
if filename == "" || filename == "." || filename == "/" {
77+
return nil, "", fmt.Errorf(L("unable to determine distro ISO filename from %s"), source)
78+
}
79+
80+
if _, err = os.Stat(source); err == nil {
81+
log.Debug().Msgf("Reading distro ISO from file %s", source)
82+
data, err = os.ReadFile(source)
83+
if err != nil {
84+
return nil, "", utils.Errorf(err, L("failed to read distro ISO file %s"), source)
85+
}
86+
} else {
87+
log.Debug().Msgf("Downloading distro ISO from %s", source)
88+
data, err = utils.GetURLBody(source)
89+
if err != nil {
90+
return nil, "", utils.Errorf(err, L("failed to download distro ISO from %s"), source)
91+
}
92+
}
93+
94+
return data, filename, nil
95+
}
96+
97+
func runDistroUpload(_ *types.GlobalFlags, flags *apiFlags, _ *cobra.Command, args []string) error {
98+
source := args[0]
99+
distro, filename, err := readDistro(source)
100+
if err != nil {
101+
return err
102+
}
103+
104+
log.Debug().Msgf("Uploading ISO...")
105+
client, err := api.Init(&flags.ConnectionDetails)
106+
if err == nil {
107+
err = client.Login()
108+
}
109+
if err != nil {
110+
return utils.Errorf(err, L("unable to login to the server"))
111+
}
112+
113+
return distroUpload(client, filename, distro)
114+
}

mgrctl/cmd/distro/upload_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package distro
6+
7+
import (
8+
"errors"
9+
"io"
10+
"net/http"
11+
"strings"
12+
"testing"
13+
14+
"github.com/uyuni-project/uyuni-tools/shared/api"
15+
"github.com/uyuni-project/uyuni-tools/shared/api/mocks"
16+
"github.com/uyuni-project/uyuni-tools/shared/testutils"
17+
)
18+
19+
const user = "testUser"
20+
const password = "testPwd"
21+
const server = "testServer"
22+
23+
var connectionDetails = &api.ConnectionDetails{User: user, Password: password, Server: server}
24+
25+
type distroUploadRequest struct {
26+
Filename string
27+
Distro []byte
28+
}
29+
30+
func readDistroUploadRequest(t *testing.T, req *http.Request) distroUploadRequest {
31+
t.Helper()
32+
33+
reader, err := req.MultipartReader()
34+
if err != nil {
35+
t.Fatalf("Failed to create multipart reader: %v", err)
36+
}
37+
38+
var data distroUploadRequest
39+
for {
40+
part, err := reader.NextPart()
41+
if errors.Is(err, io.EOF) {
42+
break
43+
}
44+
if err != nil {
45+
t.Fatalf("Failed to read multipart part: %v", err)
46+
}
47+
48+
partData, err := io.ReadAll(part)
49+
if err != nil {
50+
t.Fatalf("Failed to read multipart data: %v", err)
51+
}
52+
53+
switch part.FormName() {
54+
case "filename":
55+
data.Filename = string(partData)
56+
case "distro":
57+
data.Distro = partData
58+
testutils.AssertEquals(t, "The form file name is not properly passed", data.Filename, part.FileName())
59+
}
60+
}
61+
62+
return data
63+
}
64+
65+
func TestDistroUpload(t *testing.T) {
66+
tests := []struct {
67+
name string
68+
filename string
69+
distro []byte
70+
statusCode int
71+
body string
72+
expectedError string
73+
}{
74+
{
75+
name: "Test uploading a distro ISO",
76+
filename: "test.iso",
77+
distro: []byte("test distro ISO content"),
78+
statusCode: 200,
79+
body: `{"success":true,"result":1}`,
80+
expectedError: "",
81+
},
82+
{
83+
name: "Test server returns error status",
84+
filename: "test.iso",
85+
distro: []byte("test distro ISO content"),
86+
statusCode: 500,
87+
body: ``,
88+
expectedError: "error uploading distro: 500:",
89+
},
90+
{
91+
name: "Test server reports upload failure",
92+
filename: "test.iso",
93+
distro: []byte("test distro ISO content"),
94+
statusCode: 200,
95+
body: `{"success":false,"message":"invalid distro format"}`,
96+
expectedError: "failed to upload distro: invalid distro format",
97+
},
98+
}
99+
100+
for _, tt := range tests {
101+
t.Run(tt.name, func(t *testing.T) {
102+
client, err := api.Init(connectionDetails)
103+
if err != nil {
104+
t.FailNow()
105+
}
106+
107+
client.Client = &mocks.MockClient{
108+
DoFunc: func(req *http.Request) (*http.Response, error) {
109+
testutils.AssertEquals(t, "Wrong URL called", req.URL.Path, "/rhn/manager/api/admin/distro/uploadDistro")
110+
testutils.AssertTrue(t, "Wrong content type", strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data; boundary="))
111+
112+
data := readDistroUploadRequest(t, req)
113+
testutils.AssertEquals(t, "The filename is not properly passed", tt.filename, data.Filename)
114+
testutils.AssertEquals(t, "The distro is not properly passed", tt.distro, data.Distro)
115+
116+
return testutils.GetResponse(tt.statusCode, tt.body)
117+
},
118+
}
119+
120+
errorMessage := ""
121+
if err := distroUpload(client, tt.filename, tt.distro); err != nil {
122+
errorMessage = err.Error()
123+
}
124+
testutils.AssertStringContains(t, "Unexpected error message", errorMessage, tt.expectedError)
125+
})
126+
}
127+
}
128+
129+
func TestGetFilenameFromSource(t *testing.T) {
130+
tests := []struct {
131+
name string
132+
source string
133+
expected string
134+
}{
135+
{
136+
name: "Test local file",
137+
source: "/tmp/test.iso",
138+
expected: "test.iso",
139+
},
140+
{
141+
name: "Test URL",
142+
source: "https://example.com/images/test.iso",
143+
expected: "test.iso",
144+
},
145+
{
146+
name: "Test URL without filename",
147+
source: "https://example.com/",
148+
expected: "",
149+
},
150+
}
151+
152+
for _, tt := range tests {
153+
t.Run(tt.name, func(t *testing.T) {
154+
actual := getFilenameFromSource(tt.source)
155+
testutils.AssertEquals(t, "Unexpected filename", tt.expected, actual)
156+
})
157+
}
158+
}

0 commit comments

Comments
 (0)