BaseClientBuilderOptions.java

/*
** Module   : BaseClientBuilderOptions.java
** Abstract : store the parameters read from directory.
**
** Copyright (c) 2013-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- -------------------------------Description------------------------------------
** 001 MAG 20131128 First version.
** 002 MAG 20140128 Add WebSocket timeout.
** 003 MAG 20140204 Add Watchdog timeout.
** 004 CA  20140206 Refactored to make it unaware of the type of the P2J client being spawned.
** 005 GES 20150211 Removed application arguments support. Forced headless to be always present
**                  in JVM args. No other JVM arg is defaulted.
** 006 GES 20160203 Moved core initialization logic into a private method.  Added a constructor
**                  for cloning, which doesn't call the core init but instead copies state from
**                  the given instance. This is useful for subclasses which need to avoid
**                  executing the initialization logic at runtime (e.g. because of security
**                  context constraints).
** 007 CA  20191211 When spawning a Java process programmatically, allow the caller to pass 
**                  environment options specific to that call (beside any jvmArgs configured in 
**                  the directory).
** 008 EVL 20200121 Adding directory based configurable debug level for spawner support.  Adding
**                  support for extra jar files to be added to the classpath from directory.
** 009 VVT 20200213 initialize(): workarownd added: the -ea option can now be used in jvmArgs
** 010 OM  20200828 Added warning message.
** 011 SBI 20210922 Used getAvailablePorts to assign different ports for debugging and profiling
**         20210926 Moved "getAvailablePorts" logic to WebClientsManager.
** 012 SBI 20230411 Added getOverriddenOptions(), overrideClientOption, overrideWebClientPortOption.
** 013 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 014 SBI 20231218 Applied the debug profile if the web client starts the java debug agent, added
**                  parseDebuggerPort and parseJMXPort
** 015 GBB 20240102 Adding fwd-slf4j.jar to client process classpath.
** 016 EVL 20240118 Adding method to check if we need native logger for spawn.
** 017 CA  20240210 'clientConfig/properties' contains a map of operating system variables to be passed to the
**                  spawned process.
** 018 SBI 20240208 Added suspend option support.
**     RFB 20240209 Made changes in handling of suspend option for full support. Ref. #7824.
** 019 TJD 20240606 Removing fwd-slf4j.jar from client process classpath, its not needed anymore since #6692
** 020 GBB 20240709 Hard-coded config names replaced by ConfigItem constants. Moving irrelevant fields 
**                  and methods out of the class for better cohesion. Making all fields but 
**                  runtimeJvmArguments immutable. Removing the mechanics of copying the object.
** 020 GBB 20240718 Config read implemented in concrete class.
** 021 CA  20240722 Fixed reading of Map configurations.  Changed the 'clientConfig/properties' to be a real 
**                  map.
** 022 CA  20240730 resolveEnvVars may retrieve an unmodifiable map, make sure it's modifiable.
** 023 AP  20250303 Added timeout for spawner launching.
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.main;

import java.io.*;
import java.net.*;
import java.util.*;
import java.util.logging.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;

/**
 * The parameters stored inside this structure are read from directory and are used as default 
 * parameters to create and spawn a P2J client process.
 */
public abstract class BaseClientBuilderOptions
{
   /** Logger. */
   private static final CentralLogger LOG = CentralLogger.get(BaseClientBuilderOptions.class.getName());

   /** Default command. */
   private static final String DEFAULT_COMMAND = "java";
   
   /** Main class, must have a main method */
   private static final Class<?> MAIN_CLASS = ClientDriver.class;
   
   /** Runtime calculated path to our main jar file. */
   private static final String DEFAULT_PATH = getDefaultPath();

   /** Default working directory. */
   private static final String DEFAULT_WORK_DIR = "./";
   
   /** The search pattern for the debugger suspend option */
   private final static Pattern DEBUGGER_SUSPEND_PATTERN = Pattern.compile("suspend=([ny])");
   
   /** Secure socket flag. */
   private final boolean secure;
   
   /** Java launcher executable name. */
   private final String command;

   /** User's working directory. */
   private final String workingDirectory;

   /** Class path. */
   private final String classPath;
   
