diff --git a/cmd/suseconnect/connectUsage.txt b/cmd/suseconnect/connectUsage.txt index fe7248d7..b3b1d046 100644 --- a/cmd/suseconnect/connectUsage.txt +++ b/cmd/suseconnect/connectUsage.txt @@ -15,6 +15,9 @@ Manage subscriptions at https://scc.suse.com be registered. Relates that product to the specified subscription, and enables software repositories for that product. + --set-labels [LABELS] + Set labels in SCC when the product is registered. + To add multiple labels, separate them with commas. -d, --de-register De-registers the system and base product, or in conjunction with --product, a single extension, and removes all its services installed by SUSEConnect. diff --git a/cmd/suseconnect/suseconnect.go b/cmd/suseconnect/suseconnect.go index cbe2fcc9..23d1f1c2 100644 --- a/cmd/suseconnect/suseconnect.go +++ b/cmd/suseconnect/suseconnect.go @@ -68,6 +68,7 @@ func main() { fsRoot string namespace string token string + labels string product singleStringFlag instanceDataFile string listExtensions bool @@ -104,6 +105,7 @@ func main() { flag.StringVar(&namespace, "namespace", "", "") flag.StringVar(&token, "regcode", "", "") flag.StringVar(&token, "r", "", "") + flag.StringVar(&labels, "set-labels", "", "") flag.StringVar(&instanceDataFile, "instance-data", "", "") flag.StringVar(&email, "email", "", "") flag.StringVar(&email, "e", "", "") @@ -130,15 +132,15 @@ func main() { connect.CFG.Load() if baseURL != "" { if err := validateURL(baseURL); err != nil { - fmt.Printf("URL \"%s\" not valid: %s\n", baseURL, err) + fmt.Fprintf(os.Stderr, "URL \"%s\" not valid: %s\n", baseURL, err) os.Exit(1) } - connect.CFG.BaseURL = baseURL + connect.CFG.ChangeBaseURL(baseURL) writeConfig = true } if fsRoot != "" { if fsRoot[0] != '/' { - fmt.Println("The path specified in the --root option must be absolute.") + fmt.Fprintln(os.Stderr, "The path specified in the --root option must be absolute.") os.Exit(1) } connect.CFG.FsRoot = fsRoot @@ -153,9 +155,9 @@ func main() { } if product.isSet { if p, err := connect.SplitTriplet(product.value); err != nil { - fmt.Print("Please provide the product identifier in this format: ") - fmt.Print("//. You can find ") - fmt.Print("these values by calling: 'SUSEConnect --list-extensions'\n") + fmt.Fprint(os.Stderr, "Please provide the product identifier in this format: ") + fmt.Fprint(os.Stderr, "//. You can find ") + fmt.Fprint(os.Stderr, "these values by calling: 'SUSEConnect --list-extensions'\n") os.Exit(1) } else { connect.CFG.Product = p @@ -259,15 +261,15 @@ func main() { fmt.Print(string(out)) } else { - if instanceDataFile != "" && connect.URLDefault() { - fmt.Print("Please use --instance-data only in combination ") - fmt.Print("with --url pointing to your RMT or SMT server\n") + if instanceDataFile != "" && connect.CFG.IsScc() { + fmt.Fprint(os.Stderr, "Please use --instance-data only in combination ") + fmt.Fprint(os.Stderr, "with --url pointing to your RMT or SMT server\n") os.Exit(1) - } else if connect.URLDefault() && token == "" && product.value == "" { + } else if connect.CFG.IsScc() && token == "" && product.value == "" { flag.Usage() os.Exit(1) } else if isSumaManaged() { - fmt.Println("This system is managed by SUSE Manager / Uyuni, do not use SUSEconnect.") + fmt.Fprintln(os.Stderr, "This system is managed by SUSE Manager / Uyuni, do not use SUSEconnect.") os.Exit(1) } else { @@ -285,26 +287,37 @@ func main() { } err := connect.Register(jsonFlag) - if jsonFlag && err != nil { - out := connect.RegisterOut{Success: false, Message: err.Error()} - str, _ := json.Marshal(&out) - fmt.Println(string(str)) - os.Exit(1) - } else { - exitOnError(err) + if err != nil { + if jsonFlag { + out := connect.RegisterOut{Success: false, Message: err.Error()} + str, _ := json.Marshal(&out) + fmt.Println(string(str)) + os.Exit(1) + } else { + exitOnError(err) + } + } + + // After successful registration we try to set labels if we are + // targetting SCC. + if connect.CFG.IsScc() && len(labels) > 0 { + err := connect.AssignAndCreateLabels(strings.Split(labels, ",")) + if err != nil && !jsonFlag { + fmt.Fprintf(os.Stderr, "Problem setting labels for this system: %s\n", err) + } } } } if writeConfig { if err := connect.CFG.Save(); err != nil { - fmt.Printf("Problem writing configuration: %s\n", err) + fmt.Fprintf(os.Stderr, "Problem writing configuration: %s\n", err) os.Exit(1) } } } func maybeBrokenSMTError() error { - if !connect.URLDefault() && !connect.UpToDate() { + if !connect.CFG.IsScc() && !connect.UpToDate() { return fmt.Errorf("Your Registration Proxy server doesn't support this function. " + "Please update it and try again.") } @@ -316,55 +329,55 @@ func exitOnError(err error) { return } if ze, ok := err.(zypper.ZypperError); ok { - fmt.Println(ze) + fmt.Fprintln(os.Stderr, ze) os.Exit(ze.ExitCode) } if ue, ok := err.(*url.Error); ok && errors.Is(ue, syscall.ECONNREFUSED) { - fmt.Println("Error:", err) + fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(64) } if je, ok := err.(connect.JSONError); ok { if err := maybeBrokenSMTError(); err != nil { - fmt.Println(err) + fmt.Fprintln(os.Stderr, err) } else { - fmt.Print("Error: Cannot parse response from server\n") - fmt.Println(je) + fmt.Fprint(os.Stderr, "Error: Cannot parse response from server\n") + fmt.Fprintln(os.Stderr, je) } os.Exit(66) } if ae, ok := err.(connect.APIError); ok { if ae.Code == http.StatusUnauthorized && connect.IsRegistered() { - fmt.Print("Error: Invalid system credentials, probably because the ") - fmt.Print("registered system was deleted in SUSE Customer Center. ") - fmt.Print("Check ", connect.CFG.BaseURL, " whether your system appears there. ") - fmt.Print("If it does not, please call SUSEConnect --cleanup and re-register this system.\n") + fmt.Fprint(os.Stderr, "Error: Invalid system credentials, probably because the ") + fmt.Fprint(os.Stderr, "registered system was deleted in SUSE Customer Center. ") + fmt.Fprint(os.Stderr, "Check ", connect.CFG.BaseURL, " whether your system appears there. ") + fmt.Fprint(os.Stderr, "If it does not, please call SUSEConnect --cleanup and re-register this system.\n") } else if err := maybeBrokenSMTError(); err != nil { - fmt.Println(err) + fmt.Fprintln(os.Stderr, err) } else { - fmt.Println(ae) + fmt.Fprintln(os.Stderr, ae) } os.Exit(67) } switch err { case connect.ErrSystemNotRegistered: - fmt.Print("Deregistration failed. Check if the system has been ") - fmt.Print("registered using the --status-text option or use the ") - fmt.Print("--regcode parameter to register it.\n") + fmt.Fprint(os.Stderr, "Deregistration failed. Check if the system has been ") + fmt.Fprint(os.Stderr, "registered using the --status-text option or use the ") + fmt.Fprint(os.Stderr, "--regcode parameter to register it.\n") os.Exit(69) case connect.ErrListExtensionsUnregistered: - fmt.Print("To list extensions, you must first register the base product, ") - fmt.Print("using: SUSEConnect -r \n") + fmt.Fprint(os.Stderr, "To list extensions, you must first register the base product, ") + fmt.Fprint(os.Stderr, "using: SUSEConnect -r \n") os.Exit(1) case connect.ErrBaseProductDeactivation: - fmt.Print("Can not deregister base product. Use SUSEConnect -d to deactivate ") - fmt.Print("the whole system.\n") + fmt.Fprint(os.Stderr, "Can not deregister base product. Use SUSEConnect -d to deactivate ") + fmt.Fprint(os.Stderr, "the whole system.\n") os.Exit(70) case connect.ErrPingFromUnregistered: - fmt.Print("Error sending keepalive: ") - fmt.Print("System is not registered. Use the --regcode parameter to register it.\n") + fmt.Fprint(os.Stderr, "Error sending keepalive: ") + fmt.Fprint(os.Stderr, "System is not registered. Use the --regcode parameter to register it.\n") os.Exit(71) default: - fmt.Printf("SUSEConnect error: %s\n", err) + fmt.Fprintf(os.Stderr, "SUSEConnect error: %s\n", err) os.Exit(1) } } diff --git a/cmd/zypper-migration/migration.go b/cmd/zypper-migration/migration.go index d70d4659..5f77bd6e 100644 --- a/cmd/zypper-migration/migration.go +++ b/cmd/zypper-migration/migration.go @@ -497,8 +497,8 @@ func compareEditions(left, right string) int { return 0 } -func cleanupProductRepos(p connect.Product, force bool) error { - productPackages, err := zypper.FindProductPackages(p.Name) +func cleanupProductRepos(p connect.Product, force, autoImportRepoKeys bool) error { + productPackages, err := zypper.FindProductPackages(p.Name, autoImportRepoKeys) if err != nil { return err } @@ -564,7 +564,7 @@ func isSUSEService(service zypper.ZypperService) bool { // adds/removes services to match target state // disables obsolete repos // returns base product version string -func migrateSystem(migration connect.MigrationPath, forceDisableRepos bool) (string, error) { +func migrateSystem(migration connect.MigrationPath, forceDisableRepos, autoImportRepoKeys bool) (string, error) { var baseProductVersion string systemServices, _ := zypper.InstalledServices() @@ -587,7 +587,7 @@ func migrateSystem(migration connect.MigrationPath, forceDisableRepos bool) (str } } - if err := cleanupProductRepos(p, forceDisableRepos); err != nil { + if err := cleanupProductRepos(p, forceDisableRepos, autoImportRepoKeys); err != nil { return baseProductVersion, err } @@ -678,7 +678,7 @@ func applyMigration(migration connect.MigrationPath, systemProducts []connect.Pr } } - baseProductVersion, err := migrateSystem(migration, nonInteractive || forceDisableRepos) + baseProductVersion, err := migrateSystem(migration, nonInteractive || forceDisableRepos, autoImportRepoKeys) if err != nil { return fsInconsistent, err } diff --git a/internal/connect/api.go b/internal/connect/api.go index 5905a027..40e3e0e0 100644 --- a/internal/connect/api.go +++ b/internal/connect/api.go @@ -339,3 +339,17 @@ func installerUpdates(product Product) ([]zypper.Repository, error) { } return repos, nil } + +func setLabels(labels []Label) error { + var payload struct { + Labels []Label `json:"labels"` + } + payload.Labels = labels + body, err := json.Marshal(payload) + + if err != nil { + return err + } + _, err = callHTTP("POST", "/connect/systems/labels", body, nil, authSystem) + return err +} diff --git a/internal/connect/api_test.go b/internal/connect/api_test.go index 37af7ff9..d7a7ec08 100644 --- a/internal/connect/api_test.go +++ b/internal/connect/api_test.go @@ -375,3 +375,46 @@ func TestMakeSysInfoBody(t *testing.T) { assert.NoError(err) assert.Equal(expectedBody, string(body)) } + +func TestSetLabelsOk(t *testing.T) { + assert := assert.New(t) + + testLabels := []Label{ + Label{Name: "label1"}, + Label{Name: "label2"}, + } + + setRootToTmp() + credentials.CreateTestCredentials("", "", CFG.FsRoot, t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(util.ReadTestFile("set_labels.json", t)) + })) + defer ts.Close() + + CFG.BaseURL = ts.URL + + err := setLabels(testLabels) + assert.NoError(err) +} + +func TestSetLabelsError(t *testing.T) { + assert := assert.New(t) + testLabels := []Label{ + Label{Name: "label1"}, + Label{Name: "label2"}, + } + + setRootToTmp() + credentials.CreateTestCredentials("", "", CFG.FsRoot, t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + errMsg := "{\"status\":422,\"error\":\"Cannot set more than 10 labels on system: test-system\",\"type\":\"error\",\"localized_error\":\"Es können nicht mehr als 10 labels per System gesetzt werden: test-system\"}" + http.Error(w, errMsg, http.StatusUnprocessableEntity) + })) + defer ts.Close() + CFG.BaseURL = ts.URL + + err := setLabels(testLabels) + assert.ErrorContains(err, "Es können") +} diff --git a/internal/connect/client.go b/internal/connect/client.go index 356c7e20..eb1ea2bb 100644 --- a/internal/connect/client.go +++ b/internal/connect/client.go @@ -387,8 +387,14 @@ func announceOrUpdate(quiet bool) error { } if err = cred.CreateCredentials(login, password, "", cred.SystemCredentialsPath(CFG.FsRoot)); err == nil { - util.Debug.Print("\nAdding SUSE registry system authentication configuration ...") - setupRegistryAuthentication(login, password) + // If the user is authenticated against the SCC, then setup the Docker + // Registry configuration for the system. Otherwise, if the system is + // behind a proxy (e.g. RMT), this step might fail and it's best to + // avoid it (see bsc#1231185). + if CFG.IsScc() { + util.Debug.Print("\nAdding SUSE registry system authentication configuration ...") + setupRegistryAuthentication(login, password) + } } return err } @@ -405,14 +411,9 @@ func UpToDate() bool { return upToDate() } -// URLDefault returns true if using https://scc.suse.com -func URLDefault() bool { - return CFG.BaseURL == defaultBaseURL -} - func printInformation(action string, jsonOutput bool) { var server string - if URLDefault() { + if CFG.IsScc() { server = "SUSE Customer Center" } else { server = "registration proxy " + CFG.BaseURL diff --git a/internal/connect/config.go b/internal/connect/config.go index 53517039..0840e896 100644 --- a/internal/connect/config.go +++ b/internal/connect/config.go @@ -26,6 +26,15 @@ const ( defaultEnableSystemUptimeTracking = false ) +// Kinds of servers which are supported by SUSEConnect. +type ServerType uint64 + +const ( + UnknownProvider ServerType = iota + SccProvider + RmtProvider +) + // Config holds the config! type Config struct { Path string @@ -40,10 +49,10 @@ type Config struct { Email string `json:"email"` AutoAgreeEULA bool EnableSystemUptimeTracking bool - - NoZypperRefresh bool - AutoImportRepoKeys bool - SkipServiceInstall bool + ServerType ServerType + NoZypperRefresh bool + AutoImportRepoKeys bool + SkipServiceInstall bool } // NewConfig returns a Config with defaults @@ -84,6 +93,12 @@ func (c Config) Save() error { func (c *Config) Load() { f, err := os.Open(c.Path) if err != nil { + // If we failed at parsing the configuration, we can make further + // assumptions based on the base URL being used. + if c.BaseURL == defaultBaseURL { + c.ServerType = SccProvider + } + util.Debug.Println(err) return } @@ -92,6 +107,22 @@ func (c *Config) Load() { util.Debug.Printf("Config after parsing: %+v", c) } +// Change the base url to be used when talking to the server to the one being +// provided. +func (c *Config) ChangeBaseURL(baseUrl string) { + c.BaseURL = baseUrl + + // When making an explicit change of the URL, we can further detect which + // kind of server we are dealing with. For now, let's keep it simple, and if + // it's the defaultBaseURL then we assume it to be SccProvider, otherwise + // RmtProvider. + if c.BaseURL == defaultBaseURL { + c.ServerType = SccProvider + } else { + c.ServerType = RmtProvider + } +} + func parseConfig(r io.Reader, c *Config) { scanner := bufio.NewScanner(r) for scanner.Scan() { @@ -123,6 +154,11 @@ func parseConfig(r io.Reader, c *Config) { util.Debug.Printf("Cannot parse line \"%s\" from %s", line, c.Path) } } + + // Set the server type depending on what we parsed from the configuration. + if c.BaseURL == defaultBaseURL { + c.ServerType = SccProvider + } } // MergeJSON merges attributes of jsn that match Config fields @@ -131,3 +167,12 @@ func (c *Config) MergeJSON(jsn string) error { util.Debug.Printf("Merged options: %+v", c) return err } + +// Returns true if we detected that the configuration points to SCC. +// +// NOTE: this will be reliable if the configuration file already pointed to SCC, +// but it might need to be filled in upon HTTP requests to further guess if it's +// a Glue instance running on localhost or similar developer-only scenarios. +func (c *Config) IsScc() bool { + return c.ServerType == SccProvider +} diff --git a/internal/connect/config_test.go b/internal/connect/config_test.go index a01ddcf5..4a311fe5 100644 --- a/internal/connect/config_test.go +++ b/internal/connect/config_test.go @@ -57,12 +57,14 @@ func TestSaveLoad(t *testing.T) { c1 := NewConfig() c1.Path = path c1.AutoAgreeEULA = true + c1.ServerType = UnknownProvider if err := c1.Save(); err != nil { t.Fatalf("Unable to write config: %s", err) } c2 := NewConfig() c2.Path = path c2.Load() + c2.ServerType = UnknownProvider if !reflect.DeepEqual(c1, c2) { t.Errorf("got %+v, expected %+v", c2, c1) } diff --git a/internal/connect/connection.go b/internal/connect/connection.go index 615158a0..03ad0231 100644 --- a/internal/connect/connection.go +++ b/internal/connect/connection.go @@ -155,6 +155,18 @@ func callHTTP(verb, path string, body []byte, query map[string]string, auth auth } defer resp.Body.Close() + // If we failed to detect which server type was being used when loading the + // configuration, we can actually further inspect it via some of the headers + // that are returned by Glue vs RMT. Hence, if the server type is unknown, + // make an educated guess now. + if CFG.ServerType == UnknownProvider { + if api := resp.Header.Get("Scc-Api-Version"); api == sccAPIVersion { + CFG.ServerType = SccProvider + } else { + CFG.ServerType = RmtProvider + } + } + // For each request SCC might update the System token for a given system. // This will be given through the `System-Token` header, so we have to grab // this here and store it for the next request. diff --git a/internal/connect/labels.go b/internal/connect/labels.go new file mode 100644 index 00000000..da0db5dc --- /dev/null +++ b/internal/connect/labels.go @@ -0,0 +1,27 @@ +package connect + +import ( + "github.com/SUSE/connect-ng/internal/util" + "strings" +) + +var ( + localSetLabels = setLabels +) + +type Label struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +func AssignAndCreateLabels(labels []string) error { + collection := []Label{} + + for _, name := range labels { + name = strings.TrimSpace(name) + collection = append(collection, Label{Name: name}) + } + + util.Debug.Printf(util.Bold("Setting Labels %s"), strings.Join(labels, ",")) + return localSetLabels(collection) +} diff --git a/internal/connect/labels_test.go b/internal/connect/labels_test.go new file mode 100644 index 00000000..b1f58e6a --- /dev/null +++ b/internal/connect/labels_test.go @@ -0,0 +1,39 @@ +package connect + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func mockSetLabelsApiCall(t *testing.T, expectedLabels []Label) { + localSetLabels = func(labels []Label) error { + assert.ElementsMatch(t, expectedLabels, labels, "setLabels: provided labels do not match expectedLabels") + return nil + } +} + +func TestAssignAndCreateLabelsOk(t *testing.T) { + assert := assert.New(t) + expectedLabels := []Label{ + Label{Name: "label1"}, + Label{Name: "label2"}, + } + + mockSetLabelsApiCall(t, expectedLabels) + + err := AssignAndCreateLabels([]string{"label1", "label2"}) + assert.NoError(err) +} + +func TestAssignAndCreateLabelsError(t *testing.T) { + assert := assert.New(t) + + localSetLabels = func([]Label) error { + return fmt.Errorf("Cannot set more than 10 labels on system: test-system") + } + + err := AssignAndCreateLabels([]string{"label1", "label2"}) + assert.ErrorContains(err, "Cannot set more than") +} diff --git a/internal/zypper/zypper.go b/internal/zypper/zypper.go index c7cea3d5..e5ea6589 100644 --- a/internal/zypper/zypper.go +++ b/internal/zypper/zypper.go @@ -358,9 +358,13 @@ func parseSearchResultXML(xmlDoc []byte) ([]Package, error) { } // FindProductPackages returns list of product packages for given product -func FindProductPackages(identifier string) ([]Package, error) { +func FindProductPackages(identifier string, autoImportRepoKeys bool) ([]Package, error) { args := []string{"--xmlout", "--no-refresh", "--non-interactive", "search", "-s", "--match-exact", "-t", "product", identifier} + if autoImportRepoKeys { + args = append([]string{"--gpg-auto-import-keys"}, args...) + } + // Don't fail when zypper exits with 104 (no product found) or 6 (no repositories) output, err := zypperRun(args, []int{zypperOK, zypperErrNoRepos, zypperInfoCapNotFound}) if err != nil { diff --git a/testdata/set_labels.json b/testdata/set_labels.json new file mode 100644 index 00000000..07cb9dc2 --- /dev/null +++ b/testdata/set_labels.json @@ -0,0 +1,10 @@ +{ + "labels": [ + { + "name": "label1" + }, + { + "name": "label2" + } + ] +}