@@ -71,32 +71,47 @@ def available_foreman_plugins(_value):
7171 return compact_list (plugins )
7272
7373
74- def list_all_features (enabled_features , only_enabled = False ):
74+ def list_all_features (enabled_features , only_enabled = False , flavor_features = None ):
7575 enabled_list = []
7676 available_list = []
7777 list_internal = os .environ .get ('FOREMANCTL_FEATURES_LIST_INTERNAL' , '' ) == 'true'
78+ flavor_features_set = set (flavor_features or [])
79+
7880 for name , meta in FEATURE_MAP .items ():
7981 internal = meta .get ('internal' , False )
8082 if internal and not list_internal :
8183 continue
8284 description = meta .get ('description' , '' )
85+
86+ if name in flavor_features_set :
87+ removable = 'flavor'
88+ elif meta .get ('removable' , False ):
89+ removable = 'yes'
90+ else :
91+ removable = 'no'
92+
8393 if has_feature (enabled_features , name ):
84- enabled_list .append ((name , 'enabled' , internal , description ))
94+ enabled_list .append ((name , 'enabled' , internal , removable , description ))
8595 elif not only_enabled :
86- available_list .append ((name , 'available' , internal , description ))
96+ available_list .append ((name , 'available' , internal , removable , description ))
8797
8898 if not list_internal :
89- output = [f"{ 'FEATURE' :<25} { 'STATE' :<12} DESCRIPTION" ]
90- for name , state , _internal , description in enabled_list + available_list :
91- output .append (f"{ name :<25} { state :<12} { description } " )
99+ output = [f"{ 'FEATURE' :<25} { 'STATE' :<12} { 'REMOVABLE' :<13 } DESCRIPTION" ]
100+ for name , state , _internal , removable , description in enabled_list + available_list :
101+ output .append (f"{ name :<25} { state :<12} { removable :<13 } { description } " )
92102 else :
93- output = [f"{ 'FEATURE' :<25} { 'STATE' :<12} { 'INTERNAL' :<8} DESCRIPTION" ]
94- for name , state , internal , description in enabled_list + available_list :
95- output .append (f"{ name :<25} { state :<12} { internal :<8} { description } " )
103+ output = [f"{ 'FEATURE' :<25} { 'STATE' :<12} { 'INTERNAL' :<8} { 'REMOVABLE' :<13 } DESCRIPTION" ]
104+ for name , state , internal , removable , description in enabled_list + available_list :
105+ output .append (f"{ name :<25} { state :<12} { internal :<8} { removable :<13 } { description } " )
96106
97107 return "\n " .join (output )
98108
99109
110+ def is_feature_removable (feature_name ):
111+ """Check if a feature supports removal."""
112+ return FEATURE_MAP .get (feature_name , {}).get ('removable' , False )
113+
114+
100115def invalid_features (features ):
101116 """Return a list of unknown features not defined in features.yaml."""
102117 return [feature for feature in features if feature not in FEATURE_MAP ]
@@ -112,6 +127,59 @@ def conflicting_features(features):
112127 return [f"{ pair [0 ]} conflicts with { pair [1 ]} " for pair in conflicts ]
113128
114129
130+ def validate_feature_removals (remove_features , flavor_features ):
131+ """Validate that requested feature removals are allowed.
132+
133+ Returns a list of error message strings. Empty list means all valid.
134+ """
135+ errors = []
136+
137+ for feature in remove_features :
138+ if feature in flavor_features :
139+ errors .append (
140+ f"Cannot remove '{ feature } ' — it is a core feature of the current flavor. "
141+ f"Flavor features cannot be removed."
142+ )
143+ elif feature not in FEATURE_MAP :
144+ errors .append (
145+ f"Cannot remove unknown feature '{ feature } '. "
146+ f"Run 'foremanctl features' to see available features."
147+ )
148+ elif not is_feature_removable (feature ):
149+ errors .append (
150+ f"Cannot remove feature '{ feature } ' — this feature does not support removal. "
151+ f"Run 'foremanctl features' to see which features can be removed."
152+ )
153+
154+ return errors
155+
156+
157+ def unsatisfied_dependencies (enabled_features , remove_features = None ):
158+ """Detect removed features that are still required by an enabled feature.
159+
160+ ``enabled_features`` is the effective feature list (flavor + user features
161+ with removals already subtracted). ``remove_features`` are the features the
162+ user asked to remove. Because dependencies are auto-included, a dependency
163+ is only ever "unsatisfied" when the user explicitly removes something that
164+ a still-enabled feature depends on.
165+
166+ Returns a list of error strings; an empty list means all removals are safe.
167+ """
168+ remove_set = set (remove_features or [])
169+ if not remove_set :
170+ return []
171+
172+ errors = []
173+ for feature in enabled_features :
174+ still_required = get_dependencies_for_feature (feature ) & remove_set
175+ for dependency in sorted (still_required ):
176+ errors .append (
177+ f"Cannot remove '{ dependency } ' — it is required by enabled feature '{ feature } '"
178+ )
179+
180+ return errors
181+
182+
115183def hammer_plugins (value ):
116184 dependencies = list (get_dependencies (filter_features (value )))
117185 features = set (filter_features (value + dependencies ))
@@ -164,6 +232,8 @@ def filters(self):
164232 'list_all_features' : list_all_features ,
165233 'invalid_features' : invalid_features ,
166234 'conflicting_features' : conflicting_features ,
235+ 'validate_feature_removals' : validate_feature_removals ,
236+ 'unsatisfied_dependencies' : unsatisfied_dependencies ,
167237 'has_feature' : has_feature ,
168238 'databases_for_features' : databases_for_features ,
169239 'to_postgresql_databases' : to_postgresql_databases ,
0 commit comments