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.DataflowAnomalyAnalysis", "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.TooFewBranchesForASwitchStatement",
101        "PMD.NcssCount", "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    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
221    public void onConfigurationUpdate(ConfigurationUpdate event) {
222        event.structured(componentPath()).ifPresent(c -> {
223            if (c.containsKey("clusterName")) {
224                clusterName = (String) c.get("clusterName");
225            } else {
226                clusterName = null;
227            }
228        });
229        event.structured(componentPath() + "/Controller").ifPresent(c -> {
230            if (c.containsKey("namespace")) {
231                namespace = (String) c.get("namespace");
232            }
233        });
234    }
235
236    /**
237     * Log the exception when a handling error is reported.
238     *
239     * @param event the event
240     */
241    @Handler(channels = Channel.class, priority = -10_000)
242    @SuppressWarnings("PMD.GuardLogStatement")
243    public void onHandlingError(HandlingError event) {
244        logger.log(Level.WARNING, event.throwable(),
245            () -> "Problem invoking handler with " + event.event() + ": "
246                + event.message());
247        event.stop();
248    }
249
250    /**
251     * On exit.
252     *
253     * @param event the event
254     */
255    @Handler
256    public void onExit(Exit event) {
257        exitStatus = event.exitStatus();
258    }
259
260    /**
261     * On stop.
262     *
263     * @param event the event
264     */
265    @Handler(priority = -1000)
266    public void onStop(Stop event) {
267        logger.fine(() -> "Application stopped.");
268    }
269
270    static {
271        try {
272            // Get logging properties from file and put them in effect
273            InputStream props;
274            var path = FsdUtils.findConfigFile(VM_OP_NAME.replace("-", ""),
275                "logging.properties");
276            if (path.isPresent()) {
277                props = Files.newInputStream(path.get());
278            } else {
279                props
280                    = Manager.class.getResourceAsStream("logging.properties");
281            }
282            LogManager.getLogManager().readConfiguration(props);
283        } catch (IOException e) {
284            e.printStackTrace(); // NOPMD
285        }
286    }
287
288    /**
289     * The main method.
290     *
291     * @param args the arguments
292     * @throws Exception the exception
293     */
294    @SuppressWarnings("PMD.SignatureDeclareThrowsException")
295    public static void main(String[] args) {
296        try {
297            // Instance logger is not available yet.
298            var logger = Logger.getLogger(Manager.class.getName());
299            version = Optional.ofNullable(
300                Manager.class.getPackage().getImplementationVersion())
301                .orElse("unknown");
302            logger.config(() -> "Version: " + version);
303            logger.config(() -> "running on "
304                + System.getProperty("java.vm.name")
305                + " (" + System.getProperty("java.vm.version") + ")"
306                + " from " + System.getProperty("java.vm.vendor"));
307
308            // Parse the command line arguments
309            CommandLineParser parser = new DefaultParser();
310            final Options options = new Options();
311            options.addOption(new Option("c", "config", true, "The configura"
312                + "tion file (defaults to /etc/opt/vmoperator/config.yaml)."));
313            CommandLine cmd = parser.parse(options, args);
314
315            // The Manager is the root component
316            app = new Manager(cmd);
317
318            // Prepare generation of Stop event
319            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
320                try {
321                    app.fire(new Stop());
322                    Components.awaitExhaustion();
323                } catch (InterruptedException e) { // NOPMD
324                    // Cannot do anything about this.
325                }
326            }));
327
328            // Start the application
329            Components.start(app);
330
331            // Wait for (regular) termination
332            Components.awaitExhaustion();
333            System.exit(exitStatus);
334        } catch (IOException | InterruptedException | URISyntaxException
335                | org.apache.commons.cli.ParseException e) {
336            Logger.getLogger(Manager.class.getName()).log(Level.SEVERE, e,
337                () -> "Failed to start manager: " + e.getMessage());
338        }
339    }
340
341}