You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cassandra.apache.org by pa...@apache.org on 2026/01/13 15:04:56 UTC

(cassandra-sidecar) branch trunk updated: CASSSIDECAR-340: Add default process-based lifecycle provider

This is an automated email from the ASF dual-hosted git repository.

paulo pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git


The following commit(s) were added to refs/heads/trunk by this push:
     new aab0d136 CASSSIDECAR-340: Add default process-based lifecycle provider
aab0d136 is described below

commit aab0d136e6d9d198bac704addfae4d4c71c90681
Author: Paulo Motta <pa...@apache.org>
AuthorDate: Sun Oct 5 13:28:56 2025 -0300

    CASSSIDECAR-340: Add default process-based lifecycle provider
    
    Implements ProcessLifecycleProvider for managing Cassandra process lifecycle with:
    - Start and stop operations via sidecar lifecycle APIs
    - Configurable startup and shutdown commands
    - Process state tracking and monitoring
    - Integration tests and example usage documentation
    
    Patch by Paulo Motta; reviewed by Arjun Ashok, Francisco Guerrero for CASSSIDECAR-340
---
 .circleci/config.yml                               |  16 +-
 .github/workflows/ci.yml                           |  20 +-
 .gitignore                                         |   4 +
 CHANGES.txt                                        |   1 +
 NEWS.txt                                           |   6 +
 build.gradle                                       |   2 +
 .../sidecar/client/SidecarClientTest.java          |   2 +-
 conf/sidecar.yaml                                  |  20 +
 examples/lifecycle/README.md                       | 162 +++++
 .../lifecycle/conf/sidecar.yaml.template           |  47 +-
 examples/lifecycle/setup.sh                        |  56 ++
 examples/{ => sidecar-ccm}/README.md               |   2 +-
 examples/{ => sidecar-ccm}/conf/sidecar-ccm.yaml   |   0
 gradle.properties                                  |   1 +
 gradle/common/integrationTestTask.gradle           |  17 +
 integration-tests/build.gradle                     |   1 +
 .../LifecycleProviderIntegrationTester.java        |   2 +-
 .../ProcessLifecycleProviderIntegrationTest.java   | 208 ++++++
 .../cassandra/sidecar/utils/TestFileUtils.java     | 168 +++++
 .../resources/config/sidecar.yaml.template         |  81 +--
 .../build-cassandra-tarball.sh                     |  43 +-
 scripts/build-dtest-jars.sh                        |  28 +-
 .../sidecar/cluster/instance/InstanceMetadata.java |  15 +
 .../cluster/instance/InstanceMetadataImpl.java     |  37 ++
 .../sidecar/config/InstanceConfiguration.java      |   6 +
 .../config/yaml/InstanceConfigurationImpl.java     |  16 +-
 .../sidecar/lifecycle/LifecycleManager.java        |   4 +-
 .../lifecycle/ProcessLifecycleProvider.java        | 355 +++++++++-
 .../lifecycle/ProcessRuntimeConfiguration.java     | 256 ++++++++
 .../sidecar/modules/ConfigurationModule.java       |   1 +
 .../sidecar/config/SidecarConfigurationTest.java   |  29 +
 .../lifecycle/ProcessLifecycleProviderTest.java    | 720 +++++++++++++++++++++
 .../lifecycle/ProcessRuntimeConfigurationTest.java | 343 ++++++++++
 33 files changed, 2527 insertions(+), 142 deletions(-)

diff --git a/.circleci/config.yml b/.circleci/config.yml
index 0b385c48..2589c310 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -110,6 +110,10 @@ jobs:
           root: dtest-jars
           paths:
             - "*.jar"
+      - persist_to_workspace:
+          root: cassandra-tarballs
+          paths:
+            - "*.tar.gz"
 
   integration_cassandra_40_java11:
     docker:
@@ -150,8 +154,10 @@ jobs:
       - checkout
       - attach_workspace:
           at: dtest-jars
+      - attach_workspace:
+          at: cassandra-tarballs
       - run: ./scripts/install-shaded-dtest-jar-local.sh
-      - run: ./gradlew --no-daemon -PdtestVersion=4.0.16 -Dcassandra.sidecar.versions_to_test="4.0" integrationTestHeavyWeight --stacktrace
+      - run: ./gradlew --no-daemon -PdtestVersion=4.0.16 -PtarballVersion=4.0 -Dcassandra.sidecar.versions_to_test="4.0" jar integrationTestHeavyWeight --stacktrace
 
       - store_artifacts:
           path: build/reports
@@ -203,8 +209,10 @@ jobs:
       - checkout
       - attach_workspace:
           at: dtest-jars
+      - attach_workspace:
+          at: cassandra-tarballs
       - run: ./scripts/install-shaded-dtest-jar-local.sh
-      - run: ./gradlew --no-daemon -PdtestVersion=5.0.3 -Dcassandra.sidecar.versions_to_test="5.0" integrationTestHeavyWeight --stacktrace
+      - run: ./gradlew --no-daemon -PdtestVersion=5.0.3 -PtarballVersion=5.0 -Dcassandra.sidecar.versions_to_test="5.0" jar integrationTestHeavyWeight --stacktrace
 
       - store_artifacts:
           path: build/reports
@@ -256,8 +264,10 @@ jobs:
       - checkout
       - attach_workspace:
           at: dtest-jars
+      - attach_workspace:
+          at: cassandra-tarballs
       - run: ./scripts/install-shaded-dtest-jar-local.sh
-      - run: ./gradlew --no-daemon -PdtestVersion=5.1 -Dcassandra.sidecar.versions_to_test="5.1" integrationTestHeavyWeight --stacktrace
+      - run: ./gradlew --no-daemon -PdtestVersion=5.1 -PtarballVersion=5.1 -Dcassandra.sidecar.versions_to_test="5.1" jar integrationTestHeavyWeight --stacktrace
 
       - store_artifacts:
           path: build/reports
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 74f52b44..ecb0a55b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -66,7 +66,9 @@ jobs:
         id: cache-dtest-jars
         uses: actions/cache@v4
         with:
-          path: dtest-jars/
+          path: |
+            dtest-jars/
+            cassandra-tarballs/
           key: dtest-jars-${{ hashFiles('scripts/build-dtest-jars.sh', 'gradle/wrapper/gradle-wrapper.properties') }}
 
       - name: Dtest jars cache status
@@ -89,6 +91,13 @@ jobs:
           path: dtest-jars/
           retention-days: 90
 
+      - name: Upload cassandra tarballs artifact
+        uses: actions/upload-artifact@v5
+        with:
+          name: cassandra-tarballs
+          path: cassandra-tarballs/
+          retention-days: 30
+
   # Run unit tests with static analysis on all Java versions
   unit-tests:
     name: Unit tests (Java ${{ matrix.java }})
@@ -304,6 +313,12 @@ jobs:
           name: dtest-jars
           path: dtest-jars/
 
+      - name: Download cassandra tarballs
+        uses: actions/download-artifact@v5
+        with:
+          name: cassandra-tarballs
+          path: cassandra-tarballs/
+
       - name: Install dtest jars to local Maven
         run: |
           ./scripts/install-shaded-dtest-jar-local.sh
@@ -314,8 +329,9 @@ jobs:
         run: |
           ./gradlew --no-daemon \
             -PdtestVersion=${{ matrix.dtestVersion }} \
+            -PtarballVersion=${{ matrix.cassandra }} \
             -Dcassandra.sidecar.versions_to_test="${{ matrix.cassandra }}" \
-            integrationTestHeavyWeight \
+            jar integrationTestHeavyWeight \
             --stacktrace
         env:
           CASSANDRA_DEP_DIR: ${{ github.workspace }}/dtest-jars
diff --git a/.gitignore b/.gitignore
index 65419151..8bdfcd61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -97,6 +97,10 @@ lib
 .gradle
 
 dtest-jars
+cassandra-tarballs
 scripts/dependency-reduced-pom.xml
 
 default-stylesheet.xsl
+
+# Examples
+examples/lifecycle/nodes/
diff --git a/CHANGES.txt b/CHANGES.txt
index c4f47450..a815b5db 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 0.3.0
 -----
+ * Add default process-based lifecycle provider (CASSSIDECAR-340)
  * Sidecar API Endpoint for Nodetool Compaction Stop (CASSSIDECAR-360)
  * Implementation of CDCPublisher (CASSSIDECAR-243)
  * Sidecar endpoint for moving a node to a new token (CASSSIDECAR-344)
diff --git a/NEWS.txt b/NEWS.txt
index 09d1a25e..390ecf54 100644
--- a/NEWS.txt
+++ b/NEWS.txt
@@ -1,6 +1,12 @@
 0.3
 ===
 
+New features
+------------
+    - ProcessLifecycleProvider for managing Cassandra process lifecycle (start/stop) via sidecar APIs with configurable
+      startup and shutdown commands, state tracking, and support for custom environment configurations. Usage example
+      available on examples/lifecycle.
+
 0.2
 ===
 
diff --git a/build.gradle b/build.gradle
index 0eda7323..8943007c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -90,6 +90,7 @@ ext {
     }
 
     dependencyLocation = propertyWithDefault("CASSANDRA_DEP_DIR", "${rootDir}/dtest-jars") + "/"
+    tarballsDir = propertyWithDefault("CASSANDRA_TARBALL_DIR", "${rootDir}/cassandra-tarballs") + "/"
 
     // This allows simplified builds and local maven installs.
     forceSigning = propertyExists("forceSigning")
