001/*
002 * VM-Operator
003 * Copyright (C) 2023 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.vmoperator.manager;
020
021import freemarker.template.TemplateMethodModelEx;
022import freemarker.template.TemplateModelException;
023import java.io.File;
024import java.io.IOException;
025import java.io.InputStream;
026import java.net.InetSocketAddress;
027import java.net.URI;
028import java.net.URISyntaxException;
029import java.nio.file.Files;
030import java.util.Arrays;
031import java.util.Collections;
032import java.util.List;
033import java.util.Map;
034import java.util.Optional;
035import java.util.logging.Level;
036import java.util.logging.LogManager;
037import java.util.logging.Logger;
038import org.apache.commons.cli.CommandLine;
039import org.apache.commons.cli.CommandLineParser;
040import org.apache.commons.cli.DefaultParser;
041import org.apache.commons.cli.Option;
042import org.apache.commons.cli.Options;
043import static org.jdrupes.vmoperator.manager.Constants.VM_OP_NAME;
044import org.jdrupes.vmoperator.manager.events.Exit;
045import org.jdrupes.vmoperator.util.FsdUtils;
046import org.jgrapes.core.Channel;
047import org.jgrapes.core.Component;
048import org.jgrapes.core.Components;
049import org.jgrapes.core.NamedChannel;
050import org.jgrapes.core.annotation.Handler;
051import org.jgrapes.core.events.HandlingError;
052import org.jgrapes.core.events.Stop;
053import org.jgrapes.http.HttpConnector;
054import org.jgrapes.http.HttpServer;
055import org.jgrapes.http.InMemorySessionManager;
056import org.jgrapes.http.LanguageSelector;
057import org.jgrapes.http.events.Request;
058import org.jgrapes.io.NioDispatcher;
059import org.jgrapes.io.util.PermitsPool;
060import org.jgrapes.net.SocketConnector;
061import org.jgrapes.net.SocketServer;
062import org.jgrapes.net.SslCodec;
063import org.jgrapes.util.ComponentCollector;
064import org.jgrapes.util.FileSystemWatcher;
065import org.jgrapes.util.YamlConfigurationStore;
066import org.jgrapes.util.events.ConfigurationUpdate;
067import org.jgrapes.util.events.WatchFile;
068import org.jgrapes.webconlet.oidclogin.LoginConlet;
069import org.jgrapes.webconlet.oidclogin.OidcClient;
070import org.jgrapes.webconsole.base.BrowserLocalBackedKVStore;
071import org.jgrapes.webconsole.base.ConletComponentFactory;
072import org.jgrapes.webconsole.base.ConsoleWeblet;
073import org.jgrapes.webconsole.base.KVStoreBasedConsolePolicy;
074import org.jgrapes.webconsole.base.PageResourceProviderFactory;
075import org.jgrapes.webconsole.base.WebConsole;
076import org.jgrapes.webconsole.rbac.RoleConfigurator;
077import org.jgrapes.webconsole.rbac.RoleConletFilter;
078import org.jgrapes.webconsole.rbac.UserLogger;
079import org.jgrapes.webconsole.vuejs.VueJsConsoleWeblet;
080
081/**
082 * The application class.
083 */
084@SuppressWarnings({ "PMD.ExcessiveImports" })
085public class Manager extends Component {
086
087    private static String version;
088    private static Manager app;
089    private String clusterName;
090    private String namespace = "unknown";
091    private static int exitStatus;
092
093    /**
094     * Instantiates a new manager.
095     * @param cmdLine 
096     *
097     * @throws IOException Signals that an I/O exception has occurred.
098     * @throws URISyntaxException 
099     */
100    @SuppressWarnings({ "PMD.NcssCount",
101        "PMD.ConstructorCallsOverridableMethod" })
102    public Manager(CommandLine cmdLine) throws IOException, URISyntaxException {
103        super(new NamedChannel("manager"));
104        // Prepare component tree
105        attach(new NioDispatcher());
106        attach(new FileSystemWatcher(channel()));
107        attach(new Controller(channel()));
108
109        // Configuration store with file in /etc/opt (default)
110        File cfgFile = new File(cmdLine.getOptionValue('c',
111            "/etc/opt/" + VM_OP_NAME.replace("-", "") + "/config.yaml"));
112        logger.config(() -> "Using configuration from: " + cfgFile.getPath());
113        // Don't rely on night config to produce a good exception
114        // for this simple case
115        if (!Files.isReadable(cfgFile.toPath())) {
116            throw new IOException("Cannot read configuration file " + cfgFile);
117        }
118        attach(new YamlConfigurationStore(channel(), cfgFile, false));
119        fire(new WatchFile(cfgFile.toPath()), channel());
120
121        // Prepare GUI
122        Channel httpTransport = new NamedChannel("guiTransport");
123        attach(new SocketServer(httpTransport)
124            .setConnectionLimiter(new PermitsPool(300))
125            .setMinimalPurgeableTime(1000)
126            .setServerAddress(new InetSocketAddress(8080))
127            .setName("GuiSocketServer"));
128
129        // Channel for HTTP application layer
130        Channel httpChannel = new NamedChannel("guiHttp");
131
132        // Create network channels for client requests.
133        Channel requestChannel = attach(new SocketConnector(SELF));
134        Channel secReqChannel
135            = attach(new SslCodec(SELF, requestChannel, true));
136        // Support for making HTTP requests
137        attach(new HttpConnector(httpChannel, requestChannel,
138            secReqChannel));
139
140        // Create an HTTP server as converter between transport and application
141        // layer.
142        HttpServer guiHttpServer = attach(new HttpServer(httpChannel,
143            httpTransport, Request.In.Get.class, Request.In.Post.class));
144        guiHttpServer.setName("GuiHttpServer");
145
146        // Build HTTP application layer
147        guiHttpServer.attach(new InMemorySessionManager(httpChannel));
148        guiHttpServer.attach(new LanguageSelector(httpChannel));
149        URI rootUri;
150        try {
151            rootUri = new URI("/");
152        } catch (URISyntaxException e) {
153            // Cannot happen
154            return;
155        }
156        ConsoleWeblet consoleWeblet = guiHttpServer
157            .attach(new VueJsConsoleWeblet(httpChannel, SELF, rootUri) {
158                @Override
159                protected Map<String, Object> createConsoleBaseModel() {
160                    return augmentBaseModel(super.createConsoleBaseModel());
161                }
162            })
163            .prependClassTemplateLoader(getClass())
164            .prependResourceBundleProvider(getClass())
165            .prependConsoleResourceProvider(getClass());
166        consoleWeblet.setName("ConsoleWeblet");
167        WebConsole console = consoleWeblet.console();
168        console.attach(new BrowserLocalBackedKVStore(
169            console.channel(), consoleWeblet.prefix().getPath()));
170        console.attach(new KVStoreBasedConsolePolicy(console.channel()));
171        console.attach(new AvoidEmptyPolicy(console.channel()));
172        console.attach(new RoleConfigurator(console.channel()));
173        console.attach(new RoleConletFilter(console.channel()));
174        console.attach(new LoginConlet(console.channel()));
175        console.attach(new OidcClient(console.channel(), httpChannel,
176            httpChannel, new URI("/oauth/callback"), 1500));
177        console.attach(new UserLogger(console.channel()));
178
179        // Add all available page resource providers
180        console.attach(new ComponentCollector<>(
181            PageResourceProviderFactory.class, console.channel()));
182
183        // Add all available conlets
184        console.attach(new ComponentCollector<>(
185            ConletComponentFactory.class, console.channel(), type -> {
186                if (LoginConlet.class.getName().equals(type)) {
187                    // Explicitly added, see above
188                    return Collections.emptyList();
189                } else {
190                    return Arrays.asList(Collections.emptyMap());
191                }
192            }));
193    }
194
195    private Map<String, Object> augmentBaseModel(Map<String, Object> base) {
196        base.put("version", version);
197        base.put("clusterName", new TemplateMethodModelEx() {
198            @Override
199            public Object exec(@SuppressWarnings("rawtypes") List arguments)
200                    throws TemplateModelException {
201                return clusterName;
202            }
203        });
204        base.put("namespace", new TemplateMethodModelEx() {
205            @Override
206            public Object exec(@SuppressWarnings("rawtypes") List arguments)
207                    throws TemplateModelException {
208                return namespace;
209            }
210        });
211        return base;
212    }
213
214    /**
215     * Configure the component.
216     *
217     * @param event the event
218     */
219    @Handler
220    public void onConfigurationUpdate(ConfigurationUpdate event) {
221        event.structured(componentPath()).ifPresent(c -> {
222            if (c.containsKey("clusterName")) {
223                clusterName = (String) c.get("clusterName");
224            } else {
225                clusterName = null;
226            }
227        });
228        event.structured(componentPath() + "/Controller").ifPresent(c -> {
229            if (c.containsKey("namespace")) {
230                namespace = (String) c.get("namespace");
231            }
232        });
233    }
234
235    /**
236     * Log the exception when a handling error is reported.
237     *
238     * @param event the event
239     */
240    @Handler(channels = Channel.class, priority = -10_000)
241    @SuppressWarnings("PMD.GuardLogStatement")
242    public void onHandlingError(HandlingError event) {
243        logger.log(Level.WARNING, event.throwable(),
244            () -> "Problem invoking handler with " + event.event() + ": "
245                + event.message());
246        event.stop();
247    }
248
249    /**
250     * On exit.
251     *
252     * @param event the event
253     */
254    @Handler
255    public void onExit(Exit event) {
256        exitStatus = event.exitStatus();
257    }
258
259    /**
260     * On stop.
261     *
262     * @param event the event
263     */
264    @Handler(priority = -1000)
265    public void onStop(Stop event) {
266        logger.info(() -> "Application stopped.");
267    }
268
269    static {
270        try {
271            // Get logging properties from file and put them in effect
272            InputStream props;
273            var path = FsdUtils.findConfigFile(VM_OP_NAME.replace("-", ""),
274                "logging.properties");
275            if (path.isPresent()) {
276                props = Files.newInputStream(path.get());
277            } else {
278                props
279                    = Manager.class.getResourceAsStream("logging.properties");
280            }
281            LogManager.getLogManager().readConfiguration(props);
282        } catch (IOException e) {
283            e.printStackTrace(); // NOPMD
284        }
285    }
286
287    /**
288     * The main method.
289     *
290     * @param args the arguments
291     * @throws Exception the exception
292     */
293    public static void main(String[] args) {
294        try {
295            // Instance logger is not available yet.
296            var logger = Logger.getLogger(Manager.class.getName());
297            version = Optional.ofNullable(
298                Manager.class.getPackage().getImplementationVersion())
299                .orElse("unknown");
300            logger.config(() -> "Version: " + version);
301            logger.config(() -> "running on "
302                + System.getProperty("java.vm.name")
303                + " (" + System.getProperty("java.vm.version") + ")"
304                + " from " + System.getProperty("java.vm.vendor"));
305
306            // Parse the command line arguments
307            CommandLineParser parser = new DefaultParser();
308            final Options options = new Options();
309            options.addOption(new Option("c", "config", true, "The configura"
310                + "tion file (defaults to /etc/opt/vmoperator/config.yaml)."));
311            CommandLine cmd = parser.parse(options, args);
312
313            // The Manager is the root component
314            app = new Manager(cmd);
315
316            // Prepare generation of Stop event
317            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
318                try {
319                    app.fire(new Stop());
320                    Components.awaitExhaustion();
321                } catch (InterruptedException e) { // NOPMD
322                    // Cannot do anything about this.
323                }
324            }));
325
326            // Start the application
327            Components.start(app);
328
329            // Wait for (regular) termination
330            Components.awaitExhaustion();
331            System.exit(exitStatus);
332        } catch (IOException | InterruptedException | URISyntaxException
333                | org.apache.commons.cli.ParseException e) {
334            Logger.getLogger(Manager.class.getName()).log(Level.SEVERE, e,
335                () -> "Failed to start manager: " + e.getMessage());
336        }
337    }
338
339}