From cef7430e4d4c918adf5aa91c13b96dc470a0820f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Sun, 5 Apr 2026 18:01:27 +0200 Subject: [PATCH 1/2] Add @Config annotation for shell command configuration injection Allow shell commands to inject OSGi ConfigurationAdmin properties via a new @Config(pid = "...") annotation on Map fields. ManagerImpl resolves the configuration PID at instantiation time and injects the properties as a LinkedHashMap. CommandExtension automatically tracks ConfigurationAdmin availability when a command uses @Config. --- .../shell/api/action/lifecycle/Config.java | 51 ++++ .../impl/action/command/ManagerImpl.java | 44 ++++ .../impl/action/osgi/CommandExtension.java | 9 + .../action/command/ManagerImplConfigTest.java | 235 ++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 shell/core/src/main/java/org/apache/karaf/shell/api/action/lifecycle/Config.java create mode 100644 shell/core/src/test/java/org/apache/karaf/shell/impl/action/command/ManagerImplConfigTest.java diff --git a/shell/core/src/main/java/org/apache/karaf/shell/api/action/lifecycle/Config.java b/shell/core/src/main/java/org/apache/karaf/shell/api/action/lifecycle/Config.java new file mode 100644 index 00000000000..0b4691d2dc8 --- /dev/null +++ b/shell/core/src/main/java/org/apache/karaf/shell/api/action/lifecycle/Config.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.karaf.shell.api.action.lifecycle; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Injects OSGi ConfigurationAdmin properties into a field. + * + *

The annotated field must be of type {@code Map}. The + * configuration properties for the given PID are retrieved from + * {@link org.osgi.service.cm.ConfigurationAdmin} and injected as a + * {@link java.util.LinkedHashMap}. + * + *

Usage example: + *

