2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.automation.jrubyscripting.internal;
16 import java.nio.file.Path;
17 import java.nio.file.Paths;
18 import java.util.Collections;
19 import java.util.List;
21 import java.util.Optional;
22 import java.util.stream.Collectors;
23 import java.util.stream.Stream;
25 import javax.script.ScriptEngine;
26 import javax.script.ScriptEngineFactory;
27 import javax.script.ScriptException;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.core.OpenHAB;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
36 * Processes JRuby Configuration Parameters.
38 * @author Brian O'Connell - Initial contribution
39 * @author Jimmy Tanagra - Add $LOAD_PATH, require injection
42 public class JRubyScriptEngineConfiguration {
44 private final Logger logger = LoggerFactory.getLogger(JRubyScriptEngineConfiguration.class);
46 private static final Path DEFAULT_GEM_HOME = Paths.get(OpenHAB.getConfigFolder(), "scripts", "lib", "ruby",
49 private static final Path DEFAULT_RUBYLIB = Paths.get(OpenHAB.getConfigFolder(), "automation", "lib", "ruby");
51 private static final String GEM_HOME = "gem_home";
52 private static final String RUBYLIB = "rubylib";
53 private static final String GEMS = "gems";
54 private static final String REQUIRE = "require";
55 private static final String CHECK_UPDATE = "check_update";
57 // Map of configuration parameters
58 private static final Map<String, OptionalConfigurationElement> CONFIGURATION_PARAMETERS = Map.ofEntries(
59 Map.entry("local_context",
60 new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.SYSTEM_PROPERTY)
61 .mappedTo("org.jruby.embed.localcontext.scope").defaultValue("singlethread").build()),
63 Map.entry("local_variable",
64 new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.SYSTEM_PROPERTY)
65 .mappedTo("org.jruby.embed.localvariable.behavior").defaultValue("transient").build()),
68 new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT)
69 .mappedTo("GEM_HOME").defaultValue(DEFAULT_GEM_HOME.toString()).build()),
72 new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT)
73 .mappedTo("RUBYLIB").defaultValue(DEFAULT_RUBYLIB.toString()).build()),
75 Map.entry(GEMS, new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.GEM).build()),
78 new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.REQUIRE).build()),
80 Map.entry(CHECK_UPDATE,
81 new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.CHECK_UPDATE).build()));
83 private static final Map<OptionalConfigurationElement.Type, List<OptionalConfigurationElement>> CONFIGURATION_TYPE_MAP = CONFIGURATION_PARAMETERS
84 .values().stream().collect(Collectors.groupingBy(v -> v.type));
87 * Update configuration
89 * @param config Configuration parameters to apply to ScriptEngine
90 * @param factory ScriptEngineFactory to configure
92 void update(Map<String, Object> config, ScriptEngineFactory factory) {
93 logger.trace("JRuby Script Engine Configuration: {}", config);
94 config.forEach(this::processConfigValue);
95 configureScriptEngine(factory);
99 * Apply configuration key/value to known configuration parameters
101 * @param key Configuration key
102 * @param value Configuration value
104 private void processConfigValue(String key, Object value) {
105 OptionalConfigurationElement configurationElement = CONFIGURATION_PARAMETERS.get(key);
106 if (configurationElement != null) {
107 configurationElement.setValue(value.toString());
109 logger.debug("Ignoring unexpected configuration key: {}", key);
114 * Configure the ScriptEngine
116 * @param factory Script Engine to configure
118 void configureScriptEngine(ScriptEngineFactory factory) {
119 configureSystemProperties();
121 ScriptEngine engine = factory.getScriptEngine();
122 configureRubyEnvironment(engine);
123 configureGems(engine);
127 * Makes Gem home directory if it does not exist
129 private void ensureGemHomeExists() {
130 OptionalConfigurationElement gemHomeConfigElement = CONFIGURATION_PARAMETERS.get(GEM_HOME);
131 if (gemHomeConfigElement == null) {
134 Optional<String> gemHome = gemHomeConfigElement.getValue();
135 if (gemHome.isPresent()) {
136 File gemHomeDirectory = new File(gemHome.get());
137 if (!gemHomeDirectory.exists()) {
138 logger.debug("gem_home directory does not exist, creating");
139 if (!gemHomeDirectory.mkdirs()) {
140 logger.warn("Error creating gem_home directory");
144 logger.debug("Gem install requested without gem_home specified, not ensuring gem_home path exists");
149 * Install a gems in ScriptEngine
151 * @param engine Engine to install gems
153 private synchronized void configureGems(ScriptEngine engine) {
154 ensureGemHomeExists();
156 OptionalConfigurationElement gemsConfigElement = CONFIGURATION_PARAMETERS.get(GEMS);
157 if (gemsConfigElement == null || !gemsConfigElement.getValue().isPresent()) {
160 boolean checkUpdate = true;
161 OptionalConfigurationElement updateConfigElement = CONFIGURATION_PARAMETERS.get(CHECK_UPDATE);
162 if (updateConfigElement != null && updateConfigElement.getValue().isPresent()) {
163 checkUpdate = updateConfigElement.getValue().get().equals("true");
166 String[] gems = gemsConfigElement.getValue().get().split(",");
167 // Set update_native_env_enabled to false so that bundler doesn't leak
168 // into other script engines
169 String gemCommand = "require 'jruby'\nJRuby.runtime.instance_config.update_native_env_enabled = false\nrequire 'bundler/inline'\nrequire 'openssl'\n\ngemfile("
170 + checkUpdate + ") do\n" + " source 'https://rubygems.org/'\n";
172 for (String gem : gems) {
174 String[] versions = {};
175 if (gem.contains("=")) {
176 String[] gemParts = gem.split("=", 2);
177 gem = gemParts[0].trim();
178 versions = gemParts[1].split(";");
185 gemCommand += " gem '" + gem + "'";
186 for (String version : versions) {
187 version = version.trim();
188 if (!version.isEmpty()) {
189 gemCommand += ", '" + version + "'";
192 gemCommand += ", require: false\n";
195 if (validGems == 0) {
198 gemCommand += "end\n";
201 logger.debug("Installing Gems");
202 logger.trace("Gem install code:\n{}", gemCommand);
203 engine.eval(gemCommand);
204 } catch (ScriptException e) {
205 logger.warn("Error installing Gems", unwrap(e));
210 * Execute ruby require statement in the ScriptEngine
212 * @param engine Engine to insert the require statements
214 public void injectRequire(ScriptEngine engine) {
215 OptionalConfigurationElement requireConfigElement = CONFIGURATION_PARAMETERS.get(REQUIRE);
216 if (requireConfigElement == null || !requireConfigElement.getValue().isPresent()) {
220 Stream.of(requireConfigElement.getValue().get().split(",")).map(s -> s.trim()).filter(s -> !s.isEmpty())
222 final String requireStatement = String.format("require '%s'", script);
224 logger.trace("Injecting require statement: {}", requireStatement);
225 engine.eval(requireStatement);
226 } catch (ScriptException e) {
227 logger.warn("Error evaluating `{}`", requireStatement, unwrap(e));
233 * Configure the optional elements of the Ruby Environment
235 * @param engine Engine in which to configure environment
237 public ScriptEngine configureRubyEnvironment(ScriptEngine engine) {
238 getConfigurationElements(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT).forEach(configElement -> {
239 final String environmentSetting = String.format("ENV['%s']='%s'", configElement.mappedTo().get(),
240 configElement.getValue().get());
242 logger.trace("Setting Ruby environment with code: {} ", environmentSetting);
243 engine.eval(environmentSetting);
244 } catch (ScriptException e) {
245 logger.warn("Error setting Ruby environment", unwrap(e));
249 configureRubyLib(engine);
254 * Split up and insert ENV['RUBYLIB'] into Ruby's $LOAD_PATH
255 * This needs to be called after ENV['RUBYLIB'] has been set by configureRubyEnvironment
257 * @param engine Engine in which to configure environment
259 private void configureRubyLib(ScriptEngine engine) {
260 OptionalConfigurationElement rubyLibConfigElement = CONFIGURATION_PARAMETERS.get(RUBYLIB);
261 if (rubyLibConfigElement == null) {
265 Optional<String> rubyLib = rubyLibConfigElement.getValue();
266 if (rubyLib.isPresent() && !rubyLib.get().trim().isEmpty()) {
267 final String code = "$LOAD_PATH.unshift *ENV['RUBYLIB']&.split(File::PATH_SEPARATOR)" + //
268 "&.reject(&:empty?)" + //
269 "&.reject { |path| $LOAD_PATH.include?(path) }"; //
272 } catch (ScriptException exception) {
273 logger.warn("Error setting $LOAD_PATH from RUBYLIB='{}'", rubyLib.get(), unwrap(exception));
279 * Configure system properties
281 * @param optionalConfigurationElements Optional system properties to configure
283 private void configureSystemProperties() {
284 getConfigurationElements(OptionalConfigurationElement.Type.SYSTEM_PROPERTY).forEach(configElement -> {
285 String systemProperty = configElement.mappedTo().get();
286 String propertyValue = configElement.getValue().get();
287 logger.trace("Setting system property ({}) to ({})", systemProperty, propertyValue);
288 System.setProperty(systemProperty, propertyValue);
292 private Stream<OptionalConfigurationElement> getConfigurationElements(
293 OptionalConfigurationElement.Type configurationType) {
294 return CONFIGURATION_TYPE_MAP
295 .getOrDefault(configurationType, Collections.<OptionalConfigurationElement> emptyList()).stream()
296 .filter(element -> element.getValue().isPresent());
300 * Unwraps the cause of an exception, if it has one.
302 * Since a user cares about the _Ruby_ stack trace of the throwable, not
303 * the details of where openHAB called it.
305 private Throwable unwrap(Throwable e) {
306 Throwable cause = e.getCause();
314 * Inner static companion class for configuration elements
316 private static class OptionalConfigurationElement {
318 private final Optional<String> defaultValue;
319 private final Optional<String> mappedTo;
320 private final Type type;
321 private Optional<String> value;
323 private OptionalConfigurationElement(Type type, @Nullable String mappedTo, @Nullable String defaultValue) {
325 this.defaultValue = Optional.ofNullable(defaultValue);
326 this.mappedTo = Optional.ofNullable(mappedTo);
327 value = Optional.empty();
330 private Optional<String> getValue() {
331 return value.or(() -> defaultValue);
334 private void setValue(String value) {
335 this.value = Optional.of(value);
338 private Optional<String> mappedTo() {
350 private static class Builder {
351 private final Type type;
352 private @Nullable String defaultValue = null;
353 private @Nullable String mappedTo = null;
355 private Builder(Type type) {
359 private Builder mappedTo(String mappedTo) {
360 this.mappedTo = mappedTo;
364 private Builder defaultValue(String value) {
365 this.defaultValue = value;
369 private OptionalConfigurationElement build() {
370 return new OptionalConfigurationElement(type, mappedTo, defaultValue);