@@ -229,6 +230,7 @@ distributions {
             exclude '**.idea/**'
             exclude 'bin/**'
             exclude 'dtest-jars/**'
+            exclude 'cassandra-tarballs/**'
             exclude '**lib/**'
             exclude '**logs/**'
             exclude '**/build/**'
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java
index 08d41c74..4eb570de 100644
--- a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java
@@ -91,6 +91,7 @@ import org.apache.cassandra.sidecar.common.response.ListCdcSegmentsResponse;
 import org.apache.cassandra.sidecar.common.response.ListOperationalJobsResponse;
 import org.apache.cassandra.sidecar.common.response.ListSnapshotFilesResponse;
 import org.apache.cassandra.sidecar.common.response.LiveMigrationStatus;
+import org.apache.cassandra.sidecar.common.response.LiveMigrationStatus.MigrationState;
 import org.apache.cassandra.sidecar.common.response.NodeSettings;
 import org.apache.cassandra.sidecar.common.response.OperationalJobResponse;
 import org.apache.cassandra.sidecar.common.response.RingResponse;
@@ -100,7 +101,6 @@ import org.apache.cassandra.sidecar.common.response.StreamStatsResponse;
 import org.apache.cassandra.sidecar.common.response.TableStatsResponse;
 import org.apache.cassandra.sidecar.common.response.TimeSkewResponse;
 import org.apache.cassandra.sidecar.common.response.TokenRangeReplicasResponse;
-import org.apache.cassandra.sidecar.common.response.LiveMigrationStatus.MigrationState;
 import org.apache.cassandra.sidecar.common.response.data.CdcSegmentInfo;
 import org.apache.cassandra.sidecar.common.response.data.ClientConnectionEntry;
 import org.apache.cassandra.sidecar.common.response.data.CreateRestoreJobResponsePayload;
diff --git a/conf/sidecar.yaml b/conf/sidecar.yaml
index 57be2e78..631a6a27 100644
--- a/conf/sidecar.yaml
+++ b/conf/sidecar.yaml
@@ -66,6 +66,13 @@ cassandra_instances:
     jmx_ssl_enabled: false
 #    jmx_role:
 #    jmx_role_password:
+    lifecycle_options:
+      cassandra_conf_dir: /etc/cassandra
+      #cassandra_log_dir: /var/log/cassandra
+      # Optional configuration for the cassandra YAML configuration file location.
+      # When not specified the configuration file will resolve to
+      # <lifecycle_options.cassandra_conf_dir>/cassandra.yaml
+      #cassandra_yaml_path: /etc/cassandra/cassandra.yaml
 
 sidecar:
   host: 0.0.0.0
@@ -455,3 +462,16 @@ live_migration:
   migration_map:                          # Map of source and destination Cassandra instances
 #    localhost1: localhost4               # This entry says that localhost1 will be migrated to localhost4
   max_concurrent_downloads: 20            # Maximum number of concurrent downloads allowed
+
+# Configuration to allow sidecar start and stop Cassandra instances via the lifecycle API (disabled by default)
+lifecycle:
+  enabled: false
+  provider:
+    class_name: org.apache.cassandra.sidecar.lifecycle.ProcessLifecycleProvider
+    parameters:
+      state_dir: /var/lib/cassandra-sidecar/lifecycle # The directory where the process state is stored
+      cassandra_home: /opt/cassandra # The default Cassandra installation directory
+#     process_timeout_seconds: 120 # Timeout in seconds for Cassandra process start/stop operations (default: 120)
+#     sys.cassandra.ring_delay_ms: 30000   # It is possible to set JVM properties via the "sys." prefix
+#     env.MAX_HEAP_SIZE: 8G # It is possible to set environment variables used to start Cassandra via the "env" prefix
+#     env.HEAP_NEWSIZE: 8G
diff --git a/examples/lifecycle/README.md b/examples/lifecycle/README.md
new file mode 100644
index 00000000..8d7d91be
--- /dev/null
+++ b/examples/lifecycle/README.md
@@ -0,0 +1,162 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+# Starting and stopping Cassandra via lifecycle APIs
+
+In this guide we will show how to start and stop a local Cassandra instance via sidecar lifecycle APIs.
+
+## Pre-requirements
+
+- Configuring Cassandra
+- Java 11
+
+## Installing and configuring Cassandra
+
+Use the setup.sh script to install and configure Cassandra and sidecar.
+
+```shell
+$ ./setup.sh
+```
+
+Once executed, the script should create the following directory structure, simulating a Cassandra host install:
+
+```shell
+$ ls -l nodes/localhost/
+nodes/localhost/etc:
+total 4
+drwxr-xr-x 3 paulo paulo 4096 Aug 29 16:58 cassandra
+
+nodes/localhost/opt:
+total 4
+drwxr-xr-x 8 paulo paulo 4096 Aug 29 16:58 apache-cassandra-4.1.9
+
+nodes/localhost/tmp:
+total 50672
+-rw-r--r-- 1 paulo paulo 51883388 May 16 08:09 apache-cassandra-4.1.9-bin.tar.gz
+
+nodes/localhost/var:
+total 8
+drwxr-xr-x 3 paulo paulo 4096 Aug 29 16:58 lib
+drwxr-xr-x 3 paulo paulo 4096 Aug 29 16:58 log
+```
+
+## Starting sidecar
+
+We can now start our Sidecar instance. The `setup.sh` has already configured `sidecar.yaml` configuration with the correct locations. Start sidecar with:
+
+```shell
+./gradlew run -Dsidecar.config=file:///$PWD/examples/lifecycle/conf/sidecar.yaml
+```
+
+Since Cassandra is not started yet, you should see the following sidecar logs indicating it's not able to reach Cassandra via JMX:
+
+```
+INFO  [sidecar-internal-worker-pool-1] 2025-08-29 17:10:33,441 JmxClient.java:197 - Could not connect to JMX on service:jmx:rmi://127.0.0.1:7199/jndi/rmi://127.0.0.1:7199/jmxrmi after 1 attempts. Will retry.
+```
+
+## Checking Cassandra lifecycle state via lifecycle API
+
+Use the following command to check that the Cassandra instance is not running and CQL is not up:
+
+```shell
+# Check lifecycle state
+$ curl localhost:9043/api/v1/cassandra/lifecycle
+{"current_state":"STOPPED","desired_state":"UNKNOWN","status":"UNDEFINED","last_update":"No lifecycle task submitted for this instance yet."}
+
+# Check CQL State
+$ curl localhost:9043/api/v1/cassandra/native/__health
+{"status":"NOT_OK"}
+```
+
+## Starting Cassandra via sidecar
+
+Now let's try to start Cassandra:
+
+```shell
+$ curl -XPUT http://localhost:9043/api/v1/cassandra/lifecycle -d'{"state": "start"}'
+{"current_state":"STOPPED","desired_state":"RUNNING","status":"CONVERGING","last_update":"Submitting start task for instance"}
+```
+
+If you see an error during this step, check the logs at `examples/lifecycle/nodes/localhost/var/lib/cassandra-sidecar/lifecycle/start-cassandra-1.out` (and corresponding `start-cassandra-1.err` file).
+
+Query the lifecycle status until the instance is started:
+```shell
+$ curl localhost:9043/api/v1/cassandra/lifecycle
+{"current_state":"RUNNING","desired_state":"RUNNING","status":"CONVERGED","last_update":"Instance has started"}
+```
+
+Query the CQL status until it's started. This might take some time since as the Cassandra process initializes.
+
+```shell
+$ curl localhost:9043/api/v1/cassandra/native/__health
+{"status":"OK"}
+```
+You should see the following in the sidecar logs, indicating the Cassandra instance is started, and it's able to connect to it via CQL and JMX:
+
+```shell
+INFO  [sidecar-internal-worker-pool-2] 2025-08-29 17:32:22,504 ProcessLifecycleProvider.java:118 - Starting Cassandra instance localhost with command: [/tmp/examples/lifecycle/nodes/localhost/opt/apache-cassandra-4.1.9/bin/cassandra, -p, /tmp/examples/lifecycle/nodes/localhost/var/lib/cassandra-sidecar/lifecycle/cassandra-localhost.pid, -Dcassandra.ring_delay_ms=5000, -D, cassandra.storagedir=/tmp/examples/lifecycle/nodes/localhost/var/lib/cassandra]
+INFO  [vert.x-eventloop-thread-2] 2025-08-29 17:32:22,520 ?:? - 0:0:0:0:0:0:0:1 - - [Fri, 29 Aug 2025 21:32:22 GMT] "PUT /api/v1/cassandra/lifecycle HTTP/1.1" 202 126 "-" "curl/8.10.1"
+INFO  [sidecar-internal-worker-pool-2] 2025-08-29 17:32:25,365 ProcessLifecycleProvider.java:124 - Started Cassandra instance localhost with PID 882
+INFO  [sidecar-internal-worker-pool-2] 2025-08-29 17:32:48,745 JmxClient.java:215 - Connected to JMX server at service:jmx:rmi://127.0.0.1:7199/jndi/rmi://127.0.0.1:7199/jmxrmi after 1 attempt(s)
+INFO  [sidecar-internal-worker-pool-2] 2025-08-29 17:32:48,757 CassandraAdapterDelegate.java:225 - Cassandra version change detected (from=null to=4.1.9) for cassandraInstanceId=1. New adapter loaded=CassandraAdapter@694c957d
+INFO  [sidecar-internal-worker-pool-2] 2025-08-29 17:32:48,758 CassandraAdapterDelegate.java:520 - JMX connected to cassandraInstanceId=1
+INFO  [sidecar-internal-worker-pool-2] 2025-08-29 17:32:48,758 CQLSessionProviderImpl.java:186 - Connecting to cluster using contact points [/127.0.0.1:9042]
+INFO  [sidecar-internal-worker-pool-2] 2025-08-29 17:32:48,931 CQLSessionProviderImpl.java:225 - Successfully connected to Cassandra!
+INFO  [sidecar-internal-worker-pool-2] 2025-08-29 17:32:48,948 CassandraAdapterDelegate.java:529 - CQL connected to cassandraInstanceId=1
+INFO  [vert.x-eventloop-thread-0] 2025-08-29 17:32:48,951 Server.java:329 - CQL is ready for all Cassandra instances. [1]
+```
+
+Check that the Cassandra process ID matches the PID in the lifecycle process ID file:
+```shell
+$ ps aux | grep CassandraDaemon | grep -v grep | awk '{ print $2 }'
+8821
+$ cat nodes/localhost/var/lib/cassandra-sidecar/lifecycle/cassandra-1.pid
+8821
+```
+
+At this stage, you may explore the cassandra logs at `examples/lifecycle/nodes/localhost/var/log/cassandra/system.log` or cassandra startup logs at `examples/lifecycle/nodes/localhost/var/lib/cassandra-sidecar/lifecycle/start-cassandra-1.out`.
+
+## Stopping Cassandra via sidecar
+
+Stop Cassandra via sidecar with the following command:
+```shell
+$ curl -XPUT http://localhost:9043/api/v1/cassandra/lifecycle -d'{"state": "stop"}'
+{"current_state":"RUNNING","desired_state":"STOPPED","status":"CONVERGING","last_update":"Submitting stop task for instance"}
+```
+
+If you see an error during this step, check the logs at `examples/lifecycle/nodes/localhost/var/lib/cassandra-sidecar/lifecycle/cassandra-localhost.out` (and corresponding `cassandra-localhost.err` file).
+
+Query the lifecycle status until the process is stopped:
+```shell
+$ curl http://localhost:9043/api/v1/cassandra/lifecycle
+{"current_state":"STOPPED","desired_state":"STOPPED","status":"CONVERGED","last_update":"Instance has stopped"}
+```
+
+You should see the following in the sidecar logs, indicating the Cassandra instance is successfully stopped.
+
+```shell
+INFO  [sidecar-internal-worker-pool-11] 2025-08-29 18:03:05,957 ProcessLifecycleProvider.java:147 - Stopping Cassandra instance localhost with command: [/tmp/examples/lifecycle/nodes/localhost/opt/apache-cassandra-4.1.9/bin/stop-server, -p, /tmp/examples/lifecycle/nodes/localhost/var/lib/cassandra-sidecar/lifecycle/cassandra-localhost.pid]
+INFO  [vert.x-eventloop-thread-2] 2025-08-29 18:03:05,958 ?:? - 0:0:0:0:0:0:0:1 - - [Fri, 29 Aug 2025 22:03:05 GMT] "PUT /api/v1/cassandra/lifecycle HTTP/1.1" 202 125 "-" "curl/8.10.1"
+INFO  [sidecar-internal-worker-pool-11] 2025-08-29 18:03:05,960 ProcessLifecycleProvider.java:185 - Waiting for Cassandra instance localhost with PID 15652 to stop...
+INFO  [cluster11-worker-0] 2025-08-29 18:03:05,969 CassandraAdapterDelegate.java:540 - CQL disconnection from cassandraInstanceId=1
+INFO  [sidecar-internal-worker-pool-11] 2025-08-29 18:03:10,961 ProcessLifecycleProvider.java:159 - Stopped Cassandra instance localhost with PID 15652.
+INFO  [sidecar-internal-worker-pool-19] 2025-08-29 18:06:30,985 CassandraAdapterDelegate.java:556 - JMX disconnection from cassandraInstanceId=1
+```
+
diff --git a/conf/sidecar.yaml b/examples/lifecycle/conf/sidecar.yaml.template
similarity index 93%
copy from conf/sidecar.yaml
copy to examples/lifecycle/conf/sidecar.yaml.template
index 57be2e78..fd312423 100644
--- a/conf/sidecar.yaml
+++ b/examples/lifecycle/conf/sidecar.yaml.template
@@ -21,7 +21,7 @@
 #
 cassandra_instances:
   - id: 1
-    host: localhost1
+    host: localhost
     port: 9042
 
     # The instance's storage directory as defined per the cassandra.storagedir property
@@ -30,7 +30,7 @@ cassandra_instances:
     # commitlog dir, cdc dir, hints dir and saved caches dir. If data_dirs, commitlog_dir,
     # cdc_dir, hints_dir and saved_caches_dir are configured explicitly, then they will be used.
     # Otherwise, default paths based on storage directory will be used.
-    storage_dir: /var/lib/cassandra
+    storage_dir: $baseDir/var/lib/cassandra
 
     # List of directories where Cassandra stores data on disk. If not set, the default directory
     # is <storage_dir>/data
@@ -66,6 +66,13 @@ cassandra_instances:
     jmx_ssl_enabled: false
 #    jmx_role:
 #    jmx_role_password:
+    lifecycle_options:
+      cassandra_conf_dir: $baseDir/etc/cassandra
+      cassandra_log_dir: $baseDir/var/log/cassandra
+      # Optional configuration for the cassandra YAML configuration file location.
+      # When not specified the configuration file will resolve to
+      # <lifecycle_options.cassandra_conf_dir>/cassandra.yaml
+      #cassandra_yaml_path: $baseDir/etc/cassandra/cassandra.yaml
 
 sidecar:
   host: 0.0.0.0
@@ -107,7 +114,6 @@ sidecar:
       maximum_size: 10000
   cdc:
     segment_hardlink_cache_expiry: 5m # 5 minutes
-    table_schema_refresh_time: 60s    # Interval time between table schema refresh for CDC
   worker_pools:
     service:
       name: "sidecar-worker-pool"
@@ -258,25 +264,6 @@ access_control:
         # trying to connect.
         client_id: recognized_client_id
         config_discover_interval: 1h
-        #
-        # Authentication type dictates whether to use the Oauth flow or a stateless JWT authenticator
-        # If not provided, default is oauth. Valid values are oauth or stateless
-        # jwt_auth_type: oauth
-        #
-        # Optional configurations for connecting to JWT PEM provider
-        #
-        # keystore_path defines the path to a keystore which can be used to provide SSL certificates to
-        # a JWT pem provider. If specified, a password must be provided.
-        # keystore_path: /path/to/keystore
-        # keystore_password: changeme
-        #
-        # truststore_path defines the path to a truststore that contains the CA used to validate SSL certs used
-        # by downstream PEM provider services. If specified, a password must be provided.
-        # truststore_path: /path/to/truststore
-        # truststore_password: changeme
-        #
-        # Optional JWT which can be used to authenticate to a downstream PEM provider if required.
-        # pem_provider_jwt: changeme
   authorizer:
     # Authorization backend, implementing io.vertx.ext.auth.authorization.AuthorizationProvider; used to
     # provide permissions a user holds.
@@ -453,5 +440,19 @@ live_migration:
   dirs_to_exclude:
     - glob:${DATA_FILE_DIR}/*/*/snapshots # Excludes snapshot directories in data folder to copy to destination
   migration_map:                          # Map of source and destination Cassandra instances
-#    localhost1: localhost4               # This entry says that localhost1 will be migrated to localhost4
+#    localhost: localhost4               # This entry says that localhost will be migrated to localhost4
   max_concurrent_downloads: 20            # Maximum number of concurrent downloads allowed
+
+# Configuration to allow sidecar start and stop Cassandra instances via the lifecycle API (disabled by default)
+lifecycle:
+  enabled: true
+  provider:
+    class_name: org.apache.cassandra.sidecar.lifecycle.ProcessLifecycleProvider
+    parameters:
+      state_dir: $baseDir/var/lib/cassandra-sidecar/lifecycle # The directory where the process state is stored
+      cassandra_home: $cassandraHome # The default Cassandra installation directory
+      process_timeout_seconds: 120 # Timeout in seconds for Cassandra process start/stop operations (default: 120)
+      sys.cassandra.ring_delay_ms: 5000   # It is possible to set JVM properties via the "sys." prefix
+      sys.com.sun.management.jmxremote.authenticate: true # Example of setting JMX authentication
+      env.MAX_HEAP_SIZE: 4G # It is possible to set environment variables used to start Cassandra via the "env." prefix
+      env.HEAP_NEWSIZE: 4G
diff --git a/examples/lifecycle/setup.sh b/examples/lifecycle/setup.sh
new file mode 100755
index 00000000..e165d7b9
--- /dev/null
+++ b/examples/lifecycle/setup.sh
@@ -0,0 +1,56 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+set -eu
+
+SCRIPT_DIR=$(realpath "$(dirname "$0")")
+CASSANDRA_VERSION=4.1.10
+CASSANDRA_DIR="apache-cassandra-${CASSANDRA_VERSION}"
+TARBALL_NAME="${CASSANDRA_DIR}-bin.tar.gz"
+TARBALL_URL="https://dlcdn.apache.org/cassandra/${CASSANDRA_VERSION}/${TARBALL_NAME}"
+NODE_DIR="${SCRIPT_DIR}/nodes/localhost"
+
+SIDECAR_YAML="${SCRIPT_DIR}/conf/sidecar.yaml"
+SIDECAR_YAML_TEMPLATE="${SCRIPT_DIR}/conf/sidecar.yaml.template"
+CASSANDRA_HOME="${NODE_DIR}/opt/${CASSANDRA_DIR}"
+CASSANDRA_LOG_DIR="${NODE_DIR}/var/log/cassandra"
+CASSANDRA_CONF="${NODE_DIR}/etc/cassandra"
+CASSANDRA_STORAGE_DIR="${NODE_DIR}/var/lib/cassandra"
+SIDECAR_LIFECYCLE_DIR="${NODE_DIR}/var/lib/cassandra-sidecar/lifecycle"
+TMP_DIR="${NODE_DIR}/tmp"
+
+echo "Creating directories"
+mkdir -p ${CASSANDRA_HOME} ${CASSANDRA_LOG_DIR} ${CASSANDRA_CONF} ${CASSANDRA_STORAGE_DIR} ${SIDECAR_LIFECYCLE_DIR} ${TMP_DIR}
+
+if [ -f ${CASSANDRA_HOME}/bin/cassandra ]; then
+  echo "Cassandra already installed at ${CASSANDRA_HOME}, skipping install"
+else
+  echo "Installing Cassandra at ${CASSANDRA_HOME}"
+  echo "Downloading ${TARBALL_URL}"
+  curl -L -o ${TMP_DIR}/$(basename ${TARBALL_URL}) ${TARBALL_URL}
+
+  echo "Extracting Cassandra tarball"
+  tar -xvzf ${TMP_DIR}/${TARBALL_NAME} -C $(dirname $CASSANDRA_HOME)
+fi
+
+echo "Creating Sidecar configuration"
+cp -r ${CASSANDRA_HOME}/conf/* ${CASSANDRA_CONF}
+sed "s#\$cassandraHome#${CASSANDRA_HOME}#g" ${SIDECAR_YAML_TEMPLATE} > ${SIDECAR_YAML}
+sed -i '' "s#\$baseDir#${NODE_DIR}#g" ${SIDECAR_YAML}
+
+echo "Setup complete!"
diff --git a/examples/README.md b/examples/sidecar-ccm/README.md
similarity index 98%
rename from examples/README.md
rename to examples/sidecar-ccm/README.md
index d2211575..14437b98 100644
--- a/examples/README.md
+++ b/examples/sidecar-ccm/README.md
@@ -79,7 +79,7 @@ We can now start our Sidecar instance. A single Sidecar instance will manage all
 in our CCM cluster.
 
 ```shell
-./gradlew run -Dsidecar.config=file:///$PWD/examples/conf/sidecar-ccm.yaml
+./gradlew run -Dsidecar.config=file:///$PWD/examples/sidecar-ccm/conf/sidecar-ccm.yaml
 ```
 
 We should see our Sidecar logs showing a successful connection to the all Cassandra instances
diff --git a/examples/conf/sidecar-ccm.yaml b/examples/sidecar-ccm/conf/sidecar-ccm.yaml
similarity index 100%
rename from examples/conf/sidecar-ccm.yaml
rename to examples/sidecar-ccm/conf/sidecar-ccm.yaml
diff --git a/gradle.properties b/gradle.properties
index 57f87c4d..515c9f7f 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -30,6 +30,7 @@ dtestApiVersion=0.0.17
 assertjCoreVersion=3.24.2
 # trunk is currently 5.1 - update when trunk moves
 dtestVersion=5.1
+tarballVersion=5.1
 dtestDependencyName=cassandra-dtest-local-all
 awsSdkVersion=2.26.12
 # The dep is to introduce xxhash impl
diff --git a/gradle/common/integrationTestTask.gradle b/gradle/common/integrationTestTask.gradle
index 39104b8f..5fa3a1ba 100644
--- a/gradle/common/integrationTestTask.gradle
+++ b/gradle/common/integrationTestTask.gradle
@@ -29,6 +29,22 @@ println("Using mTLS for tests? ${integrationEnableMtls}")
 
 apply from: "${project.rootDir}/gradle/common/java11Options.gradle"
 
+def getCassandraTarballLocation() {
+    def tarballsDirFile = file(tarballsDir)
+    if (tarballsDirFile.exists() && tarballsDirFile.isDirectory()) {
+        def matchingFiles = fileTree(tarballsDirFile).files.findAll {it.name ==~ /^apache-cassandra-${tarballVersion}\.?\d*(-SNAPSHOT){0,1}-bin\.tar.gz$/ }
+        if (matchingFiles.size() > 0) {
+            def tarballToTest = matchingFiles.first()
+            println("Testing with tarball ${tarballToTest}")
+            return tarballToTest
+        } else {
+            logger.warn("WARN: No tarballs matching version ${tarballVersion} found in the directory: ${tarballsDir}. Use script build-dtest-jars.sh to build it.")
+        }
+    } else {
+        logger.warn("WARN: Tarballs directory does not exist or is not a directory: ${tarballsDir}. Use script build-dtest-jars.sh to build it.")
+    }
+}
+
 ['integrationTestLightWeight', 'integrationTestHeavyWeight'].each { name ->
     tasks.register(name, Test) {
         if (JavaVersion.current().isJava11Compatible()) {
@@ -37,6 +53,7 @@ apply from: "${project.rootDir}/gradle/common/java11Options.gradle"
         }
         systemProperty "cassandra.integration.sidecar.test.enable_mtls", integrationEnableMtls
         systemProperty "cassandra.test.dtest_jar_path", dependencyLocation
+        systemProperty "cassandra.test.tarball_path", getCassandraTarballLocation()
         // Disable direct memory allocator as it doesn't release properly
         systemProperty "cassandra.netty_use_heap_allocator", "true"
         systemProperty "vertxweb.environment", "dev"
diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle
index 07d1c8e7..c34a7503 100644
--- a/integration-tests/build.gradle
+++ b/integration-tests/build.gradle
@@ -72,6 +72,7 @@ dependencies {
     integrationTestImplementation(group: 'net.java.dev.jna', name: 'jna', version: '5.9.0')
     integrationTestImplementation(project(path: ":integration-framework"))
     integrationTestImplementation("com.google.inject:guice:${guiceVersion}")
+    integrationTestImplementation 'org.apache.commons:commons-compress:1.27.1'
     testRuntimeOnly 'com.fasterxml.jackson.core:jackson-annotations:2.14.2'
 
     integrationTestCompileOnly(group: 'com.intellij', name: 'annotations', version: '12.0')
diff --git a/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/lifecycle/LifecycleProviderIntegrationTester.java b/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/lifecycle/LifecycleProviderIntegrationTester.java
index 7431cbf2..0a7a1d1e 100644
--- a/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/lifecycle/LifecycleProviderIntegrationTester.java
+++ b/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/lifecycle/LifecycleProviderIntegrationTester.java
@@ -203,7 +203,7 @@ public class LifecycleProviderIntegrationTester
                 throw new TimeoutException("Expected lifecycle update not reached after " + timeoutSeconds + " seconds. " +
                                            ", last_update: " + lastUpdate);
             }
-            Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS);
+            Uninterruptibles.sleepUninterruptibly(10, TimeUnit.SECONDS);
             lastUpdate = getLifecycle().bodyAsJsonObject().getString("last_update");
         }
     }
diff --git a/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProviderIntegrationTest.java b/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProviderIntegrationTest.java
new file mode 100644
index 00000000..135e4c58
--- /dev/null
+++ b/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProviderIntegrationTest.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.lifecycle;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.CleanupMode;
+import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.codahale.metrics.MetricRegistry;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import io.vertx.core.Vertx;
+import io.vertx.ext.web.client.WebClient;
+import io.vertx.junit5.VertxExtension;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadataImpl;
+import org.apache.cassandra.sidecar.modules.SidecarModules;
+import org.apache.cassandra.sidecar.server.Server;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.apache.cassandra.sidecar.utils.TestFileUtils.copyDirectoryRecursively;
+import static org.apache.cassandra.sidecar.utils.TestFileUtils.extractGzippedTarball;
+import static org.apache.cassandra.sidecar.utils.TestFileUtils.replacePlaceholdersInFileWithPattern;
+
+/**
+ * Process lifecycle provider integration test.
+ */
+@ExtendWith(VertxExtension.class)
+@Tag("heavy")
+class ProcessLifecycleProviderIntegrationTest
+{
+    static final String TEST_NODE = "localhost";
+    static final int TEST_NODE_ID = 1;
+    static final int TIMEOUT_SECONDS = 30;
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ProcessLifecycleProviderIntegrationTest.class);
+    private static final MetricRegistry METRIC_REGISTRY = new MetricRegistry();
+
+    static String sidecarDeploymentId;
+    static Path lifecycleDir;
+
+    static final String TARBALL_PATH = System.getProperty("cassandra.test.tarball_path");
+
+    @TempDir(cleanup = CleanupMode.ON_SUCCESS)
+    private static Path tmpDir;
+
+    private static Server server;
+    private static Vertx vertx;
+    private static WebClient client;
+
+    @BeforeAll
+    public static void setup() throws Exception
+    {
+        LOGGER.info("Created temporary directory for test: {}", tmpDir);
+
+        // Setup Cassandra
+        Path cassandraInstallDir = tmpDir.resolve("opt");
+        Path cassandraConfDir = Files.createDirectories(tmpDir.resolve("etc/cassandra"));
+        ProcessRuntimeConfiguration cassandraConfig = installCassandra(cassandraInstallDir, cassandraConfDir);
+
+        // Setup sidecar configuration
+        lifecycleDir = Files.createDirectories(tmpDir.resolve("var/lib/sidecar/lifecycle"));
+        Path sidecarYaml = createSidecarYaml(cassandraConfig, lifecycleDir);
+        LOGGER.info("Testing with cassandra config at: {} and sidecar yaml at: {}", cassandraConfig.cassandraConf(), sidecarYaml);
+        configureSidecar(sidecarYaml);
+
+        sidecarDeploymentId = server.start().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, SECONDS);
+    }
+
+    @AfterAll
+    public static void tearDown() throws ExecutionException, InterruptedException, TimeoutException
+    {
+        server.stop(sidecarDeploymentId).toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, SECONDS);
+        // Make sure server is stopped
+        forceCassandraStop();
+    }
+
+    private static void forceCassandraStop()
+    {
+        Path pidFileLocation = Path.of(ProcessLifecycleProvider.pidFileLocation(lifecycleDir.toString(), TEST_NODE_ID));
+        if (!pidFileLocation.toFile().exists())
+        {
+            LOGGER.info("No PID file exists, Cassandra already stopped.");
+            return;
+        }
+        Long pid = ProcessLifecycleProvider.readPidFromFile(pidFileLocation);
+        try
+        {
+            Optional<ProcessHandle> processHandle = ProcessHandle.of(pid);
+            if (processHandle.isPresent())
+            {
+                LOGGER.info("Killing Cassandra process with PID {}", pid);
+                CompletableFuture<ProcessHandle> terminationFuture = processHandle.get().onExit();
+                processHandle.get().destroyForcibly();
+                terminationFuture.get(TIMEOUT_SECONDS, SECONDS);
+            }
+        }
+        catch (InterruptedException | ExecutionException | TimeoutException e)
+        {
+            LOGGER.error("Failed to kill Cassandra process with PID {}", pid, e);
+            throw new RuntimeException("Failed to kill Cassandra process", e);
+        }
+    }
+
+    static void configureSidecar(Path sidecarYamlPath)
+    {
+        Injector injector = Guice.createInjector(SidecarModules.all(sidecarYamlPath));
+        vertx = injector.getInstance(Vertx.class);
+        server = injector.getInstance(Server.class);
+        client = WebClient.create(vertx);
+    }
+
+    @Test
+    void testProcessLifecycleProviderStartAndStopAndRecoveryAfterCrash() throws Exception
+    {
+        LifecycleProviderIntegrationTester tester = new LifecycleProviderIntegrationTester(
+        client,
+        TEST_NODE,
+        server.actualPort(),
+        ProcessLifecycleProviderIntegrationTest::forceCassandraStop);
+
+        tester.testLifecycleProviderStartAndStopAndRecoveryAfterCrash();
+    }
+
+    private static Path createSidecarYaml(ProcessRuntimeConfiguration cassandraConfig, Path lifecycleDir) throws IOException, URISyntaxException
+    {
+        Path sidecarConfDir = Files.createDirectories(tmpDir.resolve("etc/sidecar"));
+        URL sidecarYamlTemplateUrl = ProcessLifecycleProviderIntegrationTest.class.getResource("/config/sidecar.yaml.template");
+        Path sidecarYamlTemplatePath = Path.of(Objects.requireNonNull(sidecarYamlTemplateUrl).toURI());
+        Path sidecarYaml = sidecarConfDir.resolve("sidecar.yaml");
+        replacePlaceholdersInFileWithPattern(sidecarYamlTemplatePath,
+                                             Map.of("cassandraHome", cassandraConfig.cassandraHome().toString(),
+                                                    "lifecycleDir", lifecycleDir.toString(),
+                                                    "cassandraConfDir", cassandraConfig.cassandraConfDir.toString(),
+                                                    "cassandraStorageDir", Objects.requireNonNull(cassandraConfig.storageDir).toString(),
+                                                    "cassandraLogDir", cassandraConfig.cassandraLogDir.toString()),
+                                             sidecarYaml);
+        return sidecarYaml;
+    }
+
+    public static ProcessRuntimeConfiguration installCassandra(Path installDir, Path confDir) throws IOException
+    {
+        if (TARBALL_PATH == null || TARBALL_PATH.isEmpty())
+        {
+            throw new IllegalStateException("System property 'cassandra.test.tarball_path' is not set");
+        }
+        Files.createDirectories(installDir);
+        extractGzippedTarball(Path.of(TARBALL_PATH), installDir);
+        File[] files = installDir.toFile().listFiles();
+        assert files != null && files.length == 1 && files[0].isDirectory() : "Expected a single directory in " + installDir;
+        Path cassandraHome = files[0].toPath();
+        Path originalCassandraConfDir = cassandraHome.resolve("conf");
+        copyDirectoryRecursively(originalCassandraConfDir, confDir);
+        Path cassandraStorageDir = Files.createDirectories(tmpDir.resolve("var/lib/cassandra"));
+        Path cassandraLogDir = Files.createDirectories(tmpDir.resolve("var/log"));
+        return ProcessRuntimeConfiguration.builder()
+                                          .instance(instanceMetadata())
+                                          .cassandraHome(cassandraHome.toString())
+                                          .cassandraConfDir(confDir.toString())
+                                          .cassandraLogDir(cassandraLogDir.toString())
+                                          .storageDir(cassandraStorageDir.toString())
+                                          .build();
+    }
+
+    static InstanceMetadata instanceMetadata()
+    {
+        return InstanceMetadataImpl.builder()
+                                   .id(TEST_NODE_ID)
+                                   .metricRegistry(METRIC_REGISTRY)
+                                   .storageDir("/tmp/storage_dir")
+                                   .build();
+    }
+}
diff --git a/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/utils/TestFileUtils.java b/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/utils/TestFileUtils.java
new file mode 100644
index 00000000..54a46495
--- /dev/null
+++ b/integration-tests/src/integrationTest/org/apache/cassandra/sidecar/utils/TestFileUtils.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.utils;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utilities for handling files on integration tests
+ */
+public class TestFileUtils
+{
+    private static final Logger LOGGER = LoggerFactory.getLogger(TestFileUtils.class);
+
+    public static void extractGzippedTarball(Path tarballPath, Path targetDir) throws IOException
+    {
+        // check if tarball exists and ends with .tar.gz
+        if (!tarballPath.toFile().exists() || !tarballPath.toString().endsWith(".tar.gz"))
+        {
+            throw new IllegalStateException("Cassandra tarball does not exist or is not a .tar.gz file: " + tarballPath);
+        }
+        LOGGER.info("Extracting tarball {} to directory {}", tarballPath, targetDir);
+        try (TarArchiveInputStream tarInput = new TarArchiveInputStream(new GzipCompressorInputStream(Files.newInputStream(tarballPath))))
+        {
+            TarArchiveEntry entry;
+            while ((entry = tarInput.getNextEntry()) != null)
+            {
+                Path entryPath = targetDir.resolve(entry.getName());
+                if (entry.isDirectory())
+                {
+                    Files.createDirectories(entryPath);
+                }
+                else
+                {
+                    Files.copy(tarInput, entryPath);
+                    // Set correct permission from TarArchiveEntry
+                    if (entry.getMode() != 0)
+                    {
+                        Files.setPosixFilePermissions(entryPath, permissionsFromInteger(entry.getMode()));
+                    }
+                }
+            }
+        }
+        LOGGER.info("Extracted tarball {} to directory {}", tarballPath, targetDir);
+    }
+
+    public static void copyDirectoryRecursively(Path sourceDir, Path targetDir) throws IOException
+    {
+        LOGGER.info("Copying directory {} to {}", sourceDir, targetDir);
+        try (var walk = Files.walk(sourceDir))
+        {
+            walk.forEach(source ->
+            {
+                try
+                {
+                    Path target = targetDir.resolve(sourceDir.relativize(source));
+                    if (Files.isDirectory(source))
+                    {
+                        Files.createDirectories(target);
+                    }
+                    else
+                    {
+                        Files.createDirectories(target.getParent());
+                        Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
+                    }
+                }
+                catch (IOException e)
+                {
+                    throw new RuntimeException("Failed to copy " + source, e);
+                }
+            });
+        }
+    }
+
+    public static void replacePlaceholdersInFileWithPattern(Path templateFile, Map<String, String> placeholders,
+                                                            Path destFile) throws IOException
+    {
+        LOGGER.info("Replacing placeholders {} in file {}", placeholders.keySet(), templateFile);
+        String content = Files.readString(templateFile);
+        for (Map.Entry<String, String> entry : placeholders.entrySet())
+        {
+            String placeholder = "\\$\\{" + Pattern.quote(entry.getKey()) + "\\}";
+            content = content.replaceAll(placeholder, Matcher.quoteReplacement(entry.getValue()));
+        }
+        Files.writeString(destFile, content, StandardOpenOption.CREATE_NEW);
+        LOGGER.info("Replaced {} placeholders in file {} and writing to {}", placeholders.size(), templateFile, destFile);
+    }
+
+    public static Set<PosixFilePermission> permissionsFromInteger(int mode)
+    {
+        Set<PosixFilePermission> permissions = EnumSet.noneOf(PosixFilePermission.class);
+
+        // Owner permissions
+        if ((mode & 0400) != 0)
+        {
+            permissions.add(PosixFilePermission.OWNER_READ);
+        }
+        if ((mode & 0200) != 0)
+        {
+            permissions.add(PosixFilePermission.OWNER_WRITE);
+        }
+        if ((mode & 0100) != 0)
+        {
+            permissions.add(PosixFilePermission.OWNER_EXECUTE);
+        }
+
+        // Group permissions
+        if ((mode & 0040) != 0)
+        {
+            permissions.add(PosixFilePermission.GROUP_READ);
+        }
+        if ((mode & 0020) != 0)
+        {
+            permissions.add(PosixFilePermission.GROUP_WRITE);
+        }
+        if ((mode & 0010) != 0)
+        {
+            permissions.add(PosixFilePermission.GROUP_EXECUTE);
+        }
+
+        // Others permissions
+        if ((mode & 0004) != 0)
+        {
+            permissions.add(PosixFilePermission.OTHERS_READ);
+        }
+        if ((mode & 0002) != 0)
+        {
+            permissions.add(PosixFilePermission.OTHERS_WRITE);
+        }
+        if ((mode & 0001) != 0)
+        {
+            permissions.add(PosixFilePermission.OTHERS_EXECUTE);
+        }
+
+        return permissions;
+    }
+}
diff --git a/conf/sidecar.yaml b/integration-tests/src/integrationTest/resources/config/sidecar.yaml.template
similarity index 83%
copy from conf/sidecar.yaml
copy to integration-tests/src/integrationTest/resources/config/sidecar.yaml.template
index 57be2e78..48a33440 100644
--- a/conf/sidecar.yaml
+++ b/integration-tests/src/integrationTest/resources/config/sidecar.yaml.template
@@ -21,7 +21,7 @@
 #
 cassandra_instances:
   - id: 1
