Repository: kurtosis-tech/kurtosis
Pull request #1028 (2023-07-28 17:16:42)

feat: Persistent service directories
## Description:
This PR adds the `Directory` object to Starlark to be used in the `ServiceConfig`'s `files` argument. Services can now have directories that persists over service restarts by doing:
```
plan.add_service(
    ...
    config=ServiceConfig(
        ...
        files={
            "/path/to/directory": Directory(
                persistent_key="directory-persistent-key",
            )
        }
        ...
    )
)
```
Persistent directories are owned by a single service. They cannot be shared across multiple services. Kurtosis keep them as long as the enclave lives. Re-using the same service name and persistent key will return the same (persisted) directory over and over again.

Note: previous usage of the `files` dictionary in `ServiceConfig` for file artifacts is still supported, but should be migrated to use the new `Directory` object:
```
plan.add_service(
    ...
    config=ServiceConfig(
        ...
        files={
            "/path/to/file/artifact": Directory(
                artifact_name=my_artifact_name,
            )
        }
        ...
    )
)
```

Docs PR will follow

## Is this change user facing?
YES
<!-- If yes, please add the "user facing" label to the PR -->
<!-- If yes, don't forget to include docs changes where relevant -->

## References (if applicable):
<!-- Add relevant Github Issues, Discord threads, or other helpful information. -->



Patch:
diff --git a/container-engine-lib/lib/backend_impls/kubernetes/kubernetes_manager/kubernetes_manager.go b/container-engine-lib/lib/backend_impls/kubernetes/kubernetes_manager/kubernetes_manager.go
index d8915c1fd..482891991 100644
--- a/container-engine-lib/lib/backend_impls/kubernetes/kubernetes_manager/kubernetes_manager.go
+++ b/container-engine-lib/lib/backend_impls/kubernetes/kubernetes_manager/kubernetes_manager.go
@@ -365,7 +365,7 @@ func (manager *KubernetesManager) CreatePersistentVolume(
 				CSI:                  nil,
 			},
 			AccessModes: []apiv1.PersistentVolumeAccessMode{
-				apiv1.ReadWriteOncePod,
+				apiv1.ReadWriteOnce, // ReadWriteOncePod would be better, but it's a fairly recent feature
 			},
 			ClaimRef:                      nil,
 			PersistentVolumeReclaimPolicy: "",
