File shared.obscpio of Package uyuni-tools

07070100000000000081a400000000000000000000000168ed21dd00002103000000000000000000000000000000000000001200000000shared/api/api.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package api

import (
	"bytes"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"regexp"
	"time"

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

// AddAPIFlags is a helper to include api details for the provided command tree.
func AddAPIFlags(cmd *cobra.Command) {
	cmd.PersistentFlags().String("api-server", "", L("FQDN of the server to connect to"))
	cmd.PersistentFlags().String("api-user", "", L("API user username"))
	cmd.PersistentFlags().String("api-password", "", L("Password for the API user"))
	cmd.PersistentFlags().String("api-cacert", "", L("Path to a cert file of the CA"))
	cmd.PersistentFlags().Bool("api-insecure", false, L("If set, server certificate will not be checked for validity"))
}

var redactRegex = regexp.MustCompile(`(((pxt-session-cookie)|(JSESSIONID))=)[^ ";]+`)

func redactHeaders(header string) string {
	return redactRegex.ReplaceAllString(header, "${1}<REDACTED>")
}

func logTraceHeader(v *http.Header) {
	// Return early when not in trace loglevel
	if log.Logger.GetLevel() != zerolog.TraceLevel {
		return
	}
	b, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		return
	}
	log.Trace().Msg(redactHeaders(string(b)))
}

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")
	req.Header.Set("Accept", "application/json; charset=utf-8")
	if c.AuthCookie != nil {
		req.AddCookie(c.AuthCookie)
	}

	logTraceHeader(&req.Header)

	res, err := c.Client.Do(req)
	if err != nil {
		log.Trace().Err(err).Msgf("Request failed")
		return nil, err
	}

	logTraceHeader(&res.Header)

	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
		if res.StatusCode == 401 {
			return nil, errors.New(L("401: unauthorized"))
		}
		var errResponse map[string]string
		if res.Body != nil {
			body, err := io.ReadAll(res.Body)
			if err == nil {
				if err = json.Unmarshal(body, &errResponse); err == nil {
					errorMessage := fmt.Sprintf("%d: '%s'", res.StatusCode, errResponse["message"])
					return nil, errors.New(errorMessage)
				}
				errorMessage := fmt.Sprintf("%d: '%s'", res.StatusCode, string(body))
				return nil, errors.New(errorMessage)
			}
		}
		return nil, fmt.Errorf(L("unknown error: %d"), res.StatusCode)
	}
	log.Debug().Msgf("Received response with code %d", res.StatusCode)

	return res, nil
}

// Init returns a HTTPClient object for further API use.
//
// Provided connectionDetails must have Server specified with FQDN to the
// target host.
//
// Optionaly connectionDetails can have user name and password set and Init
// will try to login to the host.
// caCert can be set to use custom CA certificate to validate target host.
func Init(conn *ConnectionDetails) (*APIClient, error) {
	// Load stored credentials as it also loads up server URL and CApath
	getStoredConnectionDetails(conn)

	caCertPool, err := x509.SystemCertPool()
	if err != nil {
		log.Warn().Msg(err.Error())
	}
	if conn.CApath != "" {
		caCert, err := os.ReadFile(conn.CApath)
		if err != nil {
			log.Fatal().Msg(err.Error())
		}
		caCertPool.AppendCertsFromPEM(caCert)
	}

	if conn.Server == "" {
		return nil, errors.New(L("server URL is not provided"))
	}
	client := &APIClient{
		Details: conn,
		BaseURL: fmt.Sprintf("https://%s%s", conn.Server, rootPathApiv1),
		Client: &http.Client{
			Timeout: time.Minute,
			Transport: &http.Transport{
				TLSClientConfig: &tls.Config{
					RootCAs:            caCertPool,
					InsecureSkipVerify: conn.Insecure,
				},
			},
		},
	}
	if conn.Cookie != "" {
		client.AuthCookie = &http.Cookie{
			Name:  "pxt-session-cookie",
			Value: conn.Cookie,
		}
	}

	return client, err
}

// Login to the server using stored or provided credentials.
func (c *APIClient) Login() error {
	if c.Details.InSession {
		if err := c.sessionValidity(); err == nil {
			// Session is valid
			return nil
		}
		log.Warn().Msg(L("Cached session is expired."))
		if err := RemoveLoginCreds(); err != nil {
			log.Warn().Err(err).Msg(L("Failed to remove stored credentials!"))
		}
	}
	if err := getLoginCredentials(c.Details); err != nil {
		return err
	}
	return c.login()
}

func (c *APIClient) login() error {
	conn := c.Details
	url := fmt.Sprintf("%s/%s", c.BaseURL, "auth/login")
	data := map[string]string{
		"login":    conn.User,
		"password": conn.Password,
	}
	jsonData, err := json.Marshal(data)
	if err != nil {
		log.Error().Err(err).Msg(L("Unable to create login data"))
		return err
	}
	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
	if err != nil {
		return err
	}

	res, err := c.sendRequest(req)
	if err != nil {
		return err
	}

	var response map[string]interface{}
	if err = json.NewDecoder(res.Body).Decode(&response); err != nil {
		return err
	}
	if !response["success"].(bool) {
		return fmt.Errorf(response["message"].(string))
	}

	cookies := res.Cookies()
	for _, cookie := range cookies {
		if cookie.Name == "pxt-session-cookie" && cookie.MaxAge > 0 {
			c.AuthCookie = cookie
			break
		}
	}

	if c.AuthCookie == nil {
		return errors.New(L("auth cookie not found in login response"))
	}

	return nil
}

func (c *APIClient) sessionValidity() error {
	// This is how spacecmd does it
	_, err := c.Get("user/listAssignableRoles")
	return err
}

// Logout from the server and remove localy stored session key.
func (c *APIClient) Logout() error {
	if _, err := c.Post("auth/logout", nil); err != nil {
		return utils.Errorf(err, L("failed to logout from the server"))
	}
	return RemoveLoginCreds()
}

// ValidateCreds checks if the login credentials are valid.
func (c *APIClient) ValidateCreds() bool {
	err := c.Login()
	return err == nil
}

// Post issues a POST HTTP request to the API target
//
// `path` specifies an API endpoint
// `data` contains a map of values to add to the POST query. `data` are serialized to the JSON
//
// returns a raw HTTP Response.
func (c *APIClient) Post(path string, data map[string]interface{}) (*http.Response, error) {
	url := fmt.Sprintf("%s/%s", c.BaseURL, path)
	jsonData, err := json.Marshal(data)
	if err != nil {
		log.Error().Err(err).Msg(L("Unable to convert data to JSON"))
		return nil, err
	}

	log.Trace().Msgf("payload: %s", string(jsonData))

	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
	if err != nil {
		return nil, err
	}

	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
//
// returns a raw HTTP Response.
func (c *APIClient) Get(path string) (*http.Response, error) {
	url := fmt.Sprintf("%s/%s", c.BaseURL, path)
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}

	res, err := c.sendRequest(req)
	if err != nil {
		return nil, err
	}

	return res, nil
}

// Post issues a POST HTTP request to the API target using the client and decodes the response.
//
// `path` specifies an API endpoint
// `data` contains a map of values to add to the POST query. `data` are serialized to the JSON
//
// returns a deserialized JSON data to the map.
func Post[T interface{}](client *APIClient, path string, data map[string]interface{}) (*APIResponse[T], error) {
	res, err := client.Post(path, data)
	if err != nil {
		return nil, err
	}

	defer res.Body.Close()

	var response APIResponse[T]
	body, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}
	log.Trace().Msgf("response: %s", string(body))

	if err = json.Unmarshal(body, &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
//
// returns an ApiResponse with the decoded result.
func Get[T interface{}](client *APIClient, path string) (*APIResponse[T], error) {
	res, err := client.Get(path)
	if err != nil {
		return nil, err
	}

	defer res.Body.Close()

	var response APIResponse[T]
	if err = json.NewDecoder(res.Body).Decode(&response); err != nil {
		return nil, err
	}

	return &response, nil
}
07070100000001000081a400000000000000000000000168ed21dd000002c5000000000000000000000000000000000000001700000000shared/api/api_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package api

import "testing"

func TestRedactHeaders(t *testing.T) {
	data := [][]string{
		{
			`"JSESSIONID=supersecret; Path=/; Secure; HttpOnly; HttpOnly;HttpOnly;Secure"`,
			`"JSESSIONID=<REDACTED>; Path=/; Secure; HttpOnly; HttpOnly;HttpOnly;Secure"`,
		},
		{
			`"pxt-session-cookie=supersecret; Max-Age=0;"`,
			`"pxt-session-cookie=<REDACTED>; Max-Age=0;"`,
		},
	}

	for i, testCase := range data {
		input := testCase[0]
		expected := testCase[1]

		actual := redactHeaders(input)

		if actual != expected {
			t.Errorf("Testcase %d: Expected %s got %s when redacting  %s", i, expected, actual, input)
		}
	}
}
07070100000002000081a400000000000000000000000168ed21dd00000d65000000000000000000000000000000000000001a00000000shared/api/credentials.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package api

import (
	"encoding/json"
	"errors"
	"os"
	"path"

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

// StoreLoginCreds stores the API credentials for future API use.
func StoreLoginCreds(client *APIClient) error {
	if client.AuthCookie.Value == "" {
		return errors.New(L("not logged in, session cookie is missing"))
	}
	// Future: Add support for more servers if needed in the future
	auth := []authStorage{
		{
			Session: client.AuthCookie.Value,
			Server:  client.Details.Server,
			CApath:  client.Details.CApath,
		},
	}

	authData, err := json.Marshal(auth)
	if err != nil {
		return utils.Errorf(err, L("unable to create credentials json"))
	}

	err = os.WriteFile(getAPICredsFile(), authData, 0600)
	if err != nil {
		return utils.Errorf(err, L("unable to write credentials store %s"), getAPICredsFile())
	}
	return nil
}

// RemoveLoginCreds removes the stored API credentials.
func RemoveLoginCreds() error {
	// Future: Multi-server support will need some parsing here
	return os.Remove(getAPICredsFile())
}

// Asks for not provided ConnectionDetails or errors out.
func getLoginCredentials(conn *ConnectionDetails) error {
	// If user name provided, but no password and not loaded
	utils.AskIfMissing(&conn.Server, L("API server URL"), 0, 0, nil)
	utils.AskIfMissing(&conn.User, L("API server user"), 0, 0, nil)
	utils.AskPasswordIfMissingOnce(&conn.Password, L("API server password"), 0, 0)

	if conn.User == "" || conn.Password == "" {
		return errors.New(L("No credentials provided"))
	}

	return nil
}

// Fills ConnectionDetails with cached credentials if possible.
func getStoredConnectionDetails(conn *ConnectionDetails) {
	if IsAlreadyLoggedIn() && conn.User == "" {
		if err := loadLoginCreds(conn); err != nil {
			log.Warn().Err(err).Msg(L("Cannot load stored credentials"))
			if err := RemoveLoginCreds(); err != nil {
				log.Warn().Err(err).Msg(L("Failed to remove stored credentials!"))
			}
		} else {
			// We have connection cookie
			conn.InSession = true
		}
	}
}

// Read stored session and server details.
func loadLoginCreds(connection *ConnectionDetails) error {
	data, err := os.ReadFile(getAPICredsFile())
	if err != nil {
		return utils.Errorf(err, L("unable to read credentials file %s"), getAPICredsFile())
	}
	authStore := []authStorage{}
	err = json.Unmarshal(data, &authStore)
	if err != nil {
		return utils.Errorf(err, L("unable to decode credentials file"))
	}

	if len(authStore) == 0 {
		return errors.New(L("no credentials loaded"))
	}

	// Currently we support storing data only to one server
	// Future: add support for more servers if wanted

	authData := authStore[0]

	if connection.Server != "" && connection.Server != authData.Server {
		return errors.New(L("specified api server does not match with stored credentials"))
	}
	connection.Server = authData.Server
	if authData.CApath != "" {
		connection.CApath = authData.CApath
	}

	connection.Cookie = authData.Session

	return nil
}

// IsAlreadyLoggedIn returns true if credentials file already exists.
//
// Does not check for credentials validity.
func IsAlreadyLoggedIn() bool {
	return utils.FileExists(getAPICredsFile())
}

func getAPICredsFile() string {
	return path.Join(utils.GetUserConfigDir(), apiCredentialsStore)
}
07070100000003000081a400000000000000000000000168ed21dd00001646000000000000000000000000000000000000001f00000000shared/api/credentials_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package api

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"testing"

	"github.com/rs/zerolog/log"
	"github.com/uyuni-project/uyuni-tools/shared/api/mocks"
)

const user = "mytestuser"
const password = "mytestpassword"
const server = "mytestserver"
const cookie = "mytestpxtcookie"

// Test happy path for credentials store.
func TestCredentialsStore(t *testing.T) {
	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
	connection := ConnectionDetails{
		User:     user,
		Password: password,
		Server:   server,
	}
	client, err := Init(&connection)
	if err != nil {
		t.FailNow()
	}

	client.Client = &mocks.MockClient{
		DoFunc: loginTestDo,
	}

	if err := client.Login(); err != nil {
		t.FailNow()
	}
	err = StoreLoginCreds(client)
	if err != nil {
		t.Fail()
	}

	connection2 := ConnectionDetails{}
	if err := loadLoginCreds(&connection2); err != nil {
		t.Fail()
	}
	if connection2.Server != server {
		log.Error().Msg("server does not match")
		t.Fail()
	}
	if connection2.Cookie != cookie {
		log.Error().Msg("cookie does not match")
		t.Fail()
	}
}

// Test credentials are cleaned-up after logout.
func TestCredentialsCleanup(t *testing.T) {
	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
	err := storeTestCredentials()
	if err != nil {
		log.Error().Err(err).Msg("failed to store creds")
		t.Fail()
	}

	if err != nil {
		t.Fail()
	}
	if err := RemoveLoginCreds(); err != nil {
		t.Fail()
	}

	connection2 := ConnectionDetails{
		Server: server,
	}
	err = loadLoginCreds(&connection2)
	if err == nil {
		t.Fail()
	}
	if connection2.User != "" {
		t.Fail()
	}
}

// Write malformed credentials file to check autocleanup of wrong credentials.
func TestAutocleanup(t *testing.T) {
	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
	err := os.WriteFile(getAPICredsFile(), []byte(""), 0600)
	if err != nil {
		t.Fail()
	}
	connection := ConnectionDetails{
		Server: server,
	}
	getStoredConnectionDetails(&connection)
	if connection.InSession {
		t.Fail()
	}
	_, err = os.Stat(getAPICredsFile())
	if err == nil {
		t.Fail()
	}
}

// Test login using cached credentials.
func TestCredentialValidation(t *testing.T) {
	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
	err := storeTestCredentials()
	if err != nil {
		log.Error().Err(err).Msg("failed to store creds")
		t.Fail()
	}

	connection := ConnectionDetails{}
	client, err := Init(&connection)
	if err != nil {
		log.Error().Err(err).Msg("failed to init connection")
		t.Fail()
	}

	if !client.Details.InSession {
		log.Error().Msg("Credentials are not marked as cached")
		t.Fail()
	}

	client.Client = &mocks.MockClient{
		DoFunc: userListRolesDo,
	}

	if err := client.Login(); err != nil {
		log.Trace().Err(err).Msg("failed")
		t.Fail()
	}
}

func TestWrongCredentials(t *testing.T) {
	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
	err := storeWrongTestCredentials()
	if err != nil {
		log.Error().Err(err).Msg("failed to store creds")
		t.Fail()
	}

	connection := ConnectionDetails{}
	client, err := Init(&connection)
	if err != nil {
		log.Error().Err(err).Msg("failed to init connection")
		t.Fail()
	}

	if !client.Details.InSession {
		log.Error().Msg("Credentials are not marked as cached")
		t.Fail()
	}

	client.Client = &mocks.MockClient{
		DoFunc: userListRolesDo,
	}

	err = client.Login()
	if err == nil {
		log.Error().Err(err).Msg("login was successful even when should not have been")
		t.Fail()
	}

	// Test that wrong login will remove auth file
	_, err = os.Stat(getAPICredsFile())
	if err == nil {
		t.Fail()
	}
}

// helper storing valid credentials.
func storeTestCredentials() error {
	client := APIClient{
		Details: &ConnectionDetails{
			User:   user,
			Server: server,
		},
		AuthCookie: &http.Cookie{
			Name:  "pxt-session-cookie",
			Value: cookie,
		},
	}
	return StoreLoginCreds(&client)
}

// helper storing invalid credentials.
func storeWrongTestCredentials() error {
	client := APIClient{
		Details: &ConnectionDetails{
			User:   user,
			Server: server,
		},
		AuthCookie: &http.Cookie{
			Name:  "pxt-session-cookie",
			Value: "wrongcookie",
		},
	}
	return StoreLoginCreds(&client)
}

// helper login request response.
func loginTestDo(req *http.Request) (*http.Response, error) {
	if req.URL.Path != "/rhn/manager/api/auth/login" {
		return &http.Response{
			StatusCode: 404,
		}, nil
	}
	data := map[string]string{}
	if err := json.NewDecoder(req.Body).Decode(&data); err != nil {
		return nil, err
	}
	if data["login"] != user || data["password"] != password {
		return &http.Response{
			StatusCode: 403,
		}, nil
	}
	json := `{"success": true}`
	r := io.NopCloser(bytes.NewReader([]byte(json)))
	headers := http.Header{}
	headers.Add("Content-Type", "application/json")
	headers.Add(
		"Set-Cookie",
		fmt.Sprintf("pxt-session-cookie=%s; Max-Age=3600; Path=/; Secure; HttpOnly;HttpOnly;Secure", cookie),
	)
	return &http.Response{
		StatusCode: 200,
		Header:     headers,
		Body:       r,
	}, nil
}

func userListRolesDo(req *http.Request) (*http.Response, error) {
	if req.URL.Path != "/rhn/manager/api/user/listAssignableRoles" {
		return &http.Response{
			StatusCode: 404,
		}, nil
	}
	if pxt, err := req.Cookie("pxt-session-cookie"); err != nil || pxt.Value != cookie {
		return &http.Response{
			StatusCode: 403,
		}, nil
	}
	json := `{"success": true}`
	r := io.NopCloser(bytes.NewReader([]byte(json)))
	headers := http.Header{}
	headers.Add("Content-Type", "application/json")
	headers.Add(
		"Set-Cookie",
		fmt.Sprintf("pxt-session-cookie=%s; Max-Age=3600; Path=/; Secure; HttpOnly;HttpOnly;Secure", cookie),
	)
	return &http.Response{
		StatusCode: 200,
		Header:     headers,
		Body:       r,
	}, nil
}
07070100000004000081a400000000000000000000000168ed21dd0000017e000000000000000000000000000000000000001a00000000shared/api/mocks/mocks.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package mocks

import "net/http"

// MockClient is a mocked api.HTTPClient.
type MockClient struct {
	DoFunc func(req *http.Request) (*http.Response, error)
}

// Do fulfills the api.HTTPClient interface.
func (m *MockClient) Do(req *http.Request) (*http.Response, error) {
	return m.DoFunc(req)
}
07070100000005000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001100000000shared/api/mocks07070100000006000081a400000000000000000000000168ed21dd000004f7000000000000000000000000000000000000001e00000000shared/api/org/createFirst.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package org

import (
	"errors"

	"github.com/uyuni-project/uyuni-tools/shared/api"
	"github.com/uyuni-project/uyuni-tools/shared/api/types"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
)

// CreateFirst creates the first organization and user after initial setup without authentication.
//
// orgName is the name of the first organization to create and admin the user to create.
func CreateFirst(cnxDetails *api.ConnectionDetails, orgName string, admin *types.User) (*types.Organization, error) {
	client, err := api.Init(cnxDetails)
	if err != nil {
		return nil, utils.Errorf(err, L("unable to prepare API client"))
	}

	data := map[string]interface{}{
		"orgName":       orgName,
		"adminLogin":    admin.Login,
		"adminPassword": admin.Password,
		"firstName":     admin.FirstName,
		"lastName":      admin.LastName,
		"email":         admin.Email,
	}

	res, err := api.Post[types.Organization](client, "org/createFirst", data)
	if err != nil {
		return nil, utils.Errorf(err, L("failed to create first user and organization"))
	}

	if !res.Success {
		return nil, errors.New(res.Message)
	}

	return &res.Result, nil
}
07070100000007000081a400000000000000000000000168ed21dd000003d7000000000000000000000000000000000000001d00000000shared/api/org/getDetails.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package org

import (
	"errors"
	"fmt"

	"github.com/uyuni-project/uyuni-tools/shared/api"
	"github.com/uyuni-project/uyuni-tools/shared/api/types"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
)

// GetOrganizationDetails gets details of organization based on organization name.
func GetOrganizationDetails(cnxDetails *api.ConnectionDetails, orgName string) (*types.Organization, error) {
	client, err := api.Init(cnxDetails)
	if err == nil {
		err = client.Login()
	}
	if err != nil {
		return nil, utils.Errorf(err, L("failed to connect to the server"))
	}
	res, err := api.Get[types.Organization](client, fmt.Sprintf("org/getDetails?name=%s", orgName))
	if err != nil {
		return nil, utils.Errorf(err, L("failed to get organization details"))
	}
	if !res.Success {
		return nil, errors.New(res.Message)
	}

	return &res.Result, nil
}
07070100000008000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000000f00000000shared/api/org07070100000009000081a400000000000000000000000168ed21dd00000568000000000000000000000000000000000000002400000000shared/api/proxy/containerConfig.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package proxy

import (
	"errors"

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

const containerConfigEndpoint = "proxy/containerConfig"

// ContainerConfig computes and downloads the configuration file for proxy containers with generated certificates.
func ContainerConfig(client *api.APIClient, request ProxyConfigRequest) (*[]int8, error) {
	return executeRequest(client, ProxyConfigRequestToMap(request))
}

// ContainerConfigGenerate computes and downloads the configuration file for proxy containers.
func ContainerConfigGenerate(client *api.APIClient, request ProxyConfigGenerateRequest) (*[]int8, error) {
	return executeRequest(client, ProxyConfigGenerateRequestToMap(request))
}

// common method to execute the request.
func executeRequest(client *api.APIClient, data map[string]interface{}) (*[]int8, error) {
	log.Trace().Msgf("Creating proxy configuration file with data: %v...", data)
	res, err := api.Post[[]int8](client, containerConfigEndpoint, data)
	if err != nil {
		return nil, utils.Errorf(err, L("failed to create proxy configuration file"))
	}
	if !res.Success {
		return nil, errors.New(res.Message)
	}
	return &res.Result, nil
}
0707010000000a000081a400000000000000000000000168ed21dd00002c18000000000000000000000000000000000000002900000000shared/api/proxy/containerConfig_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package proxy_test

import (
	"encoding/json"
	"fmt"
	"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/api/proxy"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

// global access to the testing object.
var globalT *testing.T

// ProxyConfigGenerateRequestBodyData is the data structure for the body of the ContainerConfigGenerate API request.
type ProxyConfigGenerateRequestBodyData struct {
	ProxyName  string
	ProxyPort  int
	Server     string
	MaxCache   int
	Email      string
	CaCrt      string
	CaKey      string
	CaPassword string
	Cnames     []string
	Country    string
	State      string
	City       string
	Org        string
	OrgUnit    string
	SSLEmail   string
}

// ProxyConfigRequestBodyData is the data structure for the request body of the ContainerConfig API request.
type ProxyConfigRequestBodyData struct {
	ProxyName       string
	ProxyPort       int
	Server          string
	MaxCache        int
	Email           string
	IntermediateCAs []string
	ProxyCrt        string
	ProxyKey        string
	RootCA          string
}

// common connection details (for generating the client).
const user = "testUser"
const password = "testPwd"
const server = "testServer"

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

// common expected values for both ContainerConfig and ContainerConfigGenerate calls.
const expectedProxyName = "testProxy"
const expectedProxyPort = 8080
const expectedServer = "testServer"
const expectedMaxCache = 100
const expectedEmail = "test@email.com"

// expected values for ContainerConfig.
const expectedCaCrt = "caCrt contents"
const expectedCaKey = "caKey contents"
const expectedCaPassword = "caPwd"
const expectedCountry = "testCountry"
const expectedState = "exampleState"
const expectedCity = "exampleCity"
const expectedOrg = "exampleOrg"
const expectedOrgUnit = "exampleOrgUnit"
const expectedSSLEmail = "sslEmail@example.com"

var expectedCnames = []string{"altNameA.example.com", "altNameB.example.com"}

// expected values for ContainerConfigGenerate.
const expectedRootCA = "rootCA contents"
const expectedProxyCrt = "proxyCrt contents"
const expectedProxyKey = "proxyKey contents"

var expectedIntermediateCAs = []string{"intermediateCA1", "intermediateCA2"}

var proxyConfigRequest = proxy.ProxyConfigRequest{
	ProxyName:       expectedProxyName,
	ProxyPort:       expectedProxyPort,
	Server:          expectedServer,
	MaxCache:        expectedMaxCache,
	Email:           expectedEmail,
	RootCA:          expectedRootCA,
	ProxyCrt:        expectedProxyCrt,
	ProxyKey:        expectedProxyKey,
	IntermediateCAs: expectedIntermediateCAs,
}

var proxyConfigGenerateRequest = proxy.ProxyConfigGenerateRequest{
	ProxyName:  expectedProxyName,
	ProxyPort:  expectedProxyPort,
	Server:     expectedServer,
	MaxCache:   expectedMaxCache,
	Email:      expectedEmail,
	CaCrt:      expectedCaCrt,
	CaKey:      expectedCaKey,
	CaPassword: expectedCaPassword,
	Cnames:     expectedCnames,
	Country:    expectedCountry,
	State:      expectedState,
	City:       expectedCity,
	Org:        expectedOrg,
	OrgUnit:    expectedOrgUnit,
	SSLEmail:   expectedSSLEmail,
}

// Tests ContainerConfig when the post request fails.
func TestFailContainerConfigWhenPostRequestFails(t *testing.T) {
	//
	expectedErrorMessage := "failed to create proxy configuration file"

	// Mock client
	client, err := api.Init(connectionDetails)
	if err != nil {
		t.FailNow()
	}
	client.Client = &mocks.MockClient{
		DoFunc: func(_ *http.Request) (*http.Response, error) {
			return testutils.GetResponse(404, `{}`)
		},
	}

	// Execute
	result, err := proxy.ContainerConfig(client, proxyConfigRequest)

	// Assertions
	testutils.AssertTrue(t, "Unexpected successful ContainerConfigGenerate call", err != nil)
	testutils.AssertTrue(t, "ContainerConfigGenerate error message", strings.Contains(err.Error(), expectedErrorMessage))
	testutils.AssertTrue(t, "Result data should be nil", result == nil)
}

// Tests ContainerConfig when the post request is successful but the response is unsuccessful.
func TestFailContainerConfigWhenPostIsUnsuccessful(t *testing.T) {
	//
	expectedErrorMessage := "some error message"

	// Mock client
	client, err := api.Init(connectionDetails)
	if err != nil {
		t.FailNow()
	}
	client.Client = &mocks.MockClient{
		DoFunc: func(_ *http.Request) (*http.Response, error) {
			return testutils.GetResponse(200, `{"success": false, "message": "some error message"}`)
		},
	}

	// Execute
	result, err := proxy.ContainerConfig(client, proxyConfigRequest)

	// Assertions
	testutils.AssertTrue(t, "Unexpected successful ContainerConfigGenerate call", err != nil)
	testutils.AssertTrue(t, "ContainerConfigGenerate error message", strings.HasSuffix(err.Error(), expectedErrorMessage))
	testutils.AssertTrue(t, "Result data should be nil", result == nil)
}

// Tests ContainerConfig when all parameters are provided.
func TestSuccessfulContainerConfigWhenAllParametersAreProvided(t *testing.T) {
	//
	expectedResponseData := []int8{1, 2, 3, 4, 5}

	// Mock client
	client, err := api.Init(connectionDetails)
	if err != nil {
		t.FailNow()
	}
	client.Client = &mocks.MockClient{
		DoFunc: func(req *http.Request) (*http.Response, error) {
			// asserts request body contents
			var data ProxyConfigRequestBodyData
			if err := json.NewDecoder(req.Body).Decode(&data); err != nil {
				return nil, err
			}

			testutils.AssertEquals(globalT, "ProxyName doesn't match", expectedProxyName, data.ProxyName)
			testutils.AssertEquals(globalT, "ProxyPort doesn't match", expectedProxyPort, data.ProxyPort)
			testutils.AssertEquals(globalT, "Server doesn't match", expectedServer, data.Server)
			testutils.AssertEquals(globalT, "MaxCache doesn't match", expectedMaxCache, data.MaxCache)
			testutils.AssertEquals(globalT, "Email doesn't match", expectedEmail, data.Email)
			testutils.AssertEquals(globalT, "RootCA doesn't match", expectedRootCA, data.RootCA)
			testutils.AssertEquals(globalT, "ProxyCrt doesn't match", expectedProxyCrt, data.ProxyCrt)
			testutils.AssertEquals(globalT, "ProxyKey doesn't match", expectedProxyKey, data.ProxyKey)
			testutils.AssertEquals(globalT, "intermediateCas don't match",
				fmt.Sprintf("%v", expectedIntermediateCAs),
				fmt.Sprintf("%v", data.IntermediateCAs))

			// mock response
			return testutils.GetResponse(200, `{"success": true, "result": [1, 2, 3, 4, 5]}`)
		},
	}

	// Execute
	result, err := proxy.ContainerConfig(client, proxyConfigRequest)

	// Assertions
	testutils.AssertTrue(t, "Unexpected error executing ContainerConfigGenerate", err == nil)
	testutils.AssertTrue(t, "Result should not be empty", result != nil)
	testutils.AssertEquals(
		t, "Result configuration binary doesn't match",
		fmt.Sprintf("%v", expectedResponseData), fmt.Sprintf("%v", *result),
	)
}

// Tests ContainerConfigGenerate when the post request fails.
func TestFailContainerConfigGenerateWhenPostRequestFails(t *testing.T) {
	//
	expectedErrorMessage := "failed to create proxy configuration file"

	// Mock client
	client, err := api.Init(connectionDetails)
	if err != nil {
		t.FailNow()
	}
	client.Client = &mocks.MockClient{
		DoFunc: func(_ *http.Request) (*http.Response, error) {
			return testutils.GetResponse(404, `{}`)
		},
	}

	// Execute
	result, err := proxy.ContainerConfigGenerate(client, proxyConfigGenerateRequest)

	// Assertions
	testutils.AssertTrue(t, "Unexpected successful ContainerConfigGenerate call", err != nil)
	testutils.AssertTrue(t, "ContainerConfigGenerate error message", strings.Contains(err.Error(), expectedErrorMessage))
	testutils.AssertTrue(t, "Result data should be nil", result == nil)
}

// Tests ContainerConfigGenerate when the post request is successful but the response is unsuccessful.
func TestFailContainerConfigGenerateWhenPostIsUnsuccessful(t *testing.T) {
	//
	expectedErrorMessage := "some error message"

	// Mock client
	client, err := api.Init(connectionDetails)
	if err != nil {
		t.FailNow()
	}
	client.Client = &mocks.MockClient{
		DoFunc: func(_ *http.Request) (*http.Response, error) {
			return testutils.GetResponse(200, `{"success": false, "message": "some error message"}`)
		},
	}

	// Execute
	result, err := proxy.ContainerConfigGenerate(client, proxyConfigGenerateRequest)

	// Assertions
	testutils.AssertTrue(t, "Unexpected successful ContainerConfigGenerate call", err != nil)
	testutils.AssertTrue(t, "ContainerConfigGenerate error message", strings.HasSuffix(err.Error(), expectedErrorMessage))
	testutils.AssertTrue(t, "Result data should be nil", result == nil)
}

// Tests ContainerConfig when all parameters are provided.
func TestSuccessfulContainerConfigGenerateWhenAllParametersAreProvided(t *testing.T) {
	//
	expectedResponseData := []int8{1, 2, 3, 4, 5}

	// Mock client
	client, err := api.Init(connectionDetails)
	if err != nil {
		t.FailNow()
	}
	client.Client = &mocks.MockClient{
		DoFunc: func(req *http.Request) (*http.Response, error) {
			// asserts request body contents
			var data ProxyConfigGenerateRequestBodyData
			if err := json.NewDecoder(req.Body).Decode(&data); err != nil {
				return nil, err
			}

			testutils.AssertEquals(globalT, "ProxyName doesn't match", expectedProxyName, data.ProxyName)
			testutils.AssertEquals(globalT, "ProxyPort doesn't match", expectedProxyPort, data.ProxyPort)
			testutils.AssertEquals(globalT, "Server doesn't match", expectedServer, data.Server)
			testutils.AssertEquals(globalT, "MaxCache doesn't match", expectedMaxCache, data.MaxCache)
			testutils.AssertEquals(globalT, "Email doesn't match", expectedEmail, data.Email)

			testutils.AssertEquals(globalT, "CaCertificate doesn't match", expectedCaCrt, data.CaCrt)
			testutils.AssertEquals(globalT, "CaKey doesn't match", expectedCaKey, data.CaKey)
			testutils.AssertEquals(globalT, "CaPassword doesn't match", expectedCaPassword, data.CaPassword)
			testutils.AssertEquals(
				globalT, "Cnames don't match", fmt.Sprintf("%v", expectedCnames), fmt.Sprintf("%v", data.Cnames),
			)
			testutils.AssertEquals(globalT, "Country doesn't match", expectedCountry, data.Country)
			testutils.AssertEquals(globalT, "State doesn't match", expectedState, data.State)
			testutils.AssertEquals(globalT, "City doesn't match", expectedCity, data.City)
			testutils.AssertEquals(globalT, "Org doesn't match", expectedOrg, data.Org)
			testutils.AssertEquals(globalT, "OrgUnit doesn't match", expectedOrgUnit, data.OrgUnit)
			testutils.AssertEquals(globalT, "SSLEmail doesn't match", expectedSSLEmail, data.SSLEmail)

			// mock response
			return testutils.GetResponse(200, `{"success": true, "result": [1, 2, 3, 4, 5]}`)
		},
	}

	// Execute
	result, err := proxy.ContainerConfigGenerate(client, proxyConfigGenerateRequest)

	// Assertions
	testutils.AssertTrue(t, "Unexpected error executing ContainerConfigGenerate", err == nil)
	testutils.AssertTrue(t, "Result should not be empty", result != nil)
	testutils.AssertEquals(
		t, "Result configuration binary doesn't match",
		fmt.Sprintf("%v", expectedResponseData), fmt.Sprintf("%v", *result),
	)
}
0707010000000b000081a400000000000000000000000168ed21dd0000057a000000000000000000000000000000000000001c00000000shared/api/proxy/mapping.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package proxy

// Mappings for the models/Schemas in scope of the proxy API

// ProxyConfigRequestToMap maps the ProxyConfigRequest to a map.
func ProxyConfigRequestToMap(request ProxyConfigRequest) map[string]interface{} {
	return map[string]interface{}{
		"proxyName":       request.ProxyName,
		"proxyPort":       request.ProxyPort,
		"server":          request.Server,
		"maxCache":        request.MaxCache,
		"email":           request.Email,
		"rootCA":          request.RootCA,
		"proxyCrt":        request.ProxyCrt,
		"proxyKey":        request.ProxyKey,
		"intermediateCAs": request.IntermediateCAs,
	}
}

// ProxyConfigGenerateRequestToMap maps the ProxyConfigGenerateRequest to a map.
func ProxyConfigGenerateRequestToMap(request ProxyConfigGenerateRequest) map[string]interface{} {
	return map[string]interface{}{
		"proxyName":  request.ProxyName,
		"proxyPort":  request.ProxyPort,
		"server":     request.Server,
		"maxCache":   request.MaxCache,
		"email":      request.Email,
		"caCrt":      request.CaCrt,
		"caKey":      request.CaKey,
		"caPassword": request.CaPassword,
		"cnames":     request.Cnames,
		"country":    request.Country,
		"state":      request.State,
		"city":       request.City,
		"org":        request.Org,
		"orgUnit":    request.OrgUnit,
		"sslEmail":   request.SSLEmail,
	}
}
0707010000000c000081a400000000000000000000000168ed21dd000003cb000000000000000000000000000000000000001a00000000shared/api/proxy/model.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package proxy

// Models/Schemas for the proxy API.

// ProxyConfigRequest is the request schema for the proxy/containerConfig endpoint when
// user has proxy certificates.
type ProxyConfigRequest struct {
	ProxyName       string
	ProxyPort       int
	Server          string
	MaxCache        int
	Email           string
	RootCA          string
	ProxyCrt        string
	ProxyKey        string
	IntermediateCAs []string
}

// ProxyConfigGenerateRequest is the request schema for the proxy/containerConfig endpoint when
// user wants to generate proxy certificates.
type ProxyConfigGenerateRequest struct {
	ProxyName  string
	ProxyPort  int
	Server     string
	MaxCache   int
	Email      string
	CaCrt      string
	CaKey      string
	CaPassword string
	Cnames     []string
	Country    string
	State      string
	City       string
	Org        string
	OrgUnit    string
	SSLEmail   string
}
0707010000000d000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001100000000shared/api/proxy0707010000000e000081a400000000000000000000000168ed21dd0000029e000000000000000000000000000000000000002100000000shared/api/types/organization.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

// Organization describe an organization in the API.
type Organization struct {
	ID                    int
	Name                  string
	ActiveUsers           int `mapstructure:"active_users"`
	Systems               int
	Trusts                int
	SystemGroups          int  `mapstructure:"system_groups"`
	ActivationKeys        int  `mapstructure:"activation_keys"`
	KickstartProfiles     int  `mapstructure:"kickstart_profiles"`
	ConfigurationChannels int  `mapstructure:"configuration_channels"`
	StagingContentEnabled bool `mapstructure:"staging_content_enabled"`
}
0707010000000f000081a400000000000000000000000168ed21dd000000fe000000000000000000000000000000000000001900000000shared/api/types/user.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

// User describes an Uyuni user in the API.
type User struct {
	Login     string
	Password  string
	FirstName string
	LastName  string
	Email     string
}
07070100000010000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001100000000shared/api/types07070100000011000081a400000000000000000000000168ed21dd000005d5000000000000000000000000000000000000001400000000shared/api/types.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package api

import "net/http"

const rootPathApiv1 = "/rhn/manager/api"
const apiCredentialsStore = ".uyuni-api.json"

// APIClient is the API entrypoint structure.
type APIClient struct {

	// URL to the API endpoint of the target host
	BaseURL string

	// net/http client
	Client HTTPClient

	// Authentication cookie storage
	AuthCookie *http.Cookie

	// Connection details
	Details *ConnectionDetails
}

// HTTPClient is a minimal HTTPClient interface primarily for unit testing.
type HTTPClient interface {
	Do(req *http.Request) (*http.Response, error)
}

// ConnectionDetails holds the details for initial API connection.
type ConnectionDetails struct {

	// FQDN of the target host.
	Server string

	// User to login under.
	User string

	// Password for the user.
	Password string

	// Path to CA certificate file used for target host validation.
	// Provided certificate is used together with system certificates.
	CApath string `mapstructure:"cacert"`

	// Disable certificate validation, unsecure and not recommended.
	Insecure bool

	// Indicates if details we loaded from cache
	InSession bool

	// PXE cookie
	Cookie string
}

// APIResponse describes the HTTP response where T is the type of the result.
type APIResponse[T interface{}] struct {
	Result  T
	Success bool
	Message string
}

// Authentication storage.
type authStorage struct {
	Session string
	Server  string
	CApath  string
}
07070100000012000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000000b00000000shared/api07070100000013000081a400000000000000000000000168ed21dd000005ce000000000000000000000000000000000000002000000000shared/completion/completion.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package completion

import (
	"os"

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

// NewCommand  command for generates completion script.
func NewCommand(_ *types.GlobalFlags) *cobra.Command {
	shellCompletionCmd := &cobra.Command{
		Use:                   "completion [bash|zsh|fish|powershell]",
		Short:                 L("Generate shell completion script"),
		Long:                  L("Generate shell completion script"),
		DisableFlagsInUseLine: true,
		ValidArgs:             []string{"bash", "zsh", "fish"},
		Args:                  cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
		Hidden:                true,
		RunE: func(cmd *cobra.Command, args []string) error {
			switch args[0] {
			case "bash":
				if err := cmd.Root().GenBashCompletion(os.Stdout); err != nil {
					return utils.Errorf(err, L("cannot generate %s completion"), args[0])
				}
			case "zsh":
				if err := cmd.Root().GenZshCompletion(os.Stdout); err != nil {
					return utils.Errorf(err, L("cannot generate %s completion"), args[0])
				}
			case "fish":
				if err := cmd.Root().GenFishCompletion(os.Stdout, true); err != nil {
					return utils.Errorf(err, L("cannot generate %s completion"), args[0])
				}
			}
			return nil
		},
	}
	return shellCompletionCmd
}
07070100000014000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001200000000shared/completion07070100000015000081a400000000000000000000000168ed21dd00004010000000000000000000000000000000000000001500000000shared/connection.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package shared

import (
	"bytes"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	"github.com/spf13/pflag"
	"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/utils"
)

// Connection contains information about how to connect to the server.
type Connection struct {
	backend          string
	command          string
	podName          string
	kubernetesFilter string
	namespace        string
	container        string
	systemd          podman.Systemd
}

// NewConnection creates a new connection object.
// The backend is either the command to use to connect to the container or the empty string.
//
// The empty strings means automatic detection of the backend where the uyuni container is running.
// container is the name of a container to look for when detecting the command.
// kubernetesFilter is a filter parameter to use to match a pod.
func NewConnection(backend string, container string, kubernetesFilter string) *Connection {
	systemd := podman.NewSystemd()
	cnx := Connection{
		backend: backend, container: container, kubernetesFilter: kubernetesFilter, systemd: systemd,
	}

	return &cnx
}

// GetCommand validates or guesses the connection backend command.
func (c *Connection) GetCommand() (string, error) {
	var err error
	if c.command == "" {
		switch c.backend {
		case "podman":
			fallthrough
		case "podman-remote":
			fallthrough
		case "kubectl":
			if _, err = exec.LookPath(c.backend); err != nil {
				err = fmt.Errorf(L("backend command not found in PATH: %s"), c.backend)
			}
			c.command = c.backend
		case "":
			hasPodman := false
			hasKubectl := false

			// Check kubectl with a timeout in case the configured cluster is not responding
			_, err = exec.LookPath("kubectl")
			if err == nil {
				hasKubectl = true
				if out, err := utils.RunCmdOutput(
					zerolog.DebugLevel, "kubectl", "--request-timeout=30s", "get", "deploy", c.kubernetesFilter,
					"-A", "-o=jsonpath={.items[*].metadata.name}",
				); err != nil {
					log.Info().Msg(L("kubectl not configured to connect to a cluster, ignoring"))
				} else if len(bytes.TrimSpace(out)) != 0 {
					c.command = "kubectl"
					return c.command, err
				}
			}

			// Search for other backends
			bins := []string{"podman", "podman-remote"}
			for _, bin := range bins {
				if _, err = exec.LookPath(bin); err == nil {
					hasPodman = true
					if checkErr := utils.RunCmd(bin, "inspect", c.container, "--format", "{{.Name}}"); checkErr == nil {
						c.command = bin
						break
					}
				}
			}
			if c.command == "" {
				// Check for uyuni-server.service or helm release
				if hasPodman && (c.systemd.HasService(podman.ServerService) || c.systemd.HasService(podman.ProxyService)) {
					c.command = "podman"
					return c.command, nil
				} else if hasKubectl {
					clusterInfos, err := kubernetes.CheckCluster()
					if err != nil {
						return c.command, err
					}
					kubeconfig := clusterInfos.GetKubeconfig()
					if kubernetes.HasHelmRelease("uyuni", kubeconfig) || kubernetes.HasHelmRelease("uyuni-proxy", kubeconfig) {
						c.command = "kubectl"
						return c.command, nil
					}
				}
			}
			if c.command == "" {
				err = errors.New(L("uyuni container is not accessible with one of podman, podman-remote or kubectl"))
			}
		default:
			err = fmt.Errorf(L("unsupported backend %s"), c.backend)
		}
	}
	return c.command, err
}

// GetNamespace finds the namespace of the running pod
// appName is the name of the application to look for, if not provided it will be guessed based on the filter.
// filters are additional filters to use to find the pod.
func (c *Connection) GetNamespace(appName string) (string, error) {
	// skip if namespace is already set
	if c.namespace != "" {
		return c.namespace, nil
	}

	command, cmdErr := c.GetCommand()
	if cmdErr != nil {
		return "", cmdErr
	}

	// skip if the command is not resolvable or does not target kubectl
	if command != "kubectl" {
		return c.namespace, nil
	}

	// if no appName is provided, we'll assume it based on its filter
	if appName == "" {
		switch c.kubernetesFilter {
		case kubernetes.ProxyFilter:
			appName = kubernetes.ProxyApp
		case kubernetes.ServerFilter:
			appName = kubernetes.ServerApp
		}

		if appName == "" {
			return "", errors.New(L("coundn't find app name"))
		}
	}

	// retrieving namespace from the first installed object we can find matching the filter.
	// This assumes that the server or proxy has been installed only in one namespace
	// with the current cluster credentials.
	out, err := utils.RunCmdOutput(
		zerolog.DebugLevel, "kubectl", "get", "all", "-A", c.kubernetesFilter,
		"-o", "jsonpath={.items[*].metadata.namespace}",
	)
	if err != nil {
		return "", utils.Errorf(err, L("failed to guest namespace"))
	}
	c.namespace = strings.TrimSpace(strings.Split(string(out), " ")[0])
	return c.namespace, nil
}

// GetPodName finds the name of the running pod.
func (c *Connection) GetPodName() (string, error) {
	var err error

	if c.podName == "" {
		command, cmdErr := c.GetCommand()
		if cmdErr != nil {
			return "", cmdErr
		}

		switch command {
		case "podman-remote":
			fallthrough
		case "podman":
			if out, _ := utils.RunCmdOutput(
				zerolog.DebugLevel, c.command, "ps", "-q", "-f", "name="+c.container,
			); len(out) == 0 {
				err = fmt.Errorf(L("container %s is not running on podman"), c.container)
			} else {
				log.Trace().Msgf("Found container ID '%s'", out)
				c.podName = c.container
			}
		case "kubectl":
			// We try the first item on purpose to make the command fail if not available
			if podName, _ := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "pod", c.kubernetesFilter, "-A",
				"-o=jsonpath={.items[0].metadata.name}"); len(podName) == 0 {
				err = fmt.Errorf(L("container labeled %s is not running on kubectl"), c.kubernetesFilter)
			} else {
				c.podName = string(podName[:])
			}
		}
	}

	return c.podName, err
}

// Exec runs command inside the container within an sh shell.
func (c *Connection) Exec(command string, args ...string) ([]byte, error) {
	if c.podName == "" {
		if _, err := c.GetPodName(); c.podName == "" {
			commandStr := fmt.Sprintf("%s %s", command, strings.Join(args, " "))
			return nil, utils.Errorf(err, L("%s command not executed:"), commandStr)
		}
	}

	cmd, cmdErr := c.GetCommand()
	if cmdErr != nil {
		return nil, cmdErr
	}

	cmdArgs := []string{"exec", c.podName}
	if cmd == "kubectl" {
		if _, err := c.GetNamespace(""); c.namespace == "" {
			return nil, utils.Errorf(err, L("failed to retrieve namespace "))
		}

		if c.container == "" {
			c.container = "uyuni"
		}

		cmdArgs = append(cmdArgs, "-n", c.namespace, "-c", c.container, "--")
	}
	shellArgs := append([]string{command}, args...)
	cmdArgs = append(cmdArgs, shellArgs...)

	return utils.RunCmdOutput(zerolog.DebugLevel, cmd, cmdArgs...)
}

// Healthcheck runs healthcheck command inside the container.
func (c *Connection) Healthcheck() ([]byte, error) {
	if c.podName == "" {
		if _, err := c.GetPodName(); c.podName == "" {
			return nil, utils.Errorf(err, L("Healthcheck not executed"))
		}
	}

	cmd, cmdErr := c.GetCommand()
	if cmdErr != nil {
		return nil, cmdErr
	}

	cmdArgs := []string{"healthcheck", "run", c.podName}

	return utils.RunCmdOutput(zerolog.DebugLevel, cmd, cmdArgs...)
}

// WaitForContainer waits up to 10 sec for the container to appear.
func (c *Connection) WaitForContainer() error {
	for i := 0; i < 10; i++ {
		podName, err := c.GetPodName()
		if err != nil {
			log.Debug().Err(err)
			time.Sleep(1 * time.Second)
			continue
		}
		args := []string{"exec", podName}
		command, err := c.GetCommand()
		if err != nil {
			return err
		}

		if command == "kubectl" {
			args = append(args, "--")
		}
		args = append(args, "true")
		err = utils.RunCmd(command, args...)
		if err == nil {
			return nil
		}
		time.Sleep(1 * time.Second)
	}
	return errors.New(L("container didn't start within 10s."))
}

// WaitForHealthcheck waits at most 120s for healtcheck to succeed.
func (c *Connection) WaitForHealthcheck() error {
	// Wait for the system to be up
	for i := 0; i < 120; i++ {
		_, err := c.Healthcheck()
		if err != nil {
			log.Debug().Err(err)
			time.Sleep(1 * time.Second)
			continue
		}
		return nil
	}
	return errors.New(L("container didn't start within 120s. Check for the service status"))
}

// Copy transfers a file to or from the container.
// Prefix one of src or dst parameters with `server:` to designate the path is in the container
// user and group parameters are used to set the owner of a file transferred in the container.
func (c *Connection) Copy(src string, dst string, user string, group string) error {
	podName, err := c.GetPodName()
	if err != nil {
		return err
	}

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

	var namespace, namespacePrefix = "", ""
	if command == "kubectl" {
		namespace, err = c.GetNamespace("")
		if err != nil {
			return err
		}
		namespacePrefix = namespace + "/"
	}

	var commandArgs []string
	extraArgs := []string{}
	srcExpanded := strings.Replace(src, "server:", namespacePrefix+podName+":", 1)
	dstExpanded := strings.Replace(dst, "server:", namespacePrefix+podName+":", 1)

	switch command {
	case "podman-remote":
		fallthrough
	case "podman":
		commandArgs = []string{"cp", srcExpanded, dstExpanded}
	case "kubectl":
		commandArgs = []string{"cp", "-c", "uyuni", "-n", namespace, srcExpanded, dstExpanded}
		extraArgs = []string{"-c", "uyuni", "--"}
	default:
		return fmt.Errorf(L("unknown container kind: %s"), command)
	}

	if err := utils.RunCmdStdMapping(zerolog.DebugLevel, command, commandArgs...); err != nil {
		return err
	}

	if user != "" && strings.HasPrefix(dst, "server:") {
		execArgs := []string{"exec", podName}
		if command == "kubectl" {
			execArgs = append(execArgs, "-n", namespace)
		}
		execArgs = append(execArgs, extraArgs...)
		owner := user
		if group != "" {
			owner = user + ":" + group
		}
		execArgs = append(execArgs, "chown", owner, strings.Replace(dst, "server:", "", 1))
		return utils.RunCmdStdMapping(zerolog.DebugLevel, command, execArgs...)
	}
	return nil
}

// TestExistenceInPod returns true if dstpath exists in the pod.
func (c *Connection) TestExistenceInPod(dstpath string) bool {
	podName, err := c.GetPodName()
	if err != nil {
		log.Fatal().Err(err)
	}
	commandArgs := []string{"exec", podName}

	command, err := c.GetCommand()
	if err != nil {
		log.Fatal().Err(err)
	}

	switch command {
	case "podman":
		commandArgs = append(commandArgs, "test", "-e", dstpath)
	case "kubectl":
		namespace, err := c.GetNamespace("")
		if err != nil {
			log.Fatal().Err(err).Msg(L("failed to detect the namespace"))
		}
		commandArgs = append(commandArgs, "-n", namespace)
		commandArgs = append(commandArgs, "-c", "uyuni", "test", "-e", dstpath)
	default:
		log.Fatal().Msgf(L("unknown container kind: %s"), command)
	}

	if _, err := utils.RunCmdOutput(zerolog.DebugLevel, command, commandArgs...); err != nil {
		return false
	}
	return true
}

// CopyCaCertificate copies the server SSL CA certificate to the host with fqdn as the name of the created file.
func (c *Connection) CopyCaCertificate(fqdn string) error {
	log.Info().Msg(L("Copying the SSL CA certificate to the host"))

	pkiDir := "/etc/pki/trust/anchors/"
	if !utils.FileExists(pkiDir) {
		pkiDir = "/etc/pki/ca-trust/source/anchors" // RedHat
		if !utils.FileExists(pkiDir) {
			pkiDir = "/usr/local/share/ca-certificates" // Debian and Ubuntu
			if !utils.FileExists(pkiDir) {
				pkiDir = "/etc/ssl/certs" // OpenSSL fallback
			}
		}
	}
	hostPath := path.Join(pkiDir, fqdn+".crt")

	const containerCertPath = "server:/etc/pki/trust/anchors/LOCAL-RHN-ORG-TRUSTED-SSL-CERT"
	if err := c.Copy(containerCertPath, hostPath, "root", "root"); err != nil {
		return err
	}

	log.Info().Msg(L("Updating host trusted certificates"))
	if utils.CommandExists("update-ca-certificates") {
		return utils.RunCmdStdMapping(zerolog.DebugLevel, "update-ca-certificates") // openSUSE, Debian and Ubuntu
	} else if utils.CommandExists("update-ca-trust") {
		return utils.RunCmdStdMapping(zerolog.DebugLevel, "update-ca-trust") // RedHat
	} else if utils.CommandExists("trust") {
		return utils.RunCmdStdMapping(zerolog.DebugLevel, "trust", "anchor", "--store", hostPath) // Fallback
	}
	return errors.New(L("Unable to update host trusted certificates."))
}

// ChoosePodmanOrKubernetes selects either the podman or the kubernetes function based on the backend.
//
// This function automatically detects the backend if compiled with kubernetes support
// and the backend flag is not passed.
func ChoosePodmanOrKubernetes[F interface{}](
	flags *pflag.FlagSet,
	podmanFn utils.CommandFunc[F],
	kubernetesFn utils.CommandFunc[F],
) (utils.CommandFunc[F], error) {
	backend := "podman"
	runningBinary := filepath.Base(os.Args[0])
	if utils.KubernetesBuilt || runningBinary == "mgrpxy" {
		backend, _ = flags.GetString("backend")
	}

	cnx := NewConnection(backend, podman.ServerContainerName, kubernetes.ServerFilter)
	return chooseBackend(cnx, podmanFn, kubernetesFn)
}

// ChooseProxyPodmanOrKubernetes selects either the podman or the kubernetes function based on the proxy backend.
func ChooseProxyPodmanOrKubernetes[F interface{}](
	flags *pflag.FlagSet,
	podmanFn utils.CommandFunc[F],
	kubernetesFn utils.CommandFunc[F],
) (utils.CommandFunc[F], error) {
	backend, _ := flags.GetString("backend")

	cnx := NewConnection(backend, podman.ProxyContainerNames[0], kubernetes.ProxyFilter)
	return chooseBackend(cnx, podmanFn, kubernetesFn)
}

func chooseBackend[F interface{}](
	cnx *Connection,
	podmanFn utils.CommandFunc[F],
	kubernetesFn utils.CommandFunc[F],
) (utils.CommandFunc[F], error) {
	command, err := cnx.GetCommand()
	if err != nil {
		return nil, errors.New(L("failed to determine suitable backend"))
	}
	switch command {
	case "podman":
		return podmanFn, nil
	case "kubectl":
		return kubernetesFn, nil
	}

	// Should never happen if the commands are the same than those handled in GetCommand()
	return nil, errors.New(L("no supported backend found"))
}

// ChooseObjPodmanOrKubernetes returns an artibraty object depending if podman or the kubernetes is installed.
func ChooseObjPodmanOrKubernetes[T any](systemd podman.Systemd, podmanOption T, kubernetesOption T) (T, error) {
	if systemd.HasService(podman.ServerService) || systemd.HasService(podman.ProxyService) {
		return podmanOption, nil
	} else if utils.IsInstalled("kubectl") && utils.IsInstalled("helm") {
		return kubernetesOption, nil
	}
	var res T
	return res, errors.New(L("failed to determine suitable backend"))
}

// RunSupportConfig will run supportconfig command on given connection.
func (c *Connection) RunSupportConfig(tmpDir string) ([]string, error) {
	var containerTarball string
	var files []string
	extensions := []string{"", ".md5"}
	containerName, err := c.GetPodName()
	if err != nil {
		return []string{}, err
	}

	// Run supportconfig in the container if it's running
	log.Info().Msgf(L("Running supportconfig in  %s"), containerName)
	out, err := c.Exec("supportconfig")
	if err != nil {
		/* do not return here.
		* supportconfig might return some error if some info is not generated
		* but we need to raise an error only if tarball is not generated.
		* In any case, show the error.
		 */
		log.Error().Err(err).Msg(L("failed to run supportconfig"))
	}
	tarballPath := utils.GetSupportConfigPath(string(out))
	if tarballPath == "" {
		return []string{}, utils.Errorf(err, L("failed to find container supportconfig tarball from command output"))
	}

	for _, ext := range extensions {
		containerTarball = path.Join(tmpDir, containerName+"-supportconfig.txz"+ext)
		if err := c.Copy("server:"+tarballPath+ext, containerTarball, "", ""); err != nil {
			return []string{}, utils.Errorf(err, L("cannot copy tarball"))
		}
		files = append(files, containerTarball)

		// Remove the generated file in the container
		if _, err := c.Exec("rm", tarballPath+ext); err != nil {
			return []string{}, utils.Errorf(err, L("failed to remove %s file in the container"), tarballPath+ext)
		}
	}
	return files, nil
}
07070100000016000081a400000000000000000000000168ed21dd00000624000000000000000000000000000000000000001b00000000shared/kubernetes/apply.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"os"
	"path"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/cli-runtime/pkg/printers"
)

// Apply runs kubectl apply for the provided objects.
//
// The message should be a user-friendly localized message to provide in case of error.
func Apply[T runtime.Object](objects []T, message string) error {
	tempDir, cleaner, err := utils.TempDir()
	if err != nil {
		return err
	}
	defer cleaner()

	// Run the job
	definitionPath := path.Join(tempDir, "definition.yaml")
	if err := YamlFile(objects, definitionPath); err != nil {
		return err
	}

	if err := utils.RunCmdStdMapping(zerolog.DebugLevel, "kubectl", "apply", "-f", definitionPath); err != nil {
		return utils.Errorf(err, message)
	}
	return nil
}

// YamlFile generates a YAML file from a list of kubernetes objects.
func YamlFile[T runtime.Object](objects []T, path string) error {
	printer := printers.YAMLPrinter{}
	file, err := os.Create(path)
	if err != nil {
		return utils.Errorf(err, L("failed to create %s YAML file"), path)
	}
	defer func() {
		if err := file.Close(); err != nil {
			log.Error().Err(err).Msgf(L("failed to close %s YAML file"), path)
		}
	}()

	for _, obj := range objects {
		err = printer.PrintObj(obj, file)
		if err != nil {
			return utils.Errorf(err, L("failed to write PVC to file"))
		}
	}

	return nil
}
07070100000017000081a400000000000000000000000168ed21dd000003fa000000000000000000000000000000000000002000000000shared/kubernetes/converters.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"github.com/uyuni-project/uyuni-tools/shared/types"
	core "k8s.io/api/core/v1"
)

// ConvertVolumeMounts converts the internal volume mounts into Kubernetes' ones.
func ConvertVolumeMounts(mounts []types.VolumeMount) []core.VolumeMount {
	res := []core.VolumeMount{}

	for _, mount := range mounts {
		converted := core.VolumeMount{
			Name:      mount.Name,
			MountPath: mount.MountPath,
		}
		res = append(res, converted)
	}

	return res
}

// ConvertPortMaps converts the internal port maps to Kubernetes ContainerPorts.
func ConvertPortMaps(ports []types.PortMap) []core.ContainerPort {
	res := []core.ContainerPort{}

	for _, port := range ports {
		protocol := core.ProtocolTCP
		if port.Protocol == "udp" {
			protocol = core.ProtocolUDP
		}
		converted := core.ContainerPort{
			ContainerPort: int32(port.Exposed),
			Protocol:      protocol,
		}
		res = append(res, converted)
	}
	return res
}
07070100000018000081a400000000000000000000000168ed21dd0000040e000000000000000000000000000000000000001c00000000shared/kubernetes/deploy.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"strconv"
	"strings"

	"github.com/rs/zerolog"
)

// HasDeployment returns true when a deployment matching the kubectl get filter is existing in the namespace.
func HasDeployment(namespace string, filter string) bool {
	out, err := runCmdOutput(zerolog.DebugLevel, "kubectl", "get", "deploy", "-n", namespace, filter, "-o", "name")
	if err == nil && strings.TrimSpace(string(out)) != "" {
		return true
	}
	return false
}

// GetReplicas return the number of replicas of a deployment.
//
// If no such deployment exists, 0 will be returned as if there was a deployment scaled down to 0.
func GetReplicas(namespace string, name string) int {
	out, err := runCmdOutput(zerolog.DebugLevel,
		"kubectl", "get", "deploy", "-n", namespace, name, "-o", "jsonpath={.status.replicas}",
	)
	if err != nil {
		return 0
	}
	replicas, err := strconv.Atoi(strings.TrimSpace(string(out)))
	if err != nil {
		return 0
	}
	return replicas
}
07070100000019000081a400000000000000000000000168ed21dd0000053e000000000000000000000000000000000000002100000000shared/kubernetes/deploy_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"errors"
	"fmt"
	"testing"

	"github.com/rs/zerolog"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

func TestHasDeployment(t *testing.T) {
	type dataType struct {
		out      string
		err      error
		expected bool
	}

	data := []dataType{
		{"deployment.apps/traefik\n", nil, true},
		{"\n", nil, false},
		{"Some error", errors.New("Some error"), false},
	}

	for i, test := range data {
		runCmdOutput = func(_ zerolog.Level, _ string, _ ...string) ([]byte, error) {
			return []byte(test.out), test.err
		}
		testutils.AssertEquals(t, fmt.Sprintf("test %d: unexpected result", i+1), test.expected,
			HasDeployment("kube-system", "-lapp.kubernetes.io/name=traefik"),
		)
	}
}

func TestGetReplicas(t *testing.T) {
	type dataType struct {
		out      string
		err      error
		expected int
	}
	data := []dataType{
		{"2\n", nil, 2},
		{"no such deploy\n", errors.New("No such deploy"), 0},
		{"invalid output\n", nil, 0},
	}

	for i, test := range data {
		runCmdOutput = func(_ zerolog.Level, _ string, _ ...string) ([]byte, error) {
			return []byte(test.out), test.err
		}
		testutils.AssertEquals(t, fmt.Sprintf("test %d: unexpected result", i+1),
			test.expected, GetReplicas("uyuni", "uyuni-hub-api"))
	}
}
0707010000001a000081a400000000000000000000000168ed21dd00000b9a000000000000000000000000000000000000001a00000000shared/kubernetes/helm.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"bytes"
	"errors"
	"os/exec"
	"strings"

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

// HelmUpgrade runs the helm upgrade command.
//
// To perform an installation, set the install parameter to true: helm would get the --install parameter.
// If repo is not empty, the --repo parameter will be passed.
// If version is not empty, the --version parameter will be passed.
func HelmUpgrade(kubeconfig string, namespace string, install bool,
	repo string, name string, chart string, version string, args ...string) error {
	helmArgs := []string{
		"upgrade",
		"-n", namespace,
		"--create-namespace",
		name,
		chart,
	}
	if kubeconfig != "" {
		helmArgs = append(helmArgs, "--kubeconfig", kubeconfig)
	}

	if repo != "" {
		helmArgs = append(helmArgs, "--repo", repo)
	}
	if version != "" {
		helmArgs = append(helmArgs, "--version", version)
	}
	if install {
		helmArgs = append(helmArgs, "--install")
	}

	helmArgs = append(helmArgs, args...)

	command := "upgrade"
	if install {
		command = "install"
	}
	if err := utils.RunCmdStdMapping(zerolog.DebugLevel, "helm", helmArgs...); err != nil {
		// TODO We cannot use the command variable in the message as that would break localization
		if command == "upgrade" {
			return utils.Errorf(err, L("failed to upgrade helm chart %[1]s in namespace %[2]s"), chart, namespace)
		} else if command == "install" {
			return utils.Errorf(err, L("failed to install helm chart %[1]s in namespace %[2]s"), chart, namespace)
		}
	}
	return nil
}

// HelmUninstall runs the helm uninstall command to remove a deployment.
func HelmUninstall(namespace string, kubeconfig string, deployment string, dryRun bool) error {
	if namespace == "" {
		return errors.New(L("namespace is required"))
	}

	helmArgs := []string{}
	if kubeconfig != "" {
		helmArgs = append(helmArgs, "--kubeconfig", kubeconfig)
	}
	helmArgs = append(helmArgs, "uninstall", "-n", namespace, deployment)

	if dryRun {
		log.Info().Msgf(L("Would run %s"), "helm "+strings.Join(helmArgs, " "))
	} else {
		log.Info().Msgf(L("Uninstalling %s"), deployment)
		if err := utils.RunCmd("helm", helmArgs...); err != nil {
			return utils.Errorf(err, L("failed to run helm %s"), strings.Join(helmArgs, " "))
		}
	}
	return nil
}

// HasHelmRelease returns whether a helm release is installed or not, even if it failed.
func HasHelmRelease(release string, kubeconfig string) bool {
	if _, err := exec.LookPath("helm"); err == nil {
		args := []string{}
		if kubeconfig != "" {
			args = append(args, "--kubeconfig", kubeconfig)
		}
		args = append(args, "list", "-aAq", "--no-headers", "-f", release)
		out, err := utils.RunCmdOutput(zerolog.TraceLevel, "helm", args...)
		return len(bytes.TrimSpace(out)) != 0 && err == nil
	}
	return false
}
0707010000001b000081a400000000000000000000000168ed21dd0000044f000000000000000000000000000000000000001d00000000shared/kubernetes/inspect.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

//go:build !nok8s

package kubernetes

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

// InspectServer check values on a given image and deploy.
func InspectServer(
	namespace string,
	serverImage string,
	pullPolicy string,
	pullSecret string,
) (*utils.ServerInspectData, error) {
	podName := "uyuni-image-inspector"

	inspector := utils.NewServerInspector()
	script, err := inspector.GenerateScript()
	if err != nil {
		return nil, err
	}

	out, err := RunPodLogs(
		namespace, podName, serverImage, pullPolicy, pullSecret,
		[]types.VolumeMount{utils.EtcRhnVolumeMount, utils.VarPgsqlDataVolumeMount},
		"sh", "-c", script,
	)
	if err != nil {
		return nil, err
	}

	// Parse the data
	inspectedData, err := utils.ReadInspectData[utils.ServerInspectData]([]byte(out))
	if err != nil {
		return nil, utils.Errorf(err, L("failed to parse the inspected data"))
	}
	return inspectedData, nil
}
0707010000001c000081a400000000000000000000000168ed21dd000007b6000000000000000000000000000000000000001900000000shared/kubernetes/job.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

//go:build !nok8s

package kubernetes

import (
	"strings"
	"time"

	"github.com/uyuni-project/uyuni-tools/shared/types"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
	batch "k8s.io/api/batch/v1"
	core "k8s.io/api/core/v1"
	meta "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// GetScriptJob prepares the definition of a kubernetes job running a shell script from a template.
// The name is suffixed with a time stamp to avoid collisions.
func GetScriptJob(
	namespace string,
	name string,
	image string,
	pullPolicy string,
	pullSecret string,
	mounts []types.VolumeMount,
	template utils.Template,
) (*batch.Job, error) {
	var maxFailures int32

	// Convert our mounts to Kubernetes objects
	volumeMounts := ConvertVolumeMounts(mounts)
	volumes := CreateVolumes(mounts)

	// Prepare the script
	scriptBuilder := new(strings.Builder)
	if err := template.Render(scriptBuilder); err != nil {
		return nil, err
	}

	timestamp := time.Now().Format("20060102150405")

	// Create the job object running the script wrapped as a sh command
	job := batch.Job{
		TypeMeta: meta.TypeMeta{Kind: "Job", APIVersion: "batch/v1"},
		ObjectMeta: meta.ObjectMeta{
			Name:      name + "-" + timestamp,
			Namespace: namespace,
			Labels:    GetLabels(ServerApp, ""),
		},
		Spec: batch.JobSpec{
			Template: core.PodTemplateSpec{
				Spec: core.PodSpec{
					Containers: []core.Container{
						{
							Name:            "runner",
							Image:           image,
							ImagePullPolicy: GetPullPolicy(pullPolicy),
							Command:         []string{"sh", "-c", scriptBuilder.String()},
							VolumeMounts:    volumeMounts,
						},
					},
					Volumes:       volumes,
					RestartPolicy: core.RestartPolicyNever,
				},
			},
			BackoffLimit: &maxFailures,
		},
	}

	if pullSecret != "" {
		job.Spec.Template.Spec.ImagePullSecrets = []core.LocalObjectReference{{Name: pullSecret}}
	}

	return &job, nil
}
0707010000001d000081a400000000000000000000000168ed21dd00000f7e000000000000000000000000000000000000001900000000shared/kubernetes/k3s.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"errors"
	"fmt"
	"os"
	"regexp"
	"strconv"
	"time"

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

const k3sTraefikConfigPath = "/var/lib/rancher/k3s/server/manifests/uyuni-traefik-config.yaml"
const k3sTraefikMainConfigPath = "/var/lib/rancher/k3s/server/manifests/traefik.yaml"

// InstallK3sTraefikConfig install K3s Traefik configuration.
func InstallK3sTraefikConfig(ports []types.PortMap) error {
	log.Info().Msg(L("Installing K3s Traefik configuration"))

	endpoints := []types.PortMap{}
	for _, port := range ports {
		port.Name = GetTraefikEndpointName(port)
		endpoints = append(endpoints, port)
	}
	version, err := getTraefikChartMajorVersion()
	if err != nil {
		return err
	}

	data := K3sTraefikConfigTemplateData{
		Ports:         endpoints,
		ExposeBoolean: version < 27,
	}
	if err := utils.WriteTemplateToFile(data, k3sTraefikConfigPath, 0600, true); err != nil {
		return utils.Errorf(err, L("Failed to write Traefik configuration"))
	}

	// Wait for traefik to be back
	return waitForTraefik()
}

// GetTraefikEndpointName computes the traefik endpoint name from the service and port names.
// Those names should be less than 15 characters long.
func GetTraefikEndpointName(portmap types.PortMap) string {
	svc := shortenName(portmap.Service)
	name := shortenName(portmap.Name)
	if name != svc {
		return fmt.Sprintf("%s-%s", svc, name)
	}
	return name
}

func shortenName(name string) string {
	shorteningMap := map[string]string{
		"taskomatic":      "tasko",
		"metrics":         "mtrx",
		"postgresql":      "pgsql",
		"exporter":        "xport",
		"uyuni-proxy-tcp": "uyuni",
		"uyuni-proxy-udp": "uyuni",
	}
	short := shorteningMap[name]
	if short == "" {
		short = name
	}
	return short
}

func waitForTraefik() error {
	log.Info().Msg(L("Waiting for Traefik to be reloaded"))
	for i := 0; i < 120; i++ {
		out, err := utils.RunCmdOutput(zerolog.TraceLevel, "kubectl", "get", "job", "-n", "kube-system",
			"-o", "jsonpath={.status.completionTime}", "helm-install-traefik")
		if err == nil {
			completionTime, err := time.Parse(time.RFC3339, string(out))
			if err == nil && time.Since(completionTime).Seconds() < 60 {
				return nil
			}
		}
		time.Sleep(1 * time.Second)
	}
	return errors.New(L("Failed to reload Traefik"))
}

// UninstallK3sTraefikConfig uninstall K3s Traefik configuration.
func UninstallK3sTraefikConfig(dryRun bool) {
	// Write a blank file first to get traefik to be reinstalled
	if !dryRun {
		log.Info().Msg(L("Reinstalling Traefik without additionnal configuration"))
		err := os.WriteFile(k3sTraefikConfigPath, []byte{}, 0600)
		if err != nil {
			log.Error().Err(err).Msg(L("failed to write empty traefik configuration"))
		} else {
			// Wait for traefik to be back
			if err := waitForTraefik(); err != nil {
				log.Error().Err(err).Msg(L("failed to uninstall traefik configuration"))
			}
		}
	} else {
		log.Info().Msg(L("Would reinstall Traefik without additionnal configuration"))
	}

	// Now that it's reinstalled, remove the file
	utils.UninstallFile(k3sTraefikConfigPath, dryRun)
}

func getTraefikChartMajorVersion() (int, error) {
	out, err := os.ReadFile(k3sTraefikMainConfigPath)
	if err != nil {
		return 0, utils.Errorf(err, L("failed to read the traefik configuration"))
	}
	matches := regexp.MustCompile(`traefik-([0-9]+)`).FindStringSubmatch(string(out))
	if matches == nil {
		return 0, errors.New(L("traefik configuration file doesn't contain the helm chart version"))
	}
	if len(matches) != 2 {
		return 0, errors.New(L("failed to find traefik helm chart version"))
	}

	majorVersion, err := strconv.Atoi(matches[1])
	if err != nil {
		return 0, utils.Errorf(err, L(""))
	}

	return majorVersion, nil
}
0707010000001e000081a400000000000000000000000168ed21dd000004bc000000000000000000000000000000000000002800000000shared/kubernetes/k3sTraefikTemplate.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"io"
	"text/template"

	"github.com/uyuni-project/uyuni-tools/shared/types"
)

const k3sTraefikConfigTemplate = `apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    ports:
{{- range .Ports }}
      {{ .Name }}:
        port: {{ .Port }}
        {{- if $.ExposeBoolean }}
        expose: true
        {{- else }}
        expose:
          default: true
        {{- end }}
        exposedPort: {{ .Exposed }}
        {{- if eq .Protocol "udp" }}
        protocol: UDP
        {{- else }}
        protocol: TCP
        {{- end }}
{{- end }}
`

// K3sTraefikConfigTemplateData represents information used to create K3s Traefik helm chart.
type K3sTraefikConfigTemplateData struct {
	Ports []types.PortMap
	// Set to true before traefik chart v27
	ExposeBoolean bool
}

// Render will create the helm chart configuation for K3sTraefik.
func (data K3sTraefikConfigTemplateData) Render(wr io.Writer) error {
	t := template.Must(template.New("k3sTraefikConfig").Parse(k3sTraefikConfigTemplate))
	return t.Execute(wr, data)
}
0707010000001f000081a400000000000000000000000168ed21dd0000029e000000000000000000000000000000000000001e00000000shared/kubernetes/k3s_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"testing"

	"github.com/uyuni-project/uyuni-tools/shared/utils"
)

// Test that the generated endpoints are valid for traefik.
func TestGetTraefikEndpointName(t *testing.T) {
	ports := utils.GetServerPorts(true)
	ports = append(ports, utils.HubXmlrpcPorts...)
	ports = append(ports, utils.GetProxyPorts()...)

	for _, port := range ports {
		actual := GetTraefikEndpointName(port)
		// Traefik would fail if the name is longer than 15 characters
		if len(actual) > 15 {
			t.Errorf("Traefik endpoint name has more than 15 characters: %s", actual)
		}
	}
}
07070100000020000081a400000000000000000000000168ed21dd00001d25000000000000000000000000000000000000002000000000shared/kubernetes/kubernetes.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"encoding/base64"
	"fmt"
	"os"
	"strings"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/types"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
	core "k8s.io/api/core/v1"
	meta "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
)

// ClusterInfos represent cluster information.
type ClusterInfos struct {
	KubeletVersion string
	Ingress        string
}

// IsK3s is true if it's a K3s Cluster.
func (infos ClusterInfos) IsK3s() bool {
	return strings.Contains(infos.KubeletVersion, "k3s")
}

// IsRke2 is true if it's a RKE2 Cluster.
func (infos ClusterInfos) IsRke2() bool {
	return strings.Contains(infos.KubeletVersion, "rke2")
}

// GetKubeconfig returns the path to the default kubeconfig file or "" if none.
func (infos ClusterInfos) GetKubeconfig() string {
	var kubeconfig string
	if infos.IsK3s() {
		// If the user didn't provide a KUBECONFIG value or file, use the k3s default
		kubeconfigPath := os.ExpandEnv("${HOME}/.kube/config")
		if os.Getenv("KUBECONFIG") == "" || !utils.FileExists(kubeconfigPath) {
			kubeconfig = "/etc/rancher/k3s/k3s.yaml"
		}
	}
	// Since even kubectl doesn't work without a trick on rke2, we assume the user has set kubeconfig
	return kubeconfig
}

// CheckCluster return cluster information.
func CheckCluster() (*ClusterInfos, error) {
	// Get the kubelet version
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "node",
		"-o", "jsonpath={.items[0].status.nodeInfo.kubeletVersion}")
	if err != nil {
		return nil, utils.Errorf(err, L("failed to get kubelet version"))
	}

	var infos ClusterInfos
	infos.KubeletVersion = string(out)
	infos.Ingress, err = guessIngress()
	if err != nil {
		return nil, err
	}

	return &infos, nil
}

func guessIngress() (string, error) {
	// Check for a traefik resource
	err := utils.RunCmd("kubectl", "explain", "ingressroutetcp")
	if err == nil {
		return "traefik", nil
	}
	log.Debug().Err(err).Msg("No ingressroutetcp resource deployed")

	// Look for a pod running the nginx-ingress-controller: there is no other common way to find out
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "pod", "-A",
		"-o", "jsonpath={range .items[*]}{.spec.containers[*].args[0]}{.spec.containers[*].command}{end}")
	if err != nil {
		return "", utils.Errorf(err, L("failed to get pod commands to look for nginx controller"))
	}

	const nginxController = "/nginx-ingress-controller"
	if strings.Contains(string(out), nginxController) {
		return "nginx", nil
	}

	return "", nil
}

// Restart restarts the pod.
func Restart(namespace string, app string) error {
	if err := Stop(namespace, app); err != nil {
		return utils.Errorf(err, L("cannot stop %s"), app)
	}
	return Start(namespace, app)
}

// Start starts the pod.
func Start(namespace string, app string) error {
	// if something is running, we don't need to set replicas to 1
	if _, err := GetNode(namespace, "-l"+AppLabel+"="+app); err != nil {
		return ReplicasTo(namespace, app, 1)
	}
	log.Debug().Msgf("Already running")
	return nil
}

// Stop stop the pod.
func Stop(namespace string, app string) error {
	return ReplicasTo(namespace, app, 0)
}

func get(component string, componentName string, args ...string) ([]byte, error) {
	kubectlArgs := []string{
		"get",
		component,
		componentName,
	}

	kubectlArgs = append(kubectlArgs, args...)

	output, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", kubectlArgs...)
	if err != nil {
		return []byte{}, err
	}
	return output, nil
}

// GetConfigMap returns the value of a given config map.
func GetConfigMap(configMapName string, filter string) (string, error) {
	out, err := get("configMap", configMapName, filter)
	if err != nil {
		return "", utils.Errorf(err, L("failed to run kubectl get configMap %[1]s %[2]s"), configMapName, filter)
	}

	return string(out), nil
}

// GetSecret returns the value of a given secret.
func GetSecret(secretName string, filter string) (string, error) {
	out, err := get("secret", secretName, filter)
	if err != nil {
		return "", utils.Errorf(err, L("failed to run kubectl get secret %[1]s %[2]s"), secretName, filter)
	}
	decoded, err := base64.StdEncoding.DecodeString(string(out))
	if err != nil {
		return "", utils.Errorf(err, L("Failed to base64 decode secret %s"), secretName)
	}

	return string(decoded), nil
}

// createDockerSecret creates a secret of docker type to authenticate registries.
func createDockerSecret(
	namespace string,
	name string,
	registry string,
	username string,
	password string,
	appLabel string,
) error {
	authString := fmt.Sprintf("%s:%s", username, password)
	auth := base64.StdEncoding.EncodeToString([]byte(authString))
	configjson := fmt.Sprintf(
		`{"auths": {"%s": {"username": "%s", "password": "%s", "auth": "%s"}}}`,
		registry, username, password, auth,
	)

	secret := core.Secret{
		TypeMeta: meta.TypeMeta{APIVersion: "v1", Kind: "Secret"},
		ObjectMeta: meta.ObjectMeta{
			Namespace: namespace,
			Name:      name,
			Labels:    GetLabels(appLabel, ""),
		},
		// It seems serializing this object automatically transforms the secrets to base64.
		Data: map[string][]byte{
			".dockerconfigjson": []byte(configjson),
		},
		Type: core.SecretTypeDockerConfigJson,
	}
	return Apply([]runtime.Object{&secret}, fmt.Sprintf(L("failed to create the %s docker secret"), name))
}

// AddSccSecret creates a secret holding the SCC credentials and adds it to the helm args.
func AddSCCSecret(helmArgs []string, namespace string, scc *types.SCCCredentials, appLabel string) ([]string, error) {
	secret, err := GetRegistrySecret(namespace, scc, appLabel)
	if secret != "" {
		helmArgs = append(helmArgs, secret)
	}
	return helmArgs, err
}

// GetRegistrySecret creates a docker secret holding the SCC credentials and returns the secret name.
func GetRegistrySecret(namespace string, scc *types.SCCCredentials, appLabel string) (string, error) {
	const secretName = "registry-credentials"

	// Return the existing secret if any.
	out, err := runCmdOutput(zerolog.DebugLevel, "kubectl", "get", "-n", namespace, "secret", secretName, "-o", "name")
	if err == nil && strings.TrimSpace(string(out)) != "" {
		return secretName, nil
	}

	// Create the secret if SCC user and password are passed.
	if scc.User != "" && scc.Password != "" {
		if err := createDockerSecret(
			namespace, secretName, "registry.suse.com", scc.User, scc.Password, appLabel,
		); err != nil {
			return "", err
		}
		return secretName, nil
	}
	return "", nil
}

// GetDeploymentImagePullSecret returns the name of the image pull secret of a deployment.
//
// This assumes only one secret is defined on the deployment.
func GetDeploymentImagePullSecret(namespace string, filter string) (string, error) {
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "deploy", "-n", namespace, filter,
		"-o", "jsonpath={.items[*].spec.template.spec.imagePullSecrets[*].name}",
	)
	if err != nil {
		return "", utils.Errorf(err, L("failed to get deployment image pull secret"))
	}

	return strings.TrimSpace(string(out)), nil
}

// HasResource checks if a resource is available on the cluster.
func HasResource(name string) bool {
	if err := utils.RunCmd("kubectl", "explain", name); err != nil {
		return false
	}
	return true
}
07070100000021000081a400000000000000000000000168ed21dd00000a2a000000000000000000000000000000000000001900000000shared/kubernetes/pod.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

//go:build !nok8s

package kubernetes

import (
	"fmt"
	"path"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/types"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
	core "k8s.io/api/core/v1"
	meta "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
)

// RunPodLogs runs a pod, waits for it to finish and returns it logs.
//
// This should be used only to run very fast tasks.
func RunPodLogs(
	namespace string,
	name string,
	image string,
	pullPolicy string,
	pullSecret string,
	volumesMounts []types.VolumeMount,
	cmd ...string,
) ([]byte, error) {
	// Read the file from the volume from a container into stdout
	mounts := ConvertVolumeMounts(volumesMounts)
	volumes := CreateVolumes(volumesMounts)

	// Use a pod here since this is a very simple task reading out a file from a volume
	pod := core.Pod{
		TypeMeta: meta.TypeMeta{Kind: "Pod", APIVersion: "v1"},
		ObjectMeta: meta.ObjectMeta{
			Name:      name,
			Namespace: namespace,
			Labels:    map[string]string{"app": name},
		},
		Spec: core.PodSpec{
			Containers: []core.Container{
				{
					Name:            name,
					Image:           image,
					ImagePullPolicy: GetPullPolicy(pullPolicy),
					Command:         cmd,
					VolumeMounts:    mounts,
				},
			},
			Volumes:       volumes,
			RestartPolicy: core.RestartPolicyNever,
		},
	}

	if pullSecret != "" {
		pod.Spec.ImagePullSecrets = []core.LocalObjectReference{{Name: pullSecret}}
	}

	tempDir, cleaner, err := utils.TempDir()
	if err != nil {
		return nil, err
	}
	defer cleaner()

	// Run the pod
	podPath := path.Join(tempDir, "pod.yaml")
	if err := YamlFile([]runtime.Object{&pod}, podPath); err != nil {
		return nil, err
	}

	if err := utils.RunCmd("kubectl", "apply", "-f", podPath); err != nil {
		return nil, utils.Errorf(err, L("failed to run the %s pod"), name)
	}
	if err := Apply(
		[]runtime.Object{&pod}, fmt.Sprintf(L("failed to run the %s pod"), name),
	); err != nil {
		return nil, err
	}

	if err := WaitForPod(namespace, name, 60); err != nil {
		return nil, err
	}

	data, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "logs", "-n", namespace, name)
	if err != nil {
		return nil, utils.Errorf(err, L("failed to get the %s pod logs"), name)
	}

	defer func() {
		if err := DeletePod(namespace, name, "-lapp="+name); err != nil {
			log.Err(err).Msgf(L("failed to delete the %s pod"), name)
		}
	}()

	return data, nil
}
07070100000022000081a400000000000000000000000168ed21dd00001adb000000000000000000000000000000000000001900000000shared/kubernetes/pvc.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"encoding/json"
	"fmt"
	"regexp"
	"strings"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/types"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
	core "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
)

// CreatePersistentVolumeClaims creates all the PVCs described by the mounts.
func CreatePersistentVolumeClaims(
	namespace string,
	mounts []types.VolumeMount,
) error {
	pvcs := GetPersistentVolumeClaims(
		namespace,
		"",
		core.ReadWriteOnce,
		false,
		GetLabels(ServerApp, ""),
		mounts,
	)

	for _, pvc := range pvcs {
		if !hasPersistentVolumeClaim(pvc.ObjectMeta.Namespace, pvc.ObjectMeta.Name) {
			if err := Apply(
				[]*core.PersistentVolumeClaim{pvc},
				fmt.Sprintf(L("failed to create %s persistent volume claim"), pvc.ObjectMeta.Name),
			); err != nil {
				return err
			}
		}
	}
	return nil
}

func hasPersistentVolumeClaim(namespace string, name string) bool {
	out, err := runCmdOutput(zerolog.DebugLevel, "kubectl", "get", "pvc", "-n", namespace, name, "-o", "name")
	return err == nil && strings.TrimSpace(string(out)) != ""
}

// CreatePersistentVolumeClaimForVolume creates a PVC bound to a specific Volume.
func CreatePersistentVolumeClaimForVolume(
	namespace string,
	volumeName string,
) error {
	// Get the PV Storage class and claimRef
	out, err := utils.RunCmdOutput(zerolog.DebugLevel,
		"kubectl", "get", "pv", volumeName, "-n", namespace, "-o", "json",
	)
	if err != nil {
		return err
	}
	var pv core.PersistentVolume
	if err := json.Unmarshal(out, &pv); err != nil {
		return utils.Errorf(err, L("failed to parse pv data"))
	}

	// Ensure the claimRef of the volume is for our PVC
	if pv.Spec.ClaimRef == nil || pv.Spec.ClaimRef.Name != volumeName && pv.Spec.ClaimRef.Namespace != namespace {
		return fmt.Errorf(L("the %[1]s volume has to reference the %[1]s claim in %[2]s namespace"), volumeName, namespace)
	}

	// Create the PVC object
	pvc := newPersistentVolumeClaim(
		namespace, volumeName, pv.Spec.StorageClassName,
		pv.Spec.Capacity.Storage().String(), pv.Spec.AccessModes, false,
	)
	pvc.Spec.VolumeName = volumeName

	return Apply([]runtime.Object{&pvc}, L("failed to run the persistent volume claims"))
}

// GetPersistentVolumeClaims creates the PVC objects matching a list of volume mounts.
func GetPersistentVolumeClaims(
	namespace string,
	storageClass string,
	accessMode core.PersistentVolumeAccessMode,
	matchPvByLabel bool,
	labels map[string]string,
	mounts []types.VolumeMount,
) []*core.PersistentVolumeClaim {
	var claims []*core.PersistentVolumeClaim

	for _, mount := range mounts {
		size := mount.Size
		if size == "" {
			log.Warn().Msgf(L("no size defined for PersistentVolumeClaim %s, using 10Mi as default"), mount.Name)
			size = "10Mi"
		}
		pv := newPersistentVolumeClaim(
			namespace,
			mount.Name,
			storageClass,
			size,
			[]core.PersistentVolumeAccessMode{accessMode},
			matchPvByLabel,
		)
		pv.ObjectMeta.SetLabels(labels)
		claims = append(claims, &pv)
	}

	return claims
}

// Creates a PVC from a few common values.
func newPersistentVolumeClaim(
	namespace string,
	name string,
	storageClass string,
	size string,
	accessModes []core.PersistentVolumeAccessMode,
	matchPvByLabel bool,
) core.PersistentVolumeClaim {
	pvc := core.PersistentVolumeClaim{
		TypeMeta: v1.TypeMeta{
			APIVersion: "v1",
			Kind:       "PersistentVolumeClaim",
		},
		ObjectMeta: v1.ObjectMeta{
			Name:      name,
			Namespace: namespace,
		},
		Spec: core.PersistentVolumeClaimSpec{
			AccessModes: accessModes,
			Resources: core.VolumeResourceRequirements{
				Requests: core.ResourceList{"storage": resource.MustParse(size)},
			},
		},
	}

	if storageClass != "" {
		pvc.Spec.StorageClassName = &storageClass
	}

	if matchPvByLabel {
		pvc.Spec.Selector = &v1.LabelSelector{
			MatchLabels: map[string]string{"data": name},
		}
	}

	return pvc
}

func createMount(mountPath string) core.VolumeMount {
	pattern := regexp.MustCompile("[^a-zA-Z]+")
	name := strings.Trim(pattern.ReplaceAllString(mountPath, "-"), "-")
	return core.VolumeMount{
		MountPath: mountPath,
		Name:      name,
	}
}

// CreateTmpfsMount creates a temporary volume and its mount.
func CreateTmpfsMount(mountPath string, size string) (core.VolumeMount, core.Volume) {
	mount := createMount(mountPath)

	parsedSize := resource.MustParse(size)
	volume := core.Volume{
		Name: mount.Name,
		VolumeSource: core.VolumeSource{
			EmptyDir: &core.EmptyDirVolumeSource{
				Medium:    core.StorageMediumMemory,
				SizeLimit: &parsedSize,
			},
		},
	}
	return mount, volume
}

// CreateHostPathMount creates the mount and volume for a host path.
// This is not secure and tied to the availability on the node, only use when needed.
func CreateHostPathMount(
	mountPath string,
	hostPath string,
	sourceType core.HostPathType,
) (core.VolumeMount, core.Volume) {
	mount := createMount(mountPath)

	volume := core.Volume{
		Name: mount.Name,
		VolumeSource: core.VolumeSource{
			HostPath: &core.HostPathVolumeSource{
				Path: hostPath,
				Type: &sourceType,
			},
		},
	}
	return mount, volume
}

// CreateSecretMount creates the volume for a secret.
func CreateSecretVolume(name string, secretName string) core.Volume {
	volume := core.Volume{
		Name: name,
		VolumeSource: core.VolumeSource{
			Secret: &core.SecretVolumeSource{
				SecretName: secretName,
			},
		},
	}

	return volume
}

// CreateConfigVolume creates the volume for a ConfigMap.
func CreateConfigVolume(name string, configMapName string) core.Volume {
	volume := core.Volume{
		Name: name,
		VolumeSource: core.VolumeSource{
			ConfigMap: &core.ConfigMapVolumeSource{
				LocalObjectReference: core.LocalObjectReference{
					Name: configMapName,
				},
			},
		},
	}

	return volume
}

// CreateVolumes creates PVC-based volumes matching the internal volumes mounts.
func CreateVolumes(mounts []types.VolumeMount) []core.Volume {
	volumes := []core.Volume{}

	for _, mount := range mounts {
		volume := core.Volume{
			Name: mount.Name,
			VolumeSource: core.VolumeSource{
				PersistentVolumeClaim: &core.PersistentVolumeClaimVolumeSource{
					ClaimName: mount.Name,
				},
			},
		}
		volumes = append(volumes, volume)
	}

	return volumes
}

var runCmdOutput = utils.RunCmdOutput

// HasVolume returns true if the pvcName persistent volume claim is bound.
func HasVolume(namespace string, pvcName string) bool {
	out, err := runCmdOutput(
		zerolog.DebugLevel, "kubectl", "get", "pvc", "-n", namespace, pvcName, "-o", "jsonpath={.status.phase}",
	)
	if err != nil {
		return false
	}
	return strings.TrimSpace(string(out)) == "Bound"
}
07070100000023000081a400000000000000000000000168ed21dd00000521000000000000000000000000000000000000001e00000000shared/kubernetes/pvc_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"errors"
	"fmt"
	"testing"

	"github.com/rs/zerolog"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

func TestHasVolume(t *testing.T) {
	type dataType struct {
		err      error
		out      string
		expected bool
	}
	data := []dataType{
		{nil, "Bound\n", true},
		{nil, "Pending\n", false},
		{errors.New("PVC not found"), "", false},
	}

	for i, test := range data {
		runCmdOutput = func(_ zerolog.Level, _ string, _ ...string) ([]byte, error) {
			return []byte(test.out), test.err
		}
		actual := HasVolume("myns", "thepvc")
		testutils.AssertEquals(t, fmt.Sprintf("test %d: unexpected output", i), test.expected, actual)
	}
}

func TestHasPersistentVolumeClaim(t *testing.T) {
	type dataType struct {
		err      error
		out      string
		expected bool
	}
	data := []dataType{
		{nil, "persistentvolumeclaim/var-pgsql\n", true},
		{errors.New("PVC not found"), "", false},
	}

	for i, test := range data {
		runCmdOutput = func(_ zerolog.Level, _ string, _ ...string) ([]byte, error) {
			return []byte(test.out), test.err
		}
		actual := hasPersistentVolumeClaim("myns", "thepvc")
		testutils.AssertEquals(t, fmt.Sprintf("test %d: unexpected output", i), test.expected, actual)
	}
}
07070100000024000081a400000000000000000000000168ed21dd000006be000000000000000000000000000000000000001a00000000shared/kubernetes/rke2.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"strconv"

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

const rke2NginxConfigPath = "/var/lib/rancher/rke2/server/manifests/uyuni-ingress-nginx-config.yaml"

// InstallRke2NgixConfig install Rke2 Nginx configuration.
func InstallRke2NginxConfig(ports []types.PortMap, namespace string) error {
	log.Info().Msg(L("Installing RKE2 Nginx configuration"))

	tcpPorts := []types.PortMap{}
	udpPorts := []types.PortMap{}
	for _, port := range ports {
		if port.Protocol == "udp" {
			udpPorts = append(udpPorts, port)
		} else {
			tcpPorts = append(tcpPorts, port)
		}
	}

	data := Rke2NginxConfigTemplateData{
		Namespace: namespace,
		TCPPorts:  tcpPorts,
		UDPPorts:  udpPorts,
	}
	if err := utils.WriteTemplateToFile(data, rke2NginxConfigPath, 0600, true); err != nil {
		return utils.Errorf(err, L("Failed to write Rke2 nginx configuration"))
	}

	// Wait for the nginx controller to be back
	log.Info().Msg(L("Waiting for Nginx controller to be reloaded"))
	for i := 0; i < 60; i++ {
		out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "daemonset", "-A",
			"-o", "jsonpath={.status.numberReady}", "rke2-ingress-nginx-controller")
		if err == nil {
			if count, err := strconv.Atoi(string(out)); err == nil && count > 0 {
				break
			}
		}
	}
	return nil
}

// UninstallRke2NginxConfig uninstall Rke2 Nginx configuration.
func UninstallRke2NginxConfig(dryRun bool) {
	utils.UninstallFile(rke2NginxConfigPath, dryRun)
}
07070100000025000081a400000000000000000000000168ed21dd00000444000000000000000000000000000000000000002700000000shared/kubernetes/rke2NginxTemplate.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"io"
	"text/template"

	"github.com/uyuni-project/uyuni-tools/shared/types"
)

const rke2NginxConfigTemplate = `apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: rke2-ingress-nginx
  namespace: kube-system
spec:
  valuesContent: |-
    controller:
      config:
        hsts: "false"
    tcp:
{{- range .TCPPorts }}
      {{ .Exposed }}: "{{ $.Namespace }}/uyuni-tcp:{{ .Port }}"
{{- end }}
    udp:
{{- range .UDPPorts }}
      {{ .Exposed }}: "{{ $.Namespace }}/uyuni-udp:{{ .Port }}"
{{- end }}
`

// Rke2NginxConfigTemplateData represents information used to create Rke2 Ngix helm chart.
type Rke2NginxConfigTemplateData struct {
	Namespace string
	TCPPorts  []types.PortMap
	UDPPorts  []types.PortMap
}

// Render will create the helm chart configuation for Rke2 Nginx.
func (data Rke2NginxConfigTemplateData) Render(wr io.Writer) error {
	t := template.Must(template.New("rke2NginxConfig").Parse(rke2NginxConfigTemplate))
	return t.Execute(wr, data)
}
07070100000026000081a400000000000000000000000168ed21dd00000987000000000000000000000000000000000000001d00000000shared/kubernetes/support.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"fmt"
	"os"
	"path"

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

// RunSupportConfigOnKubernetesHost will run supportconfig command on kubernetes machine.
func RunSupportConfigOnKubernetesHost(dir string, namespace string, filter string) ([]string, error) {
	files, err := utils.RunSupportConfigOnHost()
	if err != nil {
		return files, err
	}

	configmapFilename, err := fetchConfigMap(dir, namespace)
	if err != nil {
		log.Warn().Msg(L("cannot retrieve any configmap"))
	} else {
		files = append(files, configmapFilename)
	}

	podFilename, err := fetchPodYaml(dir, namespace, filter)
	if err != nil {
		log.Warn().Msg(L("cannot retrieve any pod"))
	} else {
		files = append(files, podFilename...)
	}

	return files, nil
}

func fetchConfigMap(dir string, namespace string) (string, error) {
	configmapFile, err := os.Create(path.Join(dir, "configmap"))
	if err != nil {
		return "", utils.Errorf(err, L("cannot create %s"), configmapFile.Name())
	}
	defer configmapFile.Close()
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "configmap", "-o", "yaml", "-n", namespace)
	if err != nil {
		return "", utils.Errorf(err, L("cannot fetch configmap"))
	}

	_, err = configmapFile.WriteString(string(out))
	if err != nil {
		return "", err
	}
	return configmapFile.Name(), nil
}

func fetchPodYaml(dir string, namespace string, filter string) ([]string, error) {
	pods, err := GetPods(namespace, filter)
	if err != nil {
		return []string{}, utils.Errorf(err, L("cannot check for pods in %s"), filter)
	}

	var podsFile []string
	for _, pod := range pods {
		podFile, err := os.Create(path.Join(dir, fmt.Sprintf("pod-%s", pod)))
		if err != nil {
			log.Warn().Msgf(L("failed to create %s"), podFile.Name())
			continue
		}
		defer podFile.Close()
		out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "pod", pod, "-o", "yaml", "-n", namespace)
		if err != nil {
			log.Warn().Msgf(L("failed to fetch info for pod %s"), podFile.Name())
			continue
		}

		_, err = podFile.WriteString(string(out))
		if err != nil {
			log.Warn().Msgf(L("failed to write in %s"), podFile.Name())
			continue
		}
		podsFile = append(podsFile, podFile.Name())
	}
	return podsFile, nil
}
07070100000027000081a400000000000000000000000168ed21dd00000277000000000000000000000000000000000000001f00000000shared/kubernetes/uninstall.go// SPDX-FileCopyrightText: 2023 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

// UninstallHelp returns the message appended in the uninstall commands for kubernetes.
func UninstallHelp() string {
	return L(`
Note that removing the volumes could also be handled automatically depending on the StorageClass used
when installed on a kubernetes cluster.

For instance on a default K3S install, the local-path-provider storage volumes will
be automatically removed when deleting the deployment even if --purge-volumes argument is not used.`)
}
07070100000028000081a400000000000000000000000168ed21dd00003e74000000000000000000000000000000000000001b00000000shared/kubernetes/utils.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/types"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
	core "k8s.io/api/core/v1"
)

const (
	// AppLabel is the app label name.
	AppLabel = "app.kubernetes.io/part-of"
	// ComponentLabel is the component label name.
	ComponentLabel = "app.kubernetes.io/component"
)

const (
	// ServerApp is the server app name.
	ServerApp = "uyuni"

	// ProxyApp is the proxy app name.
	ProxyApp = "uyuni-proxy"
)

const (
	// ServerComponent is the value of the component label for the server resources.
	ServerComponent = "server"
	// DBComponent is the value of the component label for the database resources.
	DBComponent = "db"
	// HubApiComponent is the value of the component label for the Hub API resources.
	HubAPIComponent = "hub-api"
	// CocoComponent is the value of the component label for the confidential computing attestation resources.
	CocoComponent = "coco"
)

// ServerFilter represents filter used to check server app.
const ServerFilter = "-l" + AppLabel + "=" + ServerApp

// ServerFilter represents filter used to check proxy app.
const ProxyFilter = "-l" + AppLabel + "=" + ProxyApp

// CAIssuerName is the name of the server CA issuer deployed if cert-manager is used.
const CAIssuerName = "uyuni-ca-issuer"

const (
	// CertSecretName is the name of the server SSL certificate secret to use.
	CertSecretName = "uyuni-cert"
	// DBCertSecretName is the name of the database SSL certificate secret to use.
	DBCertSecretName = "db-cert"

	// CASecretName is the name of the Secret containing the server TLS root CA certificate and key.
	CASecretName = "uyuni-ca"
	// CAConfigName is the name of the ConfigMap containing the server CA certificate.
	CAConfigName = "uyuni-ca"
	// CAConfigName is the name of the ConfigMap containing the database CA certificate.
	DBCAConfigName = "db-ca"
)

// GetLabels creates the label map with the app and component.
// The component label may be an empty string to skip it.
func GetLabels(app string, component string) map[string]string {
	labels := map[string]string{
		AppLabel: app,
	}
	if component != "" {
		labels[ComponentLabel] = component
	}
	return labels
}

// WaitForDeployment waits for a kubernetes deployment to have at least one replica.
func WaitForDeployments(namespace string, names ...string) error {
	log.Info().Msgf(
		NL("Waiting for %[1]s deployment to be ready in %[2]s namespace\n",
			"Waiting for %[1]s deployments to be ready in %[2]s namespace\n", len(names)),
		strings.Join(names, ", "), namespace)

	deploymentsStarting := names
	// Wait for ever for all deployments to be ready
	for len(deploymentsStarting) > 0 {
		starting := []string{}
		for _, deploymentName := range deploymentsStarting {
			ready, err := IsDeploymentReady(namespace, deploymentName)
			if err != nil {
				return err
			}
			if !ready {
				starting = append(starting, deploymentName)
			}
			deploymentsStarting = starting
		}
		if len(deploymentsStarting) > 0 {
			time.Sleep(1 * time.Second)
		}
	}
	return nil
}

// WaitForRunningDeployment waits for a deployment to have at least one replica in running state.
func WaitForRunningDeployment(namespace string, name string) error {
	log.Info().Msgf(L("Waiting for %[1]s deployment to be started in %[2]s namespace\n"), name, namespace)
	for {
		pods, err := getPodsForDeployment(namespace, name)
		if err != nil {
			return err
		}

		if len(pods) > 0 {
			jsonPath := "jsonpath={.status.containerStatuses[*].state.running.startedAt}"
			if len(pods) > 1 {
				jsonPath = "jsonpath={.items[*].status.containerStatuses[*].state.running.startedAt}"
			}
			out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "pod", "-n", namespace,
				"-o", jsonPath,
				strings.Join(pods, " "),
			)
			if err != nil {
				return utils.Errorf(err, L("failed to check if the deployment has running pods"))
			}
			if strings.TrimSpace(string(out)) != "" {
				break
			}
			if err := hasAllPodsFailed(namespace, pods, name); err != nil {
				return err
			}
		}
		time.Sleep(1 * time.Second)
	}
	return nil
}

// IsDeploymentReady returns true if a kubernetes deployment has at least one ready replica.
//
// An empty namespace means searching through all the namespaces.
func IsDeploymentReady(namespace string, name string) (bool, error) {
	jsonpath := fmt.Sprintf("jsonpath={.items[?(@.metadata.name==\"%s\")].status.readyReplicas}", name)
	args := []string{"get", "-o", jsonpath, "deploy"}
	args = addNamespace(args, namespace)

	out, err := utils.RunCmdOutput(zerolog.TraceLevel, "kubectl", args...)
	// kubectl errors out if the deployment or namespace doesn't exist
	if err == nil {
		if replicas, _ := strconv.Atoi(string(out)); replicas > 0 {
			return true, nil
		}
	}

	pods, err := getPodsForDeployment(namespace, name)
	if err != nil {
		return false, err
	}

	if err := hasAllPodsFailed(namespace, pods, name); err != nil {
		return false, err
	}

	return false, nil
}

func hasAllPodsFailed(namespace string, names []string, deployment string) error {
	failedPods := 0
	for _, podName := range names {
		if failed, err := isPodFailed(namespace, podName); err != nil {
			return err
		} else if failed {
			failedPods = failedPods + 1
		}
	}
	if len(names) > 0 && failedPods == len(names) {
		return fmt.Errorf(L("all the pods of %s deployment have a failure"), deployment)
	}
	return nil
}

func getPodsForDeployment(namespace string, name string) ([]string, error) {
	rs, err := getCurrentDeploymentReplicaSet(namespace, name)
	if err != nil {
		return []string{}, err
	}

	// Check if all replica set pods have failed to start
	return getPodsFromOwnerReference(namespace, rs)
}

func getCurrentDeploymentReplicaSet(namespace string, name string) (string, error) {
	// Get the replicasets matching the deployments and their revision as
	// Kubernetes doesn't remove the old replicasets after update.
	revisionPath := "{.metadata.annotations['deployment\\.kubernetes\\.io/revision']}"
	rsArgs := []string{
		"get", "rs", "-o",
		fmt.Sprintf(
			"jsonpath={range .items[?(@.metadata.ownerReferences[0].name=='%s')]}{.metadata.name},%s {end}",
			name, revisionPath,
		),
	}
	rsArgs = addNamespace(rsArgs, namespace)
	out, err := runCmdOutput(zerolog.DebugLevel, "kubectl", rsArgs...)
	if err != nil {
		return "", utils.Errorf(err, L("failed to list ReplicaSets for deployment %s"), name)
	}
	replicasetsOut := strings.TrimSpace(string(out))
	// No replica, no deployment
	if replicasetsOut == "" {
		return "", nil
	}

	// Get the current deployment revision to look for
	out, err = runCmdOutput(zerolog.DebugLevel, "kubectl", "get", "deploy", "-n", namespace, name,
		"-o", "jsonpath="+revisionPath,
	)
	if err != nil {
		return "", utils.Errorf(err, L("failed to get the %s deployment revision"), name)
	}
	revision := strings.TrimSpace(string(out))

	replicasets := strings.Split(replicasetsOut, " ")
	for _, rs := range replicasets {
		data := strings.SplitN(rs, ",", 2)
		if len(data) != 2 {
			return "", fmt.Errorf(L("invalid replicasset response: :%s"), replicasetsOut)
		}
		if data[1] == revision {
			return data[0], nil
		}
	}
	return "", nil
}

func getPodsFromOwnerReference(namespace string, owner string) ([]string, error) {
	jsonpath := fmt.Sprintf("jsonpath={.items[?(@.metadata.ownerReferences[0].name=='%s')].metadata.name}", owner)
	podArgs := []string{"get", "pod", "-o", jsonpath}
	podArgs = addNamespace(podArgs, namespace)
	out, err := runCmdOutput(zerolog.DebugLevel, "kubectl", podArgs...)
	if err != nil {
		return []string{}, utils.Errorf(err, L("failed to find pods for owner reference %s"), owner)
	}

	outStr := strings.TrimSpace(string(out))

	pods := []string{}
	if outStr != "" {
		pods = strings.Split(outStr, " ")
	}
	return pods, nil
}

// isPodFailed checks if any of the containers of the pod are in BackOff state.
//
// An empty namespace means searching through all the namespaces.
func isPodFailed(namespace string, name string) (bool, error) {
	// If a container failed to pull the image it status will have waiting.reason = ImagePullBackOff
	// If a container crashed its status will have waiting.reason = CrashLoopBackOff
	filter := fmt.Sprintf(".items[?(@.metadata.name==\"%s\")]", name)
	jsonpath := fmt.Sprintf("jsonpath={%[1]s.status.containerStatuses[*].state.waiting.reason}"+
		"{%[1]s.status.initContainerStatuses[*].state.waiting.reason}", filter)
	args := []string{"get", "pod", "-n", namespace, "-o", jsonpath}
	args = addNamespace(args, namespace)

	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", args...)
	if err != nil {
		return true, utils.Errorf(err, L("failed to get the status of %s pod"), name)
	}
	statuses := string(out)
	if strings.Contains(statuses, "CrashLoopBackOff") || strings.Contains(statuses, "ImagePullBackOff") {
		return true, nil
	}
	return false, nil
}

// DeploymentStatus represents the kubernetes deployment status.
type DeploymentStatus struct {
	AvailableReplicas int
	ReadyReplicas     int
	UpdatedReplicas   int
	Replicas          int
}

// GetDeploymentStatus returns the replicas status of the deployment.
func GetDeploymentStatus(namespace string, name string) (*DeploymentStatus, error) {
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", "get", "deploy", "-n", namespace,
		name, "-o", "jsonpath={.status}")
	if err != nil {
		return nil, err
	}

	var status DeploymentStatus
	if err = json.Unmarshal(out, &status); err != nil {
		return nil, utils.Errorf(err, L("failed to parse deployment status"))
	}
	return &status, nil
}

// ReplicasTo set the replicas for a deployment to the given value.
func ReplicasTo(namespace string, name string, replica uint) error {
	args := []string{"scale", "-n", namespace, "deploy", name, "--replicas", strconv.FormatUint(uint64(replica), 10)}
	log.Debug().Msgf("Setting replicas for deployment in %s to %d", name, replica)

	_, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", args...)
	if err != nil {
		return utils.Errorf(err, L("cannot run kubectl %s"), args)
	}

	if err := waitForReplicas(namespace, name, replica); err != nil {
		return err
	}

	log.Debug().Msgf("Replicas for %s deployment in %s are now %d", name, namespace, replica)
	return nil
}

func isPodRunning(namespace string, podname string, filter string) (bool, error) {
	pods, err := GetPods(namespace, filter)
	if err != nil {
		return false, utils.Errorf(err, L("cannot check if pod %[1]s is running in app %[2]s"), podname, filter)
	}
	return utils.Contains(pods, podname), nil
}

// GetPods return the list of the pod given a filter.
func GetPods(namespace string, filter string) (pods []string, err error) {
	log.Debug().Msgf("Checking all pods for %s", filter)
	cmdArgs := []string{"get", "pods", "-n", namespace, filter, "--output=custom-columns=:.metadata.name", "--no-headers"}
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", cmdArgs...)
	if err != nil {
		return pods, utils.Errorf(err, L("cannot execute %s"), strings.Join(cmdArgs, string(" ")))
	}
	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
	for _, pod := range lines {
		pods = append(pods, strings.TrimSpace(pod))
	}
	log.Debug().Msgf("Pods in %s are %s", filter, pods)

	return pods, err
}

func waitForReplicas(namespace string, name string, replicas uint) error {
	waitSeconds := 120
	log.Debug().Msgf("Checking replica for %s ready to %d", name, replicas)
	cmdArgs := []string{
		"get", "deploy", name, "-n", namespace, "-o", "jsonpath={.status.readyReplicas}", "--no-headers",
	}

	for i := 0; i < waitSeconds; i++ {
		out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", cmdArgs...)
		if err != nil {
			return utils.Errorf(err, L("cannot execute %s"), strings.Join(cmdArgs, string(" ")))
		}
		outStr := strings.TrimSpace(string(out))
		var readyReplicas uint64
		if outStr != "" {
			var err error
			readyReplicas, err = strconv.ParseUint(outStr, 10, 8)
			if err != nil {
				return utils.Errorf(err, L("invalid replicas result"))
			}
		}
		if uint(readyReplicas) == replicas {
			return nil
		}
		time.Sleep(1 * time.Second)
	}
	return nil
}

func addNamespace(args []string, namespace string) []string {
	if namespace != "" {
		args = append(args, "-n", namespace)
	} else {
		args = append(args, "-A")
	}
	return args
}

// GetPullPolicy returns the kubernetes PullPolicy value, if exists.
func GetPullPolicy(name string) core.PullPolicy {
	policies := map[string]core.PullPolicy{
		"always":       core.PullAlways,
		"never":        core.PullNever,
		"ifnotpresent": core.PullIfNotPresent,
	}
	policy := policies[strings.ToLower(name)]
	if policy == "" {
		log.Fatal().Msgf(L("%s is not a valid image pull policy value"), name)
	}
	return policy
}

// RunPod runs a pod, waiting for its execution and deleting it.
func RunPod(
	namespace string,
	podname string,
	filter string,
	image string,
	pullPolicy string,
	command string,
	override ...string,
) error {
	arguments := []string{
		"run", "--rm", "-n", namespace, "--attach", "--pod-running-timeout=3h", "--restart=Never", podname,
		"--image", image, "--image-pull-policy", pullPolicy, filter,
	}

	if len(override) > 0 {
		arguments = append(arguments, `--override-type=strategic`)
		for _, arg := range override {
			overrideParam := "--overrides=" + arg
			arguments = append(arguments, overrideParam)
		}
	}

	arguments = append(arguments, "--command", "--", command)
	err := utils.RunCmdStdMapping(zerolog.DebugLevel, "kubectl", arguments...)
	if err != nil {
		return utils.Errorf(err, PL("The first placeholder is a command",
			"cannot run %[1]s using image %[2]s"), command, image)
	}
	return nil
}

// DeletePod deletes a kubernetes pod named podname.
func DeletePod(namespace string, podname string, filter string) error {
	isRunning, err := isPodRunning(namespace, podname, filter)
	if err != nil {
		return utils.Errorf(err, L("cannot delete pod %s"), podname)
	}
	if !isRunning {
		log.Debug().Msgf("no need to delete pod %s because is not running", podname)
		return nil
	}
	arguments := []string{"delete", "pod", podname, "-n", namespace}
	_, err = utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", arguments...)
	if err != nil {
		return utils.Errorf(err, L("cannot delete pod %s"), podname)
	}
	return nil
}

// GetNode return the node where the app is running.
func GetNode(namespace string, filter string) (string, error) {
	nodeName := ""
	cmdArgs := []string{"get", "pod", "-n", namespace, filter, "-o", "jsonpath={.items[*].spec.nodeName}"}
	for i := 0; i < 60; i++ {
		out, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", cmdArgs...)
		if err == nil {
			nodeName = string(out)
			break
		}
	}
	if len(nodeName) > 0 {
		log.Debug().Msgf("Node name matching filter %s is: %s", filter, nodeName)
	} else {
		return "", fmt.Errorf(L("cannot find node name matching filter %s"), filter)
	}
	return nodeName, nil
}

// GenerateOverrideDeployment generate a JSON files represents the deployment information.
func GenerateOverrideDeployment(deployData types.Deployment) (string, error) {
	ret, err := json.Marshal(deployData)
	if err != nil {
		return "", utils.Errorf(err, L("cannot serialize pod definition override"))
	}
	return string(ret), nil
}

// GetRunningImage returns the image of containerName for the server running in the current system.
func GetRunningImage(containerName string) (string, error) {
	args := []string{
		"get", "pods", "-A", ServerFilter,
		"-o", "jsonpath={.items[0].spec.containers[?(@.name=='" + containerName + "')].image}",
	}
	image, err := utils.RunCmdOutput(zerolog.DebugLevel, "kubectl", args...)

	log.Debug().Msgf("%[1]s container image is: %[2]s", containerName, image)
	if err != nil {
		return "", err
	}
	return strings.Trim(string(image), "\n"), nil
}
07070100000029000081a400000000000000000000000168ed21dd00000bdd000000000000000000000000000000000000002000000000shared/kubernetes/utils_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package kubernetes

import (
	"errors"
	"fmt"
	"strings"
	"testing"

	"github.com/rs/zerolog"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
)

func TestGetCurrentDeploymentReplicaSet(t *testing.T) {
	type testType struct {
		rsOut         string
		rsErr         error
		revisionOut   string
		revisionErr   error
		expected      string
		expectedError bool
	}

	testCases := []testType{
		{
			rsOut:         "uyuni-64d597fccf,1 uyuni-66f7677dc6,2\n",
			rsErr:         nil,
			revisionOut:   "2\n",
			revisionErr:   nil,
			expected:      "uyuni-66f7677dc6",
			expectedError: false,
		},
		{
			rsOut:         "uyuni-64d597fccf,1\n",
			rsErr:         nil,
			revisionOut:   "1\n",
			revisionErr:   nil,
			expected:      "uyuni-64d597fccf",
			expectedError: false,
		},
		{
			rsOut:         "\n",
			rsErr:         nil,
			revisionOut:   "not found\n",
			revisionErr:   errors.New("not found"),
			expected:      "",
			expectedError: false,
		},
		{
			rsOut:         "get rs error\n",
			rsErr:         errors.New("get rs error"),
			revisionOut:   "1\n",
			revisionErr:   nil,
			expected:      "",
			expectedError: true,
		},
		{
			rsOut:         "uyuni-64d597fccf,1\n",
			rsErr:         nil,
			revisionOut:   "get rev error\n",
			revisionErr:   errors.New("get rev error"),
			expected:      "",
			expectedError: true,
		},
	}

	for i, test := range testCases {
		runCmdOutput = func(_ zerolog.Level, _ string, args ...string) ([]byte, error) {
			if utils.Contains(args, "rs") {
				return []byte(test.rsOut), test.rsErr
			}
			return []byte(test.revisionOut), test.revisionErr
		}
		actual, err := getCurrentDeploymentReplicaSet("uyunins", "uyuni")
		caseMsg := fmt.Sprintf("test %d: ", i+1)
		testutils.AssertEquals(t, fmt.Sprintf("%sunexpected error raised: %s", caseMsg, err),
			test.expectedError, err != nil,
		)
		testutils.AssertEquals(t, caseMsg+"unexpected result", test.expected, actual)
	}
}

func TestGetPodsFromOwnerReference(t *testing.T) {
	type testType struct {
		out      string
		err      error
		expected []string
	}

	data := []testType{
		{
			out:      "pod1 pod2 pod3\n",
			err:      nil,
			expected: []string{"pod1", "pod2", "pod3"},
		},
		{
			out:      "\n",
			err:      nil,
			expected: []string{},
		},
		{
			out:      "error\n",
			err:      errors.New("some error"),
			expected: []string{},
		},
	}

	for i, test := range data {
		runCmdOutput = func(_ zerolog.Level, _ string, _ ...string) ([]byte, error) {
			return []byte(test.out), test.err
		}
		actual, err := getPodsFromOwnerReference("myns", "owner")
		if test.err == nil {
			testutils.AssertTrue(t, "Shouldn't have raise an error", err == nil)
		} else {
			testutils.AssertTrue(t, "Unexpected error raised", strings.Contains(err.Error(), test.err.Error()))
		}
		testutils.AssertEquals(t, fmt.Sprintf("test %d: unexpected result", i+1), test.expected, actual)
	}
}
0707010000002a000081a400000000000000000000000168ed21dd00000aa2000000000000000000000000000000000000001d00000000shared/kubernetes/waiters.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

//go:build !nok8s

package kubernetes

import (
	"fmt"
	"strings"
	"time"

	"github.com/rs/zerolog"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
)

// WaitForSecret waits for a secret to be available.
func WaitForSecret(namespace string, secret string) {
	for i := 0; ; i++ {
		if err := utils.RunCmd("kubectl", "get", "-n", namespace, "secret", secret); err == nil {
			break
		}
		time.Sleep(1 * time.Second)
	}
}

// WaitForJob waits for a job to be completed before timeout seconds.
//
// If the timeout value is 0 the job will be awaited for for ever.
func WaitForJob(namespace string, name string, timeout int) error {
	for i := 0; ; i++ {
		status, err := jobStatus(namespace, name)
		if err != nil {
			return err
		}
		if status == "error" {
			return fmt.Errorf(
				L("%[1]s job failed, run kubectl logs -n %[2]s --tail=-1 -ljob-name=%[1]s for details"),
				name, namespace,
			)
		}
		if status == "success" {
			return nil
		}

		if timeout > 0 && i == timeout {
			return fmt.Errorf(L("%[1]s job failed to complete within %[2]d seconds"), name, timeout)
		}
		time.Sleep(1 * time.Second)
	}
}

func jobStatus(namespace string, name string) (string, error) {
	out, err := utils.RunCmdOutput(
		zerolog.DebugLevel, "kubectl", "get", "job", "-n", namespace, name,
		"-o", "jsonpath={.status.succeeded},{.status.failed}",
	)
	if err != nil {
		return "", utils.Errorf(err, L("failed to get %s job status"), name)
	}
	results := strings.SplitN(strings.TrimSpace(string(out)), ",", 2)
	if len(results) != 2 {
		return "", fmt.Errorf(L("invalid job status response: '%s'"), string(out))
	}
	if results[0] == "1" {
		return "success", nil
	} else if results[1] == "1" {
		return "error", nil
	}
	return "", nil
}

// WaitForPod waits for a pod to complete before timeout seconds.
//
// If the timeout value is 0 the pod will be awaited for for ever.
func WaitForPod(namespace string, pod string, timeout int) error {
	for i := 0; ; i++ {
		out, err := utils.RunCmdOutput(
			zerolog.DebugLevel, "kubectl", "get", "pod", "-n", namespace, pod,
			"-o", "jsonpath={.status.containerStatuses[0].state.terminated.reason}",
		)
		if err != nil {
			return utils.Errorf(err, L("failed to get %s pod status"), pod)
		}
		status := strings.TrimSpace(string(out))
		if status != "" {
			if status == "Completed" {
				return nil
			}
			return fmt.Errorf(L("%[1]s pod failed with status %[2]s"), pod, status)
		}

		if timeout > 0 && i == timeout {
			return fmt.Errorf(L("%[1]s pod failed to complete within %[2]d seconds"), pod, timeout)
		}
		time.Sleep(1 * time.Second)
	}
}
0707010000002b000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001200000000shared/kubernetes0707010000002c000081a400000000000000000000000168ed21dd00000301000000000000000000000000000000000000001700000000shared/l10n/gettext.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package l10n

import "github.com/chai2010/gettext-go"

// L localizes a string using the set up gettext domain and locale.
// This is an alias for gettext.Gettext().
func L(message string) string {
	return gettext.Gettext(message)
}

// NL returns a localized message depending on the value of count.
// This is an alias for gettext.NGettext().
func NL(message string, plural string, count int) string {
	return gettext.NGettext(message, plural, count)
}

// PL localizes a string using the set up gettext domain and locale, but adding a context.
// This is an alias for gettext.PGettext().
func PL(context string, message string) string {
	return gettext.PGettext(context, message)
}
0707010000002d000081a400000000000000000000000168ed21dd0000051d000000000000000000000000000000000000001f00000000shared/l10n/utils/defaultfs.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package l10n

import "github.com/chai2010/gettext-go"

// DefaultFS providing a empty data if no data is found.
type DefaultFS struct {
	osFs gettext.FileSystem
	gettext.FileSystem
}

// New creates a new DefaultFS delegating to an OS FileSystem.
func New(path string) *DefaultFS {
	return &DefaultFS{
		osFs: gettext.OS(path),
	}
}

// LocaleList gets the list of locales from the underlying os FileSystem.
func (f *DefaultFS) LocaleList() []string {
	return f.osFs.LocaleList()
}

// LoadMessagesFile loads a messages or returns the content of an empty json file.
func (f *DefaultFS) LoadMessagesFile(domain, lang, ext string) ([]byte, error) {
	osFile, err := f.osFs.LoadMessagesFile(domain, lang, ext)
	// Return an empty file by default
	if err != nil {
		return []byte("[]"), nil
	}
	return osFile, nil
}

// LoadResourceFile loads the resource file or returns empty data.
func (f *DefaultFS) LoadResourceFile(domain, lang, ext string) ([]byte, error) {
	osFile, err := f.osFs.LoadResourceFile(domain, lang, ext)
	// Return an empty file by default
	if err != nil {
		return []byte{}, nil
	}
	return osFile, nil
}

// String returns a name of the FileSystem.
func (f *DefaultFS) String() string {
	return "DefaultFS"
}
0707010000002e000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001200000000shared/l10n/utils0707010000002f000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000000c00000000shared/l10n07070100000030000081a400000000000000000000000168ed21dd00000811000000000000000000000000000000000000001f00000000shared/podman/hostinspector.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"github.com/rs/zerolog"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/templates"
	"github.com/uyuni-project/uyuni-tools/shared/types"
	"github.com/uyuni-project/uyuni-tools/shared/utils"
)

// NewHostInspector creates a new templates.InspectTemplateData for host data.
func NewHostInspector() templates.InspectTemplateData {
	return templates.InspectTemplateData{
		Values: []types.InspectData{
			types.NewInspectData(
				"scc_username",
				"cat /etc/zypp/credentials.d/SCCcredentials 2>/dev/null | grep username | cut -d= -f2 || true"),
			types.NewInspectData(
				"scc_password",
				"cat /etc/zypp/credentials.d/SCCcredentials 2>/dev/null | grep password | cut -d= -f2 || true"),

			types.NewInspectData(
				"has_uyuni_server",
				"systemctl list-unit-files uyuni-server.service >/dev/null && echo true || echo false"),
			types.NewInspectData(
				"has_salt_minion",
				"systemctl list-unit-files venv-salt-minion.service >/dev/null && echo true || echo false"),
		},
	}
}

// HostInspectData are the data returned by the host inspector.
type HostInspectData struct {
	SCCUsername    string `mapstructure:"scc_username"`
	SCCPassword    string `mapstructure:"scc_password"`
	HasUyuniServer bool   `mapstructure:"has_uyuni_server"`
	HasSaltMinion  bool   `mapstructure:"has_salt_minion"`
}

// InspectHost gathers data on the host where to install the server or proxy.
func InspectHost() (*HostInspectData, error) {
	inspector := NewHostInspector()
	script, err := inspector.GenerateScript()
	if err != nil {
		return nil, err
	}

	out, err := newRunner("bash", "-c", script).Log(zerolog.DebugLevel).Exec()
	if err != nil {
		return nil, utils.Errorf(err, L("failed to run inspect script in host system"))
	}

	inspectResult, err := utils.ReadInspectData[HostInspectData](out)
	if err != nil {
		return nil, utils.Errorf(err, L("cannot inspect host data"))
	}

	return inspectResult, err
}
07070100000031000081a400000000000000000000000168ed21dd000005ec000000000000000000000000000000000000002400000000shared/podman/hostinspector_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"testing"

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

func TestHostInspectorGenerate(t *testing.T) {
	inspector := NewHostInspector()
	script, err := inspector.GenerateScript()
	if err != nil {
		t.Errorf("Unexpected error %s", err)
	}

	expected := `
# inspect.sh, generated by mgradm
echo "scc_username=$(cat /etc/zypp/credentials.d/SCCcredentials 2>/dev/null | grep username | cut -d= -f2 || true)"
echo "scc_password=$(cat /etc/zypp/credentials.d/SCCcredentials 2>/dev/null | grep password | cut -d= -f2 || true)"
echo "has_uyuni_server=$(systemctl list-unit-files uyuni-server.service >/dev/null && echo true || echo false)"
echo "has_salt_minion=$(systemctl list-unit-files venv-salt-minion.service >/dev/null && echo true || echo false)"
exit 0
`

	testutils.AssertEquals(t, "Wrongly generated script", expected, script)
}

func TestHostInspectorParse(t *testing.T) {
	content := `
scc_username=myuser
scc_password=mysecret
has_uyuni_server=true
`

	actual, err := utils.ReadInspectData[HostInspectData]([]byte(content))
	if err != nil {
		t.Fatalf("Unexpected error: %s", err)
	}

	testutils.AssertEquals(t, "Invalid SCC username", "myuser", actual.SCCUsername)
	testutils.AssertEquals(t, "Invalid SCC password", "mysecret", actual.SCCPassword)
	testutils.AssertTrue(t, "HasUyuniServer should be true", actual.HasUyuniServer)
}
07070100000032000081a400000000000000000000000168ed21dd000031c6000000000000000000000000000000000000001800000000shared/podman/images.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strings"

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

const rpmImageDir = "/usr/share/suse-docker-images/native/"

// PrepareImage ensures the container image is pulled or pull it if the pull policy allows it.
//
// Returns the image name to use. Note that it may be changed if the image has been loaded from a local RPM package.
func PrepareImage(authFile string, image string, pullPolicy string, pullEnabled bool) (string, error) {
	if strings.ToLower(pullPolicy) != "always" {
		log.Info().Msgf(L("Ensure image %s is available"), image)

		presentImage, err := IsImagePresent(image)
		if err != nil {
			return image, err
		}

		if len(presentImage) > 0 {
			log.Debug().Msgf("Image %s already present", presentImage)
			return presentImage, nil
		}
		log.Debug().Msgf("Image %s is missing", image)
	} else {
		log.Info().Msgf(
			L("Pull Policy is always. Presence of RPM image will be checked and pulled from registry if not present"),
		)
	}

	rpmImageFile := GetRpmImagePath(image)

	if len(rpmImageFile) > 0 {
		log.Debug().Msgf("Image %s present as RPM. Loading it", image)
		loadedImage, err := loadRpmImage(rpmImageFile)
		if err != nil {
			log.Warn().Err(err).Msgf(L("Cannot use RPM image for %s"), image)
		} else {
			log.Info().Msgf(L("Using the %[1]s image loaded from the RPM instead of its online version %[2]s"),
				strings.TrimSpace(loadedImage), image)
			return loadedImage, nil
		}
	} else {
		log.Info().Msgf(L("Cannot find RPM image for %s"), image)
	}

	if strings.ToLower(pullPolicy) != "never" {
		if pullEnabled {
			log.Debug().Msgf("Pulling image %s because it is missing and pull policy is not 'never'", image)
			return image, pullImage(authFile, image)
		}
		log.Debug().Msgf("Not pulling image %s, although the pull policy is not 'never', maybe replicas is zero?", image)
		return image, nil
	}

	return image, fmt.Errorf(L("image %s is missing and cannot be fetched"), image)
}

func PrepareImages(
	authFile string,
	image types.ImageFlags,
	pgsqlFlags types.PgsqlFlags,
) (string, string, error) {
	serverImage, err := utils.ComputeImage(image.Registry, utils.DefaultTag, image)
	if err != nil && len(serverImage) > 0 {
		return "", "", utils.Error(err, L("failed to determine image"))
	}

	if len(serverImage) <= 0 {
		log.Debug().Msg("Use deployed image")

		serverImage, err = GetRunningImage(ServerContainerName)
		if err != nil {
			return "", "", utils.Error(err, L("failed to find the image of the currently running server container"))
		}
	}

	globalTag := utils.DefaultTag
	if image.Tag != "" {
		globalTag = image.Tag
	}

	pgsqlImage, err := utils.ComputeImage(image.Registry, globalTag, pgsqlFlags.Image)
	if err != nil && len(pgsqlImage) > 0 {
		return "", "", utils.Error(err, L("failed to determine pgsql image"))
	}

	if len(pgsqlImage) <= 0 {
		log.Debug().Msg("Use deployed postgresql image")

		pgsqlImage, err = GetRunningImage(DBContainerName)
		if err != nil {
			return "", "", utils.Error(err, L("failed to find the image of the currently running db container"))
		}
	}

	preparedServerImage, err := PrepareImage(authFile, serverImage, image.PullPolicy, true)
	if err != nil {
		return preparedServerImage, "", err
	}

	preparedPgsqlImage, err := PrepareImage(authFile, pgsqlImage, image.PullPolicy, true)
	if err != nil {
		return preparedServerImage, preparedPgsqlImage, err
	}

	return preparedServerImage, preparedPgsqlImage, nil
}

// GetRpmImageName return the RPM Image name and the tag, given an image.
func GetRpmImageName(image string) (rpmImageFile string, tag string) {
	pattern := regexp.MustCompile(`^https?://|^docker://|^oci://`)
	if pattern.FindStringIndex(image) == nil {
		image = "docker://" + image
	}
	url, err := url.Parse(image)
	if err != nil {
		log.Warn().Msgf(L("Cannot correctly parse image name '%s', local image cannot be used"), image)
		return "", ""
	}
	rpmImageFile = strings.TrimPrefix(url.Path, "/")
	rpmImageFile = strings.ReplaceAll(rpmImageFile, "/", "-")
	parts := strings.Split(rpmImageFile, ":")
	tag = "latest"
	if len(parts) > 1 {
		tag = parts[1]
	}
	rpmImageFile = parts[0]
	return rpmImageFile, tag
}

// BuildRpmImagePath checks the image metadata and returns the RPM Image path.
func BuildRpmImagePath(byteValue []byte, rpmImageFile string, tag string) (string, error) {
	var data types.Metadata
	if err := json.Unmarshal(byteValue, &data); err != nil {
		return "", utils.Errorf(err, L("cannot unmarshal image RPM metadata"))
	}
	fullPathFile := rpmImageDir + data.Image.File
	if data.Image.Name == rpmImageFile {
		for _, metadataTag := range data.Image.Tags {
			if metadataTag == tag {
				return fullPathFile, nil
			}
		}
	}
	return "", nil
}

// GetRpmImagePath return the RPM image path.
func GetRpmImagePath(image string) string {
	log.Debug().Msgf("Looking for installed RPM package containing %s image", image)

	rpmImageFile, tag := GetRpmImageName(image)
	if !utils.FileExists(rpmImageDir) {
		log.Info().Msgf(L("skipping loading image from RPM as %s doesn't exist"), rpmImageDir)
		return ""
	}

	files, err := os.ReadDir(rpmImageDir)
	if err != nil {
		log.Debug().Err(err).Msgf("Cannot read directory %s", rpmImageDir)
		return ""
	}

	for _, file := range files {
		if !strings.HasSuffix(file.Name(), "metadata") {
			continue
		}
		fullPathFileName := path.Join(rpmImageDir, file.Name())
		log.Debug().Msgf("Parsing metadata file %s", fullPathFileName)
		fileHandler, err := os.Open(fullPathFileName)
		if err != nil {
			log.Debug().Err(err).Msgf("Error opening metadata file %s", fullPathFileName)
			continue
		}
		defer fileHandler.Close()
		byteValue, err := io.ReadAll(fileHandler)
		if err != nil {
			log.Debug().Err(err).Msgf("Error reading metadata file %s", fullPathFileName)
			continue
		}

		fullPathFile, err := BuildRpmImagePath(byteValue, rpmImageFile, tag)
		if err != nil {
			log.Warn().Err(err).Msgf(L("Cannot unmarshal metadata file %s"), fullPathFileName)
			return ""
		}
		if len(fullPathFile) > 0 {
			log.Debug().Msgf("%s match with %s", fullPathFileName, image)
			return fullPathFile
		}
		log.Debug().Msgf("%s does not match with %s", fullPathFileName, image)
	}
	log.Debug().Msgf("No installed RPM package containing %s image", image)
	return ""
}

func loadRpmImage(rpmImageBasePath string) (string, error) {
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "podman", "load", "--quiet", "--input", rpmImageBasePath)
	if err != nil {
		return "", err
	}
	parseOutput := strings.SplitN(string(out), ":", 2)
	if len(parseOutput) == 2 {
		return strings.TrimSpace(parseOutput[1]), nil
	}
	return "", fmt.Errorf(L("error parsing: %s"), string(out))
}

// IsImagePresent returns the image name if the image is present.
func IsImagePresent(image string) (string, error) {
	log.Debug().Msgf("Checking for %s", image)
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "podman", "images", "--format={{ .Repository }}", image)
	if err != nil {
		return "", fmt.Errorf(L("failed to check if image %s has already been pulled"), image)
	}

	if len(bytes.TrimSpace(out)) > 0 {
		return image, nil
	}

	splitImage := strings.SplitN(string(image), "/", 2)
	if len(splitImage) < 2 {
		return "", nil
	}
	log.Debug().Msgf("Checking for local image of %s", image)
	out, err = utils.RunCmdOutput(zerolog.DebugLevel, "podman", "images", "--quiet", "localhost/"+splitImage[1])
	if err != nil {
		return "", fmt.Errorf(L("failed to check if image %s has already been pulled"), image)
	}
	if len(bytes.TrimSpace(out)) > 0 {
		return "localhost/" + splitImage[1], nil
	}

	return "", nil
}

// GetPulledImageName returns the fullname of a pulled image.
func GetPulledImageName(image string) (string, error) {
	parts := strings.Split(image, "/")
	imageWithTag := parts[len(parts)-1]
	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "podman", "images", imageWithTag, "--format", "{{.Repository}}")
	if err != nil {
		return "", fmt.Errorf(L("failed to check if image %s has already been pulled"), parts[len(parts)-1])
	}
	return string(bytes.TrimSpace(out)), nil
}

func pullImage(authFile string, image string) error {
	if utils.ContainsUpperCase(image) {
		return fmt.Errorf(L("%s should contains just lower case character, otherwise podman pull would fails"), image)
	}
	log.Info().Msgf(L("Running podman pull %s"), image)
	podmanArgs := []string{"pull", image}

	if authFile != "" {
		podmanArgs = append(podmanArgs, "--authfile", authFile)
	}

	return utils.RunCmdStdMapping(zerolog.DebugLevel, "podman", podmanArgs...)
}

// ShowAvailableTag returns the list of available tag for a given image.
func ShowAvailableTag(registry string, image types.ImageFlags, authFile string) error {
	log.Info().Msgf(L("Running podman image search --list-tags %s --format={{.Tag}}"), image.Name)

	name, err := utils.ComputeImage(registry, utils.DefaultTag, image)
	if err != nil {
		return err
	}

	args := []string{"image", "search", "--list-tags", name, "--format={{.Tag}}"}
	if authFile != "" {
		args = append(args, "--authfile", authFile)
	}

	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "podman", args...)
	if err != nil {
		return utils.Errorf(err, L("cannot find any tag for image %s"), image)
	}

	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
		if !strings.HasSuffix(line, ".sig") && !strings.HasSuffix(line, ".att") {
			fmt.Println(line)
		}
	}

	return nil
}

// GetRunningImage given a container name, return the image name.
func GetRunningImage(container string) (string, error) {
	log.Info().Msgf(L("Running podman ps --filter=name=%s --format={{ .Image }}"), container)

	out, err := utils.RunCmdOutput(
		zerolog.DebugLevel, "podman", "ps", fmt.Sprintf("--filter=name=%s", container), "--format={{ .Image }}",
	)
	if err != nil {
		return "", utils.Errorf(err, L("cannot find any running image for container %s"), container)
	}

	image := strings.TrimSpace(string(out))
	return image, nil
}

// HasRemoteImage returns true if the image is available remotely.
//
// The image has to be a full image with registry, path and tag.
func HasRemoteImage(image string) bool {
	out, err := runCmdOutput(zerolog.DebugLevel,
		"podman", "search", "--list-tags", "--format", "{{.Name}}:{{.Tag}}", image,
	)
	if err != nil {
		return false
	}
	imageFinder := regexp.MustCompile("(?Um)^" + image + "$")
	return imageFinder.Match(out)
}

// DeleteImage deletes a podman image based on its name.
// If dryRun is set to true, nothing will be done, only messages logged to explain what would happen.
func DeleteImage(name string, dryRun bool) error {
	exists := imageExists(name)
	if exists {
		if dryRun {
			log.Info().Msgf(L("Would run %s"), "podman image rm "+name)
		} else {
			log.Info().Msgf(L("Run %s"), "podman image rm "+name)
			err := utils.RunCmd("podman", "image", "rm", name)
			if err != nil {
				return utils.Errorf(err, L("Failed to remove image %s"), name)
			}
		}
	}
	return nil
}

var newRunner = utils.NewRunner

// ExportImage saves a podman image based on its name to a specified directory.
// outputDir option expects already existing directory.
// If dryRun is set to true, nothing will be done, only messages logged to explain what would happen.
func ExportImage(name string, outputDir string, dryRun bool) error {
	exists := imageExists(name)
	if exists {
		baseName, _, _ := strings.Cut(filepath.Base(name), ":")
		saveCommand := []string{"podman", "image", "save", "--quiet", "-o", path.Join(outputDir, baseName+".tar"), name}
		if dryRun {
			log.Info().Msgf(L("Would run %s"), strings.Join(saveCommand, " "))
		} else {
			log.Info().Msgf(L("Run %s"), strings.Join(saveCommand, " "))
			_, err := newRunner(saveCommand[0], saveCommand[1:]...).Exec()
			if err != nil {
				return utils.Errorf(err, L("Failed to export image %s"), name)
			}
		}
	}
	return nil
}

func imageExists(image string) bool {
	err := utils.RunCmd("podman", "image", "exists", image)
	return err == nil
}

func RestoreImage(imageFile string, dryRun bool) error {
	restoreCommand := []string{"podman", "image", "load", "--quiet", "-i", imageFile}
	if dryRun {
		log.Info().Msgf(L("Would run %s"), strings.Join(restoreCommand, " "))
	} else {
		log.Info().Msgf(L("Run %s"), strings.Join(restoreCommand, " "))
		_, err := newRunner(restoreCommand[0], restoreCommand[1:]...).Exec()
		if err != nil {
			return utils.Errorf(err, L("Failed to restore image %s"), imageFile)
		}
	}
	return nil
}
07070100000033000081a400000000000000000000000168ed21dd000010ba000000000000000000000000000000000000001d00000000shared/podman/images_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"errors"
	"testing"

	"github.com/rs/zerolog"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

func TestGetRpmImageName(t *testing.T) {
	data := [][]string{
		{
			"suse-multi-linux-manager-5.1-x86_64-server-postgresql",
			"latest",
			"registry.suse.com/suse/multi-linux-manager/5.1/x86_64/server-postgresql"},
		{
			"suse-multi-linux-manager-5.1-x86_64-server-postgresql",
			"latest",
			"registry.suse.com/suse/multi-linux-manager/5.1/x86_64/server-postgresql:latest"},
		{
			"suse-multi-linux-manager-5.1-x86_64-server-postgresql",
			"5.1.0",
			"http://registry.suse.com/suse/multi-linux-manager/5.1/x86_64/server-postgresql:5.1.0",
		},
		{
			"suse-multi-linux-manager-5.1-x86_64-server-postgresql",
			"5.1.0",
			"https://registry.suse.com/suse/multi-linux-manager/5.1/x86_64/server-postgresql:5.1.0",
		},
		{
			"suse-multi-linux-manager-5.1-x86_64-server-postgresql",
			"5.1.0",
			"docker://registry.suse.com/suse/multi-linux-manager/5.1/x86_64/server-postgresql:5.1.0",
		},
		{
			"suse-multi-linux-manager-5.1-x86_64-server-postgresql",
			"latest",
			"oci://registry.suse.com/suse/multi-linux-manager/5.1/x86_64/server-postgresql"},
	}

	for i, testCase := range data {
		rpmImage := testCase[0]
		tag := testCase[1]
		image := testCase[2]

		rpmImageResult, tagResult := GetRpmImageName(image)

		if rpmImage != rpmImageResult {
			t.Errorf("Testcase %d: Expected %s got %s when computing RPM for image %s", i, rpmImage, rpmImageResult, image)
		}
		if tag != tagResult {
			t.Errorf("Testcase %d: Expected %s got %s when computing RPM for image %s", i, tag, tagResult, image)
		}
	}
}

func TestMatchingMetadata(t *testing.T) {
	jsonData := []byte(`{
  "image": {
    "name": "multi-linux-manager-5.1-x86_64-server",
    "tags": [   "5.1.0-beta1",   "5.1.0-beta1.12.55",   "latest" ] ,
    "file": "multi-linux-manager-5.1-x86_64-server-5.1.0-beta1.x86_64-12.55.tar"
  }
}
`)

	data := [][]string{
		{
			"/usr/share/suse-docker-images/native/multi-linux-manager-5.1-x86_64-server-5.1.0-beta1.x86_64-12.55.tar",
			"multi-linux-manager-5.1-x86_64-server",
			"5.1.0-beta1.12.55",
		},
		{
			"/usr/share/suse-docker-images/native/multi-linux-manager-5.1-x86_64-server-5.1.0-beta1.x86_64-12.55.tar",
			"multi-linux-manager-5.1-x86_64-server",
			"latest",
		},
		{"", "multi-linux-manager-5.1-x86_64-server", "missing_tag"},
		{"", "missing_image", "missing_tag"},
		{"", "missing_image", "latest"},
	}

	for i, testCase := range data {
		expectedResult := testCase[0]
		rpmImage := testCase[1]
		tag := testCase[2]

		testResult, err := BuildRpmImagePath(jsonData, rpmImage, tag)

		if err != nil && expectedResult != testResult {
			t.Errorf(
				"Testcase %d: Expected %s got %s when computing RPM for image %s with tag %s",
				i, expectedResult, testResult, rpmImage, tag,
			)
		}
	}

	jsonDataInvalidWithTypo := []byte(`{
		"image: {
			"name": "suse-manager-5.0-x86_64-proxy-tftpd",
			"tags": ["latest", "5.0.0-beta1", "5.0.0-beta1.59.128"],
			"file": "suse-manager-5.0-x86_64-proxy-tftpd-latest.x86_64-59.128.tar"
		}
	}`)

	_, err := BuildRpmImagePath(jsonDataInvalidWithTypo, "", "")
	if err == nil {
		t.Error("typo in json: this should fail")
	}
}

func TestHasRemoteImage(t *testing.T) {
	type testData struct {
		out      string
		err      error
		expected bool
	}

	data := []testData{
		{
			`Error: 1 error occurred:
	* getting repository tags: fetching tags list: repository name not known to registry
`,
			errors.New("exit code 125"),
			false,
		},
		{
			`myregistry.org/path/image:1.2.2
myregistry.org/path/image:1.2.3
myregistry.org/path/image:1.2.3.4
myregistry.org/path/image:1.2
myregistry.org/path/image:latest`,
			nil,
			true,
		},
		{
			`myregistry.org/path/image:1.2.1
myregistry.org/path/image:1.2.1.2
myregistry.org/path/image:1.2
myregistry.org/path/image:latest`,
			nil,
			false,
		},
	}

	for _, test := range data {
		runCmdOutput = func(_ zerolog.Level, _ string, _ ...string) ([]byte, error) {
			return []byte(test.out), test.err
		}
		searchedImage := "myregistry.org/path/image:1.2.3"
		testutils.AssertEquals(t, "Unexpected result", test.expected, HasRemoteImage(searchedImage))
	}
}
07070100000034000081a400000000000000000000000168ed21dd00000b58000000000000000000000000000000000000001c00000000shared/podman/interfaces.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

const (
	// FragmentPath is a systemd property containing the path of the service file.
	FragmentPath = "FragmentPath"

	// DropInPaths is a systemd property containing the paths of the service configuration file separated by a space.
	DropInPaths = "DropInPaths"
)

// Systemd is an interface providing systemd operations.
type Systemd interface {

	// HasService returns if a systemd service is installed.
	// name is the name of the service without the '.service' part.
	HasService(name string) bool

	// ServiceIsEnabled returns if a service is enabled.
	// name is the name of the service without the '.service' part.
	ServiceIsEnabled(name string) bool

	// EnableService enables and starts a systemd service.
	EnableService(name string) error

	// DisableService disables a service.
	// name is the name of the service without the '.service' part.
	DisableService(name string) error

	// UninstallService stops and remove a systemd service.
	// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
	UninstallService(name string, dryRun bool)

	// ReloadDaemon resets the failed state of services and reload the systemd daemon.
	// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
	ReloadDaemon(dryRun bool) error

	// IsServiceRunning returns whether the systemd service is started or not.
	IsServiceRunning(name string) bool

	// RestartService restarts the systemd service.
	RestartService(name string) error

	// StartService starts the systemd service.
	StartService(name string) error

	// StopService starts the systemd service.
	StopService(name string) error

	// Scales a templated systemd service to the requested number of replicas.
	// name is the name of the service without the '.service' part.
	ScaleService(replicas int, name string) error

	// CurrentReplicaCount returns the current enabled replica count for a template service
	// name is the name of the service without the '.service' part.
	CurrentReplicaCount(name string) int

	// UninstallInstantiatedService stops and remove an instantiated systemd service.
	// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
	UninstallInstantiatedService(name string, dryRun bool)

	// StartInstantiated starts all replicas.
	StartInstantiated(name string) error

	// RestartInstantiated restarts all replicas.
	RestartInstantiated(name string) error

	// StopInstantiated stops all replicas.
	StopInstantiated(name string) error

	// GetServiceProperty returns the value of a systemd service property.
	GetServiceProperty(service string, property string) (string, error)

	// Show calls the systemctl show command and returns the output.
	Show(service string, property string) (string, error)
}
07070100000035000081a400000000000000000000000168ed21dd000006c3000000000000000000000000000000000000001700000000shared/podman/login.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"encoding/base64"
	"fmt"
	"os"

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

// PodmanLogin logs in the registry.suse.com registry if needed.
//
// It returns an authentication file, a cleanup function and an error.
func PodmanLogin(hostData *HostInspectData, scc types.SCCCredentials) (string, func(), error) {
	sccUser := hostData.SCCUsername
	sccPassword := hostData.SCCPassword
	if scc.User != "" && scc.Password != "" {
		log.Info().Msg(L("SCC credentials parameters will be used. SCC credentials from host will be ignored."))
		sccUser = scc.User
		sccPassword = scc.Password
	}
	if sccUser != "" && sccPassword != "" {
		// We have SCC credentials, so we are pretty likely to need registry.suse.com
		token := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", sccUser, sccPassword)))
		authFileContent := fmt.Sprintf(`{
	"auths": {
		"registry.suse.com" : {
			"auth": "%s"
		}
	}
}`, token)
		authFile, err := os.CreateTemp("", "mgradm-")
		if err != nil {
			return "", nil, err
		}
		authFilePath := authFile.Name()

		if _, err := authFile.Write([]byte(authFileContent)); err != nil {
			os.Remove(authFilePath)
			return "", nil, err
		}

		if err := authFile.Close(); err != nil {
			os.Remove(authFilePath)
			return "", nil, utils.Error(err, L("failed to close the temporary auth file"))
		}

		return authFilePath, func() {
			os.Remove(authFilePath)
		}, nil
	}

	noopCleaner := func() {
		// Nothing to clean
	}

	return "", noopCleaner, nil
}
07070100000036000081a400000000000000000000000168ed21dd000010e2000000000000000000000000000000000000001900000000shared/podman/network.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"os/exec"
	"strings"

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

// UyuniNetwork is the name of the podman network for Uyuni and its proxies.
const UyuniNetwork = "uyuni"

// HasIpv6Enabled returns whether a podman network has IPv6 enabled.
func HasIpv6Enabled(network string) bool {
	hasIpv6, err := utils.RunCmdOutput(zerolog.DebugLevel, "podman", "network", "inspect",
		"--format", "{{.IPv6Enabled}}", network)
	if err == nil && strings.TrimSpace(string(hasIpv6)) == "true" {
		return true
	}
	return false
}

// SetupNetwork creates the podman network.
func SetupNetwork(isProxy bool) error {
	log.Info().Msgf(L("Setting up %s network"), UyuniNetwork)

	ipv6Enabled := isIpv6Enabled()

	// check if network exists before trying to get the IPV6 information
	networkExists := IsNetworkPresent(UyuniNetwork)
	if networkExists {
		log.Debug().Msgf("%s network already present", UyuniNetwork)
		// Check if the uyuni network exists and is IPv6 enabled
		hasIpv6 := HasIpv6Enabled(UyuniNetwork)
		if !hasIpv6 && ipv6Enabled {
			log.Info().Msgf(L("%s network doesn't have IPv6, deleting existing network to enable IPv6 on it"), UyuniNetwork)
			err := utils.RunCmd("podman", "network", "rm", UyuniNetwork,
				"--log-level", log.Logger.GetLevel().String())
			if err != nil {
				return utils.Errorf(err, L("failed to remove %s podman network"), UyuniNetwork)
			}
		} else {
			log.Info().Msgf(L("Reusing existing %s network"), UyuniNetwork)
			return nil
		}
	}

	// We do not need inter-container resolution, disable dns plugin
	args := []string{"network", "create"}
	if isProxy {
		args = append(args, "--disable-dns")
	}
	if ipv6Enabled {
		// An IPv6 network on a host where IPv6 is disabled doesn't work: don't try it.
		// Check if the networkd backend is netavark
		out, err := utils.RunCmdOutput(zerolog.DebugLevel, "podman", "info", "--format", "{{.Host.NetworkBackend}}")
		backend := strings.Trim(string(out), "\n")
		if err != nil {
			return utils.Errorf(err, L("failed to find podman's network backend"))
		} else if backend != "netavark" {
			log.Info().Msgf(L("Podman's network backend (%[1]s) is not netavark, skipping IPv6 enabling on %[2]s network"),
				backend, UyuniNetwork)
		} else {
			args = append(args, "--ipv6")
		}
	}
	args = append(args, UyuniNetwork)
	err := utils.RunCmd("podman", args...)
	if err != nil {
		return utils.Errorf(err, L("failed to create %s network with IPv6 enabled"), UyuniNetwork)
	}
	return nil
}

func isIpv6Enabled() bool {
	files := []string{
		"/sys/module/ipv6/parameters/disable",
		"/proc/sys/net/ipv6/conf/default/disable_ipv6",
		"/proc/sys/net/ipv6/conf/all/disable_ipv6",
	}

	for _, file := range files {
		// Mind that we are checking disable files, the semantic is inverted
		if utils.GetFileBoolean(file) {
			log.Debug().Msgf("IPv6 is disabled")
			return false
		}
	}
	log.Debug().Msgf("IPv6 is enabled")
	return true
}

// DeleteNetwork deletes the uyuni podman network.
// If dryRun is set to true, nothing will be done, only messages logged to explain what would happen.
func DeleteNetwork(dryRun bool) {
	err := utils.RunCmd("podman", "network", "exists", UyuniNetwork)
	if err != nil {
		log.Info().Msgf(L("Network %s already removed"), UyuniNetwork)
	} else {
		if dryRun {
			log.Info().Msgf(L("Would run %s"), "podman network rm "+UyuniNetwork)
		} else {
			err := utils.RunCmd("podman", "network", "rm", UyuniNetwork)
			if err != nil {
				log.Error().Msgf(L("Failed to remove network %s"), UyuniNetwork)
			} else {
				log.Info().Msg(L("Network removed"))
			}
		}
	}
}

// IsNetworkPresent returns whether a network is already present.
func IsNetworkPresent(network string) bool {
	cmd := exec.Command("podman", "network", "exists", network)
	if err := cmd.Run(); err != nil {
		return false
	}
	return cmd.ProcessState.ExitCode() == 0
}

// IsSecretPresent returns true if podman secret is already present.
func IsSecretPresent(secret string) bool {
	cmd := exec.Command("podman", "secret", "exists", secret)
	if err := cmd.Run(); err != nil {
		return false
	}
	return cmd.ProcessState.ExitCode() == 0
}
07070100000037000081a400000000000000000000000168ed21dd000013aa000000000000000000000000000000000000001800000000shared/podman/secret.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"os"
	"path"
	"strings"

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

const (
	// DBUserSecret is the name of the podman secret containing the database username.
	DBUserSecret = "uyuni-db-user"
	// DBPassSecret is the name of the podman secret containing the database password.
	DBPassSecret = "uyuni-db-pass"
	// ReportDBUserSecret is the name of the podman secret containing the report database username.
	ReportDBUserSecret = "uyuni-reportdb-user"
	// ReportDBPassSecret is the name of the podman secret containing the report database password.
	ReportDBPassSecret = "uyuni-reportdb-pass"
	// DBUserSecret is the name of the podman secret containing the database admin username.
	DBAdminUserSecret = "uyuni-db-admin-user"
	// DBAdminPassSecret is the name of the podman secret containing the database admin password.
	DBAdminPassSecret = "uyuni-db-admin-pass"
	// CASecret is the name of the podman secret containing the CA certificate.
	CASecret = "uyuni-ca"
	// SSLCertSecret is the name of the podman secret containing the Apache certificate.
	SSLCertSecret = "uyuni-cert"
	// SSLKeySecret is the name of the podman secret containing the Apache SSL certificate key.
	SSLKeySecret = "uyuni-key"
	// DBCASecret is the name of the podman secret containing the Root CA certificate for the database.
	DBCASecret = "uyuni-db-ca"
	// DBSSLCertSecret is the name of the podman secret containing the report database certificate.
	DBSSLCertSecret = "uyuni-db-cert"
	// DBSSLKeySecret is the name of the podman secret containing the report database SSL certificate key.
	DBSSLKeySecret = "uyuni-db-key"
)

// CreateCredentialsSecrets creates the podman secrets, one for the user name and one for the password.
func CreateCredentialsSecrets(userSecret string, user string, passwordSecret string, password string) error {
	if err := CreateSecret(userSecret, user); err != nil {
		return err
	}
	return CreateSecret(passwordSecret, password)
}

// CreateCASecrets creates SSL CA.
func CreateCASecrets(
	caSecret string, caPath string,
) error {
	if err := createSecretFromFile(caSecret, caPath); err != nil {
		return utils.Errorf(err, L("failed to create %s secret"), CASecret)
	}
	return nil
}

// CreateTLSSecrets creates SSL CA, Certificate and key secrets.
func CreateTLSSecrets(
	caSecret string, caPath string,
	certSecret string, certPath string,
	keySecret string, keyPath string,
) error {
	if err := createSecretFromFile(certSecret, certPath); err != nil {
		return utils.Errorf(err, L("failed to create %s secret"), SSLCertSecret)
	}

	if err := createSecretFromFile(keySecret, keyPath); err != nil {
		return utils.Errorf(err, L("failed to create %s secret"), SSLKeySecret)
	}
	return CreateCASecrets(caSecret, caPath)
}

// createSecret creates a podman secret.
func CreateSecret(name string, value string) error {
	tmpDir, cleaner, err := utils.TempDir()
	if err != nil {
		return err
	}
	defer cleaner()

	secretFile := path.Join(tmpDir, "secret")
	if err := os.WriteFile(secretFile, []byte(value), 0600); err != nil {
		return utils.Errorf(err, L("failed to write %s secret to file"), name)
	}

	return createSecretFromFile(name, secretFile)
}

// createSecretFromFile creates a podman secret from a file.
// Removes any already existing secret with that name.
func createSecretFromFile(name string, secretFile string) error {
	if err := deleteSecret(name, false); err != nil {
		return err
	}

	runner := utils.NewRunner("podman", "secret", "create", name, secretFile).Log(zerolog.DebugLevel)
	if _, err := runner.Exec(); err != nil {
		return utils.Errorf(err, L("failed to create podman secret %s"), name)
	}

	return nil
}

// HasSecret returns whether the secret is defined or not.
func HasSecret(name string) bool {
	return utils.RunCmd("podman", "secret", "exists", name) == nil
}

// DeleteSecret removes a podman secret.
func DeleteSecret(name string, dryRun bool) {
	if err := deleteSecret(name, dryRun); err != nil {
		log.Error().Err(err).Send()
	}
}

func deleteSecret(name string, dryRun bool) error {
	if !HasSecret(name) {
		return nil
	}

	args := []string{"secret", "rm", name}
	command := "podman " + strings.Join(args, " ")
	if dryRun {
		log.Info().Msgf(L("Would run %s"), command)
	} else {
		runner := utils.NewRunner("podman", args...).Log(zerolog.DebugLevel)
		if _, err := runner.Exec(); err != nil {
			return utils.Errorf(err, L("Failed to delete %s secret"), name)
		}
	}
	return nil
}

// GetSecret gets the content of a podman secret given its name.
func GetSecret(name string) (string, error) {
	out, err := newRunner("podman", "secret", "inspect", "--showsecret", name, "--format", "{{.SecretData}}").
		Exec()
	if err != nil {
		return "", utils.Errorf(err, L("failed to get the content of the %s secret"), name)
	}
	return strings.TrimSpace(string(out)), nil
}
07070100000038000081a400000000000000000000000168ed21dd0000015a000000000000000000000000000000000000001900000000shared/podman/selinux.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import "github.com/rs/zerolog"

// IsSELinuxEnabled reports whether SELinux is enabled or not.
// It relies on selinuxenabled tool.
func IsSELinuxEnabled() bool {
	_, err := runCmdOutput(zerolog.DebugLevel, "selinuxenabled")
	return err == nil
}
07070100000039000081a400000000000000000000000168ed21dd000002db000000000000000000000000000000000000001e00000000shared/podman/selinux_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"errors"
	"fmt"
	"testing"

	"github.com/rs/zerolog"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

func TestIsSELinuxEnabled(t *testing.T) {
	type testType struct {
		err      error
		expected bool
	}

	cases := []testType{
		{nil, true},
		{errors.New("no such program selinuxenabled"), false},
	}

	for i, testCase := range cases {
		runCmdOutput = func(_ zerolog.Level, _ string, _ ...string) ([]byte, error) {
			return []byte(""), testCase.err
		}
		caseString := fmt.Sprintf("case %d: ", i)
		testutils.AssertEquals(t, caseString+"unexpected return value", testCase.expected, IsSELinuxEnabled())
	}
}
0707010000003a000081a400000000000000000000000168ed21dd00001b3f000000000000000000000000000000000000001900000000shared/podman/support.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"os"
	"path"
	"strings"

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

// RunSupportConfigOnPodmanHost will run supportconfig command on podman machine.
func RunSupportConfigOnPodmanHost(systemd Systemd, dir string) ([]string, error) {
	files, err := utils.RunSupportConfigOnHost()
	if err != nil {
		return files, err
	}
	logDump, err := createLogDump(dir)
	if err != nil {
		log.Warn().Msg(L("No logs file on host to add to the archive"))
	} else {
		files = append(files, logDump)
	}

	systemdDump, err := createSystemdDump(dir)
	if err != nil {
		log.Warn().Msg(L("No systemd file to add to the archive"))
	} else {
		files = append(files, systemdDump)
	}

	containerList, err := hostedContainers(systemd)
	if err != nil {
		return files, err
	}
	if len(containerList) > 0 {
		for _, container := range containerList {
			inspectDump, err := runPodmanInspectCommand(dir, container)
			if err != nil {
				log.Warn().Err(err).Msgf(L("failed to run podman inspect %s"), container)
			}
			files = append(files, inspectDump)

			boundFilesDump, err := fetchBoundFileCommand(dir, container)
			if err != nil {
				log.Warn().Err(err).Msgf(L("failed to fetch the config files bound to container %s"), container)
			}
			files = append(files, boundFilesDump)

			logsDump, err := runJournalCtlCommand(dir, container)
			if err != nil {
				log.Warn().Err(err).Msgf(L("failed to run podman logs %s"), container)
			}
			files = append(files, logsDump)
		}
	}

	return files, nil
}

func createLogDump(dir string) (string, error) {
	logConfig, err := os.Create(path.Join(dir, "logs"))
	if err != nil {
		return "", utils.Errorf(err, L("failed to create %s file"), logConfig.Name())
	}

	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "cat", utils.GlobalLogPath)
	if err != nil {
		return "", utils.Errorf(err, L("failed to cat %s"), utils.GlobalLogPath)
	}
	defer logConfig.Close()

	_, err = logConfig.WriteString("====cat " + utils.GlobalLogPath + "====\n" + string(out))
	if err != nil {
		return "", err
	}

	return logConfig.Name(), nil
}

func createSystemdDump(dir string) (string, error) {
	systemdSupportConfig, err := os.Create(path.Join(dir, "systemd-conf"))
	if err != nil {
		return "", utils.Errorf(err, L("failed to create %s file"), systemdSupportConfig.Name())
	}

	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "systemctl", "cat", "uyuni-*")
	if err != nil {
		return "", utils.Errorf(err, L("failed to run systemctl cat uyuni-*"))
	}
	defer systemdSupportConfig.Close()

	_, err = systemdSupportConfig.WriteString("====systemctl cat uyuni-*====\n" + string(out))
	if err != nil {
		return "", err
	}

	return systemdSupportConfig.Name(), nil
}

func runPodmanInspectCommand(dir string, container string) (string, error) {
	podmanInspectDump, err := os.Create(path.Join(dir, "inspect-"+container))
	defer func() {
		if err := podmanInspectDump.Close(); err != nil {
			log.Error().Err(err).Msg(L("failed to close inspect dump file"))
		}
	}()
	if err != nil {
		return "", utils.Errorf(err, L("failed to create %s file"), podmanInspectDump)
	}

	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "podman", "inspect", container)
	if err != nil {
		return "", utils.Errorf(err, L("failed to run podman inspect %s"), container)
	}

	_, err = podmanInspectDump.WriteString("====podman inspect " + container + "====\n" + string(out))
	if err != nil {
		return "", err
	}
	return podmanInspectDump.Name(), nil
}

func fetchBoundFileCommand(dir string, container string) (string, error) {
	boundFilesDump, err := os.Create(path.Join(dir, "bound-files-"+container))
	defer func() {
		if err := boundFilesDump.Close(); err != nil {
			log.Error().Err(err).Msg(L("failed to close bound files"))
		}
	}()
	if err != nil {
		return "", utils.Errorf(err, L("failed to create %s file"), boundFilesDump)
	}

	_, err = boundFilesDump.WriteString("====bound files====" + "\n")
	if err != nil {
		return "", err
	}
	out, err := utils.RunCmdOutput(
		zerolog.DebugLevel, "podman", "inspect", container,
		"--format", "{{range .Mounts}}{{if eq .Type \"bind\"}} {{.Source}}{{end}}{{end}}",
	)
	if err != nil {
		return "", utils.Errorf(err, L("failed to run podman inspect %s"), container)
	}
	boundFiles := strings.Split(string(out), " ")

	for _, boundFile := range boundFiles {
		boundFile = strings.TrimSpace(boundFile)
		if len(boundFile) <= 0 {
			continue
		}
		if stat, err := os.Stat(boundFile); err == nil && stat.Mode().IsRegular() {
			_, err = boundFilesDump.WriteString("====" + boundFile + "====" + "\n")
			if allErrors := utils.JoinErrors(err, utils.CopyFile(boundFile, boundFilesDump)); allErrors != nil {
				return "", allErrors
			}
		}
	}
	return boundFilesDump.Name(), nil
}

func runJournalCtlCommand(dir string, container string) (string, error) {
	journalctlDump, err := os.Create(path.Join(dir, "journalctl-"+container))
	if err != nil {
		return "", utils.Errorf(err, L("failed create %s file"), journalctlDump)
	}

	out, err := utils.RunCmdOutput(zerolog.DebugLevel, "journalctl", "-u", container)
	if err != nil {
		return "", utils.Errorf(err, L("failed to run journalctl -u %s"), container)
	}

	_, err = journalctlDump.WriteString("====journalctl====\n" + string(out))
	if err != nil {
		return "", err
	}
	return journalctlDump.Name(), nil
}

func getSystemdFileList() ([]byte, error) {
	return utils.RunCmdOutput(
		zerolog.DebugLevel, "find", "/etc/systemd/system", "-maxdepth", "1", "-name", "uyuni-*service",
	)
}

func hostedContainers(systemd Systemd) ([]string, error) {
	systemdFiles, err := getSystemdFileList()
	if err != nil {
		return []string{}, err
	}
	servicesList := getServicesFromSystemdFiles(systemd, string(systemdFiles))

	var containerList []string

	for _, service := range servicesList {
		service = strings.Replace(service, ".service", "", -1)
		// we can collect container data only from the first instance
		// and assume there's no difference with other intances
		containerList = append(containerList, strings.Replace(service, "@", "-0", -1))
	}

	return containerList, nil
}

// getServicesFromSystemdFiles return the uyuni enabled services as string list.
func getServicesFromSystemdFiles(systemd Systemd, systemdFileList string) []string {
	services := strings.Replace(string(systemdFileList), "/etc/systemd/system/", "", -1)
	services = strings.Replace(services, ".service", "", -1)
	servicesList := strings.Split(strings.TrimSpace(services), "\n")

	var trimmedServices []string
	for _, service := range servicesList {
		if systemd.ServiceIsEnabled(service) {
			trimmedServices = append(trimmedServices, strings.TrimSpace(service))
		} else {
			log.Debug().Msgf("service %s is not enabled. Do not run any action on the container.", service)
		}
	}
	return trimmedServices
}
0707010000003b000081a400000000000000000000000168ed21dd00004255000000000000000000000000000000000000001900000000shared/podman/systemd.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

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

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

var servicesPath = "/etc/systemd/system/"

// ServerService is the name of the systemd service for the server.
const ServerService = "uyuni-server"

// DBService is the name of the systemd service for the database container.
const DBService = "uyuni-db"

// ServerAttestationService is the name of the systemd service for the coco attestation container.
const ServerAttestationService = "uyuni-server-attestation"

// HubXmlrpcService is the name of the systemd service for the Hub XMLRPC container.
const HubXmlrpcService = "uyuni-hub-xmlrpc"

// SalineService is the name of the systemd service for the saline container.
const SalineService = "uyuni-saline"

// ProxyService is the name of the systemd service for the proxy.
const ProxyService = "uyuni-proxy-pod"

// CustomConf is the name of the custom configuration file of services.
const CustomConf = "custom.conf"

// Interface to perform systemd calls.
// This is not meant to be used elsewhere than in the SystemdImpl class and the unit tests.
type SystemdDriver interface {
	// HasService returns if a systemd service is installed.
	// name is the name of the service without the '.service' part.
	HasService(name string) bool

	// ServiceIsEnabled returns if a service is enabled
	// name is the name of the service without the '.service' part.
	ServiceIsEnabled(name string) bool

	// DisableService disables a service
	// name is the name of the service without the '.service' part.
	DisableService(name string) error

	// ReloadDaemon resets the failed state of services and reload the systemd daemon.
	// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
	ReloadDaemon() error

	// IsServiceRunning returns whether the systemd service is started or not.
	IsServiceRunning(service string) bool

	// RestartService restarts the systemd service.
	RestartService(service string) error

	// StartService starts the systemd service.
	StartService(service string) error

	// StopService starts the systemd service.
	StopService(service string) error

	// EnableService enables and starts a systemd service.
	EnableService(service string) error

	// GetServiceProperty returns the value of a systemd service property.
	GetServiceProperty(service string, property string) (string, error)
}

type systemdDriverImpl struct {
}

// HasService returns if a systemd service is installed.
// name is the name of the service without the '.service' part.
func (d *systemdDriverImpl) HasService(name string) bool {
	err := utils.RunCmd("systemctl", "list-unit-files", name+".service")
	return err == nil
}

// ServiceIsEnabled returns if a service is enabled
// name is the name of the service without the '.service' part.
func (d *systemdDriverImpl) ServiceIsEnabled(name string) bool {
	err := utils.RunCmd("systemctl", "is-enabled", name+".service")
	return err == nil
}

// DisableService disables a service
// name is the name of the service without the '.service' part.
func (d *systemdDriverImpl) DisableService(name string) error {
	if !d.ServiceIsEnabled(name) {
		log.Debug().Msgf("%s is already disabled.", name)
		return nil
	}
	if err := utils.RunCmd("systemctl", "disable", "--now", name); err != nil {
		return utils.Errorf(err, L("failed to disable %s systemd service"), name)
	}
	return nil
}

// ReloadDaemon resets the failed state of services and reload the systemd daemon.
// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
func (d *systemdDriverImpl) ReloadDaemon() error {
	err := utils.RunCmd("systemctl", "reset-failed")
	if err != nil {
		return errors.New(L("failed to reset-failed systemd"))
	}
	err = utils.RunCmd("systemctl", "daemon-reload")
	if err != nil {
		return errors.New(L("failed to reload systemd daemon"))
	}
	return nil
}

// IsServiceRunning returns whether the systemd service is started or not.
func (d *systemdDriverImpl) IsServiceRunning(service string) bool {
	cmd := exec.Command("systemctl", "is-active", "-q", service)
	if err := cmd.Run(); err != nil {
		return false
	}
	return cmd.ProcessState.ExitCode() == 0
}

// RestartService restarts the systemd service.
func (d *systemdDriverImpl) RestartService(service string) error {
	if err := utils.RunCmd("systemctl", "restart", service); err != nil {
		return utils.Errorf(err, L("failed to restart systemd %s.service"), service)
	}
	return nil
}

// StartService starts the systemd service.
func (d *systemdDriverImpl) StartService(service string) error {
	if err := utils.RunCmd("systemctl", "start", service); err != nil {
		return utils.Errorf(err, L("failed to start systemd %s.service"), service)
	}
	return nil
}

// StopService starts the systemd service.
func (d *systemdDriverImpl) StopService(service string) error {
	if err := utils.RunCmd("systemctl", "stop", service); err != nil {
		return utils.Errorf(err, L("failed to stop systemd %s.service"), service)
	}
	return nil
}

// EnableService enables and starts a systemd service.
func (d *systemdDriverImpl) EnableService(service string) error {
	if d.ServiceIsEnabled(service) {
		log.Debug().Msgf("%s is already enabled.", service)
		return nil
	}
	if err := utils.RunCmd("systemctl", "enable", "--now", service); err != nil {
		return utils.Errorf(err, L("failed to enable %s systemd service"), service)
	}
	return nil
}

func (d *systemdDriverImpl) GetServiceProperty(service string, property string) (string, error) {
	serviceName := service
	if strings.HasSuffix(service, "@") {
		serviceName = service + "0"
	}
	out, err := newRunner("systemctl", "show", "-p", property, serviceName).Exec()
	if err != nil {
		return "", utils.Errorf(err, L("Failed to get the %[1]s property from %[2]s service"), property, service)
	}
	return strings.TrimPrefix(strings.TrimSpace(string(out)), property+"="), nil
}

// NewSystemd returns a new Systemd instance.
func NewSystemd() Systemd {
	driver := systemdDriverImpl{}
	return SystemdImpl{
		driver: &driver,
	}
}

// NewSystemdWithDriver returns a new Systemd instance with a custom driver.
func NewSystemdWithDriver(driver SystemdDriver) Systemd {
	return SystemdImpl{
		driver: driver,
	}
}

// SystemdImpl implements the Systemd interface.
type SystemdImpl struct {
	// driver actually calls the systemd executable.
	driver SystemdDriver
}

// HasService returns if a systemd service is installed.
// name is the name of the service without the '.service' part.
func (s SystemdImpl) HasService(name string) bool {
	return s.driver.HasService(name)
}

// ServiceIsEnabled returns if a service is enabled
// name is the name of the service without the '.service' part.
func (s SystemdImpl) ServiceIsEnabled(name string) bool {
	return s.driver.ServiceIsEnabled(name)
}

// DisableService disables a service
// name is the name of the service without the '.service' part.
func (s SystemdImpl) DisableService(name string) error {
	return s.driver.DisableService(name)
}

// GetServicePath return the path for a given service.
func GetServicePath(name string) string {
	return path.Join(servicesPath, name+".service")
}

func (s SystemdImpl) GetServiceProperty(service string, property string) (string, error) {
	return s.driver.GetServiceProperty(service, property)
}

// GetServiceConfFolder return the conf folder for systemd services.
func GetServiceConfFolder(name string) string {
	return path.Join(servicesPath, name+".service.d")
}

// GetServiceConfPath return the path for generated.conf file.
func GetServiceConfPath(name string) string {
	return path.Join(GetServiceConfFolder(name), "generated.conf")
}

// UninstallService stops and remove a systemd service.
// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
func (s SystemdImpl) UninstallService(name string, dryRun bool) {
	if !s.HasService(name) {
		log.Info().Msgf(L("Systemd has no %s.service unit"), name)
	} else {
		if dryRun {
			log.Info().Msgf(L("Would run %s"), "systemctl disable --now "+name)
		} else {
			log.Info().Msgf(L("Disable %s service"), name)
			err := s.DisableService(name)
			if err != nil {
				log.Error().Err(err).Send()
			}
		}
		uninstallServiceFiles(name, dryRun)
	}
}

func uninstallServiceFiles(name string, dryRun bool) {
	servicePath := GetServicePath(name)
	serviceConfFolder := GetServiceConfFolder(name)

	if dryRun {
		log.Info().Msgf(L("Would remove %s"), servicePath)
	} else {
		// Remove the service unit
		log.Info().Msgf(L("Remove %s"), servicePath)
		if err := os.Remove(servicePath); err != nil {
			log.Error().Err(err).Msgf(L("Failed to remove %s.service file"), name)
		}
	}

	if utils.FileExists(serviceConfFolder) {
		confPaths := []string{
			GetServiceConfPath(name),
			path.Join(serviceConfFolder, "Service.conf"),
		}
		for _, confPath := range confPaths {
			if utils.FileExists(confPath) {
				if dryRun {
					log.Info().Msgf(L("Would remove %s"), confPath)
				} else {
					log.Info().Msgf(L("Remove %s"), confPath)
					if err := os.Remove(confPath); err != nil {
						log.Error().Err(err).Msgf(L("Failed to remove %s file"), confPath)
					}
				}
			}
		}

		if dryRun {
			log.Info().Msgf(L("Would remove %s if empty"), serviceConfFolder)
		} else {
			if utils.IsEmptyDirectory(serviceConfFolder) {
				log.Debug().Msgf("Removing %s folder, since it's empty", serviceConfFolder)
				_ = utils.RemoveDirectory(serviceConfFolder)
			} else {
				log.Warn().Msgf(
					L("%s folder contains file created by the user. Please remove them when uninstallation is completed."),
					serviceConfFolder,
				)
			}
		}
	}
}

// UninstallInstantiatedService stops and remove an instantiated systemd service.
// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
func (s SystemdImpl) UninstallInstantiatedService(name string, dryRun bool) {
	if dryRun {
		log.Info().Msgf(L("Would scale %s to 0 replicas"), name)
	} else {
		if err := s.ScaleService(0, name); err != nil {
			log.Error().Err(err).Msgf(L("Failed to disable %s service"), name)
		}
	}

	uninstallServiceFiles(name+"@", dryRun)
}

// ReloadDaemon resets the failed state of services and reload the systemd daemon.
// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
func (s SystemdImpl) ReloadDaemon(dryRun bool) error {
	if dryRun {
		log.Info().Msgf(L("Would run %s"), "systemctl reset-failed")
		log.Info().Msgf(L("Would run %s"), "systemctl daemon-reload")
	} else {
		return s.driver.ReloadDaemon()
	}
	return nil
}

// IsServiceRunning returns whether the systemd service is started or not.
func (s SystemdImpl) IsServiceRunning(service string) bool {
	return s.driver.IsServiceRunning(service)
}

// RestartService restarts the systemd service.
func (s SystemdImpl) RestartService(service string) error {
	return s.driver.RestartService(service)
}

// StartService starts the systemd service.
func (s SystemdImpl) StartService(service string) error {
	return s.driver.StartService(service)
}

// StopService starts the systemd service.
func (s SystemdImpl) StopService(service string) error {
	return s.driver.StopService(service)
}

// EnableService enables and starts a systemd service.
func (s SystemdImpl) EnableService(service string) error {
	return s.driver.EnableService(service)
}

// StartInstantiated starts all replicas.
func (s SystemdImpl) StartInstantiated(service string) error {
	var errList []error
	for i := 0; i < s.CurrentReplicaCount(service); i++ {
		err := s.StartService(fmt.Sprintf("%s@%d", service, i))
		errList = append(errList, err)
	}
	return utils.JoinErrors(errList...)
}

// RestartInstantiated restarts all replicas.
func (s SystemdImpl) RestartInstantiated(service string) error {
	var errList []error
	for i := 0; i < s.CurrentReplicaCount(service); i++ {
		err := s.RestartService(fmt.Sprintf("%s@%d", service, i))
		errList = append(errList, err)
	}
	return utils.JoinErrors(errList...)
}

// StopInstantiated stops all replicas.
func (s SystemdImpl) StopInstantiated(service string) error {
	var errList []error
	for i := 0; i < s.CurrentReplicaCount(service); i++ {
		err := s.StopService(fmt.Sprintf("%s@%d", service, i))
		errList = append(errList, err)
	}
	return utils.JoinErrors(errList...)
}

// CurrentReplicaCount returns the current enabled replica count for a template service
// name is the name of the service without the '.service' part.
func (s SystemdImpl) CurrentReplicaCount(name string) int {
	count := 0
	for s.ServiceIsEnabled(fmt.Sprintf("%s@%d", name, count)) {
		count++
	}
	return count
}

// ScaleService scales a templated systemd service to the requested number of replicas.
// name is the name of the service without the '.service' part.
func (s SystemdImpl) ScaleService(replicas int, name string) error {
	currentReplicas := s.CurrentReplicaCount(name)
	if currentReplicas == replicas {
		log.Info().Msgf(L("Service %[1]s already has %[2]d replicas."), name, currentReplicas)
		return nil
	}
	log.Info().Msgf(L("Scale %[1]s from %[2]d to %[3]d replicas."), name, currentReplicas, replicas)
	for i := currentReplicas; i < replicas; i++ {
		serviceName := fmt.Sprintf("%s@%d", name, i)
		if err := s.EnableService(serviceName); err != nil {
			return utils.Errorf(err, L("cannot enable service"))
		}
	}
	for i := replicas; i < currentReplicas; i++ {
		serviceName := fmt.Sprintf("%s@%d", name, i)
		if err := s.DisableService(serviceName); err != nil {
			return utils.Errorf(err, L("cannot disable service"))
		}
	}
	return s.RestartInstantiated(name)
}

// Show calls the systemctl show command and returns the output.
func (s SystemdImpl) Show(service string, property string) (string, error) {
	out, err := newRunner("systemctl", "show", "--property", property, service).Log(zerolog.DebugLevel).Exec()
	if err != nil {
		return "", utils.Errorf(err, L("failed to show %[1]s property of %[2] systemd service"), property, service)
	}
	return strings.TrimSpace(string(out)), nil
}

// confHeader is the header for the generated systemd configuration files.
const confHeader = `# This file is generated by mgradm and will be overwritten during upgrades.
# Custom configuration should go in another .conf file in the same folder.

`

// GenerateSystemdConfFile creates a new systemd service configuration file (e.g. Service.conf).
func GenerateSystemdConfFile(serviceName string, filename string, body string, withHeader bool) error {
	systemdFilePath := GetServicePath(serviceName)

	systemdConfFolder := systemdFilePath + ".d"
	if err := os.MkdirAll(systemdConfFolder, 0750); err != nil {
		return utils.Errorf(err, L("failed to create %s folder"), systemdConfFolder)
	}
	systemdConfFilePath := path.Join(systemdConfFolder, filename)

	header := ""
	if withHeader {
		header = confHeader
	}
	content := []byte(fmt.Sprintf("%s[Service]\n%s\n", header, body))
	if err := os.WriteFile(systemdConfFilePath, content, 0640); err != nil {
		return utils.Errorf(err, L("cannot write %s file"), systemdConfFilePath)
	}

	return nil
}

// CleanSystemdConfFile separates the Service.conf file once generated into generated.conf and custom.conf.
func CleanSystemdConfFile(serviceName string) error {
	systemdFilePath := GetServicePath(serviceName) + ".d"
	oldConfPath := path.Join(systemdFilePath, "Service.conf")

	// The first containerized release generated a Service.conf where the image and the configuration
	// where stored. This had the side effect to remove the conf at upgrade time.
	// If this file exists split it in two:
	// - generated.conf with the image
	// - custom.conf with everything that shouldn't be touched at upgrade
	if utils.FileExists(oldConfPath) {
		content := string(utils.ReadFile(oldConfPath))
		lines := strings.Split(content, "\n")

		generated := ""
		custom := ""
		hasCustom := false

		for _, line := range lines {
			trimmedLine := strings.TrimSpace(line)
			if strings.HasPrefix(trimmedLine, "Environment=UYUNI_IMAGE=") {
				generated = generated + trimmedLine
			} else {
				custom = custom + trimmedLine + "\n"
				if trimmedLine != "" && trimmedLine != "[Service]" {
					hasCustom = true
				}
			}
		}

		if generated != "" {
			if err := GenerateSystemdConfFile(serviceName, "generated.conf", generated, true); err != nil {
				return err
			}
		}

		if hasCustom {
			customPath := path.Join(systemdFilePath, CustomConf)
			if err := os.WriteFile(customPath, []byte(custom), 0644); err != nil {
				return utils.Errorf(err, L("failed to write %s file"), customPath)
			}
		}

		if err := os.Remove(oldConfPath); err != nil {
			return utils.Errorf(err, L("failed to remove old %s systemd service configuration file"), oldConfPath)
		}
	}

	return nil
}
0707010000003c000081a400000000000000000000000168ed21dd00000da4000000000000000000000000000000000000001e00000000shared/podman/systemd_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"errors"
	"os"
	"path"
	"strings"
	"testing"

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

func TestCleanSystemdConfFile(t *testing.T) {
	currentFile := `[Service]
# Some comment
Environment=TZ=Europe/Berlin
Environment="PODMAN_EXTRA_ARGS="
Environment=UYUNI_IMAGE=path/to/image
`

	generatedFile := confHeader + `[Service]
Environment=UYUNI_IMAGE=path/to/image
`

	customFile := `[Service]
# Some comment
Environment=TZ=Europe/Berlin
Environment="PODMAN_EXTRA_ARGS="

`

	testDir := t.TempDir()

	serviceConfDir := path.Join(testDir, "uyuni-server.service.d")
	if err := os.Mkdir(serviceConfDir, 0750); err != nil {
		t.Fatalf("failed to create fake service configuration directory: %s", err)
	}

	servicesPath = testDir

	testutils.WriteFile(t, path.Join(serviceConfDir, "Service.conf"), currentFile)

	if err := CleanSystemdConfFile("uyuni-server"); err != nil {
		t.Errorf("unexpected error: %s", err)
	}

	actual := testutils.ReadFile(t, path.Join(serviceConfDir, "generated.conf"))
	testutils.AssertEquals(t, "invalid generated.conf file", generatedFile, actual)

	actual = testutils.ReadFile(t, path.Join(serviceConfDir, CustomConf))
	testutils.AssertEquals(t, "invalid custom.conf file", customFile, actual)

	if utils.FileExists(path.Join(serviceConfDir, "Service.conf")) {
		t.Error("the old Service.conf file is not removed")
	}
}

func TestCleanSystemdConfFileNoop(t *testing.T) {
	generatedFile := confHeader + `[Service]
Environment=UYUNI_IMAGE=path/to/image
`

	customFile := `[Service]
# Some comment
Environment=TZ=Europe/Berlin
Environment="PODMAN_EXTRA_ARGS="
`

	testDir := t.TempDir()

	serviceConfDir := path.Join(testDir, "uyuni-server.service.d")
	if err := os.Mkdir(serviceConfDir, 0750); err != nil {
		t.Fatalf("failed to create fake service configuration directory: %s", err)
	}

	servicesPath = testDir

	testutils.WriteFile(t, path.Join(serviceConfDir, "generated.conf"), generatedFile)
	testutils.WriteFile(t, path.Join(serviceConfDir, CustomConf), customFile)

	if err := CleanSystemdConfFile("uyuni-server"); err != nil {
		t.Errorf("unexpected error: %s", err)
	}

	actual := testutils.ReadFile(t, path.Join(serviceConfDir, "generated.conf"))
	testutils.AssertEquals(t, "invalid generated.conf file", generatedFile, actual)

	actual = testutils.ReadFile(t, path.Join(serviceConfDir, CustomConf))
	testutils.AssertEquals(t, "invalid custom.conf file", customFile, actual)
}

func TestGetServiceProperty(t *testing.T) {
	newRunner = testutils.FakeRunnerGenerator("TestProperty=foo bar\n", nil)
	tested := NewSystemd()
	actual, err := tested.GetServiceProperty("myservice", "TestProperty")
	testutils.AssertTrue(t, "No error expected", err == nil)
	testutils.AssertEquals(t, "Wrong expected property", "foo bar", actual)
}

func TestGetServicePropertyError(t *testing.T) {
	newRunner = testutils.FakeRunnerGenerator("", errors.New("Test error"))
	tested := NewSystemd()
	actual, err := tested.GetServiceProperty("myservice", "TestProperty")
	testutils.AssertTrue(t, "Error message missing the root error message", strings.Contains(err.Error(), "Test error"))
	testutils.AssertTrue(t, "Unexpected error description",
		strings.Contains(err.Error(), "Failed to get the TestProperty property from myservice service"))
	testutils.AssertEquals(t, "Wrong expected property", "", actual)
}
0707010000003d000081a400000000000000000000000168ed21dd000030c0000000000000000000000000000000000000001700000000shared/podman/utils.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"errors"
	"io"
	"os"
	"os/exec"
	"path"
	"regexp"
	"strconv"
	"strings"

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

// runCmd* are a function pointers to use for easies unit testing.
var runCmdOutput = utils.RunCmdOutput
var runCmd = utils.RunCmd

var runner = utils.NewRunner

const commonArgs = "--rm --cap-add NET_RAW --tmpfs /run -v cgroup:/sys/fs/cgroup:rw"

// ServerContainerName represents the server container name.
const ServerContainerName = "uyuni-server"

// HubXmlrpcContainerName is the container name for the Hub XML-RPC API.
const HubXmlrpcContainerName = "uyuni-hub-xmlrpc"

// DBContainerName represents the database container name.
const DBContainerName = "uyuni-db"

// ProxyContainerNames represents all the proxy container names.
var ProxyContainerNames = []string{
	"uyuni-proxy-httpd",
	"uyuni-proxy-salt-broker",
	"uyuni-proxy-squid",
	"uyuni-proxy-ssh",
	"uyuni-proxy-tftpd",
}

// PodmanFlags stores the podman arguments.
type PodmanFlags struct {
	Args []string `mapstructure:"arg"`
}

// GetCommonParams splits the common arguments.
func GetCommonParams() []string {
	return strings.Split(commonArgs, " ")
}

// AddPodmanArgFlag add the podman arguments to a command.
func AddPodmanArgFlag(cmd *cobra.Command) {
	cmd.Flags().StringSlice("podman-arg", []string{}, L("Extra arguments to pass to podman"))
}

// EnablePodmanSocket enables the podman socket.
func EnablePodmanSocket() error {
	err := utils.RunCmd("systemctl", "enable", "--now", "podman.socket")
	if err != nil {
		return utils.Errorf(err, L("failed to enable podman.socket unit"))
	}
	return err
}

// ReadFromContainer read a file from a container.
func ReadFromContainer(name string, image string, volumes []types.VolumeMount,
	extraArgs []string, file string) ([]byte, error) {
	podmanArgs := append([]string{"run", "--name", name}, GetCommonParams()...)
	podmanArgs = append(podmanArgs, extraArgs...)
	for _, volume := range volumes {
		if IsVolumePresent(volume.Name) {
			podmanArgs = append(podmanArgs, "-v", volume.Name+":"+volume.MountPath)
		}
	}
	podmanArgs = append(podmanArgs, "--network", UyuniNetwork)
	podmanArgs = append(podmanArgs, image)
	podmanArgs = append(podmanArgs, []string{"cat", file}...)

	out, err := runner("podman", podmanArgs...).Spinner("").Exec()
	if err != nil {
		return []byte{}, utils.Errorf(err, L("failed to run %s container"), name)
	}

	return out, nil
}

// PrepareContainerRunArgs computes the common podman arguments to run a container.
func PrepareContainerRunArgs(
	name string, image string, volumes []types.VolumeMount, extraArgs []string, cmd []string,
) []string {
	podmanArgs := append([]string{"run", "--name", name}, GetCommonParams()...)
	podmanArgs = append(podmanArgs, extraArgs...)
	podmanArgs = append(podmanArgs, "--shm-size=0")
	podmanArgs = append(podmanArgs, "--shm-size-systemd=0")
	for _, volume := range volumes {
		podmanArgs = append(podmanArgs, "-v", volume.Name+":"+volume.MountPath)
	}
	podmanArgs = append(podmanArgs, "--network", UyuniNetwork)
	podmanArgs = append(podmanArgs, image)
	podmanArgs = append(podmanArgs, cmd...)

	return podmanArgs
}

// RunContainer execute a container.
func RunContainer(name string, image string, volumes []types.VolumeMount, extraArgs []string, cmd []string) error {
	podmanArgs := PrepareContainerRunArgs(name, image, volumes, extraArgs, cmd)
	err := utils.RunCmdStdMapping(zerolog.DebugLevel, "podman", podmanArgs...)
	if err != nil {
		return utils.Errorf(err, L("failed to run %s container"), name)
	}

	return nil
}

// DeleteContainer deletes a container based on its name.
// If dryRun is set to true, nothing will be done, only messages logged to explain what would happen.
func DeleteContainer(name string, dryRun bool) {
	if out, _ := utils.RunCmdOutput(zerolog.DebugLevel, "podman", "ps", "-a", "-q", "-f", "name="+name); len(out) > 0 {
		if dryRun {
			log.Info().Msgf(L("Would run podman kill %[1]s for container id %[2]s"), name, out)
			log.Info().Msgf(L("Would run podman remove %[1]s for container id %[2]s"), name, out)
		} else {
			log.Info().Msgf(L("Run podman kill %[1]s for container id %[2]s"), name, out)
			err := utils.RunCmd("podman", "kill", name)
			if err != nil {
				log.Error().Err(err).Msg(L("Failed to kill the server"))

				log.Info().Msgf(L("Run podman remove %[1]s for container id %[2]s"), name, out)
				err = utils.RunCmd("podman", "rm", name)
				if err != nil {
					log.Error().Err(err).Msg(L("Error removing container"))
				}
			}
		}
	} else {
		log.Info().Msg(L("Container already removed"))
	}
}

// GetServiceImage returns the value of the UYUNI_IMAGE variable for a systemd service.
func GetServiceImage(service string) string {
	out, err := runCmdOutput(zerolog.DebugLevel, "systemctl", "cat", service)
	if err != nil {
		log.Warn().Err(err).Msgf(L("failed to get %s systemd service definition"), service)
		return ""
	}

	imageFinder := regexp.MustCompile(`UYUNI.*_IMAGE=(.*)`)
	matches := imageFinder.FindStringSubmatch(string(out))
	if len(matches) < 2 {
		log.Warn().Msgf(L("no UYUNI.*_IMAGE defined in %s systemd service"), service)
		return ""
	}
	return matches[1]
}

// GetImageVirtualSize returns the size of the image with its layers.
func GetImageVirtualSize(name string) (size int64, err error) {
	out, err := utils.NewRunner("podman", "inspect", "--format", "{{.VirtualSize}}", name).
		Log(zerolog.DebugLevel).
		Exec()
	if err != nil {
		return
	}
	sizeStr := strings.TrimSpace(string(out))
	size, err = strconv.ParseInt(sizeStr, 10, 64)
	return
}

// DeleteVolume deletes a podman volume based on its name.
// If dryRun is set to true, nothing will be done, only messages logged to explain what would happen.
func DeleteVolume(name string, dryRun bool) error {
	exists := IsVolumePresent(name)
	if exists {
		if dryRun {
			log.Info().Msgf(L("Would run %s"), "podman volume rm "+name)
		} else {
			log.Info().Msgf(L("Run %s"), "podman volume rm "+name)
			if err := utils.RunCmd("podman", "volume", "rm", name); err != nil {
				log.Trace().Err(err).Msgf("podman volume rm %s", name)
				// Check if the volume is not mounted - for example var-pgsql - as second storage device
				// We need to compute volume path ourselves because above `podman volume rm` call may have
				// already removed volume from podman internal structures
				basePath, errBasePath := GetPodmanVolumeBasePath()
				if errBasePath != nil {
					return errBasePath
				}
				target := path.Join(basePath, name)
				if isVolumePathEmpty(target) && isVolumePathMounted(target) {
					log.Info().Msgf(L("Volume %s is externally mounted, directory cannot be removed"), name)
					return nil
				}
				return err
			}
		}
	}
	return nil
}

// ExportVolume exports a podman volume based on its name to the specified targed directory.
// outputDir option expects already existing directory.
// If dryRun is set to true, only messages will be logged to explain what would happen.
func ExportVolume(name string, outputDir string, dryRun bool) error {
	exists := IsVolumePresent(name)
	if exists {
		outputFile := path.Join(outputDir, name+".tar")
		exportCommand := []string{"podman", "volume", "export", "-o", outputFile, name}
		if dryRun {
			log.Info().Msgf(L("Would run %s"), strings.Join(exportCommand, " "))
			return nil
		}
		log.Info().Msgf(L("Run %s"), strings.Join(exportCommand, " "))
		if err := runCmd(exportCommand[0], exportCommand[1:]...); err != nil {
			return utils.Errorf(err, L("Failed to export volume %s"), name)
		}
		if err := utils.CreateChecksum(outputFile); err != nil {
			return utils.Errorf(err, L("Failed to write checksum of volume %[1]s to the %[2]s"), name, outputFile+".sha256sum")
		}
	}
	return nil
}

// ImportVolume imports a podman volume from provided volumePath.
// If dryRun is set to true, only messages will be logged to exmplain what would happen.
func ImportVolume(name string, volumePath string, skipVerify bool, dryRun bool) error {
	createCommand := []string{"podman", "volume", "create", "--ignore", name}

	basePath, err := GetPodmanVolumeBasePath()
	if err != nil {
		log.Debug().Msg("cannot get base volume path")
		return err
	}
	targetPath := path.Join(basePath, name, "_data")
	importCommand := []string{"tar", "xf", volumePath, "-C", targetPath}
	restoreconCommand := []string{"restorecon", "-rF", targetPath}

	if dryRun {
		log.Info().Msgf(L("Would run %s"), strings.Join(importCommand, " "))
		return nil
	}
	if !skipVerify {
		if err := utils.ValidateChecksum(volumePath); err != nil {
			return utils.Errorf(err, L("Checksum does not match for volume %s"), volumePath)
		}
	}
	if err := runCmd(createCommand[0], createCommand[1:]...); err != nil {
		return utils.Errorf(err, L("Failed to precreate empty volume %s"), name)
	}
	log.Info().Msgf(L("Run %s"), strings.Join(importCommand, " "))
	if err := runCmd(importCommand[0], importCommand[1:]...); err != nil {
		return utils.Errorf(err, L("Failed to import volume %s"), name)
	}
	if utils.IsInstalled("restorecon") {
		if err := utils.RunCmd(restoreconCommand[0], restoreconCommand[1:]...); err != nil {
			log.Warn().Err(err).Msgf(L("Unable to restore selinux context for %s, manual action is required"), targetPath)
		}
	}
	return nil
}

func IsVolumePresent(volume string) bool {
	var exitError *exec.ExitError
	cmd := exec.Command("podman", "volume", "exists", volume)
	if err := cmd.Run(); err != nil && errors.As(err, &exitError) {
		log.Debug().Err(err).Msgf("podman volume exists %s", volume)
		return false
	}
	return cmd.ProcessState.Success()
}

func isVolumePathMounted(volume string) bool {
	cmd := exec.Command("findmnt", "--target", volume)
	var exitError *exec.ExitError
	if err := cmd.Run(); err != nil && errors.As(err, &exitError) {
		log.Debug().Err(err).Msgf("findmnt --target %s", volume)
		return false
	}
	return cmd.ProcessState.Success()
}

func isVolumePathEmpty(volume string) bool {
	f, err := os.Open(volume)
	if err != nil {
		return false
	}
	defer f.Close()

	_, err = f.Readdirnames(1)
	return errors.Is(err, io.EOF)
}

// GetPodmanVolumeBasePath returns the path to all volumes on the host system.
func GetPodmanVolumeBasePath() (string, error) {
	cmd := exec.Command("podman", "system", "info", "--format={{ .Store.VolumePath }}")
	out, err := cmd.Output()
	return strings.TrimSpace(string(out)), err
}

// GetVolumeMountPoint returns the path to the volume mount point on the host system.
// This shouldn't be confused with GetPodmanVolumeBasePath() that returns the path to the folder containing all volumes.
func GetVolumeMountPoint(name string) (path string, err error) {
	out, err := utils.NewRunner("podman", "volume", "inspect", "--format", "{{.Mountpoint}}", name).
		Log(zerolog.DebugLevel).
		Exec()
	if err != nil {
		return
	}
	path = strings.TrimSpace(string(out))
	return
}

// Inspect check values on given images.
// The images are assumed to be already available locally.
func Inspect(serverImage string, pgsqlImage string) (*utils.ServerInspectData, error) {
	inspectResult, err := ContainerInspect[utils.ServerInspectData](
		serverImage, utils.ServerVolumeMounts, utils.NewServerInspector(),
	)
	if err != nil {
		return nil, err
	}

	dbData, err := ContainerInspect[utils.DBInspectData](
		pgsqlImage, utils.PgsqlRequiredVolumeMounts, utils.NewDBInspector(),
	)
	if err != nil {
		return nil, err
	}
	inspectResult.DBInspectData = *dbData

	return inspectResult, err
}

// ContainerInspect runs an inspector script on a container image.
func ContainerInspect[T any](
	image string, volumes []types.VolumeMount, inspector templates.InspectTemplateData,
) (*T, error) {
	podmanArgs := []string{
		"--security-opt", "label=disable",
	}

	script, err := inspector.GenerateScript()
	if err != nil {
		return nil, err
	}

	args := PrepareContainerRunArgs("uyuni-inspect", image, volumes, podmanArgs, []string{"bash", "-c", script})
	out, err := newRunner("podman", args...).Log(zerolog.DebugLevel).Exec()
	if err != nil {
		return nil, err
	}

	inspectResult, err := utils.ReadInspectData[T](out)
	if err != nil {
		return nil, utils.Errorf(err, L("cannot inspect data"))
	}

	return inspectResult, nil
}
0707010000003e000081a400000000000000000000000168ed21dd0000049b000000000000000000000000000000000000001c00000000shared/podman/utils_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package podman

import (
	"errors"
	"testing"

	"github.com/rs/zerolog"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

func TestGetServiceImage(t *testing.T) {
	type dataType struct {
		catOut   string
		catErr   error
		expected string
	}
	data := []dataType{
		{"", errors.New("service not existing"), ""},
		{"content with no image defined", nil, ""},
		{`# /etc/systemd/system/uyuni-server-attestation@.service
[Unit]
Description=Uyuni server attestation container service
Wants=network.target
After=network-online.target
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
[Install]
WantedBy=multi-user.target default.target

# /etc/systemd/system/uyuni-server-attestation@.service.d/generated.conf

[Service]
Environment=UYUNI_IMAGE=myregistry.org/silly/image:tag
`, nil, "myregistry.org/silly/image:tag"},
	}

	for _, testData := range data {
		runCmdOutput = func(_ zerolog.Level, _ string, _ ...string) ([]byte, error) {
			return []byte(testData.catOut), testData.catErr
		}

		testutils.AssertEquals(t, "Wrong image found", testData.expected, GetServiceImage("myservice"))
	}
}
0707010000003f000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000000e00000000shared/podman07070100000040000081a400000000000000000000000168ed21dd00000cf0000000000000000000000000000000000000001400000000shared/ssl/flags.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package ssl

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

const (
	GeneratedFlagsGroup  = "ssl"
	ThirdPartyFlagsGroup = "ssl3rd"
)

// AddSSLGenerationFlags adds the command flags to generate SSL certificates.
func AddSSLGenerationFlags(cmd *cobra.Command) {
	cmd.Flags().StringSlice("ssl-cname", []string{}, L("SSL certificate cnames separated by commas"))
	cmd.Flags().String("ssl-country", "DE", L("SSL certificate country"))
	cmd.Flags().String("ssl-state", "Bayern", L("SSL certificate state"))
	cmd.Flags().String("ssl-city", "Nuernberg", L("SSL certificate city"))
	cmd.Flags().String("ssl-org", "SUSE", L("SSL certificate organization"))
	cmd.Flags().String("ssl-ou", "SUSE", L("SSL certificate organization unit"))

	_ = utils.AddFlagHelpGroup(cmd, &utils.Group{ID: GeneratedFlagsGroup, Title: L("SSL Certificate Flags")})
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-cname", GeneratedFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-country", GeneratedFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-state", GeneratedFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-city", GeneratedFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-org", GeneratedFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-ou", GeneratedFlagsGroup)
}

// AddSSLThirdPartyFlags adds the command flags to pass Apache third party SSL certificates.
func AddSSLThirdPartyFlags(cmd *cobra.Command) {
	cmd.Flags().StringSlice("ssl-ca-intermediate", []string{}, L("Intermediate CA certificate path"))
	cmd.Flags().String("ssl-ca-root", "", L("Root CA certificate path"))
	cmd.Flags().String("ssl-server-cert", "", L("Server certificate path"))
	cmd.Flags().String("ssl-server-key", "", L("Server key path"))

	_ = utils.AddFlagHelpGroup(cmd, &utils.Group{ID: ThirdPartyFlagsGroup, Title: L("3rd Party SSL Certificate Flags")})
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-ca-intermediate", ThirdPartyFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-ca-root", ThirdPartyFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-server-cert", ThirdPartyFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-server-key", ThirdPartyFlagsGroup)
}

// AddSSLDBThirdPartyFlags adds the command flags to pass database third party SSL certificates.
func AddSSLDBThirdPartyFlags(cmd *cobra.Command) {
	cmd.Flags().StringSlice("ssl-db-ca-intermediate", []string{},
		L("Intermediate CA certificate path for the database if different from the server one"))
	cmd.Flags().String("ssl-db-ca-root", "",
		L("Root CA certificate path for the database if different from the server one"))
	cmd.Flags().String("ssl-db-cert", "", L("Database certificate path"))
	cmd.Flags().String("ssl-db-key", "", L("Database key path"))

	_ = utils.AddFlagHelpGroup(cmd, &utils.Group{ID: ThirdPartyFlagsGroup, Title: L("3rd Party SSL Certificate Flags")})
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-db-ca-intermediate", ThirdPartyFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-db-ca-root", ThirdPartyFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-db-cert", ThirdPartyFlagsGroup)
	_ = utils.AddFlagToHelpGroupID(cmd, "ssl-db-key", ThirdPartyFlagsGroup)
}
07070100000041000081a400000000000000000000000168ed21dd00002c1c000000000000000000000000000000000000001200000000shared/ssl/ssl.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package ssl

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"
	"time"

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

// ! Any changes below needs to be double checked against upgrade/migration scenario !
const (
	// CAContainerPath is the path to the Root CA certificate in the server container.
	CAContainerPath = "/etc/pki/trust/anchors/LOCAL-RHN-ORG-TRUSTED-SSL-CERT"
	// DBCAContainerPath is the path to the DB Root CA certificate in the server container.
	DBCAContainerPath = "/etc/pki/trust/anchors/DB-RHN-ORG-TRUSTED-SSL-CERT"
	// ServerCertPath is the path to the server certificate in the server container.
	ServerCertPath = "/etc/pki/tls/certs/spacewalk.crt"
	// ServerCertKeyPath is the path to the server certificate key in the server container.
	ServerCertKeyPath = "/etc/pki/tls/private/spacewalk.key"
	// DBCertPath is the path to the database certificate in the database container.
	DBCertPath = "/etc/pki/tls/certs/spacewalk.crt"
	// DBCertKeyPaht is the path to the database certificate in the database container.
	DBCertKeyPath = "/etc/pki/tls/private/pg-spacewalk.key"
)

// OrderCas generates the server certificate with the CA chain.
//
// Returns the certificate chain and the root CA.
func OrderCas(chain *types.CaChain, serverPair *types.SSLPair) (orderedCert []byte, rootCA []byte, err error) {
	if err = CheckPaths(chain, serverPair); err != nil {
		return
	}

	// Extract all certificates and their data
	certs, err := readCertificates(chain.Root)
	if err != nil {
		return
	}
	for _, caPath := range chain.Intermediate {
		var intermediateCerts []certificate
		intermediateCerts, err = readCertificates(caPath)
		if err != nil {
			return
		}
		certs = append(certs, intermediateCerts...)
	}
	serverCerts, err := readCertificates(serverPair.Cert)
	if err != nil {
		return
	}
	certs = append(certs, serverCerts...)

	serverCert, err := findServerCert(certs)
	if err != nil {
		err = errors.New(L("Failed to find a non-CA certificate"))
		return
	}

	// Map all certificates using their hashes
	mapBySubjectHash := map[string]certificate{}
	if serverCert.subjectHash != "" {
		mapBySubjectHash[serverCert.subjectHash] = *serverCert
	}

	for _, caCert := range certs {
		if caCert.subjectHash != "" {
			mapBySubjectHash[caCert.subjectHash] = caCert
		}
	}

	// Sort from server certificate to RootCA
	return sortCertificates(mapBySubjectHash, serverCert.subjectHash)
}

type certificate struct {
	content      []byte
	subject      string
	subjectHash  string
	issuer       string
	issuerHash   string
	startDate    time.Time
	endDate      time.Time
	subjectKeyID string
	authKeyID    string
	isCa         bool
	isRoot       bool
}

func findServerCert(certs []certificate) (*certificate, error) {
	for _, cert := range certs {
		if !cert.isCa {
			return &cert, nil
		}
	}
	return nil, errors.New(L("expected to find a certificate, got none"))
}

func readCertificates(path string) ([]certificate, error) {
	fd, err := os.Open(path)
	if err != nil {
		return []certificate{}, utils.Errorf(err, L("Failed to read certificate file %s"), path)
	}

	certs := []certificate{}
	for {
		log.Debug().Msgf("Running openssl x509 on %s", path)
		cmd := exec.Command("openssl", "x509")
		cmd.Stdin = fd
		out, err := cmd.Output()

		if err != nil {
			// openssl got an invalid certificate or the end of the file
			break
		}

		// Extract data from the certificate
		cert, err := extractCertificateData(out)
		if err != nil {
			return []certificate{}, err
		}
		certs = append(certs, cert)
	}
	return certs, nil
}

// Extract data from the certificate to help ordering and verifying it.
func extractCertificateData(content []byte) (certificate, error) {
	args := []string{"x509", "-noout", "-subject", "-subject_hash", "-startdate", "-enddate",
		"-issuer", "-issuer_hash", "-ext", "subjectKeyIdentifier,authorityKeyIdentifier,basicConstraints"}
	log.Debug().Msg("Running command openssl " + strings.Join(args, " "))
	cmd := exec.Command("openssl", args...)

	log.Trace().Msgf("Extracting data from certificate:\n%s", string(content))

	reader := bytes.NewReader(content)
	cmd.Stdin = reader

	out, err := cmd.Output()
	if err != nil {
		return certificate{}, utils.Error(err, L("Failed to extract data from certificate"))
	}
	lines := strings.Split(string(out), "\n")

	cert := certificate{content: content}

	const timeLayout = "Jan 2 15:04:05 2006 MST"

	nextVal := ""
	for _, line := range lines {
		if strings.TrimSpace(line) == "" {
			continue
		}
		if strings.HasPrefix(line, "subject=") {
			cert.subject = strings.SplitN(line, "=", 2)[1]
		} else if strings.HasPrefix(line, "issuer=") {
			cert.issuer = strings.SplitN(line, "=", 2)[1]
		} else if strings.HasPrefix(line, "notBefore=") {
			date := strings.SplitN(line, "=", 2)[1]
			cert.startDate, err = time.Parse(timeLayout, date)
			if err != nil {
				return cert, utils.Errorf(err, L("Failed to parse start date: %s\n"), date)
			}
		} else if strings.HasPrefix(line, "notAfter=") {
			date := strings.SplitN(line, "=", 2)[1]
			cert.endDate, err = time.Parse(timeLayout, date)
			if err != nil {
				return cert, utils.Errorf(err, L("Failed to parse end date: %s\n"), date)
			}
		} else if strings.HasPrefix(line, "X509v3 Subject Key Identifier") {
			nextVal = "subjectKeyId"
		} else if strings.HasPrefix(line, "X509v3 Authority Key Identifier") {
			nextVal = "authKeyId"
		} else if strings.HasPrefix(line, "X509v3 Basic Constraints") {
			nextVal = "basicConstraints"
		} else if strings.HasPrefix(line, "    ") {
			if nextVal == "subjectKeyId" {
				cert.subjectKeyID = strings.ToUpper(strings.TrimSpace(line))
			} else if nextVal == "authKeyId" && strings.HasPrefix(line, "    keyid:") {
				cert.authKeyID = strings.ToUpper(strings.TrimSpace(strings.SplitN(line, ":", 2)[1]))
			} else if nextVal == "basicConstraints" && strings.Contains(line, "CA:TRUE") {
				cert.isCa = true
			} else {
				// Unhandled extension value
				continue
			}
		} else if cert.subjectHash == "" {
			// subject_hash comes first without key to identify it
			cert.subjectHash = strings.TrimSpace(line)
		} else {
			// second issue_hash without key to identify this value
			cert.issuerHash = strings.TrimSpace(line)
		}
	}

	if cert.subject == cert.issuer {
		cert.isRoot = true
		// Some Root CAs might not have their authorityKeyIdentifier set to themself
		if cert.isCa && cert.authKeyID == "" {
			cert.authKeyID = cert.subjectKeyID
		}
	} else {
		cert.isRoot = false
	}
	return cert, nil
}

// Prepare the certificate chain starting by the server up to the root CA.
// Returns the certificate chain and the root CA.
func sortCertificates(
	mapBySubjectHash map[string]certificate,
	serverCertHash string,
) (orderedCert []byte, rootCA []byte, err error) {
	if len(mapBySubjectHash) == 0 {
		err = errors.New(L("No CA found"))
		return
	}

	cert := mapBySubjectHash[serverCertHash]
	issuerHash := cert.issuerHash
	_, found := mapBySubjectHash[issuerHash]
	if issuerHash == "" || !found {
		err = errors.New(L("No CA found for server certificate"))
		return
	}

	sortedChain := bytes.NewBuffer(mapBySubjectHash[serverCertHash].content)

	for {
		cert, found = mapBySubjectHash[issuerHash]
		if !found {
			err = fmt.Errorf(L("Missing CA with subject hash %s"), issuerHash)
			return
		}

		nextHash := cert.issuerHash
		if nextHash == issuerHash {
			// Found Root CA, we can exit
			rootCA = cert.content
			break
		}
		issuerHash = nextHash
		sortedChain.Write(cert.content)
	}
	orderedCert = sortedChain.Bytes()
	return orderedCert, rootCA, nil
}

// CheckPaths ensures that all the passed path exists and the required files are available.
func CheckPaths(chain *types.CaChain, serverPair *types.SSLPair) error {
	if err := mandatoryFile(chain.Root, "root CA"); err != nil {
		return err
	}
	for _, ca := range chain.Intermediate {
		if err := optionalFile(ca); err != nil {
			return err
		}
	}
	if err := mandatoryFile(serverPair.Cert, L("server certificate is required")); err != nil {
		return err
	}
	if err := mandatoryFile(serverPair.Key, L("server key is required")); err != nil {
		return err
	}
	return nil
}

func mandatoryFile(file string, msg string) error {
	if file == "" {
		return errors.New(msg)
	}
	return optionalFile(file)
}

func optionalFile(file string) error {
	if file != "" && !utils.FileExists(file) {
		return fmt.Errorf(L("%s file is not accessible"), file)
	}
	return nil
}

// Converts an SSL key to RSA.
func GetRsaKey(keyContent string, password string) []byte {
	// Kubernetes only handles RSA private TLS keys, convert and strip password
	caPassword := password
	utils.AskPasswordIfMissing(&caPassword, L("Source server SSL CA private key password"), 0, 0)

	// Convert the key file to RSA format for kubectl to handle it
	cmd := exec.Command("openssl", "rsa", "-passin", "env:pass")
	stdin, err := cmd.StdinPipe()
	if err != nil {
		log.Fatal().Err(err).Msg(L("Failed to open openssl rsa process input stream"))
	}
	if _, err := io.WriteString(stdin, keyContent); err != nil {
		log.Fatal().Err(err).Msg(L("Failed to write openssl key content to input stream"))
	}

	cmd.Env = append(cmd.Env, "pass="+caPassword)
	out, err := cmd.Output()
	if err != nil {
		log.Fatal().Err(err).Msg(L("Failed to convert CA private key to RSA"))
	}
	return out
}

// StripTextFromCertificate removes the optional text part of an x509 certificate.
func StripTextFromCertificate(certContent string) []byte {
	cmd := exec.Command("openssl", "x509")
	stdin, err := cmd.StdinPipe()
	if err != nil {
		log.Fatal().Err(err).Msg(L("Failed to open openssl x509 process input stream"))
	}
	if _, err := io.WriteString(stdin, certContent); err != nil {
		log.Fatal().Err(err).Msg(L("Failed to write SSL certificate to input stream"))
	}
	out, err := cmd.Output()
	if err != nil {
		log.Fatal().Err(err).Msg(L("failed to strip text part from CA certificate"))
	}
	return out
}

var newRunner = utils.NewRunner

// CheckKey verifies that the SSL key located at keyPath is valid and not encrypted.
func CheckKey(keyPath string) error {
	if err := mandatoryFile(keyPath, L("server key is required")); err != nil {
		return err
	}

	_, err := newRunner("openssl", "pkey", "-in", keyPath, "-passin", "pass:invalid", "-text", "-noout").Exec()
	if err != nil {
		return utils.Error(err, L("Invalid SSL key, it is probably encrypted"))
	}

	return nil
}

// nochecktime disables time verification and should only be for unit tests as the test cert isn't refreshed.
var nochecktime = false

// VerifyHostname checks that the certificate at certPath is matching the hostname.
func VerifyHostname(caPath string, certPath string, hostname string) error {
	args := []string{"verify"}
	if nochecktime {
		args = append(args, "-no_check_time")
	}
	args = append(args, "-trusted", certPath, "-trusted", caPath, "-verify_hostname", hostname, certPath)
	// The certPath needs to be added as trusted too since it could be a bundle with intermediate certs.
	_, err := newRunner("openssl", args...).Log(zerolog.DebugLevel).Exec()
	return err
}
07070100000042000081a400000000000000000000000168ed21dd00002182000000000000000000000000000000000000001700000000shared/ssl/ssl_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package ssl

import (
	"fmt"
	"os"
	"path"
	"strings"
	"testing"

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

func TestReadCertificatesRootCa(t *testing.T) {
	actual, err := readCertificates("testdata/chain1/root-ca.crt")
	testutils.AssertEquals(t, "error not nil", nil, err)
	testutils.AssertEquals(t, "Didn't get the expected certificates count", 1, len(actual))
	testutils.AssertTrue(t, "CA should be root", actual[0].isRoot)
}

func TestReadCertificatesNoCa(t *testing.T) {
	actual, err := readCertificates("testdata/chain1/server.crt")
	testutils.AssertEquals(t, "error not nil", nil, err)
	testutils.AssertEquals(t, "Didn't get the expected certificates count", 1, len(actual))
	testutils.AssertTrue(t, "Shouldn't be a CA certificate", !actual[0].isCa)
}

func TestReadCertificatesMultiple(t *testing.T) {
	actual, err := readCertificates("testdata/chain1/intermediate-ca.crt")
	testutils.AssertEquals(t, "error not nil", nil, err)
	testutils.AssertEquals(t, "Didn't get the expected certificates count", 2, len(actual))
	if len(actual) != 2 {
		t.Errorf("readCertificates got %d certificates; want 2", len(actual))
	}

	content := string(actual[0].content)
	if !strings.HasPrefix(content, "-----BEGIN CERTIFICATE-----\nMIIEXjCCA0agA") ||
		!strings.HasSuffix(content, "nrUN5m7Y0taw4qrOVOZRmGXu\n-----END CERTIFICATE-----\n") {
		t.Errorf("Wrong certificate content:\n%s", content)
	}

	testutils.AssertEquals(t, "Wrong certificate subject",
		"C=DE, ST=STATE, O=ORG, OU=ORGUNIT, CN=TeamCA",
		canonicalizeOpenSSLOutput(actual[1].subject),
	)

	testutils.AssertEquals(t, "Wrong subject hash", "85a51924", actual[1].subjectHash)

	testutils.AssertEquals(t, "Wrong certificate issuer",
		"C=DE, ST=STATE, L=CITY, O=ORG, OU=ORGUNIT, CN=RootCA",
		canonicalizeOpenSSLOutput(actual[0].issuer),
	)

	testutils.AssertEquals(t, "Wrong issuer hash", "e96ab651", actual[0].issuerHash)
	testutils.AssertTrue(t, "CA shouldn't be root", !actual[0].isRoot)
	testutils.AssertTrue(t, "Should be a CA", actual[0].isCa)

	testutils.AssertEquals(t, "Wrong subject key id",
		"62:00:25:E4:EE:70:E5:37:2D:1E:9E:AE:4E:B7:3E:FC:62:08:BF:27", actual[1].subjectKeyID,
	)

	testutils.AssertEquals(t, "Wrong auth key id",
		"6E:6D:4B:35:22:23:3E:13:18:A5:93:61:0E:9C:BE:1E:D2:B8:1B:D4", actual[0].authKeyID,
	)
}

// canonicalizeOpenSSLOutput standardizes openSSL test output across its versions.
func canonicalizeOpenSSLOutput(value string) string {
	return strings.ReplaceAll(value, " = ", "=")
}

func TestOrderCas(t *testing.T) {
	chain := types.CaChain{
		Root:         "testdata/chain1/root-ca.crt",
		Intermediate: []string{"testdata/chain1/intermediate-ca.crt"},
	}
	server := types.SSLPair{Cert: "testdata/chain1/server.crt", Key: "testdata/chain1/server.key"}

	certs, rootCa, err := OrderCas(&chain, &server)
	testutils.AssertEquals(t, "error not nil", nil, err)
	ordered := strings.Split(string(certs), "-----BEGIN CERTIFICATE-----\n")

	testutils.AssertEquals(t, "Found unknown content before first certificate", "", ordered[0])
	onlyCerts := ordered[1:]

	expected := []struct {
		Begin string
		End   string
	}{
		{Begin: "MIIEdDCCA1ygAwIBAgIUZ2P1Ka9Eun", End: "JtS8rmkQpYyJciifX0PxYzTg=="},
		{Begin: "MIIETzCCAzegAwIBAgIUZ2P1Ka9Eun", End: "s3DjcCbkzyTUCKh9Po4\nmoUf"},
		{Begin: "MIIEXjCCA0agAwIBAgIUZ2P1Ka9Eunnv3dy/", End: "nrUN5m7Y0taw4qrOVOZRmGXu"},
	}

	// Do not count the empty first item
	testutils.AssertEquals(t, "Wrong number of certificates in the chain", len(expected), len(onlyCerts))

	for i, data := range expected {
		if !strings.HasPrefix(onlyCerts[i], data.Begin) ||
			!strings.HasSuffix(onlyCerts[i], data.End+"\n-----END CERTIFICATE-----\n") {
			t.Errorf("Invalid certificate #%d, got:\n:%s", i, onlyCerts[i])
		}
	}

	rootCert := string(rootCa)
	if !strings.HasPrefix(rootCert, "-----BEGIN CERTIFICATE-----\nMIIEVjCCAz6gAwIBAgIUSZYESIXLDe") ||
		!strings.HasSuffix(rootCert, "5c7cfxV\nkABuj9PJxnNnFQ==\n-----END CERTIFICATE-----\n") {
		t.Errorf("Invalid root CA certificate, got:\n:%s", rootCert)
	}
}

func TestFindServerCertificate(t *testing.T) {
	certsList, err := readCertificates("testdata/chain2/spacewalk.crt")
	testutils.AssertEquals(t, "error not nil", nil, err)

	actual, err := findServerCert(certsList)

	testutils.AssertEquals(t, "Expected to find a certificate, got none", nil, err)
	testutils.AssertEquals(t, "Wrong subject hash", "78b716a6", actual.subjectHash)
}

// Test a CA chain with all the chain in the server certificate file.
func TestOrderCasChain2(t *testing.T) {
	chain := types.CaChain{Root: "testdata/chain2/RHN-ORG-TRUSTED-SSL-CERT", Intermediate: []string{}}
	server := types.SSLPair{Cert: "testdata/chain2/spacewalk.crt", Key: "testdata/chain2/spacewalk.key"}

	certs, rootCa, err := OrderCas(&chain, &server)
	testutils.AssertEquals(t, "error not nil", nil, err)

	ordered := strings.Split(string(certs), "-----BEGIN CERTIFICATE-----\n")

	testutils.AssertEquals(t, "Found unknown content before first certificate", "", ordered[0])
	onlyCerts := ordered[1:]

	expected := []struct {
		Begin string
		End   string
	}{
		{Begin: "MIIEejCCA2KgAwIBAgIUEbWzxg57E", End: "Ur+fgZpBNvbkjD8b+S0ECQA6Dg=="},
		{Begin: "MIIETzCCAzegAwIBAgIUEbWzxg57E", End: "TT2Sljt0YfkmWfdXA\nwOUt"},
		{Begin: "MIIEXjCCA0agAwIBAgIUEbWzxg57E", End: "ivyvRvlwCUNstG6u8Y7IxHHn"},
	}

	// Do not count the empty first item
	testutils.AssertEquals(t, "Wrong number of certificates in the chain", len(expected), len(onlyCerts))

	for i, data := range expected {
		if !strings.HasPrefix(onlyCerts[i], data.Begin) ||
			!strings.HasSuffix(onlyCerts[i], data.End+"\n-----END CERTIFICATE-----\n") {
			t.Errorf("Invalid certificate #%d, got:\n:%s", i, onlyCerts[i])
		}
	}

	rootCert := string(rootCa)
	if !strings.HasPrefix(rootCert, "-----BEGIN CERTIFICATE-----\nMIIEVjCCAz6gAwIBAgIUA12e94NK") ||
		!strings.HasSuffix(rootCert, "AQKotV5y5qBInw==\n-----END CERTIFICATE-----\n") {
		t.Errorf("Invalid root CA certificate, got:\n:%s", rootCert)
	}
}

func TestGetRsaKey(t *testing.T) {
	key := testutils.ReadFile(t, "testdata/RootCA.key")
	actual := string(GetRsaKey(key, "secret"))

	// This is what new openssl would generate
	matchingPKCS8 := strings.HasPrefix(actual, "-----BEGIN PRIVATE KEY-----\nMIIEugIBADANBgkqhkiG9w0BAQEFAAS") &&
		strings.HasSuffix(actual, "DKY9SmW6QD+RJwbMc4M=\n-----END PRIVATE KEY-----\n")

	// This is what older openssl would generate
	matchingPKCS1 := strings.HasPrefix(actual, "-----BEGIN RSA PRIVATE KEY-----\nMIIEoAIBAAKCAQEArqQvTR0") &&
		strings.HasSuffix(actual, "+3i4RXV4XtWHzmQymPUplukA/kScGzHOD\n-----END RSA PRIVATE KEY-----\n")

	if !matchingPKCS1 && !matchingPKCS8 {
		t.Errorf("Unexpected generated RSA key: %s", actual)
	}
}

func TestCheckKey(t *testing.T) {
	type testData struct {
		file string
		err  string
	}

	testCases := []testData{
		{"testdata/RootCA.key", "Invalid SSL key, it is probably encrypted"},
		{"testdata/chain1/server.key", ""},
	}

	for i, testCase := range testCases {
		err := CheckKey(testCase.file)
		if testCase.err == "" {
			testutils.AssertEquals(t, fmt.Sprintf("case %d: expected no error", i+1), nil, err)
		} else {
			testutils.AssertTrue(t, "Error message should state the key is probably encrypted",
				strings.Contains(err.Error(), testCase.err),
			)
		}
	}
}

func TestVerifyHostname(t *testing.T) {
	chain := types.CaChain{
		Root:         "testdata/chain1/root-ca.crt",
		Intermediate: []string{"testdata/chain1/intermediate-ca.crt"},
	}
	server := types.SSLPair{Cert: "testdata/chain1/server.crt", Key: "testdata/chain1/server.key"}

	certs, rootCa, err := OrderCas(&chain, &server)
	testutils.AssertEquals(t, "Ordering certificate chain shouldn't have raised an error", nil, err)

	dir, err := os.MkdirTemp("", "tools-tests")
	defer func() {
		os.RemoveAll(dir)
	}()
	testutils.AssertEquals(t, "Creating a temporary directory shouldn't fail", nil, err)

	tmpca := path.Join(dir, "ca.crt")
	tmpcert := path.Join(dir, "srv.crt")

	testutils.WriteFile(t, tmpca, string(rootCa))
	testutils.WriteFile(t, tmpcert, string(certs))

	// openssl verifies time by default and our test certificates are never updated and expired.
	nochecktime = true
	err = VerifyHostname(tmpca, tmpcert, "failed.fqdn")
	testutils.AssertTrue(t, "Unexpected error message", strings.Contains(err.Error(), "hostname mismatch"))

	err = VerifyHostname(tmpca, tmpcert, "test.example.com")
	testutils.AssertEquals(t, "Unexpected error", nil, err)
}
07070100000043000081a400000000000000000000000168ed21dd0000073e000000000000000000000000000000000000001f00000000shared/ssl/testdata/RootCA.key-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFHTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIZDERQAIgS+ACAggA
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBA0CZ/wEFCBHg0mYxZucHcwBIIE
wI40HqL0NoUUuxx0qo+UVoXuTPp5bWEmv4be+0v3+ya0ucCLx41l7HiYSwL/fYvt
tStWm9ArOlLW48uxF54eyA3Nwvt59xnZBGN0YokJR1heeYzns1xadgHwiFjl4CLI
IfpUoQvGmnph51301YRgwuTRGJsih8EH8g24TQ5qy1uRMS1BZLd7elUuHzVFyu6y
PMfxpqcygBvjnufgOye4aAkTQIeGlJBDk5DwVx56T5VEUA8/v9VXVFy4took8Gwr
B08ZVszbR7AeUpa16aqKXFV1hQPcEle/n/0zsqPTE0drRxAGKufdpvK1rfDRDSQx
LudQJ31NJkCBRe+sP+rv4BA+zme4xFEjAI12IVQ6sUaTCW/qMnPBcSC5iSvVLXen
iSB519BJgLPmJi2gNXsuluHddwRp1QFUOoxnyPHDyGFkQ4zNwnG/pPvxASoBQRPA
PR/JsZC9p+oGHBds0HzZETuU5oKWdrZwyawQaAWEeuZEeICsrkrKi9PkumY5SMCT
47tn0vyQTQEGexKag/UTaC5PaGX8SxpWBXrskxgLcYnUAQnpkEhnU92CJQSI36uL
pGC7kQKY8vPjCOw33lLrEkWDxIn3oFX76NuHtMBdKgNT/Qlxb1tqIfuaNI80MrGV
zAzeK6dw4ZakI6aSlOBa4l71JB3HfWZjWAldhS33GvWAdsnPef5c5jMEsDqMFFnw
lZh2hrdfUTkAb+v2tJ13CUccSqIy3i9DmOU/ijdEGGte3E1ws+qyouHsFC09ETcy
XC4YYXF4ccGNZemcqpsQhi5iQEL+HjaNRkv16+qyXRUG49TGRe8nlEA2mBXV8Wzf
mkEdzp9Oc3iOPqeQYgnhtHbYaj0iHjiwaUmKiisadD7Jo76z2CAG0YJdWw4FNNUI
tpM2eADVHXcFGLDzvmFWtPnfkBnhn09GhOJJXOIEsoNyhczv6TqMrtGx8wYvbNqc
YOgl42mDYn6v0P+uEDWcHWFQiHNDNPHUCT/LmjYcVRTOPlCqnv5Fh+yKS50pGtFy
8h53QURP7e4cJxE8CuBZAAGiEkoXEbXGoslnrtpQbB0bVpMDKrLpJMrMjSr4/pKK
zWRgVo7wOuUJN+o30lcQ/RM8NzJRii6tnHRG1eOijNOLqRYmyEHFSeTB7+lhjE5A
xyXotHOml32pW4lEu7Ks18fmTQtyI1opx4ocVLNMeRvesxCf3z5eMUciXG+frxnF
a9UScTR1rIbVVP2V7PiHVCXY6WXZqiefWU/Sn+oVndm/9GGH9f2WVW1xn6YX3L+e
pTpzZScReh7B0gd3cVtwmfAbQU3xuA8IFJnrLuDwQCs19WweImgPm/rf7ITBuxeO
3vdvVd8GfCsN3sTJ/9G5XJJstM0eXoXjoXhthQ4OuTyVSiI3tHROYW+iggAJsaVH
HwzTnGE5m5WBvQ9GziSThxDz4vfDtwVlNS1K0+wkt9stt9ruCpXxntQV2FEin0GM
iWptJuYpCMOH9gkALDYoYv/jG0PbMBs63FRLeZg2ehtYw6kU5KtjHkLZTiccuwSq
W010SdsstOrwrZKEJAuRNQHLHRFmjyoskz0nCtmyJBCTkP46Awu5PFVwBE9auiIe
Y0UUre0B4tPsoPewpvgRJio=
-----END ENCRYPTED PRIVATE KEY-----
07070100000044000081a400000000000000000000000168ed21dd000027be000000000000000000000000000000000000002f00000000shared/ssl/testdata/chain1/intermediate-ca.crtCertificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            67:63:f5:29:af:44:ba:79:ef:dd:dc:bf:bb:ab:3e:36:22:b8:71:26
        Signature Algorithm: sha384WithRSAEncryption
        Issuer: C=DE, ST=STATE, L=CITY, O=ORG, OU=ORGUNIT, CN=RootCA
        Validity
            Not Before: Oct  2 13:09:11 2023 GMT
            Not After : Feb 13 13:09:11 2025 GMT
        Subject: C=DE, ST=STATE, O=ORG, OU=ORGUNIT, CN=OrgCa
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:e0:25:1f:af:4d:59:23:82:a6:f1:c9:33:d1:c6:
                    10:47:ad:f4:f2:b6:96:aa:79:2b:45:97:d3:d8:a5:
                    29:fe:9c:b9:2c:26:30:37:5f:ae:69:6a:ac:85:e0:
                    28:d3:d3:7e:83:c8:87:2a:70:e5:a6:78:99:d9:42:
                    f9:d3:17:c0:e1:bc:4c:51:af:f0:aa:a8:fb:19:65:
                    27:91:35:80:5a:3d:fd:90:0b:fc:af:a1:d3:af:26:
                    02:ba:7c:26:bc:aa:08:6e:cf:d5:5d:ed:c9:8c:33:
                    9f:63:51:45:49:1f:f2:1f:12:c2:7c:e4:42:05:8a:
                    ef:33:d3:0e:e5:44:58:99:88:aa:2f:e3:5f:37:d8:
                    36:fb:21:ac:90:0f:82:b9:55:bc:9e:ba:23:70:4a:
                    83:c0:44:37:c2:0a:9a:03:fb:1d:4a:d2:67:a8:70:
                    e0:8c:b2:c1:d7:d8:e7:c9:bd:ee:6f:f6:4e:f7:25:
                    f2:4b:9b:93:33:28:40:18:c6:f1:47:78:0d:84:fa:
                    7f:f4:82:9b:37:f0:37:84:25:b5:ae:f5:88:4f:d2:
                    d9:7e:61:c0:8e:92:24:c8:32:55:cb:c4:8c:e6:be:
                    1f:e8:32:e2:9f:18:1e:2f:a6:8f:80:27:d4:77:7b:
                    5d:2d:cb:eb:a4:b8:2f:28:a0:38:34:a5:91:c8:6e:
                    b6:71
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:TRUE
            X509v3 Key Usage:
                Digital Signature, Key Encipherment, Certificate Sign
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            Netscape Comment:
                SSL Generated Certificate
            X509v3 Subject Key Identifier:
                9B:C1:07:5E:AB:5C:7E:6B:9E:E4:23:7B:18:61:34:CB:D0:06:91:3B
            X509v3 Authority Key Identifier:
                keyid:6E:6D:4B:35:22:23:3E:13:18:A5:93:61:0E:9C:BE:1E:D2:B8:1B:D4
                DirName:/C=DE/ST=STATE/L=CITY/O=ORG/OU=ORGUNIT/CN=RootCA
                serial:49:96:04:48:85:CB:0D:EC:2C:31:FE:EF:E9:CB:12:2B:DB:80:F8:71
    Signature Algorithm: sha384WithRSAEncryption
    Signature Value:
        78:5a:ac:de:87:a3:fb:d5:e1:29:4f:c3:1b:39:2b:da:29:78:
        1c:07:3d:e3:db:da:8a:40:3d:c3:d4:51:9d:21:59:d2:37:66:
        f5:47:69:b8:96:2a:2e:f0:35:1a:5b:5b:23:cd:d5:ac:88:49:
        97:e1:5b:91:e4:b8:7a:2d:ab:46:17:c4:61:a9:1a:b1:29:d3:
        50:52:af:0d:c2:4a:e1:2f:aa:00:1b:07:5a:7d:f9:d1:57:19:
        66:49:52:c5:74:3c:1e:3d:a3:1f:49:64:60:92:48:03:a2:37:
        52:26:69:24:34:d7:a4:68:fd:ea:b7:a6:d6:c2:b0:46:19:a7:
        2c:b9:cc:a3:0f:87:4c:cb:fb:69:d1:a9:c5:93:73:69:7c:34:
        aa:3d:f0:98:83:88:14:48:29:2d:ed:f9:c0:96:22:ae:03:7a:
        2f:09:ad:43:6d:a3:12:d5:8c:48:e6:65:ca:e1:97:b4:ec:d7:
        aa:fc:db:e2:cf:16:30:2c:46:f3:dd:a5:37:db:d9:0f:99:c5:
        74:e7:21:2d:ca:2b:c5:4b:50:56:0a:2c:0d:25:30:56:39:87:
        33:b2:ae:d7:98:74:e9:3d:ce:78:ca:1b:bd:ea:f8:a6:3f:2a:
        a4:21:3b:19:9e:b5:0d:e6:6e:d8:d2:d6:b0:e2:aa:ce:54:e6:
        51:98:65:ee
-----BEGIN CERTIFICATE-----
MIIEXjCCA0agAwIBAgIUZ2P1Ka9Eunnv3dy/u6s+NiK4cSYwDQYJKoZIhvcNAQEM
BQAwXTELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRFMQ0wCwYDVQQHDARDSVRZ
MQwwCgYDVQQKDANPUkcxEDAOBgNVBAsMB09SR1VOSVQxDzANBgNVBAMMBlJvb3RD
QTAeFw0yMzEwMDIxMzA5MTFaFw0yNTAyMTMxMzA5MTFaME0xCzAJBgNVBAYTAkRF
MQ4wDAYDVQQIDAVTVEFURTEMMAoGA1UECgwDT1JHMRAwDgYDVQQLDAdPUkdVTklU
MQ4wDAYDVQQDDAVPcmdDYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AOAlH69NWSOCpvHJM9HGEEet9PK2lqp5K0WX09ilKf6cuSwmMDdfrmlqrIXgKNPT
foPIhypw5aZ4mdlC+dMXwOG8TFGv8Kqo+xllJ5E1gFo9/ZAL/K+h068mArp8Jryq
CG7P1V3tyYwzn2NRRUkf8h8SwnzkQgWK7zPTDuVEWJmIqi/jXzfYNvshrJAPgrlV
vJ66I3BKg8BEN8IKmgP7HUrSZ6hw4IyywdfY58m97m/2Tvcl8kubkzMoQBjG8Ud4
DYT6f/SCmzfwN4Qlta71iE/S2X5hwI6SJMgyVcvEjOa+H+gy4p8YHi+mj4An1Hd7
XS3L66S4LyigODSlkchutnECAwEAAaOCASQwggEgMAwGA1UdEwQFMAMBAf8wCwYD
VR0PBAQDAgKkMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAoBglghkgB
hvhCAQ0EGxYZU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUm8EH
Xqtcfmue5CN7GGE0y9AGkTswgZoGA1UdIwSBkjCBj4AUbm1LNSIjPhMYpZNhDpy+
HtK4G9ShYaRfMF0xCzAJBgNVBAYTAkRFMQ4wDAYDVQQIDAVTVEFURTENMAsGA1UE
BwwEQ0lUWTEMMAoGA1UECgwDT1JHMRAwDgYDVQQLDAdPUkdVTklUMQ8wDQYDVQQD
DAZSb290Q0GCFEmWBEiFyw3sLDH+7+nLEivbgPhxMA0GCSqGSIb3DQEBDAUAA4IB
AQB4Wqzeh6P71eEpT8MbOSvaKXgcBz3j29qKQD3D1FGdIVnSN2b1R2m4liou8DUa
W1sjzdWsiEmX4VuR5Lh6LatGF8RhqRqxKdNQUq8NwkrhL6oAGwdaffnRVxlmSVLF
dDwePaMfSWRgkkgDojdSJmkkNNekaP3qt6bWwrBGGacsucyjD4dMy/tp0anFk3Np
fDSqPfCYg4gUSCkt7fnAliKuA3ovCa1DbaMS1YxI5mXK4Ze07Neq/NvizxYwLEbz
3aU329kPmcV05yEtyivFS1BWCiwNJTBWOYczsq7XmHTpPc54yhu96vimPyqkITsZ
nrUN5m7Y0taw4qrOVOZRmGXu
-----END CERTIFICATE-----
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            67:63:f5:29:af:44:ba:79:ef:dd:dc:bf:bb:ab:3e:36:22:b8:71:27
        Signature Algorithm: sha384WithRSAEncryption
        Issuer: C=DE, ST=STATE, O=ORG, OU=ORGUNIT, CN=OrgCa
        Validity
            Not Before: Oct  2 13:09:11 2023 GMT
            Not After : Nov  5 13:09:11 2024 GMT
        Subject: C=DE, ST=STATE, O=ORG, OU=ORGUNIT, CN=TeamCA
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:eb:35:fe:18:8f:59:de:23:4a:84:bc:de:7b:f1:
                    79:f8:1a:5d:94:95:54:2c:00:bd:42:c1:e6:f5:c6:
                    ca:25:da:97:cd:5b:85:d6:89:8a:7c:45:11:9e:df:
                    65:10:68:e4:49:6a:cf:fd:76:48:08:c7:09:aa:e3:
                    88:c2:7e:2f:f9:85:b4:df:d4:00:ec:a9:71:38:1d:
                    ff:8d:d4:d1:84:2a:f9:9b:e7:7f:e1:61:3e:75:06:
                    7f:18:66:59:23:96:e6:c2:75:11:e7:f4:f3:47:7b:
                    e6:17:8c:25:d9:ee:da:01:d6:cd:94:9e:a7:8e:35:
                    f6:d8:24:d6:cf:58:4f:29:36:42:18:96:aa:87:ca:
                    ad:af:05:a2:e5:a6:6b:4f:42:98:e3:4e:86:b4:d7:
                    1f:2f:db:c3:5b:bd:e9:da:7d:d0:d9:8d:83:c9:28:
                    56:27:e7:0d:a2:15:88:99:af:eb:a3:85:73:9e:3d:
                    64:70:01:be:cb:71:c0:d8:ca:e7:6e:25:b7:3b:fe:
                    73:0a:92:d2:23:2d:f5:f4:9c:0e:d6:65:c6:ef:6c:
                    9a:c5:c5:af:70:10:ba:fc:2d:b1:29:26:88:9e:06:
                    e5:63:5f:d4:25:0c:98:18:f0:46:77:86:f5:98:00:
                    63:38:3a:36:81:27:94:2a:cc:84:24:75:01:54:ed:
                    a4:d7
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:TRUE
            X509v3 Key Usage:
                Digital Signature, Key Encipherment, Certificate Sign
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            Netscape Comment:
                SSL Generated Certificate
            X509v3 Subject Key Identifier:
                62:00:25:E4:EE:70:E5:37:2D:1E:9E:AE:4E:B7:3E:FC:62:08:BF:27
            X509v3 Authority Key Identifier:
                keyid:9B:C1:07:5E:AB:5C:7E:6B:9E:E4:23:7B:18:61:34:CB:D0:06:91:3B
                DirName:/C=DE/ST=STATE/L=CITY/O=ORG/OU=ORGUNIT/CN=RootCA
                serial:67:63:F5:29:AF:44:BA:79:EF:DD:DC:BF:BB:AB:3E:36:22:B8:71:26
    Signature Algorithm: sha384WithRSAEncryption
    Signature Value:
        26:a0:7e:98:44:58:ab:81:9f:f9:a6:04:dc:08:59:d8:b4:5a:
        11:47:8e:9c:23:7f:53:66:f9:b9:93:5b:df:50:d6:2a:11:a1:
        cb:1c:d5:2e:cd:5d:f3:eb:45:b5:fe:01:9f:0c:d0:f0:1d:8f:
        57:ac:0f:2a:5b:a4:6a:57:a7:0e:25:e1:69:25:f5:ef:2f:3c:
        60:9c:26:ac:e8:cd:3a:89:fa:84:18:da:bb:83:f6:f5:02:53:
        51:f2:ab:76:e8:fb:d0:63:dc:5c:09:c5:f7:de:68:90:c0:50:
        80:ec:88:ff:16:95:a0:c1:97:69:fb:1f:9d:43:32:0c:5d:f9:
        bc:5e:48:c4:52:f2:f3:43:1f:ff:c5:bb:58:6c:ee:11:cb:0c:
        22:45:29:1c:62:26:78:9c:31:10:d8:14:24:17:17:4a:7d:a9:
        1d:3b:5b:64:8e:b2:84:91:66:fb:f2:e6:37:3d:c2:1b:db:98:
        11:10:6d:67:9c:95:a5:d9:a4:8a:e1:b6:c4:ab:2d:f7:48:3a:
        66:9b:9c:af:9b:b7:a5:27:cc:4b:53:e6:21:0c:2b:6c:c8:b2:
        cc:6c:51:58:df:b2:bc:53:ed:25:0f:4a:e6:44:6c:be:74:46:
        0b:6a:df:46:76:cd:c3:8d:c0:9b:93:3c:93:50:22:a1:f4:fa:
        38:9a:85:1f
-----BEGIN CERTIFICATE-----
MIIETzCCAzegAwIBAgIUZ2P1Ka9Eunnv3dy/u6s+NiK4cScwDQYJKoZIhvcNAQEM
BQAwTTELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRFMQwwCgYDVQQKDANPUkcx
EDAOBgNVBAsMB09SR1VOSVQxDjAMBgNVBAMMBU9yZ0NhMB4XDTIzMTAwMjEzMDkx
MVoXDTI0MTEwNTEzMDkxMVowTjELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRF
MQwwCgYDVQQKDANPUkcxEDAOBgNVBAsMB09SR1VOSVQxDzANBgNVBAMMBlRlYW1D
QTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOs1/hiPWd4jSoS83nvx
efgaXZSVVCwAvULB5vXGyiXal81bhdaJinxFEZ7fZRBo5Elqz/12SAjHCarjiMJ+
L/mFtN/UAOypcTgd/43U0YQq+Zvnf+FhPnUGfxhmWSOW5sJ1Eef080d75heMJdnu
2gHWzZSep4419tgk1s9YTyk2QhiWqofKra8FouWma09CmONOhrTXHy/bw1u96dp9
0NmNg8koVifnDaIViJmv66OFc549ZHABvstxwNjK524ltzv+cwqS0iMt9fScDtZl
xu9smsXFr3AQuvwtsSkmiJ4G5WNf1CUMmBjwRneG9ZgAYzg6NoEnlCrMhCR1AVTt
pNcCAwEAAaOCASQwggEgMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgKkMB0GA1Ud
JQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAoBglghkgBhvhCAQ0EGxYZU1NMIEdl
bmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUYgAl5O5w5TctHp6uTrc+/GII
vycwgZoGA1UdIwSBkjCBj4AUm8EHXqtcfmue5CN7GGE0y9AGkTuhYaRfMF0xCzAJ
BgNVBAYTAkRFMQ4wDAYDVQQIDAVTVEFURTENMAsGA1UEBwwEQ0lUWTEMMAoGA1UE
CgwDT1JHMRAwDgYDVQQLDAdPUkdVTklUMQ8wDQYDVQQDDAZSb290Q0GCFGdj9Smv
RLp5793cv7urPjYiuHEmMA0GCSqGSIb3DQEBDAUAA4IBAQAmoH6YRFirgZ/5pgTc
CFnYtFoRR46cI39TZvm5k1vfUNYqEaHLHNUuzV3z60W1/gGfDNDwHY9XrA8qW6Rq
V6cOJeFpJfXvLzxgnCas6M06ifqEGNq7g/b1AlNR8qt26PvQY9xcCcX33miQwFCA
7Ij/FpWgwZdp+x+dQzIMXfm8XkjEUvLzQx//xbtYbO4RywwiRSkcYiZ4nDEQ2BQk
FxdKfakdO1tkjrKEkWb78uY3PcIb25gREG1nnJWl2aSK4bbEqy33SDpmm5yvm7el
J8xLU+YhDCtsyLLMbFFY37K8U+0lD0rmRGy+dEYLat9Gds3DjcCbkzyTUCKh9Po4
moUf
-----END CERTIFICATE-----
07070100000045000081a400000000000000000000000168ed21dd000013b4000000000000000000000000000000000000002700000000shared/ssl/testdata/chain1/root-ca.crtCertificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            49:96:04:48:85:cb:0d:ec:2c:31:fe:ef:e9:cb:12:2b:db:80:f8:71
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = DE, ST = STATE, L = CITY, O = ORG, OU = ORGUNIT, CN = RootCA
        Validity
            Not Before: Oct  2 13:09:10 2023 GMT
            Not After : Jul 22 13:09:10 2026 GMT
        Subject: C = DE, ST = STATE, L = CITY, O = ORG, OU = ORGUNIT, CN = RootCA
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:f3:67:90:0c:b8:98:e1:5c:8d:00:32:13:26:95:
                    95:6f:7c:2f:96:34:b8:6e:2f:5a:61:da:e2:bd:2e:
                    9e:8a:ad:5e:4e:27:a9:1c:08:06:7c:36:26:28:5e:
                    a7:6e:bc:04:68:eb:2c:97:b6:4b:ca:f0:0d:9c:5a:
                    47:ee:9e:15:1e:c0:62:3c:72:1b:80:01:07:67:51:
                    64:34:0f:41:50:73:21:09:d9:79:ac:73:51:db:5c:
                    a0:30:fa:79:49:02:a4:8e:cb:8f:15:dd:99:4c:b7:
                    9e:d1:ec:18:f9:6f:d2:73:27:d1:ff:c9:07:07:4e:
                    8a:6e:02:2d:6d:ab:5b:5f:5b:a2:4a:4d:c7:d6:7b:
                    26:6e:b7:a0:44:0d:82:18:43:f8:3a:49:f7:47:40:
                    d0:ed:72:dd:f2:8b:c4:9f:e5:64:24:49:f0:0d:e8:
                    5b:21:66:89:31:4a:3e:1e:9c:9b:11:89:91:9d:57:
                    af:73:64:19:bf:ed:02:8f:3f:0b:5f:aa:2c:5c:93:
                    9b:03:08:c4:a1:72:58:7a:df:cb:f2:00:8c:71:7e:
                    76:23:29:ac:c6:6a:46:2a:a9:b2:6e:f4:14:2a:16:
                    e8:7b:3c:f4:c3:14:89:11:54:d3:10:70:6c:98:c8:
                    66:e3:f7:31:cf:fd:78:76:e2:eb:2a:3e:37:a6:ce:
                    4c:07
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:TRUE
            X509v3 Key Usage:
                Digital Signature, Key Encipherment, Certificate Sign
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            Netscape Comment:
                SSL Generated Certificate
            X509v3 Subject Key Identifier:
                6E:6D:4B:35:22:23:3E:13:18:A5:93:61:0E:9C:BE:1E:D2:B8:1B:D4
            X509v3 Authority Key Identifier:
                DirName:/C=DE/ST=STATE/L=CITY/O=ORG/OU=ORGUNIT/CN=RootCA
                serial:49:96:04:48:85:CB:0D:EC:2C:31:FE:EF:E9:CB:12:2B:DB:80:F8:71
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        82:e6:ae:cb:60:cf:85:3e:00:70:37:c7:dc:c9:51:b9:70:36:
        25:e2:f5:bc:e0:8f:34:3d:67:1f:09:8e:48:e9:de:b5:78:b5:
        b5:97:f6:75:fa:fc:0f:05:c4:e1:33:ab:f5:f9:b1:32:9f:75:
        b3:c4:fd:a9:6d:c6:88:c6:a5:35:68:28:04:1d:c0:1d:92:9a:
        9b:be:52:e9:b9:9c:0d:01:b1:a8:0d:42:89:f7:f3:43:58:99:
        98:6c:0d:9f:ff:9d:10:29:68:9f:db:41:e7:b7:c6:43:67:79:
        ec:a6:f2:5b:ce:b7:d9:17:90:c2:f4:ac:56:8f:9a:af:fb:85:
        85:59:95:d1:5e:37:f6:40:2a:16:cf:53:fa:55:8b:35:48:31:
        10:c6:c4:b9:85:07:96:48:c3:dd:35:d0:04:e3:c8:fd:7e:8e:
        a2:ab:60:6b:b4:cc:f5:33:44:f8:bc:e6:b1:1b:86:f0:6d:a1:
        23:48:62:63:de:e3:27:d7:8c:9b:58:a2:10:ed:11:b6:b8:4c:
        ee:83:4a:be:0b:ee:6a:80:1d:02:91:21:4d:84:64:5f:a5:1e:
        1b:c5:8c:c6:d9:7d:0c:43:da:45:c9:13:e5:47:46:8d:bb:36:
        51:f8:72:70:d7:40:43:97:3b:71:fc:55:90:00:6e:8f:d3:c9:
        c6:73:67:15
-----BEGIN CERTIFICATE-----
MIIEVjCCAz6gAwIBAgIUSZYESIXLDewsMf7v6csSK9uA+HEwDQYJKoZIhvcNAQEL
BQAwXTELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRFMQ0wCwYDVQQHDARDSVRZ
MQwwCgYDVQQKDANPUkcxEDAOBgNVBAsMB09SR1VOSVQxDzANBgNVBAMMBlJvb3RD
QTAeFw0yMzEwMDIxMzA5MTBaFw0yNjA3MjIxMzA5MTBaMF0xCzAJBgNVBAYTAkRF
MQ4wDAYDVQQIDAVTVEFURTENMAsGA1UEBwwEQ0lUWTEMMAoGA1UECgwDT1JHMRAw
DgYDVQQLDAdPUkdVTklUMQ8wDQYDVQQDDAZSb290Q0EwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDzZ5AMuJjhXI0AMhMmlZVvfC+WNLhuL1ph2uK9Lp6K
rV5OJ6kcCAZ8NiYoXqduvARo6yyXtkvK8A2cWkfunhUewGI8chuAAQdnUWQ0D0FQ
cyEJ2Xmsc1HbXKAw+nlJAqSOy48V3ZlMt57R7Bj5b9JzJ9H/yQcHTopuAi1tq1tf
W6JKTcfWeyZut6BEDYIYQ/g6SfdHQNDtct3yi8Sf5WQkSfAN6FshZokxSj4enJsR
iZGdV69zZBm/7QKPPwtfqixck5sDCMShclh638vyAIxxfnYjKazGakYqqbJu9BQq
Fuh7PPTDFIkRVNMQcGyYyGbj9zHP/Xh24usqPjemzkwHAgMBAAGjggEMMIIBCDAM
BgNVHRMEBTADAQH/MAsGA1UdDwQEAwICpDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
KwYBBQUHAwIwKAYJYIZIAYb4QgENBBsWGVNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNh
dGUwHQYDVR0OBBYEFG5tSzUiIz4TGKWTYQ6cvh7SuBvUMIGCBgNVHSMEezB5oWGk
XzBdMQswCQYDVQQGEwJERTEOMAwGA1UECAwFU1RBVEUxDTALBgNVBAcMBENJVFkx
DDAKBgNVBAoMA09SRzEQMA4GA1UECwwHT1JHVU5JVDEPMA0GA1UEAwwGUm9vdENB
ghRJlgRIhcsN7Cwx/u/pyxIr24D4cTANBgkqhkiG9w0BAQsFAAOCAQEAguauy2DP
hT4AcDfH3MlRuXA2JeL1vOCPND1nHwmOSOnetXi1tZf2dfr8DwXE4TOr9fmxMp91
s8T9qW3GiMalNWgoBB3AHZKam75S6bmcDQGxqA1CiffzQ1iZmGwNn/+dEClon9tB
57fGQ2d57KbyW8632ReQwvSsVo+ar/uFhVmV0V439kAqFs9T+lWLNUgxEMbEuYUH
lkjD3TXQBOPI/X6Ooqtga7TM9TNE+LzmsRuG8G2hI0hiY97jJ9eMm1iiEO0RtrhM
7oNKvgvuaoAdApEhTYRkX6UeG8WMxtl9DEPaRckT5UdGjbs2UfhycNdAQ5c7cfxV
kABuj9PJxnNnFQ==
-----END CERTIFICATE-----
07070100000046000081a400000000000000000000000168ed21dd00001482000000000000000000000000000000000000002600000000shared/ssl/testdata/chain1/server.crtCertificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            67:63:f5:29:af:44:ba:79:ef:dd:dc:bf:bb:ab:3e:36:22:b8:71:28
        Signature Algorithm: sha384WithRSAEncryption
        Issuer: C=DE, ST=STATE, O=ORG, OU=ORGUNIT, CN=TeamCA
        Validity
            Not Before: Oct  2 13:09:12 2023 GMT
            Not After : Oct  1 13:09:12 2024 GMT
        Subject: C=DE, ST=STATE, O=ORG, OU=ORGUNIT, CN=uyuni-server-cert
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:83:51:78:0e:31:92:ea:d9:51:6e:9d:02:ed:d8:
                    55:dc:53:5f:7e:0c:a5:26:df:e1:c1:86:e0:38:9d:
                    67:59:e2:42:21:37:4d:5d:2c:f7:28:f1:d4:7f:00:
                    91:e4:0e:fa:eb:c8:bb:2d:f2:cd:37:62:5e:94:67:
                    83:0a:e0:69:4d:86:f4:39:be:1b:59:ec:64:65:41:
                    0c:5a:3a:7e:4b:98:8e:62:4c:4b:2c:b7:68:3c:36:
                    e2:ea:7e:58:70:e7:3e:7e:0a:b4:7f:32:b7:d0:0f:
                    15:b4:ac:24:ce:a8:f4:13:9e:62:44:7d:f6:3e:fd:
                    a5:67:5a:3d:67:54:40:89:6f:51:f0:4a:60:35:d4:
                    51:27:ca:bb:a1:5e:32:12:a5:3f:b4:e3:d0:7d:9e:
                    b2:84:4f:4e:84:db:52:00:9e:46:bf:c8:31:3b:79:
                    30:09:fe:ba:01:b7:3e:c4:9b:c4:cc:18:8b:f0:42:
                    fd:88:71:8c:d6:4c:d2:99:c8:7a:bf:7e:d6:dc:18:
                    d2:97:57:ca:0c:0c:66:52:2e:38:1c:8a:56:13:44:
                    a5:84:3d:95:70:e5:aa:94:2f:59:48:3d:22:74:8d:
                    f8:5d:ef:42:9c:e1:cf:ca:f5:24:d8:a8:8c:4e:1d:
                    75:9d:ac:15:a9:6f:f7:81:ab:5e:11:61:f7:8f:e3:
                    a5:c1
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:FALSE
            X509v3 Key Usage:
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            Netscape Cert Type:
                SSL Server
            Netscape Comment:
                SSL Generated Certificate
            X509v3 Subject Key Identifier:
                FD:F8:AA:26:08:7F:F7:FA:92:1E:68:3F:4A:29:54:E5:7A:A6:0D:42
            X509v3 Authority Key Identifier:
                keyid:62:00:25:E4:EE:70:E5:37:2D:1E:9E:AE:4E:B7:3E:FC:62:08:BF:27
                DirName:/C=DE/ST=STATE/O=ORG/OU=ORGUNIT/CN=OrgCa
                serial:67:63:F5:29:AF:44:BA:79:EF:DD:DC:BF:BB:AB:3E:36:22:B8:71:27
            X509v3 Subject Alternative Name:
                DNS:*.example.com
    Signature Algorithm: sha384WithRSAEncryption
    Signature Value:
        24:4e:b4:4d:29:e6:ad:12:e6:39:9d:95:0e:fc:b7:af:e3:55:
        60:cc:f2:57:1c:38:05:fb:1f:8c:95:40:40:b3:23:c3:11:1b:
        5f:7b:99:01:0d:fe:3a:05:7e:d0:b1:9a:c8:fc:6a:78:41:fb:
        3f:5a:6a:26:0d:dd:2e:f3:ab:d5:16:45:99:51:3e:94:87:3a:
        a7:67:e4:25:43:c9:1a:e5:84:df:15:ba:f3:11:64:99:1d:22:
        0e:44:35:9c:9b:52:e8:b0:a8:a9:04:d2:4b:cf:10:14:35:6c:
        1f:a3:ec:81:4f:2b:98:c0:02:8a:b9:03:50:f3:97:25:25:05:
        ba:c4:e0:5b:b2:34:7e:d3:d6:e1:69:d8:72:38:9e:0a:50:f6:
        25:d0:97:c3:58:37:49:40:08:11:9d:39:b1:4b:4d:e9:18:08:
        38:9d:b5:b4:a2:8b:d8:24:94:b3:b5:41:e9:17:b8:22:17:6e:
        70:33:a2:a3:ee:8e:ae:be:ee:c4:dd:6c:2c:c2:ae:8b:31:8f:
        5d:ca:9d:01:83:5d:89:59:cf:f6:30:3a:59:4d:17:82:ab:6e:
        a3:bf:4d:61:98:9a:f6:29:20:9b:eb:c5:3a:cd:06:b6:82:8c:
        24:34:65:60:12:6d:4b:ca:e6:91:0a:58:c8:97:22:89:f5:f4:
        3f:16:33:4e
-----BEGIN CERTIFICATE-----
MIIEdDCCA1ygAwIBAgIUZ2P1Ka9Eunnv3dy/u6s+NiK4cSgwDQYJKoZIhvcNAQEM
BQAwTjELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRFMQwwCgYDVQQKDANPUkcx
EDAOBgNVBAsMB09SR1VOSVQxDzANBgNVBAMMBlRlYW1DQTAeFw0yMzEwMDIxMzA5
MTJaFw0yNDEwMDExMzA5MTJaMFkxCzAJBgNVBAYTAkRFMQ4wDAYDVQQIDAVTVEFU
RTEMMAoGA1UECgwDT1JHMRAwDgYDVQQLDAdPUkdVTklUMRowGAYDVQQDDBF1eXVu
aS1zZXJ2ZXItY2VydDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAINR
eA4xkurZUW6dAu3YVdxTX34MpSbf4cGG4DidZ1niQiE3TV0s9yjx1H8AkeQO+uvI
uy3yzTdiXpRngwrgaU2G9Dm+G1nsZGVBDFo6fkuYjmJMSyy3aDw24up+WHDnPn4K
tH8yt9APFbSsJM6o9BOeYkR99j79pWdaPWdUQIlvUfBKYDXUUSfKu6FeMhKlP7Tj
0H2esoRPToTbUgCeRr/IMTt5MAn+ugG3PsSbxMwYi/BC/YhxjNZM0pnIer9+1twY
0pdXygwMZlIuOByKVhNEpYQ9lXDlqpQvWUg9InSN+F3vQpzhz8r1JNiojE4ddZ2s
Falv94GrXhFh94/jpcECAwEAAaOCAT0wggE5MAkGA1UdEwQCMAAwCwYDVR0PBAQD
AgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjARBglghkgBhvhCAQEE
BAMCBkAwKAYJYIZIAYb4QgENBBsWGVNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUw
HQYDVR0OBBYEFP34qiYIf/f6kh5oP0opVOV6pg1CMIGJBgNVHSMEgYEwf4AUYgAl
5O5w5TctHp6uTrc+/GIIvyehUaRPME0xCzAJBgNVBAYTAkRFMQ4wDAYDVQQIDAVT
VEFURTEMMAoGA1UECgwDT1JHMRAwDgYDVQQLDAdPUkdVTklUMQ4wDAYDVQQDDAVP
cmdDYYIUZ2P1Ka9Eunnv3dy/u6s+NiK4cScwGAYDVR0RBBEwD4INKi5leGFtcGxl
LmNvbTANBgkqhkiG9w0BAQwFAAOCAQEAJE60TSnmrRLmOZ2VDvy3r+NVYMzyVxw4
BfsfjJVAQLMjwxEbX3uZAQ3+OgV+0LGayPxqeEH7P1pqJg3dLvOr1RZFmVE+lIc6
p2fkJUPJGuWE3xW68xFkmR0iDkQ1nJtS6LCoqQTSS88QFDVsH6PsgU8rmMACirkD
UPOXJSUFusTgW7I0ftPW4WnYcjieClD2JdCXw1g3SUAIEZ05sUtN6RgIOJ21tKKL
2CSUs7VB6Re4IhducDOio+6Orr7uxN1sLMKuizGPXcqdAYNdiVnP9jA6WU0Xgqtu
o79NYZia9ikgm+vFOs0GtoKMJDRlYBJtS8rmkQpYyJciifX0PxYzTg==
-----END CERTIFICATE-----
07070100000047000081a400000000000000000000000168ed21dd000006a8000000000000000000000000000000000000002600000000shared/ssl/testdata/chain1/server.key-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCDUXgOMZLq2VFu
nQLt2FXcU19+DKUm3+HBhuA4nWdZ4kIhN01dLPco8dR/AJHkDvrryLst8s03Yl6U
Z4MK4GlNhvQ5vhtZ7GRlQQxaOn5LmI5iTEsst2g8NuLqflhw5z5+CrR/MrfQDxW0
rCTOqPQTnmJEffY+/aVnWj1nVECJb1HwSmA11FEnyruhXjISpT+049B9nrKET06E
21IAnka/yDE7eTAJ/roBtz7Em8TMGIvwQv2IcYzWTNKZyHq/ftbcGNKXV8oMDGZS
LjgcilYTRKWEPZVw5aqUL1lIPSJ0jfhd70Kc4c/K9STYqIxOHXWdrBWpb/eBq14R
YfeP46XBAgMBAAECggEAHjz9Sy9pKEEAelsXWJNvOfvMyma5BNmaz4hySzcbnFv4
ZFOqseDvzPLavp/v+Dbm2rJvP6ZgUPeK1dt8Fl4UgXCo/j7jZ3KCr7op0QEVIe0w
JDxzNwnIq8zrtZmAXgcxoa5vX7bbEsLWebMGCrxm77mR4TmsIVcg5kqmRwvkjIDM
SZxAJSMYVWlmyI695fMPng8f4nOxRWPBgW73XzMvlyr68OUtji9+JqI8C9mJ471S
F2qL+ubaovZM83EQ1gAol5RX8rDdkQD+/OlkLaVvdKOpp3VLKx1KszYyqhR/wYqD
4FUjK5Abxz8JlVOZkPOaF3Y9IgAuAhK45lqyUjGtvQKBgQC3wTzcpB2zjmw07iAP
B0/REU1RGPvkehsV3KnjsSvWAzrgAJMgS1l5pWJLLBRJDguX3jbDMiQ05Z9RSLdL
+IbvL75+QhbXLRiv7aGkM1xIaBrGJpCANZmHKjjy/T7LfewSwyNqlP/FdMOy+tat
2/t/IrmpArryd/cP/Pw8rfKS0wKBgQC28oixIJP3uAtQzpiqKnIovw57s8MXcMzh
QaxR00v8ss4d82T6BmJolF+gjotsYq4lVnOmL1+E/74dN8p0xL8ITIrr8AAO2Kp2
j0qWVgagvjqTq+5H7er9dRwCpKTo2dt2/JM6MCrKyNICdZdljYJiUVijTenckzeS
K78RD3BAmwKBgQCu0PNjAeuT4IJHVOhBA/bGcsx4w+kYs6ZDBTzHds26fDYt174g
8i58kX/S/muKGQekgu7cgz546J/KSADCEP3mXii/m4Z5Tdj3vn6SZZ588DXQn+3H
W7blJaEqYw2zsOe/7dAq3Pf8VZq9EvDcVLWOfW3eQc+zT7hHiKo73E0zqwKBgHa+
0a52gNRnJyEaF8lLp7F+4T21nkmWs8T5xYmO5mFtBZA3LTGD91f+BlvGagS9wF8H
0CTr1soS3SlFzykfkwcl933Q15jLVUmDFFykFcU78/VpwU36xW4iFz4387oXvfVr
V3yLSxs4Yeeqv8vwn9KFDk1hAwximc1Mi8XdCXVFAoGAVIYfKAO9KgshtbxRE3ql
kC7DhT2iZ7du8F2qLZf5tEV4WcWtxy5vI89MYHzg1MhToKPGauvOxDSqdLLuzeKa
0MWuGiV02z4nA5xv40OVWI5zylcwPeV7drCoitjvbFCpv4bKcagdVOOF8SjB25GA
yBPHa/QCsfYNCPpWHS7DYvk=
-----END PRIVATE KEY-----
07070100000048000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001b00000000shared/ssl/testdata/chain107070100000049000081a400000000000000000000000168ed21dd0000061e000000000000000000000000000000000000003400000000shared/ssl/testdata/chain2/RHN-ORG-TRUSTED-SSL-CERT-----BEGIN CERTIFICATE-----
MIIEVjCCAz6gAwIBAgIUA12e94NKtyrGIZpdEYgrqkjHXN8wDQYJKoZIhvcNAQEL
BQAwXTELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRFMQ0wCwYDVQQHDARDSVRZ
MQwwCgYDVQQKDANPUkcxEDAOBgNVBAsMB09SR1VOSVQxDzANBgNVBAMMBlJvb3RD
QTAeFw0yMzEwMDYxNTQyMzBaFw0yNjA3MjYxNTQyMzBaMF0xCzAJBgNVBAYTAkRF
MQ4wDAYDVQQIDAVTVEFURTENMAsGA1UEBwwEQ0lUWTEMMAoGA1UECgwDT1JHMRAw
DgYDVQQLDAdPUkdVTklUMQ8wDQYDVQQDDAZSb290Q0EwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDDbR3+UOxtw6KO8s/XsvkjukqSAFggAjSJxOw+KJL4
tOykM4lBXkC3nLiV6ve5Np2koi9bX1At/nk1Fxftwy37WbeVAFs6wprkI0sDbK6z
ZfT/qRoNChpYnzMFs28VCgftsOv1q5aLEUHfnSgEIK3lH3lMvDaEO6VgTDa84Y3h
DlNbj5bssq3mMHsKE5DRCSM0wXP8ZlnwfY8S/LMxf8FN8S+c3fwg6/+dUKiAHU8Q
goXQliH/NvZZPvvYTiADTY+xt6fEeZ4OdVVV31V7so3v6cIN4WwaOtGAzWKOrB4r
Oa4ZybhmEMW7rLOnSUvl+r1UyWfh/8rH+ATSYQSynI/TAgMBAAGjggEMMIIBCDAM
BgNVHRMEBTADAQH/MAsGA1UdDwQEAwICpDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
KwYBBQUHAwIwKAYJYIZIAYb4QgENBBsWGVNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNh
dGUwHQYDVR0OBBYEFDOCM0j6AGn+E4QnDh2Y9rR5Mc1DMIGCBgNVHSMEezB5oWGk
XzBdMQswCQYDVQQGEwJERTEOMAwGA1UECAwFU1RBVEUxDTALBgNVBAcMBENJVFkx
DDAKBgNVBAoMA09SRzEQMA4GA1UECwwHT1JHVU5JVDEPMA0GA1UEAwwGUm9vdENB
ghQDXZ73g0q3KsYhml0RiCuqSMdc3zANBgkqhkiG9w0BAQsFAAOCAQEAbvvh+GyX
KwFC8xaGAAHBsz0yg43LS5W9TNNOospC1qwbgCpSZJ9nWBbF2UWTKUgzjSPUpCjJ
0kMUvkpFnwFujE8IgJiP0Tha3KE3D14kj91Vfs5jDSyBsexUi8GMTP4caTMbXnU5
+q1iVhigbtOh2gSBKQvTIIdhzhghp9iFX7f68WERRVlSG/xGCSt6DXO5sgUyQb2U
ArHMJZkROIrVeGY6pXp1dWB/j6iguRUTC3GJ0JfRgx5E+pgpFnjItDQ1e2/pxsQr
ikqyFuc2CAHkhlEl0oWz+yWwCQrKkZNLABWyPWtnMecIoCqZQ79EoeQ59JZzQPrg
AQKotV5y5qBInw==
-----END CERTIFICATE-----
0707010000004a000081a400000000000000000000000168ed21dd00003c89000000000000000000000000000000000000002900000000shared/ssl/testdata/chain2/spacewalk.crtCertificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            11:b5:b3:c6:0e:7b:13:28:c9:e6:28:3c:f1:40:25:6d:cb:14:eb:3e
        Signature Algorithm: sha384WithRSAEncryption
        Issuer: C = DE, ST = STATE, O = ORG, OU = ORGUNIT, CN = TeamCA
        Validity
            Not Before: Oct  6 15:42:31 2023 GMT
            Not After : Oct  5 15:42:31 2024 GMT
        Subject: C = DE, ST = STATE, O = ORG, OU = ORGUNIT, CN = uyuni.world-co.com
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (2048 bit)
                Modulus:
                    00:b8:3f:47:da:13:fe:f4:7b:af:ff:75:fc:0b:dd:
                    e7:26:d6:34:7b:19:33:80:7d:b9:20:40:17:b8:34:
                    a4:80:3a:4c:bb:25:0c:8a:40:65:47:32:04:af:ef:
                    2d:b6:97:70:66:1e:23:28:b0:8f:98:d4:f0:2c:b6:
                    c0:40:6a:29:06:c2:8d:5e:81:5b:60:66:54:54:9f:
                    fd:77:ae:b4:62:63:87:f1:5b:fb:aa:41:cc:82:16:
                    10:3e:35:9d:99:98:63:c1:ad:2c:7b:2d:02:0e:0a:
                    af:1d:75:6d:5c:44:c1:3d:a8:28:a5:a4:53:35:10:
                    5b:58:a8:ab:54:77:ad:f4:f4:e7:5a:51:5f:75:6f:
                    05:37:fd:55:56:a2:4d:2e:3a:58:3a:a4:d6:ad:20:
                    6d:4f:7e:1d:a2:83:94:a2:6c:0c:b8:03:ba:39:55:
                    05:93:ad:7c:9f:7a:12:99:28:3e:53:9d:3a:83:bc:
                    4a:3e:6e:2e:52:e6:63:a2:fa:e7:d9:12:90:2c:5b:
                    78:52:34:92:19:19:ac:28:84:c3:25:4f:8f:f9:0d:
                    64:ef:eb:e4:bc:cd:87:89:1c:74:01:6f:e2:1a:78:
                    92:e2:2e:15:d0:8e:2b:94:69:6d:87:f4:91:f1:5d:
                    f3:47:73:95:e3:d6:80:87:93:15:6a:f7:ae:af:83:
                    b6:55
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:FALSE
            X509v3 Key Usage:
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            Netscape Cert Type:
                SSL Server
            Netscape Comment:
                SSL Generated Certificate
            X509v3 Subject Key Identifier:
                93:88:1D:52:E3:27:6E:73:35:9E:0E:AE:20:9F:E2:2E:58:41:CA:90
            X509v3 Authority Key Identifier:
                keyid:C6:C6:FD:7F:A3:EA:C1:50:0A:7E:33:1D:50:F7:E0:94:3F:93:EA:B7
                DirName:/C=DE/ST=STATE/O=ORG/OU=ORGUNIT/CN=OrgCa
                serial:11:B5:B3:C6:0E:7B:13:28:C9:E6:28:3C:F1:40:25:6D:CB:14:EB:3D

            X509v3 Subject Alternative Name:
                DNS:uyuni.world-co.com
    Signature Algorithm: sha384WithRSAEncryption
         75:04:ec:e4:e7:cc:3b:00:df:a3:5e:42:70:f4:30:91:17:8c:
         2a:19:58:6d:0c:0c:ff:8a:5e:b8:1f:03:e5:c5:01:7e:7f:c1:
         9d:25:3d:5a:89:df:0b:97:a4:f6:94:3b:ce:fc:11:f2:db:b2:
         4f:76:5a:4e:9a:6d:ef:b9:b5:69:db:c7:33:27:d8:8b:ce:a7:
         45:e5:12:84:38:48:b3:f3:54:6f:bf:fb:35:3f:ae:26:1a:03:
         b2:e1:55:45:97:eb:d2:b3:7e:d3:bd:f3:21:d0:34:56:51:15:
         88:e6:49:e3:ea:ac:e8:aa:5c:16:d2:95:fa:f2:d6:f6:f0:5a:
         e7:8b:c1:7e:f6:54:c5:a4:36:99:0c:ef:d9:c3:9d:d4:22:f9:
         55:d1:2b:10:ed:6c:9d:84:87:88:c2:b3:bf:ac:54:fa:3e:3d:
         42:5d:76:83:cb:9a:b9:a2:88:b3:99:31:ac:05:f2:d6:16:be:
         73:85:bd:56:49:17:6a:f6:81:e4:f7:ec:2a:38:50:11:b6:c6:
         af:6e:df:8a:97:57:f6:36:b6:ca:3c:04:e0:c6:2b:20:c2:c0:
         50:f7:21:ec:46:23:e5:3c:5d:e3:37:19:48:88:3c:40:10:fb:
         bd:86:40:52:bf:9f:81:9a:41:36:f6:e4:8c:3f:1b:f9:2d:04:
         09:00:3a:0e
-----BEGIN CERTIFICATE-----
MIIEejCCA2KgAwIBAgIUEbWzxg57EyjJ5ig88UAlbcsU6z4wDQYJKoZIhvcNAQEM
BQAwTjELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRFMQwwCgYDVQQKDANPUkcx
EDAOBgNVBAsMB09SR1VOSVQxDzANBgNVBAMMBlRlYW1DQTAeFw0yMzEwMDYxNTQy
MzFaFw0yNDEwMDUxNTQyMzFaMFoxCzAJBgNVBAYTAkRFMQ4wDAYDVQQIDAVTVEFU
RTEMMAoGA1UECgwDT1JHMRAwDgYDVQQLDAdPUkdVTklUMRswGQYDVQQDDBJ1eXVu
aS53b3JsZC1jby5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4
P0faE/70e6//dfwL3ecm1jR7GTOAfbkgQBe4NKSAOky7JQyKQGVHMgSv7y22l3Bm
HiMosI+Y1PAstsBAaikGwo1egVtgZlRUn/13rrRiY4fxW/uqQcyCFhA+NZ2ZmGPB
rSx7LQIOCq8ddW1cRME9qCilpFM1EFtYqKtUd6309OdaUV91bwU3/VVWok0uOlg6
pNatIG1Pfh2ig5SibAy4A7o5VQWTrXyfehKZKD5TnTqDvEo+bi5S5mOi+ufZEpAs
W3hSNJIZGawohMMlT4/5DWTv6+S8zYeJHHQBb+IaeJLiLhXQjiuUaW2H9JHxXfNH
c5Xj1oCHkxVq966vg7ZVAgMBAAGjggFCMIIBPjAJBgNVHRMEAjAAMAsGA1UdDwQE
AwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwEQYJYIZIAYb4QgEB
BAQDAgZAMCgGCWCGSAGG+EIBDQQbFhlTU0wgR2VuZXJhdGVkIENlcnRpZmljYXRl
MB0GA1UdDgQWBBSTiB1S4yduczWeDq4gn+IuWEHKkDCBiQYDVR0jBIGBMH+AFMbG
/X+j6sFQCn4zHVD34JQ/k+q3oVGkTzBNMQswCQYDVQQGEwJERTEOMAwGA1UECAwF
U1RBVEUxDDAKBgNVBAoMA09SRzEQMA4GA1UECwwHT1JHVU5JVDEOMAwGA1UEAwwF
T3JnQ2GCFBG1s8YOexMoyeYoPPFAJW3LFOs9MB0GA1UdEQQWMBSCEnV5dW5pLndv
cmxkLWNvLmNvbTANBgkqhkiG9w0BAQwFAAOCAQEAdQTs5OfMOwDfo15CcPQwkReM
KhlYbQwM/4peuB8D5cUBfn/BnSU9WonfC5ek9pQ7zvwR8tuyT3ZaTppt77m1advH
MyfYi86nReUShDhIs/NUb7/7NT+uJhoDsuFVRZfr0rN+073zIdA0VlEViOZJ4+qs
6KpcFtKV+vLW9vBa54vBfvZUxaQ2mQzv2cOd1CL5VdErEO1snYSHiMKzv6xU+j49
Ql12g8uauaKIs5kxrAXy1ha+c4W9VkkXavaB5PfsKjhQEbbGr27fipdX9ja2yjwE
4MYrIMLAUPch7EYj5Txd4zcZSIg8QBD7vYZAUr+fgZpBNvbkjD8b+S0ECQA6Dg==
-----END CERTIFICATE-----
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            11:b5:b3:c6:0e:7b:13:28:c9:e6:28:3c:f1:40:25:6d:cb:14:eb:3d
        Signature Algorithm: sha384WithRSAEncryption
        Issuer: C = DE, ST = STATE, O = ORG, OU = ORGUNIT, CN = OrgCa
        Validity
            Not Before: Oct  6 15:42:30 2023 GMT
            Not After : Nov  9 15:42:30 2024 GMT
        Subject: C = DE, ST = STATE, O = ORG, OU = ORGUNIT, CN = TeamCA
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (2048 bit)
                Modulus:
                    00:87:88:e3:ca:8e:8a:f1:5e:1e:b4:78:1d:32:79:
                    ef:bd:51:74:fb:40:8d:85:01:98:a4:b3:73:fa:18:
                    f5:5f:7c:6c:fb:56:ad:30:ee:df:da:19:cb:db:d2:
                    f8:59:8b:15:52:6a:46:c2:1c:12:4d:ed:83:a5:67:
                    97:47:9e:98:94:78:e2:fd:e4:7e:18:48:12:92:29:
                    54:49:ac:bb:8e:de:db:c2:22:37:a9:4f:0d:ff:39:
                    5a:ca:98:2b:fd:b5:ec:e2:e1:88:2a:cf:b6:3a:60:
                    1b:11:74:a2:af:fa:e6:a7:b4:71:21:f7:d9:6c:2f:
                    c5:33:d4:e2:fd:b1:93:8d:de:ff:2c:86:52:e9:84:
                    19:dd:ba:a6:0b:85:f4:64:ef:15:97:79:21:a9:da:
                    46:ef:b5:89:00:01:e0:6d:72:21:6b:ea:a3:7c:d1:
                    42:8a:26:ca:7c:f2:47:a8:8e:86:2b:a9:1b:61:66:
                    02:93:ab:57:cf:e4:7b:94:08:7f:71:62:f1:29:23:
                    35:a4:33:6c:e1:84:4c:c4:91:aa:45:b3:d4:a7:6b:
                    83:80:6b:ec:03:27:73:ff:10:20:1c:fd:aa:3d:79:
                    f7:4f:cf:c4:83:bd:4c:b1:5e:55:1c:f4:49:34:23:
                    c3:01:fc:25:b0:45:81:da:cc:10:84:66:e1:9b:c2:
                    4a:57
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:TRUE
            X509v3 Key Usage:
                Digital Signature, Key Encipherment, Certificate Sign
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            Netscape Comment:
                SSL Generated Certificate
            X509v3 Subject Key Identifier:
                C6:C6:FD:7F:A3:EA:C1:50:0A:7E:33:1D:50:F7:E0:94:3F:93:EA:B7
            X509v3 Authority Key Identifier:
                keyid:5B:5B:28:4B:13:37:60:B5:95:D4:5B:47:09:97:59:DF:16:63:AF:D9
                DirName:/C=DE/ST=STATE/L=CITY/O=ORG/OU=ORGUNIT/CN=RootCA
                serial:11:B5:B3:C6:0E:7B:13:28:C9:E6:28:3C:F1:40:25:6D:CB:14:EB:3C

    Signature Algorithm: sha384WithRSAEncryption
         92:16:c7:1b:6c:7a:f9:a1:dc:57:bd:24:45:26:0a:72:91:75:
         38:bc:f0:2c:d3:9f:ab:7a:bf:11:c0:1a:60:f3:5d:6a:ba:fe:
         f7:83:c4:21:f9:72:02:eb:47:85:16:6b:c9:38:58:6b:06:5f:
         c8:55:c1:ac:6e:9d:3c:ca:20:d1:94:15:d8:86:ed:b6:58:fc:
         56:81:15:8d:53:8f:62:da:5a:15:74:b0:78:41:da:fc:c1:69:
         fb:8d:cc:65:86:de:e5:79:f8:2d:53:1c:5c:c8:76:50:07:fe:
         f5:31:46:73:ba:e8:be:bb:f3:63:09:ae:f4:91:22:99:68:f6:
         82:b3:52:e5:92:4c:91:c8:12:b2:df:48:71:cc:ee:44:22:db:
         7e:52:97:5f:99:13:96:06:31:67:8c:00:c0:31:62:57:9a:aa:
         82:fe:e5:d9:56:07:fa:2a:15:fa:47:01:e4:ce:b0:98:fe:c0:
         ab:67:6d:7b:dc:30:d5:51:f5:13:7e:44:48:2a:d5:6f:4d:ab:
         22:0c:1a:45:bb:d3:36:37:aa:5c:f6:d3:6e:6e:d7:83:8c:3d:
         60:f5:b4:c9:67:f0:f3:a0:3a:6a:9e:7d:8c:e0:8e:75:9c:9c:
         9b:25:9b:cc:71:a2:53:4f:64:a5:8e:dd:18:7e:49:96:7d:d5:
         c0:c0:e5:2d
-----BEGIN CERTIFICATE-----
MIIETzCCAzegAwIBAgIUEbWzxg57EyjJ5ig88UAlbcsU6z0wDQYJKoZIhvcNAQEM
BQAwTTELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRFMQwwCgYDVQQKDANPUkcx
EDAOBgNVBAsMB09SR1VOSVQxDjAMBgNVBAMMBU9yZ0NhMB4XDTIzMTAwNjE1NDIz
MFoXDTI0MTEwOTE1NDIzMFowTjELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRF
MQwwCgYDVQQKDANPUkcxEDAOBgNVBAsMB09SR1VOSVQxDzANBgNVBAMMBlRlYW1D
QTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIeI48qOivFeHrR4HTJ5
771RdPtAjYUBmKSzc/oY9V98bPtWrTDu39oZy9vS+FmLFVJqRsIcEk3tg6Vnl0ee
mJR44v3kfhhIEpIpVEmsu47e28IiN6lPDf85WsqYK/217OLhiCrPtjpgGxF0oq/6
5qe0cSH32WwvxTPU4v2xk43e/yyGUumEGd26pguF9GTvFZd5IanaRu+1iQAB4G1y
IWvqo3zRQoomynzyR6iOhiupG2FmApOrV8/ke5QIf3Fi8SkjNaQzbOGETMSRqkWz
1Kdrg4Br7AMnc/8QIBz9qj1590/PxIO9TLFeVRz0STQjwwH8JbBFgdrMEIRm4ZvC
SlcCAwEAAaOCASQwggEgMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgKkMB0GA1Ud
JQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAoBglghkgBhvhCAQ0EGxYZU1NMIEdl
bmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUxsb9f6PqwVAKfjMdUPfglD+T
6rcwgZoGA1UdIwSBkjCBj4AUW1soSxM3YLWV1FtHCZdZ3xZjr9mhYaRfMF0xCzAJ
BgNVBAYTAkRFMQ4wDAYDVQQIDAVTVEFURTENMAsGA1UEBwwEQ0lUWTEMMAoGA1UE
CgwDT1JHMRAwDgYDVQQLDAdPUkdVTklUMQ8wDQYDVQQDDAZSb290Q0GCFBG1s8YO
exMoyeYoPPFAJW3LFOs8MA0GCSqGSIb3DQEBDAUAA4IBAQCSFscbbHr5odxXvSRF
JgpykXU4vPAs05+rer8RwBpg811quv73g8Qh+XIC60eFFmvJOFhrBl/IVcGsbp08
yiDRlBXYhu22WPxWgRWNU49i2loVdLB4Qdr8wWn7jcxlht7lefgtUxxcyHZQB/71
MUZzuui+u/NjCa70kSKZaPaCs1LlkkyRyBKy30hxzO5EItt+UpdfmROWBjFnjADA
MWJXmqqC/uXZVgf6KhX6RwHkzrCY/sCrZ2173DDVUfUTfkRIKtVvTasiDBpFu9M2
N6pc9tNubteDjD1g9bTJZ/DzoDpqnn2M4I51nJybJZvMcaJTT2Sljt0YfkmWfdXA
wOUt
-----END CERTIFICATE-----
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            11:b5:b3:c6:0e:7b:13:28:c9:e6:28:3c:f1:40:25:6d:cb:14:eb:3c
        Signature Algorithm: sha384WithRSAEncryption
        Issuer: C = DE, ST = STATE, L = CITY, O = ORG, OU = ORGUNIT, CN = RootCA
        Validity
            Not Before: Oct  6 15:42:30 2023 GMT
            Not After : Feb 17 15:42:30 2025 GMT
        Subject: C = DE, ST = STATE, O = ORG, OU = ORGUNIT, CN = OrgCa
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (2048 bit)
                Modulus:
                    00:c5:6a:66:7e:44:c7:79:2c:85:ab:72:48:b8:d3:
                    c2:a4:33:a9:ec:4d:4a:4c:9a:cb:f7:d8:a9:38:81:
                    70:12:f2:6c:b5:31:f4:f9:2b:5c:d3:e6:d1:d3:7e:
                    97:a7:ab:30:06:6a:82:13:15:3b:bf:1c:b5:9a:81:
                    c6:da:ab:8b:10:b7:3e:ec:21:63:72:fd:6e:cd:6e:
                    83:53:af:aa:d1:1e:66:79:42:03:50:aa:71:a5:6e:
                    ac:8f:5d:1a:b1:21:35:65:10:56:7f:fb:59:f7:f7:
                    3c:1c:41:1d:a3:bd:98:a5:df:a6:00:9a:9f:a9:f4:
                    f4:10:3f:1d:63:9e:dc:ab:44:3d:8a:2a:bc:70:7f:
                    56:e0:bd:ca:1b:45:54:94:72:db:12:02:22:c9:07:
                    f5:cf:60:6f:a8:b5:b0:cc:8e:16:25:33:21:f3:3a:
                    ac:7a:12:2c:f4:f6:25:55:be:98:4a:d0:cc:5a:25:
                    82:16:27:70:6b:d3:4d:f6:10:0f:2d:75:03:1f:90:
                    a9:31:28:24:78:c3:4a:af:54:69:46:a4:5c:c0:3a:
                    6b:94:5f:3e:b8:86:b9:40:ce:c1:0f:bf:de:5b:cf:
                    59:14:49:49:cb:d7:27:d5:d0:d2:14:b9:5b:4d:0a:
                    90:1c:e3:2b:8c:c4:d5:8d:a7:9c:db:2b:60:36:45:
                    e7:3d
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:TRUE
            X509v3 Key Usage:
                Digital Signature, Key Encipherment, Certificate Sign
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            Netscape Comment:
                SSL Generated Certificate
            X509v3 Subject Key Identifier:
                5B:5B:28:4B:13:37:60:B5:95:D4:5B:47:09:97:59:DF:16:63:AF:D9
            X509v3 Authority Key Identifier:
                keyid:33:82:33:48:FA:00:69:FE:13:84:27:0E:1D:98:F6:B4:79:31:CD:43
                DirName:/C=DE/ST=STATE/L=CITY/O=ORG/OU=ORGUNIT/CN=RootCA
                serial:03:5D:9E:F7:83:4A:B7:2A:C6:21:9A:5D:11:88:2B:AA:48:C7:5C:DF

    Signature Algorithm: sha384WithRSAEncryption
         9e:36:a2:c3:9f:7c:88:74:be:19:70:3f:bd:bf:44:93:86:1e:
         e7:72:96:66:62:d6:45:ee:c3:22:d8:09:13:2c:96:26:e8:dc:
         72:8d:6d:c4:51:68:32:58:ec:4b:45:1b:59:58:fb:ef:bc:91:
         c2:f3:ed:c6:4f:70:8b:e1:48:f7:7e:b4:b6:91:98:1d:a1:0e:
         e0:08:36:ff:d7:8f:79:d8:5d:76:f6:49:d7:c1:9e:24:58:dd:
         48:77:69:8e:80:82:ec:f5:5a:44:0d:b8:7d:5c:8e:ce:b0:1d:
         e7:3c:b4:73:10:e6:1b:9e:fb:45:42:34:64:98:58:a2:da:4b:
         b9:3f:df:61:c2:1e:25:f8:8e:84:3d:2c:e7:a9:43:54:2d:39:
         3b:9c:f9:9b:22:e1:37:dd:46:25:11:a1:c6:3a:60:18:56:56:
         8d:e0:99:31:8a:5b:ad:a5:4f:4b:b5:d4:cf:ca:91:93:1b:d4:
         41:16:56:85:fd:99:df:0d:48:c1:0c:af:4a:60:e0:d2:9e:9b:
         18:81:58:fe:54:f2:42:bc:60:70:d4:f8:0c:70:4a:b3:3f:90:
         0b:63:f3:1b:b1:2e:40:c2:ef:59:ab:49:9b:26:22:c2:09:8e:
         ec:39:d0:95:8a:fc:af:46:f9:70:09:43:6c:b4:6e:ae:f1:8e:
         c8:c4:71:e7
-----BEGIN CERTIFICATE-----
MIIEXjCCA0agAwIBAgIUEbWzxg57EyjJ5ig88UAlbcsU6zwwDQYJKoZIhvcNAQEM
BQAwXTELMAkGA1UEBhMCREUxDjAMBgNVBAgMBVNUQVRFMQ0wCwYDVQQHDARDSVRZ
MQwwCgYDVQQKDANPUkcxEDAOBgNVBAsMB09SR1VOSVQxDzANBgNVBAMMBlJvb3RD
QTAeFw0yMzEwMDYxNTQyMzBaFw0yNTAyMTcxNTQyMzBaME0xCzAJBgNVBAYTAkRF
MQ4wDAYDVQQIDAVTVEFURTEMMAoGA1UECgwDT1JHMRAwDgYDVQQLDAdPUkdVTklU
MQ4wDAYDVQQDDAVPcmdDYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AMVqZn5Ex3kshatySLjTwqQzqexNSkyay/fYqTiBcBLybLUx9PkrXNPm0dN+l6er
MAZqghMVO78ctZqBxtqrixC3PuwhY3L9bs1ug1OvqtEeZnlCA1CqcaVurI9dGrEh
NWUQVn/7Wff3PBxBHaO9mKXfpgCan6n09BA/HWOe3KtEPYoqvHB/VuC9yhtFVJRy
2xICIskH9c9gb6i1sMyOFiUzIfM6rHoSLPT2JVW+mErQzFolghYncGvTTfYQDy11
Ax+QqTEoJHjDSq9UaUakXMA6a5RfPriGuUDOwQ+/3lvPWRRJScvXJ9XQ0hS5W00K
kBzjK4zE1Y2nnNsrYDZF5z0CAwEAAaOCASQwggEgMAwGA1UdEwQFMAMBAf8wCwYD
VR0PBAQDAgKkMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAoBglghkgB
hvhCAQ0EGxYZU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUW1so
SxM3YLWV1FtHCZdZ3xZjr9kwgZoGA1UdIwSBkjCBj4AUM4IzSPoAaf4ThCcOHZj2
tHkxzUOhYaRfMF0xCzAJBgNVBAYTAkRFMQ4wDAYDVQQIDAVTVEFURTENMAsGA1UE
BwwEQ0lUWTEMMAoGA1UECgwDT1JHMRAwDgYDVQQLDAdPUkdVTklUMQ8wDQYDVQQD
DAZSb290Q0GCFANdnveDSrcqxiGaXRGIK6pIx1zfMA0GCSqGSIb3DQEBDAUAA4IB
AQCeNqLDn3yIdL4ZcD+9v0SThh7ncpZmYtZF7sMi2AkTLJYm6NxyjW3EUWgyWOxL
RRtZWPvvvJHC8+3GT3CL4Uj3frS2kZgdoQ7gCDb/14952F129knXwZ4kWN1Id2mO
gILs9VpEDbh9XI7OsB3nPLRzEOYbnvtFQjRkmFii2ku5P99hwh4l+I6EPSznqUNU
LTk7nPmbIuE33UYlEaHGOmAYVlaN4JkxilutpU9LtdTPypGTG9RBFlaF/ZnfDUjB
DK9KYODSnpsYgVj+VPJCvGBw1PgMcEqzP5ALY/MbsS5Awu9Zq0mbJiLCCY7sOdCV
ivyvRvlwCUNstG6u8Y7IxHHn
-----END CERTIFICATE-----
0707010000004b000081a400000000000000000000000168ed21dd000006a8000000000000000000000000000000000000002900000000shared/ssl/testdata/chain2/spacewalk.key-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC4P0faE/70e6//
dfwL3ecm1jR7GTOAfbkgQBe4NKSAOky7JQyKQGVHMgSv7y22l3BmHiMosI+Y1PAs
tsBAaikGwo1egVtgZlRUn/13rrRiY4fxW/uqQcyCFhA+NZ2ZmGPBrSx7LQIOCq8d
dW1cRME9qCilpFM1EFtYqKtUd6309OdaUV91bwU3/VVWok0uOlg6pNatIG1Pfh2i
g5SibAy4A7o5VQWTrXyfehKZKD5TnTqDvEo+bi5S5mOi+ufZEpAsW3hSNJIZGawo
hMMlT4/5DWTv6+S8zYeJHHQBb+IaeJLiLhXQjiuUaW2H9JHxXfNHc5Xj1oCHkxVq
966vg7ZVAgMBAAECggEAErkNgGX5Sdda2GshKISNdYcdcKfsMaG1Awe4UVX6JHSo
MPlQF6l5ET3ON6Gmw9AKUjo8SOl+QiHbYTPWAAW5sw/opUKgakCjz7CtZXDZsEjc
euSlw5Spp0t+LZAtunq/omIKa970P0CLMINrEE4FVBJnRQPYl8MYgT8sn+oEgaiI
J0p1MaxvvLWJfCF9niFWevCxjWwFNP5nYA7XdfdG8yKO7AXIlE3Xte174juTQ5+Q
neAEYnh2bp+uqEgfgvhp830NsBmegvqn7USSUqWXO++aTEwlDv70t5YWtD6GvSu2
8SWEPdAsLZkH1tzD+0jgMhdiJdcsY48fSnOxP1LHSQKBgQDI+SBLwtvAtEHI1lY7
LQ+lonJSNY/MtFEkPBAgDtU97PzF7ucoQdYitoLOyAu2d6TN7ze/H8crKjLSbKXn
X5DcID+7fLWbAeF4jDUfB9cm/zMkywxHcWL3K+aHzD08NVdTcGpIxViUZ/7ekBOr
9l+32+tRkYLumH9roUsSnLN0DQKBgQDqscbR8OtzwGi2ic/cjJvyZ1Vkbr19yeea
2WM0RiSO3vU7NY7aBSWkoHOAECKdKqJ0J7VhhMYYP+LKGFQbQQge+LmQpDRIJaM9
tq5QpOW6YYpwvGLwsbHSwfz93iRR+SO9+X9mx/FeoyOv9qKBvxv3UzdNpO5W5BKt
3oJiqbVRaQKBgQCC8sB2XNru7wTGJdI98Jh3ZidzJW8zBHKyV2hyWvfax6XUGlwH
wQ4TxDPrJDFtjPuXKz15jO0rVO2UajKXVY9/vouIUDPMcidFcqXSODuaL0JVwO+Z
RWokfzhQV2W261KhDWhTTjLvT+ujfOE0dO3dULA9j8BuUnMD4C6YS/4pqQKBgQC3
2oSyOlV43CYruVIIqG4SOzj98HKpc93nxJyeesRw1+CsfYxm5tlSWg+hJwK2tIuH
CwRgXK8CmCmFwAFDSHKgMKDN2pTKYBG9arqrmkIM/BSDtFCd1dZEEIusJLW3McD6
NdXEIqXHSW3PjxpHIfs6iQot3SKJFyo64rCpseDE4QKBgHHp6c9UEJjq+IYT6hXg
kvxS87ZWWVqFRAjS93WRS7ZllFyX9+9GfUAEaOrArJf8gvVl+8QOqVn9spjUoV0o
VIWMPlh9VRS9nQGYNTTg3vYRRCdNE0SGwNg4CL7oW1kvoPOVMk7nST/9AnGAMpkF
LY7A0vS56vx3wZuWfPbqcK1s
-----END PRIVATE KEY-----
0707010000004c000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001b00000000shared/ssl/testdata/chain20707010000004d000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001400000000shared/ssl/testdata0707010000004e000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000000b00000000shared/ssl0707010000004f000081a400000000000000000000000168ed21dd000003d4000000000000000000000000000000000000002400000000shared/templates/inspectTemplate.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package templates

import (
	"io"
	"strings"
	"text/template"

	"github.com/uyuni-project/uyuni-tools/shared/types"
)

const inspectTemplate = `
# inspect.sh, generated by mgradm
{{- range .Values }}
echo "{{ .Variable }}=$({{ .CLI }})"
{{- end }}
exit 0
`

// InspectTemplateData represents information used to create inspect script.
type InspectTemplateData struct {
	Values []types.InspectData
}

// Render will create inspect script.
func (data InspectTemplateData) Render(wr io.Writer) error {
	t := template.Must(template.New("inspect").Parse(inspectTemplate))
	return t.Execute(wr, data)
}

// GenerateScriptString creates the inspector script and returns it as a string.
func (data InspectTemplateData) GenerateScript() (string, error) {
	scriptBuilder := new(strings.Builder)
	if err := data.Render(scriptBuilder); err != nil {
		return "", err
	}

	return scriptBuilder.String(), nil
}
07070100000050000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001100000000shared/templates07070100000051000081a400000000000000000000000168ed21dd0000068d000000000000000000000000000000000000001800000000shared/testutils/api.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package testutils

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
)

const defaultCookie = "testCookie"

// GetResponse is a helper function to generate a response with the given status and json body.
func GetResponse(status int, json string) (*http.Response, error) {
	return GetResponseWithCookie(defaultCookie, status, json)
}

// GetResponseWithCookie is a helper function to generate a response with the given status and json body.
func GetResponseWithCookie(cookie string, status int, json string) (*http.Response, error) {
	body := io.NopCloser(bytes.NewReader([]byte(json)))
	headers := http.Header{}
	headers.Add("Content-Type", "application/json")
	headers.Add(
		"Set-Cookie",
		fmt.Sprintf("pxt-session-cookie=%s; Max-Age=3600; Path=/; Secure; HttpOnly;HttpOnly;Secure", cookie),
	)
	return &http.Response{
		StatusCode: status,
		Header:     headers,
		Body:       body,
	}, nil
}

// SuccessfulLoginTestDo is a helper function to mock a successful login response.
func SuccessfulLoginTestDo(req *http.Request) (*http.Response, error) {
	if req.URL.Path != "/rhn/manager/api/auth/login" {
		return &http.Response{
			StatusCode: 404,
		}, nil
	}

	return GetResponse(200, `{"success": true}`)
}

// FailedLoginTestDo is a helper function to mock a failed login response due to incorrect credentials.
func FailedLoginTestDo(req *http.Request) (*http.Response, error) {
	if req.URL.Path != "/rhn/manager/api/auth/login" {
		return &http.Response{
			StatusCode: 404,
		}, nil
	}

	return GetResponse(200, `{"success":false,"message":"Either the password or username is incorrect."}`)
}
07070100000052000081a400000000000000000000000168ed21dd000008db000000000000000000000000000000000000001c00000000shared/testutils/asserts.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package testutils

import (
	"reflect"
	"strings"
	"testing"

	"github.com/spf13/cobra"
	"github.com/spf13/pflag"
)

// AssertEquals ensures two values are equals and raises and error if not.
func AssertEquals[T any](t *testing.T, message string, expected T, actual T) {
	if !reflect.DeepEqual(actual, expected) {
		t.Errorf(message+": got '%v' expected '%v'", actual, expected)
	}
}

// AssertTrue ensures a value is true and raises and error if not.
func AssertTrue(t *testing.T, message string, actual bool) {
	if !actual {
		t.Error(message)
	}
}

// AssertNoError ensures error was not produced.
func AssertNoError(t *testing.T, message string, err error) {
	if err != nil {
		t.Errorf(message+"err: %v", err)
	}
}

// AssertHasAllFlagsIgnores ensures that all but the ignored flags are present in the args slice.
func AssertHasAllFlagsIgnores(t *testing.T, cmd *cobra.Command, args []string, ignored []string) {
	// Some flags can be in the form --foo=bar, we only want to check the --foo part.
	noValueArgs := []string{}
	for _, arg := range args {
		noValueArgs = append(noValueArgs, strings.SplitN(arg, "=", 2)[0])
	}

	cmd.Flags().VisitAll(func(flag *pflag.Flag) {
		flagString := "--" + flag.Name
		if !contains(ignored, flagString) && !contains(noValueArgs, flagString) {
			t.Error("Untested flag " + flagString)
		}
	})
}

// AssertHasAllFlags ensures that all the flags of a command are present in the args slice.
func AssertHasAllFlags(t *testing.T, cmd *cobra.Command, args []string) {
	AssertHasAllFlagsIgnores(t, cmd, args, []string{})
}

// AssertContains ensures a slice contains the expected value.
func AssertContains(t *testing.T, message string, actual []string, expected string) {
	if !contains(actual, expected) {
		t.Error(message)
	}
}

// AssertNotContains ensures a slice contains the expected value.
func AssertNotContains(t *testing.T, message string, actual []string, expected string) {
	if contains(actual, expected) {
		t.Error(message)
	}
}

// contains is copied from utils to avoid to dependency loop.
func contains(slice []string, needle string) bool {
	for _, item := range slice {
		if item == needle {
			return true
		}
	}
	return false
}
07070100000053000081a400000000000000000000000168ed21dd00000420000000000000000000000000000000000000001a00000000shared/testutils/files.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package testutils

import (
	"os"
	"testing"
)

// WriteFile writes the content in a file at the given path and fails if anything wrong happens.
func WriteFile(t *testing.T, path string, content string) {
	if err := os.WriteFile(path, []byte(content), 0755); err != nil {
		t.Fatalf("failed to write test file %s: %s", path, err)
	}
}

// ReadFile returns the content of a file as a string and fails is anything wrong happens.
func ReadFile(t *testing.T, path string) string {
	content, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("failed to read file %s: %s", path, err)
	}
	return string(content)
}

// ReadFileAsBinary returns the content of a file as a slice of int8.
func ReadFileAsBinary(t *testing.T, path string) []int8 {
	content, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("failed to read file %s: %s", path, err)
	}

	int8Content := make([]int8, len(content))
	for i, b := range content {
		int8Content[i] = int8(b)
	}

	return int8Content
}
07070100000054000081a400000000000000000000000168ed21dd00000406000000000000000000000000000000000000002300000000shared/testutils/flagstests/api.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package flagstests

import (
	"testing"

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

// APIFlagsTestArgs is the slice of parameters to use with AssertAPIFlags.
var APIFlagsTestArgs = []string{
	"--api-server", "mysrv",
	"--api-user", "apiuser",
	"--api-password", "api-pass",
	"--api-cacert", "path/to/ca.crt",
	"--api-insecure",
}

// AssertAPIFlags checks that all API parameters are parsed correctly.
func AssertAPIFlags(t *testing.T, flags *api.ConnectionDetails) {
	testutils.AssertEquals(t, "Error parsing --api-server", "mysrv", flags.Server)
	testutils.AssertEquals(t, "Error parsing --api-user", "apiuser", flags.User)
	testutils.AssertEquals(t, "Error parsing --api-password", "api-pass", flags.Password)
	testutils.AssertEquals(t, "Error parsing --api-cacert", "path/to/ca.crt", flags.CApath)
	testutils.AssertTrue(t, "Error parsing --api-insecure", flags.Insecure)
}
07070100000055000081a400000000000000000000000168ed21dd00002b29000000000000000000000000000000000000002600000000shared/testutils/flagstests/mgradm.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package flagstests

import (
	"testing"

	"github.com/uyuni-project/uyuni-tools/mgradm/shared/utils"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
	"github.com/uyuni-project/uyuni-tools/shared/types"
)

// ServerKubernetesFlagsTestArgs are the expected values for AssertServerKubernetesFlags.
var ServerKubernetesFlagsTestArgs = []string{
	"--kubernetes-uyuni-namespace", "uyunins",
	"--kubernetes-certmanager-namespace", "certmanagerns",
	"--kubernetes-certmanager-chart", "oci://srv/certmanager",
	"--kubernetes-certmanager-version", "4.5.6",
	"--kubernetes-certmanager-values", "certmanager/values.yaml",
}

// AssertServerKubernetesFlags checks that all Kubernetes flags are parsed correctly.
func AssertServerKubernetesFlags(t *testing.T, flags *utils.KubernetesFlags) {
	testutils.AssertEquals(t, "Error parsing --helm-uyuni-namespace", "uyunins", flags.Uyuni.Namespace)
	testutils.AssertEquals(t, "Error parsing --helm-certmanager-namespace",
		"certmanagerns", flags.CertManager.Namespace,
	)
	testutils.AssertEquals(t, "Error parsing --helm-certmanager-chart",
		"oci://srv/certmanager", flags.CertManager.Chart,
	)
	testutils.AssertEquals(t, "Error parsing --helm-certmanager-version", "4.5.6", flags.CertManager.Version)
	testutils.AssertEquals(t, "Error parsing --helm-certmanager-values",
		"certmanager/values.yaml", flags.CertManager.Values,
	)
}

// VolumesFlagsTestExpected is the expected values for AssertVolumesFlags.
var VolumesFlagsTestExpected = []string{
	"--volumes-class", "MyStorageClass",
	"--volumes-mirror", "mirror-pv",
	"--volumes-database-size", "123Gi",
	"--volumes-database-class", "dbclass",
	"--volumes-packages-size", "456Gi",
	"--volumes-packages-class", "pkgclass",
	"--volumes-www-size", "123Mi",
	"--volumes-www-class", "wwwclass",
	"--volumes-cache-size", "789Gi",
	"--volumes-cache-class", "cacheclass",
}

// AssertVolumesFlags checks that all the volumes flags are parsed correctly.
func AssertVolumesFlags(t *testing.T, flags *utils.VolumesFlags) {
	testutils.AssertEquals(t, "Error parsing --volumes-class", "MyStorageClass", flags.Class)
	testutils.AssertEquals(t, "Error parsing --volumes-mirror", "mirror-pv", flags.Mirror)
	testutils.AssertEquals(t, "Error parsing --volumes-database-size", "123Gi", flags.Database.Size)
	testutils.AssertEquals(t, "Error parsing --volumes-database-class", "dbclass", flags.Database.Class)
	testutils.AssertEquals(t, "Error parsing --volumes-packages-size", "456Gi", flags.Packages.Size)
	testutils.AssertEquals(t, "Error parsing --volumes-packages-class", "pkgclass", flags.Packages.Class)
	testutils.AssertEquals(t, "Error parsing --volumes-www-size", "123Mi", flags.Www.Size)
	testutils.AssertEquals(t, "Error parsing --volumes-www-class", "wwwclass", flags.Www.Class)
	testutils.AssertEquals(t, "Error parsing --volumes-cache-size", "789Gi", flags.Cache.Size)
	testutils.AssertEquals(t, "Error parsing --volumes-cache-class", "cacheclass", flags.Cache.Class)
}

// DBFlagsTestArgs is the expected values for DBFlag.
var DBFlagsTestArgs = []string{
	"--db-user", "dbuser",
	"--db-password", "dbpass",
	"--db-name", "dbname",
	"--db-host", "dbhost",
	"--db-port", "1234",
	"--db-admin-user", "dbadmin",
	"--db-admin-password", "dbadminpass",
	"--db-provider", "aws",
}

// ReportDBFlagsTestArgs is the expected values for ReportDBFlag.
var ReportDBFlagsTestArgs = []string{
	"--reportdb-user", "reportdbuser",
	"--reportdb-password", "reportdbpass",
	"--reportdb-name", "reportdbname",
	"--reportdb-host", "reportdbhost",
	"--reportdb-port", "5678",
}

// InstallDBSSLFlagsTestArgs is the expected values for InstallSSLFlagsTestArg for the DB.
var InstallDBSSLFlagsTestArgs = []string{
	"--ssl-db-ca-intermediate", "path/dbinter1.crt",
	"--ssl-db-ca-intermediate", "path/dbinter2.crt",
	"--ssl-db-ca-root", "path/dbroot.crt",
	"--ssl-db-cert", "path/dbsrv.crt",
	"--ssl-db-key", "path/dbsrv.key",
	"--ssl-password", "sslsecret",
}

// InstallSSLFlagsTestArgs is the expected values for InstallSSLFlagsTestArg.
var InstallSSLFlagsTestArgs = []string{
	"--ssl-ca-intermediate", "path/inter1.crt",
	"--ssl-ca-intermediate", "path/inter2.crt",
	"--ssl-ca-root", "path/root.crt",
	"--ssl-server-cert", "path/srv.crt",
	"--ssl-server-key", "path/srv.key",
}

// ImageFlagsTestArgs is the expected values for AssertImageFlag.
var ImageFlagsTestArgs = []string{
	"--image", "path/to/image",
	"--registry", "myregistry",
	"--tag", "v1.2.3",
	"--pullPolicy", "never",
}

// AssertImageFlag checks that all image flags are parsed correctly.
func AssertImageFlag(t *testing.T, flags *types.ImageFlags) {
	testutils.AssertEquals(t, "Error parsing --image", "path/to/image", flags.Name)
	testutils.AssertEquals(t, "Error parsing --registry", "myregistry", flags.Registry)
	testutils.AssertEquals(t, "Error parsing --tag", "v1.2.3", flags.Tag)
	testutils.AssertEquals(t, "Error parsing --pullPolicy", "never", flags.PullPolicy)
}

// DBUpdateImageFlagTestArgs is the expected values for AssertDBUpgradeImageFlag.
var DBUpdateImageFlagTestArgs = []string{
	"--dbupgrade-image", "dbupgradeimg",
	"--dbupgrade-tag", "dbupgradetag",
}

// AssertDBUpgradeImageFlag asserts that all DB upgrade image flags are parsed correctly.
func AssertDBUpgradeImageFlag(t *testing.T, flags *types.ImageFlags) {
	testutils.AssertEquals(t, "Error parsing --dbupgrade-image", "dbupgradeimg", flags.Name)
	testutils.AssertEquals(t, "Error parsing --dbupgrade-tag", "dbupgradetag", flags.Tag)
}

// MirrorFlagTestArgs is the expected values for AssertMirrorFlag.
var MirrorFlagTestArgs = []string{
	"--mirror", "/path/to/mirror",
}

// AssertMirrorFlag asserts that all mirror flags are parsed correctly.
func AssertMirrorFlag(t *testing.T, value string) {
	testutils.AssertEquals(t, "Error parsing --mirror", "/path/to/mirror", value)
}

// CocoFlagsTestArgs is the expected values for AssertCocoFlag.
var CocoFlagsTestArgs = []string{
	"--coco-image", "cocoimg",
	"--coco-tag", "cocotag",
	"--coco-replicas", "2",
}

// AssertCocoFlag asserts that all confidential computing flags are parsed correctly.
func AssertCocoFlag(t *testing.T, flags *utils.CocoFlags) {
	testutils.AssertEquals(t, "Error parsing --coco-image", "cocoimg", flags.Image.Name)
	testutils.AssertEquals(t, "Error parsing --coco-tag", "cocotag", flags.Image.Tag)
	testutils.AssertEquals(t, "Error parsing --coco-replicas", 2, flags.Replicas)
	testutils.AssertTrue(t, "Coco should be changed", flags.IsChanged)
}

// HubXmlrpcFlagsTestArgs is the expected values for AssertHubXmlrpcFlag.
var HubXmlrpcFlagsTestArgs = []string{
	"--hubxmlrpc-image", "hubimg",
	"--hubxmlrpc-tag", "hubtag",
	"--hubxmlrpc-replicas", "1",
}

// AssertHubXmlrpcFlag asserts that all hub XML-RPC API flags are parsed correctly.
func AssertHubXmlrpcFlag(t *testing.T, flags *utils.HubXmlrpcFlags) {
	testutils.AssertEquals(t, "Error parsing --hubxmlrpc-image", "hubimg", flags.Image.Name)
	testutils.AssertEquals(t, "Error parsing --hubxmlrpc-tag", "hubtag", flags.Image.Tag)
	testutils.AssertEquals(t, "Error parsing --hubxmlrpc-replicas", 1, flags.Replicas)
	testutils.AssertTrue(t, "Hub should be changed", flags.IsChanged)
}

// SalineFlagsTestArgs is the expected values for AssertSalineFlag.
var SalineFlagsTestArgs = []string{
	"--saline-image", "salineimg",
	"--saline-tag", "salinetag",
	"--saline-replicas", "1",
	"--saline-port", "8226",
}

// AssertSalineFlag asserts that all saline flags are parsed correctly.
func AssertSalineFlag(t *testing.T, flags *utils.SalineFlags) {
	testutils.AssertEquals(t, "Error parsing --saline-image", "salineimg", flags.Image.Name)
	testutils.AssertEquals(t, "Error parsing --saline-tag", "salinetag", flags.Image.Tag)
	testutils.AssertEquals(t, "Error parsing --saline-replicas", 1, flags.Replicas)
	testutils.AssertEquals(t, "Error parsing --saline-port", 8226, flags.Port)
	testutils.AssertTrue(t, "Saline should be changed", flags.IsChanged)
}

// PgsqlFlagsTestArgs is the expected values for AssertPgsqlFlag.
var PgsqlFlagsTestArgs = []string{
	"--pgsql-image", "pgsqlimg",
	"--pgsql-tag", "pgsqltag",
}

// AssertPgsqlFlag asserts that all pgsql flags are parsed correctly.
func AssertPgsqlFlag(t *testing.T, flags *types.PgsqlFlags) {
	testutils.AssertEquals(t, "Error parsing --pgsql-image", "pgsqlimg", flags.Image.Name)
	testutils.AssertEquals(t, "Error parsing --pgsql-tag", "pgsqltag", flags.Image.Tag)
}

// AssertDBFlag asserts that all DB flags are parsed correctly.
func AssertDBFlag(t *testing.T, flags *utils.DBFlags) {
	testutils.AssertEquals(t, "Error parsing --db-user", "dbuser", flags.User)
	testutils.AssertEquals(t, "Error parsing --db-pass", "dbpass", flags.Password)
	testutils.AssertEquals(t, "Error parsing --db-name", "dbname", flags.Name)
	testutils.AssertEquals(t, "Error parsing --db-host", "dbhost", flags.Host)
	testutils.AssertEquals(t, "Error parsing --db-port", 1234, flags.Port)
	testutils.AssertEquals(t, "Error parsing --db-admin-user", "dbadmin", flags.Admin.User)
	testutils.AssertEquals(t, "Error parsing --db-admin-password", "dbadminpass", flags.Admin.Password)
	testutils.AssertEquals(t, "Error parsing --db-provider", "aws", flags.Provider)
}

// AssertReportDBFlag asserts that all ReportDB flags are parsed correctly.
func AssertReportDBFlag(t *testing.T, flags *utils.DBFlags) {
	testutils.AssertEquals(t, "Error parsing --reportdb-user", "reportdbuser", flags.User)
	testutils.AssertEquals(t, "Error parsing --reportdb-password", "reportdbpass", flags.Password)
	testutils.AssertEquals(t, "Error parsing --reportdb-name", "reportdbname", flags.Name)
	testutils.AssertEquals(t, "Error parsing --reportdb-host", "reportdbhost", flags.Host)
	testutils.AssertEquals(t, "Error parsing --reportdb-port", 5678, flags.Port)
}

// AssertInstallSSLFlag asserts that all InstallSSLFlags flags are parsed correctly.
func AssertInstallSSLFlag(t *testing.T, flags *utils.InstallSSLFlags) {
	testutils.AssertEquals(t, "Error parsing --ssl-password", "sslsecret", flags.Password)
	testutils.AssertEquals(t, "Error parsing --ssl-ca-intermediate",
		[]string{"path/inter1.crt", "path/inter2.crt"}, flags.Ca.Intermediate)
	testutils.AssertEquals(t, "Error parsing --ssl-ca-root", "path/root.crt", flags.Ca.Root)
	testutils.AssertEquals(t, "Error parsing --ssl-server-cert", "path/srv.crt", flags.Server.Cert)
	testutils.AssertEquals(t, "Error parsing --ssl-server-key", "path/srv.key", flags.Server.Key)
}

// AssertInstallDBSSLFlag asserts that all InstallSSLFlags flags are parsed correctly.
func AssertInstallDBSSLFlag(t *testing.T, flags *utils.SSLFlags) {
	testutils.AssertEquals(t, "Error parsing --ssl-db-ca-intermediate",
		[]string{"path/dbinter1.crt", "path/dbinter2.crt"}, flags.CA.Intermediate)
	testutils.AssertEquals(t, "Error parsing --ssl-db-ca-root", "path/dbroot.crt", flags.CA.Root)
	testutils.AssertEquals(t, "Error parsing --ssl-db-cert", "path/dbsrv.crt", flags.Cert)
	testutils.AssertEquals(t, "Error parsing --ssl-db-key", "path/dbsrv.key", flags.Key)
}
07070100000056000081a400000000000000000000000168ed21dd00000c0d000000000000000000000000000000000000002e00000000shared/testutils/flagstests/mgradm_install.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package flagstests

import (
	"testing"

	"github.com/uyuni-project/uyuni-tools/mgradm/shared/utils"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

// InstallFlagsTestArgs is the slice of command parameters to use with AssertInstallFlags.
var InstallFlagsTestArgs = func() []string {
	args := []string{
		"--tz", "CEST",
		"--email", "admin@foo.bar",
		"--emailfrom", "sender@foo.bar",
		"--issParent", "parent.iss.com",
		"--tftp=false",
		"--reportdb-user", "reportdbuser",
		"--reportdb-password", "reportdbpass",
		"--reportdb-name", "reportdbname",
		"--reportdb-host", "reportdbhost",
		"--reportdb-port", "5678",
		"--debug-java",
		"--admin-login", "adminuser",
		"--admin-password", "adminpass",
		"--admin-firstName", "adminfirst",
		"--admin-lastName", "adminlast",
		"--organization", "someorg",
	}

	args = append(args, SCCFlagTestArgs...)
	args = append(args, ImageFlagsTestArgs...)
	args = append(args, CocoFlagsTestArgs...)
	args = append(args, HubXmlrpcFlagsTestArgs...)
	args = append(args, SalineFlagsTestArgs...)
	args = append(args, SSLGenerationFlagsTestArgs...)
	args = append(args, PgsqlFlagsTestArgs...)
	args = append(args, DBFlagsTestArgs...)
	args = append(args, ReportDBFlagsTestArgs...)
	args = append(args, InstallSSLFlagsTestArgs...)
	args = append(args, InstallDBSSLFlagsTestArgs...)

	return args
}

// AssertInstallFlags checks that all the install flags are parsed correctly.
func AssertInstallFlags(t *testing.T, flags *utils.ServerFlags) {
	testutils.AssertEquals(t, "Error parsing --tz", "CEST", flags.Installation.TZ)
	testutils.AssertEquals(t, "Error parsing --email", "admin@foo.bar", flags.Installation.Email)
	testutils.AssertEquals(t, "Error parsing --emailfrom", "sender@foo.bar", flags.Installation.EmailFrom)
	testutils.AssertEquals(t, "Error parsing --issParent", "parent.iss.com", flags.Installation.IssParent)
	testutils.AssertEquals(t, "Error parsing --tftp", false, flags.Installation.Tftp)
	testutils.AssertTrue(t, "Error parsing --debug-java", flags.Installation.Debug.Java)
	testutils.AssertEquals(t, "Error parsing --admin-login", "adminuser", flags.Installation.Admin.Login)
	testutils.AssertEquals(t, "Error parsing --admin-password", "adminpass", flags.Installation.Admin.Password)
	testutils.AssertEquals(t, "Error parsing --admin-firstName", "adminfirst", flags.Installation.Admin.FirstName)
	testutils.AssertEquals(t, "Error parsing --admin-lastName", "adminlast", flags.Installation.Admin.LastName)
	testutils.AssertEquals(t, "Error parsing --organization", "someorg", flags.Installation.Organization)
	AssertSCCFlag(t, &flags.Installation.SCC)
	AssertImageFlag(t, &flags.Image)
	AssertCocoFlag(t, &flags.Coco)
	AssertHubXmlrpcFlag(t, &flags.HubXmlrpc)
	AssertSalineFlag(t, &flags.Saline)
	AssertPgsqlFlag(t, &flags.Pgsql)
	AssertDBFlag(t, &flags.Installation.DB)
	AssertReportDBFlag(t, &flags.Installation.ReportDB)
	AssertInstallSSLFlag(t, &flags.Installation.SSL)
	AssertInstallDBSSLFlag(t, &flags.Installation.SSL.DB)
}
07070100000057000081a400000000000000000000000168ed21dd00000cd5000000000000000000000000000000000000003100000000shared/testutils/flagstests/mgrpxy_kubernetes.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package flagstests

import (
	"testing"

	"github.com/uyuni-project/uyuni-tools/mgrpxy/shared/kubernetes"
	"github.com/uyuni-project/uyuni-tools/mgrpxy/shared/utils"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

// ProxyHelmFlagsTestArgs is the slice of parameters to use with AssertHelmFlags.
var ProxyHelmFlagsTestArgs = []string{
	"--helm-proxy-namespace", "uyunins",
	"--helm-proxy-chart", "oci://srv/proxy-helm",
	"--helm-proxy-version", "v1.2.3",
	"--helm-proxy-values", "path/value.yaml",
}

// AssertProxyHelmFlags checks that the proxy helm flags are parsed correctly.
func AssertProxyHelmFlags(t *testing.T, flags *kubernetes.HelmFlags) {
	testutils.AssertEquals(t, "Error parsing --helm-proxy-namespace", "uyunins", flags.Proxy.Namespace)
	testutils.AssertEquals(t, "Error parsing --helm-proxy-chart", "oci://srv/proxy-helm", flags.Proxy.Chart)
	testutils.AssertEquals(t, "Error parsing --helm-proxy-version", "v1.2.3", flags.Proxy.Version)
	testutils.AssertEquals(t, "Error parsing --helm-proxy-values", "path/value.yaml", flags.Proxy.Values)
}

// ImageProxyFlagsTestArgs is the slice of parameters to use with AssertImageFlags.
var ImageProxyFlagsTestArgs = []string{
	"--registry", "myregistry.com",
	"--tag", "v1.2.3",
	"--pullPolicy", "never",
	"--httpd-image", "path/to/httpd",
	"--httpd-tag", "httpd-tag",
	"--saltbroker-image", "path/to/saltbroker",
	"--saltbroker-tag", "saltbroker-tag",
	"--squid-image", "path/to/squid",
	"--squid-tag", "squid-tag",
	"--ssh-image", "path/to/ssh",
	"--ssh-tag", "ssh-tag",
	"--tftpd-image", "path/to/tftpd",
	"--tftpd-tag", "tftpd-tag",
	"--tuning-httpd", "path/to/httpd.conf",
	"--tuning-squid", "path/to/squid.conf",
}

// AssertProxyImageFlags checks that all image flags are parsed correctly.
func AssertProxyImageFlags(t *testing.T, flags *utils.ProxyImageFlags) {
	testutils.AssertEquals(t, "Error parsing --registry", "myregistry.com", flags.Registry)
	testutils.AssertEquals(t, "Error parsing --tag", "v1.2.3", flags.Tag)
	testutils.AssertEquals(t, "Error parsing --pullPolicy", "never", flags.PullPolicy)
	testutils.AssertEquals(t, "Error parsing --httpd-image", "path/to/httpd", flags.Httpd.Name)
	testutils.AssertEquals(t, "Error parsing --httpd-tag", "httpd-tag", flags.Httpd.Tag)
	testutils.AssertEquals(t, "Error parsing --saltbroker-image", "path/to/saltbroker", flags.SaltBroker.Name)
	testutils.AssertEquals(t, "Error parsing --saltbroker-tag", "saltbroker-tag", flags.SaltBroker.Tag)
	testutils.AssertEquals(t, "Error parsing --squid-image", "path/to/squid", flags.Squid.Name)
	testutils.AssertEquals(t, "Error parsing --squid-tag", "squid-tag", flags.Squid.Tag)
	testutils.AssertEquals(t, "Error parsing --ssh-image", "path/to/ssh", flags.SSH.Name)
	testutils.AssertEquals(t, "Error parsing --ssh-tag", "ssh-tag", flags.SSH.Tag)
	testutils.AssertEquals(t, "Error parsing --tftpd-image", "path/to/tftpd", flags.Tftpd.Name)
	testutils.AssertEquals(t, "Error parsing --tftpd-tag", "tftpd-tag", flags.Tftpd.Tag)
	testutils.AssertEquals(t, "Error parsing --tuning-httpd", "path/to/httpd.conf", flags.Tuning.Httpd)
	testutils.AssertEquals(t, "Error parsing --tuning-squid", "path/to/squid.conf", flags.Tuning.Squid)
}
07070100000058000081a400000000000000000000000168ed21dd0000027f000000000000000000000000000000000000002600000000shared/testutils/flagstests/podman.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package flagstests

import (
	"testing"

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

// PodmanFlagsTestArgs is the values for PodmanFlagsTestArgs.
var PodmanFlagsTestArgs = []string{
	"--podman-arg", "arg1",
	"--podman-arg", "arg2",
}

// AssertPodmanInstallFlags checks that all podman flags are parsed correctly.
func AssertPodmanInstallFlags(t *testing.T, flags *podman.PodmanFlags) {
	testutils.AssertEquals(t, "Error parsing --podman-arg", []string{"arg1", "arg2"}, flags.Args)
}
07070100000059000081a400000000000000000000000168ed21dd000002b5000000000000000000000000000000000000002300000000shared/testutils/flagstests/scc.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package flagstests

import (
	"testing"

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

// SCCFlagTestArgs is the expected values for AssertSccFlag.
var SCCFlagTestArgs = []string{
	"--scc-user", "mysccuser",
	"--scc-password", "mysccpass",
}

// AssertSCCFlag checks that all SCC flags are parsed correctly.
func AssertSCCFlag(t *testing.T, flags *types.SCCCredentials) {
	testutils.AssertEquals(t, "Error parsing --scc-user", "mysccuser", flags.User)
	testutils.AssertEquals(t, "Error parsing --scc-password", "mysccpass", flags.Password)
}
0707010000005a000081a400000000000000000000000168ed21dd00000662000000000000000000000000000000000000002600000000shared/testutils/flagstests/server.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package flagstests

import (
	"testing"

	"github.com/uyuni-project/uyuni-tools/mgradm/shared/utils"
)

// ServerFlagsTestArgs is the slide of server-related command parameters to use with AssertServerFlags.
var ServerFlagsTestArgs = func() []string {
	args := []string{}
	args = append(args, SCCFlagTestArgs...)
	args = append(args, PgsqlFlagsTestArgs...)
	args = append(args, DBFlagsTestArgs...)
	args = append(args, ReportDBFlagsTestArgs...)
	args = append(args, InstallSSLFlagsTestArgs...)
	args = append(args, InstallDBSSLFlagsTestArgs...)
	args = append(args, SSLGenerationFlagsTestArgs...)
	args = append(args, SalineFlagsTestArgs...)
	args = append(args, ImageFlagsTestArgs...)
	args = append(args, DBUpdateImageFlagTestArgs...)
	args = append(args, CocoFlagsTestArgs...)
	args = append(args, HubXmlrpcFlagsTestArgs...)
	return args
}

// AssertServerFlags checks that all the server-related common flags are parsed correctly.
func AssertServerFlags(t *testing.T, flags *utils.ServerFlags) {
	AssertImageFlag(t, &flags.Image)
	AssertDBUpgradeImageFlag(t, &flags.DBUpgradeImage)
	AssertCocoFlag(t, &flags.Coco)
	AssertHubXmlrpcFlag(t, &flags.HubXmlrpc)
	AssertSalineFlag(t, &flags.Saline)
	AssertSCCFlag(t, &flags.Installation.SCC)
	AssertPgsqlFlag(t, &flags.Pgsql)
	AssertDBFlag(t, &flags.Installation.DB)
	AssertReportDBFlag(t, &flags.Installation.ReportDB)
	AssertInstallDBSSLFlag(t, &flags.Installation.SSL.DB)
	AssertInstallSSLFlag(t, &flags.Installation.SSL)
	AssertSSLGenerationFlag(t, &flags.Installation.SSL.SSLCertGenerationFlags)
}
0707010000005b000081a400000000000000000000000168ed21dd000004d2000000000000000000000000000000000000002300000000shared/testutils/flagstests/ssl.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package flagstests

import (
	"testing"

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

// SSLGenerationFlagsTestArgs is the slice of command parameters to use with AssertSSLGenerationFlags.
var SSLGenerationFlagsTestArgs = []string{
	"--ssl-cname", "cname1",
	"--ssl-cname", "cname2",
	"--ssl-country", "OS",
	"--ssl-state", "sslstate",
	"--ssl-city", "sslcity",
	"--ssl-org", "sslorg",
	"--ssl-ou", "sslou",
}

// AssertSSLGenerationFlag checks that all the SSL certificate generation flags are parsed correctly.
func AssertSSLGenerationFlag(t *testing.T, flags *types.SSLCertGenerationFlags) {
	testutils.AssertEquals(t, "Error parsing --ssl-cname", []string{"cname1", "cname2"}, flags.Cnames)
	testutils.AssertEquals(t, "Error parsing --ssl-country", "OS", flags.Country)
	testutils.AssertEquals(t, "Error parsing --ssl-state", "sslstate", flags.State)
	testutils.AssertEquals(t, "Error parsing --ssl-city", "sslcity", flags.City)
	testutils.AssertEquals(t, "Error parsing --ssl-org", "sslorg", flags.Org)
	testutils.AssertEquals(t, "Error parsing --ssl-ou", "sslou", flags.OU)
}
0707010000005c000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001c00000000shared/testutils/flagstests0707010000005d000081a400000000000000000000000168ed21dd00000680000000000000000000000000000000000000001b00000000shared/testutils/runner.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package testutils

import (
	"bytes"

	"github.com/rs/zerolog"
	"github.com/uyuni-project/uyuni-tools/shared/types"
	"golang.org/x/net/context"
)

type fakeRunner struct {
	out []byte
	err error
}

func (r fakeRunner) Log(_ zerolog.Level) types.Runner {
	return r
}

func (r fakeRunner) Spinner(_ string) types.Runner {
	return r
}

func (r fakeRunner) StdMapping() types.Runner {
	return r
}

func (r fakeRunner) Std(buf *bytes.Buffer) types.Runner {
	buf.Write(r.out)
	return r
}

func (r fakeRunner) InputString(_ string) types.Runner {
	return r
}

func (r fakeRunner) Env(_ []string) types.Runner {
	return r
}

func (r fakeRunner) Exec() ([]byte, error) {
	return r.out, r.err
}

func (r fakeRunner) Start() error {
	return r.err
}

func (r fakeRunner) Wait() error {
	return r.err
}

// FakeRunnerGenerator creates NewRunner function generating a FakeRunner.
// out and err are the returns of the mocked Exec().
func FakeRunnerGenerator(out string, err error) func(string, ...string) types.Runner {
	return func(_ string, _ ...string) types.Runner {
		runner := fakeRunner{
			out: []byte(out),
			err: err,
		}
		return runner
	}
}

// FakeContextRunnerGenerator creates NewContextRunner function generating a FakeRunner.
// out is returned via Std mocking to the buffer. To be used with Start() and Wait().
func FakeContextRunnerGenerator(out string, err error) func(context.Context, string, ...string) types.Runner {
	return func(_ context.Context, _ string, _ ...string) types.Runner {
		runner := fakeRunner{
			out: []byte(out),
			err: err,
		}
		return runner
	}
}
0707010000005e000081a400000000000000000000000168ed21dd00001329000000000000000000000000000000000000001c00000000shared/testutils/systemd.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package testutils

import (
	"errors"
	"fmt"
)

// FakeSystemdDriver is a dummy implementation of the systemd driver for unit tests.
type FakeSystemdDriver struct {
	// Installed is the slice of installed services.
	// Instantiated services are to be listed with the trailing @.
	Installed []string

	// Enabled is the slice of enabled services.
	// All the instances of the instantiated services need to be listed here.
	Enabled []string

	// Running is the slice of running services.
	// All the instances of the instantiated services need to be listed here.
	Running []string

	// DisableServiceErrors maps an error with a service name to mock errors in DisableService.
	DisableServiceErrors map[string]error

	// EnableServiceErrors maps an error with a service name to mock errors in EnableService.
	EnableServiceErrors map[string]error

	// ReloadDaemonError is the error to return in ReloadDaemon.
	ReloadDaemonError error

	// RestartServiceErrors maps an error with a service name to mock errors in RestartService.
	RestartServiceErrors map[string]error

	// StartServiceErrors maps an error with a service name to mock errors in StartService.
	StartServiceErrors map[string]error

	// StopServiceErrors maps an error with a service name to mock errors in StopService.
	StopServiceErrors map[string]error

	// ServiceProperties maps all the properties of each service.
	ServiceProperties map[string]map[string]string
}

// HasService returns if a systemd service is installed.
// name is the name of the service without the '.service' part.
func (d *FakeSystemdDriver) HasService(name string) bool {
	return contains(d.Installed, name)
}

// ServiceIsEnabled returns if a service is enabled
// name is the name of the service without the '.service' part.
func (d *FakeSystemdDriver) ServiceIsEnabled(name string) bool {
	return contains(d.Enabled, name)
}

// DisableService disables a service
// name is the name of the service without the '.service' part.
func (d *FakeSystemdDriver) DisableService(name string) error {
	if !d.ServiceIsEnabled(name) {
		return fmt.Errorf("%s service is not enabled", name)
	}
	err := d.DisableServiceErrors[name]
	if err == nil {
		d.Enabled = deleteItems(d.Enabled, name)
	}
	return err
}

// EnableService enables and starts a systemd service.
func (d *FakeSystemdDriver) EnableService(service string) error {
	err := d.EnableServiceErrors[service]
	if err == nil && !contains(d.Enabled, service) {
		d.Enabled = append(d.Enabled, service)
	}
	return err
}

// ReloadDaemon resets the failed state of services and reload the systemd daemon.
// If dryRun is set to true, nothing happens but messages are logged to explain what would be done.
func (d *FakeSystemdDriver) ReloadDaemon() error {
	return d.ReloadDaemonError
}

// IsServiceRunning returns whether the systemd service is started or not.
func (d *FakeSystemdDriver) IsServiceRunning(service string) bool {
	return contains(d.Running, service)
}

// RestartService restarts the systemd service.
func (d *FakeSystemdDriver) RestartService(service string) error {
	if !d.ServiceIsEnabled(service) {
		return fmt.Errorf("%s service is not enabled", service)
	}
	err := d.RestartServiceErrors[service]
	// Same implementation than start, may be this needs to be enhanced for unit tests observability
	if err == nil && !contains(d.Running, service) {
		d.Running = append(d.Running, service)
	}
	return err
}

// StartService starts the systemd service.
func (d *FakeSystemdDriver) StartService(service string) error {
	if !d.ServiceIsEnabled(service) {
		return fmt.Errorf("%s service is not enabled", service)
	}
	err := d.StartServiceErrors[service]
	if err == nil && !contains(d.Running, service) {
		d.Running = append(d.Running, service)
	}
	return err
}

// StopService starts the systemd service.
func (d *FakeSystemdDriver) StopService(service string) error {
	if !d.ServiceIsEnabled(service) {
		return fmt.Errorf("%s service is not enabled", service)
	}
	err := d.StopServiceErrors[service]
	if err == nil {
		d.Running = deleteItems(d.Running, service)
	}
	return err
}

// GetServiceProperty gets the value from the ServiceProperties structure.
// An error is returned if either the service or property doesn't exist.
func (d *FakeSystemdDriver) GetServiceProperty(service string, property string) (string, error) {
	properties, exists := d.ServiceProperties[service]
	if !exists {
		return "", errors.New("no such service")
	}
	value, exists := properties[property]
	if !exists {
		return "", errors.New("no such property")
	}
	return value, nil
}

// deleteItems removes all items equal to needle in the slice.
func deleteItems(slice []string, needle string) []string {
	cleaned := []string{}
	for _, item := range slice {
		if item != needle {
			cleaned = append(cleaned, item)
		}
	}
	return cleaned
}
0707010000005f000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001100000000shared/testutils07070100000060000081a400000000000000000000000168ed21dd000000fd000000000000000000000000000000000000001600000000shared/types/chart.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

// ChartFlags represents the flags required by charts.
type ChartFlags struct {
	Namespace string
	Chart     string
	Version   string
	Values    string
}
07070100000061000081a400000000000000000000000168ed21dd00000819000000000000000000000000000000000000001b00000000shared/types/deployment.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0
package types

// VolumeMount type used for mapping pod definition structure.
type VolumeMount struct {
	MountPath string `json:"mountPath,omitempty"`
	Name      string `json:"name,omitempty"`
	Size      string `json:"size,omitempty"`
	Class     string `json:"class,omitempty"`
}

// Container type used for mapping pod definition structure.
type Container struct {
	Name         string        `json:"name,omitempty"`
	Image        string        `json:"image,omitempty"`
	VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"`
}

// PersistentVolumeClaim type used for mapping Volume structure.
type PersistentVolumeClaim struct {
	ClaimName string `json:"claimName,omitempty"`
}

// HostPath type used for mapping Volume structure.
type HostPath struct {
	Path string `json:"path,omitempty"`
	Type string `json:"type,omitempty"`
}

// SecretItem for mapping Secret structure.
type SecretItem struct {
	Key  string `json:"key,omitempty"`
	Path string `json:"path,omitempty"`
}

// Secret type for mapping Volume structure.
type Secret struct {
	SecretName string       `json:"secretName,omitempty"`
	Items      []SecretItem `json:"items,omitempty"`
}

// Volume type for mapping Spec structure.
type Volume struct {
	Name                  string                 `json:"name,omitempty"`
	PersistentVolumeClaim *PersistentVolumeClaim `json:"persistentVolumeClaim,omitempty"`
	HostPath              *HostPath              `json:"hostPath,omitempty"`
	Secret                *Secret                `json:"secret,omitempty"`
}

// Spec type for mapping Deployment structure.
type Spec struct {
	NodeName      string      `json:"nodeName,omitempty"`
	RestartPolicy string      `json:"restartPolicy,omitempty"`
	Containers    []Container `json:"containers,omitempty"`
	Volumes       []Volume    `json:"volumes,omitempty"`
}

// Deployment type can store k8s deployment data.
type Deployment struct {
	APIVersion string `json:"apiVersion,omitempty"`
	Spec       *Spec  `json:"spec,omitempty"`
}
07070100000062000081a400000000000000000000000168ed21dd00000448000000000000000000000000000000000000001700000000shared/types/distro.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

// Distribution contains information about the distribution.
type Distribution struct {
	TreeLabel    string
	BasePath     string
	ChannelLabel string
	InstallType  string
}

// DistributionDetails contains distro details passed from the command line.
type DistributionDetails struct {
	Name    string
	Version string
	Arch    Arch
}

// ProductMap contains mapping of distro, version arch to the Distribution.
type ProductMap map[string]map[string]map[Arch]Distribution

// Arch type to store architecture.
type Arch string

// Constants for supported archhitectures.
const (
	UnknownArch Arch = "unknown"
	AMD64       Arch = "x86_64"
	AArch64     Arch = "aarch64"
	S390X       Arch = "s390x"
	PPC64LE     Arch = "ppc64le"
)

// GetArch translates string representation of architecture to Arch type.
func GetArch(a string) Arch {
	switch a {
	case "x86_64":
		return AMD64
	case "aarch64":
		return AArch64
	case "s390x":
		return S390X
	case "ppc64le":
		return PPC64LE
	}
	return UnknownArch
}
07070100000063000081a400000000000000000000000168ed21dd000000df000000000000000000000000000000000000001700000000shared/types/global.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

// GlobalFlags represents the flags used by all commands.
type GlobalFlags struct {
	ConfigPath string
	LogLevel   string
}
07070100000064000081a400000000000000000000000168ed21dd00000398000000000000000000000000000000000000001700000000shared/types/images.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

// ImageFlags represents the flags used by an image.
type ImageFlags struct {
	Registry   string `mapstructure:"registry"`
	Name       string `mapstructure:"image"`
	Tag        string `mapstructure:"tag"`
	PullPolicy string `mapstructure:"pullPolicy"`
}

// PgsqlFlags contains settings for Pgsql container.
type PgsqlFlags struct {
	Replicas  int
	Image     ImageFlags `mapstructure:",squash"`
	IsChanged bool
}

// ImageMetadata represents the image metadata of an RPM image.
type ImageMetadata struct {
	Name string   `json:"name"`
	Tags []string `json:"tags"`
	File string   `json:"file"`
}

// Metadata represents the metadata of an RPM image.
type Metadata struct {
	Image ImageMetadata `json:"image"`
}

// SCCCredentials can store SCC Credentials.
type SCCCredentials struct {
	User     string
	Password string
}
07070100000065000081a400000000000000000000000168ed21dd000001c6000000000000000000000000000000000000001800000000shared/types/inspect.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

/* InspectData represents CLI command to run in the container
* and the variable where the output is stored.
 */
type InspectData struct {
	Variable string
	CLI      string
}

// NewInspectData creates an InspectData instance.
func NewInspectData(variable string, cli string) InspectData {
	return InspectData{
		Variable: variable,
		CLI:      cli,
	}
}
07070100000066000081a400000000000000000000000168ed21dd000000e7000000000000000000000000000000000000001900000000shared/types/networks.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

// PortMap describes a port.
type PortMap struct {
	Service  string
	Name     string
	Exposed  int
	Port     int
	Protocol string
}
07070100000067000081a400000000000000000000000168ed21dd00000537000000000000000000000000000000000000001700000000shared/types/runner.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

import (
	"bytes"

	"github.com/rs/zerolog"
)

// Runner is an interface to execute system calls.
type Runner interface {
	// Log sets the log level of the output.
	Log(logLevel zerolog.Level) Runner

	// Spinner sets a spinner with its message.
	// If no message is passed, the command will be used.
	Spinner(message string) Runner

	// StdMapping maps the process output and error streams to the standard ones.
	// This is useful to show the process output in the console and the logs and can be combined with Log().
	StdMapping() Runner

	// Std maps the process output to the out bytes buffer.
	// This is useful to get process output for backgrounds tasks.
	Std(out *bytes.Buffer) Runner

	// Wait waits for the command running in the background to ends.
	Wait() error

	// InputString adds a string as input of the process.
	InputString(input string) Runner

	// Env sets environment variables to use for the command.
	Env(env []string) Runner

	// Exec really executes the command and returns its output and error.
	// The error output to used as error message if the StdMapping() function wasn't called.
	Exec() ([]byte, error)

	// Start starts the command, particularly for commands to run in the background.
	Start() error
}
07070100000068000081a400000000000000000000000168ed21dd00000457000000000000000000000000000000000000001400000000shared/types/ssl.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

// SSLCertGenerationFlags stores informations to generate an SSL Certificate.
type SSLCertGenerationFlags struct {
	Cnames   []string `mapstructure:"cname"`
	Country  string
	State    string
	City     string
	Org      string
	OU       string
	Password string
	Email    string
}

// CaChain is a type to store CA Chain.
type CaChain struct {
	Root         string
	Intermediate []string
	// Key is the CA key file in the case of a migration of a self-generate CA.
	Key string
}

// IsThirdParty returns whether the CA chain is a third party one.
func (c *CaChain) IsThirdParty() bool {
	return c.IsDefined() && c.Key == ""
}

// IsDefined returns whether the CA chain is defined.
// At least the CA root certificate is available.
func (c *CaChain) IsDefined() bool {
	return c.Root != ""
}

// SSLPair is a type for SSL Cert and Key.
type SSLPair struct {
	Cert string
	Key  string
}

// IsDefined returns whether the SSL pair is defined.
func (p *SSLPair) IsDefined() bool {
	return p.Cert != "" && p.Key != ""
}
07070100000069000081a400000000000000000000000168ed21dd00000255000000000000000000000000000000000000001d00000000shared/types/uyuniservice.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package types

type UyuniServiceOption struct {
	Name        string
	Value       interface{}
	Description string
}

type UyuniServiceReplicas struct {
	Max     uint
	Min     uint
	Default uint
}

type UyuniService struct {
	Name        string
	Image       ImageFlags
	Description string
	Replicas    UyuniServiceReplicas
	Options     []UyuniServiceOption
}

var SingleMandatoryReplica = UyuniServiceReplicas{Max: 1, Min: 1, Default: 1}
var SingleOptionalReplica = UyuniServiceReplicas{Max: 1, Min: 0, Default: 0}
0707010000006a000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000000d00000000shared/types0707010000006b000081a400000000000000000000000168ed21dd00001168000000000000000000000000000000000000001400000000shared/utils/cmd.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"github.com/rs/zerolog/log"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/types"
)

// LocaleRoot is the default path where to look for locale files.
//
// On SUSE distros this should be overridden with /usr/share/locale.
var LocaleRoot = "locale"

// DefaultRegistry represents the default name used for container image.
var DefaultRegistry = "registry.opensuse.org/uyuni"

// DefaultHelmRegistry represents the default name used for helm charts.
var DefaultHelmRegistry = "registry.opensuse.org/uyuni"

// DefaultTag represents the default tag used for image.
var DefaultTag = "latest"

// DefaultPullPolicy represents the default pull policy used for image.
var DefaultPullPolicy = "Always"

// Version is the tools version.
//
// This variable needs to be set a build time using git tags.
var Version = "0.0.0"

// CommandFunc is a function to be executed by a Cobra command.
type CommandFunc[F interface{}] func(*types.GlobalFlags, *F, *cobra.Command, []string) error

// FlagsUpdaterFunc is a function to be executed to update the flags from the viper instance used to parsed the config.
type FlagsUpdaterFunc func(*viper.Viper)

// CommandHelper parses the configuration file into the flags and runs the fn function.
// This function should be passed to Command's RunE.
func CommandHelper[T interface{}](
	globalFlags *types.GlobalFlags,
	cmd *cobra.Command,
	args []string,
	flags *T,
	flagsUpdater FlagsUpdaterFunc,
	fn CommandFunc[T],
) error {
	viper, err := ReadConfig(cmd, GlobalConfigFilename, globalFlags.ConfigPath)
	if err != nil {
		return err
	}
	if err := viper.Unmarshal(&flags); err != nil {
		log.Error().Err(err).Msg(L("failed to unmarshall configuration"))
		return Error(err, L("failed to unmarshall configuration"))
	}
	if flagsUpdater != nil {
		flagsUpdater(viper)
	}
	err = fn(globalFlags, flags, cmd, args)
	if err != nil {
		log.Error().Err(err).Send()
	}
	return err
}

// AddBackendFlag add the flag for setting the backend ('podman', 'podman-remote', 'kubectl').
func AddBackendFlag(cmd *cobra.Command) {
	cmd.Flags().String("backend", "",
		L(`tool to use to reach the container. Possible values: 'podman', 'podman-remote', 'kubectl'.
Default guesses which to use.`),
	)
}

// AddPullPolicyFlag adds the --pullPolicy flag to a command.
//
// Since podman doesn't have such a concept of pull policy like kubernetes,
// the values need some explanations for it:
//   - Never: just check and fail if needed
//   - IfNotPresent: check and pull
//   - Always: pull without checking
//
// For kubernetes the value is simply passed to the helm charts.
func AddPullPolicyFlag(cmd *cobra.Command) {
	cmd.Flags().String("pullPolicy", DefaultPullPolicy,
		L("set whether to pull the images or not. The value can be one of 'Never', 'IfNotPresent' or 'Always'"))
}

// AddPTFFlag add PTF flag to a command.
func AddPTFFlag(cmd *cobra.Command) {
	cmd.Flags().String("ptf", "", L("PTF ID"))
	cmd.Flags().String("test", "", L("Test package ID"))
	cmd.Flags().String("user", "", L("SCC user"))
}

// PurgeFlags defined what has te be removed in an uninstall command.
type PurgeFlags struct {
	Volumes bool
	Images  bool
}

// UninstallFlags are the common flags for uninstall commands.
type UninstallFlags struct {
	Backend string
	Force   bool
	Purge   PurgeFlags
}

// AddUninstallFlags adds the common flags for uninstall commands.
func AddUninstallFlags(cmd *cobra.Command, withBackend bool) {
	cmd.Flags().BoolP("force", "f", false, L("Actually remove the server"))
	cmd.Flags().Bool("purge-volumes", false, L("Also remove the volumes"))
	cmd.Flags().Bool("purge-images", false, L("Also remove the container images"))

	if withBackend {
		AddBackendFlag(cmd)
	}
}

// AddLogLevelFlags adds the --logLevel and --loglevel flags to a command.
func AddLogLevelFlags(cmd *cobra.Command, logLevel *string) {
	cmd.PersistentFlags().StringVar(logLevel, "logLevel", "",
		L("application log level")+"(trace|debug|info|warn|error|fatal|panic)",
	)
	cmd.PersistentFlags().StringVar(logLevel, "loglevel", "",
		L("application log level")+"(trace|debug|info|warn|error|fatal|panic)",
	)
	if err := cmd.PersistentFlags().MarkHidden("loglevel"); err != nil {
		log.Warn().Err(err).Msg(L("Failed to hide --loglevel parameter"))
	}
}
0707010000006c000081a400000000000000000000000168ed21dd00000080000000000000000000000000000000000000002a00000000shared/utils/conf_test/firstConfFile.yaml#SPDX-FileCopyrightText: 2024 SUSE LLC

#SPDX-License-Identifier: Apache-2.0
secondConf: firstConfFile
thirdConf: firstConfFile
0707010000006d000081a400000000000000000000000168ed21dd00000082000000000000000000000000000000000000002b00000000shared/utils/conf_test/secondConfFile.yaml#SPDX-FileCopyrightText: 2024 SUSE LLC

#SPDX-License-Identifier: Apache-2.0
thirdConf: SecondConfFile
fourthConf: SecondConfFile
0707010000006e000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000001700000000shared/utils/conf_test0707010000006f000081a400000000000000000000000168ed21dd000017ee000000000000000000000000000000000000001700000000shared/utils/config.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"errors"
	"os"
	"path"
	"strings"
	"text/template"

	"github.com/rs/zerolog/log"
	"github.com/spf13/cobra"
	"github.com/spf13/pflag"
	"github.com/spf13/viper"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

const envPrefix = "UYUNI"
const appName = "uyuni-tools"
const configFilename = "config.yaml"

// GlobalConfigFilename is the path for the global configuration.
const GlobalConfigFilename = "/etc/uyuni/uyuni-tools.yaml"

func addConfigurationFile(v *viper.Viper, cmd *cobra.Command, configFilename string) error {
	if FileExists(configFilename) {
		v.SetConfigFile(configFilename)
	}
	if err := bindFlags(cmd, v); err != nil {
		return err
	}
	if err := v.MergeInConfig(); err != nil {
		// It's okay if there isn't a config file
		var configFileNotFoundErr viper.ConfigFileNotFoundError
		if !errors.As(err, &configFileNotFoundErr) {
			// TODO Provide help on the config file format
			return Errorf(err, L("failed to parse configuration file %s"), v.ConfigFileUsed())
		}
	}
	return nil
}

// GetUserConfigDir returns the user configuration directory.
//
// Can be $XDG_CONFIG_HOME or `homedir/.config`.
func GetUserConfigDir() string {
	xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
	if xdgConfigHome == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			log.Warn().Err(err).Msg(L("Failed to find home directory"))
		} else {
			xdgConfigHome = path.Join(home, ".config")
		}
	}
	return xdgConfigHome
}

// ReadConfig parse configuration file and env variables a return parameters.
func ReadConfig(cmd *cobra.Command, configPaths ...string) (*viper.Viper, error) {
	v := viper.New()

	for _, configPath := range configPaths {
		if err := addConfigurationFile(v, cmd, configPath); err != nil {
			return v, err
		}
	}

	// once global configuration are set, set the local config file as default
	v.SetConfigType("yaml")
	v.SetConfigName(configFilename)

	xdgConfigHome := GetUserConfigDir()
	if xdgConfigHome != "" {
		v.AddConfigPath(path.Join(xdgConfigHome, appName))
	}
	v.AddConfigPath(".")

	v.SetEnvPrefix(envPrefix)

	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

	v.AutomaticEnv()

	return v, nil
}

// Bind each cobra flag to its associated viper configuration (config file and environment variable).
func bindFlags(cmd *cobra.Command, v *viper.Viper) error {
	var errors []error
	cmd.Flags().VisitAll(func(f *pflag.Flag) {
		configName := strings.ReplaceAll(f.Name, "-", ".")
		if err := v.BindPFlag(configName, f); err != nil {
			errors = append(errors, Errorf(err, L("failed to bind %[1]s config to parameter %[2]s"), configName, f.Name))
		}
	})

	if len(errors) > 0 {
		return errors[0]
	}
	return nil
}

// GetLocalizedUsageTemplate provides the help template, but localized.
func GetLocalizedUsageTemplate() string {
	return L(`Usage:{{if .Runnable}}
  {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
  {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}

Aliases:
  {{.NameAndAliases}}{{end}}{{if .HasExample}}

Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}

Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}

{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}

Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}

Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}

Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}

Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
  {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}

Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`)
}

// GetConfigHelpCommand provides a help command describing the config file and environment variables.
func GetConfigHelpCommand() *cobra.Command {
	var configTemplate = L(`
Configuration:

  All the non-global flags can alternatively be passed as configuration.

  The configuration file is a YAML file with entries matching the flag name.
  The name of a flag is the part after the '--' of the command line parameter.
  Every '_' character in the flag name means a nested property.

  For instance the '--tz CEST' and '--ssl-password secret' will be mapped to
  this YAML configuration:

    tz: CEST
    ssl:
      password: secret

  The configuration file will be searched in the following places and order:
  · /etc/uyuni/uyuni-tools.yaml
  · $XDG_CONFIG_HOME/{{ .Name }}/{{ .ConfigFile }}
  · $HOME/.config/{{ .Name }}/{{ .ConfigFile }}
  · $PWD/{{ .ConfigFile }}
  · the value of the --config flag


Environment variables:

  All the non-global flags can also be passed as environment variables.

  The environment variable name is the flag name with '-' replaced by with '_'
  and the {{ .EnvPrefix }} prefix.

  For example the '--tz CEST' flag will be mapped to '{{ .EnvPrefix }}_TZ'
  and '--ssl-password' flags to '{{ .EnvPrefix }}_SSL_PASSWORD'
`)

	cmd := &cobra.Command{
		Use:   "config",
		Short: L("Help on configuration file and environment variables"),
	}
	t := template.Must(template.New("help").Parse(configTemplate))
	var helpBuilder strings.Builder
	if err := t.Execute(&helpBuilder, configTemplateData{
		EnvPrefix:  envPrefix,
		Name:       appName,
		ConfigFile: configFilename,
	}); err != nil {
		log.Fatal().Err(err).Msg(L("failed to compute config help command"))
	}
	cmd.SetHelpTemplate(helpBuilder.String())
	return cmd
}

type configTemplateData struct {
	EnvPrefix  string
	ConfigFile string
	Name       string
}
07070100000070000081a400000000000000000000000168ed21dd0000032c000000000000000000000000000000000000001c00000000shared/utils/dbinspector.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"github.com/uyuni-project/uyuni-tools/shared/templates"
	"github.com/uyuni-project/uyuni-tools/shared/types"
)

// NewDBInspector creates a new templates.InspectTemplateData for the database info.
func NewDBInspector() templates.InspectTemplateData {
	return templates.InspectTemplateData{
		Values: []types.InspectData{
			types.NewInspectData("image_pg_version",
				"echo $PG_MAJOR || true"),
			types.NewInspectData("image_libc_version", "ldd --version | head -n1 | sed 's/^ldd (GNU libc) //'"),
		},
	}
}

// DBInspectData are data of the DB data.
type DBInspectData struct {
	ImagePgVersion   string `mapstructure:"image_pg_version"`
	ImageLibcVersion string `mapstructure:"image_libc_version"`
}
07070100000071000081a400000000000000000000000168ed21dd00001b91000000000000000000000000000000000000001500000000shared/utils/exec.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"time"

	"github.com/briandowns/spinner"
	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	"github.com/uyuni-project/uyuni-tools/shared/types"
)

// OutputLogWriter contains information output the logger and the loglevel.
type OutputLogWriter struct {
	Logger   zerolog.Logger
	LogLevel zerolog.Level
}

// Write writes a byte array to an OutputLogWriter.
func (l OutputLogWriter) Write(p []byte) (n int, err error) {
	n = len(p)
	if n > 0 && p[n-1] == '\n' {
		// Trim CR added by stdlog.
		p = p[0 : n-1]
	}
	l.Logger.WithLevel(l.LogLevel).CallerSkipFrame(1).Msg(string(p))
	return
}

// NewRunner creates a new runner instance for the command.
func NewRunner(command string, args ...string) types.Runner {
	runner := runnerImpl{logger: log.Logger}
	runner.cmd = exec.Command(command, args...)
	return &runner
}

// NewRunnerWithContext creates a new runner instance for the command,
// interuptable based on provided context.
func NewRunnerWithContext(ctx context.Context, command string, args ...string) types.Runner {
	runner := runnerImpl{logger: log.Logger}
	runner.cmd = exec.CommandContext(ctx, command, args...)
	return &runner
}

// runnerImpl is a helper object around the exec.Command() function.
// It implements the Runner interface.
//
// This is supposed to be created using the NewRunner() function.
type runnerImpl struct {
	logger  zerolog.Logger
	cmd     *exec.Cmd
	spinner *spinner.Spinner
}

// Log sets the log level of the output.
func (r *runnerImpl) Log(logLevel zerolog.Level) types.Runner {
	r.logger = log.Logger.Level(logLevel)
	return r
}

// Spinner sets a spinner with its message.
// If no message is passed, the command will be used.
func (r *runnerImpl) Spinner(message string) types.Runner {
	r.spinner = spinner.New(spinner.CharSets[14], 100*time.Millisecond)
	text := message
	if message == "" {
		text = strings.Join(r.cmd.Args, " ")
	}
	r.spinner.Suffix = fmt.Sprintf(" %s\n", text)
	return r
}

// StdMapping maps the process output and error streams to the standard ones.
// This is useful to show the process output in the console and the logs and can be combined with Log().
func (r *runnerImpl) StdMapping() types.Runner {
	r.cmd.Stdout = r.logger
	r.cmd.Stderr = r.logger
	return r
}

// Std maps the process output to the out bytes buffer.
// This is useful to get process output for backgrounds tasks.
func (r *runnerImpl) Std(out *bytes.Buffer) types.Runner {
	r.cmd.Stdout = out
	return r
}

// Env sets environment variables to use for the command.
func (r *runnerImpl) Env(env []string) types.Runner {
	if r.cmd.Env == nil {
		r.cmd.Env = os.Environ()
	}
	r.cmd.Env = append(r.cmd.Env, env...)
	return r
}

// InputString adds a string as input of the process.
func (r *runnerImpl) InputString(input string) types.Runner {
	r.cmd.Stdin = strings.NewReader(input)
	return r
}

// Exec really executes the command and returns its output and error.
// The error output to used as error message if the StdMapping() function wasn't called.
func (r *runnerImpl) Exec() ([]byte, error) {
	if r.spinner != nil {
		r.spinner.Start()
	}

	r.logger.Debug().Msgf("Running: %s", strings.Join(r.cmd.Args, " "))
	var out []byte
	var err error

	if r.cmd.Stdout != nil {
		err = r.cmd.Run()
	} else {
		out, err = r.cmd.Output()
	}

	if r.spinner != nil {
		r.spinner.Stop()
	}

	var exitErr *exec.ExitError
	if errors.As(err, &exitErr) {
		err = &CmdError{exitErr}
	}

	r.logger.Trace().Msgf("Command output: %s, error: %s", out, err)

	return out, err
}

// Start starts the command, particularly for commands to run in the background.
func (r *runnerImpl) Start() error {
	r.logger.Debug().Msgf("Starting: %s", strings.Join(r.cmd.Args, " "))

	err := r.cmd.Start()
	var exitErr *exec.ExitError
	if errors.As(err, &exitErr) {
		err = &CmdError{exitErr}
	}

	return err
}

// Wait waits for the command running in the background to ends.
func (r *runnerImpl) Wait() error {
	return r.cmd.Wait()
}

// CmdError is a wrapper around exec.ExitError to show the standard error as message.
type CmdError struct {
	*exec.ExitError
}

// Error returns the stderr as error message.
func (e *CmdError) Error() string {
	return strings.TrimSpace(string(e.Stderr))
}

// RunCmd execute a shell command.
func RunCmd(command string, args ...string) error {
	s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) // Build our new spinner
	s.Suffix = fmt.Sprintf(" %s %s\n", command, strings.Join(args, " "))
	s.Start() // Start the spinner
	log.Debug().Msgf("Running: %s %s", command, strings.Join(args, " "))
	err := exec.Command(command, args...).Run()
	s.Stop()
	return err
}

// RunCmdStdMapping execute a shell command mapping the stdout and stderr.
func RunCmdStdMapping(logLevel zerolog.Level, command string, args ...string) error {
	localLogger := log.Logger.Level(logLevel)
	localLogger.Debug().Msgf("Running: %s %s", command, strings.Join(args, " "))

	runCmd := exec.Command(command, args...)
	runCmd.Stdout = localLogger
	runCmd.Stderr = localLogger
	err := runCmd.Run()
	return err
}

// RunCmdOutput execute a shell command and collects output.
func RunCmdOutput(logLevel zerolog.Level, command string, args ...string) ([]byte, error) {
	localLogger := log.Level(logLevel)
	s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) // Build our new spinner
	s.Suffix = fmt.Sprintf(" %s %s\n", command, strings.Join(args, " "))
	if logLevel != zerolog.Disabled {
		s.Start() // Start the spinner
	}
	localLogger.Debug().Msgf("Running: %s %s", command, strings.Join(args, " "))
	cmd := exec.Command(command, args...)
	var errBuf bytes.Buffer
	cmd.Stderr = &errBuf
	output, err := cmd.Output()
	if logLevel != zerolog.Disabled {
		s.Stop()
	}
	localLogger.Trace().Msgf("Command output: %s, error: %s", output, err)
	message := strings.TrimSpace(errBuf.String())
	if message != "" {
		err = errors.New(message)
	}
	return output, err
}

// RunCmdInput execute a shell command and pass input string to the StdIn.
func RunCmdInput(command string, input string, args ...string) error {
	s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) // Build our new spinner
	s.Suffix = fmt.Sprintf(" %s %s\n", command, strings.Join(args, " "))
	s.Start() // Start the spinner
	log.Debug().Msgf("Running: %s %s", command, strings.Join(args, " "))
	cmd := exec.Command(command, args...)
	cmd.Stdin = strings.NewReader(input)
	err := cmd.Run()
	s.Stop()
	return err
}

// IsInstalled checks if a tool is in the path.
func IsInstalled(tool string) bool {
	_, err := exec.LookPath(tool)
	return err == nil
}

// GetEnvironmentVarsList returns list of environmental variables to be passed to exec.
func GetEnvironmentVarsList() []string {
	// Taken from /etc/profile and /etc/profile.d/lang
	return []string{"TERM", "PAGER",
		"LESS", "LESSOPEN", "LESSKEY", "LESSCLOSE", "LESS_ADVANCED_PREPROCESSOR", "MORE",
		"LANG", "LC_CTYPE", "LC_ALL"}
}
07070100000072000081a400000000000000000000000168ed21dd00000bab000000000000000000000000000000000000001a00000000shared/utils/exec_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

func TestRunner(t *testing.T) {
	type testCase struct {
		exit     int
		logLevel zerolog.Level
	}

	testCases := []testCase{
		{exit: 0, logLevel: zerolog.TraceLevel},
		{exit: 2, logLevel: zerolog.TraceLevel},
		{exit: 0, logLevel: zerolog.DebugLevel},
		{exit: 0, logLevel: zerolog.InfoLevel},
	}

	for i, test := range testCases {
		logWriter := new(strings.Builder)
		log.Logger = zerolog.New(logWriter)

		runner := NewRunner("sh", "-c",
			fmt.Sprintf(`echo "Test output: ENV=$ENV"; echo 'error message' >&2; exit %d`, test.exit),
		)
		out, err := runner.Log(test.logLevel).Env([]string{"ENV=foo"}).Exec()

		caseMsg := fmt.Sprintf("test %d: ", i)

		// Check the output
		testutils.AssertEquals(t, caseMsg+"Unexpected output", "Test output: ENV=foo\n", string(out))

		// Check the returned error
		if test.exit == 0 {
			testutils.AssertEquals(t, caseMsg+"Unexpected error", nil, err)
		} else {
			testutils.AssertEquals(t, caseMsg+"Unexpected error", "error message", string(err.Error()))
			var cmdErr *CmdError
			if errors.As(err, &cmdErr) {
				testutils.AssertEquals(t, caseMsg+"Unexpected exit code", test.exit, cmdErr.ExitCode())
			} else {
				t.Errorf("%s unexpected error type", caseMsg)
			}
		}

		// Check the log content
		logContent := logWriter.String()
		t.Logf("log: %s", logContent)
		if test.logLevel == zerolog.TraceLevel {
			testutils.AssertTrue(t, caseMsg+"missing trace log entry", strings.Contains(logContent, "Command output:"))
		} else {
			testutils.AssertTrue(t, caseMsg+"unexpected trace log entry", !strings.Contains(logContent, `"level":"trace"`))
		}

		if test.logLevel <= zerolog.DebugLevel {
			testutils.AssertTrue(t, caseMsg+"missing debug log entry", strings.Contains(logContent, "Running:"))
		} else {
			testutils.AssertTrue(t, caseMsg+"unexpected debug log entry", !strings.Contains(logContent, `"level":"debug"`))
		}
	}
}

func ExampleRunner() {
	out, err := NewRunner("sh", "-c", `echo "Hello $user"`).
		Env([]string{"user=world"}).
		Log(zerolog.DebugLevel).
		Exec()
	if err != nil {
		fmt.Printf("Error: %s", err)
	}
	fmt.Println(strings.TrimSpace(string(out)))
	// Output: Hello world
}

func TestContextRunner(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	testData := []byte("Hello world")

	runner := NewRunnerWithContext(ctx, "sh", "-c", fmt.Sprintf(`echo -n "%s"`, testData))
	var out bytes.Buffer
	err := runner.Std(&out).Start()
	testutils.AssertTrue(t, "Unexpected start failure", err == nil)
	err = runner.Wait()
	testutils.AssertTrue(t, "Unexpected wait failure", err == nil)

	testutils.AssertEquals(t, "Output does not match", out.Bytes(), testData)
}
07070100000073000081a400000000000000000000000168ed21dd00000acc000000000000000000000000000000000000001600000000shared/utils/files.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"errors"
	"io"
	"os"
	"strings"

	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

// IsEmptyDirectory return true if a given directory is empty.
func IsEmptyDirectory(path string) bool {
	files, err := os.ReadDir(path)
	if err != nil {
		log.Fatal().Err(err).Msgf(L("cannot check content of %s"), path)
		return false
	}
	if len(files) > 0 {
		return false
	}
	return true
}

// RemoveDirectory remove a given directory.
func RemoveDirectory(path string) error {
	if err := os.Remove(path); err != nil {
		return Errorf(err, L("Cannot remove %s folder"), path)
	}
	return nil
}

// FileExists check if path exists.
func FileExists(path string) bool {
	_, err := os.Stat(path)
	if err == nil {
		return true
	} else if !os.IsNotExist(err) {
		log.Fatal().Err(err).Msgf(L("Failed to get %s file informations"), path)
	}
	return false
}

// ReadFile returns the content of a file and exit if there was an error.
func ReadFile(file string) []byte {
	out, err := os.ReadFile(file)
	if err != nil {
		log.Fatal().Err(err).Msgf(L("Failed to read file %s"), file)
	}
	return out
}

// GetFileBoolean gets the value of a file containing a boolean.
//
// This is handy for files from the kernel API.
func GetFileBoolean(file string) bool {
	return strings.TrimSpace(string(ReadFile(file))) != "0"
}

// UninstallFile uninstalls a file.
func UninstallFile(path string, dryRun bool) {
	if FileExists(path) {
		if dryRun {
			log.Info().Msgf(L("Would remove file %s"), path)
		} else {
			log.Info().Msgf(L("Removing file %s"), path)
			if err := os.Remove(path); err != nil {
				log.Info().Err(err).Msgf(L("Failed to remove file %s"), path)
			}
		}
	}
}

// TempDir creates a temporary directory.
func TempDir() (string, func(), error) {
	tempDir, err := os.MkdirTemp("", "mgradm-*")
	if err != nil {
		return "", nil, Error(err, L("failed to create temporary directory"))
	}
	cleaner := func() {
		if err := os.RemoveAll(tempDir); err != nil {
			log.Error().Err(err).Msg(L("failed to remove temporary directory"))
		}
	}
	return tempDir, cleaner, nil
}

// CopyFile copies the content of the file at src path into the opened dst file.
func CopyFile(src string, dst *os.File) error {
	srcFile, err := os.Open(src)
	if err != nil {
		return Errorf(err, L("fails to open %s file"), src)
	}

	const bufSize = 1024
	buf := make([]byte, bufSize)

	for {
		read, err := srcFile.Read(buf)
		if err != nil {
			if errors.Is(err, io.EOF) {
				return nil
			}
			return Errorf(err, L("failed to read %s file"), src)
		}
		_, err = dst.Write(buf[:read])
		if err != nil {
			return Errorf(err, L("failed to copy %s file"), src)
		}
	}
}
07070100000074000081a400000000000000000000000168ed21dd00000bd7000000000000000000000000000000000000001b00000000shared/utils/flaggroups.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"fmt"
	"regexp"

	"github.com/spf13/cobra"
	flag "github.com/spf13/pflag"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

// Group Structure to manage groups for commands.
type Group struct {
	ID    string
	Title string
}

// FlagHelpGroupAnnotation is an annotation to store the flag group to.
const FlagHelpGroupAnnotation = "cobra_annotation_flag_help_group"

var commandGroups = make(map[*cobra.Command][]Group)

func usageByFlagHelpGroupID(cmd *cobra.Command, groupID string) string {
	fs := &flag.FlagSet{}

	cmd.LocalFlags().VisitAll(func(f *flag.Flag) {
		if _, ok := f.Annotations[FlagHelpGroupAnnotation]; !ok {
			if groupID == "" {
				fs.AddFlag(f)
			}
			return
		}

		if id := f.Annotations[FlagHelpGroupAnnotation][0]; id == groupID {
			fs.AddFlag(f)
		}
	})

	return fs.FlagUsages()
}

func usageFunc(cmd *cobra.Command) error {
	flagsUsage := ""
	for _, group := range commandGroups[cmd] {
		flagsUsage += group.Title + ":\n"
		flagsUsage += usageByFlagHelpGroupID(cmd, group.ID)
		flagsUsage += "\n"
	}

	genericFlagsUsage := usageByFlagHelpGroupID(cmd, "")
	if len(genericFlagsUsage) > 0 {
		flagsUsage = L("Flags:\n") + genericFlagsUsage + "\n" + flagsUsage
	}

	template := cmd.UsageTemplate()
	re := regexp.MustCompile(`(?s)\{\{if \.HasAvailableLocalFlags\}\}.*?\{\{end\}\}`)
	template = re.ReplaceAllString(template, "\n\n"+flagsUsage)
	cmd.SetUsageTemplate(template)

	// call the original UsageFunc with the modified template
	blankCmd := cobra.Command{}
	cmd.SetUsageFunc(blankCmd.UsageFunc())
	origUsageFunc := cmd.UsageFunc()
	cmd.SetUsageFunc(usageFunc)

	return origUsageFunc(cmd)
}

// ContainsGroup checks if the command alrady has a group with the same ID.
func ContainsGroup(cmd *cobra.Command, groupID string) bool {
	for _, grp := range commandGroups[cmd] {
		if grp.ID == groupID {
			return true
		}
	}
	return false
}

// AddFlagHelpGroup adds a new flags group.
func AddFlagHelpGroup(cmd *cobra.Command, groups ...*Group) error {
	for _, group := range groups {
		if ContainsGroup(cmd, group.ID) {
			continue
		}
		commandGroups[cmd] = append(commandGroups[cmd], *group)
	}

	cmd.SetUsageFunc(usageFunc)
	return nil
}

// AddFlagToHelpGroupID adds a flag to a group.
func AddFlagToHelpGroupID(cmd *cobra.Command, flag, groupID string) error {
	lf := cmd.Flags()

	found := false
	for _, existing := range commandGroups[cmd] {
		if existing.ID == groupID {
			found = true
			break
		}
	}
	if !found {
		return fmt.Errorf(L("no such flag help group: %v"), groupID)
	}

	err := lf.SetAnnotation(flag, FlagHelpGroupAnnotation, []string{groupID})
	if err != nil {
		return err
	}

	return nil
}

// AddFlagsToHelpGroupID adds several flags to a group.
func AddFlagsToHelpGroupID(cmd *cobra.Command, groupID string, flags ...string) error {
	for _, flag := range flags {
		err := AddFlagToHelpGroupID(cmd, flag, groupID)
		if err != nil {
			return err
		}
	}
	return nil
}
07070100000075000081a400000000000000000000000168ed21dd00000317000000000000000000000000000000000000001a00000000shared/utils/inspector.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"bytes"

	"github.com/spf13/viper"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

// ReadInspectData returns an unmarshalled object of type T from the data as a string.
//
// This function is most likely to be used for the implementation of the inspectors, but can also be used directly.
func ReadInspectData[T any](data []byte) (*T, error) {
	viper.SetConfigType("env")
	if err := viper.MergeConfig(bytes.NewBuffer(data)); err != nil {
		return nil, Error(err, L("cannot read config"))
	}

	var inspectResult T
	if err := viper.Unmarshal(&inspectResult); err != nil {
		return nil, Error(err, L("failed to unmarshal the inspected data"))
	}
	return &inspectResult, nil
}
07070100000076000081a400000000000000000000000168ed21dd000004a0000000000000000000000000000000000000001f00000000shared/utils/inspector_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"testing"

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

func TestReadInspectData(t *testing.T) {
	content := `Timezone=Europe/Berlin
image_pg_version=16
current_pg_version=14
db_user=myuser
db_password=mysecret
db_name=mydb
db_port=1234
has_hubxmlrpc=true
`

	actual, err := ReadInspectData[InspectResult]([]byte(content))
	if err != nil {
		t.Fatalf("Unexpected failure: %s", err)
	}

	testutils.AssertEquals(t, "Invalid timezone", "Europe/Berlin", actual.Timezone)
	testutils.AssertEquals(t, "Invalid current postgresql version", "14", actual.CommonInspectData.CurrentPgVersion)
	testutils.AssertEquals(t, "Invalid image postgresql version", "16", actual.DBInspectData.ImagePgVersion)
	testutils.AssertEquals(t, "Invalid DB user", "myuser", actual.DBUser)
	testutils.AssertEquals(t, "Invalid DB password", "mysecret", actual.DBPassword)
	testutils.AssertEquals(t, "Invalid DB name", "mydb", actual.DBName)
	testutils.AssertEquals(t, "Invalid DB port", 1234, actual.DBPort)
	testutils.AssertTrue(t, "HasHubXmlrpcApi should be true", actual.HasHubXmlrpcAPI)
}
07070100000077000081a400000000000000000000000168ed21dd00000105000000000000000000000000000000000000001b00000000shared/utils/kubernetes.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

//go:build !nok8s

package utils

// KubernetesBuilt is a flag for compiling kubernetes code. True when go:build !nok8s, False when go:build nok8s.
const KubernetesBuilt = true
07070100000078000081a400000000000000000000000168ed21dd00001116000000000000000000000000000000000000001900000000shared/utils/logUtils.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"io"
	"os"
	"path"
	"regexp"
	"strconv"
	"strings"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"golang.org/x/term"
	"gopkg.in/natefinch/lumberjack.v2"
)

var redactRegex = regexp.MustCompile(`([pP]assword[\t :"\\]+)[^\\][^\t "\\]+`)

// The default directory where log files are written.
const logDir = "/var/log/"
const logFileName = "uyuni-tools.log"
const GlobalLogPath = logDir + logFileName

// UyuniLogger is an io.WriteCloser that writes to the specified filename.
type UyuniLogger struct {
	logger *lumberjack.Logger
}

// UyuniConsoleWriter parses the JSON input and writes it in an (optionally) colorized, human-friendly format to Out.
type UyuniConsoleWriter struct {
	consoleWriter zerolog.ConsoleWriter
}

func (l *UyuniLogger) Write(p []byte) (n int, err error) {
	_, err = l.logger.Write([]byte(redact(string(p)) + "\n"))
	if err != nil {
		return 0, err
	}
	// using len(p) prevents "zerolog: could not write event: short write" error
	return len(p), nil
}

// Close implements io.Closer, and closes the current logfile.
func (l *UyuniLogger) Close() error {
	return l.logger.Close()
}

// Rotate causes Logger to close the existing log file and immediately create a
// new one.  This is a helper function for applications that want to initiate
// rotations outside of the normal rotation rules, such as in response to
// SIGHUP.  After rotating, this initiates compression and removal of old log
// files according to the configuration.
func (l *UyuniLogger) Rotate() error {
	return l.logger.Rotate()
}

// Write transforms the JSON input with formatters and appends to w.Out.
func (c UyuniConsoleWriter) Write(p []byte) (n int, err error) {
	_, err = c.consoleWriter.Write([]byte(redact(string(p))))
	if err != nil {
		return 0, err
	}
	// using len(p) prevents "zerolog: could not write event: short write" error
	return len(p), nil
}

func redact(line string) string {
	return redactRegex.ReplaceAllString(line, "${1}<REDACTED>")
}

// LogInit initialize logs.
func LogInit(logToConsole bool) {
	zerolog.CallerMarshalFunc = logCallerMarshalFunction
	zerolog.SetGlobalLevel(zerolog.InfoLevel)

	fileWriter := getFileWriter()
	writers := []io.Writer{fileWriter}
	if logToConsole {
		consoleWriter := zerolog.NewConsoleWriter()
		consoleWriter.NoColor = !term.IsTerminal(int(os.Stdout.Fd()))
		uyuniConsoleWriter := UyuniConsoleWriter{
			consoleWriter: consoleWriter,
		}
		writers = append(writers, uyuniConsoleWriter)
	}

	multi := zerolog.MultiLevelWriter(writers...)
	log.Logger = zerolog.New(multi).With().Timestamp().Stack().Logger()

	if fileWriter.logger.Filename != GlobalLogPath {
		log.Warn().Msgf(
			L("Couldn't open %[1]s file for writing, writing log to %[2]s"),
			GlobalLogPath, fileWriter.logger.Filename,
		)
	}
}

func getFileWriter() *UyuniLogger {
	logPath := GlobalLogPath

	if file, err := os.OpenFile(GlobalLogPath, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0600); err != nil {
		homeDir, err := os.UserHomeDir()
		if err != nil {
			logPath = path.Join(".", logFileName)
		} else {
			logPath = path.Join(homeDir, logFileName)
		}
	} else {
		file.Close()
	}

	fileLogger := &lumberjack.Logger{
		Filename:   logPath,
		MaxSize:    5,
		MaxBackups: 5,
		MaxAge:     90,
		Compress:   true,
	}
	uyuniLogger := &UyuniLogger{
		logger: fileLogger,
	}
	return uyuniLogger
}

// SetLogLevel sets the loglevel.
func SetLogLevel(logLevel string) {
	globalLevel := zerolog.InfoLevel

	level, err := zerolog.ParseLevel(logLevel)
	if logLevel != "" && err == nil {
		globalLevel = level
	}
	if globalLevel <= zerolog.DebugLevel {
		log.Logger = log.Logger.With().Caller().Logger()
	}
	zerolog.SetGlobalLevel(globalLevel)
}

func logCallerMarshalFunction(_ uintptr, file string, line int) string {
	paths := strings.Split(file, "/")
	callerFile := file
	foundSubDir := false
	if strings.HasSuffix(file, "/io/io.go") {
		return "Cmd output"
	}

	for _, currentPath := range paths {
		if foundSubDir {
			if callerFile != "" {
				callerFile = callerFile + "/"
			}
			callerFile = callerFile + currentPath
		} else {
			if strings.Contains(currentPath, "uyuni-tools") {
				foundSubDir = true
				callerFile = ""
			}
		}
	}
	return callerFile + ":" + strconv.Itoa(line)
}
07070100000079000081a400000000000000000000000168ed21dd00000658000000000000000000000000000000000000001e00000000shared/utils/logUtils_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"testing"
)

func TestRedact(t *testing.T) {
	data := [][]string{
		{
			`{"level":"info","time":"2024-04-29T15:23:39+02:00","message":"Running /usr/bin/uyuni-setup-reportdb create ` +
				`--db reportdb --user pythia_susemanager --host localhost --address * --remote 0.0.0.0/0,::/0 --password ` +
				`/z4FffHC2HxaagBeIXFzshxtNUfbqm5Zwv/EgvxT"}`,
			`{"level":"info","time":"2024-04-29T15:23:39+02:00","message":"Running /usr/bin/uyuni-setup-reportdb create ` +
				`--db reportdb --user pythia_susemanager --host localhost --address * --remote 0.0.0.0/0,::/0 --password ` +
				`<REDACTED>"}`,
		},
		{
			`Running /usr/bin/uyuni-setup-reportdb create --db reportdb --user pythia_susemanager --host localhost --address *` +
				` --remote 0.0.0.0/0,::/0 --password iVgQsuPDGxwKhFc5bfk4IjpVBbqrbyRDYKEsww+Y`,
			`Running /usr/bin/uyuni-setup-reportdb create --db reportdb --user pythia_susemanager --host localhost --address *` +
				` --remote 0.0.0.0/0,::/0 --password <REDACTED>`,
		},
		{
			`{"adminLogin":"admin","adminPassword":"secret","email":"no@email.com"}`,
			`{"adminLogin":"admin","adminPassword":"<REDACTED>","email":"no@email.com"}`,
		},
		{
			`password\n`,
			`password\n`,
		},
		{
			`\"password\": \"foo\"`,
			`\"password\": \"<REDACTED>\"`,
		},
	}

	for i, testCase := range data {
		input := testCase[0]
		expected := testCase[1]

		actual := redact(input)

		if actual != expected {
			t.Errorf("Testcase %d: Expected %s got %s when redacting  %s", i, expected, actual, input)
		}
	}
}
0707010000007a000081a400000000000000000000000168ed21dd00000093000000000000000000000000000000000000001d00000000shared/utils/nokubernetes.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

//go:build nok8s

package utils

const KubernetesBuilt = false
0707010000007b000081a400000000000000000000000168ed21dd000015cb000000000000000000000000000000000000001600000000shared/utils/ports.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"github.com/uyuni-project/uyuni-tools/shared/types"
)

const (
	// WebServiceName is the name of the server web service.
	WebServiceName = "web"
	// SaltServiceName is the name of the server salt service.
	SaltServiceName = "salt"
	// CobblerServiceName is the name of the server cobbler service.
	CobblerServiceName = "cobbler"
	// ReportdbServiceName is the name of the server report database service.
	ReportdbServiceName = "reportdb"
	// DBServiceName is the name of the server internal database service.
	DBServiceName = "db"
	// DBExporterServiceName is the name of the Prometheus database exporter service.
	DBExporterServiceName = "db"
	// TaskoServiceName is the name of the server taskomatic service.
	TaskoServiceName = "taskomatic"
	// TftpServiceName is the name of the server tftp service.
	TftpServiceName = "tftp"
	// TomcatServiceName is the name of the server tomcat service.
	TomcatServiceName = "tomcat"
	// SearchServiceName is the name of the server search service.
	SearchServiceName = "search"

	// HubAPIServiceName is the name of the server hub API service.
	HubAPIServiceName = "hub-api"

	// ProxyTCPServiceName is the name of the proxy TCP service.
	ProxyTCPServiceName = "uyuni-proxy-tcp"

	// ProxyUDPServiceName is the name of the proxy UDP service.
	ProxyUDPServiceName = "uyuni-proxy-udp"
)

// NewPortMap is a constructor for PortMap type.
func NewPortMap(service string, name string, exposed int, port int) types.PortMap {
	return types.PortMap{
		Service: service,
		Name:    name,
		Exposed: exposed,
		Port:    port,
	}
}

// WebPorts is the list of ports for the server web service.
var WebPorts = []types.PortMap{
	NewPortMap(WebServiceName, "http", 80, 80),
}

// DBExporterPorts is the list of ports for the db exporter service.
var DBExporterPorts = []types.PortMap{
	NewPortMap(DBExporterServiceName, "exporter", 9187, 9187),
}

// ReportDBPorts is the list of ports for the server report db service.
var ReportDBPorts = []types.PortMap{
	NewPortMap(ReportdbServiceName, "pgsql", 5432, 5432),
}

// DBPorts is the list of ports for the server internal db service.
var DBPorts = []types.PortMap{
	NewPortMap(DBServiceName, "pgsql", 5432, 5432),
}

// SaltPorts is the list of ports for the server salt service.
var SaltPorts = []types.PortMap{
	NewPortMap(SaltServiceName, "publish", 4505, 4505),
	NewPortMap(SaltServiceName, "request", 4506, 4506),
}

// CobblerPorts is the list of ports for the server cobbler service.
var CobblerPorts = []types.PortMap{
	NewPortMap(CobblerServiceName, "cobbler", 25151, 25151),
}

// TaskoPorts is the list of ports for the server taskomatic service.
var TaskoPorts = []types.PortMap{
	NewPortMap(TaskoServiceName, "jmx", 5556, 5556),
	NewPortMap(TaskoServiceName, "mtrx", 9800, 9800),
	NewPortMap(TaskoServiceName, "debug", 8001, 8001),
}

// TomcatPorts is the list of ports for the server tomcat service.
var TomcatPorts = []types.PortMap{
	NewPortMap(TomcatServiceName, "jmx", 5557, 5557),
	NewPortMap(TomcatServiceName, "debug", 8003, 8003),
}

// SearchPorts is the list of ports for the server search service.
var SearchPorts = []types.PortMap{
	NewPortMap(SearchServiceName, "debug", 8002, 8002),
}

// TftpPorts is the list of ports for the server tftp service.
var TftpPorts = []types.PortMap{
	{
		Service:  TftpServiceName,
		Name:     "tftp",
		Exposed:  69,
		Port:     69,
		Protocol: "udp",
	},
}

// GetServerPorts returns all the server container ports.
//
// if debug is set to true, the debug ports are added to the list.
func GetServerPorts(debug bool) []types.PortMap {
	ports := []types.PortMap{}
	ports = appendPorts(ports, debug, WebPorts...)
	ports = appendPorts(ports, debug, SaltPorts...)
	ports = appendPorts(ports, debug, CobblerPorts...)
	ports = appendPorts(ports, debug, TaskoPorts...)
	ports = appendPorts(ports, debug, TomcatPorts...)
	ports = appendPorts(ports, debug, SearchPorts...)
	ports = appendPorts(ports, debug, TftpPorts...)
	ports = appendPorts(ports, debug, DBExporterPorts...)

	return ports
}

func appendPorts(ports []types.PortMap, debug bool, newPorts ...types.PortMap) []types.PortMap {
	for _, newPort := range newPorts {
		if debug || newPort.Name != "debug" && !debug {
			ports = append(ports, newPort)
		}
	}
	return ports
}

// TCPPodmanPorts are the tcp ports required by the server on podman.
var TCPPodmanPorts = []types.PortMap{
	// TODO: Replace Node exporter with cAdvisor
	NewPortMap("tomcat", "node-exporter", 9100, 9100),
}

// HubXmlrpcPorts are the tcp ports required by the Hub XMLRPC API service.
var HubXmlrpcPorts = []types.PortMap{
	NewPortMap(HubAPIServiceName, "xmlrpc", 2830, 2830),
}

// ProxyTCPPorts are the tcp ports required by the proxy.
var ProxyTCPPorts = []types.PortMap{
	NewPortMap(ProxyTCPServiceName, "ssh", 8022, 22),
	NewPortMap(ProxyTCPServiceName, "publish", 4505, 4505),
	NewPortMap(ProxyTCPServiceName, "request", 4506, 4506),
}

// ProxyPodmanPorts are the http/s ports required by the proxy.
var ProxyPodmanPorts = []types.PortMap{
	NewPortMap(ProxyTCPServiceName, "https", 443, 443),
	NewPortMap(ProxyTCPServiceName, "http", 80, 80),
}

// GetProxyPorts returns all the proxy container ports.
func GetProxyPorts() []types.PortMap {
	ports := []types.PortMap{}
	ports = appendPorts(ports, false, ProxyTCPPorts...)
	ports = appendPorts(ports, false, types.PortMap{
		Service:  ProxyUDPServiceName,
		Name:     "tftp",
		Exposed:  69,
		Port:     69,
		Protocol: "udp",
	})

	return ports
}
0707010000007c000081a400000000000000000000000168ed21dd00000279000000000000000000000000000000000000001b00000000shared/utils/ports_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"testing"

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

func TestGetServerPorts(t *testing.T) {
	allPorts := len(WebPorts) + len(SaltPorts) + len(CobblerPorts) +
		len(TaskoPorts) + len(TomcatPorts) + len(SearchPorts) + len(TftpPorts) + len(DBExporterPorts)

	ports := GetServerPorts(false)
	testutils.AssertEquals(t, "Wrong number of ports without debug ones", allPorts-3, len(ports))

	ports = GetServerPorts(true)
	testutils.AssertEquals(t, "Wrong number of ports with debug ones", allPorts, len(ports))
}
0707010000007d000081a400000000000000000000000168ed21dd00000d1e000000000000000000000000000000000000002000000000shared/utils/serverinspector.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"github.com/uyuni-project/uyuni-tools/shared/templates"
	"github.com/uyuni-project/uyuni-tools/shared/types"
)

// NewServerInspector creates a new templates.InspectTemplateData for the big container inspection.
func NewServerInspector() templates.InspectTemplateData {
	return templates.InspectTemplateData{
		Values: []types.InspectData{
			types.NewInspectData(
				"uyuni_release",
				"cat /etc/*release | grep 'Uyuni release' | cut -d ' ' -f3 || true"),
			types.NewInspectData(
				"suse_manager_release",
				`[ -f /etc/susemanager-release ] && sed 's/.*(\([0-9.]\+\).*/\1/g' /etc/susemanager-release || true`),
			types.NewInspectData(
				"fqdn",
				`sed -n '/^java\.hostname/{s/^java\.hostname[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true`),
			types.NewInspectData("current_pg_version",
				"(psql -V | awk '{print $3}' | cut -d. -f1) || true"),
			types.NewInspectData("current_pg_version_not_migrated",
				"(test -e /var/lib/pgsql/data/data/PG_VERSION && cat /var/lib/pgsql/data/data/PG_VERSION) || true"),
			types.NewInspectData("current_libc_version", "ldd --version | head -n1 | sed 's/^ldd (GNU libc) //'"),
			types.NewInspectData("db_user",
				`sed -n '/^db_user/{s/^db_user[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true`),
			types.NewInspectData("db_password",
				`sed -n '/^db_password/{s/^db_password[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true`),
			types.NewInspectData("db_name",
				`sed -n '/^db_name/{s/^db_name[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true`),
			types.NewInspectData("db_port",
				`sed -n '/^db_port/{s/^db_port[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true`),
			types.NewInspectData("db_host",
				`sed -n '/^db_host/{s/^db_host[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true`),
			types.NewInspectData("report_db_host",
				`sed -n '/^report_db_host/{s/^report_db_host[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true`),
		},
	}
}

// CommonInspectData are data common between the migration source inspect and server inspector results.
type CommonInspectData struct {
	CurrentPgVersion            string `mapstructure:"current_pg_version"`
	CurrentPgVersionNotMigrated string `mapstructure:"current_pg_version_not_migrated"`
	CurrentLibcVersion          string `mapstructure:"current_libc_version"`
	DBUser                      string `mapstructure:"db_user"`
	DBPassword                  string `mapstructure:"db_password"`
	DBName                      string `mapstructure:"db_name"`
	DBPort                      int    `mapstructure:"db_port"`
	DBHost                      string `mapstructure:"db_host"`
	ReportDBUser                string `mapstructure:"report_db_user"`
	ReportDBPassword            string `mapstructure:"report_db_password"`
	ReportDBHost                string `mapstructure:"report_db_host"`
}

// ServerInspectData are the data extracted by a server inspector.
type ServerInspectData struct {
	CommonInspectData  `mapstructure:",squash"`
	DBInspectData      `mapstructure:",squash"`
	UyuniRelease       string `mapstructure:"uyuni_release"`
	SuseManagerRelease string `mapstructure:"suse_manager_release"`
	Fqdn               string
}
0707010000007e000081a400000000000000000000000168ed21dd00000bb8000000000000000000000000000000000000002500000000shared/utils/serverinspector_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"testing"

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

func TestServerInspectorGenerate(t *testing.T) {
	inspector := NewServerInspector()
	script, err := inspector.GenerateScript()
	if err != nil {
		t.Errorf("Unexpected error %s", err)
	}

	//nolint:lll
	expected := `
# inspect.sh, generated by mgradm
echo "uyuni_release=$(cat /etc/*release | grep 'Uyuni release' | cut -d ' ' -f3 || true)"
echo "suse_manager_release=$([ -f /etc/susemanager-release ] && sed 's/.*(\([0-9.]\+\).*/\1/g' /etc/susemanager-release || true)"
echo "fqdn=$(sed -n '/^java\.hostname/{s/^java\.hostname[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true)"
echo "current_pg_version=$((psql -V | awk '{print $3}' | cut -d. -f1) || true)"
echo "current_pg_version_not_migrated=$((test -e /var/lib/pgsql/data/data/PG_VERSION && cat /var/lib/pgsql/data/data/PG_VERSION) || true)"
echo "current_libc_version=$(ldd --version | head -n1 | sed 's/^ldd (GNU libc) //')"
echo "db_user=$(sed -n '/^db_user/{s/^db_user[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true)"
echo "db_password=$(sed -n '/^db_password/{s/^db_password[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true)"
echo "db_name=$(sed -n '/^db_name/{s/^db_name[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true)"
echo "db_port=$(sed -n '/^db_port/{s/^db_port[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true)"
echo "db_host=$(sed -n '/^db_host/{s/^db_host[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true)"
echo "report_db_host=$(sed -n '/^report_db_host/{s/^report_db_host[[:space:]]*=[[:space:]]*\(.*\)/\1/;p}' /etc/rhn/rhn.conf || true)"
exit 0
`

	testutils.AssertEquals(t, "Wrongly generated script", expected, script)
}

func TestServerInspectorParse(t *testing.T) {
	content := `
uyuni_release=2024.5
suse_manager_release=5.0.0
fqdn=my.server.name
image_pg_version=16
current_pg_version=14
db_user=myuser
db_password=mysecret
db_name=mydb
db_port=1234
`
	actual, err := ReadInspectData[ServerInspectData]([]byte(content))
	if err != nil {
		t.Fatalf("Unexpected error: %s", err)
	}

	testutils.AssertEquals(t, "Invalid uyuni release", "2024.5", actual.UyuniRelease)
	testutils.AssertEquals(t, "Invalid SUSE Manager release", "5.0.0", actual.SuseManagerRelease)
	testutils.AssertEquals(t, "Invalid FQDN", "my.server.name", actual.Fqdn)
	testutils.AssertEquals(t, "Invalid current postgresql version", "14", actual.CommonInspectData.CurrentPgVersion)
	testutils.AssertEquals(t, "Invalid image postgresql version", "16", actual.DBInspectData.ImagePgVersion)
	testutils.AssertEquals(t, "Invalid DB user", "myuser", actual.DBUser)
	testutils.AssertEquals(t, "Invalid DB password", "mysecret", actual.DBPassword)
	testutils.AssertEquals(t, "Invalid DB name", "mydb", actual.DBName)
	testutils.AssertEquals(t, "Invalid DB port", 1234, actual.DBPort)
}
0707010000007f000081a400000000000000000000000168ed21dd00000136000000000000000000000000000000000000001700000000shared/utils/slices.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

// Contains returns true if a string is contained in a string slice.
func Contains(slice []string, needle string) bool {
	for _, item := range slice {
		if item == needle {
			return true
		}
	}
	return false
}
07070100000080000081a400000000000000000000000168ed21dd00000ccc000000000000000000000000000000000000001800000000shared/utils/support.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"errors"
	"fmt"
	"os/exec"
	"path"
	"regexp"
	"strings"
	"time"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

// GetSupportConfigPath returns the support config tarball path.
func GetSupportConfigPath(out string) string {
	re := regexp.MustCompile(`/var/log/scc_(.*?)\.txz`)
	return re.FindString(out)
}

// GetSupportConfigFileSaveName returns the support config file name.
func GetSupportConfigFileSaveName() string {
	out, err := RunCmdOutput(zerolog.DebugLevel, "hostname")
	var hostname string
	if err != nil {
		log.Warn().Err(err).Msg(L("Unable to detect hostname, using localhost"))
		hostname = "localhost"
	} else {
		hostname = strings.TrimSpace(string(out))
	}
	now := time.Now()
	return fmt.Sprintf("scc_%s_%s", hostname, now.Format("20060102_1504"))
}

/* CreateSupportConfigTarball will create a tarball in outputFolder with all the supportconfig
 * files generated by host and pods.
 */
func CreateSupportConfigTarball(outputFolder string, files []string) error {
	// Pack it all into a tarball
	log.Info().Msg(L("Preparing the tarball"))

	supportFileName := GetSupportConfigFileSaveName()
	supportFilePath := path.Join(outputFolder, fmt.Sprintf("%s.tar.gz", supportFileName))

	tarball, err := NewTarGz(supportFilePath)
	if err != nil {
		return err
	}

	for _, file := range files {
		if !FileExists(file) {
			log.Trace().Msgf("excluding file %s from tarball", file)
			continue
		}
		if err := tarball.AddFile(file, path.Join(supportFileName, path.Base(file))); err != nil {
			log.Warn().Err(err).Msgf(L("failed to add %s to tarball"), path.Base(file))
		}
	}
	tarball.Close()
	return nil
}

// GetContainersFromSystemdFiles parse a string of systemdfile and return a list of containers.
func GetContainersFromSystemdFiles(systemdFileList string) []string {
	serviceList := strings.Replace(string(systemdFileList), "/etc/systemd/system/", "", -1)
	containers := strings.Replace(serviceList, ".service", "", -1)

	containerList := strings.Split(strings.TrimSpace(containers), "\n")

	var trimmedContainers []string
	for _, container := range containerList {
		trimmedContainers = append(trimmedContainers, strings.TrimSpace(container))
	}
	return trimmedContainers
}

// RunSupportConfigOnHost will run supportconfig command on host machine.
func RunSupportConfigOnHost() ([]string, error) {
	var files []string
	extensions := []string{"", ".md5"}

	// Run supportconfig on the host if installed
	if _, err := exec.LookPath("supportconfig"); err == nil {
		out, err := RunCmdOutput(zerolog.DebugLevel, "supportconfig")
		if err != nil {
			log.Error().Err(err).Msgf(L("failed to run supportconfig on the host"))
		}
		tarballPath := GetSupportConfigPath(string(out))

		// Look for the generated supportconfig file
		if tarballPath != "" && FileExists(tarballPath) {
			for _, ext := range extensions {
				files = append(files, tarballPath+ext)
			}
		} else {
			return []string{}, errors.New(L("failed to find host supportconfig tarball from command output"))
		}
	} else {
		log.Warn().Msg(L("supportconfig is not available on the host, skipping it"))
	}
	return files, nil
}
07070100000081000081a400000000000000000000000168ed21dd000004e3000000000000000000000000000000000000001d00000000shared/utils/support_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"strings"
	"testing"
)

func TestGetSupportConfigPath(t *testing.T) {
	data := [][]string{
		{`/var/log/scc_uyuni-server.mgr.internal_240529_1124.txz`, `/var/log/scc_uyuni-server.mgr.internal_240529_1124.txz`},
		{`/var/log/scc_uyuni-server.mgr.internal.txz`, `/var/log/scc_uyuni-server.mgr.internal.txz`},
		{`/var/log/scc_uyuni-server_240529_1124.txz`, `/var/log/scc_uyuni-server_240529_1124.txz`},
		{`/var/log/scc_uyuni-server.txz`, `/var/log/scc_uyuni-server.txz`},
	}

	for i, testCase := range data {
		input := testCase[0]
		expected := testCase[1]

		actual := GetSupportConfigPath(input)

		if actual != expected {
			t.Errorf("Testcase %d: Expected %s got %s when GetSupportConfigPath %s", i, expected, actual, input)
		}
	}
}

func TestHostedContainers(t *testing.T) {
	data := `
    /etc/systemd/system/uyuni-server.service
	/etc/systemd/system/uyuni-server-attestation@.service
	`

	expected := []string{`uyuni-server`, `uyuni-server-attestation@`}

	actual := GetContainersFromSystemdFiles(data)

	if strings.Join(actual, " ") != strings.Join(expected, " ") {
		t.Errorf("Testcase: Expected %s got %s ", expected, actual)
	}
}
07070100000082000081a400000000000000000000000168ed21dd00000ac9000000000000000000000000000000000000001400000000shared/utils/tar.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"archive/tar"
	"compress/gzip"
	"errors"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

// ExtractTarGz extracts a tar.gz file to dstPath.
func ExtractTarGz(tarballPath string, dstPath string) error {
	reader, err := os.Open(tarballPath)
	if err != nil {
		return err
	}
	defer reader.Close()

	archive, err := gzip.NewReader(reader)
	if err != nil {
		return err
	}
	defer archive.Close()

	tarReader := tar.NewReader(archive)
	for {
		header, err := tarReader.Next()
		if errors.Is(err, io.EOF) {
			break
		} else if err != nil {
			return err
		}

		path, err := filepath.Abs(filepath.Join(dstPath, header.Name))
		if err != nil {
			return err
		}
		if !strings.HasPrefix(path, dstPath) {
			log.Warn().Msgf(L("Skipping extraction of %[1]s in %[2]s file as it resolves outside the target path"),
				header.Name, tarballPath)
			continue
		}

		info := header.FileInfo()
		if info.IsDir() {
			log.Debug().Msgf("Creating folder %s", path)
			if err = os.MkdirAll(path, info.Mode()); err != nil {
				return err
			}
			continue
		}

		log.Debug().Msgf("Extracting file %s", path)
		file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
		if err != nil {
			return err
		}
		defer file.Close()
		if _, err = io.Copy(file, tarReader); err != nil {
			return err
		}
	}

	return nil
}

// TarGz holds a .tar.gz to write it to a file.
type TarGz struct {
	fileWriter *os.File
	tarWriter  *tar.Writer
	gzipWriter *gzip.Writer
}

// NewTarGz create a targz object with writers opened.
// A successful call should be followed with a close.
func NewTarGz(path string) (*TarGz, error) {
	var targz TarGz
	var err error
	targz.fileWriter, err = os.Create(path)
	if err != nil {
		return nil, Errorf(err, L("failed to write tar.gz to %s"), path)
	}

	targz.gzipWriter = gzip.NewWriter(targz.fileWriter)
	targz.tarWriter = tar.NewWriter(targz.gzipWriter)
	return &targz, nil
}

// Close stops all the writers.
func (t *TarGz) Close() {
	t.tarWriter.Close()
	t.gzipWriter.Close()
	t.fileWriter.Close()
}

// AddFile adds the file at filepath to the archive as entrypath.
func (t *TarGz) AddFile(filepath string, entrypath string) error {
	file, err := os.Open(filepath)
	if err != nil {
		return err
	}
	defer file.Close()

	info, err := file.Stat()
	if err != nil {
		return err
	}

	header, err := tar.FileInfoHeader(info, info.Name())
	if err != nil {
		return err
	}

	header.Name = entrypath
	if err = t.tarWriter.WriteHeader(header); err != nil {
		return err
	}

	if _, err = io.Copy(t.tarWriter, file); err != nil {
		return err
	}
	return nil
}
07070100000083000081a400000000000000000000000168ed21dd00000d08000000000000000000000000000000000000001900000000shared/utils/tar_test.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"os"
	"os/exec"
	"path"
	"testing"
)

const dataDir = "data"
const outDir = "out"

const file1Content = "file1 content"

var filesData = map[string]string{
	"file1":     file1Content,
	"sub/file2": "file2 content",
}

// Prepare test files to include in the tarball.
func setup(t *testing.T) string {
	dir := t.TempDir()

	// Create sub directories for the data and the test
	for _, dirPath := range []string{dataDir, outDir} {
		subDir := path.Join(dir, dirPath)
		if err := os.Mkdir(subDir, 0700); err != nil {
			t.Fatalf("failed to create %s directory: %s", dirPath, err)
		}
	}

	// Add some content to the data directory
	for name, content := range filesData {
		filePath := path.Dir(name)
		if filePath != "." {
			absDir := path.Join(dir, dataDir, filePath)
			if err := os.MkdirAll(absDir, 0700); err != nil {
				t.Fatalf("failed to create subdirectory %s for test: %s", absDir, err)
			}
		}
		if err := os.WriteFile(path.Join(dir, dataDir, name), []byte(content), 0700); err != nil {
			t.Fatalf("failed to write test data file %s: %s", name, err)
		}
	}

	return dir
}

func TestWriteTarGz(t *testing.T) {
	tmpDir := setup(t)

	// Create the tarball
	tarballPath := path.Join(tmpDir, "test.tar.gz")
	tarball, err := NewTarGz(tarballPath)
	if err != nil {
		t.Fatalf("failed to create tarball: %s", err)
	}
	if err := tarball.AddFile(path.Join(tmpDir, dataDir, "file1"), "otherfile1"); err != nil {
		t.Fatalf("failed to add file1 to tarball: %s", err)
	}
	if err := tarball.AddFile(path.Join(tmpDir, dataDir, "sub/file2"), "sub/file2"); err != nil {
		t.Fatalf("failed to add sub/file2 to tarball: %s", err)
	}
	tarball.Close()

	// Check the tarball using the tar utility
	testDir := path.Join(tmpDir, outDir)
	if out, err := exec.Command("tar", "xzf", tarballPath, "-C", testDir).CombinedOutput(); err != nil {
		t.Fatalf("failed to extract generated tarball: %s", string(out))
	}

	// Ensure we have all expected files
	for _, file := range []string{"otherfile1", "sub/file2"} {
		if !FileExists(path.Join(testDir, file)) {
			t.Errorf("Missing %s in archive", file)
		}
	}

	// Check the content of a file
	if out, err := os.ReadFile(path.Join(testDir, "otherfile1")); err != nil {
		t.Errorf("failed to read otherfile1: %s", err)
	} else if string(out) != file1Content {
		t.Errorf("expected otherfile1 content %s, but got %s", file1Content, string(out))
	}
}

func TestExtractTarGz(t *testing.T) {
	tmpDir := setup(t)

	// Create an archive using the tar tool
	tarballPath := path.Join(tmpDir, "test.tar.gz")
	dataPath := path.Join(tmpDir, dataDir)
	if out, err := exec.Command("tar", "czf", tarballPath, "-C", dataPath, ".").CombinedOutput(); err != nil {
		t.Fatalf("failed to create test tar.gz: %s", string(out))
	}

	// Extract the tarball
	testDir := path.Join(tmpDir, outDir)
	if err := ExtractTarGz(tarballPath, testDir); err != nil {
		t.Errorf("Failed to extract tar.gz: %s", err)
	}

	// Check the extracted content
	for name, content := range filesData {
		if out, err := os.ReadFile(path.Join(testDir, name)); err != nil {
			t.Errorf("failed to read %s: %s", name, err)
		} else if string(out) != content {
			t.Errorf("expected %s content %s, but got %s", name, content, string(out))
		}
	}
}
07070100000084000081a400000000000000000000000168ed21dd00000352000000000000000000000000000000000000001900000000shared/utils/template.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"fmt"
	"io"
	"os"

	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

// Template is an interface for implementing Render function.
type Template interface {
	Render(wr io.Writer) error
}

// WriteTemplateToFile writes a template to a file.
func WriteTemplateToFile(template Template, path string, perm os.FileMode, overwrite bool) error {
	// Check if the file is existing
	if !overwrite {
		if FileExists(path) {
			return fmt.Errorf(L("%s file already present, not overwriting"), path)
		}
	}

	// Write the configuration
	file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
	if err != nil {
		return Errorf(err, L("failed to open %s for writing"), path)
	}
	defer file.Close()

	return template.Render(file)
}
07070100000085000081a400000000000000000000000168ed21dd00003a18000000000000000000000000000000000000001600000000shared/utils/utils.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"bufio"
	"bytes"
	"crypto/rand"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path"
	"regexp"
	"strconv"
	"strings"
	"syscall"
	"unicode"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/types"
	"golang.org/x/term"
)

const promptEnd = ": "

var prodVersionArchRegex = regexp.MustCompile(`suse\/(?:multi-linux-)?manager\/.*:`)
var imageValid = regexp.MustCompile("^((?:[^:/]+(?::[0-9]+)?/)?[^:]+)(?::([^:]+))?$")

// Taken from https://github.com/go-playground/validator/blob/2e1df48/regexes.go#L58
var fqdnValid = regexp.MustCompile(
	`^([a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})(\.[a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})*?` +
		`(\.[a-zA-Z]{1}[a-zA-Z0-9]{0,62})\.?$`,
)

// InspectResult holds the results of the inspection scripts.
type InspectResult struct {
	CommonInspectData `mapstructure:",squash"`
	DBInspectData     `mapstructure:",squash"`
	Timezone          string
	HasHubXmlrpcAPI   bool `mapstructure:"has_hubxmlrpc"`
	Debug             bool `mapstructure:"debug"`
}

func checkValueSize(value string, minValue int, maxValue int) bool {
	if minValue == 0 && maxValue == 0 {
		return true
	}

	if len(value) < minValue {
		fmt.Printf(NL("Has to be more than %d character long", "Has to be more than %d characters long", minValue), minValue)
		return false
	}
	if len(value) > maxValue {
		fmt.Printf(NL("Has to be less than %d character long", "Has to be less than %d characters long", maxValue), maxValue)
		return false
	}
	return true
}

// CheckValidPassword performs check to a given password.
func CheckValidPassword(value *string, prompt string, minValue int, maxValue int) string {
	fmt.Print(prompt + promptEnd)
	bytePassword, err := term.ReadPassword(int(syscall.Stdin))
	if err != nil {
		log.Error().Err(err).Msg(L("Failed to read password"))
		return ""
	}
	tmpValue := strings.TrimSpace(string(bytePassword))

	if tmpValue == "" {
		fmt.Println("A value is required")
		return ""
	}

	r := regexp.MustCompile(`[\t ]`)
	invalidChars := r.MatchString(tmpValue)

	if invalidChars {
		fmt.Println(L("Cannot contain spaces or tabs"))
		return ""
	}

	if !checkValueSize(tmpValue, minValue, maxValue) {
		fmt.Println()
		return ""
	}
	fmt.Println()
	*value = tmpValue
	return *value
}

// AskPasswordIfMissing asks for password if missing.
// Don't perform any check if min and max are set to 0.
func AskPasswordIfMissing(value *string, prompt string, minValue int, maxValue int) {
	if *value == "" && !term.IsTerminal(int(os.Stdin.Fd())) {
		log.Warn().Msg(L("not an interactive device, not asking for missing value"))
		return
	}

	for *value == "" {
		firstRound := CheckValidPassword(value, prompt, minValue, maxValue)
		if firstRound == "" {
			continue
		}
		secondRound := CheckValidPassword(value, L("Confirm the password"), minValue, maxValue)
		if secondRound != firstRound {
			fmt.Println(L("Two different passwords have been provided"))
			*value = ""
		} else {
			*value = secondRound
		}
	}
}

// AskPasswordIfMissingOnce asks for password if missing only once
// Don't perform any check if min and max are set to 0.
func AskPasswordIfMissingOnce(value *string, prompt string, minValue int, maxValue int) {
	if *value == "" && !term.IsTerminal(int(os.Stdin.Fd())) {
		log.Warn().Msg(L("not an interactive device, not asking for missing value"))
		return
	}

	for *value == "" {
		*value = CheckValidPassword(value, prompt, minValue, maxValue)
	}
}

// AskIfMissing asks for a value if missing.
// Don't perform any check if minValue and maxValue are set to 0.
func AskIfMissing(value *string, prompt string, minValue int, maxValue int, checker func(string) bool) {
	if *value == "" && !term.IsTerminal(int(os.Stdin.Fd())) {
		log.Warn().Msg(L("not an interactive device, not asking for missing value"))
		return
	}

	reader := bufio.NewReader(os.Stdin)
	for *value == "" {
		fmt.Print(prompt + promptEnd)
		newValue, err := reader.ReadString('\n')
		if err != nil {
			log.Fatal().Err(err).Msg(L("failed to read input"))
		}
		tmpValue := strings.TrimSpace(newValue)
		if checkValueSize(tmpValue, minValue, maxValue) && (checker == nil || checker(tmpValue)) {
			*value = tmpValue
		}
		fmt.Println()
		if *value == "" {
			fmt.Print(L("A value is required"))
		}
	}
}

// YesNo asks a question in CLI.
func YesNo(question string) (bool, error) {
	reader := bufio.NewReader(os.Stdin)
	for {
		fmt.Printf("%s [y/N]?", question)

		response, err := reader.ReadString('\n')
		if err != nil {
			return false, err
		}

		response = strings.ToLower(strings.TrimSpace(response))

		if strings.ToLower(response) == "y" || strings.ToLower(response) == "yes" {
			return true, nil
		}
		if strings.ToLower(response) == "n" || strings.ToLower(response) == "no" {
			return false, nil
		}
	}
}

// RemoveRegistryFromImage removes registry fqdn from image path.
func RemoveRegistryFromImage(imagePath string) string {
	separator := "://"
	index := strings.Index(imagePath, separator)
	if index != -1 {
		imagePath = imagePath[index+len(separator):]
	}

	parts := strings.Split(imagePath, "/")
	if strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":") || index != -1 {
		// first part is a registry fqdn
		parts = parts[1:]
	}
	return strings.Join(parts, "/")
}

// SplitRegistryHostAndPath splits a registry string into domain and path.
func SplitRegistryHostAndPath(registry string) (domain string, path string) {
	idx := strings.Index(registry, "/")
	if idx == -1 {
		return registry, ""
	}
	return registry[:idx], registry[idx+1:]
}

// ComputeImage assembles the container image from its name and tag.
func ComputeImage(
	registry string,
	globalTag string,
	imageFlags types.ImageFlags,
	appendToName ...string,
) (string, error) {
	if !strings.Contains(DefaultRegistry, registry) {
		log.Info().Msgf(L("Registry %[1]s would be used instead of namespace %[2]s"), registry, DefaultRegistry)
	}
	name := imageFlags.Name
	if !strings.Contains(imageFlags.Name, registry) {
		name = path.Join(registry, RemoveRegistryFromImage(imageFlags.Name))
	}

	// Compute the tag
	tag := globalTag
	if imageFlags.Tag != "" {
		tag = imageFlags.Tag
	}

	submatches := imageValid.FindStringSubmatch(name)
	if submatches == nil {
		return "", fmt.Errorf(L("invalid image name: %s"), name)
	}
	if submatches[2] == `` {
		if len(tag) <= 0 {
			return name, fmt.Errorf(L("tag missing on %s"), name)
		}
		if len(appendToName) > 0 {
			name = name + strings.Join(appendToName, ``)
		}
		// No tag provided in the URL name, append the one passed
		imageName := fmt.Sprintf("%s:%s", name, tag)
		imageName = strings.ToLower(imageName) // podman does not accept repo in upper case
		log.Info().Msgf(L("Computed image name is %s"), imageName)
		return imageName, nil
	}
	imageName := submatches[1] + strings.Join(appendToName, ``) + `:` + submatches[2]
	imageName = strings.ToLower(imageName) // podman does not accept repo in upper case
	log.Info().Msgf(L("Computed image name is %s"), imageName)
	return imageName, nil
}

// The fullImage must contain the pattern `suse/manager/...` or `suse/multi-linux-manager/...`
// If registry has a path, then, the fullImage must start with that path.
func ComputePTF(registry string, user string, ptfID string, fullImage string, suffix string) (string, error) {
	submatches := prodVersionArchRegex.FindStringSubmatch(fullImage)
	if submatches == nil || len(submatches) != 1 {
		return "", fmt.Errorf(L("invalid image name: %s"), fullImage)
	}
	imagePath := submatches[0]

	registryHost, registryPath := SplitRegistryHostAndPath(registry)
	if registryPath != "" && !strings.HasPrefix(imagePath, registryPath) {
		return "", fmt.Errorf("image path '%s' does not start with registry path '%s'", imagePath, registryPath)
	}

	tag := fmt.Sprintf("latest-%s-%s", suffix, ptfID)
	return fmt.Sprintf("%s/a/%s/%s/%s%s", registryHost, strings.ToLower(user), ptfID, imagePath, tag), nil
}

// GetLocalTimezone returns the timezone set on the current machine.
func GetLocalTimezone() string {
	out, err := RunCmdOutput(zerolog.DebugLevel, "timedatectl", "show", "--value", "-p", "Timezone")
	if err != nil {
		log.Fatal().Err(err).Msgf(L("Failed to run %s"), "timedatectl show --value -p Timezone")
	}
	return strings.TrimSpace(string(out))
}

// GetRandomBase64 generates random base64-encoded data.
func GetRandomBase64(size int) string {
	data := make([]byte, size)
	if _, err := rand.Read(data); err != nil {
		log.Fatal().Err(err).Msg(L("Failed to read random data"))
	}
	return base64.StdEncoding.EncodeToString(data)
}

// ContainsUpperCase check if string contains an uppercase character.
func ContainsUpperCase(str string) bool {
	for _, char := range str {
		if unicode.IsUpper(char) {
			return true
		}
	}
	return false
}

// GetURLBody provide the body content of an GET HTTP request.
func GetURLBody(URL string) ([]byte, error) {
	// Download the key from the URL
	log.Debug().Msgf("Downloading %s", URL)
	resp, err := http.Get(URL)
	if err != nil {
		return nil, Errorf(err, L("error downloading from %s"), URL)
	}
	defer resp.Body.Close()

	// Check server response
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf(L("bad status: %s"), resp.Status)
	}

	var buf bytes.Buffer

	if _, err = io.Copy(&buf, resp.Body); err != nil {
		return nil, err
	}

	// Extract the byte slice from the buffer
	data := buf.Bytes()
	return data, nil
}

// DownloadFile downloads from a remote path to a local file.
func DownloadFile(filepath string, URL string) (err error) {
	data, err := GetURLBody(URL)
	if err != nil {
		return err
	}

	// Writer the body to file
	log.Debug().Msgf("Saving %s to %s", URL, filepath)
	return os.WriteFile(filepath, data, 0644)
}

// maxInts compensates the absence of max on Debian 12's go version.
func maxInts(a int, b int) int {
	if a < b {
		return b
	}
	return a
}

// CompareVersion compare the server image version and the server deployed  version.
func CompareVersion(imageVersion string, deployedVersion string) int {
	image := versionAsSlice(imageVersion)
	deployed := versionAsSlice(deployedVersion)

	maxLen := maxInts(len(image), len(deployed))
	return getPaddedVersion(image, maxLen) - getPaddedVersion(deployed, maxLen)
}

func versionAsSlice(version string) []string {
	re := regexp.MustCompile(`[^0-9]`)
	parts := strings.Split(version, ".")
	result := make([]string, len(parts))
	for i, part := range parts {
		result[i] = re.ReplaceAllString(part, "")
	}
	return result
}

func getPaddedVersion(version []string, size int) int {
	padded := version
	if len(version) != size {
		padded = make([]string, size)
		copy(padded, version)
		for i, part := range padded {
			if part == "" {
				padded[i] = "0"
			}
		}
	}

	result, _ := strconv.Atoi(strings.Join(padded, ""))
	return result
}

// Errorf helps providing consistent errors.
//
// Instead of fmt.Printf(L("the message for %s: %s"), value, err) use:
//
//	Errorf(err, L("the message for %s"), value)
func Errorf(err error, message string, args ...any) error {
	formattedMessage := fmt.Sprintf(message, args...)
	return Error(err, formattedMessage)
}

// Error helps providing consistent errors.
//
// Instead of fmt.Printf(L("the message: %s"), err) use:
//
//	Error(err, L("the message"))
func Error(err error, message string) error {
	// l10n-ignore
	return fmt.Errorf("%s: %w", message, err)
}

// JoinErrors aggregate multiple multiple errors into one.
//
// Replacement for errors.Join which is not available in go 1.19.
func JoinErrors(errs ...error) error {
	var messages []string
	for _, err := range errs {
		if err != nil {
			messages = append(messages, err.Error())
		}
	}
	if len(messages) == 0 {
		return nil
	}
	return errors.New(strings.Join(messages, "; "))
}

// GetFqdn returns and checks the FQDN of the host system.
func GetFqdn(args []string) (string, error) {
	var fqdn string
	if len(args) == 1 {
		fqdn = args[0]
	} else {
		out, err := RunCmdOutput(zerolog.DebugLevel, "hostname", "-f")
		if err != nil {
			return "", Error(err, L("failed to compute server FQDN"))
		}
		fqdn = strings.TrimSpace(string(out))
	}
	if err := IsValidFQDN(fqdn); err != nil {
		return "", err
	}

	return fqdn, nil
}

// IsValidFQDN returns an error if the argument is not a valid FQDN.
func IsValidFQDN(fqdn string) error {
	if !IsWellFormedFQDN(fqdn) {
		return fmt.Errorf(L("%s is not a valid FQDN"), fqdn)
	}
	_, err := net.LookupHost(fqdn)
	if err != nil {
		return Errorf(err, L("cannot resolve %s"), fqdn)
	}
	return nil
}

// IsWellFormedFQDN returns an false if the argument is not a well formed FQDN.
func IsWellFormedFQDN(fqdn string) bool {
	return fqdnValid.MatchString(fqdn)
}

// CommandExists checks if cmd exists in $PATH.
func CommandExists(cmd string) bool {
	_, err := exec.LookPath(cmd)
	return err == nil
}

// SaveBinaryData saves binary data to a file.
func SaveBinaryData(filename string, data []int8) error {
	// Need to convert the array of signed ints to unsigned/byte
	byteArray := make([]byte, len(data))
	for i, v := range data {
		byteArray[i] = byte(v)
	}
	file, err := os.Create(filename)
	if err != nil {
		return Errorf(err, L("error creating file %s"), filename)
	}
	defer file.Close()
	_, err = file.Write(byteArray)
	if err != nil {
		return Errorf(err, L("error writing file %s"), filename)
	}
	return nil
}

// CreateChecksum creates sha256 checksum of provided file.
// Uses system `sha256sum` binary to avoid pulling crypto dependencies.
func CreateChecksum(file string) error {
	outputFile := file + ".sha256sum"

	output, err := NewRunner("sha256sum", file).Exec()
	if err != nil {
		return Errorf(err, L("Failed to calculate checksum of the file %s"), file)
	}
	// We want only checksum, drop the filepath
	output = bytes.Split(output, []byte(" "))[0]
	if err := os.WriteFile(outputFile, output, 0622); err != nil {
		return Errorf(err, L("Failed to write checksum of the file %[1]s to the %[2]s"), file, outputFile)
	}
	return nil
}

// ValidateChecksum checks integrity of the file by checking against stored checksum
// Uses system `sha256sum` binary to avoid pulling crypt dependencies.
func ValidateChecksum(file string) error {
	checksum, err := NewRunner("sha256sum", file).Exec()
	if err != nil {
		return Errorf(err, L("Failed to calculate checksum of the file %s"), file)
	}
	// We want only checksum, drop the filepath
	checksum = bytes.Split(checksum, []byte(" "))[0]

	output, err := os.ReadFile(file + ".sha256sum")
	if err != nil {
		return Errorf(err, L("Failed to read checksum of the file %[1]s"), file)
	}
	// Split by space to work with older backups
	if !bytes.Equal(checksum, bytes.Split(output, []byte(" "))[0]) {
		return fmt.Errorf(L("Checksum of %s does not match"), file)
	}
	return nil
}
07070100000086000081a400000000000000000000000168ed21dd00004910000000000000000000000000000000000000001b00000000shared/utils/utils_test.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"fmt"
	"os"
	"path"
	"regexp"
	"strings"
	"syscall"
	"testing"

	expect "github.com/Netflix/go-expect"
	"github.com/chai2010/gettext-go"
	"github.com/spf13/cobra"
	l10n_utils "github.com/uyuni-project/uyuni-tools/shared/l10n/utils"
	"github.com/uyuni-project/uyuni-tools/shared/testutils"
	"github.com/uyuni-project/uyuni-tools/shared/types"
)

type askTestData struct {
	value           string
	expectedMessage string
	min             int
	max             int
	checker         func(string) bool
}

func setupConsole(t *testing.T) (*expect.Console, func()) {
	// Set english locale to not depend on the system one
	gettext.BindLocale(gettext.New("", "", l10n_utils.New("")))
	gettext.SetLanguage("en")

	c, err := expect.NewConsole(expect.WithStdout(os.Stdout))
	if err != nil {
		t.Errorf("Failed to create fake console")
	}

	origStdin := syscall.Stdin
	origOsStdin := os.Stdin
	origStdout := os.Stdout

	syscall.Stdin = int(c.Tty().Fd())
	os.Stdin = c.Tty()
	os.Stdout = c.Tty()

	return c, func() {
		syscall.Stdin = origStdin
		os.Stdin = origOsStdin
		os.Stdout = origStdout
		c.Close()
	}
}

func TestAskIfMissing(t *testing.T) {
	c, teardown := setupConsole(t)
	defer teardown()

	fChecker := func(v string) bool {
		if !strings.Contains(v, "f") {
			fmt.Println("Has to contain an 'f'")
			return false
		}
		return true
	}

	data := []askTestData{
		{value: "\n", expectedMessage: "A value is required", min: 1, max: 5},
		{value: "superlong\n", expectedMessage: "Has to be less than 5 characters long", min: 1, max: 5},
		{value: "a\n", expectedMessage: "Has to be more than 2 characters long", min: 2, max: 5},
		{value: "booh\n", expectedMessage: "Has to contain an 'f'", min: 0, max: 0, checker: fChecker},
	}

	for i, testCase := range data {
		go func() {
			sendInput(t, i, c, "Prompted value:", testCase.value, testCase.expectedMessage)
			// Send a good value
			sendInput(t, i, c, "Prompted value:", "foo\n", "")
		}()

		var value string
		AskIfMissing(&value, "Prompted value", testCase.min, testCase.max, testCase.checker)
		if value != "foo" {
			t.Errorf("Testcase %d: Expected 'foo', got '%s' value", i, value)
		}
	}
}

func TestCheckValidPassword(t *testing.T) {
	c, teardown := setupConsole(t)
	defer teardown()

	data := []askTestData{
		{value: "\n", expectedMessage: "A value is required", min: 1, max: 5},
		{value: "superlong\n", expectedMessage: "Has to be less than 5 characters long", min: 1, max: 5},
		{value: "a\n", expectedMessage: "Has to be more than 2 characters long", min: 2, max: 5},
	}

	for i, testCase := range data {
		go func() {
			sendInput(t, i, c, "Prompted password:", testCase.value, testCase.expectedMessage)
			// Send a good password
			sendInput(t, i, c, "Prompted password: ", "foo\n", "")
			sendInput(t, i, c, "Confirm the password: ", "foo\n", "")
		}()

		var value string
		AskPasswordIfMissing(&value, "Prompted password", testCase.min, testCase.max)
		if value != "foo" {
			t.Errorf("Testcase %d: Expected 'foo', got '%s' value", i, value)
		}
	}
}

func TestPasswordMismatch(t *testing.T) {
	c, teardown := setupConsole(t)
	defer teardown()

	go func() {
		sendInput(t, 1, c, "Prompted password: ", "password1\n", "")
		sendInput(t, 1, c, "Confirm the password: ", "password2\n", "")

		if _, err := c.ExpectString("Two different passwords have been provided"); err != nil {
			t.Errorf("Expected message error: %s", err)
		}

		// Send a good password
		sendInput(t, 1, c, "Prompted password: ", "foo\n", "")
		sendInput(t, 1, c, "Confirm the password: ", "foo\n", "")
	}()

	var value string
	AskPasswordIfMissing(&value, "Prompted password", 1, 20)
	if value != "foo" {
		t.Errorf("Expected 'foo', got '%s' value", value)
	}
}

func sendInput(
	t *testing.T,
	testcase int,
	c *expect.Console,
	expectedPrompt string,
	value string,
	expectedMessage string,
) {
	if _, err := c.ExpectString(expectedPrompt); err != nil {
		t.Errorf("Testcase %d: Expected prompt error: %s", testcase, err)
	}
	if _, err := c.Send(value); err != nil {
		t.Errorf("Testcase %d: Failed to send value to fake console: %s", testcase, err)
	}
	t.Logf("Value sent: '%s'", value)
	if expectedMessage == "" {
		return
	}

	if _, err := c.Expect(expect.Regexp(regexp.MustCompile(expectedMessage))); err != nil {
		t.Errorf("Testcase %d: Expected '%s' message: %s", testcase, expectedMessage, err)
	}
	if expectedMessage == "" {
		return
	}
}

func TestComputePTF(t *testing.T) {
	// Constants
	const (
		defaultPtfID      = "27977"
		defaultUser       = "150158"
		defaultSuffix     = "ptf"
		baseRegistryHost  = "registry.suse.com"
		defaultRegistry50 = "registry.suse.com/suse/manager/5.0/x86_64"
		defaultRegistry51 = "registry.suse.com/suse/multi-linux-manager/5.1/x86_64"
	)

	tests := []struct {
		name                 string
		registry             string
		user                 string
		ptfID                string
		fullImage            string
		suffix               string
		expected             string
		expectedErrorMessage string
	}{
		// Success cases - 5.0 Manager
		{
			name:      "success 5.0 container with 5.0 registry",
			registry:  defaultRegistry50,
			fullImage: defaultRegistry50 + "/proxy-tftpd:5.0.0",
			expected:  "registry.suse.com/a/150158/27977/suse/manager/5.0/x86_64/proxy-tftpd:latest-ptf-27977",
		},
		{
			name:      "success 5.0 rpm container with 5.0 registry",
			registry:  defaultRegistry50,
			fullImage: "localhost/suse/manager/5.0/x86_64/proxy-ssh:5.0.0",
			expected:  "registry.suse.com/a/150158/27977/suse/manager/5.0/x86_64/proxy-ssh:latest-ptf-27977",
		},
		{
			name:      "success 5.0 container and base registry host",
			registry:  baseRegistryHost,
			fullImage: defaultRegistry50 + "/proxy-tftpd:latest",
			expected:  "registry.suse.com/a/150158/27977/suse/manager/5.0/x86_64/proxy-tftpd:latest-ptf-27977",
		},
		{
			name:      "success 5.0 container and custom registry",
			registry:  "mysccregistry.com",
			fullImage: defaultRegistry50 + "/proxy-helm:latest",
			expected:  "mysccregistry.com/a/150158/27977/suse/manager/5.0/x86_64/proxy-helm:latest-ptf-27977",
		},
		{
			name:      "success 5.0 rpm container and custom registry",
			registry:  "mysccregistry.com",
			fullImage: "localhost/suse/manager/5.0/x86_64/proxy-helm:latest",
			expected:  "mysccregistry.com/a/150158/27977/suse/manager/5.0/x86_64/proxy-helm:latest-ptf-27977",
		},

		// Success cases - 5.1 Multi-Linux Manager
		{
			name:      "success 5.1 container with 5.1 registry",
			registry:  defaultRegistry51,
			fullImage: defaultRegistry51 + "/proxy-tftpd:5.1.0",
			expected:  "registry.suse.com/a/150158/27977/suse/multi-linux-manager/5.1/x86_64/proxy-tftpd:latest-ptf-27977",
		},

		// Failure cases
		{
			name:                 "fail invalid image",
			registry:             baseRegistryHost,
			fullImage:            "some.domain.com/not/matching/suse/proxy-helm:latest",
			expectedErrorMessage: "invalid image name: some.domain.com/not/matching/suse/proxy-helm:latest",
		},
		{
			name:      "fail 5.0 container and invalid custom registry",
			registry:  "mysccregistry.com/invalid/path",
			fullImage: defaultRegistry50 + "/proxy-helm:latest",
			expectedErrorMessage: "image path 'suse/manager/5.0/x86_64/proxy-helm:' does not start with registry " +
				"path 'invalid/path'",
		},
		{
			name:      "fail 5.0 container with 5.1 registry",
			registry:  defaultRegistry51,
			fullImage: defaultRegistry50 + "/proxy-salt-broker:5.0.0",
			expectedErrorMessage: "image path 'suse/manager/5.0/x86_64/proxy-salt-broker:' does not start with " +
				"registry path 'suse/multi-linux-manager/5.1/x86_64'",
		},
		{
			name:      "fail 5.1 container with 5.0 registry",
			registry:  defaultRegistry50,
			fullImage: defaultRegistry51 + "/proxy-squid:5.0.0",
			expectedErrorMessage: "image path 'suse/multi-linux-manager/5.1/x86_64/proxy-squid:' does not start with" +
				" registry path 'suse/manager/5.0/x86_64'",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ptfID := defaultPtfID
			if tt.ptfID != "" {
				ptfID = tt.ptfID
			}
			user := defaultUser
			if tt.user != "" {
				user = tt.user
			}
			suffix := defaultSuffix
			if tt.suffix != "" {
				suffix = tt.suffix
			}

			actual, err := ComputePTF(tt.registry, user, ptfID, tt.fullImage, suffix)
			if err != nil {
				if tt.expectedErrorMessage == "" {
					t.Errorf("Unexpected error while executing ComputePTF('%s', '%s', '%s', '%s', '%s'): %s",
						tt.registry, tt.user, tt.ptfID, tt.fullImage, tt.suffix, err)
				} else if !strings.Contains(err.Error(), tt.expectedErrorMessage) {
					t.Errorf("Expected error message to contain '%s', but got: %s",
						tt.expectedErrorMessage, err.Error())
				}
			} else if actual != tt.expected {
				t.Errorf("ComputePTF('%s', '%s', '%s', '%s', '%s') = %s\nexpected: %s",
					tt.registry, tt.user, tt.ptfID, tt.fullImage, tt.suffix, actual, tt.expected)
			}
		})
	}
}

func TestComputeImage(t *testing.T) {
	data := [][]string{
		{"registry:5000/path/to/image:foo", "registry:5000/path/to/image:foo", "bar", ""},
		{"registry:5000/path/to/image:foo", "REGISTRY:5000/path/to/image:foo", "bar", ""},
		{"registry:5000/path/to/image:foo", "REGISTRY:5000/path/to/image:foo", "BAR", ""},
		{"registry:5000/path/to/image:bar", "registry:5000/path/to/image", "bar", ""},
		{"registry/path/to/image:foo", "registry/path/to/image:foo", "bar", ""},
		{"registry/path/to/image:bar", "registry/path/to/image", "bar", ""},
		{"registry/path/to/image:bar", "path/to/image", "bar", "registry"},
		{"registry:5000/path/to/image:foo", "path/to/image:foo", "BAR", "REGISTRY:5000"},
		{"registry:5000/path/to/image-migration-14-16:foo", "registry:5000/path/to/image:foo", "bar", "", "-migration-14-16"},
		{"registry:5000/path/to/image-migration-14-16:bar", "registry:5000/path/to/image", "bar", "", "-migration-14-16"},
		{"registry/path/to/image-migration-14-16:foo", "registry/path/to/image:foo", "bar", "", "-migration-14-16"},
		{"registry/path/to/image-migration-14-16:bar", "registry/path/to/image", "bar", "", "-migration-14-16"},
		{"registry/path/to/image-migration-14-16:bar", "path/to/image", "bar", "registry", "-migration-14-16"},
		{
			// bsc#1226436
			"registry.suse.de/suse/sle-15-sp6/update/products/manager50/containerfile/suse/manager/5.0/x86_64/server:bar",
			"registry.suse.com/suse/manager/5.0/x86_64/server",
			"bar",
			"registry.suse.de/suse/sle-15-sp6/update/products/manager50/containerfile",
			"",
		},
		{
			"cloud.com/suse/manager/5.0/x86_64/server:5.0.0",
			"registry.suse.com/suse/manager/5.0/x86_64/server",
			"5.0.0",
			"cloud.com",
			"",
		},
		{
			"cloud.com/suse/manager/5.0/x86_64/server:5.0.0",
			"/suse/manager/5.0/x86_64/server",
			"5.0.0",
			"cloud.com",
			"",
		},
		{
			"cloud.com/suse/manager/5.0/x86_64/server:5.0.0",
			"suse/manager/5.0/x86_64/server",
			"5.0.0",
			"cloud.com",
			"",
		},
		{
			"cloud.com/my/path/server:5.0.0",
			"my/path/server",
			"5.0.0",
			"cloud.com",
			"",
		},
	}

	for i, testCase := range data {
		result := testCase[0]
		image := types.ImageFlags{
			Name: testCase[1],
			Tag:  testCase[2],
		}
		appendToImage := testCase[4:]

		actual, err := ComputeImage(testCase[3], "defaulttag", image, appendToImage...)

		if err != nil {
			t.Errorf(
				"Testcase %d: Unexpected error while computing image with %s, %s, %s: %s",
				i, image.Name, image.Tag, appendToImage, err,
			)
		}
		if actual != result {
			t.Errorf(
				"Testcase %d: Expected %s got %s when computing image with %s, %s, %s",
				i, result, actual, image.Name, image.Tag, appendToImage,
			)
		}
	}
}

func TestIsWellFormedFQDN(t *testing.T) {
	data := []string{
		"manager.mgr.suse.de",
		"suma50.suse.de",
	}

	for i, testCase := range data {
		if !IsWellFormedFQDN(testCase) {
			t.Errorf("Testcase %d: Unexpected failure while validating FQDN with %s", i, testCase)
		}
	}
	wrongData := []string{
		"manager",
		"suma50",
		"test24.example24.com..",
		"127.0.0.1",
	}

	for i, testCase := range wrongData {
		if IsWellFormedFQDN(testCase) {
			t.Errorf("Testcase %d: Unexpected success while validating FQDN with %s", i, testCase)
		}
	}
}
func TestComputeImageError(t *testing.T) {
	data := [][]string{
		{"registry:path/to/image:tag:tag", "bar"},
	}

	for _, testCase := range data {
		image := types.ImageFlags{
			Name: testCase[0],
			Tag:  testCase[1],
		}

		_, err := ComputeImage("defaultregistry", "defaulttag", image)
		if err == nil {
			t.Errorf("Expected error for %s with tag %s, got none", image.Name, image.Tag)
		}
	}
}

func TestConfig(t *testing.T) {
	type fakeFlags struct {
		firstConf  string
		secondConf string
		thirdConf  string
		fourthConf string
	}
	fakeCmd := &cobra.Command{
		Use:  "podman",
		Args: cobra.RangeArgs(0, 1),
		RunE: func(cmd *cobra.Command, args []string) error {
			var flags fakeFlags
			flags.firstConf = ""
			flags.secondConf = ""
			flags.thirdConf = ""
			flags.fourthConf = ""
			return CommandHelper(nil, cmd, args, &flags, nil, nil)
		},
	}

	fakeCmd.Flags().String("firstConf", "hardcodedDefault", "")
	fakeCmd.Flags().String("secondConf", "hardcodedDefault", "")
	fakeCmd.Flags().String("thirdConf", "hardcodedDefault", "")
	fakeCmd.Flags().String("fourthConf", "hardcodedDefault", "")

	viper, err := ReadConfig(fakeCmd, "conf_test/firstConfFile.yaml", "conf_test/secondConfFile.yaml")
	if err != nil {
		t.Errorf("Unexpected error while reading configuration files: %s", err)
	}

	//This value is not set by conf file, so it should be the hardcoded default value
	if viper.Get("firstConf") != "hardcodedDefault" {
		t.Errorf("firstConf is %s, instead of hardcodedDefault", viper.Get("firstConf"))
	}
	//This value is set by firstConfFile.yaml
	if viper.Get("secondConf") != "firstConfFile" {
		t.Errorf("secondConf is %s, instead of firstConfFile", viper.Get("secondConf"))
	}
	//This value is as first set by firstConfFile.yaml, but then overwritten by secondConfFile.yaml
	if viper.Get("thirdConf") != "SecondConfFile" {
		t.Errorf("thirdConf is %s, instead of SecondConfFile", viper.Get("thirdConf"))
	}
	//This value is set by secondConfFile.yaml
	if viper.Get("fourthconf") != "SecondConfFile" {
		t.Errorf("fourthconf is %s, instead of SecondConfFile", viper.Get("fourthconf"))
	}
}

func TestCompareVersion(t *testing.T) {
	testutils.AssertTrue(t, "2024.07 is not inferior to 2024.13", CompareVersion("2024.07", "2024.13") < 0)
	testutils.AssertTrue(t, "2024.13 is not superior to 2024.07", CompareVersion("2024.13", "2024.07") > 0)
	testutils.AssertTrue(t, "2024.13 is not equal to 2024.13", CompareVersion("2024.13", "2024.13") == 0)

	testutils.AssertEquals(t, "invalid padded version", 5041, getPaddedVersion(versionAsSlice("5.0.4.1"), 4))
	testutils.AssertEquals(t, "invalid padded version", 5100, getPaddedVersion(versionAsSlice("5.1.0"), 4))
	testutils.AssertTrue(t, "5.1.0 is not superior to 5.0.4.1", CompareVersion("5.1.0", "5.0.4.1") > 0)
	testutils.AssertTrue(t, "5.1-rc is not superior to 5.0.4.1", CompareVersion("5.1-rc", "5.0.4.1") > 0)
}

func TestCreatingChecksumFile(t *testing.T) {
	testDir := t.TempDir()
	filepath := path.Join(testDir, "testfile")

	err := os.WriteFile(filepath, []byte("testfiledata"), 0666)
	testutils.AssertTrue(t, "Failed to prepare test data file", err == nil)

	err = CreateChecksum(filepath)
	testutils.AssertTrue(t, "Failed to calculate checksum", err == nil)

	out, err := os.ReadFile(filepath + ".sha256sum")
	testutils.AssertTrue(t, "Failed to read checksum file", err == nil)

	testutils.AssertEquals(t, "Checksum does not match", out,
		[]byte("886d35a29af629be5c45ff24320dd4d48ee8860b25a9a724f8ac88cf15755a22"))
}

func TestValidatingChecksumFile(t *testing.T) {
	testDir := t.TempDir()
	filepath := path.Join(testDir, "testfile")

	err := os.WriteFile(filepath, []byte("testfiledata"), 0666)
	testutils.AssertTrue(t, "Failed to prepare test data file", err == nil)

	err = CreateChecksum(filepath)
	testutils.AssertTrue(t, "Failed to calculate checksum", err == nil)

	err = ValidateChecksum(filepath)
	testutils.AssertTrue(t, "Failed to validate checksum", err == nil)
}

func TestFailedValidation(t *testing.T) {
	testDir := t.TempDir()
	filepath := path.Join(testDir, "testfile")

	err := os.WriteFile(filepath, []byte("testfiledata"), 0666)
	testutils.AssertTrue(t, "Failed to prepare test data file", err == nil)

	err = os.WriteFile(filepath+".sha256sum", []byte("wrongchecksum"), 0666)
	testutils.AssertTrue(t, "Failed to write checksum file", err == nil)

	err = ValidateChecksum(filepath)
	testutils.AssertTrue(t, "Checksum validation passed when should have not", err != nil)
}

func TestValidationInDifferentDir(t *testing.T) {
	testDir := t.TempDir()
	filepath := path.Join(testDir, "testfile")

	err := os.WriteFile(filepath, []byte("testfiledata"), 0666)
	testutils.AssertTrue(t, "Failed to prepare test data file", err == nil)

	err = CreateChecksum(filepath)
	testutils.AssertTrue(t, "Failed to calculate checksum", err == nil)

	testDir2 := t.TempDir()

	filepath2 := path.Join(testDir2, "testfile")
	fh, err := os.OpenFile(filepath2, os.O_CREATE|os.O_WRONLY, 0666)
	testutils.AssertTrue(t, "Could not create new file", err == nil)
	err = CopyFile(filepath, fh)
	testutils.AssertTrue(t, "Could not copy test file", err == nil)
	err = fh.Close()
	testutils.AssertTrue(t, "Could not close new file", err == nil)

	fh, err = os.OpenFile(filepath2+".sha256sum", os.O_CREATE|os.O_WRONLY, 0666)
	testutils.AssertTrue(t, "Could not create new file", err == nil)
	err = CopyFile(filepath+".sha256sum", fh)
	testutils.AssertTrue(t, "Could not copy test file", err == nil)
	err = fh.Close()
	testutils.AssertTrue(t, "Could not close new file", err == nil)

	err = os.Remove(filepath)
	testutils.AssertTrue(t, "Could not remove original file", err == nil)
	err = os.Remove(filepath + ".sha256sum")
	testutils.AssertTrue(t, "Could not remove original checksum file", err == nil)

	err = ValidateChecksum(filepath2)
	testutils.AssertTrue(t, "Failed to validate checksum", err == nil)
}

func TestValidatingOlderChecksumFile(t *testing.T) {
	testDir := t.TempDir()
	filepath := path.Join(testDir, "testfile")

	err := os.WriteFile(filepath, []byte("testfiledata"), 0666)
	testutils.AssertTrue(t, "Failed to prepare test data file", err == nil)

	err = os.WriteFile(filepath+".sha256sum",
		[]byte("886d35a29af629be5c45ff24320dd4d48ee8860b25a9a724f8ac88cf15755a22 /path/to/testfile"), 0666)
	testutils.AssertTrue(t, "Failed to write test data checksum", err == nil)

	err = ValidateChecksum(filepath)
	testutils.AssertTrue(t, "Failed to validate checksum", err == nil)
}
07070100000087000081a400000000000000000000000168ed21dd00000b25000000000000000000000000000000000000001e00000000shared/utils/uyuniservices.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	. "github.com/uyuni-project/uyuni-tools/shared/l10n"
	"github.com/uyuni-project/uyuni-tools/shared/types"
)

// Server

// UyuniServices is the list of services to expose.
var UyuniServices = []types.UyuniService{
	{Name: "uyuni-server",
		Image:       ServerImage,
		Description: L("Main service"),
		Replicas:    types.SingleMandatoryReplica,
		Options:     []types.UyuniServiceOption{}},

	{Name: "uyuni-server-migration",
		Image:       Migration14To16Image,
		Description: L("Migration helper"),
		Replicas:    types.SingleOptionalReplica,
		Options:     []types.UyuniServiceOption{}},

	{Name: "uyuni-server-attestation",
		Image:       COCOAttestationImage,
		Description: L("Confidential computing attestation"),
		Replicas:    types.SingleOptionalReplica,
		Options:     []types.UyuniServiceOption{}},

	{Name: "uyuni-hub-xmlrpc",
		Image:       HubXMLRPCImage,
		Description: L("Hub XML-RPC API"),
		Replicas:    types.SingleOptionalReplica,
		Options:     []types.UyuniServiceOption{}},

	{Name: "uyuni-saline",
		Image:       SalineImage,
		Description: L("Saline"),
		Replicas:    types.SingleOptionalReplica,
		Options:     []types.UyuniServiceOption{}},
	{Name: "uyuni-db",
		Description: L("Database"),
		Replicas:    types.SingleMandatoryReplica,
		Options:     []types.UyuniServiceOption{}},
}

// ServerImage holds the flags to tune the server container image.
var ServerImage = types.ImageFlags{
	Name:       "server",
	Tag:        DefaultTag,
	Registry:   DefaultRegistry,
	PullPolicy: DefaultPullPolicy,
}

// HubXMLRPCImage holds the flags to tune the hub XML-RPC API container image.
var HubXMLRPCImage = types.ImageFlags{
	Name:       "server-hub-xmlrpc-api",
	Tag:        DefaultTag,
	Registry:   DefaultRegistry,
	PullPolicy: DefaultPullPolicy,
}

// COCOAttestationImage holds the flags to tune the confidential computing attestation container image.
var COCOAttestationImage = types.ImageFlags{
	Name:       "server-attestation",
	Tag:        DefaultTag,
	Registry:   DefaultRegistry,
	PullPolicy: DefaultPullPolicy,
}

// Saline holds the flags to tune the saline container image.
var SalineImage = types.ImageFlags{
	Name:       "server-saline",
	Tag:        DefaultTag,
	Registry:   DefaultRegistry,
	PullPolicy: DefaultPullPolicy,
}

// Migration14To16Image holds the flags to tune the DB migration container image.
var Migration14To16Image = types.ImageFlags{
	Name:       "server-migration-14-16",
	Tag:        DefaultTag,
	Registry:   DefaultRegistry,
	PullPolicy: DefaultPullPolicy,
}

// PostgreSQLImage holds the flags to tune the DB container image.
var PostgreSQLImage = types.ImageFlags{
	Name:       "uyuni-db",
	Tag:        DefaultTag,
	Registry:   DefaultRegistry,
	PullPolicy: DefaultPullPolicy,
}
07070100000088000081a400000000000000000000000168ed21dd00000196000000000000000000000000000000000000002000000000shared/utils/validationUtils.go// SPDX-FileCopyrightText: 2024 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import (
	"github.com/spf13/cobra"
)

// MarkMandatoryFlags ensures that the specified flags are marked as required for the given command.
func MarkMandatoryFlags(cmd *cobra.Command, fields []string) {
	for _, field := range fields {
		if err := cmd.MarkFlagRequired(field); err != nil {
			return
		}
	}
}
07070100000089000081a400000000000000000000000168ed21dd00001019000000000000000000000000000000000000001800000000shared/utils/volumes.go// SPDX-FileCopyrightText: 2025 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package utils

import "github.com/uyuni-project/uyuni-tools/shared/types"

// EtcRhnVolumeMount defines the /etc/rhn volume mount.
var EtcRhnVolumeMount = types.VolumeMount{MountPath: "/etc/rhn", Name: "etc-rhn", Size: "1Mi"}

// VarPgsqlDataVolumeMount defines the /var/lib/pgsql/data volume mount.
var VarPgsqlDataVolumeMount = types.VolumeMount{MountPath: "/var/lib/pgsql/data", Name: "var-pgsql", Size: "50Gi"}

// RootVolumeMount defines the /root volume mount.
var RootVolumeMount = types.VolumeMount{MountPath: "/root", Name: "root", Size: "1Mi"}

// PgsqlRequiredVolumeMounts represents volumes mount used by PostgreSQL.
var PgsqlRequiredVolumeMounts = []types.VolumeMount{
	VarPgsqlDataVolumeMount,
}

// CaCertVolumeMount represents volume for CA certificates.
var CaCertVolumeMount = types.VolumeMount{MountPath: "/etc/pki/trust/anchors/", Name: "ca-cert"}

// EtcTLSTmpVolumeMount represents temporary volume for SSL certificates.
var EtcTLSTmpVolumeMount = types.VolumeMount{MountPath: "/etc/pki/tls/", Name: "etc-tls", Size: "1Mi"}

// ServerVolumeMounts should match the volumes mapping from the container definition in both
// the helm chart and the systemctl services definitions.
var ServerVolumeMounts = []types.VolumeMount{
	{MountPath: "/var/lib/cobbler", Name: "var-cobbler", Size: "10Mi"},
	{MountPath: "/var/lib/rhn/search", Name: "var-search", Size: "10Gi"},
	{MountPath: "/var/lib/salt", Name: "var-salt", Size: "10Mi"},
	{MountPath: "/var/cache", Name: "var-cache", Size: "10Gi"},
	{MountPath: "/var/spacewalk", Name: "var-spacewalk", Size: "100Gi"},
	{MountPath: "/var/log", Name: "var-log", Size: "2Gi"},
	{MountPath: "/srv/salt", Name: "srv-salt", Size: "10Mi"},
	{MountPath: "/srv/www/", Name: "srv-www", Size: "100Gi"},
	{MountPath: "/srv/tftpboot", Name: "srv-tftpboot", Size: "300Mi"},
	{MountPath: "/srv/formula_metadata", Name: "srv-formulametadata", Size: "10Mi"},
	{MountPath: "/srv/pillar", Name: "srv-pillar", Size: "10Mi"},
	{MountPath: "/srv/susemanager", Name: "srv-susemanager", Size: "1Mi"},
	{MountPath: "/srv/spacewalk", Name: "srv-spacewalk", Size: "10Mi"},
	RootVolumeMount,
	CaCertVolumeMount,
	{MountPath: "/run/salt/master", Name: "run-salt-master"},
	{MountPath: "/etc/apache2", Name: "etc-apache2", Size: "1Mi"},
	{MountPath: "/etc/systemd/system/multi-user.target.wants", Name: "etc-systemd-multi", Size: "1Mi"},
	{MountPath: "/etc/systemd/system/sockets.target.wants", Name: "etc-systemd-sockets", Size: "1Mi"},
	{MountPath: "/etc/salt", Name: "etc-salt", Size: "1Mi"},
	{MountPath: "/etc/tomcat", Name: "etc-tomcat", Size: "1Mi"},
	{MountPath: "/etc/cobbler", Name: "etc-cobbler", Size: "1Mi"},
	{MountPath: "/etc/sysconfig", Name: "etc-sysconfig", Size: "20Mi"},
	{MountPath: "/etc/postfix", Name: "etc-postfix", Size: "1Mi"},
	{MountPath: "/etc/sssd", Name: "etc-sssd", Size: "1Mi"},
	EtcRhnVolumeMount,
}

// ServerMigrationVolumeMounts match server + postgres volume mounts, used for migration.
var ServerMigrationVolumeMounts = append(ServerVolumeMounts, VarPgsqlDataVolumeMount, EtcTLSTmpVolumeMount)

// DatabaseMigrationVolumeMounts match database + etc/rhn volume mounts, used for database migration.
var DatabaseMigrationVolumeMounts = []types.VolumeMount{EtcRhnVolumeMount, VarPgsqlDataVolumeMount}

// SalineVolumeMounts represents volumes used by Saline container.
var SalineVolumeMounts = []types.VolumeMount{
	{Name: "etc-salt", MountPath: "/etc/salt"},
	{Name: "run-salt-master", MountPath: "/run/salt/master"},
}

// ProxyHttpdVolumes volumes used by HTTPD in proxy.
var ProxyHttpdVolumes = []types.VolumeMount{
	{Name: "uyuni-proxy-rhn-cache", MountPath: "/var/cache/rhn"},
	{Name: "uyuni-proxy-tftpboot", MountPath: "/srv/tftpboot"},
}

// ProxySquidVolumes volumes used by Squid in  proxy.
var ProxySquidVolumes = []types.VolumeMount{
	{Name: "uyuni-proxy-squid-cache", MountPath: "/var/cache/squid"},
}

// ProxyTftpdVolumes used by TFTP in proxy.
var ProxyTftpdVolumes = []types.VolumeMount{
	{Name: "uyuni-proxy-tftpboot", MountPath: "/srv/tftpboot:ro"},
}
0707010000008a000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000000d00000000shared/utils0707010000008b000041ed00000000000000000000000168ed21dd00000000000000000000000000000000000000000000000700000000shared07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000b00000000TRAILER!!!
openSUSE Build Service is sponsored by