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}