-    host: localhost1
+    host: localhost
     port: 9042
 
     # The instance's storage directory as defined per the cassandra.storagedir property
@@ -30,7 +30,7 @@ cassandra_instances:
     # commitlog dir, cdc dir, hints dir and saved caches dir. If data_dirs, commitlog_dir,
     # cdc_dir, hints_dir and saved_caches_dir are configured explicitly, then they will be used.
     # Otherwise, default paths based on storage directory will be used.
-    storage_dir: /var/lib/cassandra
+    storage_dir: ${cassandraStorageDir}
 
     # List of directories where Cassandra stores data on disk. If not set, the default directory
     # is <storage_dir>/data
@@ -66,6 +66,9 @@ cassandra_instances:
     jmx_ssl_enabled: false
 #    jmx_role:
 #    jmx_role_password:
+    lifecycle_options:
+      cassandra_conf_dir: ${cassandraConfDir}
+      cassandra_log_dir: ${cassandraLogDir}
 
 sidecar:
   host: 0.0.0.0
@@ -107,7 +110,6 @@ sidecar:
       maximum_size: 10000
   cdc:
     segment_hardlink_cache_expiry: 5m # 5 minutes
-    table_schema_refresh_time: 60s    # Interval time between table schema refresh for CDC
   worker_pools:
     service:
       name: "sidecar-worker-pool"
@@ -163,26 +165,10 @@ sidecar:
       # The minimum value allowed for the cluster lease claim process task implementation is 30 seconds
       execute_interval: 100s
 
-# Exposes vertx configurations
 vertx:
-  # Configuration options for the vertx filesystem
-  # These options will affect the behavior when serving static resources from the
-  # classpath. For example, setting classpath_resolving_enabled to false will cause
-  # the static file resolution to only look for files in the filesystem and avoid
-  # resolving resources from the classpath.
   filesystem_options:
-    # When vert.x cannot find the file on the filesystem it tries to resolve the
-    # file from the classpath when this is set to true. Otherwise, vert.x
-    # will not attempt to resolve the file on the classpath
-    classpath_resolving_enabled: true
-    # Set to true to enable caching files on the real file system
-    # when the filesystem performs class path resolving. Set to false to
-    # disable caching.
-    file_caching_enabled: true
-    # When vert.x reads a file that is packaged with the application it gets
-    # extracted to this directory first and subsequent reads will use the extracted
-    # file to get better IO performance.
-    #file_cache_dir: /tmp
+    classpath_resolving_enabled: false
+    file_caching_enabled: false
 
 schema_reporting:                            # Schema Reporting configuration
   enabled:          false                    # Disabled by default