   /** Native library path. */
   private final String libPath;

   /** Spawner executable name. */
   private final String spawner;
   
   /** Client configuration file. */
   private final String configFile;
   
   /** Using optional logging in native spawn code. */
   private final boolean spawnerDebugEnabled;

   /** JVM arguments. */
   private final List<String> defaultJvmArguments;

   /** JVM arguments overrides. */
   private final ThreadLocal<List<String>> runtimeJvmArguments;

   /** Storage for debugger suspend option */
   private final String suspendOption;
   
   /** The operating system environment variables to be set at the spawned OS process. */
   private final Map<String, String> envProperties;

   /** Unmodifiable list of P2J client bootstrap configs */
   private final List<String> cfgOverrides;
   
   /** Launch timeout for the spawner, expressed in seconds */
   private final Integer spawnerLaunchTimeout;
   
   /**
    * Initialize. Read all configuration parameters from directory.
    *
    * @param    env
    *           Map of additional environment properties, in the <code>key=value</code> form.
    */
   protected BaseClientBuilderOptions(Map<String, String> env)
   {
      // TODO: should not support insecure mode
      secure = read(ConfigItem.SECURE, true);

      command = quotedString(read(ConfigItem.COMMAND, DEFAULT_COMMAND));
      libPath = quotedString(read(ConfigItem.LIB_PATH, DEFAULT_PATH));
      spawner = quotedString(read(ConfigItem.SPAWNER, DEFAULT_PATH + "spawn"));
      spawnerDebugEnabled = read(ConfigItem.SPAWNER_DEBUG, false);
      workingDirectory = quotedString(read(ConfigItem.WORKING_DIR, DEFAULT_WORK_DIR));
      configFile = quotedString(read(ConfigItem.CONFIG_FILE, null));

      classPath = addJarsToClasspath();

      String jvm = read(ConfigItem.JVM_ARGS, "");
      suspendOption = parseDebuggerSuspendOption(jvm, "n");
      defaultJvmArguments = resolveJvmArgs(jvm);

      envProperties = resolveEnvVars(env);

      String bootstrapCfg = readCfgOverrides("");
      List<String> cfgOverrides = new LinkedList<>(Arrays.asList(bootstrapCfg.split(" ")));
      this.cfgOverrides = Collections.unmodifiableList(cfgOverrides);
      
      this.runtimeJvmArguments = new ThreadLocal<List<String>>()
      {
         @Override public List<String> initialValue() {
            ArrayList<String> resetJvmArgs = new ArrayList<>();
            // addAll instead of using the constructor of ArrayList, because defaultJvmArguments is unmodifiable
            resetJvmArgs.addAll(defaultJvmArguments);
            return resetJvmArgs;
         }
      };

      this.spawnerLaunchTimeout = read(ConfigItem.SPAWNER_LAUNCH_TIMEOUT, null);
   }

   /**
    * Resets the thread local value of {@link #runtimeJvmArguments}.
    */
   public void reset()
   {
      ArrayList<String> resetJvmArgs = new ArrayList<>();
      // addAll instead of using the constructor of ArrayList, because defaultJvmArguments is unmodifiable
      resetJvmArgs.addAll(defaultJvmArguments);
      runtimeJvmArguments.set(resetJvmArgs);
   }

   /**
    * Get the {@link #envProperties}.
    * 
    * @return   See above.
    */
   public Map<String, String> getEnvProperties()
   {
      return envProperties;
   }
   
   /**
    * Get the security mode.
    * 
    * @return  <code>true</code> use a secured connection <code>false</code> otherwise.
    */
   public boolean isSecure()
   {
      return secure;
   }

   /**
    * Get command
    * 
    * @return  Command name string.
    */
   public String getCommand()
   {
      return command;
   }
   
   /**
    * Get classpath
    * 
    * @return  Class path string.
    */
   public String getClassPath()
   {
      return classPath;
   }

   /**
    * Get native library path.
    * 
    * @return  Native library path string.
    */
   public String getLibPath()
   {
      return libPath;
   }

   /**
    * Get user working directory.
    * 
    * @return  User working directory.
    */
   public String getWorkingDirectory()
   {
      return workingDirectory;
   }

