From deb72f1d22835b7f1646dbf0e07f379fa4932937 Mon Sep 17 00:00:00 2001 From: Marvin Friedrich Date: Thu, 28 May 2026 17:09:59 +0200 Subject: [PATCH] Add mgrctl command for distro management --- mgradm/cmd/distro/distro.go | 14 +- mgrctl/cmd/cmd.go | 2 + mgrctl/cmd/distro/distro.go | 41 +++++ mgrctl/cmd/distro/upload.go | 114 +++++++++++++ mgrctl/cmd/distro/upload_test.go | 159 ++++++++++++++++++ shared/api/api.go | 49 +++++- ...anges.mfriedrich.add_mgrctl_distro_command | 1 + 7 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 mgrctl/cmd/distro/distro.go create mode 100644 mgrctl/cmd/distro/upload.go create mode 100644 mgrctl/cmd/distro/upload_test.go create mode 100644 uyuni-tools.changes.mfriedrich.add_mgrctl_distro_command diff --git a/mgradm/cmd/distro/distro.go b/mgradm/cmd/distro/distro.go index f26b2d9dbcd..31fc33c0197 100644 --- a/mgradm/cmd/distro/distro.go +++ b/mgradm/cmd/distro/distro.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 SUSE LLC +// SPDX-FileCopyrightText: 2026 SUSE LLC // // SPDX-License-Identifier: Apache-2.0 @@ -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{ @@ -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.")) diff --git a/mgrctl/cmd/cmd.go b/mgrctl/cmd/cmd.go index bb8bcb6e428..d966357d746 100644 --- a/mgrctl/cmd/cmd.go +++ b/mgrctl/cmd/cmd.go @@ -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" @@ -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()) diff --git a/mgrctl/cmd/distro/distro.go b/mgrctl/cmd/distro/distro.go new file mode 100644 index 00000000000..c9d053fc537 --- /dev/null +++ b/mgrctl/cmd/distro/distro.go @@ -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 +} diff --git a/mgrctl/cmd/distro/upload.go b/mgrctl/cmd/distro/upload.go new file mode 100644 index 00000000000..97e6b3b431e --- /dev/null +++ b/mgrctl/cmd/distro/upload.go @@ -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) +} diff --git a/mgrctl/cmd/distro/upload_test.go b/mgrctl/cmd/distro/upload_test.go new file mode 100644 index 00000000000..199d19f2f14 --- /dev/null +++ b/mgrctl/cmd/distro/upload_test.go @@ -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) + }) + } +} diff --git a/shared/api/api.go b/shared/api/api.go index 053a72af688..e3756d7fff0 100644 --- a/shared/api/api.go +++ b/shared/api/api.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SUSE LLC +// SPDX-FileCopyrightText: 2026 SUSE LLC // // SPDX-License-Identifier: Apache-2.0 @@ -53,7 +53,9 @@ func logTraceHeader(v *http.Header) { func (c *APIClient) sendRequest(req *http.Request) (*http.Response, error) { log.Debug().Msgf("Sending %s request %s", req.Method, req.URL) - req.Header.Set("Content-Type", "application/json; charset=utf-8") + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } req.Header.Set("Accept", "application/json; charset=utf-8") if c.AuthCookie != nil { req.AddCookie(c.AuthCookie) @@ -254,6 +256,25 @@ func (c *APIClient) Post(path string, data map[string]interface{}) (*http.Respon return res, nil } +// PostRaw issues a POST HTTP request to the API target using the provided body and content type. +func (c *APIClient) PostRaw(path string, body io.Reader, contentType string) (*http.Response, error) { + url := fmt.Sprintf("%s/%s", c.BaseURL, path) + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + res, err := c.sendRequest(req) + if err != nil { + return nil, err + } + + return res, nil +} + // Get issues GET HTTP request to the API target // // `path` specifies API endpoint together with query options @@ -302,6 +323,30 @@ func Post[T interface{}](client *APIClient, path string, data map[string]interfa return &response, nil } +// PostRaw issues a POST HTTP request to the API using the client and decodes the response. +func PostRaw[T interface{}](client *APIClient, path string, body io.Reader, contentType string) ( + *APIResponse[T], error) { + res, err := client.PostRaw(path, body, contentType) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + var response APIResponse[T] + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + log.Trace().Msgf("response: %s", string(bodyBytes)) + + if err = json.Unmarshal(bodyBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + // Get issues an HTTP GET request to the API using the client and decodes the response. // // `path` specifies API endpoint together with query options diff --git a/uyuni-tools.changes.mfriedrich.add_mgrctl_distro_command b/uyuni-tools.changes.mfriedrich.add_mgrctl_distro_command new file mode 100644 index 00000000000..ec8675346b0 --- /dev/null +++ b/uyuni-tools.changes.mfriedrich.add_mgrctl_distro_command @@ -0,0 +1 @@ +- Add mgrctl command for distro management