@@ -258,25 +244,6 @@ access_control:
         # trying to connect.
         client_id: recognized_client_id
         config_discover_interval: 1h
-        #
-        # Authentication type dictates whether to use the Oauth flow or a stateless JWT authenticator
-        # If not provided, default is oauth. Valid values are oauth or stateless
-        # jwt_auth_type: oauth
-        #
-        # Optional configurations for connecting to JWT PEM provider
-        #
-        # keystore_path defines the path to a keystore which can be used to provide SSL certificates to
-        # a JWT pem provider. If specified, a password must be provided.
-        # keystore_path: /path/to/keystore
-        # keystore_password: changeme
-        #
-        # truststore_path defines the path to a truststore that contains the CA used to validate SSL certs used
-        # by downstream PEM provider services. If specified, a password must be provided.
-        # truststore_path: /path/to/truststore
-        # truststore_password: changeme
-        #
-        # Optional JWT which can be used to authenticate to a downstream PEM provider if required.
-        # pem_provider_jwt: changeme
   authorizer:
     # Authorization backend, implementing io.vertx.ext.auth.authorization.AuthorizationProvider; used to
     # provide permissions a user holds.
@@ -292,10 +259,7 @@ access_control:
 #    - spiffe://authorized/admin/identities
   permission_cache:
     enabled: true
-    # refresh_after_write does async cache refreshes. expire_after_access removes the cache entry on expiry. Prefer
-    # setting refresh_after_write over expire_after_access for AuthCaches
-    # expire_after_access: 5m
-    refresh_after_write: 5m
+    expire_after_access: 5m
     maximum_size: 1000
     warmup_retries: 5
     warmup_retry_interval: 2s
@@ -375,23 +339,6 @@ metrics:
 #      value: "vertx.eventbus.*"              # exclude all metrics starts with vertx.eventbus
 
 cassandra_input_validation:
-  validator:
-    # Implementation to use for the validation of Casandra inputs. Out of the box, Cassandra Sidecar provides
-    # org.apache.cassandra.sidecar.utils.{RegexBasedCassandraInputValidator, FastCassandraInputValidator}.
-    #
-    # - RegexBasedCassandraInputValidator default implementation the uses regular expressions to perform validations
-    # - FastCassandraInputValidator optimized implementation that does not use regular expressions. This implementation
-    #                               ignores the regular expressions configured in the cassandra_input_validation
-    #                               configuration.
-  - class_name: org.apache.cassandra.sidecar.utils.RegexBasedCassandraInputValidator
-    # Configuration parameters that are only applicable to the FastCassandraInputValidator implementation
-    #parameters:
-    #  # Comma-separated list of terminations allowed for the component name. By default, allowed component
-    #  # names can only end in .db, .cql, .json, .crc32, or TOC.txt
-    #  valid_terminations: ".db,.cql,.json,.crc32,TOC.txt"
-    #  # Comma-separated list of terminations allowed for the restricted component name. By default, allowed
-    #  # restricted component names con only end in .db or TOC.txt
-    #  valid_restricted_terminations: ".db,TOC.txt"
   forbidden_keyspaces:
     - system_schema
     - system_traces
@@ -455,3 +402,15 @@ live_migration:
   migration_map:                          # Map of source and destination Cassandra instances
 #    localhost1: localhost4               # This entry says that localhost1 will be migrated to localhost4
   max_concurrent_downloads: 20            # Maximum number of concurrent downloads allowed
+
+# Configuration to allow sidecar start and stop Cassandra instances via the lifecycle API (disabled by default)
+lifecycle:
+    enabled: true
+    provider:
+        class_name: org.apache.cassandra.sidecar.lifecycle.ProcessLifecycleProvider
+        parameters:
+          state_dir: ${lifecycleDir}
+          cassandra_home: ${cassandraHome} # The default Cassandra installation directory
+          sys.cassandra.ring_delay_ms: 5000   # down from 30s default
+          env.MAX_HEAP_SIZE: 4G # this should be enough, since we only start and stop cassandra
+          env.HEAP_NEWSIZE: 1G # this should be enough, since we only start and stop cassandra
diff --git a/gradle.properties b/scripts/build-cassandra-tarball.sh
old mode 100644
new mode 100755
similarity index 51%
copy from gradle.properties
copy to scripts/build-cassandra-tarball.sh
index 57f87c4d..d47801d7
--- a/gradle.properties
+++ b/scripts/build-cassandra-tarball.sh
@@ -1,3 +1,4 @@
+#!/bin/bash
 #
 # Licensed to the Apache Software Foundation (ASF) under one
 # or more contributor license agreements.  See the NOTICE file
@@ -16,34 +17,14 @@
 # limitations under the License.
 #
 
-version=0.3.0
-junitVersion=5.9.2
-vertxVersion=4.5.23
-nettyVersion=4.1.118.Final
-guavaVersion=27.0.1-jre
-guiceVersion=7.0.0
-slf4jVersion=2.0.17
-logbackVersion=1.5.15
-jacksonVersion=2.14.3
-caffeineVersion=3.2.2
-dtestApiVersion=0.0.17
-assertjCoreVersion=3.24.2
-# trunk is currently 5.1 - update when trunk moves
-dtestVersion=5.1
-dtestDependencyName=cassandra-dtest-local-all
-awsSdkVersion=2.26.12
-# The dep is to introduce xxhash impl
-commonsCodecVersion=1.16.1
-# openSSL
-boringSslVersion=2.0.61.Final
-# OpenAPI dependencies
-smallryeOpenApiVersion=4.1.1
-microprofileOpenApiVersion=3.1.1
-jakartaWsRsVersion=3.1.0
-swaggerVersion=2.2.21
-# Cdc dependencies
-kryoVersion=4.0.2
-# OSHI dependencies
-oshiVersion=6.9.0
-analyticsVersion=0.2.0
-kafkaClientVersion=3.7.0
+set -xe
+
+export CASSANDRA_USE_JDK11=true
+SCRIPT_DIR=$( dirname -- "$( readlink -f -- "$0"; )"; )
+TARBALL_DIR="$(dirname "${SCRIPT_DIR}/")/cassandra-tarballs"
+
+ant artifacts -Dant.gen-doc.skip=true -Dno-checkstyle=true
+mkdir -p ${TARBALL_DIR}
+cp ./build/apache-cassandra-*-bin.tar.gz ${TARBALL_DIR}
+
+set +xe
diff --git a/scripts/build-dtest-jars.sh b/scripts/build-dtest-jars.sh
index 806de69a..d48b7bd3 100755
--- a/scripts/build-dtest-jars.sh
+++ b/scripts/build-dtest-jars.sh
@@ -30,11 +30,14 @@ REPO=${REPO:-"https://github.com/apache/cassandra.git"}
 SCRIPT_DIR=$( dirname -- "$( readlink -f -- "$0"; )"; )
 DTEST_JAR_DIR="$(dirname "${SCRIPT_DIR}/")/dtest-jars"
 DTEST_JAR_DIR=${CASSANDRA_DEP_DIR:-$DTEST_JAR_DIR}
+TARBALL_DIR="$(dirname "${SCRIPT_DIR}/")/cassandra-tarballs"
 BUILD_DIR="${DTEST_JAR_DIR}/build"
 
 if [[ "x$CLEAN" != "x" ]]; then
   echo "Clean up $DTEST_JAR_DIR"
   rm -rf $DTEST_JAR_DIR
+  echo "Cleanup $TARBALL_DIR"
+  rm -rf $TARBALL_DIR
 fi
 
 source "$SCRIPT_DIR/functions.sh"
@@ -83,16 +86,27 @@ for index in "${!CANDIDATE_BRANCHES[@]}"; do
   CASSANDRA_VERSION=$(cat build.xml | grep 'property name="base.version"' | awk -F "\"" '{print $4}')
   # Loop to prevent failure due to maven-ant-tasks not downloading a jar.
   for x in $(seq 1 3); do
-      if [ -f "${DTEST_JAR_DIR}/dtest-${CASSANDRA_VERSION}.jar" ]; then
-          RETURN="0"
+        RETURN="0"
+        DTEST_JAR_FILE="${DTEST_JAR_DIR}/dtest-${CASSANDRA_VERSION}.jar"
+        TARBALL_GLOB_FILE="${TARBALL_DIR}/apache-cassandra-${CASSANDRA_VERSION}*-bin.tar.gz"
+
+        if [ -f "${DTEST_JAR_DIR}/dtest-${CASSANDRA_VERSION}.jar" ] && (ls ${TARBALL_GLOB_FILE} >/dev/null 2>&1); then
+          echo "Found existing dtest jar ${DTEST_JAR_FILE} and tarball `ls ${TARBALL_GLOB_FILE}` for version ${CASSANDRA_VERSION}, skipping build."
           break
-        else
+        fi
+
+        if ! [ -f "${DTEST_JAR_FILE}" ]; then
+          echo "Building dtest jar ${DTEST_JAR_FILE} for version ${CASSANDRA_VERSION}"
           "${SCRIPT_DIR}/build-shaded-dtest-jar-local.sh"
           RETURN="$?"
-          if [ "${RETURN}" -eq "0" ]; then
-              break
-          fi
-      fi
+        fi
+
+        if [ "${RETURN}" -eq "0" ] && ! (ls ${TARBALL_GLOB_FILE} >/dev/null 2>&1); then
+          echo "Building tarball for version ${CASSANDRA_VERSION}"
+          "${SCRIPT_DIR}/build-cassandra-tarball.sh"
+          RETURN="$?"
+          break
+        fi
   done
   # Exit, if we didn't build successfully
   if [ "${RETURN}" -ne "0" ]; then
diff --git a/server/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadata.java b/server/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadata.java
index 5abcea8b..0118575d 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadata.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadata.java
@@ -20,6 +20,7 @@ package org.apache.cassandra.sidecar.cluster.instance;
 
 import java.net.UnknownHostException;
 import java.util.List;
+import java.util.Map;
 
 import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate;
 import org.apache.cassandra.sidecar.exceptions.CassandraUnavailableException;
@@ -74,6 +75,11 @@ public interface InstanceMetadata
      */
     String stagingDir();
 
+    /**
+     * @return the storage directory of the cassandra instance
+     */
+    String storageDir();
+
     /**
      * @return a {@link CassandraAdapterDelegate} specific for the instance, or throws when the delegate is unavailable
      * @throws CassandraUnavailableException when the Cassandra service is unavailable
@@ -118,4 +124,13 @@ public interface InstanceMetadata
      */
     @NotNull
     InstanceMetrics metrics();
+
+    /**
+     * @return The lifecycle options specific to this instance
+     * For example, Cassandra configuration directory or logging directory,
+     * since this is different between different Cassandra instances
+     * residing in the same host
+     */
+    @NotNull
+    Map<String, String> lifecycleOptions();
 }
diff --git a/server/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadataImpl.java b/server/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadataImpl.java
index b7e66f31..6d6410db 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadataImpl.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadataImpl.java
@@ -23,6 +23,7 @@ import java.net.UnknownHostException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.stream.Collectors;
 
@@ -64,10 +65,13 @@ public class InstanceMetadataImpl implements InstanceMetadata
     private final String savedCachesDir;
     private final String localSystemDataFileDir;
     @Nullable
+    private final String storageDir;
+    @Nullable
     private final CassandraAdapterDelegate delegate;
     private final InstanceMetrics metrics;
     private final DnsResolver dnsResolver;
     private volatile String ipAddress;
+    private final Map<String, String> lifecycleOptions;
 
     protected InstanceMetadataImpl(Builder builder)
     {
@@ -89,6 +93,10 @@ public class InstanceMetadataImpl implements InstanceMetadata
         hintsDir = builder.resolveHintsDir();
         savedCachesDir = builder.resolveSavedCachesDir();
         localSystemDataFileDir = FileUtils.maybeResolveHomeDirectory(builder.localSystemDataFileDir);
+        lifecycleOptions = builder.lifecycleOptions != null
+                           ? Collections.unmodifiableMap(builder.lifecycleOptions)
+                           : Collections.emptyMap();
+        storageDir = builder.storageDir;
     }
 
     @Override
@@ -136,6 +144,12 @@ public class InstanceMetadataImpl implements InstanceMetadata
         return stagingDir;
     }
 
+    @Override
+    public String storageDir()
+    {
+        return storageDir;
+    }
+
     @Override
     @Nullable
     public String cdcDir()
@@ -188,6 +202,16 @@ public class InstanceMetadataImpl implements InstanceMetadata
         return metrics;
     }
 
+    /**
+     * @return The lifecycle options for this Cassandra instance
+     */
+    @Override
+    @NotNull
+    public Map<String, String> lifecycleOptions()
+    {
+        return lifecycleOptions;
+    }
+
     public static Builder builder()
     {
         return new Builder();
@@ -224,6 +248,7 @@ public class InstanceMetadataImpl implements InstanceMetadata
         protected CassandraAdapterDelegate delegate;
         protected MetricRegistry metricRegistry;
         protected InstanceMetrics metrics;
+        protected Map<String, String> lifecycleOptions;
 
         protected Builder()
         {
@@ -244,6 +269,7 @@ public class InstanceMetadataImpl implements InstanceMetadata
             localSystemDataFileDir = instanceMetadata.localSystemDataFileDir;
             delegate = instanceMetadata.delegate;
             metrics = instanceMetadata.metrics;
+            lifecycleOptions = instanceMetadata.lifecycleOptions;
         }
 
         @Override
@@ -378,6 +404,17 @@ public class InstanceMetadataImpl implements InstanceMetadata
             return update(b -> b.savedCachesDir = savedCachesDir);
         }
 
+        /**
+         * Sets the {@code lifecycleOptions} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param lifecycleOptions the {@code lifecycleOptions} to set
+         * @return a reference to this Builder
+         */
+        public Builder lifecycleOptions(Map<String, String> lifecycleOptions)
+        {
+            return update(b -> b.lifecycleOptions = lifecycleOptions);
+        }
+
         /**
          * Sets the {@code localSystemDataFileDir} and return a reference to this Builder enabling method chaining.
          *
diff --git a/server/src/main/java/org/apache/cassandra/sidecar/config/InstanceConfiguration.java b/server/src/main/java/org/apache/cassandra/sidecar/config/InstanceConfiguration.java
index bd34f481..f91b2714 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/config/InstanceConfiguration.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/config/InstanceConfiguration.java
@@ -19,6 +19,7 @@
 package org.apache.cassandra.sidecar.config;
 
 import java.util.List;
+import java.util.Map;
 
 /**
  * Encapsulates the basic configuration needed to connect to a single Cassandra instance
@@ -104,4 +105,9 @@ public interface InstanceConfiguration
      * @return the password for the JMX role for the JMX service for the Cassandra instance
      */
     String jmxRolePassword();
+
+    /**
+     * @return The lifecycle options for this Cassandra instance
+     */
+    Map<String, String> lifecycleOptions();
 }
diff --git a/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/InstanceConfigurationImpl.java b/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/InstanceConfigurationImpl.java
index d7360891..b952ca24 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/InstanceConfigurationImpl.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/config/yaml/InstanceConfigurationImpl.java
@@ -20,6 +20,7 @@ package org.apache.cassandra.sidecar.config.yaml;
 
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
@@ -48,6 +49,7 @@ public class InstanceConfigurationImpl implements InstanceConfiguration
     protected final boolean jmxSslEnabled;
     protected final String jmxRole;
     protected final String jmxRolePassword;
+    protected final Map<String, String> lifecycleOptions;
 
     @JsonCreator
     public InstanceConfigurationImpl(@JsonProperty("id") int id,
@@ -65,7 +67,8 @@ public class InstanceConfigurationImpl implements InstanceConfiguration
                                      @JsonProperty("jmx_port") int jmxPort,
                                      @JsonProperty("jmx_ssl_enabled") boolean jmxSslEnabled,
                                      @Nullable @JsonProperty("jmx_role") String jmxRole,
-                                     @Nullable @JsonProperty("jmx_role_password") String jmxRolePassword)
+                                     @Nullable @JsonProperty("jmx_role_password") String jmxRolePassword,
+                                     @Nullable @JsonProperty("lifecycle_options") Map<String, String> lifecycleOptions)
     {
         this.id = id;
         this.host = host;
@@ -83,6 +86,7 @@ public class InstanceConfigurationImpl implements InstanceConfiguration
         this.jmxSslEnabled = jmxSslEnabled;
         this.jmxRole = jmxRole;
         this.jmxRolePassword = jmxRolePassword;
+        this.lifecycleOptions = lifecycleOptions != null ? Collections.unmodifiableMap(lifecycleOptions) : Collections.emptyMap();
     }
 
     /**
@@ -241,4 +245,14 @@ public class InstanceConfigurationImpl implements InstanceConfiguration
     {
         return jmxRolePassword;
     }
+
+    /**
+     * @return The lifecycle options for this Cassandra instance
+     */
+    @Override
+    @JsonProperty("lifecycle_options")
+    public Map<String, String> lifecycleOptions()
+    {
+        return lifecycleOptions;
+    }
 }