   /**
    * Get configuration file name.
    * 
    * @return  Configuration file name.
    */
   public String getConfigFile()
   {
      return configFile;
   }
   
   /**
    * Get JVM arguments
    * 
    * @return   A List with JVM arguments.
    */
   public List<String> getJvmArguments()
   {
      return runtimeJvmArguments.get();
   }

   /**
    * Get spawner command.
    * 
    * @return  The spawner command;
    */
   public String getSpawner()
   {
      return spawner;
   }

   /**
    * Get the Java process main class.
    * 
    * @return  The main class.
    */
   public Class<?> getMainClass()
   {
      return MAIN_CLASS;
   }
   
   /**
    * Check if native log option is required.
    * 
    * @return  If <code>true</code> use a extra logger in native code <code>false</code> otherwise.
    */
   public boolean isSpawnerDebugEnabled()
   {
      return spawnerDebugEnabled;
   }

   /**
    * Returns the debugger suspend option.
    *
    * @return  See above.
    */
   public String getSuspendOption()
   {
      return suspendOption;
   }

   /**
    * Get P2J client's bootstrap config.
    *
    * @return   A List with bootstrap config settings.
    */
   public List<String> getCfgOverrides()
   {
      return cfgOverrides;
   }

   /**
    * When the string contains space characters a quoted string is build.
    * On Windows OS spaces could be used in file path, file name, font name and other
    * strings. In order to use such kind of strings in API calls the string should be
    * transformed in quoted form. 
    * 
    * @param   text  Text to search for spaces.
    * 
    * @return  If no spaces found return the initial string otherwise return a quoted string. 
    */
   static String quotedString(String text)
   {
      if (text == null)
      {
         return null;
      }
      
      if (text.startsWith("\"") && text.endsWith("\""))
      {
         return text;
      }
      return (text.contains(" ")) ? "\\\"" + text +"\\\"" : text;
   }
   
   /**
    * Returns the spawner launch timeout, expressed in seconds.
    *
    * @return  See above.
    */
   public Integer getSpawnerLaunchTimeout()
   {
      return spawnerLaunchTimeout;
   }
   
   /**
    * Update jvmArguments with new key and value pair.
    *
    * @param   key
    *          The key of a target jvm argument
    * @param   value
    *          Its new value
    */
   void updateJvmArgument(String key, String value)
   {
      List<String> jvmArgs = runtimeJvmArguments.get();
      String keyWithAssignmentPrefix = key + "=";
      for(int i = 0; i < jvmArgs.size(); i++)
      {
         String arg = jvmArgs.get(i);
         if (arg.startsWith(keyWithAssignmentPrefix))
         {
            jvmArgs.set(i, keyWithAssignmentPrefix + value);
         }
      }
   }

   /**
    * A convenience method to read the client config with the specified default value.
    * 
    * @param   clientConfig
    *          The client config to read.
    * @param   defaultValue
    *          The default value returned if no value found in directory.
    * @param   <T>
    *          The type of the client config.
    *    
    * @return  The client config value as read from directory.
    */
   abstract <T> T read(ConfigItem<T> clientConfig, T defaultValue);

   /**
    * A convenience method to read the client config with the specified default value.
    *
    * @param   clientConfig
    *          The client config to read.
    * @param   defaultValue
    *          The default value returned if no value found in directory.
    * @param   placeholderValue
    *          The value to replace the placeholder in the directory node name for the config.
    * @param   <T>
    *          The type of the client config.
    *
    * @return  The client config value as read from directory.
    */
   abstract <T> T read(ConfigItem<T> clientConfig, T defaultValue, String placeholderValue);

   /**
    * Reads and returns the value of clientConfig/cfgOverrides.
    *
    * @param   defaultValue
    *          The default value returned if no value found in directory.
    *
    * @return  The cfgOverrides value.
    */
   abstract String readCfgOverrides(String defaultValue);
   
   /**
    * Get the debugger suspend option from the jvm arguments string, otherwise return null.
    * 
    * @param    jvmArgs
    *           The jvm arguments string
    * @param    defaultValue
    *           The default value
    * 
    * @return   The debugger suspend option or null value.
    */
   private String parseDebuggerSuspendOption(String jvmArgs, String defaultValue)
   {
      Matcher m = DEBUGGER_SUSPEND_PATTERN.matcher(jvmArgs);
      if (m.find())
      {
         return m.group(1);
      }
      
      return defaultValue;
   }
   
