Skip to content

Commit dcebd3a

Browse files
authored
[Application] Support single-file source deployment with artifacts (mlrun#9244)
1 parent 458ba27 commit dcebd3a

File tree

6 files changed

+245
-15
lines changed

6 files changed

+245
-15
lines changed

mlrun/common/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
NUCLIO_LABEL_PREFIX = "nuclio.io/"
2828
RESERVED_TAG_NAME_LATEST = "latest"
2929

30+
# Internal path for application runtime source artifacts (avoids user artifact conflicts)
31+
# "+/" prefix makes it relative to the project's default artifact path (see extend_artifact_path)
32+
MLRUN_INTERNAL_ARTIFACT_PATH = "+/.mlrun/sources"
33+
3034
# Kubernetes DNS-1123 label name length limit
3135
K8S_DNS_1123_LABEL_MAX_LENGTH = 63
3236

@@ -87,6 +91,8 @@ class MLRunInternalLabels:
8791
app_name = f"{MLRUN_LABEL_PREFIX}app-name"
8892
endpoint_id = f"{MLRUN_LABEL_PREFIX}endpoint-id"
8993
endpoint_name = f"{MLRUN_LABEL_PREFIX}endpoint-name"
94+
function_name = f"{MLRUN_LABEL_PREFIX}function-name"
95+
system_generated = f"{MLRUN_LABEL_PREFIX}system-generated"
9096
host = "host"
9197
job_type = "job-type"
9298
kind = "kind"

mlrun/model.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,13 +571,15 @@ def source(self):
571571
def source(self, source):
572572
if source and not (
573573
source.startswith("git://")
574+
# Allow store artifact URIs for single-file sources
575+
or mlrun.datastore.is_store_uri(source)
574576
# lenient check for file extension because we support many file types locally and remotely
575577
or pathlib.Path(source).suffix
576578
or source in [".", "./"]
577579
):
578580
raise mlrun.errors.MLRunInvalidArgumentError(
579581
f"source ({source}) must be a compressed (tar.gz / zip) file, a git repo, "
580-
f"a file path or in the project's context (.)"
582+
f"a file path, a store URI, or in the project's context (.)"
581583
)
582584

583585
self._source = source

mlrun/projects/project.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5934,7 +5934,17 @@ def _init_function_from_dict(
59345934
)
59355935

59365936
elif url.endswith(".py"):
5937-
if in_context and with_repo:
5937+
# For application runtime we set the source path directly, deploy will upload it as an artifact
5938+
if kind == mlrun.runtimes.RuntimeKinds.application:
5939+
func = new_function(
5940+
name,
5941+
image=image,
5942+
kind=kind,
5943+
handler=handler,
5944+
tag=tag,
5945+
)
5946+
func.spec.build.source = url
5947+
elif in_context and with_repo:
59385948
# when load_source_on_run is used we allow not providing image as code will be loaded pre-run. ML-4994
59395949
if (
59405950
not image

mlrun/runtimes/nuclio/application/application.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import copy
15+
import os
1516
import pathlib
1617
import typing
1718

1819
import nuclio
1920
import nuclio.auth
2021

22+
import mlrun.common.constants
2123
import mlrun.common.schemas as schemas
24+
import mlrun.datastore
2225
import mlrun.errors
2326
import mlrun.run
2427
import mlrun.runtimes.nuclio.api_gateway as nuclio_api_gateway
@@ -28,7 +31,7 @@
2831
ProbeTimeConfig,
2932
ProbeType,
3033
)
31-
from mlrun.utils import is_valid_port, logger, update_in
34+
from mlrun.utils import is_relative_path, is_valid_port, logger, update_in
3235

3336

3437
class ApplicationSpec(nuclio_function.NuclioSpec):
@@ -490,6 +493,8 @@ def deploy(
490493
491494
:return: The default API gateway URL if created or True if the function is ready (deployed)
492495
"""
496+
# Upload local single-file source as artifact (if applicable)
497+
self._upload_source_as_artifact()
493498

494499
if (self.requires_build() and not self.spec.image) or force_build:
495500
self._fill_credentials()
@@ -1050,3 +1055,68 @@ def _validate_set_probes_input(params: dict):
10501055
raise ValueError(
10511056
"Empty probe configuration: at least one parameter must be set"
10521057
)
1058+
1059+
def _upload_source_as_artifact(self) -> None:
1060+
"""
1061+
Upload local single-file source as an MLRun artifact.
1062+
1063+
If spec.build.source is a local file path, upload it to the artifact store
1064+
and update spec.build.source with the artifact URI.
1065+
"""
1066+
source = self.spec.build.source
1067+
if not source:
1068+
return
1069+
1070+
# Only upload if it's a local single file
1071+
if not self._is_single_local_file(source):
1072+
return
1073+
1074+
project_name = self.metadata.project
1075+
if not project_name:
1076+
raise mlrun.errors.MLRunMissingProjectError(
1077+
"Project is required to upload source as artifact"
1078+
)
1079+
project = mlrun.get_or_create_project(project_name)
1080+
1081+
# Use function name as part of the artifact key for identification
1082+
artifact_key = f"{self.metadata.name}-source"
1083+
1084+
logger.info(
1085+
"Uploading local source file as artifact",
1086+
source=source,
1087+
artifact_key=artifact_key,
1088+
project=project_name,
1089+
)
1090+
1091+
# Upload the file as an artifact to an internal path with system-generated label
1092+
try:
1093+
artifact = project.log_artifact(
1094+
item=artifact_key,
1095+
local_path=source,
1096+
artifact_path=mlrun.common.constants.MLRUN_INTERNAL_ARTIFACT_PATH,
1097+
upload=True,
1098+
labels={
1099+
mlrun.common.constants.MLRunInternalLabels.function_name: self.metadata.name,
1100+
mlrun.common.constants.MLRunInternalLabels.system_generated: "true",
1101+
},
1102+
)
1103+
except Exception as exc:
1104+
raise mlrun.errors.MLRunRuntimeError(
1105+
f"Failed to upload source file '{source}' as artifact"
1106+
) from exc
1107+
1108+
# Update the source to point to the artifact URI
1109+
self.spec.build.source = artifact.uri
1110+
1111+
@staticmethod
1112+
def _is_single_local_file(source: str) -> bool:
1113+
# Skip if the source is already a store URI
1114+
if mlrun.datastore.is_store_uri(source):
1115+
return False
1116+
1117+
# Skip if it's a remote URL (not a relative/local path)
1118+
if not (is_relative_path(source) or os.path.isabs(source)):
1119+
return False
1120+
1121+
# Check if it's a local file (not a directory)
1122+
return os.path.isfile(source)

tests/integration/sdk_api/run/test_main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,8 @@ def test_main_local_source(self):
376376
self._exec_run("./handler.py", args.split(), "test_main_local_source")
377377
assert (
378378
f"source ({examples_path}) must be a compressed (tar.gz / zip) file, "
379-
f"a git repo, a file path or in the project's context (.)" in str(e.value)
379+
f"a git repo, a file path, a store URI, or in the project's context (.)"
380+
in str(e.value)
380381
)
381382

382383
def test_main_run_archive_subdir(self):

tests/runtimes/test_application.py

Lines changed: 152 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414

1515
import base64
1616
import pathlib
17+
import unittest.mock
1718

1819
import pytest
1920

2021
import mlrun
22+
import mlrun.common.constants
2123
import mlrun.common.schemas
2224
import mlrun.runtimes
2325
import mlrun.utils
@@ -395,17 +397,6 @@ def test_deploy_reverse_proxy_image(rundb_mock, igz_version_mock):
395397
assert mlrun.runtimes.ApplicationRuntime.reverse_proxy_image
396398

397399

398-
def test_application_from_local_file_validation():
399-
project = mlrun.get_or_create_project("test-application", allow_cross_project=True)
400-
func_path = assets_path / "sample_function.py"
401-
with pytest.raises(
402-
mlrun.errors.MLRunInvalidArgumentError,
403-
match="Embedding a code file is not supported for application runtime. "
404-
"Code files should be specified via project/function source.",
405-
):
406-
project.set_function(func=str(func_path), name="my-app", kind="application")
407-
408-
409400
def _assert_function_code(fn, file_path=None):
410401
file_path = (
411402
file_path or mlrun.runtimes.ApplicationRuntime.get_filename_and_handler()[0]
@@ -967,3 +958,153 @@ def test_enrich_sidecar_probe_ports_no_probes():
967958
assert ProbeType.READINESS.key not in sidecar
968959
assert ProbeType.LIVENESS.key not in sidecar
969960
assert ProbeType.STARTUP.key not in sidecar
961+
962+
963+
@pytest.mark.parametrize(
964+
"source,setup_file,expected",
965+
[
966+
("local_file.py", True, True),
967+
("directory_path", False, False),
968+
("", False, False),
969+
("store://artifacts/project/file", False, False),
970+
("https://example.com/file.py", False, False),
971+
("git://github.com/repo.git", False, False),
972+
("/non/existent/path.py", False, False),
973+
],
974+
)
975+
def test_is_single_local_file(tmp_path, source, setup_file, expected):
976+
# Test _is_single_local_file identifies local files vs remote/invalid sources.
977+
func_name = "application-test"
978+
fn: mlrun.runtimes.ApplicationRuntime = mlrun.new_function(
979+
func_name,
980+
kind="application",
981+
image="mlrun/mlrun",
982+
)
983+
984+
if setup_file:
985+
# Create a temporary file for local file case
986+
file_path = tmp_path / source
987+
file_path.write_text("def handler(): pass")
988+
test_source = str(file_path)
989+
elif source == "directory_path":
990+
# Use tmp_path as directory
991+
test_source = str(tmp_path)
992+
else:
993+
test_source = source
994+
995+
assert fn._is_single_local_file(test_source) is expected
996+
997+
998+
def test_upload_source_as_artifact(tmp_path):
999+
# Test that local single file is uploaded as artifact
1000+
func_name = "application-test"
1001+
# Create a temporary source file
1002+
source_file = tmp_path / "handler.py"
1003+
source_file.write_text("def handler(): pass")
1004+
1005+
fn: mlrun.runtimes.ApplicationRuntime = mlrun.new_function(
1006+
func_name,
1007+
kind="application",
1008+
image="mlrun/mlrun",
1009+
project="test-project",
1010+
)
1011+
fn.spec.build.source = str(source_file)
1012+
1013+
mock_artifact = unittest.mock.MagicMock()
1014+
mock_artifact.uri = "store://artifacts/test-project/application-test-source"
1015+
1016+
mock_project = unittest.mock.MagicMock()
1017+
mock_project.log_artifact.return_value = mock_artifact
1018+
1019+
with unittest.mock.patch(
1020+
"mlrun.get_or_create_project", return_value=mock_project
1021+
) as mock_get_project:
1022+
fn._upload_source_as_artifact()
1023+
1024+
# Verify project was retrieved
1025+
mock_get_project.assert_called_once_with("test-project")
1026+
1027+
# Verify artifact was logged with correct parameters
1028+
mock_project.log_artifact.assert_called_once_with(
1029+
item="application-test-source",
1030+
local_path=str(source_file),
1031+
artifact_path=mlrun.common.constants.MLRUN_INTERNAL_ARTIFACT_PATH,
1032+
upload=True,
1033+
labels={
1034+
mlrun.common.constants.MLRunInternalLabels.function_name: func_name,
1035+
mlrun.common.constants.MLRunInternalLabels.system_generated: "true",
1036+
},
1037+
)
1038+
1039+
# Verify source was updated to the artifact URI
1040+
assert (
1041+
fn.spec.build.source == "store://artifacts/test-project/application-test-source"
1042+
)
1043+
1044+
1045+
@pytest.mark.parametrize(
1046+
"source",
1047+
[
1048+
"store://artifacts/test-project/existing-artifact",
1049+
"https://github.com/org/repo.git",
1050+
"s3://bucket/path/file.py",
1051+
"",
1052+
],
1053+
)
1054+
def test_upload_source_as_artifact_skip_non_local(source):
1055+
# Test that non-local sources (store URIs, remote URLs) and empty path are not uploaded
1056+
func_name = "application-test"
1057+
fn: mlrun.runtimes.ApplicationRuntime = mlrun.new_function(
1058+
func_name,
1059+
kind="application",
1060+
image="mlrun/mlrun",
1061+
project="test-project",
1062+
)
1063+
fn.spec.build.source = source
1064+
1065+
with unittest.mock.patch("mlrun.get_or_create_project") as mock_get_project:
1066+
fn._upload_source_as_artifact()
1067+
1068+
# Verify project was not called (upload skipped)
1069+
mock_get_project.assert_not_called()
1070+
1071+
# Verify source remains unchanged
1072+
assert fn.spec.build.source == source
1073+
1074+
1075+
def test_upload_source_as_artifact_no_project_error():
1076+
# Test that missing project raises an error
1077+
func_name = "application-test"
1078+
# Create a temporary source file
1079+
with unittest.mock.patch("os.path.isfile", return_value=True):
1080+
fn: mlrun.runtimes.ApplicationRuntime = mlrun.new_function(
1081+
func_name,
1082+
kind="application",
1083+
image="mlrun/mlrun",
1084+
)
1085+
fn.metadata.project = None
1086+
fn.spec.build.source = "/path/to/handler.py"
1087+
1088+
with pytest.raises(
1089+
mlrun.errors.MLRunMissingProjectError,
1090+
match="Project is required to upload source as artifact",
1091+
):
1092+
fn._upload_source_as_artifact()
1093+
1094+
1095+
def test_set_function_single_file_application(tmp_path):
1096+
# Test that set_function with single .py file works for application runtime
1097+
source_file = tmp_path / "handler.py"
1098+
source_file.write_text("def handler(): pass")
1099+
1100+
project = mlrun.get_or_create_project("test-proj", allow_cross_project=True)
1101+
fn = project.set_function(
1102+
str(source_file),
1103+
name="my-app",
1104+
kind="application",
1105+
image="mlrun/mlrun",
1106+
)
1107+
1108+
assert fn.kind == "application"
1109+
assert fn.metadata.project == "test-proj"
1110+
assert fn.spec.build.source == str(source_file)

0 commit comments

Comments
 (0)