001/* 002 * Copyright 2025 devteam@scivics-lab.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, 011 * software distributed under the License is distributed on an 012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 013 * either express or implied. See the License for the 014 * specific language governing permissions and limitations 015 * under the License. 016 */ 017 018package com.scivicslab.actoriac.cli; 019 020import java.io.BufferedReader; 021import java.io.File; 022import java.io.IOException; 023import java.io.InputStreamReader; 024import java.io.OutputStream; 025import java.net.InetSocketAddress; 026import java.net.Socket; 027import java.nio.charset.StandardCharsets; 028import java.sql.Connection; 029import java.sql.DriverManager; 030import java.sql.ResultSet; 031import java.sql.SQLException; 032import java.sql.Statement; 033import java.time.OffsetDateTime; 034import java.time.format.DateTimeFormatter; 035import java.util.ArrayList; 036import java.util.List; 037import java.util.concurrent.Callable; 038import java.util.concurrent.CountDownLatch; 039import java.util.logging.Logger; 040 041import com.sun.net.httpserver.HttpExchange; 042import com.sun.net.httpserver.HttpServer; 043 044import org.h2.tools.Server; 045 046import picocli.CommandLine.Command; 047import picocli.CommandLine.Option; 048 049/** 050 * CLI subcommand to start an H2 TCP server for centralized workflow logging. 051 * 052 * <p>This enables multiple actor-IaC processes on the same machine to write to 053 * a single shared log database. The server runs on localhost only - remote 054 * connections are not needed because actor-IaC operates from a single 055 * operation terminal.</p> 056 * 057 * <p><strong>Architecture:</strong></p> 058 * <pre> 059 * [Operation Terminal] 060 * ├── H2 Log Server (localhost:29090) 061 * ├── Workflow Process A ──→ TCP write 062 * ├── Workflow Process B ──→ TCP write 063 * └── Workflow Process C ──→ TCP write 064 * │ 065 * │ SSH commands 066 * ▼ 067 * [Remote Nodes] ← Don't write directly to log server 068 * </pre> 069 * 070 * <p><strong>Usage:</strong></p> 071 * <pre>{@code 072 * # Start the log server 073 * actor-iac log-server --db ./logs/actor-iac-logs 074 * 075 * # In another terminal, run workflow with --log-server 076 * actor-iac -d ./workflows -w deploy --log-server=localhost:29090 077 * }</pre> 078 * 079 * @author devteam@scivics-lab.com 080 */ 081@Command( 082 name = "log-serve", 083 mixinStandardHelpOptions = true, 084 version = "actor-IaC log-serve 2.11.0", 085 description = "Serve H2 TCP server for centralized workflow logging." 086) 087public class LogServerCLI implements Callable<Integer> { 088 089 private static final Logger LOG = Logger.getLogger(LogServerCLI.class.getName()); 090 091 @Option( 092 names = {"-p", "--port"}, 093 description = "TCP port for H2 server (default: ${DEFAULT-VALUE})", 094 defaultValue = "29090" 095 ) 096 private int port; 097 098 @Option( 099 names = {"--db"}, 100 description = "Database file path without extension (default: ${DEFAULT-VALUE})", 101 defaultValue = "./actor-iac-logs" 102 ) 103 private File dbPath; 104 105 @Option( 106 names = {"-v", "--verbose"}, 107 description = "Enable verbose output" 108 ) 109 private boolean verbose; 110 111 @Option( 112 names = {"--find"}, 113 description = "Find running H2 log servers on localhost and show port status" 114 ) 115 private boolean find; 116 117 @Option( 118 names = {"--info-port"}, 119 description = "HTTP port for info API (default: TCP port - 200)" 120 ) 121 private Integer infoPort; 122 123 @Option( 124 names = {"--auto-shutdown"}, 125 description = "Enable automatic shutdown when no connections for --idle-timeout seconds" 126 ) 127 private boolean autoShutdown; 128 129 @Option( 130 names = {"--idle-timeout"}, 131 description = "Idle timeout in seconds for auto-shutdown (default: ${DEFAULT-VALUE})", 132 defaultValue = "300" 133 ) 134 private long idleTimeoutSeconds; 135 136 @Option( 137 names = {"--check-interval"}, 138 description = "Connection check interval in seconds (default: ${DEFAULT-VALUE})", 139 defaultValue = "60" 140 ) 141 private long checkIntervalSeconds; 142 143 private Server tcpServer; 144 private HttpServer httpServer; 145 private OffsetDateTime startedAt; 146 147 /** ActorSystem for actor-based shutdown management */ 148 private com.scivicslab.pojoactor.core.ActorSystem actorSystem; 149 150 /** The log server actor (used when --auto-shutdown is enabled) */ 151 private com.scivicslab.pojoactor.core.ActorRef<LogServerActor> logServerActorRef; 152 153 /** The connection watcher actor */ 154 private ConnectionWatcherActor connectionWatcher; 155 156 /** Scheduler for periodic connection checks */ 157 private java.util.concurrent.ScheduledExecutorService scheduler; 158 159 /** Default offset from TCP port to HTTP port (TCP - 200 = HTTP) */ 160 private static final int HTTP_PORT_OFFSET = -200; 161 162 /** Ports to scan for H2 servers (actor-IaC reserved range: 29090-29100) */ 163 private static final int[] SCAN_PORTS = {29090, 29091, 29092, 29093, 29094, 29095, 29096, 29097, 29098, 29099, 29100}; 164 private final CountDownLatch shutdownLatch = new CountDownLatch(1); 165 166 @Override 167 public Integer call() { 168 // Handle --find option 169 if (find) { 170 return findLogServers(); 171 } 172 173 // Calculate HTTP port 174 int httpPort = (infoPort != null) ? infoPort : port + HTTP_PORT_OFFSET; 175 176 // Choose between actor mode (with auto-shutdown) and legacy mode 177 if (autoShutdown) { 178 return runWithActorMode(httpPort); 179 } else { 180 return runLegacyMode(httpPort); 181 } 182 } 183 184 /** 185 * Runs the log server with actor-based auto-shutdown. 186 * 187 * <p>Architecture:</p> 188 * <pre> 189 * ActorSystem 190 * ├── LogServerActor (wraps H2 TCP + HTTP servers) 191 * └── ConnectionWatcherActor (monitors and triggers shutdown) 192 * </pre> 193 */ 194 private Integer runWithActorMode(int httpPort) { 195 try { 196 // Create ActorSystem 197 actorSystem = new com.scivicslab.pojoactor.core.ActorSystem("log-server", 2); 198 199 // Create LogServerActor POJO 200 LogServerActor logServerPojo = new LogServerActor(port, httpPort, dbPath, verbose); 201 202 // Wrap with ActorRef 203 logServerActorRef = actorSystem.actorOf("log-server", logServerPojo); 204 205 // Start the server via actor 206 logServerActorRef.ask(server -> { 207 try { 208 server.start(shutdownLatch); 209 } catch (Exception e) { 210 throw new RuntimeException("Failed to start server", e); 211 } 212 return null; 213 }).join(); 214 215 // Print connection info 216 printConnectionInfo(httpPort); 217 System.out.println(" Auto-shutdown: enabled (idle timeout: " + idleTimeoutSeconds + "s)"); 218 219 // Create and start ConnectionWatcher 220 connectionWatcher = new ConnectionWatcherActor( 221 logServerActorRef, 222 checkIntervalSeconds, 223 idleTimeoutSeconds, 224 verbose 225 ); 226 227 // Create scheduler for watcher 228 scheduler = java.util.concurrent.Executors.newScheduledThreadPool(1, r -> { 229 Thread t = new Thread(r, "ConnectionWatcher"); 230 t.setDaemon(true); 231 return t; 232 }); 233 234 connectionWatcher.start(scheduler); 235 236 // Register shutdown hook 237 Runtime.getRuntime().addShutdownHook(new Thread(this::shutdownActorMode, "LogServer-Shutdown")); 238 239 System.out.println("\nServer is running with auto-shutdown enabled."); 240 System.out.println("Will shutdown after " + idleTimeoutSeconds + " seconds of no connections."); 241 242 // Wait for shutdown signal (from either Ctrl+C or auto-shutdown) 243 shutdownLatch.await(); 244 245 return 0; 246 247 } catch (Exception e) { 248 System.err.println("Failed to start log server: " + e.getMessage()); 249 if (verbose) { 250 e.printStackTrace(); 251 } 252 return 1; 253 } 254 } 255 256 /** 257 * Runs the log server in legacy mode (no auto-shutdown). 258 */ 259 private Integer runLegacyMode(int httpPort) { 260 try { 261 // Record start time 262 startedAt = OffsetDateTime.now(); 263 264 // Build server arguments (localhost only - no remote connections needed) 265 String[] serverArgs = new String[]{ 266 "-tcp", 267 "-tcpPort", String.valueOf(port), 268 "-ifNotExists" 269 }; 270 271 // Create and start TCP server 272 tcpServer = Server.createTcpServer(serverArgs); 273 tcpServer.start(); 274 275 // Initialize database schema 276 initializeDatabase(); 277 278 // Start HTTP info server 279 startHttpServer(httpPort); 280 281 // Print connection info 282 printConnectionInfo(httpPort); 283 284 // Register shutdown hook 285 Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown, "LogServer-Shutdown")); 286 287 System.out.println("\nServer is running. Press Ctrl+C to stop."); 288 289 // Wait for shutdown signal 290 shutdownLatch.await(); 291 292 return 0; 293 294 } catch (SQLException e) { 295 System.err.println("Failed to initialize database: " + e.getMessage()); 296 if (verbose) { 297 e.printStackTrace(); 298 } 299 return 1; 300 } catch (Exception e) { 301 System.err.println("Failed to start log server: " + e.getMessage()); 302 if (verbose) { 303 e.printStackTrace(); 304 } 305 return 1; 306 } 307 } 308 309 private void initializeDatabase() throws SQLException { 310 String dbUrl = "jdbc:h2:tcp://localhost:" + port + "/" + dbPath.getAbsolutePath(); 311 312 try (Connection conn = DriverManager.getConnection(dbUrl); 313 Statement stmt = conn.createStatement()) { 314 315 // Sessions table 316 stmt.execute(""" 317 CREATE TABLE IF NOT EXISTS sessions ( 318 id IDENTITY PRIMARY KEY, 319 started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 320 ended_at TIMESTAMP, 321 workflow_name VARCHAR(255), 322 overlay_name VARCHAR(255), 323 inventory_name VARCHAR(255), 324 node_count INT, 325 status VARCHAR(20) DEFAULT 'RUNNING' 326 ) 327 """); 328 329 // Logs table 330 stmt.execute(""" 331 CREATE TABLE IF NOT EXISTS logs ( 332 id IDENTITY PRIMARY KEY, 333 session_id BIGINT, 334 timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 335 node_id VARCHAR(255) NOT NULL, 336 vertex_name CLOB, 337 action_name CLOB, 338 level VARCHAR(10) NOT NULL, 339 message CLOB, 340 exit_code INT, 341 duration_ms BIGINT, 342 FOREIGN KEY (session_id) REFERENCES sessions(id) 343 ) 344 """); 345 346 // Node results table 347 stmt.execute(""" 348 CREATE TABLE IF NOT EXISTS node_results ( 349 id IDENTITY PRIMARY KEY, 350 session_id BIGINT, 351 node_id VARCHAR(255) NOT NULL, 352 status VARCHAR(20) NOT NULL, 353 reason VARCHAR(1000), 354 FOREIGN KEY (session_id) REFERENCES sessions(id), 355 UNIQUE (session_id, node_id) 356 ) 357 """); 358 359 // Indexes 360 stmt.execute("CREATE INDEX IF NOT EXISTS idx_logs_session ON logs(session_id)"); 361 stmt.execute("CREATE INDEX IF NOT EXISTS idx_logs_node ON logs(node_id)"); 362 stmt.execute("CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level)"); 363 stmt.execute("CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp)"); 364 stmt.execute("CREATE INDEX IF NOT EXISTS idx_sessions_workflow ON sessions(workflow_name)"); 365 stmt.execute("CREATE INDEX IF NOT EXISTS idx_sessions_overlay ON sessions(overlay_name)"); 366 stmt.execute("CREATE INDEX IF NOT EXISTS idx_sessions_inventory ON sessions(inventory_name)"); 367 stmt.execute("CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)"); 368 369 if (verbose) { 370 System.out.println("Database schema initialized: " + dbPath.getAbsolutePath()); 371 } 372 } 373 } 374 375 /** 376 * Starts the HTTP server for the /info API endpoint. 377 */ 378 private void startHttpServer(int httpPort) throws IOException { 379 httpServer = HttpServer.create(new InetSocketAddress("localhost", httpPort), 0); 380 httpServer.createContext("/info", this::handleInfoRequest); 381 httpServer.setExecutor(null); // Use default executor 382 httpServer.start(); 383 384 if (verbose) { 385 System.out.println("HTTP info server started on port " + httpPort); 386 } 387 } 388 389 /** 390 * Handles GET /info requests, returning server information as JSON. 391 */ 392 private void handleInfoRequest(HttpExchange exchange) throws IOException { 393 if (!"GET".equals(exchange.getRequestMethod())) { 394 exchange.sendResponseHeaders(405, -1); 395 return; 396 } 397 398 try { 399 // Get session count from database 400 int sessionCount = getSessionCount(); 401 402 // Build JSON response 403 String json = String.format(""" 404 { 405 "server": "actor-iac-log-server", 406 "version": "2.11.0", 407 "port": %d, 408 "db_path": "%s", 409 "db_file": "%s.mv.db", 410 "started_at": "%s", 411 "session_count": %d 412 } 413 """, 414 port, 415 escapeJson(dbPath.getAbsolutePath()), 416 escapeJson(dbPath.getAbsolutePath()), 417 startedAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), 418 sessionCount 419 ); 420 421 byte[] response = json.getBytes(StandardCharsets.UTF_8); 422 exchange.getResponseHeaders().add("Content-Type", "application/json"); 423 exchange.sendResponseHeaders(200, response.length); 424 try (OutputStream os = exchange.getResponseBody()) { 425 os.write(response); 426 } 427 } catch (Exception e) { 428 String error = "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}"; 429 byte[] response = error.getBytes(StandardCharsets.UTF_8); 430 exchange.getResponseHeaders().add("Content-Type", "application/json"); 431 exchange.sendResponseHeaders(500, response.length); 432 try (OutputStream os = exchange.getResponseBody()) { 433 os.write(response); 434 } 435 } 436 } 437 438 /** 439 * Gets the current session count from the database. 440 */ 441 private int getSessionCount() { 442 String dbUrl = "jdbc:h2:tcp://localhost:" + port + "/" + dbPath.getAbsolutePath(); 443 try (Connection conn = DriverManager.getConnection(dbUrl); 444 Statement stmt = conn.createStatement(); 445 ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM sessions")) { 446 if (rs.next()) { 447 return rs.getInt(1); 448 } 449 } catch (SQLException e) { 450 if (verbose) { 451 System.err.println("Failed to get session count: " + e.getMessage()); 452 } 453 } 454 return 0; 455 } 456 457 /** 458 * Escapes special characters for JSON strings. 459 */ 460 private String escapeJson(String value) { 461 if (value == null) return ""; 462 return value 463 .replace("\\", "\\\\") 464 .replace("\"", "\\\"") 465 .replace("\n", "\\n") 466 .replace("\r", "\\r") 467 .replace("\t", "\\t"); 468 } 469 470 private void printConnectionInfo(int httpPort) { 471 System.out.println(); 472 System.out.println("=".repeat(60)); 473 System.out.println("H2 Log Server Started"); 474 System.out.println("=".repeat(60)); 475 System.out.println(" TCP Port: " + port); 476 System.out.println(" HTTP Port: " + httpPort); 477 System.out.println(" Database: " + dbPath.getAbsolutePath() + ".mv.db"); 478 System.out.println(); 479 System.out.println("Connect from workflows using:"); 480 System.out.println(" --log-serve=localhost:" + port); 481 System.out.println(); 482 System.out.println("Server info API:"); 483 System.out.println(" curl http://localhost:" + httpPort + "/info"); 484 System.out.println(); 485 System.out.println("Query logs using:"); 486 System.out.println(" actor-iac log-search --server=localhost:" + port + " --db " + dbPath.getAbsolutePath() + " --list"); 487 System.out.println("=".repeat(60)); 488 } 489 490 private void shutdown() { 491 System.out.println("\nShutting down servers..."); 492 if (httpServer != null) { 493 httpServer.stop(0); 494 } 495 if (tcpServer != null) { 496 tcpServer.stop(); 497 } 498 shutdownLatch.countDown(); 499 System.out.println("Servers stopped."); 500 } 501 502 /** 503 * Shuts down the server when running in actor mode. 504 */ 505 private void shutdownActorMode() { 506 System.out.println("\nShutting down (actor mode)..."); 507 508 // Stop the watcher first 509 if (connectionWatcher != null) { 510 connectionWatcher.stop(); 511 } 512 513 // Stop the scheduler 514 if (scheduler != null) { 515 scheduler.shutdownNow(); 516 } 517 518 // Stop the log server via actor 519 if (logServerActorRef != null) { 520 try { 521 logServerActorRef.ask(server -> { 522 server.stop(); 523 return null; 524 }).join(); 525 } catch (Exception e) { 526 // Server might already be stopped 527 } 528 } 529 530 // Terminate actor system 531 if (actorSystem != null) { 532 actorSystem.terminate(); 533 } 534 535 System.out.println("Servers stopped (actor mode)."); 536 } 537 538 /** 539 * Finds running H2 log servers on localhost. 540 * 541 * @return exit code (0 = success) 542 */ 543 private Integer findLogServers() { 544 System.out.println("=".repeat(60)); 545 System.out.println("Scanning for H2 Log Servers on localhost..."); 546 System.out.println("=".repeat(60)); 547 System.out.println(); 548 549 List<ServerInfo> h2Servers = new ArrayList<>(); 550 List<ServerInfo> otherServices = new ArrayList<>(); 551 552 for (int scanPort : SCAN_PORTS) { 553 ServerInfo info = checkPort(scanPort); 554 if (info != null) { 555 if (info.isH2Server) { 556 h2Servers.add(info); 557 } else { 558 otherServices.add(info); 559 } 560 } 561 } 562 563 // Report H2 log servers found 564 if (h2Servers.isEmpty()) { 565 System.out.println("No H2 log servers found."); 566 System.out.println(); 567 System.out.println("To start a log server:"); 568 System.out.println(" ./actor_iac.java log-serve --db ./logs/actor-iac-logs"); 569 } else { 570 System.out.println("H2 Log Servers Found:"); 571 System.out.println("-".repeat(60)); 572 for (ServerInfo info : h2Servers) { 573 System.out.println(" Port " + info.port + ": H2 Database Server"); 574 if (info.hasHttpApi) { 575 System.out.println(" HTTP API: http://localhost:" + info.httpPort + "/info"); 576 if (info.version != null) { 577 System.out.println(" Version: " + info.version); 578 } 579 } 580 if (info.dbPath != null) { 581 System.out.println(" Database: " + info.dbPath); 582 } 583 if (info.sessionCount >= 0) { 584 System.out.println(" Sessions: " + info.sessionCount); 585 } 586 if (info.startedAt != null) { 587 System.out.println(" Started: " + info.startedAt); 588 } 589 System.out.println(" Connect: --log-serve=localhost:" + info.port); 590 System.out.println(); 591 } 592 } 593 594 // Report other services on nearby ports 595 if (!otherServices.isEmpty()) { 596 System.out.println(); 597 System.out.println("Other Services on Nearby Ports:"); 598 System.out.println("-".repeat(60)); 599 for (ServerInfo info : otherServices) { 600 System.out.println(" Port " + info.port + ": " + info.serviceName); 601 if (info.processInfo != null) { 602 System.out.println(" Process: " + info.processInfo); 603 } 604 } 605 } 606 607 // Suggest available port 608 int availablePort = findAvailablePort(); 609 if (availablePort > 0 && h2Servers.isEmpty()) { 610 System.out.println(); 611 System.out.println("All ports in range 29090-29100 are available."); 612 System.out.println("Start a log server with:"); 613 System.out.println(" ./actor_iac.java log-serve --db ./logs/actor-iac-logs"); 614 } else if (availablePort > 0) { 615 System.out.println(); 616 System.out.println("Next available port: " + availablePort); 617 System.out.println(" ./actor_iac.java log-serve --port " + availablePort + " --db ./logs/actor-iac-logs"); 618 } 619 620 System.out.println(); 621 System.out.println("=".repeat(60)); 622 623 return 0; 624 } 625 626 /** 627 * Checks what's running on a port. 628 */ 629 private ServerInfo checkPort(int checkPort) { 630 // First, check if port is open 631 try (Socket socket = new Socket("localhost", checkPort)) { 632 socket.setSoTimeout(1000); 633 // Port is open, try to identify the service 634 } catch (Exception e) { 635 // Port not open 636 return null; 637 } 638 639 ServerInfo info = new ServerInfo(checkPort); 640 641 // Try to connect as H2 642 try { 643 // Try to connect to H2 and query session count 644 String url = "jdbc:h2:tcp://localhost:" + checkPort + "/~/.h2/test;IFEXISTS=TRUE"; 645 try (Connection conn = DriverManager.getConnection(url)) { 646 info.isH2Server = true; 647 // This DB might not have our schema, but we know it's H2 648 } 649 } catch (SQLException e) { 650 // Check if it's "Database not found" - that still means H2 server is running 651 String msg = e.getMessage(); 652 if (msg != null && (msg.contains("Database") || msg.contains("not found") || 653 msg.contains("Connection is broken") || msg.contains("90067"))) { 654 info.isH2Server = true; 655 } 656 } 657 658 if (info.isH2Server) { 659 // Try to get info from actor-iac-logs database 660 tryGetLogServerInfo(info); 661 } else { 662 // Try to identify other services using lsof 663 info.serviceName = identifyService(checkPort); 664 } 665 666 return info; 667 } 668 669 /** 670 * Tries to get information about a log server's database. 671 * First tries HTTP API, then falls back to database queries. 672 */ 673 private void tryGetLogServerInfo(ServerInfo info) { 674 // First, try HTTP API (TCP port - 200) 675 if (tryGetInfoFromHttpApi(info, info.port + HTTP_PORT_OFFSET)) { 676 return; 677 } 678 679 // Fallback: try common database paths via JDBC 680 String[] commonPaths = { 681 "./actor-iac-logs", 682 "./logs/actor-iac-logs", 683 System.getProperty("user.home") + "/actor-iac-logs" 684 }; 685 686 for (String path : commonPaths) { 687 try { 688 String url = "jdbc:h2:tcp://localhost:" + info.port + "/" + path; 689 try (Connection conn = DriverManager.getConnection(url); 690 Statement stmt = conn.createStatement(); 691 ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM sessions")) { 692 if (rs.next()) { 693 info.sessionCount = rs.getInt(1); 694 info.dbPath = path; 695 return; 696 } 697 } 698 } catch (SQLException e) { 699 // This path doesn't have our schema, try next 700 } 701 } 702 } 703 704 /** 705 * Tries to get server info from HTTP API. 706 * 707 * @return true if successfully got info from API 708 */ 709 private boolean tryGetInfoFromHttpApi(ServerInfo info, int httpPort) { 710 try { 711 java.net.URL url = new java.net.URL("http://localhost:" + httpPort + "/info"); 712 java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); 713 conn.setConnectTimeout(1000); 714 conn.setReadTimeout(1000); 715 conn.setRequestMethod("GET"); 716 717 if (conn.getResponseCode() == 200) { 718 try (BufferedReader reader = new BufferedReader( 719 new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { 720 StringBuilder json = new StringBuilder(); 721 String line; 722 while ((line = reader.readLine()) != null) { 723 json.append(line); 724 } 725 726 // Parse JSON manually (avoid dependency) 727 String jsonStr = json.toString(); 728 info.hasHttpApi = true; 729 info.httpPort = httpPort; 730 info.dbPath = extractJsonString(jsonStr, "db_path"); 731 info.version = extractJsonString(jsonStr, "version"); 732 info.startedAt = extractJsonString(jsonStr, "started_at"); 733 info.sessionCount = extractJsonInt(jsonStr, "session_count"); 734 return true; 735 } 736 } 737 } catch (Exception e) { 738 // HTTP API not available 739 } 740 return false; 741 } 742 743 /** 744 * Extracts a string value from JSON. 745 */ 746 private String extractJsonString(String json, String key) { 747 String pattern = "\"" + key + "\"\\s*:\\s*\"([^\"]+)\""; 748 java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern); 749 java.util.regex.Matcher m = p.matcher(json); 750 return m.find() ? m.group(1) : null; 751 } 752 753 /** 754 * Extracts an integer value from JSON. 755 */ 756 private int extractJsonInt(String json, String key) { 757 String pattern = "\"" + key + "\"\\s*:\\s*(\\d+)"; 758 java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern); 759 java.util.regex.Matcher m = p.matcher(json); 760 return m.find() ? Integer.parseInt(m.group(1)) : -1; 761 } 762 763 /** 764 * Identifies a service using lsof. 765 */ 766 private String identifyService(int checkPort) { 767 try { 768 ProcessBuilder pb = new ProcessBuilder("lsof", "-i", ":" + checkPort, "-sTCP:LISTEN"); 769 pb.redirectErrorStream(true); 770 Process process = pb.start(); 771 772 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { 773 String line; 774 while ((line = reader.readLine()) != null) { 775 if (!line.startsWith("COMMAND")) { 776 String[] parts = line.split("\\s+"); 777 if (parts.length > 0) { 778 return parts[0]; // Process name 779 } 780 } 781 } 782 } 783 process.waitFor(); 784 } catch (Exception e) { 785 // lsof not available or failed 786 } 787 788 return "Unknown service"; 789 } 790 791 /** 792 * Finds an available port. 793 */ 794 private int findAvailablePort() { 795 for (int scanPort : SCAN_PORTS) { 796 try (Socket socket = new Socket("localhost", scanPort)) { 797 // Port is in use 798 } catch (Exception e) { 799 // Port is available 800 return scanPort; 801 } 802 } 803 return -1; 804 } 805 806 /** 807 * Information about a service running on a port. 808 */ 809 private static class ServerInfo { 810 final int port; 811 boolean isH2Server = false; 812 boolean hasHttpApi = false; 813 int httpPort = -1; 814 String dbPath = null; 815 int sessionCount = -1; 816 String startedAt = null; 817 String version = null; 818 String serviceName = null; 819 String processInfo = null; 820 821 ServerInfo(int port) { 822 this.port = port; 823 } 824 } 825}