@@ -471,7 +471,7 @@ func (manager *KubernetesManager) CreatePersistentVolumeClaim(
 		},
 		Spec: apiv1.PersistentVolumeClaimSpec{
 			AccessModes: []apiv1.PersistentVolumeAccessMode{
-				apiv1.ReadWriteOncePod,
+				apiv1.ReadWriteOnce, // ReadWriteOncePod would be better, but it's a fairly recent feature
 			},
 			Selector: nil,
 			Resources: apiv1.ResourceRequirements{
diff --git a/container-engine-lib/lib/backend_interface/objects/service_directory/persistent_directories.go b/container-engine-lib/lib/backend_interface/objects/service_directory/persistent_directories.go
index 725a86b83..17250b31d 100644
--- a/container-engine-lib/lib/backend_interface/objects/service_directory/persistent_directories.go
+++ b/container-engine-lib/lib/backend_interface/objects/service_directory/persistent_directories.go
@@ -5,3 +5,9 @@ type DirectoryPersistentKey string
 type PersistentDirectories struct {
 	ServiceDirpathToDirectoryPersistentKey map[string]DirectoryPersistentKey
 }
+
+func NewPersistentDirectories(serviceDirpathToDirectoryPersistentKey map[string]DirectoryPersistentKey) *PersistentDirectories {
+	return &PersistentDirectories{
+		ServiceDirpathToDirectoryPersistentKey: serviceDirpathToDirectoryPersistentKey,
+	}
+}
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_builtins.go b/core/server/api_container/server/startosis_engine/kurtosis_builtins.go
index ca50de0ca..976b82795 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_builtins.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_builtins.go
@@ -23,6 +23,7 @@ import (
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_instruction/wait"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/kurtosis_plan_instruction"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types/connection_config"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types/directory"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types/packet_delay_distribution"
 	"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"
@@ -88,6 +89,7 @@ func KurtosisHelpers(recursiveInterpret func(moduleId string, scriptContent stri
 // Example: ServiceConfig, PortSpec, etc.
 func KurtosisTypeConstructors() []*starlark.Builtin {
 	return []*starlark.Builtin{
+		starlark.NewBuiltin(directory.DirectoryTypeName, directory.NewDirectoryType().CreateBuiltin()),
 		starlark.NewBuiltin(recipe.ExecRecipeTypeName, recipe.NewExecRecipeType().CreateBuiltin()),
 		starlark.NewBuiltin(recipe.GetHttpRecipeTypeName, recipe.NewGetHttpRequestRecipeType().CreateBuiltin()),
 		starlark.NewBuiltin(recipe.PostHttpRecipeTypeName, recipe.NewPostHttpRequestRecipeType().CreateBuiltin()),
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_types/directory/directory.go b/core/server/api_container/server/startosis_engine/kurtosis_types/directory/directory.go
new file mode 100644
index 000000000..7591b5963
--- /dev/null
+++ b/core/server/api_container/server/startosis_engine/kurtosis_types/directory/directory.go
@@ -0,0 +1,114 @@
+package directory
+
+import (
+	"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_type_constructor"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/startosis_errors"
+	"go.starlark.net/starlark"
+)
+
+const (
+	DirectoryTypeName = "Directory"
+
+	ArtifactNameAttr  = "artifact_name"
+	PersistentKeyAttr = "persistent_key"
+)
+
+func NewDirectoryType() *kurtosis_type_constructor.KurtosisTypeConstructor {
+	return &kurtosis_type_constructor.KurtosisTypeConstructor{
+		KurtosisBaseBuiltin: &kurtosis_starlark_framework.KurtosisBaseBuiltin{
+			Name: DirectoryTypeName,
+
+			Arguments: []*builtin_argument.BuiltinArgument{
+				{
+					Name:              ArtifactNameAttr,
+					IsOptional:        true,
+					ZeroValueProvider: builtin_argument.ZeroValueProvider[starlark.String],
+					Validator: func(value starlark.Value) *startosis_errors.InterpretationError {
+						return builtin_argument.NonEmptyString(value, ArtifactNameAttr)
+					},
+				},
+				{
+					Name:              PersistentKeyAttr,
+					IsOptional:        true,
+					ZeroValueProvider: builtin_argument.ZeroValueProvider[starlark.String],
+					Validator: func(value starlark.Value) *startosis_errors.InterpretationError {
+						return builtin_argument.NonEmptyString(value, ArtifactNameAttr)
+					},
+				},
+			},
+		},
+
+		Instantiate: instantiate,
+	}
+}
+
+func instantiate(arguments *builtin_argument.ArgumentValuesSet) (builtin_argument.KurtosisValueType, *startosis_errors.InterpretationError) {
+	kurtosisValueType, interpretationErr := kurtosis_type_constructor.CreateKurtosisStarlarkTypeDefault(DirectoryTypeName, arguments)
+	if interpretationErr != nil {
+		return nil, interpretationErr
+	}
+	return &Directory{
+		KurtosisValueTypeDefault: kurtosisValueType,
+	}, nil
+}
+
+type Directory struct {
+	*kurtosis_type_constructor.KurtosisValueTypeDefault
+}
+
+// CreateDirectoryFromFilesArtifact creates a directory object from a file artifact name. This is only for backward
+// compatibility
+func CreateDirectoryFromFilesArtifact(
+	filesArtifactName string,
+) (*Directory, *startosis_errors.InterpretationError) {
+	args := []starlark.Value{
+		starlark.String(filesArtifactName),
+		nil,
+	}
+
+	argumentDefinitions := NewDirectoryType().KurtosisBaseBuiltin.Arguments
+	argumentValuesSet := builtin_argument.NewArgumentValuesSet(argumentDefinitions, args)
+	kurtosisDefaultValue, interpretationErr := kurtosis_type_constructor.CreateKurtosisStarlarkTypeDefault(ArtifactNameAttr, argumentValuesSet)
+	if interpretationErr != nil {
+		return nil, interpretationErr
+	}
+	return &Directory{
+		KurtosisValueTypeDefault: kurtosisDefaultValue,
+	}, nil
+}
+
+func (directory *Directory) Copy() (builtin_argument.KurtosisValueType, error) {
+	copiedValueType, err := directory.KurtosisValueTypeDefault.Copy()
+	if err != nil {
+		return nil, err
+	}
+	return &Directory{
+		KurtosisValueTypeDefault: copiedValueType,
+	}, nil
+}
+
+func (directory *Directory) GetArtifactNameIfSet() (string, bool, *startosis_errors.InterpretationError) {
+	fileArtifact, found, interpretationErr := kurtosis_type_constructor.ExtractAttrValue[starlark.String](
+		directory.KurtosisValueTypeDefault, ArtifactNameAttr)
+	if interpretationErr != nil {
+		return "", false, interpretationErr
+	}
+	if !found {
+		return "", false, nil
+	}
+	return fileArtifact.GoString(), true, nil
+}
+
+func (directory *Directory) GetPersistentKeyIfSet() (string, bool, *startosis_errors.InterpretationError) {
+	persistentKey, found, interpretationErr := kurtosis_type_constructor.ExtractAttrValue[starlark.String](
+		directory.KurtosisValueTypeDefault, PersistentKeyAttr)
+	if interpretationErr != nil {
+		return "", false, interpretationErr
+	}
+	if !found {
+		return "", false, nil
+	}
+	return persistentKey.GoString(), true, nil
+}
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_types/service_config/service_config.go b/core/server/api_container/server/startosis_engine/kurtosis_types/service_config/service_config.go
index 9793bb752..79760ede4 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_types/service_config/service_config.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_types/service_config/service_config.go
@@ -11,6 +11,7 @@ import (
 	"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_type_constructor"
 	"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/directory"
 	starlark_port_spec "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/starlark_warning"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/startosis_errors"
@@ -250,6 +251,14 @@ func ConvertFilesArtifactsMounts(filesArtifactsMountDirpathsMap map[string]strin
 	}, nil
 }
 
+func convertPersistentDirectoryMounts(persistentDirectoriesDirpathsMap map[string]string) *service_directory.PersistentDirectories {
+	persistentDirectoriesMap := map[string]service_directory.DirectoryPersistentKey{}
+	for dirPath, persistentKeyStr := range persistentDirectoriesDirpathsMap {
+		persistentDirectoriesMap[dirPath] = service_directory.DirectoryPersistentKey(persistentKeyStr)
+	}
+	return service_directory.NewPersistentDirectories(persistentDirectoriesMap)
+}
+
 func (config *ServiceConfig) ToKurtosisType(serviceNetwork service_network.ServiceNetwork) (*service.ServiceConfig, *startosis_errors.InterpretationError) {
 	var ok bool
 	image, found, interpretationErr := kurtosis_type_constructor.ExtractAttrValue[starlark.String](config.KurtosisValueTypeDefault, ImageAttr)
@@ -293,13 +302,15 @@ func (config *ServiceConfig) ToKurtosisType(serviceNetwork service_network.Servi
 	}
 
 	var filesArtifactExpansions *service_directory.FilesArtifactsExpansion
+	var persistentDirectories *service_directory.PersistentDirectories
 	filesStarlark, found, interpretationErr := kurtosis_type_constructor.ExtractAttrValue[*starlark.Dict](config.KurtosisValueTypeDefault, FilesAttr)
 	if interpretationErr != nil {
 		return nil, interpretationErr
 	}
 	if found {
 		var filesArtifactsMountDirpathsMap map[string]string
-		filesArtifactsMountDirpathsMap, interpretationErr = kurtosis_types.SafeCastToMapStringString(filesStarlark, FilesAttr)
+		var persistentDirectoriesDirpathsMap map[string]string
+		filesArtifactsMountDirpathsMap, persistentDirectoriesDirpathsMap, interpretationErr = convertFilesArguments(FilesAttr, filesStarlark)
 		if interpretationErr != nil {
 			return nil, interpretationErr
 		}
@@ -307,6 +318,7 @@ func (config *ServiceConfig) ToKurtosisType(serviceNetwork service_network.Servi
 		if interpretationErr != nil {
 			return nil, interpretationErr
 		}
+		persistentDirectories = convertPersistentDirectoryMounts(persistentDirectoriesDirpathsMap)
 	}
 
 	var entryPointArgs []string
@@ -451,7 +463,7 @@ func (config *ServiceConfig) ToKurtosisType(serviceNetwork service_network.Servi
 		cmdArgs,
 		envVars,
 		filesArtifactExpansions,
-		nil,
+		persistentDirectories,
 		maxCpu,
 		maxMemory,
 		privateIpAddressPlaceholder,
@@ -488,3 +500,51 @@ func convertPortMapEntry(attrNameForLogging string, key starlark.Value, value st
 	}
 	return keyStr.GoString(), servicePortSpec, nil
 }
+
+func convertFilesArguments(attrNameForLogging string, filesDict *starlark.Dict) (map[string]string, map[string]string, *startosis_errors.InterpretationError) {
+	filesArtifacts := map[string]string{}
+	persistentDirectories := map[string]string{}
+	for _, fileItem := range filesDict.Items() {
+		rawDirPath := fileItem[0]
+		dirPath, ok := rawDirPath.(starlark.String)
+		if !ok {
+			return nil, nil, startosis_errors.NewInterpretationError("Unable to convert key of '%s' dictionary '%v' to string", attrNameForLogging, filesDict)
+		}
+
+		var interpretationErr *startosis_errors.InterpretationError
+		rawDirectoryObj := fileItem[1]
+		directoryObj, isDirectoryArg := rawDirectoryObj.(*directory.Directory)
+		if !isDirectoryArg {
+			// might be okay as we're supporting strings as well for backward compat
+			directoryObjAsStr, isSimpleStringArg := rawDirectoryObj.(starlark.String)
+			if !isSimpleStringArg {
+				return nil, nil, startosis_errors.NewInterpretationError("Unable to convert value of '%s' dictionary '%v' to a Directory object", attrNameForLogging, filesDict)
+			}
+			// TODO: add starlark_warning.PrintOnceAtTheEndOfExecutionf("%v %v", starlark_warning.WarningConstant, warningMessage) once it's in the doc
+			directoryObj, interpretationErr = directory.CreateDirectoryFromFilesArtifact(directoryObjAsStr.GoString())
+			if interpretationErr != nil {
+				return nil, nil, interpretationErr
+			}
+		}
+		artifactName, artifactNameSet, interpretationErr := directoryObj.GetArtifactNameIfSet()
+		if interpretationErr != nil {
+			return nil, nil, interpretationErr
+		}
+		persistentKey, persistentKeySet, interpretationErr := directoryObj.GetPersistentKeyIfSet()
+		if interpretationErr != nil {
+			return nil, nil, interpretationErr
+		}
+		if artifactNameSet == persistentKeySet {
+			// this condition is a XOR
+			return nil, nil, startosis_errors.NewInterpretationError("Parameter '%s' and '%s' cannot be set on the same '%s' object: '%s'",
+				directory.ArtifactNameAttr, directory.PersistentKeyAttr, directory.DirectoryTypeName, directoryObj.String())
+		}
+		if artifactNameSet {
+			filesArtifacts[dirPath.GoString()] = artifactName
+		} else {
+			// persistentKey is necessarily set since we checked the exclusivity above
+			persistentDirectories[dirPath.GoString()] = persistentKey
+		}
+	}
+	return filesArtifacts, persistentDirectories, nil
+}


Test patch:
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/directory_file_artifact_framework_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/directory_file_artifact_framework_test.go
new file mode 100644
index 000000000..43634e700
--- /dev/null
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/directory_file_artifact_framework_test.go
@@ -0,0 +1,42 @@
+package test_engine
+
+import (
+	"fmt"
+	"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_types/directory"
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+type directoryFileArtifactTestCase struct {
+	*testing.T
+}
+
+func newDirectoryFileArtifactTestCase(t *testing.T) *directoryFileArtifactTestCase {
+	return &directoryFileArtifactTestCase{
+		T: t,
+	}
+}
+
+func (t *directoryFileArtifactTestCase) GetId() string {
+	return fmt.Sprintf("%s_%s", directory.DirectoryTypeName, "FileArtifact")
+}
+
+func (t *directoryFileArtifactTestCase) GetStarlarkCode() string {
+	return fmt.Sprintf("%s(%s=%q)", directory.DirectoryTypeName, directory.ArtifactNameAttr, TestFilesArtifactName1)
+}
+
+func (t *directoryFileArtifactTestCase) Assert(typeValue builtin_argument.KurtosisValueType) {
+	directoryStarlark, ok := typeValue.(*directory.Directory)
+	require.True(t, ok)
+
+	artifactName, found, err := directoryStarlark.GetArtifactNameIfSet()
+	require.Nil(t, err)
+	require.True(t, found)
+	require.Equal(t, TestFilesArtifactName1, artifactName)
+
+	persistentKey, found, err := directoryStarlark.GetPersistentKeyIfSet()
+	require.Nil(t, err)
+	require.False(t, found)
+	require.Empty(t, persistentKey)
+}
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/directory_persistent_directory_framework_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/directory_persistent_directory_framework_test.go
new file mode 100644
index 000000000..d728f6b30
--- /dev/null
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/directory_persistent_directory_framework_test.go
@@ -0,0 +1,42 @@
+package test_engine
+
+import (
+	"fmt"
+	"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_types/directory"
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+type directoryPersistentDirectoryTestCase struct {
+	*testing.T
+}
+
+func newDirectoryPersistnetDirectoryTestCase(t *testing.T) *directoryPersistentDirectoryTestCase {
+	return &directoryPersistentDirectoryTestCase{
+		T: t,
+	}
+}
+
+func (t *directoryPersistentDirectoryTestCase) GetId() string {
+	return fmt.Sprintf("%s_%s", directory.DirectoryTypeName, "PersistentDirectory")
+}
+
+func (t *directoryPersistentDirectoryTestCase) GetStarlarkCode() string {
+	return fmt.Sprintf("%s(%s=%q)", directory.DirectoryTypeName, directory.PersistentKeyAttr, TestPersistentDirectoryKey)
+}
+
+func (t *directoryPersistentDirectoryTestCase) Assert(typeValue builtin_argument.KurtosisValueType) {
+	directoryStarlark, ok := typeValue.(*directory.Directory)
+	require.True(t, ok)
+
+	artifactName, found, err := directoryStarlark.GetArtifactNameIfSet()
+	require.Nil(t, err)
+	require.False(t, found)
+	require.Empty(t, artifactName)
+
+	persistentKey, found, err := directoryStarlark.GetPersistentKeyIfSet()
+	require.Nil(t, err)
+	require.True(t, found)
+	require.Equal(t, TestPersistentDirectoryKey, persistentKey)
+}
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 d16cb12f5..d60725598 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
@@ -3,8 +3,10 @@ package test_engine
 import (
 	"fmt"
 	"github.com/kurtosis-tech/kurtosis/container-engine-lib/lib/backend_interface/objects/port_spec"
+	"github.com/kurtosis-tech/kurtosis/container-engine-lib/lib/backend_interface/objects/service_directory"
 	"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_starlark_framework/builtin_argument"
+	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types/directory"
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_types/service_config"
 	"github.com/stretchr/testify/require"
 	"net"
@@ -34,12 +36,15 @@ func (t *serviceConfigFullTestCase) GetId() string {
 }
 
 func (t *serviceConfigFullTestCase) GetStarlarkCode() string {
+	fileArtifact1 := fmt.Sprintf("%s(%s=%q)", directory.DirectoryTypeName, directory.ArtifactNameAttr, TestFilesArtifactName1)
+	fileArtifact2 := fmt.Sprintf("%s(%s=%q)", directory.DirectoryTypeName, directory.ArtifactNameAttr, TestFilesArtifactName2)
+	persistentDirectory := fmt.Sprintf("%s(%s=%q)", directory.DirectoryTypeName, directory.PersistentKeyAttr, TestPersistentDirectoryKey)
 	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=%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, wait=%q)}", TestPrivatePortId, TestPrivatePortNumber, TestPrivatePortProtocolStr, TestPrivateApplicationProtocol, TestWaitConfiguration),
 		service_config.PublicPortsAttr, fmt.Sprintf("{%q: PortSpec(number=%d, transport_protocol=%q, application_protocol=%q, wait=%q)}", TestPublicPortId, TestPublicPortNumber, TestPublicPortProtocolStr, TestPublicApplicationProtocol, TestWaitConfiguration),
-		service_config.FilesAttr, fmt.Sprintf("{%q: %q, %q: %q}", TestFilesArtifactPath1, TestFilesArtifactName1, TestFilesArtifactPath2, TestFilesArtifactName2),
+		service_config.FilesAttr, fmt.Sprintf("{%q: %s, %q: %s, %q: %s}", TestFilesArtifactPath1, fileArtifact1, TestFilesArtifactPath2, fileArtifact2, TestPersistentDirectoryPath, persistentDirectory),
 		service_config.EntrypointAttr, fmt.Sprintf("[%q, %q]", TestEntryPointSlice[0], TestEntryPointSlice[1]),
 		service_config.CmdAttr, fmt.Sprintf("[%q, %q, %q]", TestCmdSlice[0], TestCmdSlice[1], TestCmdSlice[2]),
 		service_config.EnvVarsAttr, fmt.Sprintf("{%q: %q, %q: %q}", TestEnvVarName1, TestEnvVarValue1, TestEnvVarName2, TestEnvVarValue2),
@@ -88,6 +93,12 @@ func (t *serviceConfigFullTestCase) Assert(typeValue builtin_argument.KurtosisVa
 	require.NotNil(t, serviceConfig.GetFilesArtifactsExpansion())
 	require.Equal(t, expectedFilesArtifactMap, serviceConfig.GetFilesArtifactsExpansion().ServiceDirpathsToArtifactIdentifiers)
 
+	expectedPersistentDirectoryMap := map[string]service_directory.DirectoryPersistentKey{
+		TestPersistentDirectoryPath: service_directory.DirectoryPersistentKey(TestPersistentDirectoryKey),
+	}
+	require.NotNil(t, serviceConfig.GetPersistentDirectories())
+	require.Equal(t, expectedPersistentDirectoryMap, serviceConfig.GetPersistentDirectories().ServiceDirpathToDirectoryPersistentKey)
+
 	require.Equal(t, TestEntryPointSlice, serviceConfig.GetEntrypointArgs())
 	require.Equal(t, TestCmdSlice, serviceConfig.GetCmdArgs())
 
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/starlark_framework_engine_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/starlark_framework_engine_test.go
index 619c39765..2b562b9a3 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/starlark_framework_engine_test.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/starlark_framework_engine_test.go
@@ -56,6 +56,8 @@ func TestAllRegisteredBuiltins(t *testing.T) {
 	testKurtosisTypeConstructor(t, newConnectionConfigFullTestCase(t))
 	testKurtosisTypeConstructor(t, newConnectionConfigWithPacketDelayTestCase(t))
 	testKurtosisTypeConstructor(t, newConnectionConfigWithPacketLossTestCase(t))
+	testKurtosisTypeConstructor(t, newDirectoryFileArtifactTestCase(t))
+	testKurtosisTypeConstructor(t, newDirectoryPersistnetDirectoryTestCase(t))
 	testKurtosisTypeConstructor(t, newExecRecipeTestCase(t))
 	testKurtosisTypeConstructor(t, newGetHttpRequestRecipeNoExtractorTestCase(t))
 	testKurtosisTypeConstructor(t, newGetHttpRequestRecipeTestCase(t))
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 9dbaae60e..d9e13f82d 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
@@ -40,10 +40,12 @@ var (
 	TestPublicPortProtocol        = port_spec.TransportProtocol_TCP
 	TestPublicApplicationProtocol = "https"
 
-	TestFilesArtifactPath1 = "path/to/file/1"
-	TestFilesArtifactName1 = "file_1"
-	TestFilesArtifactPath2 = "path/to/file/2"
-	TestFilesArtifactName2 = "file_2"
+	TestFilesArtifactPath1      = "path/to/file/1"
+	TestFilesArtifactName1      = "file_1"
+	TestFilesArtifactPath2      = "path/to/file/2"
+	TestFilesArtifactName2      = "file_2"
+	TestPersistentDirectoryPath = "path/to/persistent/dir"
+	TestPersistentDirectoryKey  = "persistent-dir-test"
 
 	TestEntryPointSlice = []string{
 		"127.0.0.0",
diff --git a/internal_testsuites/golang/testsuite/startosis_persistent_directory_test/startosis_persistent_directory_test.go b/internal_testsuites/golang/testsuite/startosis_persistent_directory_test/startosis_persistent_directory_test.go
new file mode 100644
index 000000000..2af4308fd
--- /dev/null
+++ b/internal_testsuites/golang/testsuite/startosis_persistent_directory_test/startosis_persistent_directory_test.go
@@ -0,0 +1,81 @@
+package startosis_persistent_directory_test
+
+import (
+	"context"
+	"fmt"
+	"github.com/kurtosis-tech/kurtosis-cli/golang_internal_testsuite/test_helpers"
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+const (
+	testName = "persist-data-test"
+
+	addServiceWriteLineToFile = `
+IMAGE = "docker/getting-started"
+SERVICE_NAME = "test-service"
+
+def run(plan):
+	service_config = ServiceConfig(
+		image=IMAGE,
+		files={
+			"/data": Directory(
+				persistent_key="persistent_data",
+			),
+		},
+		min_cpu=%d,
+	)
+	service = plan.add_service(name=SERVICE_NAME, config=service_config)
+
+	plan.exec(
+        service_name=SERVICE_NAME,
+        recipe=ExecRecipe([
+            "/bin/sh",
+            "-c",
+            "echo 'Hello world !' >> /data/test.log",
+        ])
+    )
+	
+	plan.exec(
+        service_name=SERVICE_NAME,
+        recipe=ExecRecipe([
+            "/bin/sh",
+            "-c",
+            "wc -l /data/test.log",
+        ])
+    )
+`
+)
+
+func TestAddServiceAndPersistentFileToDirectory(t *testing.T) {
+	ctx := context.Background()
+	// ------------------------------------- ENGINE SETUP ----------------------------------------------
+	enclaveCtx, _, destroyEnclaveFunc, err := test_helpers.CreateEnclave(t, ctx, testName)
+	require.NoError(t, err, "An error occurred creating an enclave")
+	defer func() {
+		err = destroyEnclaveFunc()
+		require.NoError(t, err, "An error occurred destroying the enclave after the test finished")
+	}()
+
+	// ------------------------------------- TEST RUN ----------------------------------------------
+	firstScript := fmt.Sprintf(addServiceWriteLineToFile, 100)
+	firstRunResult, err := test_helpers.RunScriptWithDefaultConfig(ctx, enclaveCtx, firstScript)
+	logrus.Infof("Test Output: %v", firstRunResult)
+	require.NoError(t, err, "Unexpected error executing starlark script")
+	require.Nil(t, firstRunResult.InterpretationError, "Unexpected interpretation error. This test requires you to be online for the read_file command to run")
+	require.Empty(t, firstRunResult.ValidationErrors, "Unexpected validation error")
+	require.Nil(t, firstRunResult.ExecutionError, "Unexpected execution error")
+	// This checks the output of the `wc -l` at the end. For the first run, the file should contain one line only
+	require.Contains(t, firstRunResult.RunOutput, "\n1 /data/test.log\n")
+
+	secondScript := fmt.Sprintf(addServiceWriteLineToFile, 150) // we slightly change the service config so that the service gets restarted and the execs re-run
+	secondRunResult, err := test_helpers.RunScriptWithDefaultConfig(ctx, enclaveCtx, secondScript)
+	logrus.Infof("Test Output: %v", secondRunResult)
+	require.NoError(t, err, "Unexpected error executing starlark script")
+	require.Nil(t, secondRunResult.InterpretationError, "Unexpected interpretation error. This test requires you to be online for the read_file command to run")
+	require.Empty(t, secondRunResult.ValidationErrors, "Unexpected validation error")
+	require.Nil(t, secondRunResult.ExecutionError, "Unexpected execution error")
+	// For the second run, it should contain 2 lines as the file persisted between the 2 runs
+	require.Contains(t, secondRunResult.RunOutput, "\n2 /data/test.log\n")
+}