diff --git a/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/LifecycleManager.java b/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/LifecycleManager.java
index 18338741..77883a70 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/LifecycleManager.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/LifecycleManager.java
@@ -148,7 +148,7 @@ public class LifecycleManager
             }
             catch (Exception e)
             {
-                LOG.error("Failed to start instance {}: {}", instanceId, e.getMessage());
+                LOG.error("Failed to start instance {}", instanceId, e);
                 lastUpdateMsgByInstance.put(instanceId, String.format("Failed to start instance %s: %s", instanceId, e.getMessage()));
             }
         });
@@ -166,7 +166,7 @@ public class LifecycleManager
             }
             catch (Exception e)
             {
-                LOG.error("Failed to stop instance {}: {}", instanceId, e.getMessage());
+                LOG.error("Failed to stop instance {}", instanceId, e);
                 lastUpdateMsgByInstance.put(instanceId, String.format("Failed to stop instance %s: %s", instanceId, e.getMessage()));
             }
         });
diff --git a/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProvider.java b/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProvider.java
index 681dc990..cfa68e73 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProvider.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProvider.java
@@ -18,38 +18,375 @@
 
 package org.apache.cassandra.sidecar.lifecycle;
 
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.exceptions.ConfigurationException;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.VisibleForTesting;
 
 /**
- * A {@link LifecycleProvider} that manages Cassandra instances as OS processes.
- * <p>
- * This implementation is a placeholder and is not yet implemented.
+ * Manage the lifecycle of Cassandra instances running on local processes
  */
 public class ProcessLifecycleProvider implements LifecycleProvider
 {
-    public ProcessLifecycleProvider(Map<String, String> params)
+    static final String OPT_CASSANDRA_HOME = "cassandra_home";
+    static final String OPT_CASSANDRA_CONF_DIR = "cassandra_conf_dir";
+    static final String OPT_CASSANDRA_LOG_DIR = "cassandra_log_dir";
+    static final String OPT_CASSANDRA_YAML_PATH = "cassandra_yaml_path";
+    static final String OPT_STATE_DIR = "state_dir";
+    static final String OPT_PROCESS_TIMEOUT_SECONDS = "process_timeout_seconds";
+
+    protected static final Logger LOGGER = LoggerFactory.getLogger(ProcessLifecycleProvider.class);
+    public static final long DEFAULT_PROCESS_TIMEOUT_SECONDS = 120L;
+
+    private final String lifecycleDir;
+    private final String defaultCassandraHome;
+    private final long processTimeoutSeconds;
+    private final Map<String, String> defaultJvmProperties = new HashMap<>();
+    private final Map<String, String> defaultEnvVars = new HashMap<>();
+
+    public ProcessLifecycleProvider(@NotNull Map<String, String> params)
     {
-        // Params unused for now
+        // Extract any JVM properties or environment variables from the params
+        for (Map.Entry<String, String> entry : params.entrySet())
+        {
+            if (entry.getKey().startsWith("sys."))
+            {
+                defaultJvmProperties.put(entry.getKey().replaceFirst("^sys\\.", ""), entry.getValue());
+            }
+            else if (entry.getKey().startsWith("env."))
+            {
+                defaultEnvVars.put(entry.getKey().replaceFirst("^env\\.", ""), entry.getValue());
+            }
+        }
+        this.lifecycleDir = params.get(OPT_STATE_DIR);
+        this.defaultCassandraHome = params.get(OPT_CASSANDRA_HOME);
+        String timeoutStr = params.get(OPT_PROCESS_TIMEOUT_SECONDS);
+        this.processTimeoutSeconds = timeoutStr != null ? Long.parseLong(timeoutStr) : DEFAULT_PROCESS_TIMEOUT_SECONDS;
+        validateConfiguration();
     }
 
     @Override
     public void start(InstanceMetadata instance)
     {
-        throw new UnsupportedOperationException("Not implemented yet");
+        if (isCassandraProcessRunning(instance))
+        {
+            LOGGER.info("Cassandra instance {} is already running.", instance);
+            return;
+        }
+        startCassandra(instance);
     }
 
     @Override
     public void stop(InstanceMetadata instance)
     {
-        throw new UnsupportedOperationException("Not implemented yet");
-
+        if (!isCassandraProcessRunning(instance))
+        {
+            LOGGER.info("Cassandra instance {} is already stopped.", instance);
+            return;
+        }
+        stopCassandra(instance);
     }
 
     @Override
     public boolean isRunning(InstanceMetadata instance)
     {
-        throw new UnsupportedOperationException("Not implemented yet");
+        return isCassandraProcessRunning(instance);
+    }
+
+    protected void validateConfiguration()
+    {
+        if (lifecycleDir == null || lifecycleDir.isEmpty())
+        {
+            throw new ConfigurationException("Configuration property '" + OPT_STATE_DIR + "' must be set for ProcessLifecycleProvider");
+        }
+        if (defaultCassandraHome == null || defaultCassandraHome.isEmpty())
+        {
+            throw new ConfigurationException("Configuration property '" + OPT_CASSANDRA_HOME + "' must be set for ProcessLifecycleProvider");
+        }
+
+        Path stateDir = Path.of(lifecycleDir);
+        if (!Files.isDirectory(stateDir))
+        {
+            throw new ConfigurationException("State directory '" + lifecycleDir + "' does not exist or is not a directory");
+        }
+        if (!Files.isWritable(stateDir))
+        {
+            throw new ConfigurationException("State directory '" + lifecycleDir + "' is not writable");
+        }
+
+        Path cassandraHomePath = Path.of(defaultCassandraHome);
+        if (!Files.isDirectory(cassandraHomePath))
+        {
+            throw new ConfigurationException("Cassandra home '" + defaultCassandraHome + "' does not exist or is not a directory");
+        }
+        if (!Files.isReadable(cassandraHomePath))
+        {
+            throw new ConfigurationException("Cassandra home '" + defaultCassandraHome + "' is not readable");
+        }
+    }
+
+    protected void startCassandra(InstanceMetadata instance)
+    {
+        ProcessRuntimeConfiguration runtimeConfig = getRuntimeConfiguration(instance);
+        try
+        {
+            Path stdoutLocation = stdoutLocation(runtimeConfig.instance());
+            Path stderrLocation = stderrLocation(runtimeConfig.instance());
+            String pidFileLocation = pidFileLocation(runtimeConfig.instance());
+            ProcessBuilder processBuilder = runtimeConfig.buildStartCommand(pidFileLocation,
+                                                                            stdoutLocation,
+                                                                            stderrLocation);
+            LOGGER.info("Starting Cassandra instance {} with command: {}", runtimeConfig.instance(), processBuilder.command());
+
+            Process process = processBuilder.start();
+            boolean completed = process.waitFor(processTimeoutSeconds, TimeUnit.SECONDS);
+
+            if (!completed)
+            {
+                LOGGER.warn("Cassandra startup script for instance {} did not complete within {} seconds",
+                            runtimeConfig.instance(), processTimeoutSeconds);
+            }
+
+            if (isCassandraProcessRunning(instance))
+            {
+                LOGGER.info("Started Cassandra instance {} with PID {}", runtimeConfig.instance(), readPidFromFile(Path.of(pidFileLocation)));
+            }
+            else
+            {
+                throw new RuntimeException("Failed to start Cassandra instance " + runtimeConfig.instance() +
+                                           ". Check stdout at " + stdoutLocation + " and stderr at " + stderrLocation);
+            }
+        }
+        catch (Throwable t)
+        {
+            throw new RuntimeException("Failed to start Cassandra instance " + runtimeConfig.instance() + " due to " + t.getMessage(), t);
+        }
+    }
+
+    protected void stopCassandra(InstanceMetadata instance)
+    {
+        ProcessRuntimeConfiguration casCfg = getRuntimeConfiguration(instance);
+        try
+        {
+            String pidFileLocation = pidFileLocation(casCfg.instance());
+            Path pidFilePath = Path.of(pidFileLocation);
+            Long pid = readPidFromFile(pidFilePath);
+            Optional<ProcessHandle> processHandle = ProcessHandle.of(pid);
+            if (processHandle.isPresent())
+            {
+                LOGGER.info("Stopping process of Cassandra instance {} with PID {}.", casCfg.instance(), pid);
+                CompletableFuture<ProcessHandle> terminationFuture = processHandle.get().onExit();
+                processHandle.get().destroy();
+                try
+                {
+                    terminationFuture.get(processTimeoutSeconds, TimeUnit.SECONDS);
+                }
+                catch (TimeoutException e)
+                {
+                    LOGGER.warn("Process {} did not terminate within timeout, forcing destroy.", pid);
+                    boolean destroyed = processHandle.get().destroyForcibly();
+                    if (!destroyed)
+                    {
+                        throw new RuntimeException("Failed to forcibly destroy process " + pid +
+                                                   " for Cassandra instance " + casCfg.instance(), e);
+                    }
+                    LOGGER.info("Process {} was forcibly destroyed for instance {}", pid, casCfg.instance());
+                }
+                Files.deleteIfExists(pidFilePath);
+            }
+            else
+            {
+                LOGGER.warn("No process running for Cassandra instance {} with PID {}.", casCfg.instance(), pid);
+            }
+        }
+        catch (Throwable t)
+        {
+            throw new RuntimeException("Failed to stop process for Cassandra instance " + casCfg.instance() + " due to " + t.getMessage(), t);
+        }
+    }
+
+    @VisibleForTesting
+    protected ProcessRuntimeConfiguration getRuntimeConfiguration(InstanceMetadata instance)
+    {
+        Map<String, String> options = instance.lifecycleOptions();
+        String cassandraHome = options.getOrDefault(OPT_CASSANDRA_HOME, defaultCassandraHome);
+        String cassandraConfDir = options.get(OPT_CASSANDRA_CONF_DIR);
+        String cassandraLogDir = options.get(OPT_CASSANDRA_LOG_DIR);
+        String cassandraYamlPath = options.get(OPT_CASSANDRA_YAML_PATH);
+        return ProcessRuntimeConfiguration.builder()
+                                          .instance(instance)
+                                          .cassandraHome(cassandraHome)
+                                          .cassandraConfDir(cassandraConfDir)
+                                          .cassandraLogDir(cassandraLogDir)
+                                          .cassandraYamlPath(cassandraYamlPath)
+                                          .storageDir(instance.storageDir())
+                                          .extraJvmOptions(defaultJvmProperties)
+                                          .extraEnvironmentVariables(defaultEnvVars)
+                                          .build();
+    }
+
+    /**
+     * Checks whether a Cassandra instance is currently running as a local process
+     * and automatically cleans up stale PID files.
+     *
+     * <p>Performs four validation steps:
+     * <ol>
+     * <li>Verifies the PID file exists and is readable. Returns false if not found.
+     * <li>Reads the PID and checks if the process is alive. Returns false and deletes the
+     * PID file if the process no longer exists or is not alive.
+     * <li>Verifies the process is a Cassandra instance by checking for
+     * {@code org.apache.cassandra.service.CassandraDaemon} in the command line. Returns true if
+     * the command line contains the Cassandra daemon class or cannot be determined.
+     * <li>If the process is running but is not a Cassandra process, returns false and deletes
+     * the stale PID file.
+     * </ol>
+     *
+     * @param instance the instance metadata containing host information
+     * @return true if the instance is running as a Cassandra process, false otherwise
+     */
+    private boolean isCassandraProcessRunning(InstanceMetadata instance)
+    {
+        Path pidFilePath = Path.of(pidFileLocation(instance));
+        try
+        {
+            // Case 1: PID file does not exist or is not readable
+            if (!Files.isRegularFile(pidFilePath) || !Files.isReadable(pidFilePath))
+            {
+                LOGGER.warn("PID file does not exist or is not readable for instance {} at path {}", instance, pidFilePath);
+                return false;
+            }
+
+            Long pid = readPidFromFile(pidFilePath);
+            Optional<ProcessHandle> processHandle = ProcessHandle.of(pid);
+
+            // Case 2: No process running with such PID or process is not alive
+            if (processHandle.isEmpty() || !processHandle.get().isAlive())
+            {
+                LOGGER.warn("No running process found with PID {} for instance {}", pid, instance);
+                deletePidFile(instance, pidFilePath);
+                return false;
+            }
+
+            // Case 3: Process with such PID is running - check if it's a Cassandra process
+            // If we can't determine the command line, we assume it's Cassandra
+            Optional<String> cmdLine = getCommandLinePlatformIndependent(processHandle.get());
+            if (cmdLine.isEmpty() || cmdLine.get().contains("org.apache.cassandra.service.CassandraDaemon"))
+            {
+                LOGGER.warn("Cassandra instance {} is running with PID {}", instance, pid);
+                return true;
+            }
+
+            // Case 4: Process with such PID is running but it's not a Cassandra process
+            LOGGER.warn("Process with PID {} for instance {} is not a Cassandra process (command line: {}).",
+                        pid, instance, cmdLine);
+            deletePidFile(instance, pidFilePath);
+            return false;
+        }
+        catch (Exception e)
+        {
+            LOGGER.warn("Failed to read PID from file {} for instance {}", pidFilePath, instance, e);
+            return false;
+        }
+    }
+
+    protected static void deletePidFile(InstanceMetadata instance, Path pidFilePath)
+    {
+        try
+        {
+            LOGGER.info("Deleting stale PID file {} for instance {}", pidFilePath, instance);
+            Files.delete(pidFilePath);
+        }
+        catch (Exception e)
+        {
+            LOGGER.warn("Failed to delete stale PID file {} for instance {}", pidFilePath, instance, e);
+        }
+    }
+
+    /*
+     * Due to JDK-8345117 java can sometimes truncate the command line returned by ProcessHandle.info().commandLine()
+     * on some platforms (ie. Linux). To work around this, we use the 'ps' command to get the full command line.
+     * This method should be platform-independent as it relies on the 'ps' command which is available on most Unix-like systems.
+     * For non-Unix systems, we fall back to the default implementation.
+     */
+    protected static Optional<String> getCommandLinePlatformIndependent(ProcessHandle processHandle)
+    {
+        long pid = processHandle.pid();
+        try
+        {
+            ProcessBuilder pb = new ProcessBuilder("ps", "-p", String.valueOf(pid), "-o", "args=");
+            Process proc = pb.start();
+            try (BufferedReader reader = new BufferedReader(
+            new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8)))
+            {
+                String line = reader.readLine();
+                proc.waitFor(5, TimeUnit.SECONDS);
+                if (line != null && !line.isEmpty())
+                {
+                    return Optional.of(line.trim());
+                }
+            }
+        }
+        catch (Exception e)
+        {
+            LOGGER.warn("Failed to get command line via ps for PID {}", pid, e);
+        }
+        // Fallback to default implementation
+        return processHandle.info().commandLine();
+    }
+
+    @VisibleForTesting
+    public static Long readPidFromFile(Path pidFilePath)
+    {
+        String pidFileContent = null;
+        try
+        {
+            pidFileContent = Files.readString(pidFilePath, StandardCharsets.UTF_8).trim();
+            return Long.parseLong(pidFileContent);
+        }
+        catch (NumberFormatException e)
+        {
+            throw new RuntimeException("Unable to parse PID from file: " + pidFilePath + " content: " + pidFileContent, e);
+        }
+        catch (IOException e)
+        {
+            throw new RuntimeException("Failed to read PID from file: " + pidFilePath, e);
+        }
+    }
+
+    protected String pidFileLocation(InstanceMetadata instance)
+    {
+        return pidFileLocation(lifecycleDir, instance.id());
+    }
+
+    protected Path stdoutLocation(InstanceMetadata instance)
+    {
+        return Path.of(lifecycleDir).resolve("start-cassandra-" + instance.id() + ".out");
+    }
+
+    protected Path stderrLocation(InstanceMetadata instance)
+    {
+        return Path.of(lifecycleDir).resolve("start-cassandra-" + instance.id() + ".err");
+    }
+
+    @VisibleForTesting
+    public static String pidFileLocation(String lifecycleDir, int instanceId)
+    {
+        return Path.of(lifecycleDir).resolve("cassandra-" + instanceId + ".pid").toString();
     }
 }