+ * @Config(value = "service.pid", pid = "foo.bar")
+ * Map<String, Object> properties = new LinkedHashMap<>();
+ * 
+ * + * If the configuration PID does not exist, an empty map is injected. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD}) +public @interface Config { + + /** + * The configuration PID to look up in ConfigurationAdmin. + */ + String pid(); + +} diff --git a/shell/core/src/main/java/org/apache/karaf/shell/impl/action/command/ManagerImpl.java b/shell/core/src/main/java/org/apache/karaf/shell/impl/action/command/ManagerImpl.java index be9af5cfcb1..0438d5113ca 100644 --- a/shell/core/src/main/java/org/apache/karaf/shell/impl/action/command/ManagerImpl.java +++ b/shell/core/src/main/java/org/apache/karaf/shell/impl/action/command/ManagerImpl.java @@ -21,14 +21,18 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Dictionary; +import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import org.apache.karaf.shell.api.action.Action; import org.apache.karaf.shell.api.action.Command; +import org.apache.karaf.shell.api.action.lifecycle.Config; import org.apache.karaf.shell.api.action.lifecycle.Destroy; import org.apache.karaf.shell.api.action.lifecycle.Init; import org.apache.karaf.shell.api.action.lifecycle.Manager; @@ -38,9 +42,15 @@ import org.apache.karaf.shell.api.console.Parser; import org.apache.karaf.shell.api.console.Registry; import org.apache.karaf.shell.support.converter.GenericType; +import org.osgi.service.cm.Configuration; +import org.osgi.service.cm.ConfigurationAdmin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class ManagerImpl implements Manager { + private static final Logger LOGGER = LoggerFactory.getLogger(ManagerImpl.class); + private final Registry dependencies; private final Registry registrations; private final Map, Object> instances = new HashMap<>(); @@ -96,6 +106,40 @@ public T instantiate(Class clazz, Registry registry) throws Exc } } } + // Inject configuration properties + ConfigurationAdmin configAdmin = registry.getService(ConfigurationAdmin.class); + if (configAdmin == null && registry != this.dependencies) { + configAdmin = this.dependencies.getService(ConfigurationAdmin.class); + } + for (Class cl = clazz; cl != Object.class; cl = cl.getSuperclass()) { + for (Field field : cl.getDeclaredFields()) { + Config cfg = field.getAnnotation(Config.class); + if (cfg != null) { + Map props = new LinkedHashMap<>(); + if (configAdmin != null) { + try { + Configuration configuration = configAdmin.getConfiguration(cfg.pid(), "?"); + if (configuration != null) { + Dictionary dict = configuration.getProperties(); + if (dict != null) { + Enumeration keys = dict.keys(); + while (keys.hasMoreElements()) { + String key = keys.nextElement(); + props.put(key, dict.get(key)); + } + } + } + } catch (Exception e) { + LOGGER.warn("Unable to retrieve configuration for PID {}", cfg.pid(), e); + } + } else { + LOGGER.debug("ConfigurationAdmin service not available, injecting empty map for PID {}", cfg.pid()); + } + field.setAccessible(true); + field.set(instance, props); + } + } + } for (Method method : clazz.getDeclaredMethods()) { Init ann = method.getAnnotation(Init.class); if (ann != null && method.getParameterTypes().length == 0 && method.getReturnType() == void.class) { diff --git a/shell/core/src/main/java/org/apache/karaf/shell/impl/action/osgi/CommandExtension.java b/shell/core/src/main/java/org/apache/karaf/shell/impl/action/osgi/CommandExtension.java index a424c26a933..ff56b24d5b7 100644 --- a/shell/core/src/main/java/org/apache/karaf/shell/impl/action/osgi/CommandExtension.java +++ b/shell/core/src/main/java/org/apache/karaf/shell/impl/action/osgi/CommandExtension.java @@ -30,6 +30,7 @@ import org.apache.felix.utils.extender.Extension; import org.apache.felix.utils.manifest.Clause; import org.apache.felix.utils.manifest.Parser; +import org.apache.karaf.shell.api.action.lifecycle.Config; import org.apache.karaf.shell.api.action.lifecycle.Manager; import org.apache.karaf.shell.api.action.lifecycle.Reference; import org.apache.karaf.shell.api.action.lifecycle.Service; @@ -43,6 +44,7 @@ import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.osgi.framework.wiring.BundleWiring; +import org.osgi.service.cm.ConfigurationAdmin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -176,8 +178,12 @@ private void inspectClass(final Class clazz) throws Exception { return; } // Create trackers + boolean needsConfigAdmin = false; for (Class cl = clazz; cl != Object.class; cl = cl.getSuperclass()) { for (Field field : cl.getDeclaredFields()) { + if (field.getAnnotation(Config.class) != null) { + needsConfigAdmin = true; + } Reference ref = field.getAnnotation(Reference.class); if (ref != null) { GenericType type = new GenericType(field.getGenericType()); @@ -194,6 +200,9 @@ private void inspectClass(final Class clazz) throws Exception { } } } + if (needsConfigAdmin && !registry.hasService(ConfigurationAdmin.class)) { + tracker.trackSingle(ConfigurationAdmin.class, false, ""); + } classes.add(clazz); } diff --git a/shell/core/src/test/java/org/apache/karaf/shell/impl/action/command/ManagerImplConfigTest.java b/shell/core/src/test/java/org/apache/karaf/shell/impl/action/command/ManagerImplConfigTest.java new file mode 100644 index 00000000000..4ef8d7a8400 --- /dev/null +++ b/shell/core/src/test/java/org/apache/karaf/shell/impl/action/command/ManagerImplConfigTest.java @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.karaf.shell.impl.action.command; + +import java.util.Hashtable; +import java.util.Map; + +import org.apache.karaf.shell.api.action.Action; +import org.apache.karaf.shell.api.action.Command; +import org.apache.karaf.shell.api.action.lifecycle.Config; +import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.apache.karaf.shell.api.console.Registry; +import org.junit.Test; +import org.osgi.service.cm.Configuration; +import org.osgi.service.cm.ConfigurationAdmin; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +public class ManagerImplConfigTest { + + @Command(scope = "test", name = "config-single") + @Service + public static class SingleConfigCommand implements Action { + @Config(pid = "my.pid") + Map config; + + @Override + public Object execute() throws Exception { + return null; + } + } + + @Command(scope = "test", name = "config-multi") + @Service + public static class MultiConfigCommand implements Action { + @Config(pid = "pid.one") + Map configOne; + + @Config(pid = "pid.two") + Map configTwo; + + @Override + public Object execute() throws Exception { + return null; + } + } + + @Command(scope = "test", name = "config-empty") + @Service + public static class EmptyConfigCommand implements Action { + @Config(pid = "nonexistent.pid") + Map config; + + @Override + public Object execute() throws Exception { + return null; + } + } + + @Test + public void testConfigInjection() throws Exception { + Hashtable props = new Hashtable<>(); + props.put("key1", "value1"); + props.put("key2", 42); + + Configuration configuration = createMock(Configuration.class); + expect(configuration.getProperties()).andReturn(props); + replay(configuration); + + ConfigurationAdmin configAdmin = createMock(ConfigurationAdmin.class); + expect(configAdmin.getConfiguration("my.pid", "?")).andReturn(configuration); + replay(configAdmin); + + Registry registry = createMock(Registry.class); + expect(registry.getService(ConfigurationAdmin.class)).andReturn(configAdmin); + replay(registry); + + ManagerImpl manager = new ManagerImpl(registry, registry, true); + SingleConfigCommand cmd = manager.instantiate(SingleConfigCommand.class, registry); + + assertNotNull(cmd.config); + assertEquals("value1", cmd.config.get("key1")); + assertEquals(42, cmd.config.get("key2")); + assertEquals(2, cmd.config.size()); + + verify(configuration, configAdmin, registry); + } + + @Test + public void testConfigInjectionWithNullProperties() throws Exception { + Configuration configuration = createMock(Configuration.class); + expect(configuration.getProperties()).andReturn(null); + replay(configuration); + + ConfigurationAdmin configAdmin = createMock(ConfigurationAdmin.class); + expect(configAdmin.getConfiguration("nonexistent.pid", "?")).andReturn(configuration); + replay(configAdmin); + + Registry registry = createMock(Registry.class); + expect(registry.getService(ConfigurationAdmin.class)).andReturn(configAdmin); + replay(registry); + + ManagerImpl manager = new ManagerImpl(registry, registry, true); + EmptyConfigCommand cmd = manager.instantiate(EmptyConfigCommand.class, registry); + + assertNotNull(cmd.config); + assertTrue(cmd.config.isEmpty()); + + verify(configuration, configAdmin, registry); + } + + @Test + public void testConfigInjectionWithoutConfigAdmin() throws Exception { + Registry registry = createMock(Registry.class); + expect(registry.getService(ConfigurationAdmin.class)).andReturn(null); + replay(registry); + + ManagerImpl manager = new ManagerImpl(registry, registry, true); + SingleConfigCommand cmd = manager.instantiate(SingleConfigCommand.class, registry); + + assertNotNull(cmd.config); + assertTrue(cmd.config.isEmpty()); + + verify(registry); + } + + @Test + public void testMultipleConfigInjection() throws Exception { + Hashtable props1 = new Hashtable<>(); + props1.put("host", "localhost"); + props1.put("port", 8080); + + Hashtable props2 = new Hashtable<>(); + props2.put("timeout", 30000L); + + Configuration config1 = createMock(Configuration.class); + expect(config1.getProperties()).andReturn(props1); + replay(config1); + + Configuration config2 = createMock(Configuration.class); + expect(config2.getProperties()).andReturn(props2); + replay(config2); + + ConfigurationAdmin configAdmin = createMock(ConfigurationAdmin.class); + expect(configAdmin.getConfiguration("pid.one", "?")).andReturn(config1); + expect(configAdmin.getConfiguration("pid.two", "?")).andReturn(config2); + replay(configAdmin); + + Registry registry = createMock(Registry.class); + expect(registry.getService(ConfigurationAdmin.class)).andReturn(configAdmin); + replay(registry); + + ManagerImpl manager = new ManagerImpl(registry, registry, true); + MultiConfigCommand cmd = manager.instantiate(MultiConfigCommand.class, registry); + + assertNotNull(cmd.configOne); + assertEquals("localhost", cmd.configOne.get("host")); + assertEquals(8080, cmd.configOne.get("port")); + assertEquals(2, cmd.configOne.size()); + + assertNotNull(cmd.configTwo); + assertEquals(30000L, cmd.configTwo.get("timeout")); + assertEquals(1, cmd.configTwo.size()); + + verify(config1, config2, configAdmin, registry); + } + + @Test + public void testConfigInjectionWithConfigAdminException() throws Exception { + ConfigurationAdmin configAdmin = createMock(ConfigurationAdmin.class); + expect(configAdmin.getConfiguration("my.pid", "?")).andThrow(new java.io.IOException("config error")); + replay(configAdmin); + + Registry registry = createMock(Registry.class); + expect(registry.getService(ConfigurationAdmin.class)).andReturn(configAdmin); + replay(registry); + + ManagerImpl manager = new ManagerImpl(registry, registry, true); + SingleConfigCommand cmd = manager.instantiate(SingleConfigCommand.class, registry); + + // Should get empty map when ConfigAdmin throws + assertNotNull(cmd.config); + assertTrue(cmd.config.isEmpty()); + + verify(configAdmin, registry); + } + + @Test + public void testConfigInjectionFallsBackToDependencies() throws Exception { + Hashtable props = new Hashtable<>(); + props.put("key", "value"); + + Configuration configuration = createMock(Configuration.class); + expect(configuration.getProperties()).andReturn(props); + replay(configuration); + + ConfigurationAdmin configAdmin = createMock(ConfigurationAdmin.class); + expect(configAdmin.getConfiguration("my.pid", "?")).andReturn(configuration); + replay(configAdmin); + + // Local registry returns null, dependencies registry has ConfigAdmin + Registry localRegistry = createMock(Registry.class); + expect(localRegistry.getService(ConfigurationAdmin.class)).andReturn(null); + replay(localRegistry); + + Registry dependenciesRegistry = createMock(Registry.class); + expect(dependenciesRegistry.getService(ConfigurationAdmin.class)).andReturn(configAdmin); + replay(dependenciesRegistry); + + ManagerImpl manager = new ManagerImpl(dependenciesRegistry, dependenciesRegistry, true); + SingleConfigCommand cmd = manager.instantiate(SingleConfigCommand.class, localRegistry); + + assertNotNull(cmd.config); + assertEquals("value", cmd.config.get("key")); + + verify(configuration, configAdmin, localRegistry, dependenciesRegistry); + } +} From c8afe72182d74e3f7860fa023dc8ad1bbde51978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JB=20Onofr=C3=A9?= Date: Tue, 14 Apr 2026 18:08:20 +0200 Subject: [PATCH 2/2] fix(shell): enable autoFlush on console PrintStream (#2267) (#2518) System.out.print() without a newline was not displayed immediately on the Karaf console because the PrintStream wrapping the terminal output was created without autoFlush. Enable autoFlush on the local console and SSH command PrintStreams, consistent with ShellFactoryImpl. --- .../karaf/shell/impl/console/osgi/LocalConsoleManager.java | 2 +- .../src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java b/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java index 440e05afea8..3cf7b175b5e 100644 --- a/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java +++ b/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java @@ -77,7 +77,7 @@ public void start() throws Exception { final Subject subject = createLocalKarafSubject(); this.session = JaasHelper.doAs(subject, (PrivilegedAction) () -> { String encoding = getEncoding(); - PrintStream pout = new PrintStream(terminal.output()) { + PrintStream pout = new PrintStream(terminal.output(), true, Charset.forName(encoding)) { @Override public void close() { // do nothing diff --git a/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java b/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java index 39d0adcd19c..0023522ed4b 100644 --- a/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java +++ b/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/ShellCommand.java @@ -100,7 +100,7 @@ public void run() { commandThread = Thread.currentThread(); int exitStatus = 0; try { - session = sessionFactory.create(in, new PrintStream(out), new PrintStream(err)); + session = sessionFactory.create(in, new PrintStream(out, true), new PrintStream(err, true)); for (Map.Entry e : env.getEnv().entrySet()) { session.put(e.getKey(), e.getValue()); }