Repository: kurtosis-tech/kurtosis
Pull request #1066 (2023-08-04 12:08:58)

feat: Update files if already present in enclave
## Description:
Right now, when a Starlark package creates a file artifact (via `upload_files`, `render_template`, or `store_service_file`), if a file artifact already exist with that name inside the enclave, it fails. This is a big pain for idempotency.
This PR introduces files artifact updates. From now on, when a files artifact is "created" via starlark and already exists in the enclave (based on its artifact name), Kurtosis will check the equality of the files based on their hash, and if they are different the files artifact will be replaced by the new one (and of course it no-ops if they are the same).
No change requires in the Starlark packages

## 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/api/golang/core/lib/shared_utils/shared_utils.go b/api/golang/core/lib/shared_utils/shared_utils.go
index e65affd84..b6bf56bb1 100644
--- a/api/golang/core/lib/shared_utils/shared_utils.go
+++ b/api/golang/core/lib/shared_utils/shared_utils.go
@@ -21,17 +21,33 @@ const (
 	readonlyPermissions       = 0400
 )
 
-// CompressPath compressed the entire content of the file or directory at pathToCompress and returns an io.ReadCloser
-// of the TGZ archive created, alongside the size (in bytes) of the archive
-// The consumer should take care of closing the io.ReadClose returned
+// CompressPath is similar to CompressPathToFile but opens the archive and returns a ReadCloser to it.
+// It's the consumer's responsibility to make sure the result gets closed appropriately
 func CompressPath(pathToCompress string, enforceMaxFileSizeLimit bool) (io.ReadCloser, uint64, []byte, error) {
+	compressedFilePath, compressedFileSize, compressedFileContentMd5, err := CompressPathToFile(pathToCompress, enforceMaxFileSizeLimit)
+	if err != nil {
+		return nil, 0, nil, stacktrace.Propagate(err,
+			"An error occurred creating the archive from the files at '%s'", pathToCompress)
+	}
+	compressedFile, err := os.OpenFile(compressedFilePath, os.O_RDONLY, ownerAllPermissions)
+	if err != nil {
+		return nil, 0, nil, stacktrace.Propagate(err,
+			"Failed to open the archive file at '%s' during files upload for '%s'.", compressedFilePath, pathToCompress)
+	}
+	return compressedFile, compressedFileSize, compressedFileContentMd5, nil
+}
+
+// CompressPathToFile compresses the entire content of the file or directory at pathToCompress and returns the path
+// to the TGZ archive created, alongside the size (in bytes) of the archive and the md5 of its content
+// Note: the MD5 is NOT the MD% of the archive file itself. See inline comments below for more info on this MD5 hash
+func CompressPathToFile(pathToCompress string, enforceMaxFileSizeLimit bool) (string, uint64, []byte, error) {
 	// First we compute the hash of the content about to be compressed
 	// Note that we're computing this "complex" hash here because the way tar.gz works, it writes files metadata to the
 	// archive, like last date of modification for each file. In our case, we just want to hash the content, regardless
 	// of those metadata.
 	filesInPathRecursive, err := listFilesInPathDeterministic(pathToCompress, true)
 	if err != nil {
-		return nil, 0, nil, stacktrace.Propagate(err, "There was an error in getting a list of files in the directory '%s' provided", pathToCompress)
+		return "", 0, nil, stacktrace.Propagate(err, "There was an error in getting a list of files in the directory '%s' provided", pathToCompress)
 	}
 	compressedFileContentMd5 := md5.New()
 	for _, filePath := range filesInPathRecursive {
@@ -40,36 +56,29 @@ func CompressPath(pathToCompress string, enforceMaxFileSizeLimit bool) (io.ReadC
 		// of the archive change but the files remains the same, the hash will change as well
 		compressedFileContentMd5.Write([]byte(pathRelativeToRoot))
 		if err = writeFileContent(filePath, compressedFileContentMd5); err != nil {
-			return nil, 0, nil, stacktrace.Propagate(err, "An error occurred computing files artifact hash for '%s' in '%s'", filePath, pathToCompress)
+			return "", 0, nil, stacktrace.Propagate(err, "An error occurred computing files artifact hash for '%s' in '%s'", filePath, pathToCompress)
 		}
 	}
 
 	// Then we compress the content into an archive
 	filepathsToUpload, err := listFilesInPathDeterministic(pathToCompress, false)
 	if err != nil {
-		return nil, 0, nil, stacktrace.Propagate(err, "There was an error in getting a list of files in the directory '%s' provided", pathToCompress)
+		return "", 0, nil, stacktrace.Propagate(err, "There was an error in getting a list of files in the directory '%s' provided", pathToCompress)
 	}
 
 	tempDir, err := os.MkdirTemp(defaultTmpDir, tempCompressionDirPattern)
 	if err != nil {
-		return nil, 0, nil, stacktrace.Propagate(err, "Failed to create temporary directory '%s' for compression.", tempDir)
+		return "", 0, nil, stacktrace.Propagate(err, "Failed to create temporary directory '%s' for compression.", tempDir)
 	}
 
 	compressedFilePath := filepath.Join(tempDir, filepath.Base(pathToCompress)+compressionExtension)
 	if err = archiver.Archive(filepathsToUpload, compressedFilePath); err != nil {
-		return nil, 0, nil, stacktrace.Propagate(err, "Failed to compress '%s'.", pathToCompress)
+		return "", 0, nil, stacktrace.Propagate(err, "Failed to compress '%s'.", pathToCompress)
 	}
 
-	compressedFile, err := os.OpenFile(compressedFilePath, os.O_RDONLY, ownerAllPermissions)
-	if err != nil {
-		return nil, 0, nil, stacktrace.Propagate(err,
-			"Failed to create a temporary archive file at '%s' during files upload for '%s'.",
-			tempDir, pathToCompress)
-	}
-
-	compressedFileInfo, err := compressedFile.Stat()
+	compressedFileInfo, err := os.Stat(compressedFilePath)
 	if err != nil {
-		return nil, 0, nil, stacktrace.Propagate(err,
+		return "", 0, nil, stacktrace.Propagate(err,
 			"Failed to inspect temporary archive file at '%s' during files upload for '%s'.",
 			tempDir, pathToCompress)
 	}
@@ -78,18 +87,18 @@ func CompressPath(pathToCompress string, enforceMaxFileSizeLimit bool) (io.ReadC
 	if compressedFileInfo.Size() >= 0 {
 		compressedFileSize = uint64(compressedFileInfo.Size())
 	} else {
-		return nil, 0, nil, stacktrace.Propagate(err,
+		return "", 0, nil, stacktrace.Propagate(err,
 			"Failed to compute archive size for temporary file '%s' obtained compressing path '%s'",
 			tempDir, pathToCompress)
 	}
 
 	if enforceMaxFileSizeLimit && compressedFileSize >= kurtosisDataTransferLimit {
-		return nil, 0, nil, stacktrace.NewError(
+		return "", 0, nil, stacktrace.NewError(
 			"The files you are trying to upload, which are now compressed, exceed or reach 100mb. " +
 				"Manipulation (i.e. uploads or downloads) of files larger than 100mb is currently disallowed in Kurtosis.")
 	}
 
-	return compressedFile, compressedFileSize, compressedFileContentMd5.Sum(nil), nil
+	return compressedFilePath, compressedFileSize, compressedFileContentMd5.Sum(nil), nil
 }
 
 // listFilesInPathDeterministic returns the list of file paths in the path passed as an argument.
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 482891991..ae9d64e92 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
@@ -37,6 +37,8 @@ import (
 const (
 	podWaitForAvailabilityTimeout          = 15 * time.Minute
 	podWaitForAvailabilityTimeBetweenPolls = 500 * time.Millisecond
+	podWaitForDeletionTimeout              = 5 * time.Minute
+	podWaitForDeletionTimeBetweenPolls     = 500 * time.Millisecond
 	podWaitForTerminationTimeout           = 5 * time.Minute
 	podWaitForTerminationTimeBetweenPolls  = 500 * time.Millisecond
 
@@ -1232,6 +1234,13 @@ func (manager *KubernetesManager) CreatePod(
 		logrus.Debugf("Going to start pod using the following JSON: %v", string(podDefinitionBytes))
 	}
 
+	// In case of a service update, it's possible the pod has not been fully deleted yet. It's still "scheduled for
+	// deletion", and so we wait for it to be deleted before creating it again. There's probably a way to optimize this
+	// a bit more using native k8s pod update operation
+	if err := manager.waitForPodDeletion(ctx, namespaceName, podName); err != nil {
+		return nil, stacktrace.Propagate(err, "An error occurred waiting for pod '%v' to be completely removed", podName)
+	}
+
 	createdPod, err := podClient.Create(ctx, podToCreate, globalCreateOptions)
 	if err != nil {
 		return nil, stacktrace.Propagate(err, "Expected to be able to create pod with name '%v' and labels '%+v', instead a non-nil error was returned", podName, podLabels)
@@ -1877,6 +1886,36 @@ func (manager *KubernetesManager) waitForPodAvailability(ctx context.Context, na
 	)
 }
 
+// waitForPodDeletion waits for the pod to be fully deleted if it has been marked for deletion
+func (manager *KubernetesManager) waitForPodDeletion(ctx context.Context, namespaceName string, podName string) error {
+	// Wait for the pod to start running
+	deadline := time.Now().Add(podWaitForDeletionTimeout)
+	var latestPodStatus *apiv1.PodStatus
+	for time.Now().Before(deadline) {
+		pod, err := manager.GetPod(ctx, namespaceName, podName)
+		if err != nil {
+			// If an error has been returned, it's likely the pod does not exist. Continue
+			return nil
+		}
+		if pod.DeletionTimestamp == nil {
+			return stacktrace.NewError("The pod '%s' currently exists in namespace '%s' and is not scheduled for deletion",
+				podName, namespaceName)
+		}
+		time.Sleep(podWaitForDeletionTimeBetweenPolls)
+	}
+
+	containerStatusStrs := renderContainerStatuses(latestPodStatus.ContainerStatuses, containerStatusLineBulletPoint)
+	return stacktrace.NewError(
+		"Pod '%v' wasn't deleted after %v; its latest state is '%v' and status message is: %v\n"+
+			"The pod's container states are as follows:\n%v",
+		podName,
+		podWaitForAvailabilityTimeout,
+		latestPodStatus.Phase,
+		latestPodStatus.Message,
+		strings.Join(containerStatusStrs, "\n"),
+	)
+}
+
 func (manager *KubernetesManager) waitForPodTermination(ctx context.Context, namespaceName string, podName string) error {
 	deadline := time.Now().Add(podWaitForTerminationTimeout)
 	var latestPodStatus *apiv1.PodStatus
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 216973924..1acef766b 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
@@ -708,13 +708,42 @@ func (network *DefaultServiceNetwork) RenderTemplates(templatesAndDataByDestinat
 }
 
 func (network *DefaultServiceNetwork) UploadFilesArtifact(data io.Reader, contentMd5 []byte, artifactName string) (enclave_data_directory.FilesArtifactUUID, error) {
-	filesArtifactUuid, err := network.uploadFilesArtifactUnlocked(data, contentMd5, artifactName)
+	filesArtifactStore, err := network.enclaveDataDir.GetFilesArtifactStore()
+	if err != nil {
+		return "", stacktrace.Propagate(err, "An error occurred while getting files artifact store")
+	}
+
+	filesArtifactUuid, err := filesArtifactStore.StoreFile(data, contentMd5, artifactName)
 	if err != nil {
-		return "", stacktrace.Propagate(err, "There was an error in uploading the files")
+		return "", stacktrace.Propagate(err, "An error occurred while trying to store files.")
 	}
 	return filesArtifactUuid, nil
 }
 
+func (network *DefaultServiceNetwork) GetFilesArtifactMd5(artifactName string) (enclave_data_directory.FilesArtifactUUID, []byte, bool, error) {
+	filesArtifactStore, err := network.enclaveDataDir.GetFilesArtifactStore()
+	if err != nil {
+		return "", nil, false, stacktrace.Propagate(err, "An error occurred while getting files artifact store")
+	}
+	filesArtifactUuid, _, currentlyStoredFileContentHash, found, err := filesArtifactStore.GetFile(artifactName)
+	if err != nil {
+		return "", nil, false, stacktrace.Propagate(err, "An error occurred retrieving the files artifact from the store")
+	}
+	return filesArtifactUuid, currentlyStoredFileContentHash, found, nil
+}
+
+func (network *DefaultServiceNetwork) UpdateFilesArtifact(fileArtifactUuid enclave_data_directory.FilesArtifactUUID, updatedContent io.Reader, contentMd5 []byte) error {
+	filesArtifactStore, err := network.enclaveDataDir.GetFilesArtifactStore()
+	if err != nil {
+		return stacktrace.Propagate(err, "An error occurred while getting files artifact store")
+	}
+
+	if err = filesArtifactStore.UpdateFile(fileArtifactUuid, updatedContent, contentMd5); err != nil {
+		return stacktrace.Propagate(err, "An error occurred while trying to update a files artifact.")
+	}
+	return nil
+}
+
 func (network *DefaultServiceNetwork) GetExistingAndHistoricalServiceIdentifiers() []*kurtosis_core_rpc_api_bindings.ServiceIdentifiers {
 	return network.allExistingAndHistoricalIdentifiers
 }
@@ -1024,20 +1053,34 @@ func (network *DefaultServiceNetwork) copyFilesFromServiceUnlocked(ctx context.C
 		return "", stacktrace.Propagate(err, "An error occurred getting the files artifact store")
 	}
 
+	existingFilesArtifactUuid, _, _, filesArtifactAlreadyExists, err := store.GetFile(artifactName)
+	if err != nil {
+		return "", stacktrace.Propagate(err, "An unexpected error occurred checking for file artifact '%s' existence in the store", artifactName)
+	}
+
 	pipeReader, pipeWriter := io.Pipe()
-	defer pipeReader.Close()
 	defer pipeWriter.Close()
 
 	storeFilesArtifactResultChan := make(chan storeFilesArtifactResult)
 	go func() {
 		defer pipeReader.Close()
-
+		defer close(storeFilesArtifactResultChan)
 		//And finally pass it the .tgz file to the artifact file store
 		// It's hard to compute the content hash here. For files stored from services, hash will be empty and they will
 		// be re-stored everytime regardless of their content
-		filesArtifactUuid, storeFileErr := store.StoreFile(pipeReader, []byte{}, artifactName)
+
+		var storeOrUpdateErr error
+		var filesArtifactUuid enclave_data_directory.FilesArtifactUUID
+		if filesArtifactAlreadyExists {
+			filesArtifactUuid = existingFilesArtifactUuid
+			// we're not able to check file hash here b/c we don't get it from the CompressPath helper method
+			// TODO: Maybe compute the hash ad-hoc here, but it would require us to consume the reader
+			storeOrUpdateErr = store.UpdateFile(filesArtifactUuid, pipeReader, []byte{})
+		} else {
+			filesArtifactUuid, storeOrUpdateErr = store.StoreFile(pipeReader, []byte{}, artifactName)
+		}
 		storeFilesArtifactResultChan <- storeFilesArtifactResult{
-			err:               storeFileErr,
+			err:               storeOrUpdateErr,
 			filesArtifactUuid: filesArtifactUuid,
 		}
 	}()
@@ -1103,8 +1146,24 @@ func (network *DefaultServiceNetwork) renderTemplatesUnlocked(templatesAndDataBy
 	if err != nil {
 		return "", stacktrace.Propagate(err, "An error occurred while getting files artifact store")
 	}
-	filesArtifactUuid, err := store.StoreFile(compressedFile, compressedFileMd5, artifactName)
+	existingFilesArtifactUuid, _, existingFilesArtifactMd5, found, err := store.GetFile(artifactName)
 	if err != nil {
+		return "", stacktrace.Propagate(err, "An unexpected error occurred checking for file artifact '%s' existence in the store", artifactName)
+	}
+
+	var storeOrUpdateErr error
+	var filesArtifactUuid enclave_data_directory.FilesArtifactUUID
+	if found {
+		filesArtifactUuid = existingFilesArtifactUuid
+		// If one of the md5 is empty, we can't really assume the files are equal so we fallback to updating them
+		// Otherwise we check the equality of their MD5 and if they are different we update them
+		if len(existingFilesArtifactMd5) == 0 || len(compressedFileMd5) == 0 || !bytes.Equal(existingFilesArtifactMd5, compressedFileMd5) {
+			storeOrUpdateErr = store.UpdateFile(existingFilesArtifactUuid, compressedFile, compressedFileMd5)
+		}
+	} else {
+		filesArtifactUuid, storeOrUpdateErr = store.StoreFile(compressedFile, compressedFileMd5, artifactName)
+	}
+	if storeOrUpdateErr != nil {
 		return "", stacktrace.Propagate(err, "An error occurred while storing the file '%v' in the files artifact store", compressedFile)
 	}
 	shouldDeleteFilesArtifact := true
@@ -1120,21 +1179,6 @@ func (network *DefaultServiceNetwork) renderTemplatesUnlocked(templatesAndDataBy
 	return filesArtifactUuid, nil
 }
 
-// This method is not thread safe. Only call this from a method where there is a mutex lock on the network.
-func (network *DefaultServiceNetwork) uploadFilesArtifactUnlocked(data io.Reader, contentMd5 []byte, artifactName string) (enclave_data_directory.FilesArtifactUUID, error) {
-	filesArtifactStore, err := network.enclaveDataDir.GetFilesArtifactStore()
-	if err != nil {
-		return "", stacktrace.Propagate(err, "An error occurred while getting files artifact store")
-	}
-
-	filesArtifactUuid, err := filesArtifactStore.StoreFile(data, contentMd5, artifactName)
-	if err != nil {
-		return "", stacktrace.Propagate(err, "An error occurred while trying to store files.")
-	}
-
-	return filesArtifactUuid, nil
-}
-
 // This isn't thread safe and must be called from a thread safe context
 func (network *DefaultServiceNetwork) cleanupInternalMapsUnlocked(serviceName service.ServiceName) {
 	_, found := network.registeredServiceInfo[serviceName]
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 22d3d78db..8018e2899 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.29.0. DO NOT EDIT.
+// Code generated by mockery v2.32.0. DO NOT EDIT.
 
 package service_network
 
@@ -296,6 +296,74 @@ func (_c *MockServiceNetwork_GetExistingAndHistoricalServiceIdentifiers_Call) Ru
 	return _c
 }
 
+// GetFilesArtifactMd5 provides a mock function with given fields: artifactName
+func (_m *MockServiceNetwork) GetFilesArtifactMd5(artifactName string) (enclave_data_directory.FilesArtifactUUID, []byte, bool, error) {
+	ret := _m.Called(artifactName)
+
+	var r0 enclave_data_directory.FilesArtifactUUID
+	var r1 []byte
+	var r2 bool
+	var r3 error
+	if rf, ok := ret.Get(0).(func(string) (enclave_data_directory.FilesArtifactUUID, []byte, bool, error)); ok {
+		return rf(artifactName)
+	}
+	if rf, ok := ret.Get(0).(func(string) enclave_data_directory.FilesArtifactUUID); ok {
+		r0 = rf(artifactName)
+	} else {
+		r0 = ret.Get(0).(enclave_data_directory.FilesArtifactUUID)
+	}
+
+	if rf, ok := ret.Get(1).(func(string) []byte); ok {
+		r1 = rf(artifactName)
+	} else {
+		if ret.Get(1) != nil {
+			r1 = ret.Get(1).([]byte)
+		}
+	}
+
+	if rf, ok := ret.Get(2).(func(string) bool); ok {
+		r2 = rf(artifactName)
+	} else {
+		r2 = ret.Get(2).(bool)
+	}
+
+	if rf, ok := ret.Get(3).(func(string) error); ok {
+		r3 = rf(artifactName)
+	} else {
+		r3 = ret.Error(3)
+	}
+
+	return r0, r1, r2, r3
+}
+
+// MockServiceNetwork_GetFilesArtifactMd5_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFilesArtifactMd5'
+type MockServiceNetwork_GetFilesArtifactMd5_Call struct {
+	*mock.Call
+}
+
+// GetFilesArtifactMd5 is a helper method to define mock.On call
+//   - artifactName string
+func (_e *MockServiceNetwork_Expecter) GetFilesArtifactMd5(artifactName interface{}) *MockServiceNetwork_GetFilesArtifactMd5_Call {
+	return &MockServiceNetwork_GetFilesArtifactMd5_Call{Call: _e.mock.On("GetFilesArtifactMd5", artifactName)}
+}
+
+func (_c *MockServiceNetwork_GetFilesArtifactMd5_Call) Run(run func(artifactName string)) *MockServiceNetwork_GetFilesArtifactMd5_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *MockServiceNetwork_GetFilesArtifactMd5_Call) Return(_a0 enclave_data_directory.FilesArtifactUUID, _a1 []byte, _a2 bool, _a3 error) *MockServiceNetwork_GetFilesArtifactMd5_Call {
+	_c.Call.Return(_a0, _a1, _a2, _a3)
+	return _c
+}
+
+func (_c *MockServiceNetwork_GetFilesArtifactMd5_Call) RunAndReturn(run func(string) (enclave_data_directory.FilesArtifactUUID, []byte, bool, error)) *MockServiceNetwork_GetFilesArtifactMd5_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // GetService provides a mock function with given fields: ctx, serviceIdentifier
 func (_m *MockServiceNetwork) GetService(ctx context.Context, serviceIdentifier string) (*service.Service, error) {
 	ret := _m.Called(ctx, serviceIdentifier)
@@ -999,6 +1067,50 @@ func (_c *MockServiceNetwork_StopServices_Call) RunAndReturn(run func(context.Co
 	return _c
 }
 
+// UpdateFilesArtifact provides a mock function with given fields: fileArtifactUuid, updatedContent, contentMd5
+func (_m *MockServiceNetwork) UpdateFilesArtifact(fileArtifactUuid enclave_data_directory.FilesArtifactUUID, updatedContent io.Reader, contentMd5 []byte) error {
+	ret := _m.Called(fileArtifactUuid, updatedContent, contentMd5)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(enclave_data_directory.FilesArtifactUUID, io.Reader, []byte) error); ok {
+		r0 = rf(fileArtifactUuid, updatedContent, contentMd5)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// MockServiceNetwork_UpdateFilesArtifact_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFilesArtifact'
+type MockServiceNetwork_UpdateFilesArtifact_Call struct {
+	*mock.Call
+}
+
+// UpdateFilesArtifact is a helper method to define mock.On call
+//   - fileArtifactUuid enclave_data_directory.FilesArtifactUUID
+//   - updatedContent io.Reader
+//   - contentMd5 []byte
+func (_e *MockServiceNetwork_Expecter) UpdateFilesArtifact(fileArtifactUuid interface{}, updatedContent interface{}, contentMd5 interface{}) *MockServiceNetwork_UpdateFilesArtifact_Call {
+	return &MockServiceNetwork_UpdateFilesArtifact_Call{Call: _e.mock.On("UpdateFilesArtifact", fileArtifactUuid, updatedContent, contentMd5)}
+}
+
+func (_c *MockServiceNetwork_UpdateFilesArtifact_Call) Run(run func(fileArtifactUuid enclave_data_directory.FilesArtifactUUID, updatedContent io.Reader, contentMd5 []byte)) *MockServiceNetwork_UpdateFilesArtifact_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(enclave_data_directory.FilesArtifactUUID), args[1].(io.Reader), args[2].([]byte))
+	})
+	return _c
+}
+
+func (_c *MockServiceNetwork_UpdateFilesArtifact_Call) Return(_a0 error) *MockServiceNetwork_UpdateFilesArtifact_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *MockServiceNetwork_UpdateFilesArtifact_Call) RunAndReturn(run func(enclave_data_directory.FilesArtifactUUID, io.Reader, []byte) error) *MockServiceNetwork_UpdateFilesArtifact_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // UpdateService provides a mock function with given fields: ctx, serviceName, updateServiceConfig
 func (_m *MockServiceNetwork) UpdateService(ctx context.Context, serviceName service.ServiceName, updateServiceConfig *service.ServiceConfig) (*service.Service, error) {
 	ret := _m.Called(ctx, serviceName, updateServiceConfig)
@@ -1174,13 +1286,12 @@ func (_c *MockServiceNetwork_UploadFilesArtifact_Call) RunAndReturn(run func(io.
 	return _c
 }
 
-type mockConstructorTestingTNewMockServiceNetwork interface {
+// NewMockServiceNetwork creates a new instance of MockServiceNetwork. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockServiceNetwork(t interface {
 	mock.TestingT
 	Cleanup(func())
-}
-
-// NewMockServiceNetwork creates a new instance of MockServiceNetwork. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-func NewMockServiceNetwork(t mockConstructorTestingTNewMockServiceNetwork) *MockServiceNetwork {
+}) *MockServiceNetwork {
 	mock := &MockServiceNetwork{}
 	mock.Mock.Test(t)
 
diff --git a/core/server/api_container/server/service_network/service_network.go b/core/server/api_container/server/service_network/service_network.go
index 8e63a62dc..4f528f864 100644
--- a/core/server/api_container/server/service_network/service_network.go
+++ b/core/server/api_container/server/service_network/service_network.go
@@ -105,6 +105,10 @@ type ServiceNetwork interface {
 
 	UploadFilesArtifact(data io.Reader, contentMd5 []byte, artifactName string) (enclave_data_directory.FilesArtifactUUID, error)
 
+	GetFilesArtifactMd5(artifactName string) (enclave_data_directory.FilesArtifactUUID, []byte, bool, error)
+
+	UpdateFilesArtifact(fileArtifactUuid enclave_data_directory.FilesArtifactUUID, updatedContent io.Reader, contentMd5 []byte) error
+
 	GetUniqueNameForFileArtifact() (string, error)
 
 	GetApiContainerInfo() *ApiContainerInfo
diff --git a/core/server/api_container/server/startosis_engine/enclave_structure/enclave_components.go b/core/server/api_container/server/startosis_engine/enclave_structure/enclave_components.go
index 005b29840..387a78f7a 100644
--- a/core/server/api_container/server/startosis_engine/enclave_structure/enclave_components.go
+++ b/core/server/api_container/server/startosis_engine/enclave_structure/enclave_components.go
@@ -5,12 +5,14 @@ import (
 )
 
 type EnclaveComponents struct {
-	enclaveServices map[service.ServiceName]EnclaveComponentStatus
+	enclaveServices      map[service.ServiceName]EnclaveComponentStatus
+	enclaveFilesArtifact map[string]EnclaveComponentStatus
 }
 
 func NewEnclaveComponents() *EnclaveComponents {
 	return &EnclaveComponents{
-		enclaveServices: map[service.ServiceName]EnclaveComponentStatus{},
+		enclaveServices:      map[service.ServiceName]EnclaveComponentStatus{},
+		enclaveFilesArtifact: map[string]EnclaveComponentStatus{},
 	}
 }
 
@@ -24,3 +26,14 @@ func (components *EnclaveComponents) HasServiceBeenUpdated(serviceName service.S
 	}
 	return false
 }
+
+func (components *EnclaveComponents) AddFilesArtifact(artifactName string, componentStatus EnclaveComponentStatus) {
+	components.enclaveFilesArtifact[artifactName] = componentStatus
+}
+
+func (components *EnclaveComponents) HasFilesArtifactBeenUpdated(artifactName string) bool {
+	if serviceStatus, found := components.enclaveFilesArtifact[artifactName]; found {
+		return serviceStatus == ComponentIsUpdated
+	}
+	return false
+}
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 248787909..1beb9b541 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
@@ -154,10 +154,7 @@ func (builtin *AddServiceCapabilities) Execute(ctx context.Context, _ *builtin_a
 }
 
 func (builtin *AddServiceCapabilities) TryResolveWith(instructionsAreEqual bool, other kurtosis_plan_instruction.KurtosisPlanInstructionCapabilities, enclaveComponents *enclave_structure.EnclaveComponents) enclave_structure.InstructionResolutionStatus {
-	if instructionsAreEqual {
-		enclaveComponents.AddService(builtin.serviceName, enclave_structure.ComponentWasLeftIntact)
-		return enclave_structure.InstructionIsEqual
-	}
+	// if other instruction is nil or other instruction is not an add_service instruction, status is unknown
 	if other == nil {
 		enclaveComponents.AddService(builtin.serviceName, enclave_structure.ComponentIsNew)
 		return enclave_structure.InstructionIsUnknown
@@ -167,12 +164,34 @@ func (builtin *AddServiceCapabilities) TryResolveWith(instructionsAreEqual bool,
 		enclaveComponents.AddService(builtin.serviceName, enclave_structure.ComponentIsNew)
 		return enclave_structure.InstructionIsUnknown
 	}
-	if otherAddServiceCapabilities.serviceName == builtin.serviceName {
+
+	// if service names don't match, status is unknown, instructions can't be resolved together
+	if otherAddServiceCapabilities.serviceName != builtin.serviceName {
+		enclaveComponents.AddService(builtin.serviceName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
+	}
+
+	// if service names are equal but the instructions are not equal, it means the service config has been updated.
+	// The instruction should be rerun
+	if !instructionsAreEqual {
 		enclaveComponents.AddService(builtin.serviceName, enclave_structure.ComponentIsUpdated)
 		return enclave_structure.InstructionIsUpdate
 	}
-	enclaveComponents.AddService(builtin.serviceName, enclave_structure.ComponentIsNew)
-	return enclave_structure.InstructionIsUnknown
+
+	// From here instructions are equal
+	// We check if there has been some updates to the files it's mounting. If that's the case, it should be rerun
+	filesArtifactsExpansion := builtin.serviceConfig.GetFilesArtifactsExpansion()
+	if filesArtifactsExpansion != nil {
+		for _, filesArtifactName := range filesArtifactsExpansion.ServiceDirpathsToArtifactIdentifiers {
+			if enclaveComponents.HasFilesArtifactBeenUpdated(filesArtifactName) {
+				enclaveComponents.AddService(builtin.serviceName, enclave_structure.ComponentIsUpdated)
+				return enclave_structure.InstructionIsUpdate
+			}
+		}
+	}
+
+	enclaveComponents.AddService(builtin.serviceName, enclave_structure.ComponentWasLeftIntact)
+	return enclave_structure.InstructionIsEqual
 }
 
 func validateAndConvertConfigAndReadyCondition(
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 4a7d2f85e..4887b6656 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
@@ -64,12 +64,12 @@ func validateSingleService(validatorEnvironment *startosis_validator.ValidatorEn
 		return startosis_errors.NewValidationError(invalidServiceNameErrorText(serviceName))
 	}
 
-	if validatorEnvironment.DoesServiceNameExist(serviceName) == startosis_validator.ServiceCreatedOrUpdatedDuringPackageRun {
+	if validatorEnvironment.DoesServiceNameExist(serviceName) == startosis_validator.ComponentCreatedOrUpdatedDuringPackageRun {
 		return startosis_errors.NewValidationError("There was an error validating '%s' as service '%s' was created inside this package. Adding the same service twice in the same package is not allowed", AddServiceBuiltinName, serviceName)
 	}
 	if serviceConfig.GetFilesArtifactsExpansion() != nil {
 		for _, artifactName := range serviceConfig.GetFilesArtifactsExpansion().ServiceDirpathsToArtifactIdentifiers {
-			if !validatorEnvironment.DoesArtifactNameExist(artifactName) {
+			if validatorEnvironment.DoesArtifactNameExist(artifactName) == startosis_validator.ComponentNotFound {
 				return startosis_errors.NewValidationError("There was an error validating '%s' as artifact name '%s' does not exist", AddServiceBuiltinName, artifactName)
 			}
 		}
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 7528fe1f6..812511169 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
@@ -204,6 +204,13 @@ func (builtin *AddServicesCapabilities) TryResolveWith(instructionsAreEqual bool
 		}
 		return enclave_structure.InstructionIsEqual
 	}
+	// if other instruction is nil or other instruction is not an add_services instruction, status is unknown
+	if other == nil {
+		for serviceName := range builtin.serviceConfigs {
+			enclaveComponents.AddService(serviceName, enclave_structure.ComponentIsNew)
+		}
+		return enclave_structure.InstructionIsUnknown
+	}
 	otherAddServicesCapabilities, ok := other.(*AddServicesCapabilities)
 	if !ok {
 		for serviceName := range builtin.serviceConfigs {
@@ -212,15 +219,27 @@ func (builtin *AddServicesCapabilities) TryResolveWith(instructionsAreEqual bool
 		return enclave_structure.InstructionIsUnknown
 	}
 
-	// The instruction can be re-run only if the set of added services is a subset of what was added by the instruction it's being compared to
+	// The instruction can be re-run only if the set of added services is a superset of what was added by the
+	// instruction it's being compared to, so we check that first
+	atLeastOneFilehasBeenUpdated := false
 	previouslyAddedService := map[service.ServiceName]bool{}
 	for serviceName := range otherAddServicesCapabilities.serviceConfigs {
 		previouslyAddedService[serviceName] = false
 	}
-	for serviceName := range builtin.serviceConfigs {
+	for serviceName, serviceConfig := range builtin.serviceConfigs {
 		if _, found := previouslyAddedService[serviceName]; found {
 			previouslyAddedService[serviceName] = true // toggle the boolean to true
 		}
+
+		// Check whether one file as been updated - if yes the instruction will need to be rerun
+		filesArtifactsExpansion := serviceConfig.GetFilesArtifactsExpansion()
+		if filesArtifactsExpansion != nil {
+			for _, filesArtifactName := range filesArtifactsExpansion.ServiceDirpathsToArtifactIdentifiers {
+				if enclaveComponents.HasFilesArtifactBeenUpdated(filesArtifactName) {
+					atLeastOneFilehasBeenUpdated = true
+				}
+			}
+		}
 	}
 	for _, servicePresentInCurrentInstruction := range previouslyAddedService {
 		if !servicePresentInCurrentInstruction {
@@ -232,14 +251,22 @@ func (builtin *AddServicesCapabilities) TryResolveWith(instructionsAreEqual bool
 		}
 	}
 
-	for serviceName := range builtin.serviceConfigs {
-		if _, found := previouslyAddedService[serviceName]; found {
-			enclaveComponents.AddService(serviceName, enclave_structure.ComponentIsUpdated)
-		} else {
-			enclaveComponents.AddService(serviceName, enclave_structure.ComponentIsNew)
+	if !instructionsAreEqual || atLeastOneFilehasBeenUpdated {
+		for serviceName := range builtin.serviceConfigs {
+			if _, found := previouslyAddedService[serviceName]; found {
+				enclaveComponents.AddService(serviceName, enclave_structure.ComponentIsUpdated)
+			} else {
+				enclaveComponents.AddService(serviceName, enclave_structure.ComponentIsNew)
+			}
 		}
+		return enclave_structure.InstructionIsUpdate
 	}
-	return enclave_structure.InstructionIsUpdate
+
+	// the instruction is equal AND no file have been updated. No need to rerun the instruction
+	for serviceName := range builtin.serviceConfigs {
+		enclaveComponents.AddService(serviceName, enclave_structure.ComponentWasLeftIntact)
+	}
+	return enclave_structure.InstructionIsEqual
 }
 
 func (builtin *AddServicesCapabilities) removeAllStartedServices(
@@ -247,10 +274,10 @@ func (builtin *AddServicesCapabilities) removeAllStartedServices(
 	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())
+	for startedServiceName, startedService := range startedServices {
+		serviceIdentifier := string(startedService.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)
+			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", startedServiceName, err)
 		}
 	}
 }
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/remove_service/remove_service.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/remove_service/remove_service.go
index 2431b9e9d..a13d8b01e 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/remove_service/remove_service.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/remove_service/remove_service.go
@@ -70,7 +70,7 @@ func (builtin *RemoveServiceCapabilities) Interpret(_ string, arguments *builtin
 }
 
 func (builtin *RemoveServiceCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet, validatorEnvironment *startosis_validator.ValidatorEnvironment) *startosis_errors.ValidationError {
-	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ServiceNotFound {
+	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ComponentNotFound {
 		return startosis_errors.NewValidationError("There was an error validating '%v' as service name '%v' doesn't exist", RemoveServiceBuiltinName, builtin.serviceName)
 	}
 	validatorEnvironment.RemoveServiceName(builtin.serviceName)
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/render_templates/render_templates.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/render_templates/render_templates.go
index 5d23d8c86..62edc3426 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/render_templates/render_templates.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/render_templates/render_templates.go
@@ -108,7 +108,7 @@ func (builtin *RenderTemplatesCapabilities) Interpret(_ string, arguments *built
 }
 
 func (builtin *RenderTemplatesCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet, validatorEnvironment *startosis_validator.ValidatorEnvironment) *startosis_errors.ValidationError {
-	if validatorEnvironment.DoesArtifactNameExist(builtin.artifactName) {
+	if validatorEnvironment.DoesArtifactNameExist(builtin.artifactName) == startosis_validator.ComponentCreatedOrUpdatedDuringPackageRun {
 		return startosis_errors.NewValidationError("There was an error validating '%v' as artifact name '%v' already exists", RenderTemplatesBuiltinName, builtin.artifactName)
 	}
 	validatorEnvironment.AddArtifactName(builtin.artifactName)
@@ -130,11 +130,31 @@ func (builtin *RenderTemplatesCapabilities) Execute(_ context.Context, _ *builti
 	return instructionResult, nil
 }
 
-func (builtin *RenderTemplatesCapabilities) TryResolveWith(instructionsAreEqual bool, _ kurtosis_plan_instruction.KurtosisPlanInstructionCapabilities, _ *enclave_structure.EnclaveComponents) enclave_structure.InstructionResolutionStatus {
-	if instructionsAreEqual {
-		return enclave_structure.InstructionIsEqual
+func (builtin *RenderTemplatesCapabilities) TryResolveWith(instructionsAreEqual bool, other kurtosis_plan_instruction.KurtosisPlanInstructionCapabilities, enclaveComponents *enclave_structure.EnclaveComponents) enclave_structure.InstructionResolutionStatus {
+	// if other instruction is nil or other instruction is not an add_service instruction, status is unknown
+	if other == nil {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
 	}
-	return enclave_structure.InstructionIsUnknown
+	otherRenderTemplateCapabilities, ok := other.(*RenderTemplatesCapabilities)
+	if !ok {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
+	}
+
+	// if artifact names don't match, status is unknown, instructions can't be resolved together
+	if otherRenderTemplateCapabilities.artifactName != builtin.artifactName {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
+	}
+
+	// just check for instruction equality. If it's not equal it needs to be rerun
+	if !instructionsAreEqual {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsUpdated)
+		return enclave_structure.InstructionIsUpdate
+	}
+	enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentWasLeftIntact)
+	return enclave_structure.InstructionIsEqual
 }
 
 func parseTemplatesAndData(templatesAndData *starlark.Dict) (map[string]*render_templates.TemplateData, *startosis_errors.InterpretationError) {
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/request/request.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/request/request.go
index 999a40639..3d0745f21 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/request/request.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/request/request.go
@@ -162,7 +162,7 @@ func (builtin *RequestCapabilities) Interpret(_ string, arguments *builtin_argum
 }
 
 func (builtin *RequestCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet, validatorEnvironment *startosis_validator.ValidatorEnvironment) *startosis_errors.ValidationError {
-	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ServiceNotFound {
+	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ComponentNotFound {
 		return startosis_errors.NewValidationError("Tried creating a request for service '%s' which doesn't exist", builtin.serviceName)
 	}
 	if validationErr := recipe.ValidateHttpRequestRecipe(builtin.httpRequestRecipe, builtin.serviceName, validatorEnvironment); validationErr != nil {
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/start_service/start_service.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/start_service/start_service.go
index e385e9cc2..cfb360e7a 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/start_service/start_service.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/start_service/start_service.go
@@ -70,7 +70,7 @@ func (builtin *StartServiceCapabilities) Interpret(_ string, arguments *builtin_
 }
 
 func (builtin *StartServiceCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet, validatorEnvironment *startosis_validator.ValidatorEnvironment) *startosis_errors.ValidationError {
-	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ServiceNotFound {
+	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ComponentNotFound {
 		return startosis_errors.NewValidationError("There was an error validating '%v' as service name '%v' doesn't exist", StartServiceBuiltinName, builtin.serviceName)
 	}
 	return nil
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/stop_service/stop_service.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/stop_service/stop_service.go
index fda2b3db7..4a7cf8b72 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/stop_service/stop_service.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/stop_service/stop_service.go
@@ -70,7 +70,7 @@ func (builtin *StopServiceCapabilities) Interpret(_ string, arguments *builtin_a
 }
 
 func (builtin *StopServiceCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet, validatorEnvironment *startosis_validator.ValidatorEnvironment) *startosis_errors.ValidationError {
-	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ServiceNotFound {
+	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ComponentNotFound {
 		return startosis_errors.NewValidationError("There was an error validating '%v' as service name '%v' doesn't exist", StopServiceBuiltinName, builtin.serviceName)
 	}
 	return nil
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/store_service_files/store_service_files.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/store_service_files/store_service_files.go
index 46864acec..6ff5b06ed 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/store_service_files/store_service_files.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/store_service_files/store_service_files.go
@@ -107,10 +107,10 @@ func (builtin *StoreServiceFilesCapabilities) Interpret(_ string, arguments *bui
 }
 
 func (builtin *StoreServiceFilesCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet, validatorEnvironment *startosis_validator.ValidatorEnvironment) *startosis_errors.ValidationError {
-	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ServiceNotFound {
+	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ComponentNotFound {
 		return startosis_errors.NewValidationError("There was an error validating '%v' with service name '%v' that does not exist", StoreServiceFilesBuiltinName, builtin.serviceName)
 	}
-	if validatorEnvironment.DoesArtifactNameExist(builtin.artifactName) {
+	if validatorEnvironment.DoesArtifactNameExist(builtin.artifactName) == startosis_validator.ComponentCreatedOrUpdatedDuringPackageRun {
 		return startosis_errors.NewValidationError("There was an error validating '%v' as artifact name '%v' already exists", StoreServiceFilesBuiltinName, builtin.artifactName)
 	}
 	validatorEnvironment.AddArtifactName(builtin.artifactName)
@@ -126,9 +126,37 @@ func (builtin *StoreServiceFilesCapabilities) Execute(ctx context.Context, _ *bu
 	return instructionResult, nil
 }
 
-func (builtin *StoreServiceFilesCapabilities) TryResolveWith(instructionsAreEqual bool, _ kurtosis_plan_instruction.KurtosisPlanInstructionCapabilities, _ *enclave_structure.EnclaveComponents) enclave_structure.InstructionResolutionStatus {
-	if instructionsAreEqual {
-		return enclave_structure.InstructionIsEqual
+func (builtin *StoreServiceFilesCapabilities) TryResolveWith(instructionsAreEqual bool, other kurtosis_plan_instruction.KurtosisPlanInstructionCapabilities, enclaveComponents *enclave_structure.EnclaveComponents) enclave_structure.InstructionResolutionStatus {
+	// if other instruction is nil or other instruction is not an add_service instruction, status is unknown
+	if other == nil {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
 	}
-	return enclave_structure.InstructionIsUnknown
+	otherStoreServiceFilesCapabilities, ok := other.(*StoreServiceFilesCapabilities)
+	if !ok {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
+	}
+
+	// if artifact names don't match, status is unknown, instructions can't be resolved together
+	if otherStoreServiceFilesCapabilities.artifactName != builtin.artifactName {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
+	}
+
+	// If artifact names are the same but instructions are not equal, it needs to be re-run anyway
+	if !instructionsAreEqual {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsUpdated)
+		return enclave_structure.InstructionIsUpdate
+	}
+
+	// From here the instructions are equal
+	// If the service has been updated, the instruction needs to be re-run since the file content might have changed
+	if enclaveComponents.HasServiceBeenUpdated(builtin.serviceName) {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsUpdated)
+		return enclave_structure.InstructionIsUpdate
+	}
+
+	enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentWasLeftIntact)
+	return enclave_structure.InstructionIsEqual
 }
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/tasks/tasks_shared.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/tasks/tasks_shared.go
index 9a2f49219..b4ee1d722 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/tasks/tasks_shared.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/tasks/tasks_shared.go
@@ -129,7 +129,7 @@ func validateTasksCommon(validatorEnvironment *startosis_validator.ValidatorEnvi
 	}
 
 	for _, artifactName := range serviceDirpathsToArtifactIdentifiers {
-		if !validatorEnvironment.DoesArtifactNameExist(artifactName) {
+		if validatorEnvironment.DoesArtifactNameExist(artifactName) == startosis_validator.ComponentNotFound {
 			return startosis_errors.NewValidationError("There was an error validating '%s' as artifact name '%s' does not exist", RunPythonBuiltinName, artifactName)
 		}
 	}
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_instruction/upload_files/upload_files.go b/core/server/api_container/server/startosis_engine/kurtosis_instruction/upload_files/upload_files.go
index cb4fcca55..a5d51a93a 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_instruction/upload_files/upload_files.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_instruction/upload_files/upload_files.go
@@ -1,6 +1,7 @@
 package upload_files
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"github.com/kurtosis-tech/kurtosis/api/golang/core/lib/shared_utils"
@@ -14,6 +15,7 @@ import (
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/startosis_validator"
 	"github.com/kurtosis-tech/stacktrace"
 	"go.starlark.net/starlark"
+	"os"
 )
 
 const (
@@ -24,6 +26,7 @@ const (
 	ArtifactNameArgName = "name"
 
 	enforceMaxFileSizeLimit = false
+	readOnlyFilePerm        = 0400
 )
 
 func NewUploadFiles(serviceNetwork service_network.ServiceNetwork, packageContentProvider startosis_packages.PackageContentProvider) *kurtosis_plan_instruction.KurtosisPlanInstruction {
@@ -52,9 +55,10 @@ func NewUploadFiles(serviceNetwork service_network.ServiceNetwork, packageConten
 				serviceNetwork:         serviceNetwork,
 				packageContentProvider: packageContentProvider,
 
-				src:          "", // populated at interpretation time
-				artifactName: "", // populated at interpretation time
-				pathOnDisk:   "", // populated at interpretation time
+				src:               "",  // populated at interpretation time
+				artifactName:      "",  // populated at interpretation time
+				archivePathOnDisk: "",  // populated at interpretation time
+				filesArtifactMd5:  nil, // populated at interpretation time
 			}
 		},
 
@@ -69,9 +73,10 @@ type UploadFilesCapabilities struct {
 	serviceNetwork         service_network.ServiceNetwork
 	packageContentProvider startosis_packages.PackageContentProvider
 
-	src          string
-	artifactName string
-	pathOnDisk   string
+	src               string
+	artifactName      string
+	archivePathOnDisk string
+	filesArtifactMd5  []byte
 }
 
 func (builtin *UploadFilesCapabilities) Interpret(locatorOfModuleInWhichThisBuiltInIsBeingCalled string, arguments *builtin_argument.ArgumentValuesSet) (starlark.Value, *startosis_errors.InterpretationError) {
@@ -104,13 +109,19 @@ func (builtin *UploadFilesCapabilities) Interpret(locatorOfModuleInWhichThisBuil
 		return nil, interpretationErr
 	}
 
+	compressedDataPath, _, compressedDataMd5, err := shared_utils.CompressPathToFile(pathOnDisk, enforceMaxFileSizeLimit)
+	if err != nil {
+		return nil, startosis_errors.WrapWithInterpretationError(err, "An error occurred while compressing the files at '%s'", pathOnDisk)
+	}
+
 	builtin.src = src.GoString()
-	builtin.pathOnDisk = pathOnDisk
+	builtin.archivePathOnDisk = compressedDataPath
+	builtin.filesArtifactMd5 = compressedDataMd5
 	return starlark.String(builtin.artifactName), nil
 }
 
 func (builtin *UploadFilesCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet, validatorEnvironment *startosis_validator.ValidatorEnvironment) *startosis_errors.ValidationError {
-	if validatorEnvironment.DoesArtifactNameExist(builtin.artifactName) {
+	if validatorEnvironment.DoesArtifactNameExist(builtin.artifactName) == startosis_validator.ComponentCreatedOrUpdatedDuringPackageRun {
 		return startosis_errors.NewValidationError("There was an error validating '%v' as artifact name '%v' already exists", UploadFilesBuiltinName, builtin.artifactName)
 	}
 	validatorEnvironment.AddArtifactName(builtin.artifactName)
@@ -118,22 +129,67 @@ func (builtin *UploadFilesCapabilities) Validate(_ *builtin_argument.ArgumentVal
 }
 
 func (builtin *UploadFilesCapabilities) Execute(_ context.Context, _ *builtin_argument.ArgumentValuesSet) (string, error) {
-	compressedData, _, compressedDataMd5, err := shared_utils.CompressPath(builtin.pathOnDisk, enforceMaxFileSizeLimit)
+	filesArtifactContentReader, err := os.OpenFile(builtin.archivePathOnDisk, os.O_RDONLY, readOnlyFilePerm)
 	if err != nil {
-		return "", stacktrace.Propagate(err, "An error occurred while compressing the files '%v'", builtin.pathOnDisk)
+		return "", stacktrace.Propagate(err, "An error occurred opening the files artifact archive at '%s'", builtin.archivePathOnDisk)
 	}
-	defer compressedData.Close()
-	filesArtifactUuid, err := builtin.serviceNetwork.UploadFilesArtifact(compressedData, compressedDataMd5, builtin.artifactName)
+	defer filesArtifactContentReader.Close()
+
+	currentlyStoredFileArtifactUuid, currentlyStoredFileContentMd5, found, err := builtin.serviceNetwork.GetFilesArtifactMd5(builtin.artifactName)
 	if err != nil {
-		return "", stacktrace.Propagate(err, "An error occurred while uploading the compressed contents\n'%v'", compressedData)
+		return "", stacktrace.Propagate(err, "An error occurred checking if the files artifact '%s' is already stored in this enclave", builtin.artifactName)
+	}
+	var instructionResult string
+	if found && (len(builtin.filesArtifactMd5) > 0 && bytes.Equal(currentlyStoredFileContentMd5, builtin.filesArtifactMd5)) {
+		instructionResult = fmt.Sprintf("Files with artifact name '%s' resolved with artifact UUID '%s' as content were matching", builtin.artifactName, currentlyStoredFileArtifactUuid)
+	} else if found {
+		err = builtin.serviceNetwork.UpdateFilesArtifact(currentlyStoredFileArtifactUuid, filesArtifactContentReader, builtin.filesArtifactMd5)
+		if err != nil {
+			return "", stacktrace.Propagate(err, "An error occurred while updating the compressed contents with md5 '%s' to artifact '%s' (UUID: '%s')",
+				builtin.filesArtifactMd5, builtin.artifactName, currentlyStoredFileArtifactUuid)
+		}
+		instructionResult = fmt.Sprintf("Files with artifact name '%s' with artifact UUID '%s' updated", builtin.artifactName, currentlyStoredFileArtifactUuid)
+	} else {
+		filesArtifactUuid, err := builtin.serviceNetwork.UploadFilesArtifact(filesArtifactContentReader, builtin.filesArtifactMd5, builtin.artifactName)
+		if err != nil {
+			return "", stacktrace.Propagate(err, "An error occurred while uploading the compressed contents with md5 '%s' to create new artifact with name '%s'",
+				builtin.filesArtifactMd5, builtin.artifactName)
+		}
+		instructionResult = fmt.Sprintf("Files with artifact name '%s' uploaded with artifact UUID '%s'", builtin.artifactName, filesArtifactUuid)
 	}
-	instructionResult := fmt.Sprintf("Files with artifact name '%s' uploaded with artifact UUID '%s'", builtin.artifactName, filesArtifactUuid)
 	return instructionResult, nil
 }
 
-func (builtin *UploadFilesCapabilities) TryResolveWith(instructionsAreEqual bool, _ kurtosis_plan_instruction.KurtosisPlanInstructionCapabilities, _ *enclave_structure.EnclaveComponents) enclave_structure.InstructionResolutionStatus {
-	if instructionsAreEqual {
-		return enclave_structure.InstructionIsEqual
+func (builtin *UploadFilesCapabilities) TryResolveWith(instructionsAreEqual bool, other kurtosis_plan_instruction.KurtosisPlanInstructionCapabilities, enclaveComponents *enclave_structure.EnclaveComponents) enclave_structure.InstructionResolutionStatus {
+	// if other instruction is nil or other instruction is not an add_service instruction, status is unknown
+	if other == nil {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
+	}
+	otherUploadFilesCapabilities, ok := other.(*UploadFilesCapabilities)
+	if !ok {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
+	}
+
+	// if artifact names don't match, status is unknown, instructions can't be resolved together
+	if otherUploadFilesCapabilities.artifactName != builtin.artifactName {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsNew)
+		return enclave_structure.InstructionIsUnknown
+	}
+
+	// If the artifact names are equal but the instructions are not equal, it's an update
+	if !instructionsAreEqual {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsUpdated)
+		return enclave_structure.InstructionIsUpdate
+	}
+
+	// From here the instructions are equal
+	// If the hash of the files don't match, the instruction needs to be re-run
+	if !bytes.Equal(otherUploadFilesCapabilities.filesArtifactMd5, builtin.filesArtifactMd5) {
+		enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentIsUpdated)
+		return enclave_structure.InstructionIsUpdate
 	}
-	return enclave_structure.InstructionIsUnknown
+	enclaveComponents.AddFilesArtifact(builtin.artifactName, enclave_structure.ComponentWasLeftIntact)
+	return enclave_structure.InstructionIsEqual
 }
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 9b8624273..946f4a8c0 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
@@ -222,7 +222,7 @@ func (builtin *WaitCapabilities) Interpret(_ string, arguments *builtin_argument
 }
 
 func (builtin *WaitCapabilities) Validate(_ *builtin_argument.ArgumentValuesSet, validatorEnvironment *startosis_validator.ValidatorEnvironment) *startosis_errors.ValidationError {
-	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ServiceNotFound {
+	if validatorEnvironment.DoesServiceNameExist(builtin.serviceName) == startosis_validator.ComponentNotFound {
 		return startosis_errors.NewValidationError("Tried creating a wait for service '%s' which doesn't exist", builtin.serviceName)
 	}
 
diff --git a/core/server/api_container/server/startosis_engine/startosis_validator/component_existence.go b/core/server/api_container/server/startosis_engine/startosis_validator/component_existence.go
new file mode 100644
index 000000000..5e896b875
--- /dev/null
+++ b/core/server/api_container/server/startosis_engine/startosis_validator/component_existence.go
@@ -0,0 +1,15 @@
+package startosis_validator
+
+//go:generate go run github.com/dmarkham/enumer -trimprefix "ComponentExistence" -type=ComponentExistence
+type ComponentExistence uint8
+
+const (
+	// ComponentNotFound - Component does not exist in the enclave
+	ComponentNotFound ComponentExistence = iota
+
+	// ComponentExistedBeforePackageRun - The component existed in the enclave at the beginning of the package run.
+	ComponentExistedBeforePackageRun
+
+	// ComponentCreatedOrUpdatedDuringPackageRun - The component was created or updated during this run.
+	ComponentCreatedOrUpdatedDuringPackageRun
+)
diff --git a/core/server/api_container/server/startosis_engine/startosis_validator/componentexistence_enumer.go b/core/server/api_container/server/startosis_engine/startosis_validator/componentexistence_enumer.go
new file mode 100644
index 000000000..d218dcc85
--- /dev/null
+++ b/core/server/api_container/server/startosis_engine/startosis_validator/componentexistence_enumer.go
@@ -0,0 +1,82 @@
+// Code generated by "enumer -trimprefix ComponentExistence -type=ComponentExistence"; DO NOT EDIT.
+
+package startosis_validator
+
+import (
+	"fmt"
+	"strings"
+)
+
+const _ComponentExistenceName = "ComponentNotFoundComponentExistedBeforePackageRunComponentCreatedOrUpdatedDuringPackageRun"
+
+var _ComponentExistenceIndex = [...]uint8{0, 17, 49, 90}
+
+const _ComponentExistenceLowerName = "componentnotfoundcomponentexistedbeforepackageruncomponentcreatedorupdatedduringpackagerun"
+
+func (i ComponentExistence) String() string {
+	if i >= ComponentExistence(len(_ComponentExistenceIndex)-1) {
+		return fmt.Sprintf("ComponentExistence(%d)", i)
+	}
+	return _ComponentExistenceName[_ComponentExistenceIndex[i]:_ComponentExistenceIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _ComponentExistenceNoOp() {
+	var x [1]struct{}
+	_ = x[ComponentNotFound-(0)]
+	_ = x[ComponentExistedBeforePackageRun-(1)]
+	_ = x[ComponentCreatedOrUpdatedDuringPackageRun-(2)]
+}
+
+var _ComponentExistenceValues = []ComponentExistence{ComponentNotFound, ComponentExistedBeforePackageRun, ComponentCreatedOrUpdatedDuringPackageRun}
+
+var _ComponentExistenceNameToValueMap = map[string]ComponentExistence{
+	_ComponentExistenceName[0:17]:       ComponentNotFound,
+	_ComponentExistenceLowerName[0:17]:  ComponentNotFound,
+	_ComponentExistenceName[17:49]:      ComponentExistedBeforePackageRun,
+	_ComponentExistenceLowerName[17:49]: ComponentExistedBeforePackageRun,
+	_ComponentExistenceName[49:90]:      ComponentCreatedOrUpdatedDuringPackageRun,
+	_ComponentExistenceLowerName[49:90]: ComponentCreatedOrUpdatedDuringPackageRun,
+}
+
+var _ComponentExistenceNames = []string{
+	_ComponentExistenceName[0:17],
+	_ComponentExistenceName[17:49],
+	_ComponentExistenceName[49:90],
+}
+
+// ComponentExistenceString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func ComponentExistenceString(s string) (ComponentExistence, error) {
+	if val, ok := _ComponentExistenceNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _ComponentExistenceNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+	return 0, fmt.Errorf("%s does not belong to ComponentExistence values", s)
+}
+
+// ComponentExistenceValues returns all values of the enum
+func ComponentExistenceValues() []ComponentExistence {
+	return _ComponentExistenceValues
+}
+
+// ComponentExistenceStrings returns a slice of all String values of the enum
+func ComponentExistenceStrings() []string {
+	strs := make([]string, len(_ComponentExistenceNames))
+	copy(strs, _ComponentExistenceNames)
+	return strs
+}
+
+// IsAComponentExistence returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i ComponentExistence) IsAComponentExistence() bool {
+	for _, v := range _ComponentExistenceValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
diff --git a/core/server/api_container/server/startosis_engine/startosis_validator/service_existence.go b/core/server/api_container/server/startosis_engine/startosis_validator/service_existence.go
deleted file mode 100644
index ab256a715..000000000
--- a/core/server/api_container/server/startosis_engine/startosis_validator/service_existence.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package startosis_validator
-
-//go:generate go run github.com/dmarkham/enumer -trimprefix "ServiceExistence" -type=ServiceExistence
-type ServiceExistence uint8
-
-const (
-	// ServiceNotFound - Service does not exist in the enclave
-	ServiceNotFound ServiceExistence = iota
-
-	// ServiceExistedBeforePackageRun - The service existed in the enclave at the beginning of the package run.
-	ServiceExistedBeforePackageRun
-
-	// ServiceCreatedOrUpdatedDUringPackageRun - The service was created or updated during this run.
-	ServiceCreatedOrUpdatedDuringPackageRun
-)
diff --git a/core/server/api_container/server/startosis_engine/startosis_validator/serviceexistence_enumer.go b/core/server/api_container/server/startosis_engine/startosis_validator/serviceexistence_enumer.go
deleted file mode 100644
index 4677261f3..000000000
--- a/core/server/api_container/server/startosis_engine/startosis_validator/serviceexistence_enumer.go
+++ /dev/null
@@ -1,82 +0,0 @@
-// Code generated by "enumer -trimprefix ServiceExistence -type=ServiceExistence"; DO NOT EDIT.
-
-package startosis_validator
-
-import (
-	"fmt"
-	"strings"
-)
-
-const _ServiceExistenceName = "ServiceNotFoundServiceExistedBeforePackageRunServiceCreatedOrUpdatedDuringPackageRun"
-
-var _ServiceExistenceIndex = [...]uint8{0, 15, 45, 84}
-
-const _ServiceExistenceLowerName = "servicenotfoundserviceexistedbeforepackagerunservicecreatedorupdatedduringpackagerun"
-
-func (i ServiceExistence) String() string {
-	if i >= ServiceExistence(len(_ServiceExistenceIndex)-1) {
-		return fmt.Sprintf("ServiceExistence(%d)", i)
-	}
-	return _ServiceExistenceName[_ServiceExistenceIndex[i]:_ServiceExistenceIndex[i+1]]
-}
-
-// An "invalid array index" compiler error signifies that the constant values have changed.
-// Re-run the stringer command to generate them again.
-func _ServiceExistenceNoOp() {
-	var x [1]struct{}
-	_ = x[ServiceNotFound-(0)]
-	_ = x[ServiceExistedBeforePackageRun-(1)]
-	_ = x[ServiceCreatedOrUpdatedDuringPackageRun-(2)]
-}
-
-var _ServiceExistenceValues = []ServiceExistence{ServiceNotFound, ServiceExistedBeforePackageRun, ServiceCreatedOrUpdatedDuringPackageRun}
-
-var _ServiceExistenceNameToValueMap = map[string]ServiceExistence{
-	_ServiceExistenceName[0:15]:       ServiceNotFound,
-	_ServiceExistenceLowerName[0:15]:  ServiceNotFound,
-	_ServiceExistenceName[15:45]:      ServiceExistedBeforePackageRun,
-	_ServiceExistenceLowerName[15:45]: ServiceExistedBeforePackageRun,
-	_ServiceExistenceName[45:84]:      ServiceCreatedOrUpdatedDuringPackageRun,
-	_ServiceExistenceLowerName[45:84]: ServiceCreatedOrUpdatedDuringPackageRun,
-}
-
-var _ServiceExistenceNames = []string{
-	_ServiceExistenceName[0:15],
-	_ServiceExistenceName[15:45],
-	_ServiceExistenceName[45:84],
-}
-
-// ServiceExistenceString retrieves an enum value from the enum constants string name.
-// Throws an error if the param is not part of the enum.
-func ServiceExistenceString(s string) (ServiceExistence, error) {
-	if val, ok := _ServiceExistenceNameToValueMap[s]; ok {
-		return val, nil
-	}
-
-	if val, ok := _ServiceExistenceNameToValueMap[strings.ToLower(s)]; ok {
-		return val, nil
-	}
-	return 0, fmt.Errorf("%s does not belong to ServiceExistence values", s)
-}
-
-// ServiceExistenceValues returns all values of the enum
-func ServiceExistenceValues() []ServiceExistence {
-	return _ServiceExistenceValues
-}
-
-// ServiceExistenceStrings returns a slice of all String values of the enum
-func ServiceExistenceStrings() []string {
-	strs := make([]string, len(_ServiceExistenceNames))
-	copy(strs, _ServiceExistenceNames)
-	return strs
-}
-
-// IsAServiceExistence returns "true" if the value is listed in the enum definition. "false" otherwise
-func (i ServiceExistence) IsAServiceExistence() bool {
-	for _, v := range _ServiceExistenceValues {
-		if i == v {
-			return true
-		}
-	}
-	return false
-}
diff --git a/core/server/api_container/server/startosis_engine/startosis_validator/validator_environment.go b/core/server/api_container/server/startosis_engine/startosis_validator/validator_environment.go
index a6daaa249..e9f3d8d12 100644
--- a/core/server/api_container/server/startosis_engine/startosis_validator/validator_environment.go
+++ b/core/server/api_container/server/startosis_engine/startosis_validator/validator_environment.go
@@ -10,8 +10,8 @@ import (
 // ValidatorEnvironment fields are not exported so that only validators can access its fields
 type ValidatorEnvironment struct {
 	requiredDockerImages          map[string]bool
-	serviceNames                  map[service.ServiceName]ServiceExistence
-	artifactNames                 map[string]bool
+	serviceNames                  map[service.ServiceName]ComponentExistence
+	artifactNames                 map[string]ComponentExistence
 	serviceNameToPrivatePortIDs   map[service.ServiceName][]string
 	availableCpuInMilliCores      compute_resources.CpuMilliCores
 	availableMemoryInMegaBytes    compute_resources.MemoryInMegaBytes
@@ -21,14 +21,18 @@ type ValidatorEnvironment struct {
 }
 
 func NewValidatorEnvironment(serviceNames map[service.ServiceName]bool, artifactNames map[string]bool, serviceNameToPrivatePortIds map[service.ServiceName][]string, availableCpuInMilliCores compute_resources.CpuMilliCores, availableMemoryInMegaBytes compute_resources.MemoryInMegaBytes, isResourceInformationComplete bool) *ValidatorEnvironment {
-	serviceNamesWithServiceExistence := map[service.ServiceName]ServiceExistence{}
+	serviceNamesWithComponentExistence := map[service.ServiceName]ComponentExistence{}
 	for serviceName := range serviceNames {
-		serviceNamesWithServiceExistence[serviceName] = ServiceExistedBeforePackageRun
+		serviceNamesWithComponentExistence[serviceName] = ComponentExistedBeforePackageRun
+	}
+	artifactNamesWithComponentExistence := map[string]ComponentExistence{}
+	for artifactName := range artifactNames {
+		artifactNamesWithComponentExistence[artifactName] = ComponentExistedBeforePackageRun
 	}
 	return &ValidatorEnvironment{
 		requiredDockerImages:          map[string]bool{},
-		serviceNames:                  serviceNamesWithServiceExistence,
-		artifactNames:                 artifactNames,
+		serviceNames:                  serviceNamesWithComponentExistence,
+		artifactNames:                 artifactNamesWithComponentExistence,
 		serviceNameToPrivatePortIDs:   serviceNameToPrivatePortIds,
 		availableCpuInMilliCores:      availableCpuInMilliCores,
 		availableMemoryInMegaBytes:    availableMemoryInMegaBytes,
@@ -47,17 +51,17 @@ func (environment *ValidatorEnvironment) GetNumberOfContainerImages() uint32 {
 }
 
 func (environment *ValidatorEnvironment) AddServiceName(serviceName service.ServiceName) {
-	environment.serviceNames[serviceName] = ServiceCreatedOrUpdatedDuringPackageRun
+	environment.serviceNames[serviceName] = ComponentCreatedOrUpdatedDuringPackageRun
 }
 
 func (environment *ValidatorEnvironment) RemoveServiceName(serviceName service.ServiceName) {
 	delete(environment.serviceNames, serviceName)
 }
 
-func (environment *ValidatorEnvironment) DoesServiceNameExist(serviceName service.ServiceName) ServiceExistence {
+func (environment *ValidatorEnvironment) DoesServiceNameExist(serviceName service.ServiceName) ComponentExistence {
 	serviceExistence, found := environment.serviceNames[serviceName]
 	if !found {
-		return ServiceNotFound
+		return ComponentNotFound
 	}
 	return serviceExistence
 }
@@ -84,16 +88,19 @@ func (environment *ValidatorEnvironment) RemoveServiceFromPrivatePortIDMapping(s
 }
 
 func (environment *ValidatorEnvironment) AddArtifactName(artifactName string) {
-	environment.artifactNames[artifactName] = true
+	environment.artifactNames[artifactName] = ComponentCreatedOrUpdatedDuringPackageRun
 }
 
 func (environment *ValidatorEnvironment) RemoveArtifactName(artifactName string) {
 	delete(environment.artifactNames, artifactName)
 }
 
-func (environment *ValidatorEnvironment) DoesArtifactNameExist(artifactName string) bool {
-	_, ok := environment.artifactNames[artifactName]
-	return ok
+func (environment *ValidatorEnvironment) DoesArtifactNameExist(artifactName string) ComponentExistence {
+	filesArtifactExistence, found := environment.artifactNames[artifactName]
+	if !found {
+		return ComponentNotFound
+	}
+	return filesArtifactExistence
 }
 
 func (environment *ValidatorEnvironment) FreeMemory(serviceName service.ServiceName) {
diff --git a/core/server/commons/enclave_data_directory/files_artifact_store.go b/core/server/commons/enclave_data_directory/files_artifact_store.go
index eb3f255c7..4db2525eb 100644
--- a/core/server/commons/enclave_data_directory/files_artifact_store.go
+++ b/core/server/commons/enclave_data_directory/files_artifact_store.go
@@ -88,6 +88,20 @@ func (store FilesArtifactStore) StoreFile(reader io.Reader, contentMd5 []byte, a
 	return filesArtifactUuid, nil
 }
 
+func (store FilesArtifactStore) UpdateFile(filesArtifactUuid FilesArtifactUUID, reader io.Reader, contentMd5 []byte) error {
+	store.mutex.Lock()
+	defer store.mutex.Unlock()
+
+	artifactName, err := store.removeFileUnlocked(filesArtifactUuid)
+	if err != nil {
+		return stacktrace.Propagate(err, "Error removing the files artifact '%s' prior to persisting its new content", filesArtifactUuid)
+	}
+	if err = store.storeFilesToArtifactUuidUnlocked(artifactName, filesArtifactUuid, reader, contentMd5); err != nil {
+		return stacktrace.Propagate(err, "Error persisting updated content for files artifact '%s'", filesArtifactUuid)
+	}
+	return nil
+}
+
 // GetFile Get the file by uuid, then by shortened uuid and finally by name
 func (store FilesArtifactStore) GetFile(artifactIdentifier string) (FilesArtifactUUID, *EnclaveDataDirFile, []byte, bool, error) {
 	store.mutex.RLock()
@@ -113,7 +127,7 @@ func (store FilesArtifactStore) GetFile(artifactIdentifier string) (FilesArtifac
 		return store.getFileUnlocked(filesArtifactUuid)
 	}
 
-	return "", nil, nil, false, stacktrace.NewError("Couldn't find file for identifier '%v' tried, tried looking up UUID, shortened UUID and by name", artifactIdentifier)
+	return "", nil, nil, false, nil
 }
 
 // RemoveFile Remove the file by uuid, then by shortened uuid and then by name
@@ -123,7 +137,7 @@ func (store FilesArtifactStore) RemoveFile(artifactIdentifier string) error {
 	var filesArtifactUuid FilesArtifactUUID
 
 	filesArtifactUuid = FilesArtifactUUID(artifactIdentifier)
-	err := store.removeFileUnlocked(filesArtifactUuid)
+	_, err := store.removeFileUnlocked(filesArtifactUuid)
 	if err == nil {
 		return nil
 	}
@@ -134,15 +148,23 @@ func (store FilesArtifactStore) RemoveFile(artifactIdentifier string) error {
 			return stacktrace.NewError("Tried using the shortened uuid '%v' to remove file but found multiple matches '%v'. Use a complete uuid to be specific about what to delete.", artifactIdentifier, filesArtifactUuids)
 		}
 		filesArtifactUuid = filesArtifactUuids[0]
-		return store.removeFileUnlocked(filesArtifactUuid)
+		_, err = store.removeFileUnlocked(filesArtifactUuid)
+		if err != nil {
+			return stacktrace.Propagate(err, "An error occurred removing files artifact with UUID '%s'", filesArtifactUuid)
+		}
+		return nil
 	}
 
 	filesArtifactUuid, found = store.artifactNameToArtifactUuid[artifactIdentifier]
 	if found {
-		return store.removeFileUnlocked(filesArtifactUuid)
+		_, err = store.removeFileUnlocked(filesArtifactUuid)
+		if err != nil {
+			return stacktrace.Propagate(err, "An error occurred removing files artifact with UUID '%s'", filesArtifactUuid)
+		}
+		return nil
 	}
 
-	return stacktrace.NewError("Couldn't find file for identifier '%v' tried, tried looking up UUID, shortened UUID and by name", artifactIdentifier)
+	return stacktrace.NewError("Couldn't find file for identifier '%v', tried looking up UUID, shortened UUID and by name", artifactIdentifier)
 }
 
 func (store FilesArtifactStore) ListFiles() map[string]bool {
@@ -243,17 +265,19 @@ func (store FilesArtifactStore) getFileUnlocked(filesArtifactUuid FilesArtifactU
 }
 
 // removeFileUnlocked this is not thread safe, must be used from a thread safe context
-func (store FilesArtifactStore) removeFileUnlocked(filesArtifactUuid FilesArtifactUUID) error {
+func (store FilesArtifactStore) removeFileUnlocked(filesArtifactUuid FilesArtifactUUID) (string, error) {
 	filename := strings.Join(
 		[]string{string(filesArtifactUuid), artifactExtension},
 		".",
 	)
 
+	var artifactName string
 	if err := store.fileCache.RemoveFile(filename); err != nil {
-		return stacktrace.Propagate(err, "There was an error in removing '%v' from the file store", filename)
+		return "", stacktrace.Propagate(err, "There was an error in removing '%v' from the file store", filename)
 	}
 	for name, artifactUuid := range store.artifactNameToArtifactUuid {
 		if artifactUuid == filesArtifactUuid {
+			artifactName = name
 			delete(store.artifactNameToArtifactUuid, name)
 			delete(store.artifactContentMd5, artifactUuid)
 		}
@@ -270,11 +294,11 @@ func (store FilesArtifactStore) removeFileUnlocked(filesArtifactUuid FilesArtifa
 		// if there's only one matching uuid we delete the shortened uuid
 		if len(artifactUuids) == 1 {
 			delete(store.shortenedUuidToFullUuid, shortenedUuid)
-			return nil
+			return artifactName, nil
 		}
 		// otherwise we just delete the target artifact uuid
 		store.shortenedUuidToFullUuid[shortenedUuid] = append(artifactUuids[0:targetArtifactIdx], artifactUuids[targetArtifactIdx+1:]...)
 	}
 
-	return nil
+	return artifactName, nil
 }


Test patch:
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 917341cf4..117234f8f 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
@@ -42,6 +42,7 @@ func TestAllRegisteredBuiltins(t *testing.T) {
 	testKurtosisPlanInstruction(t, newStoreServiceFilesTestCase(t))
 	testKurtosisPlanInstruction(t, newStoreServiceFilesWithoutNameTestCase(t))
 	testKurtosisPlanInstruction(t, newUploadFilesTestCase(t))
+	testKurtosisPlanInstruction(t, newUploadFilesUpdateTestCase(t))
 	testKurtosisPlanInstruction(t, newUploadFilesWithoutNameTestCase(t))
 	testKurtosisPlanInstruction(t, newWaitTestCase1(t))
 	testKurtosisPlanInstruction(t, newWaitTestCase2(t))
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_framework_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_framework_test.go
index 548fb2124..d036eae0a 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_framework_test.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_framework_test.go
@@ -6,6 +6,7 @@ import (
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_instruction/upload_files"
 	"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/startosis_packages/mock_package_content_provider"
+	"github.com/kurtosis-tech/kurtosis/core/server/commons/enclave_data_directory"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 	"go.starlark.net/starlark"
@@ -31,6 +32,14 @@ func (t *uploadFilesTestCase) GetInstruction() *kurtosis_plan_instruction.Kurtos
 	packageContentProvider := mock_package_content_provider.NewMockPackageContentProvider()
 	require.Nil(t, packageContentProvider.AddFileContent(TestModuleFileName, "Hello World!"))
 
+	serviceNetwork.EXPECT().GetFilesArtifactMd5(
+		TestArtifactName,
+	).Times(1).Return(
+		enclave_data_directory.FilesArtifactUUID(""),
+		nil,
+		false,
+		nil,
+	)
 	serviceNetwork.EXPECT().UploadFilesArtifact(
 		mock.Anything, // data gets written to disk and compressed to it's a bit tricky to replicate here.
 		mock.Anything, // and same for the hash
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_update_framework_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_update_framework_test.go
new file mode 100644
index 000000000..6ecde1555
--- /dev/null
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_update_framework_test.go
@@ -0,0 +1,66 @@
+package test_engine
+
+import (
+	"fmt"
+	"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/upload_files"
+	"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/startosis_packages/mock_package_content_provider"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+	"go.starlark.net/starlark"
+	"testing"
+)
+
+type uploadFilesUpdateTestCase struct {
+	*testing.T
+}
+
+func newUploadFilesUpdateTestCase(t *testing.T) *uploadFilesUpdateTestCase {
+	return &uploadFilesUpdateTestCase{
+		T: t,
+	}
+}
+
+func (t *uploadFilesUpdateTestCase) GetId() string {
+	return upload_files.UploadFilesBuiltinName
+}
+
+func (t *uploadFilesUpdateTestCase) GetInstruction() *kurtosis_plan_instruction.KurtosisPlanInstruction {
+	serviceNetwork := service_network.NewMockServiceNetwork(t)
+	packageContentProvider := mock_package_content_provider.NewMockPackageContentProvider()
+	require.Nil(t, packageContentProvider.AddFileContent(TestModuleFileName, "Hello World!"))
+
+	serviceNetwork.EXPECT().GetFilesArtifactMd5(
+		TestArtifactName,
+	).Times(1).Return(
+		TestArtifactUuid,
+		[]byte{},
+		true,
+		nil,
+	)
+	serviceNetwork.EXPECT().UpdateFilesArtifact(
+		TestArtifactUuid,
+		mock.Anything, // data gets written to disk and compressed to it's a bit tricky to replicate here.
+		mock.Anything, // and same for the hash
+	).Times(1).Return(
+		nil,
+	)
+
+	return upload_files.NewUploadFiles(serviceNetwork, packageContentProvider)
+}
+
+func (t uploadFilesUpdateTestCase) GetStarlarkCode() string {
+	return fmt.Sprintf("%s(%s=%q, %s=%q)", upload_files.UploadFilesBuiltinName, upload_files.SrcArgName, TestModuleFileName, upload_files.ArtifactNameArgName, TestArtifactName)
+}
+
+func (t *uploadFilesUpdateTestCase) GetStarlarkCodeForAssertion() string {
+	return ""
+}
+
+func (t *uploadFilesUpdateTestCase) Assert(interpretationResult starlark.Value, executionResult *string) {
+	require.Equal(t, starlark.String(TestArtifactName), interpretationResult)
+
+	expectedExecutionResult := fmt.Sprintf("Files with artifact name '%s' with artifact UUID '%s' updated", TestArtifactName, TestArtifactUuid)
+	require.Equal(t, expectedExecutionResult, *executionResult)
+}
diff --git a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_without_name_framework_test.go b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_without_name_framework_test.go
index 0e1d985da..fb8ca2abf 100644
--- a/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_without_name_framework_test.go
+++ b/core/server/api_container/server/startosis_engine/kurtosis_starlark_framework/test_engine/upload_files_without_name_framework_test.go
@@ -6,6 +6,7 @@ import (
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/kurtosis_instruction/upload_files"
 	"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/startosis_packages/mock_package_content_provider"
+	"github.com/kurtosis-tech/kurtosis/core/server/commons/enclave_data_directory"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 	"go.starlark.net/starlark"
@@ -36,6 +37,14 @@ func (t *uploadFilesWithoutNameTestCase) GetInstruction() *kurtosis_plan_instruc
 		nil,
 	)
 
+	serviceNetwork.EXPECT().GetFilesArtifactMd5(
+		mockedFileArtifactName,
+	).Times(1).Return(
+		enclave_data_directory.FilesArtifactUUID(""),
+		nil,
+		false,
+		nil,
+	)
 	serviceNetwork.EXPECT().UploadFilesArtifact(
 		mock.Anything, // data gets written to disk and compressed to it's a bit tricky to replicate here.
 		mock.Anything, // and same for the hash
diff --git a/core/server/api_container/server/startosis_engine/startosis_interpreter_idempotent_test.go b/core/server/api_container/server/startosis_engine/startosis_interpreter_idempotent_test.go
index 4ca250166..882b3b4bc 100644
--- a/core/server/api_container/server/startosis_engine/startosis_interpreter_idempotent_test.go
+++ b/core/server/api_container/server/startosis_engine/startosis_interpreter_idempotent_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/kurtosis-tech/kurtosis/core/server/api_container/server/startosis_engine/startosis_packages/mock_package_content_provider"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
+	"net"
 	"testing"
 )
 
@@ -27,6 +28,9 @@ func (suite *StartosisInterpreterIdempotentTestSuite) SetupTest() {
 	suite.packageContentProvider = mock_package_content_provider.NewMockPackageContentProvider()
 	runtimeValueStore := runtime_value_store.NewRuntimeValueStore()
 	serviceNetwork := service_network.NewMockServiceNetwork(suite.T())
+	serviceNetwork.EXPECT().GetApiContainerInfo().Maybe().Return(
+		service_network.NewApiContainerInfo(net.IPv4(0, 0, 0, 0), uint16(1234), "0.0.0"),
+	)
 	suite.interpreter = NewStartosisInterpreter(serviceNetwork, suite.packageContentProvider, runtimeValueStore)
 }
 
@@ -348,9 +352,9 @@ func (suite *StartosisInterpreterIdempotentTestSuite) TestInterpretAndOptimize_I
 	require.False(suite.T(), scheduledInstruction6.IsExecuted())
 }
 
-// Submit a package with an update to an add_service instruction. add_service instructions is the only one right now
-// support being run twice in an enclave, updating "live" the service underneath. Check that the add_service and its
-// direct dependencies are scheduled for a re-run but other instructions remains "SKIPPED"
+// Submit a package with an update to an add_service instruction. add_service instructions is supports being run twice
+// in an enclave, updating "live" the service underneath. Check that the add_service and its direct dependencies are
+// scheduled for a re-run but other instructions remains "SKIPPED"
 func (suite *StartosisInterpreterIdempotentTestSuite) TestInterpretAndOptimize_AddServiceIdempotency() {
 	initialScript := `def run(plan):
 	service_1 = plan.add_service(name="service_1", config=ServiceConfig(image="kurtosistech/image:1.2.3"))
@@ -413,3 +417,79 @@ func (suite *StartosisInterpreterIdempotentTestSuite) TestInterpretAndOptimize_A
 	require.False(suite.T(), scheduledInstruction4.IsImportedFromCurrentEnclavePlan())
 	require.True(suite.T(), scheduledInstruction4.IsExecuted())
 }
+
+// Submit a package with an update to an upload_files instruction. upload_files instructions support being run twice
+// in an enclave, updating "live" the file underneath if it has changed. Check that the add_service and its direct
+// dependencies are scheduled for a re-run but other instructions remains "SKIPPED"
+func (suite *StartosisInterpreterIdempotentTestSuite) TestInterpretAndOptimize_UploadFilesIdempotency() {
+	initialScript := `def run(plan):
+	files_artifact = plan.render_templates(
+        name="files_artifact",
+        config={
+            "/output.txt": struct(template="Hello {{.World}}", data={"World" : "World!"}),
+        }
+    )
+	service_1 = plan.add_service(name="service_1", config=ServiceConfig(image="kurtosistech/image:1.2.3", files={"/path/": files_artifact}))
+	plan.exec(service_name="service_1", recipe=ExecRecipe(command=["echo", "Hello World!"]))
+	plan.assert(value=service_1.ip_address, assertion="==", target_value="fake_ip")
+`
+	// Interpretation of the initial script to generate the current enclave plan
+	_, currentEnclavePlan, interpretationApiErr := suite.interpreter.Interpret(
+		context.Background(),
+		startosis_constants.PackageIdPlaceholderForStandaloneScript,
+		useDefaultMainFunctionName,
+		startosis_constants.PlaceHolderMainFileForPlaceStandAloneScript,
+		initialScript,
+		noInputParams,
+		enclave_structure.NewEnclaveComponents(),
+		resolver.NewInstructionsPlanMask(0))
+	require.Nil(suite.T(), interpretationApiErr)
+	require.Equal(suite.T(), 4, currentEnclavePlan.Size())
+
+	updatedScript := `def run(plan):
+	files_artifact = plan.render_templates(
+        name="files_artifact",
+        config={
+            "/output.txt": struct(template="Bonjour {{.World}}", data={"World" : "World!"}), # updated template!!
+        }
+    )
+	service_1 = plan.add_service(name="service_1", config=ServiceConfig(image="kurtosistech/image:1.2.3", files={"/path/": files_artifact}))
+	plan.exec(service_name="service_1", recipe=ExecRecipe(command=["echo", "Hello World!"]))
+	plan.assert(value=service_1.ip_address, assertion="==", target_value="fake_ip")
+`
+	// Interpret the updated script against the current enclave plan
+	_, instructionsPlan, interpretationError := suite.interpreter.InterpretAndOptimizePlan(
+		context.Background(),
+		startosis_constants.PackageIdPlaceholderForStandaloneScript,
+		useDefaultMainFunctionName,
+		startosis_constants.PlaceHolderMainFileForPlaceStandAloneScript,
+		updatedScript,
+		noInputParams,
+		currentEnclavePlan,
+	)
+	require.Nil(suite.T(), interpretationError)
+
+	instructionSequence, err := instructionsPlan.GeneratePlan()
+	require.Nil(suite.T(), err)
+	require.Equal(suite.T(), 4, len(instructionSequence))
+
+	scheduledInstruction1 := instructionSequence[0]
+	require.Equal(suite.T(), `render_templates(config={"/output.txt": struct(data={"World": "World!"}, template="Bonjour {{.World}}")}, name="files_artifact")`, scheduledInstruction1.GetInstruction().String())
+	require.False(suite.T(), scheduledInstruction1.IsImportedFromCurrentEnclavePlan())
+	require.False(suite.T(), scheduledInstruction1.IsExecuted())
+
+	scheduledInstruction2 := instructionSequence[1]
+	require.Equal(suite.T(), `add_service(name="service_1", config=ServiceConfig(image="kurtosistech/image:1.2.3", files={"/path/": "files_artifact"}))`, scheduledInstruction2.GetInstruction().String())
+	require.False(suite.T(), scheduledInstruction2.IsImportedFromCurrentEnclavePlan())
+	require.False(suite.T(), scheduledInstruction2.IsExecuted()) // since the files artifact has been updated, the service will be updated as well
+
+	scheduledInstruction3 := instructionSequence[2]
+	require.Equal(suite.T(), `exec(service_name="service_1", recipe=ExecRecipe(command=["echo", "Hello World!"]))`, scheduledInstruction3.GetInstruction().String())
+	require.False(suite.T(), scheduledInstruction3.IsImportedFromCurrentEnclavePlan())
+	require.False(suite.T(), scheduledInstruction3.IsExecuted()) // since the service has been updated, the exec will be re-run
+
+	scheduledInstruction4 := instructionSequence[3]
+	require.Regexp(suite.T(), `assert\(value="{{kurtosis:[a-z0-9]{32}:ip_address\.runtime_value}}", assertion="==", target_value="fake_ip"\)`, scheduledInstruction4.GetInstruction().String())
+	require.False(suite.T(), scheduledInstruction4.IsImportedFromCurrentEnclavePlan())
+	require.True(suite.T(), scheduledInstruction4.IsExecuted()) // this instruction is not affected, i.e. it won't be re-run
+}
diff --git a/internal_testsuites/golang/testsuite/startosis_idempotency_test/startosis_idempotency_test.go b/internal_testsuites/golang/testsuite/startosis_idempotency_test/startosis_idempotency_test.go
new file mode 100644
index 000000000..05fc8e844
--- /dev/null
+++ b/internal_testsuites/golang/testsuite/startosis_idempotency_test/startosis_idempotency_test.go
@@ -0,0 +1,127 @@
+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"
+
+	templateScript = `
+IMAGE = "docker/getting-started"
+SERVICE_NAME = "test-service"
+
+def run(plan):
+    # create a file artifact from a template
+    template_data = {"World" : "World!"}
+    plan.render_templates(
+        name="render_template_file_artifact",
+        config={
+            "/output.txt": struct(template="%s {{.World}}", data={"World" : "World!"}),
+        }
+    )
+
+    # create a file artifact from a service
+    file_generator_service_name = "file-generator" 
+    service = plan.add_service(
+		name=file_generator_service_name, 
+		config=ServiceConfig(
+			image=IMAGE,
+			cmd=[
+				"/bin/sh",
+				"-c",
+				"echo '%s world !' >> /test.log && sleep 99999"
+			]
+		)
+	)
+    plan.store_service_files(service_name=file_generator_service_name, src="/test.log", name="service_file_artifact")
+
+    service = plan.add_service(
+        name=SERVICE_NAME,
+		config=ServiceConfig(
+            image=IMAGE,
+            files={
+                "/data/": "render_template_file_artifact", 
+            }
+        )
+    )
+`
+)
+
+func TestIdempotentRenderTemplateTest(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(templateScript, "Hello", "Hello")
+	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")
+	require.Regexp(t, `Templates artifact name 'render_template_file_artifact' rendered with artifact UUID '[a-z0-9]{32}'
+Service 'file-generator' added with service UUID '[a-z0-9]{32}'
+Files with artifact name 'service_file_artifact' uploaded with artifact UUID '[a-z0-9]{32}'
+Service 'test-service' added with service UUID '[a-z0-9]{32}'`, firstRunResult.RunOutput)
+
+	secondScript := fmt.Sprintf(templateScript, "Bonjour", "Hello")
+	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")
+	require.Regexp(t, `Templates artifact name 'render_template_file_artifact' rendered with artifact UUID '[a-z0-9]{32}'
+SKIPPED - This instruction has already been run in this enclave
+SKIPPED - This instruction has already been run in this enclave
+Service 'test-service' added with service UUID '[a-z0-9]{32}'`, secondRunResult.RunOutput)
+}
+
+func TestIdempotentStoreServiceFileTest(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(templateScript, "Hello", "Hello")
+	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")
+	require.Regexp(t, `Templates artifact name 'render_template_file_artifact' rendered with artifact UUID '[a-z0-9]{32}'
+Service 'file-generator' added with service UUID '[a-z0-9]{32}'
+Files with artifact name 'service_file_artifact' uploaded with artifact UUID '[a-z0-9]{32}'
+Service 'test-service' added with service UUID '[a-z0-9]{32}'`, firstRunResult.RunOutput)
+
+	secondScript := fmt.Sprintf(templateScript, "Hello", "Bonjour")
+	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")
+	require.Regexp(t, `SKIPPED - This instruction has already been run in this enclave
+Service 'file-generator' added with service UUID '[a-z0-9]{32}'
+Files with artifact name 'service_file_artifact' uploaded with artifact UUID '[a-z0-9]{32}'
+SKIPPED - This instruction has already been run in this enclave
+`, secondRunResult.RunOutput)
+}