]> git.basschouten.com Git - openhab-addons.git/blob
b490aa5edc17791668546674c6da5db0e3d530f7
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.automation.jrubyscripting.internal;
14
15 import java.io.File;
16 import java.nio.file.Paths;
17 import java.util.List;
18 import java.util.Map;
19 import java.util.Objects;
20 import java.util.Optional;
21 import java.util.stream.Stream;
22
23 import javax.script.ScriptContext;
24 import javax.script.ScriptEngine;
25 import javax.script.ScriptEngineFactory;
26 import javax.script.ScriptException;
27
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.jruby.runtime.Constants;
31 import org.openhab.core.OpenHAB;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
34
35 /**
36  * Processes JRuby Configuration Parameters.
37  *
38  * @author Brian O'Connell - Initial contribution
39  * @author Jimmy Tanagra - Add $LOAD_PATH, require injection
40  */
41 @NonNullByDefault
42 public class JRubyScriptEngineConfiguration {
43
44     private final Logger logger = LoggerFactory.getLogger(JRubyScriptEngineConfiguration.class);
45
46     private static final String RUBY_ENGINE_REPLACEMENT = "{RUBY_ENGINE}";
47     private static final String RUBY_ENGINE_VERSION_REPLACEMENT = "{RUBY_ENGINE_VERSION}";
48     private static final String RUBY_VERSION_REPLACEMENT = "{RUBY_VERSION}";
49     private static final List<String> REPLACEMENTS = List.of(RUBY_ENGINE_REPLACEMENT, RUBY_ENGINE_VERSION_REPLACEMENT,
50             RUBY_VERSION_REPLACEMENT);
51
52     private static final String DEFAULT_GEM_HOME = Paths
53             .get(OpenHAB.getConfigFolder(), "automation", "ruby", ".gem", RUBY_ENGINE_VERSION_REPLACEMENT).toString();
54     private static final String DEFAULT_RUBYLIB = Paths.get(OpenHAB.getConfigFolder(), "automation", "ruby", "lib")
55             .toString();
56
57     private static final String GEM_HOME_CONFIG_KEY = "gem_home";
58     private static final String RUBYLIB_CONFIG_KEY = "rubylib";
59     private static final String GEMS_CONFIG_KEY = "gems";
60     private static final String REQUIRE_CONFIG_KEY = "require";
61     private static final String CHECK_UPDATE_CONFIG_KEY = "check_update";
62
63     // Map of configuration parameters
64     private final Map<String, OptionalConfigurationElement> configurationParameters = Map.ofEntries(
65             Map.entry("local_context",
66                     new OptionalConfigurationElement(OptionalConfigurationElement.Type.SYSTEM_PROPERTY, "singlethread",
67                             "org.jruby.embed.localcontext.scope")),
68
69             Map.entry("local_variable",
70                     new OptionalConfigurationElement(OptionalConfigurationElement.Type.SYSTEM_PROPERTY, "transient",
71                             "org.jruby.embed.localvariable.behavior")),
72
73             Map.entry(GEM_HOME_CONFIG_KEY,
74                     new OptionalConfigurationElement(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT,
75                             DEFAULT_GEM_HOME, "GEM_HOME")),
76
77             Map.entry(RUBYLIB_CONFIG_KEY,
78                     new OptionalConfigurationElement(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT,
79                             DEFAULT_RUBYLIB, "RUBYLIB")),
80
81             Map.entry(GEMS_CONFIG_KEY, new OptionalConfigurationElement("")),
82
83             Map.entry(REQUIRE_CONFIG_KEY, new OptionalConfigurationElement("")),
84
85             Map.entry(CHECK_UPDATE_CONFIG_KEY, new OptionalConfigurationElement("true")));
86
87     /**
88      * Update configuration
89      * 
90      * @param config Configuration parameters to apply to ScriptEngine
91      * @param factory ScriptEngineFactory to configure
92      */
93     void update(Map<String, Object> config, ScriptEngineFactory factory) {
94         logger.trace("JRuby Script Engine Configuration: {}", config);
95         configurationParameters.forEach((k, v) -> v.clearValue());
96         config.forEach(this::processConfigValue);
97
98         configureSystemProperties();
99
100         ScriptEngine engine = factory.getScriptEngine();
101         configureRubyEnvironment(engine);
102         configureGems(engine);
103     }
104
105     /**
106      * Apply configuration key/value to known configuration parameters
107      * 
108      * @param key Configuration key
109      * @param value Configuration value
110      */
111     private void processConfigValue(String key, Object value) {
112         OptionalConfigurationElement configurationElement = configurationParameters.get(key);
113         if (configurationElement != null) {
114             configurationElement.setValue(value.toString().trim());
115         } else {
116             logger.debug("Ignoring unexpected configuration key: {}", key);
117         }
118     }
119
120     /**
121      * Gets a single configuration element.
122      */
123     private String get(String key) {
124         OptionalConfigurationElement configElement = configurationParameters.get(key);
125
126         return Objects.requireNonNull(configElement).getValue();
127     }
128
129     /**
130      * Gets the concrete gem home to install gems into for this version of JRuby.
131      * 
132      * {RUBY_ENGINE} and {RUBY_VERSION} are replaced with their current actual values.
133      */
134     public String getSpecificGemHome() {
135         String gemHome = get(GEM_HOME_CONFIG_KEY);
136         if (gemHome.isEmpty()) {
137             return gemHome;
138         }
139
140         gemHome = gemHome.replace(RUBY_ENGINE_REPLACEMENT, Constants.ENGINE);
141         gemHome = gemHome.replace(RUBY_ENGINE_VERSION_REPLACEMENT, Constants.VERSION);
142         gemHome = gemHome.replace(RUBY_VERSION_REPLACEMENT, Constants.RUBY_VERSION);
143         return new File(gemHome).toString();
144     }
145
146     /**
147      * Get the base for all possible gem homes.
148      * 
149      * If the configured gem home contains {RUBY_ENGINE} or {RUBY_VERSION},
150      * the path is cut off at that point. This means a single configuration
151      * value will include the gem homes for all parallel-installed ruby
152      * versions.
153      * 
154      */
155     public String getGemHomeBase() {
156         String gemHome = get(GEM_HOME_CONFIG_KEY);
157
158         for (String replacement : REPLACEMENTS) {
159             int loc = gemHome.indexOf(replacement);
160             if (loc != -1) {
161                 gemHome = gemHome.substring(0, loc);
162             }
163         }
164         return new File(gemHome).toString();
165     }
166
167     /**
168      * Makes Gem home directory if it does not exist
169      */
170     private boolean ensureGemHomeExists(String gemHome) {
171         File gemHomeDirectory = new File(gemHome);
172         if (!gemHomeDirectory.exists()) {
173             logger.debug("gem_home directory does not exist, creating");
174             if (!gemHomeDirectory.mkdirs()) {
175                 logger.warn("Error creating gem_home directory");
176                 return false;
177             }
178         }
179         return true;
180     }
181
182     /**
183      * Install a gems in ScriptEngine
184      * 
185      * @param engine Engine to install gems
186      */
187     private synchronized void configureGems(ScriptEngine engine) {
188         String gems = get(GEMS_CONFIG_KEY);
189         if (gems.isEmpty()) {
190             return;
191         }
192
193         String gemHome = getSpecificGemHome();
194         if (gemHome.isEmpty()) {
195             logger.warn("Gem install requested with empty gem_home, not installing gems.");
196             return;
197         }
198
199         if (!ensureGemHomeExists(gemHome)) {
200             return;
201         }
202
203         boolean checkUpdate = "true".equals(get(CHECK_UPDATE_CONFIG_KEY));
204
205         String[] gemsArray = gems.split(",");
206         // Set update_native_env_enabled to false so that bundler doesn't leak
207         // into other script engines
208         String gemCommand = "require 'jruby'\nJRuby.runtime.instance_config.update_native_env_enabled = false\nrequire 'bundler/inline'\nrequire 'openssl'\n\ngemfile("
209                 + checkUpdate + ") do\n" + "  source 'https://rubygems.org/'\n";
210         int validGems = 0;
211         for (String gem : gemsArray) {
212             gem = gem.trim();
213             String[] versions = {};
214             if (gem.contains("=")) {
215                 String[] gemParts = gem.split("=", 2);
216                 gem = gemParts[0].trim();
217                 versions = gemParts[1].split(";");
218             }
219
220             if (gem.isEmpty()) {
221                 continue;
222             }
223
224             gemCommand += "  gem '" + gem + "'";
225             for (String version : versions) {
226                 version = version.trim();
227                 if (!version.isEmpty()) {
228                     gemCommand += ", '" + version + "'";
229                 }
230             }
231             gemCommand += ", require: false\n";
232             validGems += 1;
233         }
234         if (validGems == 0) {
235             return;
236         }
237         gemCommand += "end\n";
238
239         try {
240             logger.debug("Installing Gems");
241             logger.trace("Gem install code:\n{}", gemCommand);
242             engine.eval(gemCommand);
243         } catch (ScriptException e) {
244             logger.warn("Error installing Gems", unwrap(e));
245         }
246     }
247
248     /**
249      * Execute ruby require statement in the ScriptEngine
250      * 
251      * @param engine Engine to insert the require statements
252      */
253     public void injectRequire(ScriptEngine engine) {
254         String requires = get(REQUIRE_CONFIG_KEY);
255
256         if (requires.isEmpty()) {
257             return;
258         }
259
260         Stream.of(requires.split(",")).map(s -> s.trim()).filter(s -> !s.isEmpty()).forEach(script -> {
261             final String requireStatement = String.format("require '%s'", script);
262             try {
263                 logger.trace("Injecting require statement: {}", requireStatement);
264                 engine.eval(requireStatement);
265             } catch (ScriptException e) {
266                 logger.warn("Error evaluating `{}`", requireStatement, unwrap(e));
267             }
268         });
269     }
270
271     /**
272      * Configure the optional elements of the Ruby Environment
273      * 
274      * @param engine Engine in which to configure environment
275      */
276     public void configureRubyEnvironment(ScriptEngine scriptEngine) {
277         getConfigurationElements(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT).forEach(configElement -> {
278             String value;
279             if ("GEM_HOME".equals(configElement.mappedTo().get())) {
280                 // this value has to be post-processed to handle replacements.
281                 value = getSpecificGemHome();
282             } else {
283                 value = configElement.getValue();
284             }
285             scriptEngine.put("__key", configElement.mappedTo().get());
286             scriptEngine.put("__value", value);
287             logger.trace("Setting Ruby environment ENV['{}''] = '{}'", configElement.mappedTo().get(), value);
288
289             try {
290                 scriptEngine.eval("ENV[__key] = __value");
291             } catch (ScriptException e) {
292                 logger.warn("Error setting Ruby environment", unwrap(e));
293             }
294             // clean up our temporary variables
295             scriptEngine.getBindings(ScriptContext.ENGINE_SCOPE).remove("__key");
296             scriptEngine.getBindings(ScriptContext.ENGINE_SCOPE).remove("__value");
297         });
298
299         configureRubyLib(scriptEngine);
300     }
301
302     /**
303      * Split up and insert ENV['RUBYLIB'] into Ruby's $LOAD_PATH
304      * This needs to be called after ENV['RUBYLIB'] has been set by configureRubyEnvironment
305      * 
306      * @param engine Engine in which to configure environment
307      */
308     private void configureRubyLib(ScriptEngine engine) {
309         String rubyLib = get(RUBYLIB_CONFIG_KEY);
310         if (!rubyLib.isEmpty()) {
311             final String code = "$LOAD_PATH.unshift *ENV['RUBYLIB']&.split(File::PATH_SEPARATOR)" + //
312                     "&.reject(&:empty?)" + //
313                     "&.reject { |path| $LOAD_PATH.include?(path) }"; //
314             try {
315                 engine.eval(code);
316             } catch (ScriptException exception) {
317                 logger.warn("Error setting $LOAD_PATH from RUBYLIB='{}'", rubyLib, unwrap(exception));
318             }
319         }
320     }
321
322     public List<String> getRubyLibPaths() {
323         String rubyLib = get(RUBYLIB_CONFIG_KEY);
324         if (rubyLib.isEmpty()) {
325             return List.of();
326         }
327         return List.of(rubyLib.split(File.pathSeparator));
328     }
329
330     /**
331      * Configure system properties
332      * 
333      * @param optionalConfigurationElements Optional system properties to configure
334      */
335     private void configureSystemProperties() {
336         getConfigurationElements(OptionalConfigurationElement.Type.SYSTEM_PROPERTY).forEach(configElement -> {
337             String systemProperty = configElement.mappedTo().get();
338             String propertyValue = configElement.getValue();
339             logger.trace("Setting system property ({}) to ({})", systemProperty, propertyValue);
340             System.setProperty(systemProperty, propertyValue);
341         });
342     }
343
344     private Stream<OptionalConfigurationElement> getConfigurationElements(OptionalConfigurationElement.Type type) {
345         return configurationParameters.values().stream().filter(element -> element.type.equals(type));
346     }
347
348     /**
349      * Unwraps the cause of an exception, if it has one.
350      *
351      * Since a user cares about the _Ruby_ stack trace of the throwable, not
352      * the details of where openHAB called it.
353      */
354     private Throwable unwrap(Throwable e) {
355         Throwable cause = e.getCause();
356         if (cause != null) {
357             return cause;
358         }
359         return e;
360     }
361
362     /**
363      * Inner static companion class for configuration elements
364      */
365     private static class OptionalConfigurationElement {
366         private enum Type {
367             SYSTEM_PROPERTY,
368             RUBY_ENVIRONMENT,
369             OTHER
370         }
371
372         private final String defaultValue;
373         private final Optional<String> mappedTo;
374         private final Type type;
375         private Optional<String> value;
376
377         private OptionalConfigurationElement(String defaultValue) {
378             this(Type.OTHER, defaultValue, null);
379         }
380
381         private OptionalConfigurationElement(Type type, String defaultValue, @Nullable String mappedTo) {
382             this.type = type;
383             this.defaultValue = defaultValue;
384             this.mappedTo = Optional.ofNullable(mappedTo);
385             value = Optional.empty();
386         }
387
388         private String getValue() {
389             return value.orElse(defaultValue);
390         }
391
392         private void setValue(String value) {
393             this.value = Optional.of(value);
394         }
395
396         private void clearValue() {
397             this.value = Optional.empty();
398         }
399
400         private Optional<String> mappedTo() {
401             return mappedTo;
402         }
403     }
404 }