diff --git a/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/ProcessRuntimeConfiguration.java b/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/ProcessRuntimeConfiguration.java
new file mode 100644
index 00000000..e5fb46cb
--- /dev/null
+++ b/server/src/main/java/org/apache/cassandra/sidecar/lifecycle/ProcessRuntimeConfiguration.java
@@ -0,0 +1,256 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.lifecycle;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.common.DataObjectBuilder;
+import org.apache.cassandra.sidecar.exceptions.ConfigurationException;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.VisibleForTesting;
+
+/**
+ * Represents the configuration for a Cassandra process instance.
+ */
+public class ProcessRuntimeConfiguration
+{
+    private final InstanceMetadata instance;
+    @NotNull
+    private final Path cassandraHome;
+    private final Path cassandraYaml;
+    @VisibleForTesting
+    @NotNull
+    final Path cassandraConfDir;
+    @VisibleForTesting
+    @Nullable
+    final String cassandraLogDir;
+    @VisibleForTesting
+    @Nullable
+    final String storageDir;
+    private final Map<String, String> extraJvmOptions;
+    private final Map<String, String> extraEnvironmentVariables;
+
+    private ProcessRuntimeConfiguration(Builder builder)
+    {
+        instance = builder.instance;
+        cassandraHome = Path.of(builder.cassandraHome);
+        cassandraConfDir = Path.of(builder.cassandraConfDir);
+        cassandraLogDir = builder.cassandraLogDir;
+        cassandraYaml = builder.cassandraYamlPath == null ? cassandraConfDir.resolve("cassandra.yaml") : Path.of(builder.cassandraYamlPath);
+        extraEnvironmentVariables = builder.extraEnvironmentVariables == null ? Map.of() : Map.copyOf(builder.extraEnvironmentVariables);
+        storageDir = builder.storageDir;
+        extraJvmOptions = builder.extraJvmOptions == null ? Map.of() : Map.copyOf(builder.extraJvmOptions);
+    }
+
+    public InstanceMetadata instance()
+    {
+        return instance;
+    }
+
+    public Path cassandraHome()
+    {
+        return cassandraHome;
+    }
+
+    public String cassandraConf()
+    {
+        return cassandraConfDir.toString();
+    }
+
+    public Path cassandraBin()
+    {
+        return cassandraHome.resolve("bin").resolve("cassandra");
+    }
+
+    public Path cassandraYaml()
+    {
+        return cassandraYaml;
+    }
+
+    public void validateStart() throws ConfigurationException
+    {
+        // Check existence
+        if (!Files.isDirectory(cassandraHome))
+        {
+            throw new ConfigurationException("Cassandra home does not exist or is not a directory: " + cassandraHome);
+        }
+        if (!Files.isDirectory(cassandraConfDir))
+        {
+            throw new ConfigurationException("Cassandra configuration directory does not exist or is not a directory: " + cassandraConfDir);
+        }
+        if (!Files.isRegularFile(cassandraYaml()))
+        {
+            throw new ConfigurationException("Cassandra YAML configuration file does not exist: " + cassandraYaml());
+        }
+        if (!Files.isRegularFile(cassandraBin()))
+        {
+            throw new ConfigurationException("Cassandra binary does not exist or is not a regular file: " + cassandraBin());
+        }
+        // Check permissions
+        if (!Files.isExecutable(cassandraBin()))
+        {
+            throw new ConfigurationException("Cassandra binary is not executable: " + cassandraBin());
+        }
+        if (!Files.isReadable(cassandraConfDir))
+        {
+            throw new ConfigurationException("Cassandra configuration directory is not readable: " + cassandraConfDir);
+        }
+    }
+
+    public ProcessBuilder buildStartCommand(String pidFileLocation, Path stdoutFileLocation, Path stderrFileLocation)
+    {
+        validateStart();
+
+        List<String> startCassandraCmd = new ArrayList<>();
+        startCassandraCmd.add(cassandraBin().toString());
+        startCassandraCmd.add("-p");
+        startCassandraCmd.add(pidFileLocation);
+        for (Map.Entry<String, String> jvmOpt : extraJvmOptions.entrySet())
+        {
+            startCassandraCmd.add("-D" + jvmOpt.getKey() + "=" + jvmOpt.getValue());
+        }
+
+        // Override storage dir if present in sidecar configuration
+        if (storageDir != null)
+        {
+            startCassandraCmd.add("-D");
+            startCassandraCmd.add("cassandra.storagedir=" + storageDir);
+        }
+
+        ProcessBuilder processBuilder = new ProcessBuilder();
+        processBuilder.command(startCassandraCmd);
+
+        // Set environment variables
+        Map<String, String> env = processBuilder.environment();
+        env.put("CASSANDRA_HOME", cassandraHome().toString());
+        env.put("CASSANDRA_CONF", cassandraConf());
+        env.putAll(extraEnvironmentVariables);
+
+        // Only override CASSANDRA_LOG_DIR if it is set in the configuration
+        if (cassandraLogDir != null)
+        {
+            env.put("CASSANDRA_LOG_DIR", cassandraLogDir);
+        }
+
+        // Redirect output to logs
+        processBuilder.redirectOutput(ProcessBuilder.Redirect.to(stdoutFileLocation.toFile()));
+        processBuilder.redirectError(ProcessBuilder.Redirect.to(stderrFileLocation.toFile()));
+
+        // Set working directory
+        processBuilder.directory(cassandraHome().toFile());
+        return processBuilder;
+    }
+
+    @Override
+    public String toString()
+    {
+        return "ProcessRuntimeConfiguration{" +
+               "instance=" + instance +
+               ", cassandraHome=" + cassandraHome +
+               ", cassandraConfDir=" + cassandraConfDir +
+               ", cassandraLogDir='" + cassandraLogDir + '\'' +
+               ", storageDir='" + storageDir + '\'' +
+               ", extraJvmOpts=" + extraJvmOptions +
+               ", extraEnvVars=" + extraEnvironmentVariables +
+               '}';
+    }
+
+    public static Builder builder()
+    {
+        return new Builder();
+    }
+
+    /**
+     * Builder for constructing {@link ProcessRuntimeConfiguration} instances.
+     */
+    public static final class Builder implements DataObjectBuilder<Builder, ProcessRuntimeConfiguration>
+    {
+        private InstanceMetadata instance;
+        private String cassandraHome;
+        private String cassandraConfDir;
+        private @Nullable String cassandraLogDir;
+        private @Nullable String cassandraYamlPath;
+        private @Nullable String storageDir;
+        private Map<String, String> extraJvmOptions;
+        private Map<String, String> extraEnvironmentVariables;
+
+        private Builder()
+        {
+        }
+
+        @Override
+        public Builder self()
+        {
+            return this;
+        }
+
+        public Builder instance(InstanceMetadata instance)
+        {
+            return update(b -> b.instance = Objects.requireNonNull(instance, "instance is required"));
+        }
+
+        public Builder cassandraHome(@NotNull String cassandraHome)
+        {
+            return update(b -> b.cassandraHome = Objects.requireNonNull(cassandraHome, "cassandraHome is required"));
+        }
+
+        public Builder cassandraConfDir(@NotNull String cassandraConfDir)
+        {
+            return update(b -> b.cassandraConfDir = Objects.requireNonNull(cassandraConfDir, "cassandraConfDir is required"));
+        }
+
+        public Builder cassandraLogDir(@Nullable String cassandraLogDir)
+        {
+            return update(b -> b.cassandraLogDir = cassandraLogDir);
+        }
+
+        public Builder cassandraYamlPath(@Nullable String cassandraYamlPath)
+        {
+            return update(b -> b.cassandraYamlPath = cassandraYamlPath);
+        }
+
+        public Builder storageDir(@Nullable String storageDir)
+        {
+            return update(b -> b.storageDir = storageDir);
+        }
+
+        public Builder extraJvmOptions(Map<String, String> extraJvmOptions)
+        {
+            return update(b -> b.extraJvmOptions = extraJvmOptions);
+        }
+
+        public Builder extraEnvironmentVariables(Map<String, String> extraEnvironmentVariables)
+        {
+            return update(b -> b.extraEnvironmentVariables = extraEnvironmentVariables);
+        }
+
+        @Override
+        public ProcessRuntimeConfiguration build()
+        {
+            return new ProcessRuntimeConfiguration(this);
+        }
+    }
+}
diff --git a/server/src/main/java/org/apache/cassandra/sidecar/modules/ConfigurationModule.java b/server/src/main/java/org/apache/cassandra/sidecar/modules/ConfigurationModule.java
index dae5b61d..d91107b5 100644
--- a/server/src/main/java/org/apache/cassandra/sidecar/modules/ConfigurationModule.java
+++ b/server/src/main/java/org/apache/cassandra/sidecar/modules/ConfigurationModule.java
@@ -252,6 +252,7 @@ public class ConfigurationModule extends AbstractModule
                                    .hintsDir(cassandraInstance.hintsDir())
                                    .savedCachesDir(cassandraInstance.savedCachesDir())
                                    .localSystemDataFileDir(cassandraInstance.localSystemDataFileDir())
+                                   .lifecycleOptions(cassandraInstance.lifecycleOptions())
                                    .delegate(delegate)
                                    .metricRegistry(instanceSpecificRegistry)
                                    .build();
diff --git a/server/src/test/java/org/apache/cassandra/sidecar/config/SidecarConfigurationTest.java b/server/src/test/java/org/apache/cassandra/sidecar/config/SidecarConfigurationTest.java
index db83cedd..11dd5d87 100644
--- a/server/src/test/java/org/apache/cassandra/sidecar/config/SidecarConfigurationTest.java
+++ b/server/src/test/java/org/apache/cassandra/sidecar/config/SidecarConfigurationTest.java
@@ -481,6 +481,35 @@ class SidecarConfigurationTest
         assertThat(clusterLeaseConfig.electorateMembershipStrategy()).isEqualTo("MostReplicatedKeyspaceTokenZeroElectorateMembership");
     }
 
