From 394e2da952fee0ef85817462f70ab21e624ae3ed Mon Sep 17 00:00:00 2001 From: Welder Luz Date: Tue, 11 Jun 2024 09:42:47 -0300 Subject: [PATCH 1/7] Configure AppStreams via Activation Keys --- .../common/hibernate/AnnotationRegistry.java | 4 +- .../rhn/common/security/acl/Access.java | 15 ++ .../domain/token/TokenChannelAppStream.java | 114 ++++++++++++++ .../appstreams/SystemAppStreamHandler.java | 2 +- .../manager/token/ActivationKeyManager.java | 123 +++++++++++++++ .../reactor/messaging/RegistrationUtils.java | 35 ++++- .../src/com/suse/manager/webui/Router.java | 4 +- .../ActivationKeysAppStreamsChanges.java | 50 ++++++ .../ActivationKeysController.java | 2 +- .../ActivationKeysViewsController.java | 147 ++++++++++++++++++ .../appstreams/AppStreamsController.java | 2 +- .../response/AppStreamModuleResponse.java | 9 +- .../response/ChannelAppStreamsResponse.java | 13 +- .../templates/activation_keys/appstreams.jade | 31 ++++ .../webapp/WEB-INF/nav/activation_key.xml | 2 + ...a.changes.welder.activationkeys-appstreams | 1 + .../tables/suseRegTokenChannelAppStream.sql | 32 ++++ .../100-suseRegTokenAppStreams-create.sql | 18 +++ .../salt/appstreams/configure.sls | 5 +- .../manager/appstreams/actions-appstreams.tsx | 20 +++ .../src/manager/appstreams/appstreams.tsx | 45 ++---- .../manager/appstreams/channel-appstreams.tsx | 31 ++-- .../manager/appstreams/list-appstreams.tsx | 72 +++------ .../manager/appstreams/panel-appstream.tsx | 41 +++++ web/html/src/manager/appstreams/utils.ts | 46 ++++++ web/html/src/manager/index.ts | 2 + .../activation-keys-appstreams.renderer.tsx | 20 +++ .../activation-keys-appstreams.tsx | 94 +++++++++++ .../manager/systems/activation-key/index.ts | 3 + ...b.changes.welder.activationkeys-appstreams | 1 + 30 files changed, 872 insertions(+), 112 deletions(-) create mode 100644 java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java create mode 100644 java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysAppStreamsChanges.java rename java/code/src/com/suse/manager/webui/controllers/{ => activationkeys}/ActivationKeysController.java (99%) create mode 100644 java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysViewsController.java create mode 100644 java/code/src/com/suse/manager/webui/templates/activation_keys/appstreams.jade create mode 100644 java/spacewalk-java.changes.welder.activationkeys-appstreams create mode 100644 schema/spacewalk/common/tables/suseRegTokenChannelAppStream.sql create mode 100644 schema/spacewalk/upgrade/susemanager-schema-5.0.7-to-susemanager-schema-5.0.8/100-suseRegTokenAppStreams-create.sql create mode 100644 web/html/src/manager/appstreams/actions-appstreams.tsx create mode 100644 web/html/src/manager/appstreams/panel-appstream.tsx create mode 100644 web/html/src/manager/appstreams/utils.ts create mode 100644 web/html/src/manager/systems/activation-key/activation-keys-appstreams.renderer.tsx create mode 100644 web/html/src/manager/systems/activation-key/activation-keys-appstreams.tsx create mode 100644 web/html/src/manager/systems/activation-key/index.ts create mode 100644 web/spacewalk-web.changes.welder.activationkeys-appstreams diff --git a/java/code/src/com/redhat/rhn/common/hibernate/AnnotationRegistry.java b/java/code/src/com/redhat/rhn/common/hibernate/AnnotationRegistry.java index 38a204d1b14b..cc52e192612f 100644 --- a/java/code/src/com/redhat/rhn/common/hibernate/AnnotationRegistry.java +++ b/java/code/src/com/redhat/rhn/common/hibernate/AnnotationRegistry.java @@ -91,6 +91,7 @@ import com.redhat.rhn.domain.server.ansible.PlaybookPath; import com.redhat.rhn.domain.server.virtualhostmanager.VirtualHostManagerNodeInfo; import com.redhat.rhn.domain.task.Task; +import com.redhat.rhn.domain.token.TokenChannelAppStream; import com.suse.cloud.domain.PaygDimensionComputation; import com.suse.cloud.domain.PaygDimensionResult; @@ -201,7 +202,8 @@ private AnnotationRegistry() { CoCoResultTypeConverter.class, ServerAppStream.class, AppStream.class, - AppStreamApi.class + AppStreamApi.class, + TokenChannelAppStream.class ); /** diff --git a/java/code/src/com/redhat/rhn/common/security/acl/Access.java b/java/code/src/com/redhat/rhn/common/security/acl/Access.java index ec5f51832613..43a7baa17172 100644 --- a/java/code/src/com/redhat/rhn/common/security/acl/Access.java +++ b/java/code/src/com/redhat/rhn/common/security/acl/Access.java @@ -31,6 +31,8 @@ import com.redhat.rhn.domain.role.RoleFactory; import com.redhat.rhn.domain.server.Server; import com.redhat.rhn.domain.server.ServerFactory; +import com.redhat.rhn.domain.token.ActivationKey; +import com.redhat.rhn.domain.token.ActivationKeyFactory; import com.redhat.rhn.domain.user.User; import com.redhat.rhn.domain.user.UserFactory; import com.redhat.rhn.frontend.dto.ChannelPerms; @@ -465,6 +467,19 @@ public boolean aclIsModularChannel(Map ctx, String[] params) { return chan.isModular(); } + /** + * Checks if a given activation key is linked to any modular channel. + * @param ctx acl context (includes the activation key id tid and the user) + * @param params parameters for acl (ignored) + * @return true if it is an activation key associated with any modular channel. + */ + public boolean aclHasModularChannel(Map ctx, String[] params) { + Long tid = getAsLong(ctx.get("tid")); + User user = (User) ctx.get("user"); + ActivationKey activationKey = ActivationKeyFactory.lookupById(tid, user.getOrg()); + return activationKey.getChannels().stream().anyMatch(Channel::isModular); + } + /** * Returns true if the user is channel admin of the corresponding channel. * If the channel is a vendor channel, the return value is false. diff --git a/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java b/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java new file mode 100644 index 000000000000..e6f34fdd790d --- /dev/null +++ b/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.redhat.rhn.domain.token; + +import com.redhat.rhn.domain.channel.Channel; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.SequenceGenerator; +import javax.persistence.Table; + +@Entity +@Table(name = "suseRegTokenChannelAppStream") +public class TokenChannelAppStream { + + /** + * Constructs a TokenChannelAppStream instance. + */ + public TokenChannelAppStream() { + // Default constructor + } + + /** + * Constructs a TokenChannelAppStream. + * + * @param tokenIn the token + * @param channelIn the channel + * @param nameIn the name of the appStream module + * @param streamIn the stream of the appStream module + */ + public TokenChannelAppStream(Token tokenIn, Channel channelIn, String nameIn, String streamIn) { + token = tokenIn; + channel = channelIn; + name = nameIn; + stream = streamIn; + } + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "suse_reg_tok_ch_as_id_seq") + @SequenceGenerator(name = "suse_reg_tok_ch_as_id_seq", sequenceName = "suse_reg_tok_ch_as_id_seq", + allocationSize = 1) + private Long id; + + @ManyToOne + @JoinColumn(name = "token_id") + private Token token; + + @ManyToOne + @JoinColumn(name = "channel_id") + private Channel channel; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String stream; + + public Long getId() { + return id; + } + + public void setId(Long idIn) { + id = idIn; + } + + public Token getToken() { + return token; + } + + public void setToken(Token tokenIn) { + token = tokenIn; + } + + public String getName() { + return name; + } + + public void setName(String nameIn) { + name = nameIn; + } + + public String getStream() { + return stream; + } + + public void setStream(String streamIn) { + stream = streamIn; + } + + public Channel getChannel() { + return channel; + } + + public void setChannel(Channel channelIn) { + channel = channelIn; + } +} diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/system/appstreams/SystemAppStreamHandler.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/system/appstreams/SystemAppStreamHandler.java index ee8c24c57b84..2d0adcafd80c 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/system/appstreams/SystemAppStreamHandler.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/system/appstreams/SystemAppStreamHandler.java @@ -187,7 +187,7 @@ public List listModuleStreams(User loggedInUser, Inte .map(channel -> new ChannelAppStreamsResponse( channel, AppStreamsManager.listChannelAppStreams(channel.getId()), - server + server::hasAppStreamModuleEnabled )) .collect(Collectors.toList()); } diff --git a/java/code/src/com/redhat/rhn/manager/token/ActivationKeyManager.java b/java/code/src/com/redhat/rhn/manager/token/ActivationKeyManager.java index 7d4731493146..f5f46735be80 100644 --- a/java/code/src/com/redhat/rhn/manager/token/ActivationKeyManager.java +++ b/java/code/src/com/redhat/rhn/manager/token/ActivationKeyManager.java @@ -37,6 +37,7 @@ import com.redhat.rhn.domain.server.ServerGroupType; import com.redhat.rhn.domain.token.ActivationKey; import com.redhat.rhn.domain.token.ActivationKeyFactory; +import com.redhat.rhn.domain.token.TokenChannelAppStream; import com.redhat.rhn.domain.user.User; import com.redhat.rhn.frontend.struts.Scrubber; import com.redhat.rhn.manager.channel.ChannelManager; @@ -60,6 +61,11 @@ import java.util.Map; import java.util.Set; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; + /** * ActivationKeyManager */ @@ -565,4 +571,121 @@ public void setupAutoConfigDeployment(ActivationKey key) { addConfigMgmtPackages(key); } } + + /** + * Checks if a specific app stream module is enabled for the given activation key and channel. + * + * @param activationKey the activation key containing the registration token for matching + * @param channel the channel to check + * @param module the name of the appStream module to check + * @param stream the stream of the appStream module to check + * @return {@code true} if the app stream module is included in the activation key and channel, + * {@code false} otherwise + */ + public boolean hasAppStreamModuleEnabled( + ActivationKey activationKey, + Channel channel, + String module, + String stream) { + return listTokenChannelAppStreams(activationKey, channel) + .stream() + .anyMatch(it -> + it.getChannel().getId().equals(channel.getId()) && + it.getName().equals(module) && + it.getStream().equals(stream) + ); + } + + /** + * Retrieves a list of {@code TokenChannelAppStream} objects that match the given activation key. + * + * @param activationKey the activation key containing the token to match + * @return a list of {@code TokenChannelAppStream} objects related to the activation key. + */ + public List listTokenChannelAppStreams(ActivationKey activationKey) { + return listTokenChannelAppStreams(activationKey, null); + } + + /** + * Retrieves a list of {@code TokenChannelAppStream} objects that match the given activation key and channel. + * If the channel is {@code null}, only the activation key is used as a filter criterion. + * + * @param activationKey the activation key containing the token to match + * @param channel the channel to match; if {@code null}, the channel criterion is ignored + * @return a list of {@code TokenChannelAppStream} objects that match the given criteria + */ + private List listTokenChannelAppStreams(ActivationKey activationKey, Channel channel) { + CriteriaBuilder criteriaBuilder = ActivationKeyFactory.getSession().getCriteriaBuilder(); + CriteriaQuery criteriaQuery = criteriaBuilder.createQuery(TokenChannelAppStream.class); + Root root = criteriaQuery.from(TokenChannelAppStream.class); + + Predicate tokenPredicate = criteriaBuilder.equal(root.get("token"), activationKey.getToken()); + if (channel != null) { + Predicate channelPredicate = criteriaBuilder.equal(root.get("channel"), channel); + criteriaQuery.where(criteriaBuilder.and(tokenPredicate, channelPredicate)); + } + else { + criteriaQuery.where(tokenPredicate); + } + + return ActivationKeyFactory.getSession().createQuery(criteriaQuery).getResultList(); + } + + /** + * Saves the specified channel app streams by including and removing the given lists of app streams. + * It will first remove the specified app streams from the channel, and then includes the specified + * app streams to the channel. The app streams are specified as a list of strings in the format "name:stream". + * + * @param activationKey the activation key containing the registration token + * @param channel the channel to which the app streams belong + * @param toInclude the list of app streams to include in the activation key + * @param toRemove the list of app streams to remove from the activation key + */ + public void saveChannelAppStreams( + ActivationKey activationKey, + Channel channel, + List toInclude, + List toRemove) { + removeChannelAppStreams(activationKey, channel, toRemove); + includeChannelAppStreams(activationKey, channel, toInclude); + } + + private void removeChannelAppStreams(ActivationKey activationKey, Channel channel, List toRemove) { + var session = ActivationKeyFactory.getSession(); + CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder(); + var criteriaDelete = criteriaBuilder.createCriteriaDelete(TokenChannelAppStream.class); + var root = criteriaDelete.from(TokenChannelAppStream.class); + + Predicate tokenPredicate = criteriaBuilder.equal(root.get("token"), activationKey.getToken()); + Predicate channelPredicate = criteriaBuilder.equal(root.get("channel"), channel); + + criteriaDelete.where( + criteriaBuilder.and( + tokenPredicate, + channelPredicate, + criteriaBuilder.or( + toRemove + .stream() + .map(appStream -> { + String[] parts = appStream.split(":"); + String name = parts[0]; + String stream = parts[1]; + Predicate namePredicate = criteriaBuilder.equal(root.get("name"), name); + Predicate streamPredicate = criteriaBuilder.equal(root.get("stream"), stream); + return criteriaBuilder.and(namePredicate, streamPredicate); + }).toArray(Predicate[]::new))) + ); + session.createQuery(criteriaDelete).executeUpdate(); + } + + private void includeChannelAppStreams(ActivationKey activationKey, Channel channel, List toInclude) { + toInclude.forEach(appStream -> { + String[] parts = appStream.split(":"); + String name = parts[0]; + String stream = parts[1]; + ActivationKeyFactory.getSession().persist( + new TokenChannelAppStream(activationKey.getToken(), channel, name, stream) + ); + }); + } } diff --git a/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java b/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java index 780181ba17e5..0416ed68ae3e 100644 --- a/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java +++ b/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java @@ -19,6 +19,7 @@ import static java.util.Collections.emptySet; import static java.util.Optional.ofNullable; import static java.util.stream.Collectors.partitioningBy; +import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toSet; import com.redhat.rhn.GlobalInstanceHolder; @@ -46,12 +47,14 @@ import com.redhat.rhn.manager.action.ActionManager; import com.redhat.rhn.manager.system.SystemManager; import com.redhat.rhn.manager.system.entitling.SystemEntitlementManager; +import com.redhat.rhn.manager.token.ActivationKeyManager; import com.redhat.rhn.taskomatic.TaskomaticApiException; import com.suse.manager.reactor.utils.RhelUtils; import com.suse.manager.reactor.utils.ValueMap; import com.suse.manager.webui.controllers.StatesAPI; import com.suse.manager.webui.controllers.channels.ChannelsUtils; +import com.suse.manager.webui.services.SaltServerActionService; import com.suse.manager.webui.services.iface.RedhatProductInfo; import com.suse.manager.webui.services.iface.SystemQuery; import com.suse.manager.webui.services.pillar.MinionPillarManager; @@ -67,6 +70,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -125,6 +129,7 @@ public static void finishRegistration(MinionServer minion, Optional !e.isBase())); // Apply initial states asynchronously + Map statesToApplyPillar = new HashMap<>(); List statesToApply = new ArrayList<>(); statesToApply.add(ApplyStatesEventMessage.CERTIFICATE); statesToApply.add(ApplyStatesEventMessage.CHANNELS); @@ -136,11 +141,15 @@ public static void finishRegistration(MinionServer minion, Optional activationKey, + List statesToApply, + Map statesToApplyPillar) { + if (activationKey.isPresent() && activationKey.get().getChannels().stream().anyMatch(Channel::isModular)) { + var appStreamsToEnable = ActivationKeyManager.getInstance().listTokenChannelAppStreams(activationKey.get()); + if (!appStreamsToEnable.isEmpty()) { + statesToApply.add(SaltServerActionService.APPSTREAMS_CONFIGURE); + var appStreamsParams = appStreamsToEnable + .stream() + .map(it -> List.of(it.getName(), it.getStream())) + .collect(toList()); + statesToApplyPillar.put(SaltServerActionService.PARAM_APPSTREAMS_ENABLE, appStreamsParams); + } + } + } + private static void triggerHardwareRefresh(MinionServer server) { try { ActionManager.scheduleHardwareRefreshAction(server.getOrg(), server, diff --git a/java/code/src/com/suse/manager/webui/Router.java b/java/code/src/com/suse/manager/webui/Router.java index 31d96c6c6fc3..19adc6f1a3ae 100644 --- a/java/code/src/com/suse/manager/webui/Router.java +++ b/java/code/src/com/suse/manager/webui/Router.java @@ -34,7 +34,6 @@ import com.suse.manager.attestation.AttestationManager; import com.suse.manager.kubernetes.KubernetesManager; import com.suse.manager.utils.SaltKeyUtils; -import com.suse.manager.webui.controllers.ActivationKeysController; import com.suse.manager.webui.controllers.AnsibleController; import com.suse.manager.webui.controllers.CSVDownloadController; import com.suse.manager.webui.controllers.CVEAuditController; @@ -64,6 +63,8 @@ import com.suse.manager.webui.controllers.SystemsController; import com.suse.manager.webui.controllers.TaskoTop; import com.suse.manager.webui.controllers.VirtualHostManagerController; +import com.suse.manager.webui.controllers.activationkeys.ActivationKeysController; +import com.suse.manager.webui.controllers.activationkeys.ActivationKeysViewsController; import com.suse.manager.webui.controllers.admin.AdminApiController; import com.suse.manager.webui.controllers.admin.AdminViewsController; import com.suse.manager.webui.controllers.appstreams.AppStreamsController; @@ -188,6 +189,7 @@ public void init() { // Activation Keys API ActivationKeysController.initRoutes(); + ActivationKeysViewsController.initRoutes(jade); SsmController.initRoutes(); diff --git a/java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysAppStreamsChanges.java b/java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysAppStreamsChanges.java new file mode 100644 index 000000000000..91a45bd409de --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysAppStreamsChanges.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.suse.manager.webui.controllers.activationkeys; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class ActivationKeysAppStreamsChanges { + private Map changes; + + /** + * Obtains the list of appStreams to remove + * @param channelId the id of the channel + * @return the list of appStreams to remove + */ + public List getToRemove(Long channelId) { + return changes.get(channelId).toRemove; + } + + /** + * Obtains the list of appStreams to include + * @param channelId the id of the channel + * @return the list of appStreams to include + */ + public List getToInclude(Long channelId) { + return changes.get(channelId).toInclude; + } + + public Set getChannelIds() { + return changes.keySet(); + } + + static class ChannelAppStreamsChanges { + private List toInclude; + private List toRemove; + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/ActivationKeysController.java b/java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysController.java similarity index 99% rename from java/code/src/com/suse/manager/webui/controllers/ActivationKeysController.java rename to java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysController.java index 70b4702599e5..fc7740f83ddf 100644 --- a/java/code/src/com/suse/manager/webui/controllers/ActivationKeysController.java +++ b/java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysController.java @@ -12,7 +12,7 @@ * granted to use or replicate Red Hat trademarks that are incorporated * in this software or its documentation. */ -package com.suse.manager.webui.controllers; +package com.suse.manager.webui.controllers.activationkeys; import static com.suse.manager.webui.controllers.channels.ChannelsUtils.generateChannelJson; import static com.suse.manager.webui.controllers.channels.ChannelsUtils.getPossibleBaseChannels; diff --git a/java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysViewsController.java b/java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysViewsController.java new file mode 100644 index 000000000000..c64c01257937 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/activationkeys/ActivationKeysViewsController.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.suse.manager.webui.controllers.activationkeys; + +import static com.suse.manager.webui.utils.SparkApplicationHelper.result; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withCsrfToken; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withDocsLocale; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withUser; +import static com.suse.manager.webui.utils.gson.ResultJson.success; +import static spark.Spark.get; +import static spark.Spark.post; + +import com.redhat.rhn.domain.channel.Channel; +import com.redhat.rhn.domain.channel.ChannelFactory; +import com.redhat.rhn.domain.role.RoleFactory; +import com.redhat.rhn.domain.token.ActivationKey; +import com.redhat.rhn.domain.token.ActivationKeyFactory; +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.manager.appstreams.AppStreamsManager; +import com.redhat.rhn.manager.token.ActivationKeyManager; + +import com.suse.manager.webui.controllers.appstreams.response.ChannelAppStreamsResponse; +import com.suse.manager.webui.utils.ViewHelper; +import com.suse.utils.Json; + +import com.google.gson.reflect.TypeToken; + +import org.apache.http.HttpStatus; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import spark.ModelAndView; +import spark.Request; +import spark.Response; +import spark.Spark; +import spark.template.jade.JadeTemplateEngine; + +public class ActivationKeysViewsController { + + private ActivationKeysViewsController() { } + + /** + * Invoked from Router. Init routes for Activation keys views. + * @param jade JadeTemplateEngine + */ + public static void initRoutes(JadeTemplateEngine jade) { + get("/manager/activationkeys/appstreams", + withCsrfToken(withDocsLocale(withUser(ActivationKeysViewsController::appstreams))), jade); + post("/manager/api/activationkeys/appstreams/save", + withUser(ActivationKeysViewsController::saveAppStreamsChanges)); + } + + private static ActivationKey getActivationKey(Request request, User user) { + ActivationKey activationKey; + try { + Long activationKeyId = Long.parseLong(request.raw().getParameter("tid")); + activationKey = ActivationKeyFactory.lookupById(activationKeyId, user.getOrg()); + } + catch (NumberFormatException e) { + throw Spark.halt(HttpStatus.SC_BAD_REQUEST, "Invalid activation key"); + } + if (activationKey == null) { + throw Spark.halt(HttpStatus.SC_NOT_FOUND); + } + return activationKey; + } + + /** + * Handler for the activation keys -> app streams page. + * + * @param request the request object + * @param response the response object + * @param user the current user + * @return the ModelAndView object to render the page + */ + public static ModelAndView appstreams(Request request, Response response, User user) { + Map data = new HashMap<>(); + var activationKey = getActivationKey(request, user); + var channelsAppStreams = getChannelAppStreams(activationKey); + + data.put("channelsAppStreams", Json.GSON.toJson(channelsAppStreams)); + data.put("note", activationKey.getNote()); + data.put("activationKeyId", activationKey.getId()); + data.put("isActivationKeyAdmin", user.hasRole(RoleFactory.ACTIVATION_KEY_ADMIN)); + data.put("tabs", ViewHelper.getInstance().renderNavigationMenu(request, "/WEB-INF/nav/activation_key.xml")); + return new ModelAndView(data, "templates/activation_keys/appstreams.jade"); + } + + private static List getChannelAppStreams(ActivationKey activationKey) { + return activationKey.getChannels() + .stream() + .filter(Channel::isModular) + .map(channel -> new ChannelAppStreamsResponse( + channel, + AppStreamsManager.listChannelAppStreams(channel.getId()), + (module, stream) -> ActivationKeyManager.getInstance().hasAppStreamModuleEnabled( + activationKey, channel, module, stream + ) + )).collect(Collectors.toList()); + } + + /** + * Saves the changes (enable/disable) to AppStreams linked to a given activation key. + * + * @param request the HTTP request + * @param response the HTTP response + * @param user the user performing the action + * @return a JSON response indicating success or failure + */ + public static String saveAppStreamsChanges(Request request, Response response, User user) { + var activationKey = getActivationKey(request, user); + var params = Json.GSON.fromJson(request.body(), ActivationKeysAppStreamsChanges.class); + params + .getChannelIds() + .stream() + .map(channelId -> ChannelFactory.lookupByIdAndUser(channelId, user)) + .filter(Objects::nonNull) + .forEach(channel -> + ActivationKeyManager + .getInstance() + .saveChannelAppStreams( + activationKey, + channel, + params.getToInclude(channel.getId()), + params.getToRemove(channel.getId()) + ) + ); + + return result(response, success(getChannelAppStreams(activationKey)), new TypeToken<>() { }); + } +} diff --git a/java/code/src/com/suse/manager/webui/controllers/appstreams/AppStreamsController.java b/java/code/src/com/suse/manager/webui/controllers/appstreams/AppStreamsController.java index 517a3f171eee..08eedc39d907 100644 --- a/java/code/src/com/suse/manager/webui/controllers/appstreams/AppStreamsController.java +++ b/java/code/src/com/suse/manager/webui/controllers/appstreams/AppStreamsController.java @@ -146,7 +146,7 @@ public static ModelAndView appstreams(Request request, Response response, User u .map(channel -> new ChannelAppStreamsResponse( channel, AppStreamsManager.listChannelAppStreams(channel.getId()), - server + server::hasAppStreamModuleEnabled )) .collect(Collectors.toList()); data.put("channelsAppStreams", GSON.toJson(channelsAppStreams)); diff --git a/java/code/src/com/suse/manager/webui/controllers/appstreams/response/AppStreamModuleResponse.java b/java/code/src/com/suse/manager/webui/controllers/appstreams/response/AppStreamModuleResponse.java index c6898a22f24f..b92a59372cc3 100644 --- a/java/code/src/com/suse/manager/webui/controllers/appstreams/response/AppStreamModuleResponse.java +++ b/java/code/src/com/suse/manager/webui/controllers/appstreams/response/AppStreamModuleResponse.java @@ -15,7 +15,6 @@ package com.suse.manager.webui.controllers.appstreams.response; import com.redhat.rhn.domain.channel.AppStream; -import com.redhat.rhn.domain.server.Server; import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; @@ -23,16 +22,16 @@ public class AppStreamModuleResponse { /** - * Constructs an AppStreamModuleResponse object based on the provided AppStream and Server objects. + * Constructs an AppStreamModuleResponse object based on the provided AppStream. * * @param appStreamIn The AppStream object to extract information from. - * @param serverIn The Server object to check for module enablement. + * @param enabledIn Whether the AppStream is enabled. */ - public AppStreamModuleResponse(AppStream appStreamIn, Server serverIn) { + public AppStreamModuleResponse(AppStream appStreamIn, boolean enabledIn) { this.name = appStreamIn.getName(); this.stream = appStreamIn.getStream(); this.arch = appStreamIn.getArch(); - this.enabled = serverIn.hasAppStreamModuleEnabled(this.name, this.stream); + this.enabled = enabledIn; } private final String name; diff --git a/java/code/src/com/suse/manager/webui/controllers/appstreams/response/ChannelAppStreamsResponse.java b/java/code/src/com/suse/manager/webui/controllers/appstreams/response/ChannelAppStreamsResponse.java index f4c2b28392f2..5aed51e675b7 100644 --- a/java/code/src/com/suse/manager/webui/controllers/appstreams/response/ChannelAppStreamsResponse.java +++ b/java/code/src/com/suse/manager/webui/controllers/appstreams/response/ChannelAppStreamsResponse.java @@ -16,32 +16,33 @@ import com.redhat.rhn.domain.channel.AppStream; import com.redhat.rhn.domain.channel.Channel; -import com.redhat.rhn.domain.server.Server; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.BiPredicate; public class ChannelAppStreamsResponse { /** * Constructs a ChannelAppStreamsResponse object based on the provided parameters. * - * @param channelIn The channel that the AppStreams belong to. - * @param appStreamsIn The set of AppStream objects associated with the channel. - * @param serverIn The Server object used to check module enablement. + * @param channelIn The channel that the AppStreams belong to. + * @param appStreamsIn The set of AppStream objects associated with the channel. + * @param appStreamEnabledChecker The object used to check module enablement. */ public ChannelAppStreamsResponse( Channel channelIn, List appStreamsIn, - Server serverIn + BiPredicate appStreamEnabledChecker ) { channel = new ChannelJson(channelIn); appStreams = new HashMap<>(); appStreamsIn.forEach(it -> { - var module = new AppStreamModuleResponse(it, serverIn); + var enabled = appStreamEnabledChecker.test(it.getName(), it.getStream()); + var module = new AppStreamModuleResponse(it, enabled); if (appStreams.containsKey(it.getName())) { appStreams.get(it.getName()).add(module); } diff --git a/java/code/src/com/suse/manager/webui/templates/activation_keys/appstreams.jade b/java/code/src/com/suse/manager/webui/templates/activation_keys/appstreams.jade new file mode 100644 index 000000000000..8b9904e2ff19 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/templates/activation_keys/appstreams.jade @@ -0,0 +1,31 @@ +.spacewalk-toolbar-h1 + .spacewalk-toolbar + if isActivationKeyAdmin + a(href="/rhn/activationkeys/Clone.do?tid=#{activationKeyId}") + i.fa.fa-files-o(title='Clone Key') + | #{l.t("Clone Key")} + a(href="/rhn/activationkeys/Delete.do?tid=#{activationKeyId}") + i.fa.fa-trash-o(title='Delete Key') + | #{l.t("Delete Key")} + h1 + i.fa.fa-key + | #{' ' + note + ' '} + a(href="/docs/#{docsLocale}/reference/systems/activation-keys.html", target="_blank") + i.fa.fa-question-circle.spacewalk-help-link +!{tabs} +#appstreams + +div#init_channels_appstreams(style="display: none") + | #{channelsAppStreams} + +script(type='text/javascript'). + window.activationKeyId = "#{activationKeyId}"; + window.csrfToken = "#{csrf_token}"; + +script(type='text/javascript'). + spaImportReactPage('activationkeys/appstreams') + .then(function(module) { + module.renderer('appstreams', { + channelsAppStreams: document.getElementById('init_channels_appstreams').textContent, + }) + }); diff --git a/java/code/webapp/WEB-INF/nav/activation_key.xml b/java/code/webapp/WEB-INF/nav/activation_key.xml index 37bdf27fc261..0f2134a5eec3 100644 --- a/java/code/webapp/WEB-INF/nav/activation_key.xml +++ b/java/code/webapp/WEB-INF/nav/activation_key.xml @@ -29,4 +29,6 @@ + diff --git a/java/spacewalk-java.changes.welder.activationkeys-appstreams b/java/spacewalk-java.changes.welder.activationkeys-appstreams new file mode 100644 index 000000000000..57f7a24eb0ba --- /dev/null +++ b/java/spacewalk-java.changes.welder.activationkeys-appstreams @@ -0,0 +1 @@ +- Configure AppStreams via Activation Keys diff --git a/schema/spacewalk/common/tables/suseRegTokenChannelAppStream.sql b/schema/spacewalk/common/tables/suseRegTokenChannelAppStream.sql new file mode 100644 index 000000000000..9722bac6c573 --- /dev/null +++ b/schema/spacewalk/common/tables/suseRegTokenChannelAppStream.sql @@ -0,0 +1,32 @@ +-- +-- Copyright (c) 2024 SUSE LLC +-- +-- This software is licensed to you under the GNU General Public License, +-- version 2 (GPLv2). There is NO WARRANTY for this software, express or +-- implied, including the implied warranties of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 +-- along with this software; if not, see +-- http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. +-- +-- Red Hat trademarks are not licensed under GPLv2. No permission is +-- granted to use or replicate Red Hat trademarks that are incorporated +-- in this software or its documentation. + +CREATE TABLE suseRegTokenChannelAppStream +( + id NUMERIC NOT NULL + CONSTRAINT suse_reg_tok_ch_as_id_pk PRIMARY KEY, + token_id NUMERIC NOT NULL, + channel_id NUMERIC NOT NULL, + name VARCHAR(128) NOT NULL, + stream VARCHAR(128) NOT NULL, + + CONSTRAINT suse_reg_tok_ch_as_id_fk FOREIGN KEY (channel_id, token_id) + REFERENCES rhnRegTokenChannels (channel_id, token_id) + ON DELETE CASCADE +); + +CREATE INDEX suse_reg_tok_ch_as_name_stream_idx + ON suseRegTokenChannelAppStream (name, stream); + +CREATE SEQUENCE suse_reg_tok_ch_as_id_seq; diff --git a/schema/spacewalk/upgrade/susemanager-schema-5.0.7-to-susemanager-schema-5.0.8/100-suseRegTokenAppStreams-create.sql b/schema/spacewalk/upgrade/susemanager-schema-5.0.7-to-susemanager-schema-5.0.8/100-suseRegTokenAppStreams-create.sql new file mode 100644 index 000000000000..5c84e67f04ef --- /dev/null +++ b/schema/spacewalk/upgrade/susemanager-schema-5.0.7-to-susemanager-schema-5.0.8/100-suseRegTokenAppStreams-create.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS suseRegTokenChannelAppStream +( + id NUMERIC NOT NULL + CONSTRAINT suse_reg_tok_ch_as_id_pk PRIMARY KEY, + token_id NUMERIC NOT NULL, + channel_id NUMERIC NOT NULL, + name VARCHAR(128) NOT NULL, + stream VARCHAR(128) NOT NULL, + + CONSTRAINT suse_reg_tok_ch_as_id_fk FOREIGN KEY (channel_id, token_id) + REFERENCES rhnRegTokenChannels (channel_id, token_id) + ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS suse_reg_tok_ch_as_name_stream_idx + ON suseRegTokenChannelAppStream (name, stream); + +CREATE SEQUENCE IF NOT EXISTS suse_reg_tok_ch_as_id_seq; diff --git a/susemanager-utils/susemanager-sls/salt/appstreams/configure.sls b/susemanager-utils/susemanager-sls/salt/appstreams/configure.sls index b5034d7e59f1..5f085bdb66a8 100644 --- a/susemanager-utils/susemanager-sls/salt/appstreams/configure.sls +++ b/susemanager-utils/susemanager-sls/salt/appstreams/configure.sls @@ -1,3 +1,5 @@ +include: +- channels {% if pillar.get('param_appstreams_disable') %} disable_appstreams: appstreams.disabled: @@ -14,8 +16,9 @@ enable_appstreams: {%- for module_name, stream in pillar.get('param_appstreams_enable', []) %} - {{ module_name }}:{{ stream }} {%- endfor %} - {% if pillar.get('param_appstreams_disable') %} - require: + - file: /etc/yum.repos.d/susemanager:channels.repo + {% if pillar.get('param_appstreams_disable') %} - appstreams: disable_appstreams {%- endif %} {%- endif %} diff --git a/web/html/src/manager/appstreams/actions-appstreams.tsx b/web/html/src/manager/appstreams/actions-appstreams.tsx new file mode 100644 index 000000000000..3d8e075b2f1c --- /dev/null +++ b/web/html/src/manager/appstreams/actions-appstreams.tsx @@ -0,0 +1,20 @@ +import { Button } from "components/buttons"; + +export const AppStreamActions = ({ numberOfChanges, onReset, onSubmit }) => { + return ( +
+
+ {numberOfChanges > 0 && ( +
+
+ ); +}; diff --git a/web/html/src/manager/appstreams/appstreams.tsx b/web/html/src/manager/appstreams/appstreams.tsx index 3e395c79df98..8fcdc27908a3 100644 --- a/web/html/src/manager/appstreams/appstreams.tsx +++ b/web/html/src/manager/appstreams/appstreams.tsx @@ -3,45 +3,24 @@ import { useState } from "react"; import { ActionChain } from "components/action-schedule"; import { Messages, MessageType, Utils as MessageUtils } from "components/messages"; -import { AppStreamModule, ChannelAppStream } from "./appstreams.type"; +import { ChannelAppStream } from "./appstreams.type"; import { AppStreamsChangesConfirm } from "./changes-confirm-appstreams"; import { AppStreamsList } from "./list-appstreams"; +import { getStreamName, handleModuleEnableDisable } from "./utils"; type Props = { channelsAppStreams: Array; }; -export const getStreamName = (module: AppStreamModule) => `${module.name}:${module.stream}`; - const AppStreams = ({ channelsAppStreams }: Props) => { const [appStreams, setAppStreams] = useState(channelsAppStreams); - const [toEnable, setToEnable] = useState>([]); - const [toDisable, setToDisable] = useState>([]); + const [toEnable, setToEnable] = useState>>(new Map()); + const [toDisable, setToDisable] = useState>>(new Map()); const [showConfirm, setShowConfirm] = useState(false); const [scheduledMsg, setScheduledMsg] = useState([]); - const isStreamEnabled = (stream: AppStreamModule) => - toEnable.includes(getStreamName(stream)) || (stream.enabled && !toDisable.includes(getStreamName(stream))); - - const handleEnableDisable = (appStream: AppStreamModule) => { - const stream = `${appStream.name}:${appStream.stream}`; - if (appStream.enabled) { - setToDisable((prevState) => - prevState.includes(stream) ? prevState.filter((it) => it !== stream) : prevState.concat(stream) - ); - } else { - setToEnable((prevState) => - prevState.includes(stream) ? prevState.filter((it) => it !== stream) : prevState.concat(stream) - ); - } - - // Disable every other stream of the module - appStreams.forEach((ch) => - ch.appStreams[appStream.name] - ?.filter((as) => getStreamName(as) !== stream && isStreamEnabled(as)) - .forEach((as) => handleEnableDisable(as)) - ); - }; + const handleEnableDisable = (channel, appStream) => + handleModuleEnableDisable(channel, appStream, appStreams, toEnable, toDisable, setToEnable, setToDisable); /** * After scheduling the action, apply the changes optimistically to the currently displayed list @@ -55,7 +34,9 @@ const AppStreams = ({ channelsAppStreams }: Props) => { ...Object.keys(ch.appStreams).reduce((acc, key) => { acc[key] = ch.appStreams[key].map((as) => ({ ...as, - enabled: toEnable.includes(getStreamName(as)) || (!toDisable.includes(getStreamName(as)) && as.enabled), + enabled: + toEnable.get(ch.channel.id)?.includes(getStreamName(as)) || + (!toDisable.get(ch.channel.id)?.includes(getStreamName(as)) && as.enabled), })); return acc; }, {}), @@ -98,16 +79,16 @@ const AppStreams = ({ channelsAppStreams }: Props) => { }; const handleReset = () => { - setToEnable([]); - setToDisable([]); + setToEnable(new Map()); + setToDisable(new Map()); }; const showContent = () => { if (showConfirm) { return ( setShowConfirm(false)} onConfirm={handleConfirmChanges} onError={handleConfirmError} diff --git a/web/html/src/manager/appstreams/channel-appstreams.tsx b/web/html/src/manager/appstreams/channel-appstreams.tsx index 15b306507679..3810b3cffee8 100644 --- a/web/html/src/manager/appstreams/channel-appstreams.tsx +++ b/web/html/src/manager/appstreams/channel-appstreams.tsx @@ -1,16 +1,25 @@ -import { getStreamName } from "./appstreams"; -import { AppStreamModule } from "./appstreams.type"; +import { AppStreamModule, Channel } from "./appstreams.type"; +import { getStreamName } from "./utils"; interface Props { + channel: Channel; streams: AppStreamModule[]; moduleName: string; - toEnable: string[]; - toDisable: string[]; - showPackages: (string) => void; + toEnable: Map; + toDisable: Map; + showPackages?: (string) => void; onToggle: (AppStreamModule) => void; } -export const ChannelAppStreams = ({ streams, moduleName, showPackages, toEnable, toDisable, onToggle }: Props) => { +export const ChannelAppStreams = ({ + channel, + streams, + moduleName, + showPackages, + toEnable, + toDisable, + onToggle, +}: Props) => { // Sort stream names alphanumerically in descending order streams.sort((m1, m2) => getStreamName(m2).localeCompare(getStreamName(m1), "en", { numeric: true })); return ( @@ -19,17 +28,21 @@ export const ChannelAppStreams = ({ streams, moduleName, showPackages, toEnable, const stream = getStreamName(moduleStream); const changedModules = streams.map((s) => { - const isChanged = s.enabled ? toDisable.includes(getStreamName(s)) : toEnable.includes(getStreamName(s)); + const isChanged = s.enabled + ? toDisable.get(channel.id)?.includes(getStreamName(s)) + : toEnable.get(channel.id)?.includes(getStreamName(s)); return isChanged ? s.name : null; }); const changedStatus = changedModules.includes(moduleName); - const enabled = toEnable.includes(stream) || (moduleStream.enabled && !toDisable.includes(stream)); + const enabled = + toEnable.get(channel.id)?.includes(stream) || + (moduleStream.enabled && !toDisable.get(channel.id)?.includes(stream)); return ( {streamIdx === 0 && {moduleName}} - diff --git a/web/html/src/manager/appstreams/list-appstreams.tsx b/web/html/src/manager/appstreams/list-appstreams.tsx index 98fdd301b911..154514fb818e 100644 --- a/web/html/src/manager/appstreams/list-appstreams.tsx +++ b/web/html/src/manager/appstreams/list-appstreams.tsx @@ -1,20 +1,19 @@ import { useState } from "react"; -import { Button } from "components/buttons"; -import { Panel } from "components/panels/Panel"; - import { Dialog } from "../../components/dialog/Dialog"; +import { AppStreamActions } from "./actions-appstreams"; import { AppStreamPackages } from "./appstream-packages"; -import { AppStreamModule, ChannelAppStream } from "./appstreams.type"; -import { ChannelAppStreams } from "./channel-appstreams"; +import { AppStreamModule, Channel, ChannelAppStream } from "./appstreams.type"; +import { AppStreamPanel } from "./panel-appstream"; +import { numberOfChanges } from "./utils"; interface Props { channelsAppStreams: ChannelAppStream[]; - toEnable: string[]; - toDisable: string[]; + toEnable: Map; + toDisable: Map; onReset: () => void; onSubmitChanges: () => void; - onModuleEnableDisable: (module: AppStreamModule) => void; + onModuleEnableDisable: (channel: Channel, module: AppStreamModule) => void; } export const AppStreamsList = ({ @@ -26,8 +25,7 @@ export const AppStreamsList = ({ onModuleEnableDisable, }: Props) => { const [moduleToShowPackages, setModuleToShowPackages] = useState<{ stream: string; channelId: number } | null>(null); - const numberOfChanges = toEnable.length + toDisable.length; - const hasChanges = () => !(toEnable.length === 0 && toDisable.length === 0); + const changes = numberOfChanges(toEnable, toDisable); // Sort channels by label channelsAppStreams.sort((a, b) => a.channel.name.localeCompare(b.channel.name)); @@ -35,52 +33,20 @@ export const AppStreamsList = ({ return ( <>

{t("The following AppStream modules are currently available to the system.")}

-
-
- {hasChanges() && ( -
-
+ {channelsAppStreams.map((channelAppStream) => { const { channel, appStreams } = channelAppStream; + const showPackages = (stream) => setModuleToShowPackages({ stream: stream, channelId: channel.id }); return ( - - - - - - - - - - - {Object.keys(appStreams).map((moduleName) => ( - setModuleToShowPackages({ stream: stream, channelId: channel.id })} - key={moduleName} - streams={appStreams[moduleName]} - moduleName={moduleName} - toEnable={toEnable} - toDisable={toDisable} - onToggle={onModuleEnableDisable} - /> - ))} - -
{t("Modules")}{t("Streams")}{t("Enabled")}
-
+ onModuleEnableDisable(channel, appStream)} + showPackages={showPackages} + /> ); })} ; + toEnable: Map; + toDisable: Map; + onToggle: (arg: AppStreamModule) => void; + showPackages?: (arg: string) => void; +} + +export const AppStreamPanel = ({ channel, appStreams, toEnable, toDisable, onToggle, showPackages }: Props) => ( + + + + + + + + + + + {Object.keys(appStreams).map((moduleName) => ( + + ))} + +
{t("Modules")}{t("Streams")}{t("Enabled")}
+
+); diff --git a/web/html/src/manager/appstreams/utils.ts b/web/html/src/manager/appstreams/utils.ts new file mode 100644 index 000000000000..8aef609ea4db --- /dev/null +++ b/web/html/src/manager/appstreams/utils.ts @@ -0,0 +1,46 @@ +import { AppStreamModule, Channel, ChannelAppStream } from "./appstreams.type"; + +export const getStreamName = (module: AppStreamModule) => `${module.name}:${module.stream}`; + +export const handleModuleEnableDisable = ( + channel: Channel, + appStream: AppStreamModule, + appStreams: ChannelAppStream[], + toEnable: Map, + toDisable: Map, + setToEnable: React.Dispatch>>, + setToDisable: React.Dispatch>> +) => { + const handleReactStateChange = (prevState: Map, stream: string) => { + const updatedMap = new Map(prevState); + let newList = updatedMap.get(channel.id) ?? []; + newList = newList.includes(stream) ? newList.filter((it) => it !== stream) : newList.concat(stream); + updatedMap.set(channel.id, newList); + return updatedMap; + }; + + const isStreamEnabled = (stream: AppStreamModule) => + toEnable.get(channel.id)?.includes(getStreamName(stream)) || + (stream.enabled && !toDisable.get(channel.id)?.includes(getStreamName(stream))); + + const stream = getStreamName(appStream); + const setStateFunction = appStream.enabled ? setToDisable : setToEnable; + setStateFunction((prevState) => handleReactStateChange(prevState, stream)); + + // Disable every other stream of the module + appStreams.forEach((ch) => + ch.appStreams[appStream.name] + ?.filter((as) => getStreamName(as) !== stream && isStreamEnabled(as)) + .forEach((as) => + handleModuleEnableDisable(ch.channel, as, appStreams, toEnable, toDisable, setToEnable, setToDisable) + ) + ); +}; + +export const numberOfChanges = (toEnable: Map, toDisable: Map) => { + return reduceMapListLength(toEnable) + reduceMapListLength(toDisable); +}; + +const reduceMapListLength = (mapToReduce: Map) => { + return Array.from(mapToReduce.keys()).reduce((acc, key) => acc + (mapToReduce.get(key)?.length ?? 0), 0); +}; diff --git a/web/html/src/manager/index.ts b/web/html/src/manager/index.ts index 336726312981..ecfbce64573a 100644 --- a/web/html/src/manager/index.ts +++ b/web/html/src/manager/index.ts @@ -33,6 +33,7 @@ import ScheduleOptions from "./schedule-options"; import Shared from "./shared"; import Highstate from "./state"; import Systems from "./systems"; +import ActivationKeys from "./systems/activation-key"; import Virtualization from "./virtualization"; const pages = { @@ -58,6 +59,7 @@ const pages = { ...Systems, ...Virtualization, ...Appstreams, + ...ActivationKeys, }; window.spaImportReactPage = function spaImportReactPage(pageName) { diff --git a/web/html/src/manager/systems/activation-key/activation-keys-appstreams.renderer.tsx b/web/html/src/manager/systems/activation-key/activation-keys-appstreams.renderer.tsx new file mode 100644 index 000000000000..8f5bd83354da --- /dev/null +++ b/web/html/src/manager/systems/activation-key/activation-keys-appstreams.renderer.tsx @@ -0,0 +1,20 @@ +import SpaRenderer from "core/spa/spa-renderer"; + +import AppStreams from "./activation-keys-appstreams"; + +type RendererProps = { + channelsAppStreams?: string; +}; + +export const renderer = (id: string, { channelsAppStreams }: RendererProps) => { + let channelsAppStreamsJson: any[] = []; + try { + channelsAppStreamsJson = JSON.parse(channelsAppStreams || ""); + } catch (error) { + Loggerhead.error(error); + } + SpaRenderer.renderNavigationReact( + , + document.getElementById(id) + ); +}; diff --git a/web/html/src/manager/systems/activation-key/activation-keys-appstreams.tsx b/web/html/src/manager/systems/activation-key/activation-keys-appstreams.tsx new file mode 100644 index 000000000000..baab2a8c7dfa --- /dev/null +++ b/web/html/src/manager/systems/activation-key/activation-keys-appstreams.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; + +import { AppStreamActions } from "manager/appstreams/actions-appstreams"; +import { AppStreamPanel } from "manager/appstreams/panel-appstream"; +import { handleModuleEnableDisable, numberOfChanges } from "manager/appstreams/utils"; + +import { Messages, MessageType, Utils as MessageUtils } from "components/messages"; + +import Network from "utils/network"; + +import { ChannelAppStream } from "../../appstreams/appstreams.type"; + +declare global { + interface Window { + activationKeyId: number; + } +} + +type Props = { + channelsAppStreams: Array; +}; + +const AppStreams = (props: Props) => { + const [channelsAppStreams, setChannelAppStreams] = useState(props.channelsAppStreams); + const [toEnable, setToEnable] = useState>>(new Map()); + const [toDisable, setToDisable] = useState>>(new Map()); + const [statusMessage, setStatusMessage] = useState([]); + const handleEnableDisable = (channel, appStream) => + handleModuleEnableDisable(channel, appStream, channelsAppStreams, toEnable, toDisable, setToEnable, setToDisable); + + const handleReset = () => { + setToEnable(new Map()); + setToDisable(new Map()); + }; + + const handleError = (error) => { + setStatusMessage(MessageUtils.error(t("Error updating activation key."))); + Loggerhead.error(error); + }; + + const handleConfirm = (data) => { + setStatusMessage(MessageUtils.info(t("Activation key has been modified."))); + handleReset(); + setChannelAppStreams(data); + }; + + const handleSubmit = () => { + const channelsKeys = new Set([...toEnable.keys(), ...toDisable.keys()]); + const changes = new Map(); + channelsKeys.forEach((channelKey) => { + changes.set(channelKey, { + toInclude: toEnable.get(channelKey) ?? [], + toRemove: toDisable.get(channelKey) ?? [], + }); + }); + const request = Network.post(`/rhn/manager/api/activationkeys/appstreams/save?tid=${window.activationKeyId}`, { + changes, + }) + .then((data) => { + handleConfirm(data.data); + }) + .catch((jqXHR) => handleError(Network.responseErrorMessage(jqXHR))); + + return request; + }; + + const changes = numberOfChanges(toEnable, toDisable); + + return ( + <> + {statusMessage.length > 0 && } +

+ + {t("AppStreams")} +

+ + {channelsAppStreams.map((channelAppStreams) => { + const { channel, appStreams } = channelAppStreams; + return ( + handleEnableDisable(channel, appStream)} + /> + ); + })} + + ); +}; + +export default AppStreams; diff --git a/web/html/src/manager/systems/activation-key/index.ts b/web/html/src/manager/systems/activation-key/index.ts new file mode 100644 index 000000000000..6701e15bf5e0 --- /dev/null +++ b/web/html/src/manager/systems/activation-key/index.ts @@ -0,0 +1,3 @@ +export default { + "activationkeys/appstreams": () => import("./activation-keys-appstreams.renderer"), +}; diff --git a/web/spacewalk-web.changes.welder.activationkeys-appstreams b/web/spacewalk-web.changes.welder.activationkeys-appstreams new file mode 100644 index 000000000000..57f7a24eb0ba --- /dev/null +++ b/web/spacewalk-web.changes.welder.activationkeys-appstreams @@ -0,0 +1 @@ +- Configure AppStreams via Activation Keys From 1b4442f5f85aab4ec048d1e2adb54209457f8154 Mon Sep 17 00:00:00 2001 From: Welder Luz Date: Thu, 4 Jul 2024 12:44:55 -0300 Subject: [PATCH 2/7] Add unit tests for appstreams util.ts --- .../src/manager/appstreams/appstreams.type.ts | 6 +- web/html/src/manager/appstreams/utils.test.ts | 181 ++++++++++++++++++ web/html/src/manager/appstreams/utils.ts | 84 ++++++-- 3 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 web/html/src/manager/appstreams/utils.test.ts diff --git a/web/html/src/manager/appstreams/appstreams.type.ts b/web/html/src/manager/appstreams/appstreams.type.ts index 6f8eb008a819..80e5caaad019 100644 --- a/web/html/src/manager/appstreams/appstreams.type.ts +++ b/web/html/src/manager/appstreams/appstreams.type.ts @@ -4,9 +4,13 @@ export type Channel = { name: string; }; +interface AppStreams { + [key: string]: Array; +} + export type ChannelAppStream = { channel: Channel; - appStreams: Map>; + appStreams: AppStreams; }; export type AppStreamModule = { diff --git a/web/html/src/manager/appstreams/utils.test.ts b/web/html/src/manager/appstreams/utils.test.ts new file mode 100644 index 000000000000..7b179b8b46e8 --- /dev/null +++ b/web/html/src/manager/appstreams/utils.test.ts @@ -0,0 +1,181 @@ +import { AppStreamModule, Channel, ChannelAppStream } from "./appstreams.type"; +import { getStreamName, handleModuleEnableDisable, numberOfChanges } from "./utils"; + +const nodejs = (stream: string, enabled: boolean = true): AppStreamModule => ({ + name: "nodejs", + stream, + version: "1234-test-version", + context: "context-test", + arch: "x86_64", + enabled, +}); + +const redis = (stream: string, enabled: boolean = true): AppStreamModule => ({ + name: "redis", + stream, + version: "4321-test-version", + context: "context", + arch: "x86_64", + enabled, +}); + +const CHANNEL: Channel = { id: 1, label: "stream-channel-1", name: "Stream Channel 1" }; + +describe("Testing appstreams module functions", () => { + test("getStreamName should return correct stream name", () => { + const module = nodejs("18"); + expect(getStreamName(module)).toBe("nodejs:18"); + }); + + test("numberOfChanges should return correct number of changes", () => { + const toEnable = new Map([ + [1, ["nodejs:18", "postgresql:15"]], + [2, ["redis:5"]], + ]); + const toDisable = new Map([ + [1, ["nodejs:20"]], + [2, ["redis:4", "mod:test"]], + ]); + expect(numberOfChanges(toEnable, toDisable)).toBe(6); + }); + + test("handleModuleEnableDisable should handle appStream disablement correctly", () => { + /** + * Given there is a channel with 2 appStreams combined in 1 module: + * - module redis - appStreams redis:4 and redis:5 + * And redis:5 is enabled in the channel + */ + const redis4 = redis("4", false); + const redis5 = redis("5", true); + const appStreamsMap = { + redis: [redis4, redis5], + }; + const appStreams: ChannelAppStream[] = [ + { + channel: CHANNEL, + appStreams: appStreamsMap, + }, + ]; + + // And there are no changes in the current state. + const toEnable = new Map(); + const toDisable = new Map(); + const setToEnable = jest.fn(); + const setToDisable = jest.fn(); + + // When redis:5 is passed to the handleEnableDisable function + handleModuleEnableDisable(CHANNEL, redis5, appStreams, toEnable, toDisable, setToEnable, setToDisable); + + // Then redis:5 should be included in the toDisable Map of the channel + expect(setToEnable).not.toHaveBeenCalled(); + expect(setToDisable).toHaveBeenCalledTimes(1); + const toDisableUpdateFn = setToDisable.mock.calls[0][0]; + const toDisableNewState = toDisableUpdateFn(new Map()); + expect(toDisableNewState.get(CHANNEL.id)).toContain(getStreamName(redis5)); + }); + + test("handleModuleEnableDisable should handle appStream enablement correctly", () => { + /** + * Given there is a channel with 4 appStreams combined in 2 modules: + * - module nodejs - appStreams nodejs:18 and nodejs:20 + * - module redis - appStreams redis:4 and redis:5 + * And all appStreams are disabled in the channel + */ + const nodejs18 = nodejs("18", false); + const nodejs20 = nodejs("20", false); + const redis4 = redis("4", false); + const redis5 = redis("5", false); + const appStreamsMap = { + nodejs: [nodejs18, nodejs20], + redis: [redis4, redis5], + }; + const appStreams: ChannelAppStream[] = [ + { + channel: CHANNEL, + appStreams: appStreamsMap, + }, + ]; + + // And there are no changes in the current state. + const toEnable = new Map(); + const toDisable = new Map(); + const setToEnable = jest.fn(); + const setToDisable = jest.fn(); + + //When nodejs:20 is passed to the handleEnableDisable function + handleModuleEnableDisable(CHANNEL, nodejs20, appStreams, toEnable, toDisable, setToEnable, setToDisable); + + // Then nodejs:20 appStream should be included in the toEnable Map of the channel. + expect(setToEnable).toHaveBeenCalledTimes(1); + expect(setToDisable).not.toHaveBeenCalled(); + const updateFunction = setToEnable.mock.calls[0][0]; + const prevState = new Map(); + const newState = updateFunction(prevState); + expect(newState.get(CHANNEL.id)).toContain(getStreamName(nodejs20)); + + // Once the nodejs:20 appStream is in the toEnable Map of the channel. + toEnable.set(CHANNEL.id, [getStreamName(nodejs20)]); + + // When the nodejs:18 is passed to the handleEnableDisable function + handleModuleEnableDisable(CHANNEL, nodejs18, appStreams, toEnable, toDisable, setToEnable, setToDisable); + + // Then it should replace nodejs:20 with nodejs:18 since they are part of the same 'nodejs' module. + + // There is the initial call to enable nodejs:20, then when we pass nodejs:18 it will be called + // twice more, one to enable nodejs:18 and another to disable nodejs:20. + expect(setToEnable).toHaveBeenCalledTimes(3); + const secondCallUpdateFunction = setToEnable.mock.calls[1][0]; + const thirdCallUpdateFunction = setToEnable.mock.calls[2][0]; + + const secondCallNewState = secondCallUpdateFunction(newState); + expect(secondCallNewState.get(CHANNEL.id)).toContain(getStreamName(nodejs18)); + expect(secondCallNewState.get(CHANNEL.id)).toContain(getStreamName(nodejs20)); + + const thirdCallNewState = thirdCallUpdateFunction(secondCallNewState); + + // After reproducing the update function calls, nodejs:18 should be enabled and nodejs:20 disabled. + expect(thirdCallNewState.get(CHANNEL.id)).toContain(getStreamName(nodejs18)); + expect(thirdCallNewState.get(CHANNEL.id)).not.toContain(getStreamName(nodejs20)); + }); + + test("handleModuleEnableDisable should handle appStream replacement correctly", () => { + /** + * Given there is a channel with 2 appStreams combined in 1 module: + * - module nodejs - appStreams nodejs:18 and nodejs:20 + * And nodejs:18 is enabled in the channel + */ + const nodejs18 = nodejs("18", true); + const nodejs20 = nodejs("20", false); + const appStreamsMap = { + nodejs: [nodejs18, nodejs20], + }; + const appStreams: ChannelAppStream[] = [ + { + channel: CHANNEL, + appStreams: appStreamsMap, + }, + ]; + + // And there are no changes in the current state. + const toEnable = new Map(); + const toDisable = new Map(); + const setToEnable = jest.fn(); + const setToDisable = jest.fn(); + + // When nodejs:20 is passed to the handleEnableDisable function + handleModuleEnableDisable(CHANNEL, nodejs20, appStreams, toEnable, toDisable, setToEnable, setToDisable); + + // Then nodejs:20 appStream should be included in the toEnable Map of the channel. + // And nodejs:18 should be included in the toDisable Map of the channel + expect(setToEnable).toHaveBeenCalledTimes(1); + expect(setToDisable).toHaveBeenCalledTimes(1); + + const toEnableUpdateFn = setToEnable.mock.calls[0][0]; + const toEnableNewState = toEnableUpdateFn(new Map()); + expect(toEnableNewState.get(CHANNEL.id)).toContain(getStreamName(nodejs20)); + + const toDisableUpdateFn = setToDisable.mock.calls[0][0]; + const toDisableNewState = toDisableUpdateFn(new Map()); + expect(toDisableNewState.get(CHANNEL.id)).toContain(getStreamName(nodejs18)); + }); +}); diff --git a/web/html/src/manager/appstreams/utils.ts b/web/html/src/manager/appstreams/utils.ts index 8aef609ea4db..839bfeab6a42 100644 --- a/web/html/src/manager/appstreams/utils.ts +++ b/web/html/src/manager/appstreams/utils.ts @@ -2,6 +2,12 @@ import { AppStreamModule, Channel, ChannelAppStream } from "./appstreams.type"; export const getStreamName = (module: AppStreamModule) => `${module.name}:${module.stream}`; +/** + * Handle the event of a click to enable or to disable a given @param appStream in the context + * of a @param channel. For doing its job, the function needs to know the current state + * (@param toEnable/@param toDisable) and to have proper functions to call for changing + * the state (@param setToEnable/@param setToDisable); + */ export const handleModuleEnableDisable = ( channel: Channel, appStream: AppStreamModule, @@ -11,36 +17,86 @@ export const handleModuleEnableDisable = ( setToEnable: React.Dispatch>>, setToDisable: React.Dispatch>> ) => { - const handleReactStateChange = (prevState: Map, stream: string) => { - const updatedMap = new Map(prevState); - let newList = updatedMap.get(channel.id) ?? []; - newList = newList.includes(stream) ? newList.filter((it) => it !== stream) : newList.concat(stream); - updatedMap.set(channel.id, newList); - return updatedMap; - }; - - const isStreamEnabled = (stream: AppStreamModule) => - toEnable.get(channel.id)?.includes(getStreamName(stream)) || - (stream.enabled && !toDisable.get(channel.id)?.includes(getStreamName(stream))); - const stream = getStreamName(appStream); + + /** + * We are handling an event to enable or disable a given appStream. The appStream.enabled + * property is managed in backend side, so it indicates the status before any changes in React. + * + * If the appStream is enabled it only makes sense to include or remove it in the toDisable list, + * therefore, the correct React state change function to call is setToDisable. + * + * Conversely, if the appStream is disabled in the backend, it only makes sense to include or + * remove it in the toEnable list. + * Hence, the correct React state change function to call is setToEnable. + */ const setStateFunction = appStream.enabled ? setToDisable : setToEnable; - setStateFunction((prevState) => handleReactStateChange(prevState, stream)); + + // After defining the correct React set state function to call, we invoke it. + // The handleReactStateChange function is passed as a callback to construct the updated Map. + setStateFunction((prevState) => handleReactStateChange(prevState, channel, stream)); // Disable every other stream of the module appStreams.forEach((ch) => ch.appStreams[appStream.name] - ?.filter((as) => getStreamName(as) !== stream && isStreamEnabled(as)) + ?.filter((as) => getStreamName(as) !== stream && isStreamToBeEnabled(channel, as, toEnable, toDisable)) .forEach((as) => handleModuleEnableDisable(ch.channel, as, appStreams, toEnable, toDisable, setToEnable, setToDisable) ) ); }; +/** + * This function updates the toEnable or toDisable Map based on the appStream being handled. + * + * As we aim to switch the current state, it checks if the stream is already in the list of + * enabled/disabled streams for the channel. + * + * If the stream already there, it is removed (toggle off), otherwise it is included (toggle on). + * + * A new Map instance is created based on the previous state to avoid direct mutation. + */ +const handleReactStateChange = (prevState: Map, channel: Channel, stream: string) => { + const updatedMap = new Map(prevState); + let newList = updatedMap.get(channel.id) ?? []; + newList = newList.includes(stream) ? newList.filter((it) => it !== stream) : newList.concat(stream); + updatedMap.set(channel.id, newList); + return updatedMap; +}; + +/** + * Determines if a given @param stream is to be enabled for a specified @param channel, + * considering both the backend status (stream.enabled) and the frontend changes at a + * particular moment (@param toEnable and @param toDisable). + * + * @returns true if the stream is to be enabled, otherwise false. + */ +const isStreamToBeEnabled = ( + channel: Channel, + stream: AppStreamModule, + toEnable: Map, + toDisable: Map +) => + toEnable.get(channel.id)?.includes(getStreamName(stream)) || + (stream.enabled && !toDisable.get(channel.id)?.includes(getStreamName(stream))); + +/** + * Calculates the total number of changes by summing the lengths of the lists in the toEnable and toDisable maps. + * + * @param toEnable - Map where keys are channel IDs and values are lists of streams to be enabled. + * @param toDisable - Map where keys are channel IDs and values are lists of streams to be disabled. + * @returns The total number of changes (streams to be enabled or disabled). + */ export const numberOfChanges = (toEnable: Map, toDisable: Map) => { return reduceMapListLength(toEnable) + reduceMapListLength(toDisable); }; +/** + * Sums the lengths of the lists in the provided map. + * + * @param mapToReduce - Map where keys are channel IDs and values are lists of streams. + * @returns The total length of all lists combined in the map. + */ const reduceMapListLength = (mapToReduce: Map) => { return Array.from(mapToReduce.keys()).reduce((acc, key) => acc + (mapToReduce.get(key)?.length ?? 0), 0); }; From 445da1d371efd24959a9858c62d41eb35362db4e Mon Sep 17 00:00:00 2001 From: Welder Luz Date: Mon, 8 Jul 2024 09:08:29 -0300 Subject: [PATCH 3/7] Apply appStreams state before packages state --- .../com/suse/manager/reactor/messaging/RegistrationUtils.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java b/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java index 0416ed68ae3e..5a9cfef2fc27 100644 --- a/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java +++ b/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java @@ -133,6 +133,7 @@ public static void finishRegistration(MinionServer minion, Optional statesToApply = new ArrayList<>(); statesToApply.add(ApplyStatesEventMessage.CERTIFICATE); statesToApply.add(ApplyStatesEventMessage.CHANNELS); + handleActivationKeyAppStreams(activationKey, statesToApply, statesToApplyPillar); statesToApply.add(ApplyStatesEventMessage.PACKAGES); if (enableMinionService) { statesToApply.add(ApplyStatesEventMessage.SALT_MINION_SERVICE); @@ -142,8 +143,6 @@ public static void finishRegistration(MinionServer minion, Optional Date: Mon, 8 Jul 2024 09:09:11 -0300 Subject: [PATCH 4/7] Copy appStreams when cloning activation key --- .../rhn/domain/token/TokenChannelAppStream.java | 4 ++++ .../manager/token/ActivationKeyCloneCommand.java | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java b/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java index e6f34fdd790d..e655059c7235 100644 --- a/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java +++ b/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java @@ -111,4 +111,8 @@ public Channel getChannel() { public void setChannel(Channel channelIn) { channel = channelIn; } + + public String getAppStream() { + return name + ":" + stream; + } } diff --git a/java/code/src/com/redhat/rhn/manager/token/ActivationKeyCloneCommand.java b/java/code/src/com/redhat/rhn/manager/token/ActivationKeyCloneCommand.java index adc4faeefbad..5762069cd868 100644 --- a/java/code/src/com/redhat/rhn/manager/token/ActivationKeyCloneCommand.java +++ b/java/code/src/com/redhat/rhn/manager/token/ActivationKeyCloneCommand.java @@ -22,6 +22,7 @@ import com.redhat.rhn.domain.server.ServerGroup; import com.redhat.rhn.domain.server.ServerGroupType; import com.redhat.rhn.domain.token.ActivationKey; +import com.redhat.rhn.domain.token.TokenChannelAppStream; import com.redhat.rhn.domain.token.TokenPackage; import com.redhat.rhn.domain.user.User; import com.redhat.rhn.frontend.xmlrpc.activationkey.ActivationKeyAlreadyExistsException; @@ -31,9 +32,12 @@ import org.hibernate.NonUniqueObjectException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; /** * ActivationKeyCloneCommand @@ -104,6 +108,17 @@ public ActivationKeyCloneCommand(User userIn, String key, // Contact method cak.setContactMethod(ak.getContactMethod()); + + // AppStreams + Map> tcasMap = akm.listTokenChannelAppStreams(ak).stream().collect( + Collectors.groupingBy(TokenChannelAppStream::getChannel) + ); + tcasMap.forEach((channel, streams) -> { + var toInclude = streams.stream().map(TokenChannelAppStream::getAppStream).collect(Collectors.toList()); + akm.saveChannelAppStreams( + cak, channel, toInclude, Collections.emptyList() + ); + }); } /** From 9fda5f1cf6d18e2906f64e360d97485ae9aae451 Mon Sep 17 00:00:00 2001 From: Welder Luz Date: Mon, 8 Jul 2024 10:56:41 -0300 Subject: [PATCH 5/7] Fix AppStreams type --- web/html/src/manager/appstreams/appstreams.type.ts | 4 ++-- web/html/src/manager/appstreams/panel-appstream.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/html/src/manager/appstreams/appstreams.type.ts b/web/html/src/manager/appstreams/appstreams.type.ts index 80e5caaad019..4891ab449020 100644 --- a/web/html/src/manager/appstreams/appstreams.type.ts +++ b/web/html/src/manager/appstreams/appstreams.type.ts @@ -4,9 +4,9 @@ export type Channel = { name: string; }; -interface AppStreams { +export type AppStreams = { [key: string]: Array; -} +}; export type ChannelAppStream = { channel: Channel; diff --git a/web/html/src/manager/appstreams/panel-appstream.tsx b/web/html/src/manager/appstreams/panel-appstream.tsx index d23a57062003..c67dab78020a 100644 --- a/web/html/src/manager/appstreams/panel-appstream.tsx +++ b/web/html/src/manager/appstreams/panel-appstream.tsx @@ -1,11 +1,11 @@ import { Panel } from "components/panels/Panel"; -import { AppStreamModule, Channel } from "./appstreams.type"; +import { AppStreamModule, AppStreams, Channel } from "./appstreams.type"; import { ChannelAppStreams } from "./channel-appstreams"; interface Props { channel: Channel; - appStreams: Map; + appStreams: AppStreams; toEnable: Map; toDisable: Map; onToggle: (arg: AppStreamModule) => void; From b9207295dd8f13cdbb125d9f88e0ebf6f384220e Mon Sep 17 00:00:00 2001 From: Welder Luz Date: Fri, 12 Jul 2024 09:46:56 -0300 Subject: [PATCH 6/7] Add activation keys api endpoints for adding and removing app streams --- .../redhat/rhn/domain/channel/AppStream.java | 4 + .../rhn/domain/token/ActivationKey.java | 8 ++ .../com/redhat/rhn/domain/token/Token.java | 15 ++ .../domain/token/TokenChannelAppStream.java | 13 +- .../rhn/domain/token/Token_legacyUser.hbm.xml | 4 + .../xmlrpc/DuplicateAppStreamException.java | 29 ++++ .../activationkey/ActivationKeyHandler.java | 66 +++++++++ .../xmlrpc/serializer/TokenSerializer.java | 11 ++ .../token/ActivationKeyCloneCommand.java | 16 +-- .../manager/token/ActivationKeyManager.java | 136 +++++++----------- .../reactor/messaging/RegistrationUtils.java | 3 +- 11 files changed, 204 insertions(+), 101 deletions(-) create mode 100644 java/code/src/com/redhat/rhn/frontend/xmlrpc/DuplicateAppStreamException.java diff --git a/java/code/src/com/redhat/rhn/domain/channel/AppStream.java b/java/code/src/com/redhat/rhn/domain/channel/AppStream.java index c247e44fe88a..ef854d090699 100644 --- a/java/code/src/com/redhat/rhn/domain/channel/AppStream.java +++ b/java/code/src/com/redhat/rhn/domain/channel/AppStream.java @@ -143,4 +143,8 @@ public Set getRpms() { public void setRpms(Set rpmsIn) { rpms = rpmsIn; } + + public String getAppStreamKey() { + return name + ":" + stream; + } } diff --git a/java/code/src/com/redhat/rhn/domain/token/ActivationKey.java b/java/code/src/com/redhat/rhn/domain/token/ActivationKey.java index 075685169f06..6a54047f5838 100644 --- a/java/code/src/com/redhat/rhn/domain/token/ActivationKey.java +++ b/java/code/src/com/redhat/rhn/domain/token/ActivationKey.java @@ -436,6 +436,14 @@ public void setContactMethod(ContactMethod contactMethodIn) { this.getToken().setContactMethod(contactMethodIn); } + /** + * Get the appStreams related to this activation key. + * @return the Set of appStreams + */ + public Set getAppStreams() { + return this.getToken().getAppStreams(); + } + /** * Makes the Activation key prefix that will get * added to the base key diff --git a/java/code/src/com/redhat/rhn/domain/token/Token.java b/java/code/src/com/redhat/rhn/domain/token/Token.java index 847d310ce057..36843ae96fb2 100644 --- a/java/code/src/com/redhat/rhn/domain/token/Token.java +++ b/java/code/src/com/redhat/rhn/domain/token/Token.java @@ -57,6 +57,7 @@ public class Token implements Identifiable { private Set channels = new HashSet<>(); private Set serverGroups = new HashSet<>(); private Set packages = new HashSet<>(); + private Set appStreams = new HashSet<>(); /** * @return Returns the entitlements. @@ -440,6 +441,20 @@ public void setPackages(Set packagesIn) { this.packages = packagesIn; } + /** + * @return the app streams associated with the token + */ + public Set getAppStreams() { + return appStreams; + } + + /** + * @param appStreamsIn the app streams to set + */ + public void setAppStreams(Set appStreamsIn) { + appStreams = appStreamsIn; + } + /** * Clear all packages associated with this token. */ diff --git a/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java b/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java index e655059c7235..a65c046cf86d 100644 --- a/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java +++ b/java/code/src/com/redhat/rhn/domain/token/TokenChannelAppStream.java @@ -40,16 +40,15 @@ public TokenChannelAppStream() { /** * Constructs a TokenChannelAppStream. * - * @param tokenIn the token - * @param channelIn the channel - * @param nameIn the name of the appStream module - * @param streamIn the stream of the appStream module + * @param tokenIn the token + * @param channelIn the channel + * @param appStreamIn the appStream in the format name:stream */ - public TokenChannelAppStream(Token tokenIn, Channel channelIn, String nameIn, String streamIn) { + public TokenChannelAppStream(Token tokenIn, Channel channelIn, String appStreamIn) { token = tokenIn; channel = channelIn; - name = nameIn; - stream = streamIn; + name = appStreamIn.split(":")[0]; + stream = appStreamIn.split(":")[1]; } @Id diff --git a/java/code/src/com/redhat/rhn/domain/token/Token_legacyUser.hbm.xml b/java/code/src/com/redhat/rhn/domain/token/Token_legacyUser.hbm.xml index a49aeffe3519..b5c1006041b9 100644 --- a/java/code/src/com/redhat/rhn/domain/token/Token_legacyUser.hbm.xml +++ b/java/code/src/com/redhat/rhn/domain/token/Token_legacyUser.hbm.xml @@ -41,6 +41,10 @@ PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN" + + + + diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/DuplicateAppStreamException.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/DuplicateAppStreamException.java new file mode 100644 index 000000000000..8c85e6b3a259 --- /dev/null +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/DuplicateAppStreamException.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.redhat.rhn.frontend.xmlrpc; + +import com.redhat.rhn.FaultException; + +public class DuplicateAppStreamException extends FaultException { + + /** + * Constructor + * @param message exception message + */ + public DuplicateAppStreamException(String message) { + super(-309, "duplicateStream", message); + } +} diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java index 45459eafe83e..a65b26d54036 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java @@ -833,6 +833,72 @@ public int removePackages(User loggedInUser, String key, return 1; } + /** + * Add app streams to an activation key. + * + * @param loggedInUser The current user + * @param key The activation key to act upon + * @param appStreams List of app streams to be added to this activation key + * @return 1 on success, exception thrown otherwise + * + * @apidoc.doc Add app streams to an activation key. If any of the provided app streams is not available in the + * channels of the activation key, the request will fail. + * @apidoc.param #session_key() + * @apidoc.param #param("string", "key") + * @apidoc.param #array_begin("appStreams") + * #struct_begin("Module Stream") + * #prop("string", "module") + * #prop("string", "stream") + * #struct_end() + * #array_end() + * @apidoc.returntype #return_int_success() + */ + public int addAppStreams(User loggedInUser, String key, List> appStreams) { + ActivationKeyManager akm = ActivationKeyManager.getInstance(); + ActivationKey activationKey = lookupKey(key, loggedInUser); + List appStreamsKeys = appStreams.stream() + .map(it -> it.get("module") + ":" + it.get("stream")) + .collect(Collectors.toList()); + Map channelsProviding = akm.getChannelsProvidingAppStreams(activationKey, appStreamsKeys); + Map> toIncludeMap = channelsProviding.entrySet().stream() + .collect(Collectors.groupingBy( + Map.Entry::getValue, + Collectors.mapping(Map.Entry::getKey, Collectors.toList()) + )); + toIncludeMap.forEach((channel, toInclude) -> + akm.saveChannelAppStreams(activationKey, channel, toInclude, Collections.emptyList()) + ); + return 1; + } + + /** + * Remove app streams from an activation key. + * + * @param loggedInUser The current user + * @param key The activation key to act upon + * @param appStreams List of app streams to be removed from this activation key + * @return 1 on success, exception thrown otherwise + * + * @apidoc.doc Remove app streams from an activation key. + * @apidoc.param #session_key() + * @apidoc.param #param("string", "key") + * @apidoc.param #array_begin("appStreams") + * #struct_begin("Module Stream") + * #prop("string", "module") + * #prop("string", "stream") + * #struct_end() + * #array_end() + * @apidoc.returntype #return_int_success() + */ + public int removeAppStreams(User loggedInUser, String key, List> appStreams) { + ActivationKeyManager akm = ActivationKeyManager.getInstance(); + ActivationKey activationKey = lookupKey(key, loggedInUser); + var toRemove = appStreams.stream().map(it -> it.get("module") + ":" + it.get("stream")) + .collect(Collectors.toList()); + akm.removeAppStreams(activationKey, toRemove); + return 1; + } + /** * Return a list of activation key structs that are visible to the requesting user. * @param loggedInUser The current user diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/serializer/TokenSerializer.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/serializer/TokenSerializer.java index 42d2013a5c5f..7f35d6d51a63 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/serializer/TokenSerializer.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/serializer/TokenSerializer.java @@ -19,6 +19,7 @@ import com.redhat.rhn.domain.server.ServerGroup; import com.redhat.rhn.domain.server.ServerGroupType; import com.redhat.rhn.domain.token.Token; +import com.redhat.rhn.domain.token.TokenChannelAppStream; import com.redhat.rhn.domain.token.TokenPackage; import com.suse.manager.api.ApiResponseSerializer; @@ -29,6 +30,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * ActivationKeySerializer @@ -116,6 +118,14 @@ static void populateTokenInfo(Token token, SerializationBuilder builder) { } packages.add(pkgMap); } + + // Channel label is the key and a List of appStreams (name:stream) for each channel. + Map> appStreams = token.getAppStreams().stream() + .collect(Collectors.groupingBy( + tokenChannelAppStream -> tokenChannelAppStream.getChannel().getLabel(), + Collectors.mapping(TokenChannelAppStream::getAppStream, Collectors.toList()) + )); + builder.add("description", token.getNote()); int usageLimit = 0; @@ -130,6 +140,7 @@ static void populateTokenInfo(Token token, SerializationBuilder builder) { builder.add("server_group_ids", serverGroupIds); builder.add("package_names", packageNames); builder.add("packages", packages); + builder.add("app_streams", appStreams); Boolean universalDefault = token.isOrgDefault(); builder.add("universal_default", universalDefault); diff --git a/java/code/src/com/redhat/rhn/manager/token/ActivationKeyCloneCommand.java b/java/code/src/com/redhat/rhn/manager/token/ActivationKeyCloneCommand.java index 5762069cd868..5980a6245b7e 100644 --- a/java/code/src/com/redhat/rhn/manager/token/ActivationKeyCloneCommand.java +++ b/java/code/src/com/redhat/rhn/manager/token/ActivationKeyCloneCommand.java @@ -32,10 +32,8 @@ import org.hibernate.NonUniqueObjectException; import java.util.ArrayList; -import java.util.Collections; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -110,15 +108,11 @@ public ActivationKeyCloneCommand(User userIn, String key, cak.setContactMethod(ak.getContactMethod()); // AppStreams - Map> tcasMap = akm.listTokenChannelAppStreams(ak).stream().collect( - Collectors.groupingBy(TokenChannelAppStream::getChannel) - ); - tcasMap.forEach((channel, streams) -> { - var toInclude = streams.stream().map(TokenChannelAppStream::getAppStream).collect(Collectors.toList()); - akm.saveChannelAppStreams( - cak, channel, toInclude, Collections.emptyList() - ); - }); + Set clonedAppStreams = ak.getAppStreams().stream() + .map(appStream -> new TokenChannelAppStream( + cak.getToken(), appStream.getChannel(), appStream.getAppStream() + )).collect(Collectors.toSet()); + cak.getAppStreams().addAll(clonedAppStreams); } /** diff --git a/java/code/src/com/redhat/rhn/manager/token/ActivationKeyManager.java b/java/code/src/com/redhat/rhn/manager/token/ActivationKeyManager.java index f5f46735be80..eec6b8c7e5e7 100644 --- a/java/code/src/com/redhat/rhn/manager/token/ActivationKeyManager.java +++ b/java/code/src/com/redhat/rhn/manager/token/ActivationKeyManager.java @@ -40,6 +40,9 @@ import com.redhat.rhn.domain.token.TokenChannelAppStream; import com.redhat.rhn.domain.user.User; import com.redhat.rhn.frontend.struts.Scrubber; +import com.redhat.rhn.frontend.xmlrpc.DuplicateAppStreamException; +import com.redhat.rhn.frontend.xmlrpc.NoSuchAppStreamException; +import com.redhat.rhn.manager.appstreams.AppStreamsManager; import com.redhat.rhn.manager.channel.ChannelManager; import com.redhat.rhn.manager.entitlement.EntitlementManager; import com.redhat.rhn.manager.kickstart.cobbler.CobblerXMLRPCHelper; @@ -60,11 +63,7 @@ import java.util.List; import java.util.Map; import java.util.Set; - -import javax.persistence.criteria.CriteriaBuilder; -import javax.persistence.criteria.CriteriaQuery; -import javax.persistence.criteria.Predicate; -import javax.persistence.criteria.Root; +import java.util.stream.Collectors; /** * ActivationKeyManager @@ -587,7 +586,7 @@ public boolean hasAppStreamModuleEnabled( Channel channel, String module, String stream) { - return listTokenChannelAppStreams(activationKey, channel) + return activationKey.getAppStreams() .stream() .anyMatch(it -> it.getChannel().getId().equals(channel.getId()) && @@ -596,96 +595,71 @@ public boolean hasAppStreamModuleEnabled( ); } - /** - * Retrieves a list of {@code TokenChannelAppStream} objects that match the given activation key. - * - * @param activationKey the activation key containing the token to match - * @return a list of {@code TokenChannelAppStream} objects related to the activation key. - */ - public List listTokenChannelAppStreams(ActivationKey activationKey) { - return listTokenChannelAppStreams(activationKey, null); - } - - /** - * Retrieves a list of {@code TokenChannelAppStream} objects that match the given activation key and channel. - * If the channel is {@code null}, only the activation key is used as a filter criterion. - * - * @param activationKey the activation key containing the token to match - * @param channel the channel to match; if {@code null}, the channel criterion is ignored - * @return a list of {@code TokenChannelAppStream} objects that match the given criteria - */ - private List listTokenChannelAppStreams(ActivationKey activationKey, Channel channel) { - CriteriaBuilder criteriaBuilder = ActivationKeyFactory.getSession().getCriteriaBuilder(); - CriteriaQuery criteriaQuery = criteriaBuilder.createQuery(TokenChannelAppStream.class); - Root root = criteriaQuery.from(TokenChannelAppStream.class); - - Predicate tokenPredicate = criteriaBuilder.equal(root.get("token"), activationKey.getToken()); - if (channel != null) { - Predicate channelPredicate = criteriaBuilder.equal(root.get("channel"), channel); - criteriaQuery.where(criteriaBuilder.and(tokenPredicate, channelPredicate)); - } - else { - criteriaQuery.where(tokenPredicate); - } - - return ActivationKeyFactory.getSession().createQuery(criteriaQuery).getResultList(); - } - /** * Saves the specified channel app streams by including and removing the given lists of app streams. - * It will first remove the specified app streams from the channel, and then includes the specified - * app streams to the channel. The app streams are specified as a list of strings in the format "name:stream". + * The app streams are specified as a list of strings in the format "name:stream". * * @param activationKey the activation key containing the registration token * @param channel the channel to which the app streams belong * @param toInclude the list of app streams to include in the activation key * @param toRemove the list of app streams to remove from the activation key + * @throws DuplicateAppStreamException if an app stream to include already exists in the activation key */ public void saveChannelAppStreams( ActivationKey activationKey, Channel channel, List toInclude, List toRemove) { - removeChannelAppStreams(activationKey, channel, toRemove); - includeChannelAppStreams(activationKey, channel, toInclude); - } - - private void removeChannelAppStreams(ActivationKey activationKey, Channel channel, List toRemove) { - var session = ActivationKeyFactory.getSession(); - CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder(); - var criteriaDelete = criteriaBuilder.createCriteriaDelete(TokenChannelAppStream.class); - var root = criteriaDelete.from(TokenChannelAppStream.class); - - Predicate tokenPredicate = criteriaBuilder.equal(root.get("token"), activationKey.getToken()); - Predicate channelPredicate = criteriaBuilder.equal(root.get("channel"), channel); - - criteriaDelete.where( - criteriaBuilder.and( - tokenPredicate, - channelPredicate, - criteriaBuilder.or( - toRemove - .stream() - .map(appStream -> { - String[] parts = appStream.split(":"); - String name = parts[0]; - String stream = parts[1]; - Predicate namePredicate = criteriaBuilder.equal(root.get("name"), name); - Predicate streamPredicate = criteriaBuilder.equal(root.get("stream"), stream); - return criteriaBuilder.and(namePredicate, streamPredicate); - }).toArray(Predicate[]::new))) - ); - session.createQuery(criteriaDelete).executeUpdate(); - } - - private void includeChannelAppStreams(ActivationKey activationKey, Channel channel, List toInclude) { + removeAppStreams(activationKey, toRemove); toInclude.forEach(appStream -> { - String[] parts = appStream.split(":"); - String name = parts[0]; - String stream = parts[1]; - ActivationKeyFactory.getSession().persist( - new TokenChannelAppStream(activationKey.getToken(), channel, name, stream) + String moduleName = appStream.split(":")[0]; + if (activationKey.getAppStreams().stream().anyMatch(it -> it.getName().equals(moduleName))) { + throw new DuplicateAppStreamException( + "App stream '" + moduleName + "' already exists in the activation key." + ); + } + activationKey.getAppStreams().add( + new TokenChannelAppStream(activationKey.getToken(), channel, appStream) ); }); } + + /** + * Removes the specified app streams from the activation key. + * The app streams to remove are specified as a list of strings in the format "name:stream". + * + * @param activationKey the activation key containing the registration token + * @param toRemove the list of app streams to remove from the activation key + */ + public void removeAppStreams(ActivationKey activationKey, List toRemove) { + activationKey.getAppStreams().removeIf(tokenAppStream -> toRemove.contains(tokenAppStream.getAppStream())); + } + + /** + * Retrieves a map of app stream keys to the activation key channels that provide them. + * + * @param activationKey the activation key containing the channels to be checked + * @param appStreams a list of app stream keys (name:stream) to be mapped to their providing channels + * @return a map where the keys are app stream keys and the values are the channels that provide these app streams + * @throws NoSuchAppStreamException if an app stream key does not exist in any activation key channel + */ + public Map getChannelsProvidingAppStreams(ActivationKey activationKey, List appStreams) { + var channelsAppStreams = activationKey.getChannels() + .stream() + .filter(Channel::isModular) + .collect(Collectors.toMap( + channel -> channel, + channel -> AppStreamsManager.listChannelAppStreams(channel.getId())) + ); + + return appStreams.stream().map(appStreamKey -> { + Channel channel = channelsAppStreams.entrySet().stream() + .findFirst() + .map(Map.Entry::getKey) + .orElseThrow(() -> new NoSuchAppStreamException( + "The app stream " + appStreamKey + " doesn't exist in any activation key channel." + )); + return Map.entry(appStreamKey, channel); + }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } } diff --git a/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java b/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java index 5a9cfef2fc27..a2cb5d57103d 100644 --- a/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java +++ b/java/code/src/com/suse/manager/reactor/messaging/RegistrationUtils.java @@ -47,7 +47,6 @@ import com.redhat.rhn.manager.action.ActionManager; import com.redhat.rhn.manager.system.SystemManager; import com.redhat.rhn.manager.system.entitling.SystemEntitlementManager; -import com.redhat.rhn.manager.token.ActivationKeyManager; import com.redhat.rhn.taskomatic.TaskomaticApiException; import com.suse.manager.reactor.utils.RhelUtils; @@ -180,7 +179,7 @@ private static void handleActivationKeyAppStreams( List statesToApply, Map statesToApplyPillar) { if (activationKey.isPresent() && activationKey.get().getChannels().stream().anyMatch(Channel::isModular)) { - var appStreamsToEnable = ActivationKeyManager.getInstance().listTokenChannelAppStreams(activationKey.get()); + var appStreamsToEnable = activationKey.get().getAppStreams(); if (!appStreamsToEnable.isEmpty()) { statesToApply.add(SaltServerActionService.APPSTREAMS_CONFIGURE); var appStreamsParams = appStreamsToEnable From a737a992555ac652299b53954e80353fb179ebdf Mon Sep 17 00:00:00 2001 From: Welder Luz Date: Sat, 13 Jul 2024 14:57:54 -0300 Subject: [PATCH 7/7] Add unit tests for app streams related methods in activation keys --- .../token/test/ActivationKeyManagerTest.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/java/code/src/com/redhat/rhn/manager/token/test/ActivationKeyManagerTest.java b/java/code/src/com/redhat/rhn/manager/token/test/ActivationKeyManagerTest.java index d0dfa13a8076..8c642f944fa0 100644 --- a/java/code/src/com/redhat/rhn/manager/token/test/ActivationKeyManagerTest.java +++ b/java/code/src/com/redhat/rhn/manager/token/test/ActivationKeyManagerTest.java @@ -14,6 +14,7 @@ */ package com.redhat.rhn.manager.token.test; +import static org.junit.Assert.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -29,7 +30,9 @@ import com.redhat.rhn.domain.token.ActivationKey; import com.redhat.rhn.domain.token.ActivationKeyFactory; import com.redhat.rhn.domain.token.Token; +import com.redhat.rhn.domain.token.TokenChannelAppStream; import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.frontend.xmlrpc.DuplicateAppStreamException; import com.redhat.rhn.manager.token.ActivationKeyManager; import com.redhat.rhn.testing.BaseTestCaseWithUser; import com.redhat.rhn.testing.ChannelTestUtils; @@ -225,6 +228,61 @@ public void testCreate() throws Exception { assertEquals(usageLimit, token.getUsageLimit()); } + @Test + public void testHasAppStreamModuleEnabled() throws Exception { + ActivationKey key = createActivationKey(); + Channel channel = ChannelTestUtils.createBaseChannel(user); + key.getAppStreams().add( + new TokenChannelAppStream(key.getToken(), channel, "ruby:3.3") + ); + + assertTrue(manager.hasAppStreamModuleEnabled(key, channel, "ruby", "3.3")); + assertFalse(manager.hasAppStreamModuleEnabled(key, channel, "ruby", "3.2")); + assertFalse(manager.hasAppStreamModuleEnabled(key, channel, "nginx", "3.3")); + } + + @Test + public void testSaveChannelAppStreams() throws Exception { + ActivationKey key = createActivationKey(); + Channel channel = ChannelTestUtils.createBaseChannel(user); + + List toInclude = List.of("php:8.1", "nginx:1.24"); + List toRemove = List.of(); + + manager.saveChannelAppStreams(key, channel, toInclude, toRemove); + + assertTrue(key.getAppStreams().stream().anyMatch(appStream -> appStream.getAppStream().equals("php:8.1"))); + assertTrue(key.getAppStreams().stream().anyMatch(appStream -> appStream.getAppStream().equals("nginx:1.24"))); + + assertThrows(DuplicateAppStreamException.class, () -> + manager.saveChannelAppStreams(key, channel, List.of("php:8.2"), List.of()) + ); + + toInclude = List.of("php:8.2"); + toRemove = List.of("php:8.1"); + manager.saveChannelAppStreams(key, channel, toInclude, toRemove); + assertFalse(key.getAppStreams().stream().anyMatch(appStream -> appStream.getAppStream().equals("php:8.1"))); + assertTrue(key.getAppStreams().stream().anyMatch(appStream -> appStream.getAppStream().equals("php:8.2"))); + } + + @Test + public void testRemoveAppStreams() throws Exception { + ActivationKey key = createActivationKey(); + Channel channel = ChannelTestUtils.createBaseChannel(user); + + key.getAppStreams().add(new TokenChannelAppStream(key.getToken(), channel, "nodejs:18")); + key.getAppStreams().add(new TokenChannelAppStream(key.getToken(), channel, "mariadb:10.11")); + + List toRemove = List.of("mariadb:10.11"); + + manager.removeAppStreams(key, toRemove); + + assertFalse(key.getAppStreams().stream() + .anyMatch(appStream -> appStream.getAppStream().equals("mariadb:10.11"))); + assertTrue(key.getAppStreams().stream() + .anyMatch(appStream -> appStream.getAppStream().equals("nodejs:18"))); + } + public ActivationKey createActivationKey() { user.addPermanentRole(RoleFactory.ACTIVATION_KEY_ADMIN); return manager.createNewActivationKey(user, TestUtils.randomString());