Repository: kurtosis-tech/kurtosis
Pull request #148 (2023-03-13 15:57:46)

feat: Added service readiness check in `add_service` and `add_services` instructions, through the `ServiceConfig.ReadyConditions` field

cloudflare-pages[bot]: ## Deploying with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>1709020</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>⚡️&nbsp; Build in progress...</td></tr>
</table>

[View logs](https://dash.cloudflare.com/?to=/:account/pages/view/kurtosis-docs/a4dcf627-2b5d-47d1-b654-2877a88bee60)

gbouv: BTW why is this a breaking change?? Aren't the readiness checks optional after all?
leoporoli: @gbouv yes it's not a breaking change, but it depends on the code that is in the breaking change branch now, which is not having the `service_name` in the  `recipe`. I mean I'm adding this to the breaking change just to organize the release order, but I could use main as the base branch and merge it after `2023-03-22` 

Patch:
diff --git a/core/README.md b/core/README.md
index 58429e4e5..02575c99b 100644
--- a/core/README.md
+++ b/core/README.md
@@ -2,7 +2,7 @@ Kurtosis
 ========
 Kurtosis is a framework on top of Docker for writing test suites for any networked system - be it blockchain, distributed datastore, or otherwise. It handles all the gruntwork of setup, test execution, and teardown so you don't have to.
 
-Official docs found [here](https://docs.kurtosis.com) (created using Docusaurus from this repo [here](https://github.com/kurtosis-tech/docs).
+Official docs found [here](https://docs.kurtosis.com) (created using Docusaurus from this repo [here](https://github.com/kurtosis-tech/kurtosis).
 
 Development Prerequisites
 -------------------------
@@ -44,4 +44,4 @@ docker stop $(docker ps -a --quiet --filter ancestor="IMAGENAME" --format="{{.ID
 
 ### Testing the API Container Server
 
-Use this [guide](https://github.com/kurtosis-tech/kurtosis-internal-docs/blob/main/testing-grpc-services.md) to test the API Container server.
\ No newline at end of file
+Use this [guide]() to test the API Container server.
\ No newline at end of file
diff --git a/core/server/.golangci.yml b/core/server/.golangci.yml
index 49142043a..a9c0846b4 100644
--- a/core/server/.golangci.yml
+++ b/core/server/.golangci.yml
@@ -2,6 +2,9 @@ issues:
   exclude-rules:
     - path: "api_container/server/api_container_service.go"
       text: "SA4023"
+    - path: "api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/"
+      linters:
+        - exhaustruct
 
 linters-settings:
   staticcheck:
diff --git a/core/server/api_container/server/service_network/default_service_network.go b/core/server/api_container/server/service_network/default_service_network.go
index 7c2753d38..72e1ed545 100644
--- a/core/server/api_container/server/service_network/default_service_network.go
+++ b/core/server/api_container/server/service_network/default_service_network.go
@@ -343,6 +343,7 @@ func (network *DefaultServiceNetwork) StartService(
 	serviceConfigMap := map[service.ServiceName]*kurtosis_core_rpc_api_bindings.ServiceConfig{
 		serviceName: serviceConfig,
 	}
+
 	startedServices, serviceFailed, err := network.StartServices(ctx, serviceConfigMap, singleServiceStartupBatch)
 	if err != nil {
 		return nil, err
diff --git a/core/server/api_container/server/service_network/mock_service_network.go b/core/server/api_container/server/service_network/mock_service_network.go
index 7a6e8b3c1..acb36cde9 100644
--- a/core/server/api_container/server/service_network/mock_service_network.go
+++ b/core/server/api_container/server/service_network/mock_service_network.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.20.0. DO NOT EDIT.
+// Code generated by mockery v2.20.2. DO NOT EDIT.
 
 package service_network
 
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_service.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_service.go
index 947ad91f1..27fd75e29 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_service.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_service.go
@@ -46,7 +46,7 @@ func NewAddService(serviceNetwork service_network.ServiceNetwork, runtimeValueSt
 					Validator: func(value starlark.Value) *startosis_errors.InterpretationError {
 						// we just try to convert the configs here to validate their shape, to avoid code duplication
 						// with Interpret
-						if _, err := validateAndConvertConfig(value); err != nil {
+						if _, _, err := validateAndConvertConfigAndReadyConditions(value); err != nil {
 							return err
 						}
 						return nil
@@ -63,7 +63,8 @@ func NewAddService(serviceNetwork service_network.ServiceNetwork, runtimeValueSt
 				serviceName:   "",  // populated at interpretation time
 				serviceConfig: nil, // populated at interpretation time
 
-				resultUuid: "", // populated at interpretation time
+				resultUuid:      "",  // populated at interpretation time
+				readyConditions: nil, // populated at interpretation time
 			}
 		},
 
@@ -78,8 +79,9 @@ type AddServiceCapabilities struct {
 	serviceNetwork    service_network.ServiceNetwork
 	runtimeValueStore *runtime_value_store.RuntimeValueStore
 
-	serviceName   service.ServiceName
-	serviceConfig *kurtosis_core_rpc_api_bindings.ServiceConfig
+	serviceName     service.ServiceName
+	serviceConfig   *kurtosis_core_rpc_api_bindings.ServiceConfig
+	readyConditions *service_config.ReadyConditions
 
 	resultUuid string
 }
@@ -94,17 +96,19 @@ func (builtin *AddServiceCapabilities) Interpret(arguments *builtin_argument.Arg
 	if err != nil {
 		return nil, startosis_errors.WrapWithInterpretationError(err, "Unable to extract value for '%s' argument", ServiceConfigArgName)
 	}
-	apiServiceConfig, interpretationErr := validateAndConvertConfig(serviceConfig)
+	apiServiceConfig, readyConditions, interpretationErr := validateAndConvertConfigAndReadyConditions(serviceConfig)
 	if interpretationErr != nil {
 		return nil, interpretationErr
 	}
 
 	builtin.serviceName = service.ServiceName(serviceName.GoString())
 	builtin.serviceConfig = apiServiceConfig
+	builtin.readyConditions = readyConditions
 	builtin.resultUuid, err = builtin.runtimeValueStore.CreateValue()
 	if err != nil {
 		return nil, startosis_errors.WrapWithInterpretationError(err, "Unable to create runtime value to hold '%v' command return values", AddServiceBuiltinName)
 	}
+
 	returnValue, interpretationErr := makeAddServiceInterpretationReturnValue(builtin.serviceConfig, builtin.resultUuid)
 	if interpretationErr != nil {
 		return nil, interpretationErr
@@ -125,23 +129,43 @@ func (builtin *AddServiceCapabilities) Execute(ctx context.Context, _ *builtin_a
 	if err != nil {
 		return "", stacktrace.Propagate(err, "An error occurred replace a magic string in '%s' instruction arguments for service '%s'. Execution cannot proceed", AddServiceBuiltinName, builtin.serviceName)
 	}
+
 	startedService, err := builtin.serviceNetwork.StartService(ctx, replacedServiceName, replacedServiceConfig)
 	if err != nil {
 		return "", stacktrace.Propagate(err, "Unexpected error occurred starting service '%s'", replacedServiceName)
 	}
+
+	if err := runServiceReadinessCheck(
+		ctx,
+		builtin.serviceNetwork,
+		builtin.runtimeValueStore,
+		replacedServiceName,
+		builtin.readyConditions,
+	); err != nil {
+		return "", stacktrace.Propagate(err, "An error occurred while checking if service '%v' is ready", replacedServiceName)
+	}
+
 	fillAddServiceReturnValueWithRuntimeValues(startedService, builtin.resultUuid, builtin.runtimeValueStore)
 	instructionResult := fmt.Sprintf("Service '%s' added with service UUID '%s'", replacedServiceName, startedService.GetRegistration().GetUUID())
 	return instructionResult, nil
 }
 
-func validateAndConvertConfig(rawConfig starlark.Value) (*kurtosis_core_rpc_api_bindings.ServiceConfig, *startosis_errors.InterpretationError) {
+func validateAndConvertConfigAndReadyConditions(
+	rawConfig starlark.Value,
+) (*kurtosis_core_rpc_api_bindings.ServiceConfig, *service_config.ReadyConditions, *startosis_errors.InterpretationError) {
 	config, ok := rawConfig.(*service_config.ServiceConfig)
 	if !ok {
-		return nil, startosis_errors.NewInterpretationError("The '%s' argument is not a ServiceConfig (was '%s').", ConfigsArgName, reflect.TypeOf(rawConfig))
+		return nil, nil, startosis_errors.NewInterpretationError("The '%s' argument is not a ServiceConfig (was '%s').", ConfigsArgName, reflect.TypeOf(rawConfig))
 	}
 	apiServiceConfig, interpretationErr := config.ToKurtosisType()
 	if interpretationErr != nil {
-		return nil, interpretationErr
+		return nil, nil, interpretationErr
 	}
-	return apiServiceConfig, nil
+
+	readyConditions, interpretationErr := config.GetReadyConditions()
+	if interpretationErr != nil {
+		return nil, nil, interpretationErr
+	}
+
+	return apiServiceConfig, readyConditions, nil
 }
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_service_shared.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_service_shared.go
index dd9b5bd7c..7ceaf43fe 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_service_shared.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_service_shared.go
@@ -1,19 +1,25 @@
 package add_service
 
 import (
+	"context"
 	"fmt"
 	"github.com/kurtosis-tech/kurtosis/api/golang/core/kurtosis_core_rpc_api_bindings"
 	"github.com/kurtosis-tech/kurtosis/api/golang/core/lib/services"
 	"github.com/kurtosis-tech/kurtosis/container-engine-lib/lib/backend_interface/objects/service"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/service_network"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/service_network/partition_topology"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_instruction/shared_helpers"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_instruction/shared_helpers/magic_string_helper"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types/port_spec"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types/service_config"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/runtime_value_store"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/startosis_errors"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/startosis_validator"
 	"github.com/kurtosis-tech/stacktrace"
+	"github.com/sirupsen/logrus"
 	"go.starlark.net/starlark"
+	"time"
 )
 
 const (
@@ -130,3 +136,82 @@ func replaceMagicStrings(
 
 	return service.ServiceName(serviceNameStr), serviceConfigBuilder.Build(), nil
 }
+
+func runServiceReadinessCheck(
+	ctx context.Context,
+	serviceNetwork service_network.ServiceNetwork,
+	runtimeValueStore *runtime_value_store.RuntimeValueStore,
+	serviceName service.ServiceName,
+	readyConditions *service_config.ReadyConditions,
+) error {
+	if readyConditions != nil {
+
+		recipe, intepretationErr := readyConditions.GetRecipe()
+		if intepretationErr != nil {
+			return stacktrace.Propagate(intepretationErr, "An error occurred getting the recipe value from ready conditions '%v'", readyConditions)
+		}
+
+		field, intepretationErr := readyConditions.GetField()
+		if intepretationErr != nil {
+			return stacktrace.Propagate(intepretationErr, "An error occurred getting the field value from ready conditions '%v'", readyConditions)
+		}
+
+		assertion, intepretationErr := readyConditions.GetAssertion()
+		if intepretationErr != nil {
+			return stacktrace.Propagate(intepretationErr, "An error occurred getting the assertion value from ready conditions '%v'", readyConditions)
+		}
+
+		target, intepretationErr := readyConditions.GetTarget()
+		if intepretationErr != nil {
+			return stacktrace.Propagate(intepretationErr, "An error occurred getting the target value from ready conditions '%v'", readyConditions)
+		}
+
+		interval, intepretationErr := readyConditions.GetInterval()
+		if intepretationErr != nil {
+			return stacktrace.Propagate(intepretationErr, "An error occurred getting the interval value from ready conditions '%v'", readyConditions)
+		}
+
+		timeout, intepretationErr := readyConditions.GetTimeout()
+		if intepretationErr != nil {
+			return stacktrace.Propagate(intepretationErr, "An error occurred getting the timeout value from ready conditions '%v'", readyConditions)
+		}
+
+		startTime := time.Now()
+		logrus.Infof("Checking service readiness for '%s' at '%v'", serviceName, startTime) //TODO change to debug
+		lastResult, tries, err := shared_helpers.ExecuteServiceAssertionWithRecipe(
+			ctx,
+			serviceNetwork,
+			runtimeValueStore,
+			serviceName,
+			recipe,
+			field,
+			assertion,
+			target,
+			interval,
+			timeout,
+		)
+		if err != nil {
+			return stacktrace.Propagate(
+				err,
+				"An error occurred checking if service '%v' is ready, using "+
+					"recipe '%+v', value field '%v', assertion '%v', target '%v', interval '%s' and time-out '%s'.",
+				serviceName,
+				recipe,
+				field,
+				assertion,
+				target,
+				interval,
+				timeout,
+			)
+		}
+		//TODO change to debug
+		logrus.Infof("Checking if service '%v' is ready took %d tries (%v in total). "+
+			"Assertion passed with following:\n%s",
+			serviceName,
+			tries,
+			time.Since(startTime),
+			recipe.ResultMapToString(lastResult),
+		)
+	}
+	return nil
+}
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_services.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_services.go
index f22bb2b26..b82850a97 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_services.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/add_service/add_services.go
@@ -14,9 +14,11 @@ import (
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/startosis_errors"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/startosis_validator"
 	"github.com/kurtosis-tech/stacktrace"
+	"github.com/sirupsen/logrus"
 	"go.starlark.net/starlark"
 	"reflect"
 	"strings"
+	"sync"
 )
 
 const (
@@ -39,7 +41,7 @@ func NewAddServices(serviceNetwork service_network.ServiceNetwork, runtimeValueS
 					Validator: func(value starlark.Value) *startosis_errors.InterpretationError {
 						// we just try to convert the configs here to validate their shape, to avoid code duplication
 						// with Interpret
-						if _, err := validateAndConvertConfigs(value); err != nil {
+						if _, _, err := validateAndConvertConfigsAndReadyConditions(value); err != nil {
 							return err
 						}
 						return nil
@@ -55,7 +57,8 @@ func NewAddServices(serviceNetwork service_network.ServiceNetwork, runtimeValueS
 
 				serviceConfigs: nil, // populated at interpretation time
 
-				resultUuids: map[service.ServiceName]string{}, // populated at interpretation time
+				resultUuids:     map[service.ServiceName]string{}, // populated at interpretation time
+				readyConditions: nil,                              // populated at interpretation time
 			}
 		},
 
@@ -73,6 +76,8 @@ type AddServicesCapabilities struct {
 
 	serviceConfigs map[service.ServiceName]*kurtosis_core_rpc_api_bindings.ServiceConfig
 
+	readyConditions map[service.ServiceName]*service_config.ReadyConditions
+
 	resultUuids map[service.ServiceName]string
 }
 
@@ -81,11 +86,12 @@ func (builtin *AddServicesCapabilities) Interpret(arguments *builtin_argument.Ar
 	if err != nil {
 		return nil, startosis_errors.WrapWithInterpretationError(err, "Unable to extract value for '%s' argument", ConfigsArgName)
 	}
-	serviceConfigs, interpretationErr := validateAndConvertConfigs(ServiceConfigsDict)
+	serviceConfigs, readyConditions, interpretationErr := validateAndConvertConfigsAndReadyConditions(ServiceConfigsDict)
 	if interpretationErr != nil {
 		return nil, interpretationErr
 	}
 	builtin.serviceConfigs = serviceConfigs
+	builtin.readyConditions = readyConditions
 
 	resultUuids, returnValue, interpretationErr := makeAddServicesInterpretationReturnValue(builtin.serviceConfigs, builtin.runtimeValueStore)
 	if interpretationErr != nil {
@@ -131,6 +137,24 @@ func (builtin *AddServicesCapabilities) Execute(ctx context.Context, _ *builtin_
 		}
 		return "", stacktrace.NewError("Some errors occurred starting the following services: '%v'. The entire batch was rolled back an no service was started. Errors were: \n%v", failedServiceNames, failedServices)
 	}
+	shouldDeleteAllStartedServices := true
+
+	//TODO we should move the readiness check functionality to the default service network to improve performance
+	///TODO because we won't have to wait for all services to start for checking readiness, but first we have to
+	//TODO propagate the Recipes to this layer too and probably move the wait instruction also
+	if failedServicesChecks := builtin.allServicesReadinessCheck(ctx, startedServices, parallelism); len(failedServicesChecks) > 0 {
+		var allServiceChecksErrMsg string
+		for serviceName, serviceErr := range failedServicesChecks {
+			serviceMsg := fmt.Sprintf("Service '%v' error:\n%v\n", serviceName, serviceErr)
+			allServiceChecksErrMsg = allServiceChecksErrMsg + serviceMsg
+		}
+		return "", stacktrace.NewError("An error occurred while checking al service, these are the errors by service:\n%s", allServiceChecksErrMsg)
+	}
+	defer func() {
+		if shouldDeleteAllStartedServices {
+			builtin.removeAllStartedServices(ctx, startedServices)
+		}
+	}()
 
 	instructionResult := strings.Builder{}
 	instructionResult.WriteString(fmt.Sprintf("Successfully added the following '%d' services:", len(startedServices)))
@@ -139,39 +163,137 @@ func (builtin *AddServicesCapabilities) Execute(ctx context.Context, _ *builtin_
 		instructionResult.WriteString(fmt.Sprintf("\n  Service '%s' added with UUID '%s'", serviceName, serviceObj.GetRegistration().GetUUID()))
 
 	}
+	shouldDeleteAllStartedServices = false
 	return instructionResult.String(), nil
 }
 
-func validateAndConvertConfigs(configs starlark.Value) (map[service.ServiceName]*kurtosis_core_rpc_api_bindings.ServiceConfig, *startosis_errors.InterpretationError) {
+func (builtin *AddServicesCapabilities) removeAllStartedServices(
+	ctx context.Context,
+	startedServices map[service.ServiceName]*service.Service,
+) {
+	//this is not executed with concurrency because the remove service method locks on every call
+	for serviceName, service := range startedServices {
+		serviceIdentifier := string(service.GetRegistration().GetUUID())
+		if _, err := builtin.serviceNetwork.RemoveService(ctx, serviceIdentifier); err != nil {
+			logrus.Debugf("Something fails while started all services and we tried to remove all the  created services to rollback the process, but this one '%s' fails throwing this error: '%v', we suggest you to manually remove it", serviceName, err)
+		}
+	}
+}
+
+func (builtin *AddServicesCapabilities) allServicesReadinessCheck(
+	ctx context.Context,
+	startedServices map[service.ServiceName]*service.Service,
+	batchSize int,
+) map[service.ServiceName]error {
+	logrus.Debugf("Checking for all services readiness...")
+
+	concurrencyControlChan := make(chan bool, batchSize)
+	defer close(concurrencyControlChan)
+
+	failedServiceChecksSyncMap := &sync.Map{}
+
+	wg := &sync.WaitGroup{}
+	for serviceName := range startedServices {
+		wg.Add(1)
+		// The concurrencyControlChan will block if the buffer is currently full
+		concurrencyControlChan <- true
+		go builtin.runServiceReadinessCheck(ctx, wg, concurrencyControlChan, serviceName, failedServiceChecksSyncMap)
+	}
+	wg.Wait()
+
+	failedServiceChecksRegularMap := map[service.ServiceName]error{}
+
+	failedServiceChecksSyncMap.Range(func(serviceNameAny any, serviceErrorAny any) bool {
+		if serviceErrorAny != nil {
+			serviceName := serviceNameAny.(service.ServiceName)
+			serviceError := serviceErrorAny.(error)
+			failedServiceChecksRegularMap[serviceName] = serviceError
+		}
+		return true
+	})
+
+	logrus.Debug("All services are ready")
+
+	return failedServiceChecksRegularMap
+}
+
+func (builtin *AddServicesCapabilities) runServiceReadinessCheck(
+	ctx context.Context,
+	wg *sync.WaitGroup,
+	concurrencyControlChan chan bool,
+	serviceName service.ServiceName,
+	failedServiceChecks *sync.Map,
+) {
+	var serviceErr error
+	defer func() {
+		failedServiceChecks.Store(serviceName, serviceErr)
+		wg.Done()
+		//pop a value from the concurrencyControlChan to allow any potentially waiting subroutine to start
+		<-concurrencyControlChan
+	}()
+
+	readyConditions, found := builtin.readyConditions[serviceName]
+	if !found {
+		serviceErr = stacktrace.NewError("Expected to find ready conditions for service '%s' in map '%+v', but none was found; this is a bug in Kurtosis", serviceName, builtin.readyConditions)
+		return
+	}
+
+	if err := runServiceReadinessCheck(
+		ctx,
+		builtin.serviceNetwork,
+		builtin.runtimeValueStore,
+		serviceName,
+		readyConditions,
+	); err != nil {
+		serviceErr = stacktrace.Propagate(err, "An error occurred while checking if service '%v' is ready", serviceName)
+		return
+	}
+}
+
+func validateAndConvertConfigsAndReadyConditions(
+	configs starlark.Value,
+) (
+	map[service.ServiceName]*kurtosis_core_rpc_api_bindings.ServiceConfig,
+	map[service.ServiceName]*service_config.ReadyConditions,
+	*startosis_errors.InterpretationError,
+) {
 	configsDict, ok := configs.(*starlark.Dict)
 	if !ok {
-		return nil, startosis_errors.NewInterpretationError("The '%s' argument should be a dictionary of matching each service name to their respective ServiceConfig object. Got '%s'", ConfigsArgName, reflect.TypeOf(configs))
+		return nil, nil, startosis_errors.NewInterpretationError("The '%s' argument should be a dictionary of matching each service name to their respective ServiceConfig object. Got '%s'", ConfigsArgName, reflect.TypeOf(configs))
 	}
 	if configsDict.Len() == 0 {
-		return nil, startosis_errors.NewInterpretationError("The '%s' argument should be a non empty dictionary", ConfigsArgName)
+		return nil, nil, startosis_errors.NewInterpretationError("The '%s' argument should be a non empty dictionary", ConfigsArgName)
 	}
 	convertedServiceConfigs := map[service.ServiceName]*kurtosis_core_rpc_api_bindings.ServiceConfig{}
+	readyConditionsByServiceName := map[service.ServiceName]*service_config.ReadyConditions{}
 	for _, serviceName := range configsDict.Keys() {
 		serviceNameStr, isServiceNameAString := serviceName.(starlark.String)
 		if !isServiceNameAString {
-			return nil, startosis_errors.NewInterpretationError("One key of the '%s' dictionary is not a string (was '%s'). Keys of this argument should correspond to service names, which should be strings", ConfigsArgName, reflect.TypeOf(serviceName))
+			return nil, nil, startosis_errors.NewInterpretationError("One key of the '%s' dictionary is not a string (was '%s'). Keys of this argument should correspond to service names, which should be strings", ConfigsArgName, reflect.TypeOf(serviceName))
 		}
 
 		dictValue, found, err := configsDict.Get(serviceName)
 		if err != nil || !found {
-			return nil, startosis_errors.NewInterpretationError("Could not extract the value of the '%s' dictionary for key '%s'. This is Kurtosis bug", ConfigsArgName, serviceName)
+			return nil, nil, startosis_errors.NewInterpretationError("Could not extract the value of the '%s' dictionary for key '%s'. This is Kurtosis bug", ConfigsArgName, serviceName)
 		}
 		serviceConfig, isDictValueAServiceConfig := dictValue.(*service_config.ServiceConfig)
 		if !isDictValueAServiceConfig {
-			return nil, startosis_errors.NewInterpretationError("One value of the '%s' dictionary is not a ServiceConfig (was '%s'). Values of this argument should correspond to the config of the service to be added", ConfigsArgName, reflect.TypeOf(dictValue))
+			return nil, nil, startosis_errors.NewInterpretationError("One value of the '%s' dictionary is not a ServiceConfig (was '%s'). Values of this argument should correspond to the config of the service to be added", ConfigsArgName, reflect.TypeOf(dictValue))
 		}
 		apiServiceConfig, interpretationErr := serviceConfig.ToKurtosisType()
 		if interpretationErr != nil {
-			return nil, interpretationErr
+			return nil, nil, interpretationErr
 		}
 		convertedServiceConfigs[service.ServiceName(serviceNameStr.GoString())] = apiServiceConfig
+
+		readyConditions, interpretationErr := serviceConfig.GetReadyConditions()
+		if interpretationErr != nil {
+			return nil, nil, interpretationErr
+		}
+
+		readyConditionsByServiceName[service.ServiceName(serviceNameStr.GoString())] = readyConditions
 	}
-	return convertedServiceConfigs, nil
+	return convertedServiceConfigs, readyConditionsByServiceName, nil
 }
 
 func makeAddServicesInterpretationReturnValue(serviceConfigs map[service.ServiceName]*kurtosis_core_rpc_api_bindings.ServiceConfig, runtimeValueStore *runtime_value_store.RuntimeValueStore) (map[service.ServiceName]string, *starlark.Dict, *startosis_errors.InterpretationError) {
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/shared_helpers/service_helpers.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/shared_helpers/service_helpers.go
new file mode 100644
index 000000000..23ee2d5f2
--- /dev/null
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/shared_helpers/service_helpers.go
@@ -0,0 +1,71 @@
+package shared_helpers
+
+import (
+	"context"
+	"github.com/cenkalti/backoff/v4"
+	"github.com/kurtosis-tech/kurtosis/container-engine-lib/lib/backend_interface/objects/service"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/service_network"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_instruction/assert"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/recipe"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/runtime_value_store"
+	"github.com/kurtosis-tech/stacktrace"
+	"go.starlark.net/starlark"
+	"time"
+)
+
+func ExecuteServiceAssertionWithRecipe(
+	ctx context.Context,
+	serviceNetwork service_network.ServiceNetwork,
+	runtimeValueStore *runtime_value_store.RuntimeValueStore,
+	serviceName service.ServiceName,
+	recipe recipe.Recipe,
+	valueField string,
+	assertion string,
+	target starlark.Comparable,
+	interval time.Duration,
+	timeout time.Duration,
+) (map[string]starlark.Comparable, int, error) {
+	var requestErr error
+	var assertErr error
+	tries := 0
+	timedOut := false
+	lastResult := map[string]starlark.Comparable{}
+	startTime := time.Now()
+
+	backoffObj := backoff.NewConstantBackOff(interval)
+
+	//TODO check if we can refactor this portion in order to use the time.Ticker(backoffDuration) pattern here:
+	for {
+		tries += 1
+		backoffDuration := backoffObj.NextBackOff()
+		if backoffDuration == backoff.Stop || time.Since(startTime) > timeout {
+			timedOut = true
+			break
+		}
+		lastResult, requestErr = recipe.Execute(ctx, serviceNetwork, runtimeValueStore, serviceName)
+		if requestErr != nil {
+			time.Sleep(backoffDuration)
+			continue
+		}
+		value, found := lastResult[valueField]
+		if !found {
+			return lastResult, tries, stacktrace.NewError("Error extracting value from key '%v'", valueField)
+		}
+		assertErr = assert.Assert(value, assertion, target)
+		if assertErr == nil {
+			break
+		}
+		time.Sleep(backoffDuration)
+	}
+	if timedOut {
+		return lastResult, tries, stacktrace.NewError("Recipe execution timed-out waiting for the assertion to become valid on service '%v'. Waited for '%v'. Last assertion error was: \n%v", serviceName, time.Since(startTime), assertErr)
+	}
+	if requestErr != nil {
+		return lastResult, tries, stacktrace.Propagate(requestErr, "Error executing recipe on service '%v'", serviceName)
+	}
+	if assertErr != nil {
+		return lastResult, tries, stacktrace.Propagate(assertErr, "Error asserting recipe on service '%v'", serviceName)
+	}
+
+	return lastResult, tries, nil
+}
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/wait/wait.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/wait/wait.go
index 690aa3e5d..cc703a40b 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/wait/wait.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/wait/wait.go
@@ -3,10 +3,10 @@ package wait
 import (
 	"context"
 	"fmt"
-	"github.com/cenkalti/backoff/v4"
 	"github.com/kurtosis-tech/kurtosis/container-engine-lib/lib/backend_interface/objects/service"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/service_network"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_instruction/assert"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_instruction/shared_helpers"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/builtin_argument"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/kurtosis_plan_instruction"
@@ -97,7 +97,7 @@ func NewWait(serviceNetwork service_network.ServiceNetwork, runtimeValueStore *r
 				valueField:  "",  // populated at interpretation time
 				assertion:   "",  // populated at interpretation time
 				target:      nil, // populated at interpretation time
-				backoff:     nil, // populated at interpretation time
+				interval:    0,   // populated at interpretation time
 				timeout:     0,   // populated at interpretation time
 				resultUuid:  "",  // populated at interpretation time
 			}
@@ -123,7 +123,7 @@ type WaitCapabilities struct {
 	valueField  string
 	assertion   string
 	target      starlark.Comparable
-	backoff     backoff.BackOff
+	interval    time.Duration
 	timeout     time.Duration
 
 	resultUuid string
@@ -164,19 +164,19 @@ func (builtin *WaitCapabilities) Interpret(arguments *builtin_argument.ArgumentV
 		return nil, startosis_errors.WrapWithInterpretationError(err, "Unable to extract value for '%s' argument", TargetArgName)
 	}
 
-	var waitBackoff backoff.BackOff
+	var interval time.Duration
 	if arguments.IsSet(IntervalArgName) {
-		interval, err := builtin_argument.ExtractArgumentValue[starlark.String](arguments, IntervalArgName)
+		intervalStr, err := builtin_argument.ExtractArgumentValue[starlark.String](arguments, IntervalArgName)
 		if err != nil {
 			return nil, startosis_errors.WrapWithInterpretationError(err, "Unable to extract value for '%s' argument", IntervalArgName)
 		}
-		parsedDuration, parseErr := time.ParseDuration(interval.GoString())
+		parsedInterval, parseErr := time.ParseDuration(intervalStr.GoString())
 		if parseErr != nil {
-			return nil, startosis_errors.WrapWithInterpretationError(parseErr, "An error occurred when parsing interval '%v'", interval.GoString())
+			return nil, startosis_errors.WrapWithInterpretationError(parseErr, "An error occurred when parsing interval '%v'", intervalStr.GoString())
 		}
-		waitBackoff = backoff.NewConstantBackOff(parsedDuration)
+		interval = parsedInterval
 	} else {
-		waitBackoff = backoff.NewConstantBackOff(defaultInterval)
+		interval = defaultInterval
 	}
 
 	var timeout time.Duration
@@ -187,7 +187,7 @@ func (builtin *WaitCapabilities) Interpret(arguments *builtin_argument.ArgumentV
 		}
 		parsedTimeout, parseErr := time.ParseDuration(starlarkTimeout.GoString())
 		if parseErr != nil {
-			return nil, startosis_errors.WrapWithInterpretationError(parseErr, "An error occurred when parsing interval '%v'", starlarkTimeout.GoString())
+			return nil, startosis_errors.WrapWithInterpretationError(parseErr, "An error occurred when parsing timeout '%v'", starlarkTimeout.GoString())
 		}
 		timeout = parsedTimeout
 	} else {
@@ -213,7 +213,7 @@ func (builtin *WaitCapabilities) Interpret(arguments *builtin_argument.ArgumentV
 	builtin.valueField = valueField.GoString()
 	builtin.assertion = assertion.GoString()
 	builtin.target = target
-	builtin.backoff = waitBackoff
+	builtin.interval = interval
 	builtin.timeout = timeout
 	builtin.resultUuid = resultUuid
 
@@ -226,45 +226,37 @@ func (builtin *WaitCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet,
 }
 
 func (builtin *WaitCapabilities) Execute(ctx context.Context, _ *builtin_argument.ArgumentValuesSet) (string, error) {
-	var requestErr error
-	var assertErr error
-	tries := 0
-	timedOut := false
-	lastResult := map[string]starlark.Comparable{}
+
 	startTime := time.Now()
-	for {
-		tries += 1
-		backoffDuration := builtin.backoff.NextBackOff()
-		if backoffDuration == backoff.Stop || time.Since(startTime) > builtin.timeout {
-			timedOut = true
-			break
-		}
-		lastResult, requestErr = builtin.recipe.Execute(ctx, builtin.serviceNetwork, builtin.runtimeValueStore, builtin.serviceName)
-		if requestErr != nil {
-			time.Sleep(backoffDuration)
-			continue
-		}
-		builtin.runtimeValueStore.SetValue(builtin.resultUuid, lastResult)
-		value, found := lastResult[builtin.valueField]
-		if !found {
-			return "", stacktrace.NewError("Error extracting value from key '%v'", builtin.valueField)
-		}
-		assertErr = assert.Assert(value, builtin.assertion, builtin.target)
-		if assertErr != nil {
-			time.Sleep(backoffDuration)
-			continue
-		}
-		break
-	}
-	if timedOut {
-		return "", stacktrace.NewError("Wait timed-out waiting for the assertion to become valid. Waited for '%v'. Last assertion error was: \n%v", time.Since(startTime), assertErr)
-	}
-	if requestErr != nil {
-		return "", stacktrace.Propagate(requestErr, "Error executing HTTP recipe on '%v'", WaitBuiltinName)
-	}
-	if assertErr != nil {
-		return "", stacktrace.Propagate(assertErr, "Error asserting HTTP recipe on '%v'", WaitBuiltinName)
+
+	lastResult, tries, err := shared_helpers.ExecuteServiceAssertionWithRecipe(
+		ctx,
+		builtin.serviceNetwork,
+		builtin.runtimeValueStore,
+		builtin.serviceName,
+		builtin.recipe,
+		builtin.valueField,
+		builtin.assertion,
+		builtin.target,
+		builtin.interval,
+		builtin.timeout,
+	)
+	if err != nil {
+		return "", stacktrace.Propagate(
+			err,
+			"An error occurred checking if service '%v' is ready.",
+			builtin.serviceName,
+		)
 	}
-	instructionResult := fmt.Sprintf("Wait took %d tries (%v in total). Assertion passed with following:\n%s", tries, time.Since(startTime), builtin.recipe.ResultMapToString(lastResult))
+
+	builtin.runtimeValueStore.SetValue(builtin.resultUuid, lastResult)
+
+	instructionResult := fmt.Sprintf(
+		"Wait took %d tries (%v in total). Assertion passed with following:\n%s",
+		tries,
+		time.Since(startTime),
+		builtin.recipe.ResultMapToString(lastResult),
+	)
+
 	return instructionResult, nil
 }


Test patch:
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/add_service_framework_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/add_service_framework_test.go
index 637d2869c..29f49a5b5 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/add_service_framework_test.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/add_service_framework_test.go
@@ -16,6 +16,10 @@ import (
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 	"go.starlark.net/starlark"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
 	"testing"
 )
 
@@ -73,6 +77,34 @@ func (t *addServiceTestCase) GetInstruction() *kurtosis_plan_instruction.Kurtosi
 		nil,
 	)
 
+	serviceNetwork.EXPECT().HttpRequestService(
+		mock.Anything,
+		string(TestServiceName),
+		TestPrivatePortId,
+		TestGetRequestMethod,
+		"",
+		TestReadyConditionsRecipeEndpoint,
+		"",
+	).Times(1).Return(&http.Response{
+		Status:     "200 OK",
+		StatusCode: 200,
+		Proto:      "HTTP/1.1",
+		ProtoMajor: 1,
+		ProtoMinor: 1,
+		Header:     http.Header{},
+		Request: &http.Request{
+			Method: TestGetRequestMethod,
+			URL:    &url.URL{},
+		},
+		Close:            true,
+		ContentLength:    -1,
+		Body:             io.NopCloser(strings.NewReader("{}")),
+		Trailer:          nil,
+		TransferEncoding: nil,
+		Uncompressed:     true,
+		TLS:              nil,
+	}, nil)
+
 	return add_service.NewAddService(serviceNetwork, runtimeValueStore)
 }
 
@@ -88,7 +120,13 @@ func (t *addServiceTestCase) GetStarlarkCode() string {
 		"private_ip_address_placeholder=%q, " +
 		"subnetwork=%q, " +
 		"cpu_allocation=%d, " +
-		"memory_allocation=%d)"
+		"memory_allocation=%d, " +
+		"ready_conditions=ReadyConditions(" +
+		"recipe=GetHttpRequestRecipe(port_id=%q, endpoint=%q, extract={})," +
+		" field=%q," +
+		" assertion=%q," +
+		" target_value=%s" +
+		"))"
 	serviceConfig := fmt.Sprintf(serviceConfigStarlarkStrTemplate,
 		TestContainerImageName,
 		TestPrivatePortId, TestPrivatePortNumber, TestPrivatePortProtocolStr, TestPrivateApplicationProtocol,
@@ -100,7 +138,13 @@ func (t *addServiceTestCase) GetStarlarkCode() string {
 		TestPrivateIPAddressPlaceholder,
 		TestSubnetwork,
 		TestCpuAllocation,
-		TestMemoryAllocation)
+		TestMemoryAllocation,
+		TestPrivatePortId,
+		TestReadyConditionsRecipeEndpoint,
+		TestReadyConditionsField,
+		TestReadyConditionsAssertion,
+		TestReadyConditionsTarget,
+	)
 	return fmt.Sprintf(`%s(%s=%q, %s=%s)`, add_service.AddServiceBuiltinName, add_service.ServiceNameArgName, TestServiceName, add_service.ServiceConfigArgName, serviceConfig)
 }
 
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/add_services_framework_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/add_services_framework_test.go
index 1330e3410..bbe4c53b9 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/add_services_framework_test.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/add_services_framework_test.go
@@ -14,6 +14,10 @@ import (
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 	"go.starlark.net/starlark"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
 	"testing"
 )
 
@@ -60,12 +64,109 @@ func (t *addServicesTestCase) GetInstruction() *kurtosis_plan_instruction.Kurtos
 		nil,
 	)
 
+	serviceNetwork.EXPECT().HttpRequestService(
+		mock.Anything,
+		string(TestServiceName),
+		TestReadyConditionsRecipePortId,
+		TestGetRequestMethod,
+		"",
+		TestReadyConditionsRecipeEndpoint,
+		"",
+	).Times(1).Return(&http.Response{
+		Status:     "200 OK",
+		StatusCode: 200,
+		Proto:      "HTTP/1.1",
+		ProtoMajor: 1,
+		ProtoMinor: 1,
+		Header:     http.Header{},
+		Request: &http.Request{
+			Method: TestGetRequestMethod,
+			URL:    &url.URL{},
+		},
+		Close:            true,
+		ContentLength:    -1,
+		Body:             io.NopCloser(strings.NewReader("{}")),
+		Trailer:          nil,
+		TransferEncoding: nil,
+		Uncompressed:     true,
+		TLS:              nil,
+	}, nil)
+
+	serviceNetwork.EXPECT().HttpRequestService(
+		mock.Anything,
+		string(TestServiceName2),
+		TestReadyConditions2RecipePortId,
+		TestGetRequestMethod,
+		"",
+		TestReadyConditions2RecipeEndpoint,
+		"",
+	).Times(1).Return(&http.Response{
+		Status:     "201 OK",
+		StatusCode: 201,
+		Proto:      "HTTP/1.1",
+		ProtoMajor: 1,
+		ProtoMinor: 1,
+		Header:     http.Header{},
+		Request: &http.Request{
+			Method: TestGetRequestMethod,
+			URL: &url.URL{
+				Path:        "",
+				Scheme:      "",
+				Opaque:      "",
+				User:        nil,
+				Host:        "",
+				RawPath:     "",
+				ForceQuery:  false,
+				RawQuery:    "",
+				Fragment:    "",
+				RawFragment: "",
+			},
+			Proto:            "",
+			ProtoMajor:       0,
+			ProtoMinor:       0,
+			Header:           http.Header{},
+			Body:             nil,
+			GetBody:          nil,
+			ContentLength:    0,
+			TransferEncoding: nil,
+			Close:            false,
+			Host:             "",
+			Form:             nil,
+			PostForm:         nil,
+			MultipartForm:    nil,
+			Trailer:          nil,
+			RemoteAddr:       "",
+			RequestURI:       "",
+			TLS:              nil,
+			Cancel:           nil,
+			Response:         nil,
+		},
+		Close:            true,
+		ContentLength:    -1,
+		Body:             io.NopCloser(strings.NewReader("{}")),
+		Trailer:          nil,
+		TransferEncoding: nil,
+		Uncompressed:     true,
+		TLS:              nil,
+	}, nil)
+
 	return add_service.NewAddServices(serviceNetwork, runtimeValueStore)
 }
 
 func (t *addServicesTestCase) GetStarlarkCode() string {
-	serviceConfig1 := fmt.Sprintf("ServiceConfig(image=%q, subnetwork=%q)", TestContainerImageName, TestSubnetwork)
-	serviceConfig2 := fmt.Sprintf("ServiceConfig(image=%q, cpu_allocation=%d, memory_allocation=%d)", TestContainerImageName, TestCpuAllocation, TestMemoryAllocation)
+	service1ReadyConditionsScriptPart := getDefaultReadyConditionsScriptPart()
+	service2ReadyConditionsScriptPart := getCustomReadyConditionsScripPart(
+		TestReadyConditions2RecipePortId,
+		TestReadyConditions2RecipeEndpoint,
+		TestReadyConditions2RecipeExtract,
+		TestReadyConditions2Field,
+		TestReadyConditions2Assertion,
+		TestReadyConditions2Target,
+		TestReadyConditions2Interval,
+		TestReadyConditions2Timeout,
+	)
+	serviceConfig1 := fmt.Sprintf("ServiceConfig(image=%q, subnetwork=%q, ready_conditions=%s)", TestContainerImageName, TestSubnetwork, service1ReadyConditionsScriptPart)
+	serviceConfig2 := fmt.Sprintf("ServiceConfig(image=%q, cpu_allocation=%d, memory_allocation=%d, ready_conditions=%s)", TestContainerImageName, TestCpuAllocation, TestMemoryAllocation, service2ReadyConditionsScriptPart)
 	return fmt.Sprintf(`%s(%s={%q: %s, %q: %s})`, add_service.AddServicesBuiltinName, add_service.ConfigsArgName, TestServiceName, serviceConfig1, TestServiceName2, serviceConfig2)
 }
 
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/service_config_full_framework_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/service_config_full_framework_test.go
index 70a73e819..7bddbbd4c 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/service_config_full_framework_test.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/service_config_full_framework_test.go
@@ -31,7 +31,7 @@ func (t *serviceConfigFullTestCase) GetTypeConstructor() *kurtosis_type_construc
 }
 
 func (t *serviceConfigFullTestCase) GetStarlarkCode() string {
-	return fmt.Sprintf("%s(%s=%q, %s=%s, %s=%s, %s=%s, %s=%s, %s=%s, %s=%s, %s=%q, %s=%q, %s=%d, %s=%d)",
+	starlarkCode := fmt.Sprintf("%s(%s=%q, %s=%s, %s=%s, %s=%s, %s=%s, %s=%s, %s=%s, %s=%q, %s=%q, %s=%d, %s=%d, %s=%s)",
 		service_config.ServiceConfigTypeName,
 		service_config.ImageAttr, TestContainerImageName,
 		service_config.PortsAttr, fmt.Sprintf("{%q: PortSpec(number=%d, transport_protocol=%q, application_protocol=%q)}", TestPrivatePortId, TestPrivatePortNumber, TestPrivatePortProtocolStr, TestPrivateApplicationProtocol),
@@ -44,7 +44,10 @@ func (t *serviceConfigFullTestCase) GetStarlarkCode() string {
 		service_config.SubnetworkAttr, TestSubnetwork,
 		service_config.CpuAllocationAttr, TestCpuAllocation,
 		service_config.MemoryAllocationAttr, TestMemoryAllocation,
+		service_config.ReadyConditionsAttr,
+		getDefaultReadyConditionsScriptPart(),
 	)
+	return starlarkCode
 }
 
 func (t *serviceConfigFullTestCase) Assert(typeValue starlark.Value) {
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/shared_functions_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/shared_functions_test.go
new file mode 100644
index 000000000..186dbb3aa
--- /dev/null
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/shared_functions_test.go
@@ -0,0 +1,53 @@
+package test_engine
+
+import (
+	"fmt"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types/service_config"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/recipe"
+)
+
+func getDefaultReadyConditionsScriptPart() string {
+	return getCustomReadyConditionsScripPart(
+		TestReadyConditionsRecipePortId,
+		TestReadyConditionsRecipeEndpoint,
+		TestReadyConditionsRecipeExtract,
+		TestReadyConditionsField,
+		TestReadyConditionsAssertion,
+		TestReadyConditionsTarget,
+		TestReadyConditionsInterval,
+		TestReadyConditionsTimeout,
+	)
+}
+
+func getCustomReadyConditionsScripPart(
+	portStr string,
+	endpointStr string,
+	extractStr string,
+	fieldStr string,
+	assertionStr string,
+	targetStr string,
+	intervalStr string,
+	timeoutStr string,
+) string {
+	return fmt.Sprintf("%s(%s=%s(%s=%q, %s=%q, %s=%s), %s=%q, %s=%q, %s=%s, %s=%q, %s=%q)",
+		service_config.ReadyConditionsTypeName,
+		service_config.RecipeAttr,
+		recipe.GetHttpRecipeTypeName,
+		recipe.PortIdAttr,
+		portStr,
+		recipe.EndpointAttr,
+		endpointStr,
+		recipe.ExtractKeyPrefix,
+		extractStr,
+		service_config.FieldAttr,
+		fieldStr,
+		service_config.AssertionAttr,
+		assertionStr,
+		service_config.TargetAttr,
+		targetStr,
+		service_config.IntervalAttr,
+		intervalStr,
+		service_config.TimeoutAttr,
+		timeoutStr,
+	)
+}
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/static_constants_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/static_constants_test.go
index c6a1ef475..f42b01c58 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/static_constants_test.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/static_constants_test.go
@@ -75,4 +75,15 @@ var (
 	TestReadyConditionsTarget         = "200"
 	TestReadyConditionsInterval       = "1s"
 	TestReadyConditionsTimeout        = "100ms"
+
+	TestReadyConditions2RecipePortId   = "https"
+	TestReadyConditions2RecipeEndpoint = "/user-access"
+	TestReadyConditions2RecipeExtract  = "{}"
+	TestReadyConditions2Field          = "code"
+	TestReadyConditions2Assertion      = "=="
+	TestReadyConditions2Target         = "201"
+	TestReadyConditions2Interval       = "500ms"
+	TestReadyConditions2Timeout        = "2s"
+
+	TestGetRequestMethod = "GET"
 )
diff --git a/internal_testsuites/golang/testsuite/startosis_add_service_test/startosis_add_service_with_ready_conditions_test.go b/internal_testsuites/golang/testsuite/startosis_add_service_test/startosis_add_service_with_ready_conditions_test.go
new file mode 100644
index 000000000..e34267214
--- /dev/null
+++ b/internal_testsuites/golang/testsuite/startosis_add_service_test/startosis_add_service_with_ready_conditions_test.go
@@ -0,0 +1,70 @@
+package startosis_add_service_test
+
+import (
+	"context"
+	"fmt"
+	"github.com/kurtosis-tech/kurtosis-cli/golang_internal_testsuite/test_helpers"
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+const (
+	addServiceWithReadyConditionsTestName = "service-test"
+
+	addServiceWithReadyConditionsScript = `
+def run(plan):
+	get_recipe = GetHttpRequestRecipe(
+		port_id = "http-port",
+		endpoint = "?input=foo/bar",
+	)
+
+	ready_conditions = ReadyConditions(
+        recipe=get_recipe,
+		field="code",
+		assertion="==",
+		target_value=%v,
+		interval="1s",
+		timeout="3s"
+    )
+
+	service_config = ServiceConfig(
+		image = "mendhak/http-https-echo:26",
+		ports = {
+			"http-port": PortSpec(number = 8080, transport_protocol = "TCP")
+		},
+        ready_conditions = ready_conditions
+	)
+
+	plan.add_service(service_name = "web-server", config = service_config)
+`
+
+	okStatusCode          = 200
+	serverErrorStatusCode = 500
+)
+
+func TestStartosis_AddServiceWithReadyConditionsCheck(t *testing.T) {
+	ctx := context.Background()
+
+	script := fmt.Sprintf(addServiceWithReadyConditionsScript, okStatusCode)
+
+	runResult := test_helpers.SetupSimpleEnclaveAndRunScript(t, ctx, addServiceWithReadyConditionsTestName, script)
+
+	require.Nil(t, runResult.InterpretationError, "Unexpected interpretation error")
+	require.Empty(t, runResult.ValidationErrors, "Unexpected validation error")
+	require.Empty(t, runResult.ExecutionError, "Unexpected execution error")
+}
+
+func TestStartosis_AddServiceWithReadyConditionsCheckFail(t *testing.T) {
+	ctx := context.Background()
+
+	expectedLastAssertionErrorStr := fmt.Sprintf("Assertion failed '%v' '==' '%v'", okStatusCode, serverErrorStatusCode)
+
+	script := fmt.Sprintf(addServiceWithReadyConditionsScript, serverErrorStatusCode)
+
+	runResult := test_helpers.SetupSimpleEnclaveAndRunScript(t, ctx, addServiceWithReadyConditionsTestName, script)
+
+	require.Nil(t, runResult.InterpretationError, "Unexpected interpretation error")
+	require.Empty(t, runResult.ValidationErrors, "Unexpected validation error")
+	require.NotEmpty(t, runResult.ExecutionError, "Expected execution error coming from failed ready conditions")
+	require.Contains(t, runResult.ExecutionError.ErrorMessage, expectedLastAssertionErrorStr)
+}
diff --git a/internal_testsuites/golang/testsuite/startosis_add_service_test/startosis_add_services_with_ready_conditions_test.go b/internal_testsuites/golang/testsuite/startosis_add_service_test/startosis_add_services_with_ready_conditions_test.go
new file mode 100644
index 000000000..4b915b9b2
--- /dev/null
+++ b/internal_testsuites/golang/testsuite/startosis_add_service_test/startosis_add_services_with_ready_conditions_test.go
@@ -0,0 +1,77 @@
+package startosis_add_service_test
+
+import (
+	"context"
+	"fmt"
+	"github.com/kurtosis-tech/kurtosis-cli/golang_internal_testsuite/test_helpers"
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+const (
+	addServicesWithReadyConditionsTestName = "services-test"
+
+	addServicesWithReadyConditionsScript = `
+HTTP_ECHO_IMAGE = "mendhak/http-https-echo:26"
+SERVICE_NAME_PREFIX = "service-"
+NUM_SERVICES = 4
+
+def run(plan):
+    plan.print("Adding {0} services to enclave".format(NUM_SERVICES))
+
+    get_recipe = GetHttpRequestRecipe(
+        port_id = "http-port",
+        endpoint = "?input=foo/bar",
+	)
+
+    ready_conditions = ReadyConditions(
+        recipe=get_recipe,
+        field="code",
+        assertion="==",
+        target_value=%v,
+        interval="1s",
+        timeout="3s"
+    )
+
+    config = ServiceConfig(
+        image = HTTP_ECHO_IMAGE,
+		ports = {
+			"http-port": PortSpec(number = 8080, transport_protocol = "TCP")
+		},
+		ready_conditions = ready_conditions,
+    )
+    configs = {}
+    for index in range(NUM_SERVICES):
+        service_name = SERVICE_NAME_PREFIX + str(index)
+        configs[service_name] = config
+    
+    plan.add_services(configs)
+`
+)
+
+func TestStartosis_AddServicesWithReadyConditionsCheck(t *testing.T) {
+	ctx := context.Background()
+
+	script := fmt.Sprintf(addServicesWithReadyConditionsScript, okStatusCode)
+
+	runResult := test_helpers.SetupSimpleEnclaveAndRunScript(t, ctx, addServicesWithReadyConditionsTestName, script)
+
+	require.Nil(t, runResult.InterpretationError, "Unexpected interpretation error")
+	require.Empty(t, runResult.ValidationErrors, "Unexpected validation error")
+	require.Empty(t, runResult.ExecutionError, "Unexpected execution error")
+}
+
+func TestStartosis_AddServicesWithReadyConditionsCheckFail(t *testing.T) {
+	ctx := context.Background()
+
+	expectedLastAssertionErrorStr := fmt.Sprintf("Assertion failed '%v' '==' '%v'", okStatusCode, serverErrorStatusCode)
+
+	script := fmt.Sprintf(addServicesWithReadyConditionsScript, serverErrorStatusCode)
+
+	runResult := test_helpers.SetupSimpleEnclaveAndRunScript(t, ctx, addServicesWithReadyConditionsTestName, script)
+
+	require.Nil(t, runResult.InterpretationError, "Unexpected interpretation error")
+	require.Empty(t, runResult.ValidationErrors, "Unexpected validation error")
+	require.NotEmpty(t, runResult.ExecutionError, "Expected execution error coming from failed ready conditions")
+	require.Contains(t, runResult.ExecutionError.ErrorMessage, expectedLastAssertionErrorStr)
+}