]> git.basschouten.com Git - openhab-addons.git/blob
f5263329ed60acac999296ab62d026d78d5eb630
[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.Path;
17 import java.nio.file.Paths;
18 import java.util.Collections;
19 import java.util.List;
20 import java.util.Map;
21 import java.util.Optional;
22 import java.util.stream.Collectors;
23 import java.util.stream.Stream;
24
25 import javax.script.ScriptEngine;
26 import javax.script.ScriptEngineFactory;
27 import javax.script.ScriptException;
28
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;
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 Path DEFAULT_GEM_HOME = Paths.get(OpenHAB.getConfigFolder(), "scripts", "lib", "ruby",
47             "gem_home");
48
49     private static final Path DEFAULT_RUBYLIB = Paths.get(OpenHAB.getConfigFolder(), "automation", "lib", "ruby");
50
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";
56
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()),
62
63             Map.entry("local_variable",
64                     new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.SYSTEM_PROPERTY)
65                             .mappedTo("org.jruby.embed.localvariable.behavior").defaultValue("transient").build()),
66
67             Map.entry(GEM_HOME,
68                     new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT)
69                             .mappedTo("GEM_HOME").defaultValue(DEFAULT_GEM_HOME.toString()).build()),
70
71             Map.entry(RUBYLIB,
72                     new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT)
73                             .mappedTo("RUBYLIB").defaultValue(DEFAULT_RUBYLIB.toString()).build()),
74
75             Map.entry(GEMS, new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.GEM).build()),
76
77             Map.entry(REQUIRE,
78                     new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.REQUIRE).build()),
79
80             Map.entry(CHECK_UPDATE,
81                     new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.CHECK_UPDATE).build()));
82
83     private static final Map<OptionalConfigurationElement.Type, List<OptionalConfigurationElement>> CONFIGURATION_TYPE_MAP = CONFIGURATION_PARAMETERS
84             .values().stream().collect(Collectors.groupingBy(v -> v.type));
85
86     /**
87      * Update configuration
88      * 
89      * @param config Configuration parameters to apply to ScriptEngine
90      * @param factory ScriptEngineFactory to configure
91      */
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);
96     }
97
98     /**
99      * Apply configuration key/value to known configuration parameters
100      * 
101      * @param key Configuration key
102      * @param value Configuration value
103      */
104     private void processConfigValue(String key, Object value) {
105         OptionalConfigurationElement configurationElement = CONFIGURATION_PARAMETERS.get(key);
106         if (configurationElement != null) {
107             configurationElement.setValue(value.toString());
108         } else {
109             logger.debug("Ignoring unexpected configuration key: {}", key);
110         }
111     }
112
113     /**
114      * Configure the ScriptEngine
115      * 
116      * @param factory Script Engine to configure
117      */
118     void configureScriptEngine(ScriptEngineFactory factory) {
119         configureSystemProperties();
120
121         ScriptEngine engine = factory.getScriptEngine();
122         configureRubyEnvironment(engine);
123         configureGems(engine);
124     }
125
126     /**
127      * Makes Gem home directory if it does not exist
128      */
129     private void ensureGemHomeExists() {
130         OptionalConfigurationElement gemHomeConfigElement = CONFIGURATION_PARAMETERS.get(GEM_HOME);
131         if (gemHomeConfigElement == null) {
132             return;
133         }
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");
141                 }
142             }
143         } else {
144             logger.debug("Gem install requested without gem_home specified, not ensuring gem_home path exists");
145         }
146     }
147
148     /**
149      * Install a gems in ScriptEngine
150      * 
151      * @param engine Engine to install gems
152      */
153     private synchronized void configureGems(ScriptEngine engine) {
154         ensureGemHomeExists();
155
156         OptionalConfigurationElement gemsConfigElement = CONFIGURATION_PARAMETERS.get(GEMS);
157         if (gemsConfigElement == null || !gemsConfigElement.getValue().isPresent()) {
158             return;
159         }
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");
164         }
165
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";
171         int validGems = 0;
172         for (String gem : gems) {
173             gem = gem.trim();
174             String[] versions = {};
175             if (gem.contains("=")) {
176                 String[] gemParts = gem.split("=", 2);
177                 gem = gemParts[0].trim();
178                 versions = gemParts[1].split(";");
179             }
180
181             if (gem.isEmpty()) {
182                 continue;
183             }
184
185             gemCommand += "  gem '" + gem + "'";
186             for (String version : versions) {
187                 version = version.trim();
188                 if (!version.isEmpty()) {
189                     gemCommand += ", '" + version + "'";
190                 }
191             }
192             gemCommand += ", require: false\n";
193             validGems += 1;
194         }
195         if (validGems == 0) {
196             return;
197         }
198         gemCommand += "end\n";
199
200         try {
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));
206         }
207     }
208
209     /**
210      * Execute ruby require statement in the ScriptEngine
211      * 
212      * @param engine Engine to insert the require statements
213      */
214     public void injectRequire(ScriptEngine engine) {
215         OptionalConfigurationElement requireConfigElement = CONFIGURATION_PARAMETERS.get(REQUIRE);
216         if (requireConfigElement == null || !requireConfigElement.getValue().isPresent()) {
217             return;
218         }
219
220         Stream.of(requireConfigElement.getValue().get().split(",")).map(s -> s.trim()).filter(s -> !s.isEmpty())
221                 .forEach(script -> {
222                     final String requireStatement = String.format("require '%s'", script);
223                     try {
224                         logger.trace("Injecting require statement: {}", requireStatement);
225                         engine.eval(requireStatement);
226                     } catch (ScriptException e) {
227                         logger.warn("Error evaluating `{}`", requireStatement, unwrap(e));
228                     }
229                 });
230     }
231
232     /**
233      * Configure the optional elements of the Ruby Environment
234      * 
235      * @param engine Engine in which to configure environment
236      */
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());
241             try {
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));
246             }
247         });
248
249         configureRubyLib(engine);
250         return engine;
251     }
252
253     /**
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
256      * 
257      * @param engine Engine in which to configure environment
258      */
259     private void configureRubyLib(ScriptEngine engine) {
260         OptionalConfigurationElement rubyLibConfigElement = CONFIGURATION_PARAMETERS.get(RUBYLIB);
261         if (rubyLibConfigElement == null) {
262             return;
263         }
264
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) }"; //
270             try {
271                 engine.eval(code);
272             } catch (ScriptException exception) {
273                 logger.warn("Error setting $LOAD_PATH from RUBYLIB='{}'", rubyLib.get(), unwrap(exception));
274             }
275         }
276     }
277
278     /**
279      * Configure system properties
280      * 
281      * @param optionalConfigurationElements Optional system properties to configure
282      */
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);
289         });
290     }
291
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());
297     }
298
299     /**
300      * Unwraps the cause of an exception, if it has one.
301      *
302      * Since a user cares about the _Ruby_ stack trace of the throwable, not
303      * the details of where openHAB called it.
304      */
305     private Throwable unwrap(Throwable e) {
306         Throwable cause = e.getCause();
307         if (cause != null) {
308             return cause;
309         }
310         return e;
311     }
312
313     /**
314      * Inner static companion class for configuration elements
315      */
316     private static class OptionalConfigurationElement {
317
318         private final Optional<String> defaultValue;
319         private final Optional<String> mappedTo;
320         private final Type type;
321         private Optional<String> value;
322
323         private OptionalConfigurationElement(Type type, @Nullable String mappedTo, @Nullable String defaultValue) {
324             this.type = type;
325             this.defaultValue = Optional.ofNullable(defaultValue);
326             this.mappedTo = Optional.ofNullable(mappedTo);
327             value = Optional.empty();
328         }
329
330         private Optional<String> getValue() {
331             return value.or(() -> defaultValue);
332         }
333
334         private void setValue(String value) {
335             this.value = Optional.of(value);
336         }
337
338         private Optional<String> mappedTo() {
339             return mappedTo;
340         }
341
342         private enum Type {
343             SYSTEM_PROPERTY,
344             RUBY_ENVIRONMENT,
345             GEM,
346             REQUIRE,
347             CHECK_UPDATE,
348         }
349
350         private static class Builder {
351             private final Type type;
352             private @Nullable String defaultValue = null;
353             private @Nullable String mappedTo = null;
354
355             private Builder(Type type) {
356                 this.type = type;
357             }
358
359             private Builder mappedTo(String mappedTo) {
360                 this.mappedTo = mappedTo;
361                 return this;
362             }
363
364             private Builder defaultValue(String value) {
365                 this.defaultValue = value;
366                 return this;
367             }
368
369             private OptionalConfigurationElement build() {
370                 return new OptionalConfigurationElement(type, mappedTo, defaultValue);
371             }
372         }
373     }
374 }