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}