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