+    @Test
+    void testLifecycle() throws Exception
+    {
+        String yaml = "lifecycle:\n" +
+                      "  enabled: true\n" +
+                      "  provider:\n" +
+                      "    class_name: org.apache.cassandra.sidecar.lifecycle.ProcessLifecycleProvider\n" +
+                      "    parameters:\n" +
+                      "      state_dir: /var/lib/cassandra-sidecar/lifecycle\n" +
+                      "      cassandra_home: /opt/cassandra\n" +
+                      "      sys.cassandra.ring_delay_ms: 30000\n" +
+                      "      sys.com.sun.management.jmxremote.authenticate: true\n" +
+                      "      env.MAX_HEAP_SIZE: 7G\n" +
+                      "      env.HEAP_NEWSIZE: 1G";
+        SidecarConfiguration config = SidecarConfigurationImpl.fromYamlString(yaml);
+        LifecycleConfiguration lifecycleConfiguration = config.lifecycleConfiguration();
+        assertThat(lifecycleConfiguration).isNotNull();
+
+        assertThat(lifecycleConfiguration.enabled()).isTrue();
+        ParameterizedClassConfiguration provider = lifecycleConfiguration.lifecycleProvider();
+        assertThat(provider.className()).isEqualTo("org.apache.cassandra.sidecar.lifecycle.ProcessLifecycleProvider");
+        assertThat(provider.namedParameters()).containsEntry("state_dir", "/var/lib/cassandra-sidecar/lifecycle")
+                                              .containsEntry("cassandra_home", "/opt/cassandra")
+                                              .containsEntry("sys.cassandra.ring_delay_ms", "30000")
+                                              .containsEntry("sys.com.sun.management.jmxremote.authenticate", "true")
+                                              .containsEntry("env.MAX_HEAP_SIZE", "7G")
+                                              .containsEntry("env.HEAP_NEWSIZE", "1G");
+    }
+
     @Test
     void testDnsResolverDefault() throws Exception
     {
diff --git a/server/src/test/java/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProviderTest.java b/server/src/test/java/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProviderTest.java
new file mode 100644
index 00000000..555e094f
--- /dev/null
+++ b/server/src/test/java/org/apache/cassandra/sidecar/lifecycle/ProcessLifecycleProviderTest.java
@@ -0,0 +1,720 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.lifecycle;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeoutException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.codahale.metrics.MetricRegistry;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadataImpl;
+import org.apache.cassandra.sidecar.exceptions.ConfigurationException;
+import org.mockito.MockedStatic;
+
+import static org.apache.cassandra.sidecar.lifecycle.ProcessLifecycleProvider.pidFileLocation;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatRuntimeException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link ProcessLifecycleProvider}. These tests use mocking to simulate process behavior.
+ * Actual process execution is tested by the {@link org.apache.cassandra.sidecar.lifecycle.ProcessLifecycleProviderIntegrationTest}
+ */
+public class ProcessLifecycleProviderTest
+{
+    private static final MetricRegistry METRIC_REGISTRY = new MetricRegistry();
+
+    @TempDir
+    Path lifecycleStateDir;
+
+    Path defaultCassandraHome;
+
+    @BeforeEach
+    void setUp() throws IOException
+    {
+        defaultCassandraHome = lifecycleStateDir.resolve("cassandra-home");
+        Files.createDirectories(defaultCassandraHome);
+    }
+
+    /**
+     * A fake implementation of ProcessLifecycleProvider for testing purposes.
+     * This class overrides the buildCassandraConfig method to return a mock configuration to avoid actual process execution.
+     * Also, this simulates starting and stopping a process by creating and deleting a PID file.
+     */
+    class FakeProcessLifecycleProvider extends ProcessLifecycleProvider
+    {
+        public FakeProcessLifecycleProvider(Map<String, String> params)
+        {
+            super(params);
+        }
+
+        protected ProcessRuntimeConfiguration getRuntimeConfiguration(InstanceMetadata instance)
+        {
+            try
+            {
+                Process mockProcess = mock(Process.class);
+                when(mockProcess.waitFor()).thenReturn(0);
+
+                ProcessBuilder startMock = mock(ProcessBuilder.class);
+                when(startMock.start()).then(invocation -> {
+                    String pidFileLocation = pidFileLocation(lifecycleStateDir.toString(), instance.id());
+                    // create the pid file to simulate a started process
+                    Path pidFile = Path.of(pidFileLocation);
+                    Files.writeString(pidFile, "12345");
+                    return mockProcess;
+                });
+                ProcessRuntimeConfiguration mockConfig = mock(ProcessRuntimeConfiguration.class);
+                when(mockConfig.buildStartCommand(any(), any(), any())).thenReturn(startMock);
+
+                when(mockConfig.instance()).thenReturn(instance);
+                return mockConfig;
+            }
+            catch (InterruptedException | IOException e)
+            {
+                throw new RuntimeException(e);
+            }
+        }
+    }
+
+    @Test
+    void testStartStopIsRunning()
+    {
+        try (MockedStatic<ProcessHandle> processHandleMock = mockStatic(ProcessHandle.class))
+        {
+            // Mock ProcessHandle.of to simulate process running state
+            ProcessHandle mockHandle = mock(ProcessHandle.class);
+            ProcessHandle.Info mockInfo = mock(ProcessHandle.Info.class);
+            when(mockHandle.isAlive()).thenReturn(true);
+            when(mockHandle.info()).thenReturn(mockInfo);
+            when(mockInfo.commandLine()).thenReturn(Optional.of("java -cp /path/to/cassandra org.apache.cassandra.service.CassandraDaemon"));
+            when(mockHandle.onExit()).thenReturn(CompletableFuture.completedFuture(null));
+            when(mockHandle.pid()).thenReturn(12345L);
+            Optional<ProcessHandle> presentHandle = Optional.of(mockHandle);
+            processHandleMock.when(() -> ProcessHandle.of(12345L))
+                             .thenReturn(presentHandle);
+
+            // Create provider with temporary lifecycle state directory
+            Map<String, String> params = Map.of(
+            ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+            ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+            );
+            FakeProcessLifecycleProvider provider = new FakeProcessLifecycleProvider(params);
+
+            InstanceMetadata instance = instanceMetadata(1);
+
+            // Initially, instance should not be running (no PID file exists)
+            String pidFileLocation = pidFileLocation(lifecycleStateDir.toString(), instance.id());
+            Path pidFilePath = Path.of(pidFileLocation);
+            assertThat(pidFilePath).doesNotExist();
+            assertThat(provider.isRunning(instance)).isFalse();
+
+            // Start the instance
+            provider.start(instance);
+
+            // After starting, instance should be running (PID file should exist)
+            assertThat(pidFilePath).exists();
+            assertThat(provider.isRunning(instance)).isTrue();
+
+            // Stop the instance
+            provider.stop(instance);
+
+            // After stopping, instance should not be running (PID file should be deleted)
+            assertThat(pidFilePath).doesNotExist();
+            assertThat(provider.isRunning(instance)).isFalse();
+        }
+    }
+
+    @Test
+    void testBuildCassandraConfigWithCassandraHomeOverride()
+    {
+        Map<String, String> params = Map.of(
+        ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+        ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+        );
+
+        ProcessLifecycleProvider provider = new ProcessLifecycleProvider(params);
+
+        Map<String, String> lifecycleOptions = Map.of(
+        ProcessLifecycleProvider.OPT_CASSANDRA_HOME, "/instance/cassandra/home",
+        ProcessLifecycleProvider.OPT_CASSANDRA_CONF_DIR, "/instance/conf/dir",
+        ProcessLifecycleProvider.OPT_CASSANDRA_LOG_DIR, "/instance/log/dir"
+        );
+
+        InstanceMetadata instance = instanceMetadata(1, lifecycleOptions);
+
+        ProcessRuntimeConfiguration config = provider.getRuntimeConfiguration(instance);
+
+        // Verify the configuration was built correctly
+        assertThat(config.instance()).isEqualTo(instance);
+        assertThat(config.cassandraHome()).isEqualTo(Path.of("/instance/cassandra/home"));
+        assertThat(config.cassandraConfDir).isEqualTo(Path.of("/instance/conf/dir"));
+        assertThat(config.cassandraLogDir).isEqualTo("/instance/log/dir");
+        assertThat(config.storageDir).isEqualTo("/custom/storage/dir");
+    }
+
+    @Test
+    void testBuildCassandraConfigWithDefaultCassandraHome()
+    {
+        Map<String, String> params = Map.of(
+        ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+        ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+        );
+
+        ProcessLifecycleProvider provider = new ProcessLifecycleProvider(params);
+
+        // Lifecycle options without CASSANDRA_HOME override
+        Map<String, String> lifecycleOptions = Map.of(
+        ProcessLifecycleProvider.OPT_CASSANDRA_CONF_DIR, "/instance/conf/dir",
+        ProcessLifecycleProvider.OPT_CASSANDRA_LOG_DIR, "/instance/log/dir"
+        );
+
+        InstanceMetadata instance = instanceMetadata(1, lifecycleOptions);
+        ProcessRuntimeConfiguration config = provider.getRuntimeConfiguration(instance);
+
+        // Verify the configuration uses default Cassandra home
+        assertThat(config.cassandraHome()).isEqualTo(defaultCassandraHome);
+    }
+
+    @Test
+    void testBuildStartCommand() throws IOException
+    {
+        // Create temporary files to simulate the required directories and files first
+        Path tempCassandraHome = lifecycleStateDir.resolve("cassandra");
+        Path tempBinDir = tempCassandraHome.resolve("bin");
+        Path tempConfDir = lifecycleStateDir.resolve("conf");
+        Files.createDirectories(tempBinDir);
+        Files.createDirectories(tempConfDir);
+
+        Path cassandraBin = tempBinDir.resolve("cassandra");
+        Path cassandraYaml = tempConfDir.resolve("cassandra.yaml");
+        Files.createFile(cassandraBin);
+        Files.createFile(cassandraYaml);
+        cassandraBin.toFile().setExecutable(true);
+
+        // Use the temp directory for Cassandra home instead of hardcoded path
+        Map<String, String> params = Map.of(
+        ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+        ProcessLifecycleProvider.OPT_CASSANDRA_HOME, tempCassandraHome.toString()
+        );
+
+        ProcessLifecycleProvider provider = new ProcessLifecycleProvider(params);
+
+        // Create mock instance metadata without storage dir
+        InstanceMetadata instance = mock(InstanceMetadata.class);
+        when(instance.id()).thenReturn(5);
+        when(instance.storageDir()).thenReturn(null);
+
+        Map<String, String> lifecycleOptions = Map.of(
+        ProcessLifecycleProvider.OPT_CASSANDRA_CONF_DIR, tempConfDir.toString()
+        );
+        when(instance.lifecycleOptions()).thenReturn(lifecycleOptions);
+
+        // Build the config and test the start command using provider helper methods
+        ProcessRuntimeConfiguration testConfig = provider.getRuntimeConfiguration(instance);
+        String pidFileLocation = provider.pidFileLocation(instance);
+        Path stdoutLocation = provider.stdoutLocation(instance);
+        Path stderrLocation = provider.stderrLocation(instance);
+
+        ProcessBuilder processBuilder = testConfig.buildStartCommand(pidFileLocation, stdoutLocation, stderrLocation);
+
+        // Verify command arguments (no storage dir override)
+        List<String> command = processBuilder.command();
+        assertThat(command).containsExactly(
+        cassandraBin.toString(),
+        "-p",
+        pidFileLocation
+        );
+
+        // Verify environment variables
+        Map<String, String> env = processBuilder.environment();
+        assertThat(env.get("CASSANDRA_HOME")).isEqualTo(tempCassandraHome.toString());
+        assertThat(env.get("CASSANDRA_CONF")).isEqualTo(tempConfDir.toString());
+        assertThat(env.get("CASSANDRA_LOG_DIR")).isNull();
+    }
+
+    @Test
+    void testBuildStartCommandWithExtraJvmOptsAndEnvVars() throws IOException
+    {
+        // Create temporary files to simulate the required directories and files first
+        Path tempCassandraHome = lifecycleStateDir.resolve("cassandra");
+        Path tempBinDir = tempCassandraHome.resolve("bin");
+        Path tempConfDir = lifecycleStateDir.resolve("conf");
+        Files.createDirectories(tempBinDir);
+        Files.createDirectories(tempConfDir);
+
+        Path cassandraBin = tempBinDir.resolve("cassandra");
+        Path cassandraYaml = tempConfDir.resolve("cassandra.yaml");
+        Files.createFile(cassandraBin);
+        Files.createFile(cassandraYaml);
+        cassandraBin.toFile().setExecutable(true);
+
+        // Create provider with extra JVM options and environment variables
+        Map<String, String> params = Map.of(
+        ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+        ProcessLifecycleProvider.OPT_CASSANDRA_HOME, tempCassandraHome.toString(),
+        "sys.cassandra.max_queued_native_transport_requests", "1024",
+        "env.JVM_OPTS", "-Xms1G -Xmx2G",
+        "env.CUSTOM_VAR", "custom_value"
+        );
+
+        ProcessLifecycleProvider provider = new ProcessLifecycleProvider(params);
+
+        Map<String, String> lifecycleOptions = Map.of(
+        ProcessLifecycleProvider.OPT_CASSANDRA_CONF_DIR, tempConfDir.toString()
+        );
+
+        InstanceMetadata instance = instanceMetadata(5, lifecycleOptions);
+
+        // Build the config and test the start command
+        ProcessRuntimeConfiguration testConfig = provider.getRuntimeConfiguration(instance);
+        InstanceMetadata instanceMetadata = instanceMetadata(5);
+        String pidFileLocation = provider.pidFileLocation(instanceMetadata);
+        Path stdoutLocation = provider.stdoutLocation(instanceMetadata);
+        Path stderrLocation = provider.stderrLocation(instanceMetadata);
+
+        ProcessBuilder processBuilder = testConfig.buildStartCommand(pidFileLocation, stdoutLocation, stderrLocation);
+
+        // Verify command includes JVM options as -D parameters
+        List<String> command = processBuilder.command();
+        assertThat(command).contains(cassandraBin.toString());
+        assertThat(command).contains("-p");
+        assertThat(command).contains(pidFileLocation);
+        assertThat(command).contains("-Dcassandra.max_queued_native_transport_requests=1024");
+
+        // Verify environment variables include both standard and extra vars
+        Map<String, String> env = processBuilder.environment();
+        assertThat(env.get("CASSANDRA_HOME")).isEqualTo(tempCassandraHome.toString());
+        assertThat(env.get("CASSANDRA_CONF")).isEqualTo(tempConfDir.toString());
+        assertThat(env.get("JVM_OPTS")).isEqualTo("-Xms1G -Xmx2G");
+        assertThat(env.get("CUSTOM_VAR")).isEqualTo("custom_value");
+    }
+
+    @Test
+    void testIsCassandraProcessRunningRemovesStalePidFile() throws IOException
+    {
+        try (MockedStatic<ProcessHandle> processHandleMock = mockStatic(ProcessHandle.class))
+        {
+            // Create provider
+            Map<String, String> params = Map.of(
+            ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+            ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+            );
+            ProcessLifecycleProvider provider = new ProcessLifecycleProvider(params);
+
+            // Create mock instance metadata
+            InstanceMetadata instance = mock(InstanceMetadata.class);
+            when(instance.id()).thenReturn(1);
+            when(instance.storageDir()).thenReturn("/storage/dir");
+            when(instance.lifecycleOptions()).thenReturn(Map.of());
+
+            // Create a PID file with a stale PID (process that doesn't exist)
+            String pidFileLocation = pidFileLocation(lifecycleStateDir.toString(), 1);
+            Path pidFilePath = Path.of(pidFileLocation);
+            Files.writeString(pidFilePath, "99999");
+
+            // Mock ProcessHandle.of to return empty (process doesn't exist)
+            processHandleMock.when(() -> ProcessHandle.of(99999L))
+                             .thenReturn(Optional.empty());
+
+            // Verify PID file exists before the check
+            assertThat(pidFilePath).exists();
+
+            // Call isRunning - should return false and remove the stale PID file
+            boolean isRunning = provider.isRunning(instance);
+
+            // Verify process is not running
+            assertThat(isRunning).isFalse();
+
+            // Verify the stale PID file was removed
+            assertThat(pidFilePath).doesNotExist();
+        }
+    }
+
+    @Test
+    void testIsCassandraProcessRunningRemovesStalePidFileWhenNotCassandra() throws IOException
+    {
+        try (MockedStatic<ProcessHandle> processHandleMock = mockStatic(ProcessHandle.class))
+        {
+            // Create provider
+            Map<String, String> params = Map.of(
+            ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+            ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+            );
+            ProcessLifecycleProvider provider = new ProcessLifecycleProvider(params);
+
+            // Create mock instance metadata
+            InstanceMetadata instance = mock(InstanceMetadata.class);
+            when(instance.id()).thenReturn(1);
+            when(instance.storageDir()).thenReturn("/storage/dir");
+            when(instance.lifecycleOptions()).thenReturn(Map.of());
+
+            // Create a PID file with a PID
+            String pidFileLocation = pidFileLocation(lifecycleStateDir.toString(), 1);
+            Path pidFilePath = Path.of(pidFileLocation);
+            Files.writeString(pidFilePath, "55555");
+
+            // Mock ProcessHandle that exists and is alive but is not a Cassandra process
+            ProcessHandle mockHandle = mock(ProcessHandle.class);
+            ProcessHandle.Info mockInfo = mock(ProcessHandle.Info.class);
+            when(mockHandle.isAlive()).thenReturn(true);
+            when(mockHandle.info()).thenReturn(mockInfo);
+            when(mockInfo.commandLine()).thenReturn(Optional.of("/usr/bin/someothercommand"));
+            when(mockHandle.pid()).thenReturn(55555L);
+
+            processHandleMock.when(() -> ProcessHandle.of(55555L))
+                             .thenReturn(Optional.of(mockHandle));
+
+            // Verify PID file exists before the check
+            assertThat(pidFilePath).exists();
+
+            // Call isRunning - should return false and remove the stale PID file
+            boolean isRunning = provider.isRunning(instance);
+
+            // Verify process is not running
+            assertThat(isRunning).isFalse();
+
+            // Verify the stale PID file was removed
+            assertThat(pidFilePath).doesNotExist();
+        }
+    }
+
+    @Test
+    void testIsCassandraProcessRunningDoesNotRemovePidFileWhenCassandraIsRunning() throws IOException
+    {
+        try (MockedStatic<ProcessHandle> processHandleMock = mockStatic(ProcessHandle.class))
+        {
+            // Create provider
+            Map<String, String> params = Map.of(
+            ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+            ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+            );
+            ProcessLifecycleProvider provider = new ProcessLifecycleProvider(params);
+
+            // Create mock instance metadata
+            InstanceMetadata instance = mock(InstanceMetadata.class);
+            when(instance.id()).thenReturn(1);
+            when(instance.storageDir()).thenReturn("/storage/dir");
+            when(instance.lifecycleOptions()).thenReturn(Map.of());
+
+            // Create a PID file with a valid Cassandra process PID
+            String pidFileLocation = pidFileLocation(lifecycleStateDir.toString(), 1);
+            Path pidFilePath = Path.of(pidFileLocation);
+            Files.writeString(pidFilePath, "77777");
+
+            // Mock ProcessHandle that exists and is a Cassandra process
+            ProcessHandle mockHandle = mock(ProcessHandle.class);
+            ProcessHandle.Info mockInfo = mock(ProcessHandle.Info.class);
+            when(mockHandle.isAlive()).thenReturn(true);
+            when(mockHandle.info()).thenReturn(mockInfo);
+            when(mockInfo.commandLine()).thenReturn(Optional.of("java -cp /path/to/cassandra org.apache.cassandra.service.CassandraDaemon"));
+            when(mockHandle.pid()).thenReturn(77777L);
+
+            processHandleMock.when(() -> ProcessHandle.of(77777L))
+                             .thenReturn(Optional.of(mockHandle));
+
+            // Verify PID file exists before the check
+            assertThat(pidFilePath).exists();
+
+            // Call isRunning
+            boolean isRunning = provider.isRunning(instance);
+
+            // Verify process is running
+            assertThat(isRunning).isTrue();
+
+            // Verify the PID file was NOT removed since process is running
+            assertThat(pidFilePath).exists();
+            assertThat(Files.readString(pidFilePath).trim()).isEqualTo("77777");
+        }
+    }
+
+    @Test
+    void testGetCommandLinePlatformIndependentReturnsFallbackWhenPsFails()
+    {
+        // Mock ProcessHandle
+        ProcessHandle mockHandle = mock(ProcessHandle.class);
+        ProcessHandle.Info mockInfo = mock(ProcessHandle.Info.class);
+        when(mockHandle.info()).thenReturn(mockInfo);
+        String expectedCommandLine = "java -cp /path/to/cassandra org.apache.cassandra.service.CassandraDaemon";
+        when(mockInfo.commandLine()).thenReturn(Optional.of(expectedCommandLine));
+        when(mockHandle.pid()).thenReturn(99999L);
+
+        // Since ps will fail (PID doesn't exist), we should get the fallback command line
+        Optional<String> result = ProcessLifecycleProvider.getCommandLinePlatformIndependent(mockHandle);
+
+        // Verify that we get the fallback result (since ps will return empty for non-existent PID)
+        assertThat(result).isPresent();
+        assertThat(result.get()).isEqualTo(expectedCommandLine);
+    }
+
+    @Test
+    void testReadPidFromFile() throws IOException
+    {
+        // Create a PID file with a valid PID
+        Path pidFilePath = lifecycleStateDir.resolve("test.pid");
+        Files.writeString(pidFilePath, "12345");
+
+        // Read the PID from the file
+        Long pid = ProcessLifecycleProvider.readPidFromFile(pidFilePath);
+
+        // Verify the PID is read correctly
+        assertThat(pid).isEqualTo(12345L);
+    }
+
+    @Test
+    void testReadPidFromFileWithWhitespace() throws IOException
+    {
+        // Create a PID file with whitespace around the PID
+        Path pidFilePath = lifecycleStateDir.resolve("test.pid");
+        Files.writeString(pidFilePath, "  54321  \n");
+
+        // Read the PID from the file
+        Long pid = ProcessLifecycleProvider.readPidFromFile(pidFilePath);
+
+        // Verify the PID is read correctly and whitespace is trimmed
+        assertThat(pid).isEqualTo(54321L);
+    }
+
+    @Test
+    void testReadPidFromFileThrowsExceptionForInvalidPid() throws IOException
+    {
+        // Create a PID file with invalid content
+        Path pidFilePath = lifecycleStateDir.resolve("test.pid");
+        assertThat(pidFilePath).doesNotExist();
+        Files.writeString(pidFilePath, "invalid_pid");
+
+        assertThatRuntimeException().isThrownBy(() -> ProcessLifecycleProvider.readPidFromFile(pidFilePath))
+                                    .withCauseInstanceOf(NumberFormatException.class)
+                                    .withMessageContaining("Unable to parse PID from file: ");
+    }
+
+    @Test
+    void testReadPidFromFileThrowsExceptionForMissingFile()
+    {
+        // Try to read a PID file that doesn't exist
+        Path pidFilePath = lifecycleStateDir.resolve("nonexistent.pid");
+
+        try
+        {
+            ProcessLifecycleProvider.readPidFromFile(pidFilePath);
+
+            // Should not reach here
+            assertThat(false).as("Expected RuntimeException to be thrown").isTrue();
+        }
+        catch (RuntimeException e)
+        {
+            assertThat(e.getMessage()).contains("Failed to read PID from file");
+            assertThat(e.getCause()).isInstanceOf(IOException.class);
+        }
+    }
+
+    @Test
+    void testDeletePidFile() throws IOException
+    {
+        // Create a mock instance
+        InstanceMetadata instance = mock(InstanceMetadata.class);
+        when(instance.host()).thenReturn("testhost");
+
+        // Create a PID file
+        Path pidFilePath = lifecycleStateDir.resolve("cassandra-testhost.pid");
+        Files.writeString(pidFilePath, "99999");
+
+        // Verify the file exists
+        assertThat(pidFilePath).exists();
+
+        // Delete the PID file
+        ProcessLifecycleProvider.deletePidFile(instance, pidFilePath);
+
+        // Verify the file was deleted
+        assertThat(pidFilePath).doesNotExist();
+    }
+
+    @Test
+    void testDeletePidFileWhenFileDoesNotExist()
+    {
+        // Create a mock instance
+        InstanceMetadata instance = mock(InstanceMetadata.class);
+        when(instance.host()).thenReturn("testhost");
+
+        // Try to delete a PID file that doesn't exist
+        Path pidFilePath = lifecycleStateDir.resolve("nonexistent-cassandra-testhost.pid");
+
+        // Verify the file doesn't exist
+        assertThat(pidFilePath).doesNotExist();
+
+        // Should not throw an exception when trying to delete a non-existent file
+        ProcessLifecycleProvider.deletePidFile(instance, pidFilePath);
+
+        // Verify the file still doesn't exist
+        assertThat(pidFilePath).doesNotExist();
+    }
+
+    @Test
+    void testStopCallsDestroyForciblyOnTimeout() throws Exception
+    {
+        try (MockedStatic<ProcessHandle> processHandleMock = mockStatic(ProcessHandle.class))
+        {
+            // Create provider
+            Map<String, String> params = Map.of(
+            ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+            ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+            );
+            FakeProcessLifecycleProvider provider = new FakeProcessLifecycleProvider(params);
+
+            InstanceMetadata instance = instanceMetadata(1);
+
+            // Create a PID file
+            String pidFileLocation = pidFileLocation(lifecycleStateDir.toString(), 1);
+            Path pidFilePath = Path.of(pidFileLocation);
+            Files.writeString(pidFilePath, "12345");
+
+            // Mock ProcessHandle that times out on graceful termination but succeeds with force
+            ProcessHandle mockHandle = mock(ProcessHandle.class);
+            ProcessHandle.Info mockInfo = mock(ProcessHandle.Info.class);
+            when(mockHandle.isAlive()).thenReturn(true);
+            when(mockHandle.info()).thenReturn(mockInfo);
+            when(mockInfo.commandLine()).thenReturn(Optional.of("java org.apache.cassandra.service.CassandraDaemon"));
+            when(mockHandle.pid()).thenReturn(12345L);
+
+            // Simulate timeout by mocking CompletableFuture.get() to throw TimeoutException
+            @SuppressWarnings("unchecked")
+            CompletableFuture<ProcessHandle> timeoutFuture = mock(CompletableFuture.class);
+            when(timeoutFuture.get(anyLong(), any())).thenThrow(new TimeoutException("Simulated timeout"));
+            when(mockHandle.onExit()).thenReturn(timeoutFuture);
+            when(mockHandle.destroy()).thenReturn(true);
+            when(mockHandle.destroyForcibly()).thenReturn(true);
+
+            processHandleMock.when(() -> ProcessHandle.of(12345L))
+                             .thenReturn(Optional.of(mockHandle));
+
+            // Call stop - should call destroyForcibly after timeout
+            provider.stop(instance);
+
+            // Verify destroyForcibly was called
+            verify(mockHandle).destroyForcibly();
+        }
+    }
+
+    @Test
+    void testStopThrowsExceptionWhenDestroyForciblyFails() throws Exception
+    {
+        try (MockedStatic<ProcessHandle> processHandleMock = mockStatic(ProcessHandle.class))
+        {
+            // Create provider
+            Map<String, String> params = Map.of(
+            ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+            ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+            );
+            FakeProcessLifecycleProvider provider = new FakeProcessLifecycleProvider(params);
+
+            // Create mock instance metadata
+            InstanceMetadata instance = mock(InstanceMetadata.class);
+            when(instance.id()).thenReturn(1);
+            when(instance.storageDir()).thenReturn("/storage/dir");
+            when(instance.lifecycleOptions()).thenReturn(Map.of());
+
+            // Create a PID file
+            String pidFileLocation = pidFileLocation(lifecycleStateDir.toString(), 1);
+            Path pidFilePath = Path.of(pidFileLocation);
+            Files.writeString(pidFilePath, "12345");
+
+            // Mock ProcessHandle that times out and fails to force destroy
+            ProcessHandle mockHandle = mock(ProcessHandle.class);
+            ProcessHandle.Info mockInfo = mock(ProcessHandle.Info.class);
+            when(mockHandle.isAlive()).thenReturn(true);
+            when(mockHandle.info()).thenReturn(mockInfo);
+            when(mockInfo.commandLine()).thenReturn(Optional.of("java org.apache.cassandra.service.CassandraDaemon"));
+            when(mockHandle.pid()).thenReturn(12345L);
+
+            // Simulate timeout by mocking CompletableFuture.get() to throw TimeoutException
+            @SuppressWarnings("unchecked")
+            CompletableFuture<ProcessHandle> timeoutFuture = mock(CompletableFuture.class);
+            when(timeoutFuture.get(anyLong(), any())).thenThrow(new TimeoutException("Simulated timeout"));
+            when(mockHandle.onExit()).thenReturn(timeoutFuture);
+            when(mockHandle.destroy()).thenReturn(true);
+            when(mockHandle.destroyForcibly()).thenReturn(false); // Force destroy fails
+
+            processHandleMock.when(() -> ProcessHandle.of(12345L))
+                             .thenReturn(Optional.of(mockHandle));
+
+            // Call stop - should throw exception because destroyForcibly returned false
+            assertThatThrownBy(() -> provider.stop(instance))
+            .isInstanceOf(RuntimeException.class)
+            .hasMessageContaining("Failed to forcibly destroy process");
+        }
+    }
+
+    @Test
+    void testThrowsExceptionWhenStateDirDoesNotExist()
+    {
+        Map<String, String> params = Map.of(
+        ProcessLifecycleProvider.OPT_STATE_DIR, "/nonexistent/state/dir",
+        ProcessLifecycleProvider.OPT_CASSANDRA_HOME, defaultCassandraHome.toString()
+        );
+
+        assertThatThrownBy(() -> new ProcessLifecycleProvider(params))
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("State directory")
+        .hasMessageContaining("does not exist or is not a directory");
+    }
+
+    @Test
+    void testThrowsExceptionWhenCassandraHomeDoesNotExist()
+    {
+        Map<String, String> params = Map.of(
+        ProcessLifecycleProvider.OPT_STATE_DIR, lifecycleStateDir.toString(),
+        ProcessLifecycleProvider.OPT_CASSANDRA_HOME, "/nonexistent/cassandra/home"
+        );
+
+        assertThatThrownBy(() -> new ProcessLifecycleProvider(params))
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("Cassandra home")
+        .hasMessageContaining("does not exist or is not a directory");
+    }
+
+    InstanceMetadata instanceMetadata(int instanceId)
+    {
+        return instanceMetadata(instanceId, Map.of());
+    }
+
+    InstanceMetadata instanceMetadata(int instanceId, Map<String, String> lifecycleOptions)
+    {
+        return InstanceMetadataImpl.builder()
+                                   .id(instanceId)
+                                   .metricRegistry(METRIC_REGISTRY)
+                                   .storageDir("/custom/storage/dir")
+                                   .lifecycleOptions(lifecycleOptions)
+                                   .build();
+    }
+}
diff --git a/server/src/test/java/org/apache/cassandra/sidecar/lifecycle/ProcessRuntimeConfigurationTest.java b/server/src/test/java/org/apache/cassandra/sidecar/lifecycle/ProcessRuntimeConfigurationTest.java
new file mode 100644
index 00000000..cfd4943f
--- /dev/null
+++ b/server/src/test/java/org/apache/cassandra/sidecar/lifecycle/ProcessRuntimeConfigurationTest.java
@@ -0,0 +1,343 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.lifecycle;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.codahale.metrics.MetricRegistry;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadataImpl;
+import org.apache.cassandra.sidecar.exceptions.ConfigurationException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link ProcessRuntimeConfiguration}
+ */
+class ProcessRuntimeConfigurationTest
+{
+    private static final MetricRegistry METRIC_REGISTRY = new MetricRegistry();
+
+    @TempDir
+    Path tempDir;
+
+    private Path cassandraHome;
+    private Path cassandraConfDir;
+    private Path cassandraBin;
+    private Path cassandraYaml;
+
+    @BeforeEach
+    void setup() throws IOException
+    {
+        cassandraHome = Files.createDirectories(tempDir.resolve("cassandra"));
+        cassandraConfDir = Files.createDirectories(tempDir.resolve("conf"));
+
+        // Create bin directory and cassandra executable
+        Path binDir = Files.createDirectories(cassandraHome.resolve("bin"));
+        cassandraBin = Files.createFile(binDir.resolve("cassandra"));
+        Files.setPosixFilePermissions(cassandraBin, Set.of(
+        PosixFilePermission.OWNER_READ,
+        PosixFilePermission.OWNER_WRITE,
+        PosixFilePermission.OWNER_EXECUTE
+        ));
+
+        // Create cassandra.yaml
+        cassandraYaml = Files.createFile(cassandraConfDir.resolve("cassandra.yaml"));
+    }
+
+    @Test
+    void testValidateStartWithValidConfiguration()
+    {
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+
+        assertThatCode(config::validateStart).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testValidateStartThrowsWhenCassandraHomeDoesNotExist()
+    {
+        Path nonExistentHome = tempDir.resolve("nonexistent");
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(nonExistentHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+
+        assertThatThrownBy(config::validateStart)
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("Cassandra home does not exist or is not a directory");
+    }
+
+    @Test
+    void testValidateStartThrowsWhenCassandraHomeIsFile() throws IOException
+    {
+        Path homeAsFile = Files.createFile(tempDir.resolve("homeAsFile"));
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(homeAsFile.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+
+        assertThatThrownBy(config::validateStart)
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("Cassandra home does not exist or is not a directory");
+    }
+
+    @Test
+    void testValidateStartThrowsWhenConfDirDoesNotExist()
+    {
+        Path nonExistentConfDir = tempDir.resolve("nonexistent-conf");
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(nonExistentConfDir.toString())
+                                                                        .build();
+
+        assertThatThrownBy(config::validateStart)
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("Cassandra configuration directory does not exist or is not a directory");
+    }
+
+    @Test
+    void testValidateStartThrowsWhenCassandraYamlDoesNotExist() throws IOException
+    {
+        Files.delete(cassandraYaml);
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+
+        assertThatThrownBy(config::validateStart)
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("Cassandra YAML configuration file does not exist");
+    }
+
+    @Test
+    void testCustomCassandraYamlFile() throws IOException
+    {
+        Path customCassandraYamlPath = Files.createFile(cassandraConfDir.resolve("custom-cassandra.yaml"));
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraYamlPath(customCassandraYamlPath.toString())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+        // validate the process runtime configuration
+        config.validateStart();
+
+        assertThat(config.cassandraYaml()).isEqualTo(tempDir.resolve("conf").resolve("custom-cassandra.yaml"));
+    }
+
+    @Test
+    void testValidateStartThrowsWhenCassandraBinDoesNotExist() throws IOException
+    {
+        Files.delete(cassandraBin);
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+
+        assertThatThrownBy(config::validateStart)
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("Cassandra binary does not exist or is not a regular file");
+    }
+
+    @Test
+    void testValidateStartThrowsWhenCassandraBinNotExecutable() throws IOException
+    {
+        Files.setPosixFilePermissions(cassandraBin, Set.of(
+        PosixFilePermission.OWNER_READ,
+        PosixFilePermission.OWNER_WRITE
+        ));
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+
+        assertThatThrownBy(config::validateStart)
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("Cassandra binary is not executable");
+    }
+
+    @Test
+    void testValidateStartThrowsWhenConfDirNotReadable() throws IOException
+    {
+        Files.setPosixFilePermissions(cassandraConfDir, Set.of(
+        PosixFilePermission.OWNER_WRITE,
+        PosixFilePermission.OWNER_EXECUTE
+        ));
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+
+        assertThatThrownBy(config::validateStart)
+        .isInstanceOf(ConfigurationException.class)
+        .hasMessageContaining("Cassandra configuration directory is not readable");
+    }
+
+    @Test
+    void testBuildStartCommand()
+    {
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .cassandraLogDir("/custom/log/dir")
+                                                                        .storageDir("/custom/storage/dir")
+                                                                        .build();
+
+        String pidFile = "/tmp/cassandra.pid";
+        Path stdoutFile = Path.of("/tmp/cassandra.out");
+        Path stderrFile = Path.of("/tmp/cassandra.err");
+
+        ProcessBuilder pb = config.buildStartCommand(pidFile, stdoutFile, stderrFile);
+
+        // Verify command
+        List<String> command = pb.command();
+        assertThat(command).hasSize(5);
+        assertThat(command.get(0)).isEqualTo(cassandraBin.toString());
+        assertThat(command.get(1)).isEqualTo("-p");
+        assertThat(command.get(2)).isEqualTo(pidFile);
+        assertThat(command.get(3)).isEqualTo("-D");
+        assertThat(command.get(4)).isEqualTo("cassandra.storagedir=/custom/storage/dir");
+
+        // Verify environment variables
+        Map<String, String> env = pb.environment();
+        assertThat(env.get("CASSANDRA_HOME")).isEqualTo(cassandraHome.toString());
+        assertThat(env.get("CASSANDRA_CONF")).isEqualTo(cassandraConfDir.toString());
+        assertThat(env.get("CASSANDRA_LOG_DIR")).isEqualTo("/custom/log/dir");
+
+        // Verify working directory
+        assertThat(pb.directory()).isEqualTo(cassandraHome.toFile());
+
+        // Verify redirects are configured (files don't need to exist for ProcessBuilder creation)
+        assertThat(pb.redirectOutput().type()).isEqualTo(ProcessBuilder.Redirect.Type.WRITE);
+        assertThat(pb.redirectError().type()).isEqualTo(ProcessBuilder.Redirect.Type.WRITE);
+    }
+
+    @Test
+    void testBuildStartCommandWithoutStorageAndLogDir()
+    {
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .build();
+
+        String pidFile = "/tmp/cassandra.pid";
+        Path stdoutFile = Path.of("/tmp/cassandra.out");
+        Path stderrFile = Path.of("/tmp/cassandra.err");
+
+        ProcessBuilder pb = config.buildStartCommand(pidFile, stdoutFile, stderrFile);
+
+        // Verify command - should not include storage dir parameters
+        List<String> command = pb.command();
+        assertThat(command).hasSize(3);
+        assertThat(command.get(0)).isEqualTo(cassandraBin.toString());
+        assertThat(command.get(1)).isEqualTo("-p");
+        assertThat(command.get(2)).isEqualTo(pidFile);
+
+        // Verify environment variables
+        Map<String, String> env = pb.environment();
+        assertThat(env.get("CASSANDRA_HOME")).isEqualTo(cassandraHome.toString());
+        assertThat(env.get("CASSANDRA_CONF")).isEqualTo(cassandraConfDir.toString());
+
+        // Verify working directory
+        assertThat(pb.directory()).isEqualTo(cassandraHome.toFile());
+
+        // Verify redirects are configured
+        assertThat(pb.redirectOutput().type()).isEqualTo(ProcessBuilder.Redirect.Type.WRITE);
+        assertThat(pb.redirectError().type()).isEqualTo(ProcessBuilder.Redirect.Type.WRITE);
+    }
+
+    @Test
+    void testBuildStartCommandWithExtraJvmOptsAndEnvVars()
+    {
+        Map<String, String> extraJvmOpts = Map.of(
+        "cassandra.jmx.local.port", "7199",
+        "java.rmi.server.hostname", "localhost"
+        );
+
+        Map<String, String> extraEnvVars = Map.of(
+        "JVM_OPTS", "-Xms1G -Xmx2G",
+        "CUSTOM_VAR", "custom_value"
+        );
+
+        ProcessRuntimeConfiguration config = ProcessRuntimeConfiguration.builder()
+                                                                        .instance(instanceMetadata())
+                                                                        .cassandraHome(cassandraHome.toString())
+                                                                        .cassandraConfDir(cassandraConfDir.toString())
+                                                                        .extraJvmOptions(extraJvmOpts)
+                                                                        .extraEnvironmentVariables(extraEnvVars)
+                                                                        .build();
+
+        String pidFile = "/tmp/cassandra.pid";
+        Path stdoutFile = Path.of("/tmp/cassandra.out");
+        Path stderrFile = Path.of("/tmp/cassandra.err");
+        ProcessBuilder pb = config.buildStartCommand(pidFile, stdoutFile, stderrFile);
+
+        // Verify JVM options are included (order may vary)
+        List<String> command = pb.command();
+        assertThat(command).contains("-Dcassandra.jmx.local.port=7199");
+        assertThat(command).contains("-Djava.rmi.server.hostname=localhost");
+
+        // Verify environment variables include both standard and extra vars
+        Map<String, String> env = pb.environment();
+        assertThat(env.get("JVM_OPTS")).isEqualTo("-Xms1G -Xmx2G");
+        assertThat(env.get("CUSTOM_VAR")).isEqualTo("custom_value");
+    }
+
+    InstanceMetadata instanceMetadata()
+    {
+        return InstanceMetadataImpl.builder()
+                                   .id(1)
+                                   .metricRegistry(METRIC_REGISTRY)
+                                   .storageDir("/tmp/storage_dir")
+                                   .build();
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@cassandra.apache.org
For additional commands, e-mail: commits-help@cassandra.apache.org