   /**
    * Get default path as the p2j.jar file location.
    * 
    * @return  Path to p2j.jar file.
    */
   private static String getDefaultPath()
   {
      String message = "Could not determine the location of the p2j.jar file.";
      String defaultPath = "./";
      
      try
      {
         URL url = MAIN_CLASS.getProtectionDomain().getCodeSource().getLocation();
         String classPath = url.toURI().getPath();
         File jarFile = new File(classPath);
         
         // if the source is in a folder and not jar, then this can cause problems
         if (!jarFile.exists())
         {
            LOG.logp(Level.WARNING, "ClientBuilderOptions", "getDefaultPath", message);
         }
         else
         {
            // default path
            defaultPath = jarFile.getParentFile().getPath();
            
            if (!defaultPath.endsWith(File.separator))
            {
               defaultPath += File.separator;
            }
         }
      }
      catch (URISyntaxException e)
      {
         LOG.logp(Level.WARNING, "ClientBuilderOptions", "getDefaultPath", message, e);
      }
      
      return defaultPath;
   }

   /**
    * Reads the classpath and the extra jars from directory, appends each entry to the default path and 
    * checks if the file exists before compiling the final classpath.
    *
    * @return  The verified classpath.
    */
   private String addJarsToClasspath()
   {
      String cp = quotedString(read(ConfigItem.CLASSPATH, DEFAULT_PATH + "p2j.jar"));
      String cpExtraJars = quotedString(read(ConfigItem.CLASSPATH_EXTRA_JARS, null));
      // check if extra jars can be added to the classpath
      if (cpExtraJars != null && cpExtraJars.length() > 1)
      {
         String[] jars = cpExtraJars.split(",");
         StringBuilder sb = new StringBuilder();
         sb.append(cp);
         for (int i = 0; i < jars.length; i++)
         {
            String extraJar = DEFAULT_PATH + jars[i] + ".jar";
            File jarFile = new File(extraJar);
            if (jarFile.exists())
            {
               sb.append(File.pathSeparator);
               sb.append(extraJar);
            }
            else
            {
               LOG.warning("Failed to add '" + jars[i] + "' library to class path. " +
                              "Cause: file not found('" + extraJar + "')");
            }
         }
         cp = sb.toString();
      }
      return cp;
   }

   /**
    * Reads the map of env vars from directory and merges it with the map of runtime env vars. Returns an 
    * unmodifiable map.  
    *
    * @param   env
    *          The map of env vars.
    *          
    * @return  The final map of env vars, unmodifiable.
    */
   private Map<String, String> resolveEnvVars(Map<String, String> env)
   {
      Map<String, String> envVars = read(ConfigItem.ENV_PROPERTIES, null);

      if (env != null)
      {
         // explicit environment properties have precedence over the ones under clientConfig/properties
         // ensure that the map is modifiable
         envVars = new LinkedHashMap<>(envVars);
         envVars.putAll(env);
      }
      return Collections.unmodifiableMap(envVars);
   }

   /**
    * Parses the JVM arguments line from directory, adds the necessary 'headless' argument if missing and 
    * returns the arguments as an unmodifiable list.
    *
    * @param   jvmConfig
    *          The JVM arguments as read from directory.
    *
    * @return  The final list of JVM arguments, unmodifiable.
    */
   private List<String> resolveJvmArgs(String jvmConfig)
   {
      // we always must pass headless mode
      if (!jvmConfig.contains("-Djava.awt.headless=true"))
      {
         String pad = (jvmConfig.length() == 0) ? "" : " ";

         jvmConfig = jvmConfig + pad + "-Djava.awt.headless=true";
      }

      List<String> jvmArgs = new LinkedList<>();
      String[] args = jvmConfig.split("(?=-[XDe])");
      for (int i = 0; i < args.length; i++)
      {
         jvmArgs.add(args[i].trim());
      }
      return Collections.unmodifiableList(jvmArgs);
   }
}