test_pipeline_app.py 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271
  1. #
  2. # Copyright 2018-2022 Elyra Authors
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. """Tests for elyra-pipeline application"""
  17. import json
  18. from pathlib import Path
  19. import shutil
  20. from click.testing import CliRunner
  21. from conftest import KFP_COMPONENT_CACHE_INSTANCE
  22. import pytest
  23. from elyra.cli.pipeline_app import pipeline
  24. from elyra.metadata.manager import MetadataManager
  25. from elyra.metadata.metadata import Metadata
  26. from elyra.metadata.schemaspaces import Runtimes
  27. from elyra.pipeline.component_catalog import ComponentCache
  28. # used to drive generic parameter handling tests
  29. SUB_COMMANDS = ["run", "submit", "describe", "validate", "export"]
  30. @pytest.fixture(autouse=True)
  31. def destroy_component_cache():
  32. """
  33. This fixture clears any ComponentCache instances that
  34. may have been created during CLI processes so that
  35. those instance doesn't side-affect later tests.
  36. """
  37. yield
  38. ComponentCache.clear_instance()
  39. @pytest.fixture
  40. def kubeflow_pipelines_runtime_instance():
  41. """Creates a Kubeflow Pipelines RTC and removes it after test."""
  42. instance_name = "valid_kfp_test_config"
  43. instance_config_file = Path(__file__).parent / "resources" / "runtime_configs" / f"{instance_name}.json"
  44. with open(instance_config_file, "r") as fd:
  45. instance_config = json.load(fd)
  46. md_mgr = MetadataManager(schemaspace=Runtimes.RUNTIMES_SCHEMASPACE_ID)
  47. # clean possible orphaned instance...
  48. try:
  49. md_mgr.remove(instance_name)
  50. except Exception:
  51. pass
  52. runtime_instance = md_mgr.create(instance_name, Metadata(**instance_config))
  53. yield runtime_instance.name
  54. md_mgr.remove(runtime_instance.name)
  55. @pytest.fixture
  56. def airflow_runtime_instance():
  57. """Creates an airflow RTC and removes it after test."""
  58. instance_name = "valid_airflow_test_config"
  59. instance_config_file = Path(__file__).parent / "resources" / "runtime_configs" / f"{instance_name}.json"
  60. with open(instance_config_file, "r") as fd:
  61. instance_config = json.load(fd)
  62. md_mgr = MetadataManager(schemaspace=Runtimes.RUNTIMES_SCHEMASPACE_ID)
  63. # clean possible orphaned instance...
  64. try:
  65. md_mgr.remove(instance_name)
  66. except Exception:
  67. pass
  68. runtime_instance = md_mgr.create(instance_name, Metadata(**instance_config))
  69. yield runtime_instance.name
  70. md_mgr.remove(runtime_instance.name)
  71. def test_no_opts():
  72. """Verify that all commands are displayed in help"""
  73. runner = CliRunner()
  74. result = runner.invoke(pipeline)
  75. assert "run Run a pipeline in your local environment" in result.output
  76. assert "submit Submit a pipeline to be executed on the server" in result.output
  77. assert "describe Display pipeline summary" in result.output
  78. assert "export Export a pipeline to a runtime-specific format" in result.output
  79. assert "validate Validate pipeline" in result.output
  80. assert result.exit_code == 0
  81. def test_bad_subcommand():
  82. runner = CliRunner()
  83. result = runner.invoke(pipeline, ["invalid_command"])
  84. assert "Error: No such command 'invalid_command'" in result.output
  85. assert result.exit_code != 0
  86. @pytest.mark.parametrize("subcommand", SUB_COMMANDS)
  87. def test_subcommand_no_opts(subcommand):
  88. runner = CliRunner()
  89. result = runner.invoke(pipeline, [subcommand])
  90. assert result.exit_code != 0
  91. assert "Error: Missing argument 'PIPELINE_PATH'" in result.output
  92. @pytest.mark.parametrize("subcommand", SUB_COMMANDS)
  93. def test_subcommand_invalid_pipeline_path(subcommand):
  94. """Verify that every command only accepts a valid pipeline_path file name"""
  95. runner = CliRunner()
  96. # test: file not found
  97. file_name = "no-such.pipeline"
  98. result = runner.invoke(pipeline, [subcommand, file_name])
  99. assert result.exit_code != 0
  100. assert f"Invalid value for 'PIPELINE_PATH': '{file_name}' is not a file." in result.output
  101. # test: file with wrong extension
  102. with runner.isolated_filesystem():
  103. file_name = "wrong.extension"
  104. with open(file_name, "w") as f:
  105. f.write("I am not a pipeline file.")
  106. result = runner.invoke(pipeline, [subcommand, file_name])
  107. assert result.exit_code != 0
  108. assert f"Invalid value for 'PIPELINE_PATH': '{file_name}' is not a .pipeline file." in result.output
  109. @pytest.mark.parametrize("subcommand", SUB_COMMANDS)
  110. def test_subcommand_with_no_pipelines_field(subcommand, kubeflow_pipelines_runtime_instance):
  111. """Verify that every command properly detects pipeline issues"""
  112. runner = CliRunner()
  113. with runner.isolated_filesystem():
  114. pipeline_file = "pipeline_without_pipelines_field.pipeline"
  115. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / pipeline_file
  116. assert pipeline_file_path.is_file()
  117. # every CLI command invocation requires these parameters
  118. invoke_parameters = [subcommand, str(pipeline_file_path)]
  119. if subcommand in ["submit", "export"]:
  120. # these commands also require a runtime configuration
  121. invoke_parameters.extend(["--runtime-config", kubeflow_pipelines_runtime_instance])
  122. result = runner.invoke(pipeline, invoke_parameters)
  123. assert result.exit_code != 0
  124. assert "Pipeline is missing 'pipelines' field." in result.output
  125. @pytest.mark.parametrize("subcommand", SUB_COMMANDS)
  126. def test_subcommand_with_zero_length_pipelines_field(subcommand, kubeflow_pipelines_runtime_instance):
  127. """Verify that every command properly detects pipeline issues"""
  128. runner = CliRunner()
  129. with runner.isolated_filesystem():
  130. pipeline_file = "pipeline_with_zero_length_pipelines_field.pipeline"
  131. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / pipeline_file
  132. assert pipeline_file_path.is_file()
  133. # every CLI command invocation requires these parameters
  134. invoke_parameters = [subcommand, str(pipeline_file_path)]
  135. if subcommand in ["submit", "export"]:
  136. # these commands also require a runtime configuration
  137. invoke_parameters.extend(["--runtime-config", kubeflow_pipelines_runtime_instance])
  138. result = runner.invoke(pipeline, invoke_parameters)
  139. assert result.exit_code != 0
  140. assert "Pipeline has zero length 'pipelines' field." in result.output
  141. @pytest.mark.parametrize("subcommand", SUB_COMMANDS)
  142. def test_subcommand_with_no_nodes(subcommand, kubeflow_pipelines_runtime_instance):
  143. """Verify that every command properly detects pipeline issues"""
  144. # don't run this test for the `describe` command
  145. # (see test_describe_with_no_nodes)
  146. if subcommand == "describe":
  147. return
  148. runner = CliRunner()
  149. with runner.isolated_filesystem():
  150. pipeline_file = "pipeline_with_zero_nodes.pipeline"
  151. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / pipeline_file
  152. assert pipeline_file_path.is_file()
  153. # every CLI command invocation requires these parameters
  154. invoke_parameters = [subcommand, str(pipeline_file_path)]
  155. if subcommand in ["submit", "export"]:
  156. # these commands also require a runtime configuration
  157. invoke_parameters.extend(["--runtime-config", kubeflow_pipelines_runtime_instance])
  158. result = runner.invoke(pipeline, invoke_parameters)
  159. assert result.exit_code != 0
  160. # ------------------------------------------------------------------
  161. # tests for 'describe' command
  162. # ------------------------------------------------------------------
  163. def test_describe_with_no_nodes():
  164. """
  165. Verify that describe yields the expected results if a pipeline without any
  166. nodes is is provided as input.
  167. """
  168. runner = CliRunner()
  169. with runner.isolated_filesystem():
  170. pipeline_file = "pipeline_with_zero_nodes.pipeline"
  171. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / pipeline_file
  172. assert pipeline_file_path.is_file()
  173. # verify human-readable output
  174. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  175. assert result.exit_code == 0, result.output
  176. assert "Pipeline name: pipeline_with_zero_nodes" in result.output
  177. assert "Description: None specified" in result.output
  178. assert "Pipeline type: None specified" in result.output
  179. assert "Pipeline runtime: Generic" in result.output
  180. assert "Pipeline format version: 7" in result.output
  181. assert "Number of generic nodes: 0" in result.output
  182. assert "Number of generic nodes: 0" in result.output
  183. assert "Script dependencies: None specified" in result.output
  184. assert "Notebook dependencies: None specified" in result.output
  185. assert "Local file dependencies: None specified" in result.output
  186. assert "Component dependencies: None specified" in result.output
  187. assert "Volume dependencies: None specified" in result.output
  188. assert "Container image dependencies: None specified" in result.output
  189. assert "Kubernetes secret dependencies: None specified" in result.output
  190. # verify machine-readable output
  191. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path), "--json"])
  192. assert result.exit_code == 0, result.output
  193. result_json = json.loads(result.output)
  194. assert result_json["name"] == "pipeline_with_zero_nodes"
  195. assert result_json["description"] is None
  196. assert result_json["pipeline_type"] is None
  197. assert result_json["pipeline_format_version"] == 7
  198. assert result_json["pipeline_runtime"] == "Generic"
  199. assert result_json["generic_node_count"] == 0
  200. assert result_json["custom_node_count"] == 0
  201. for property in [
  202. "scripts",
  203. "notebooks",
  204. "files",
  205. "custom_components",
  206. "container_images",
  207. "volumes",
  208. "kubernetes_secrets",
  209. ]:
  210. assert isinstance(result_json["dependencies"][property], list)
  211. assert len(result_json["dependencies"][property]) == 0
  212. def test_describe_with_kfp_components():
  213. runner = CliRunner()
  214. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  215. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  216. assert "Description: 3-node custom component pipeline" in result.output
  217. assert "Pipeline type: KUBEFLOW_PIPELINES" in result.output
  218. assert "Number of custom nodes: 3" in result.output
  219. assert "Number of generic nodes: 0" in result.output
  220. assert "Local file dependencies: None specified" in result.output
  221. assert (
  222. '- {"catalog_type": "elyra-kfp-examples-catalog", "component_ref": {"component-id": "download_data.yaml"}}'
  223. in result.output
  224. )
  225. assert (
  226. '- {"catalog_type": "elyra-kfp-examples-catalog", "component_ref": '
  227. '{"component-id": "filter_text_using_shell_and_grep.yaml"}}' in result.output
  228. )
  229. assert (
  230. '- {"catalog_type": "elyra-kfp-examples-catalog", "component_ref": {"component-id": "calculate_hash.yaml"}}'
  231. in result.output
  232. )
  233. assert result.exit_code == 0
  234. def test_describe_with_missing_kfp_component():
  235. runner = CliRunner()
  236. with runner.isolated_filesystem():
  237. valid_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  238. pipeline_file_path = Path.cwd() / "foo.pipeline"
  239. with open(pipeline_file_path, "w") as pipeline_file:
  240. with open(valid_file_path) as valid_file:
  241. valid_data = json.load(valid_file)
  242. # Update known component name to trigger a missing component
  243. valid_data["pipelines"][0]["nodes"][0]["op"] = valid_data["pipelines"][0]["nodes"][0]["op"] + "Missing"
  244. pipeline_file.write(json.dumps(valid_data))
  245. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  246. assert "Description: 3-node custom component pipeline" in result.output
  247. assert "Pipeline type: KUBEFLOW_PIPELINES" in result.output
  248. assert "Number of custom nodes: 3" in result.output
  249. assert "Number of generic nodes: 0" in result.output
  250. assert result.exit_code == 0
  251. def test_describe_notebooks_scripts_report():
  252. """
  253. Test human-readable output for notebooks/scripts property when none, one or many instances are present
  254. """
  255. runner = CliRunner()
  256. #
  257. # Pipeline references no notebooks/no scripts:
  258. # - Pipeline does not contain nodes -> test_describe_with_no_nodes
  259. # - Pipeline contains only script nodes
  260. # - Pipeline contains only notebook nodes
  261. # - Pipeline contains only custom components
  262. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  263. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  264. assert result.exit_code == 0
  265. assert "Notebook dependencies: None specified" in result.output
  266. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  267. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  268. assert result.exit_code == 0
  269. assert "Script dependencies: None specified" in result.output
  270. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  271. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  272. assert result.exit_code == 0
  273. assert "Notebook dependencies: None specified" in result.output
  274. assert "Script dependencies: None specified" in result.output
  275. #
  276. # Pipeline references multiple notebooks:
  277. #
  278. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  279. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  280. assert result.exit_code == 0
  281. assert "Notebook dependencies:\n" in result.output
  282. assert "notebooks/notebook_1.ipyn" in result.output
  283. assert "notebooks/notebook_2.ipyn" in result.output
  284. # Ensure no entries for scripts
  285. assert "Script dependencies: None specified" in result.output
  286. assert "Number of generic nodes: 2" in result.output
  287. assert "Number of custom nodes: 0" in result.output
  288. #
  289. # Pipeline references multiple scripts:
  290. #
  291. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  292. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  293. assert result.exit_code == 0
  294. assert "Script dependencies:\n" in result.output
  295. assert "scripts/script_1.py" in result.output
  296. assert "scripts/script_2.py" in result.output
  297. assert "scripts/script_3.py" in result.output
  298. # Ensure no entries for notebooks
  299. assert "Notebook dependencies: None specified" in result.output
  300. assert "Number of generic nodes: 3" in result.output
  301. assert "Number of custom nodes: 0" in result.output
  302. #
  303. # Pipeline references multiple notebooks and scripts:
  304. #
  305. pipeline_file_path = (
  306. Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks_and_scripts.pipeline"
  307. )
  308. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  309. assert result.exit_code == 0
  310. assert "Notebook dependencies:\n" in result.output
  311. assert "notebooks/notebook_1.ipyn" in result.output
  312. assert "notebooks/notebook_2.ipyn" in result.output
  313. assert "Script dependencies:\n" in result.output
  314. assert "scripts/script_1.py" in result.output
  315. assert "scripts/script_2.py" in result.output
  316. assert "scripts/script_3.py" in result.output
  317. assert "Number of generic nodes: 5" in result.output
  318. assert "Number of custom nodes: 0" in result.output
  319. def test_describe_notebooks_scripts_json():
  320. """
  321. Test machine-readable output for notebooks/scripts property when none, one or many instances are present
  322. """
  323. runner = CliRunner()
  324. #
  325. # Pipeline references no notebooks/no scripts:
  326. # - Pipeline does not contain nodes -> test_describe_with_no_nodes
  327. # - Pipeline contains only script nodes
  328. # - Pipeline contains only notebook nodes
  329. # - Pipeline contains only custom components
  330. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  331. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  332. assert result.exit_code == 0
  333. result_json = json.loads(result.output)
  334. assert result_json["generic_node_count"] == 3
  335. assert result_json["custom_node_count"] == 0
  336. dependencies = result_json.get("dependencies")
  337. assert isinstance(dependencies.get("notebooks"), list)
  338. assert len(dependencies.get("notebooks")) == 0
  339. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  340. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  341. assert result.exit_code == 0
  342. result_json = json.loads(result.output)
  343. assert result_json["generic_node_count"] == 2
  344. assert result_json["custom_node_count"] == 0
  345. dependencies = result_json.get("dependencies")
  346. assert isinstance(dependencies.get("scripts"), list)
  347. assert len(dependencies.get("scripts")) == 0
  348. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  349. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  350. assert result.exit_code == 0
  351. result_json = json.loads(result.output)
  352. assert result_json["generic_node_count"] == 0
  353. assert result_json["custom_node_count"] == 3
  354. dependencies = result_json.get("dependencies")
  355. assert isinstance(dependencies.get("notebooks"), list)
  356. assert len(dependencies.get("notebooks")) == 0
  357. assert isinstance(dependencies.get("scripts"), list)
  358. assert len(dependencies.get("scripts")) == 0
  359. #
  360. # Pipeline references multiple notebooks:
  361. #
  362. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  363. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  364. assert result.exit_code == 0
  365. result_json = json.loads(result.output)
  366. assert result_json["generic_node_count"] == 2
  367. assert result_json["custom_node_count"] == 0
  368. dependencies = result_json.get("dependencies")
  369. assert isinstance(dependencies.get("notebooks"), list)
  370. assert any(x.endswith("notebooks/notebook_1.ipynb") for x in dependencies["notebooks"]), dependencies["notebooks"]
  371. assert any(x.endswith("notebooks/notebook_2.ipynb") for x in dependencies["notebooks"]), dependencies["notebooks"]
  372. # Ensure no entries for scripts
  373. assert isinstance(dependencies.get("scripts"), list)
  374. assert len(dependencies.get("scripts")) == 0
  375. #
  376. # Pipeline references multiple scripts:
  377. #
  378. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  379. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  380. assert result.exit_code == 0
  381. result_json = json.loads(result.output)
  382. assert result_json["generic_node_count"] == 3
  383. assert result_json["custom_node_count"] == 0
  384. dependencies = result_json.get("dependencies")
  385. assert isinstance(dependencies.get("scripts"), list)
  386. assert len(dependencies.get("scripts")) == 3
  387. assert any(x.endswith("scripts/script_1.py") for x in dependencies["scripts"]), dependencies["scripts"]
  388. assert any(x.endswith("scripts/script_2.py") for x in dependencies["scripts"]), dependencies["scripts"]
  389. assert any(x.endswith("scripts/script_3.py") for x in dependencies["scripts"]), dependencies["scripts"]
  390. # Ensure no entries for notebooks
  391. assert isinstance(dependencies.get("notebooks"), list)
  392. assert len(dependencies.get("notebooks")) == 0
  393. #
  394. # Pipeline references multiple notebooks and scripts:
  395. #
  396. pipeline_file_path = (
  397. Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks_and_scripts.pipeline"
  398. )
  399. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  400. assert result.exit_code == 0
  401. result_json = json.loads(result.output)
  402. assert result_json["generic_node_count"] == 5
  403. assert result_json["custom_node_count"] == 0
  404. assert isinstance(result_json.get("dependencies"), dict)
  405. dependencies = result_json.get("dependencies")
  406. assert isinstance(dependencies.get("scripts"), list)
  407. assert len(dependencies.get("scripts")) == 3
  408. assert any(x.endswith("scripts/script_1.py") for x in dependencies["scripts"]), dependencies["scripts"]
  409. assert any(x.endswith("scripts/script_2.py") for x in dependencies["scripts"]), dependencies["scripts"]
  410. assert any(x.endswith("scripts/script_3.py") for x in dependencies["scripts"]), dependencies["scripts"]
  411. assert isinstance(dependencies.get("notebooks"), list)
  412. assert len(dependencies.get("notebooks")) == 2
  413. assert any(x.endswith("notebooks/notebook_1.ipynb") for x in dependencies["notebooks"]), dependencies["notebooks"]
  414. assert any(x.endswith("notebooks/notebook_2.ipynb") for x in dependencies["notebooks"]), dependencies["notebooks"]
  415. def test_describe_container_images_report():
  416. """
  417. Test report output for container_images property when none, one or many instances are present
  418. """
  419. runner = CliRunner()
  420. #
  421. # Pipeline references no container images
  422. # - Pipeline does not contain nodes -> test_describe_with_no_nodes
  423. # - Pipeline contains only custom components
  424. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  425. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  426. assert result.exit_code == 0
  427. assert "Container image dependencies: None specified" in result.output
  428. #
  429. # Pipeline references multiple container images through notebook nodes:
  430. #
  431. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  432. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  433. assert result.exit_code == 0
  434. assert "Container image dependencies:\n" in result.output
  435. assert "- tensorflow/tensorflow:2.8.0" in result.output, result.output
  436. #
  437. # Pipeline references multiple container images through script nodes
  438. #
  439. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  440. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  441. assert result.exit_code == 0
  442. assert "Container image dependencies:\n" in result.output, result.output
  443. assert "- tensorflow/tensorflow:2.8.0-gpu" in result.output, result.output
  444. assert "- tensorflow/tensorflow:2.8.0" in result.output, result.output
  445. #
  446. # Pipeline references multiple notebooks and scripts:
  447. #
  448. pipeline_file_path = (
  449. Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks_and_scripts.pipeline"
  450. )
  451. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  452. assert result.exit_code == 0
  453. assert "Container image dependencies:\n" in result.output
  454. assert "- tensorflow/tensorflow:2.8.0-gpu" in result.output, result.output
  455. assert "- tensorflow/tensorflow:2.8.0" in result.output, result.output
  456. assert "- amancevice/pandas:1.4.1" in result.output, result.output
  457. def test_describe_container_images_json():
  458. """
  459. Test JSON output for runtime_images property when none, one or many instances are present
  460. """
  461. runner = CliRunner()
  462. #
  463. # Pipeline references no container images
  464. # - Pipeline does not contain nodes -> test_describe_with_no_nodes
  465. # - Pipeline contains only custom components
  466. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  467. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  468. assert result.exit_code == 0
  469. result_json = json.loads(result.output)
  470. assert result_json["generic_node_count"] == 0
  471. assert result_json["custom_node_count"] == 3
  472. assert isinstance(result_json.get("dependencies"), dict)
  473. dependencies = result_json["dependencies"]
  474. assert isinstance(dependencies["container_images"], list)
  475. assert len(dependencies["container_images"]) == 0
  476. #
  477. # Pipeline references multiple container images through notebook nodes:
  478. #
  479. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  480. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  481. assert result.exit_code == 0
  482. result_json = json.loads(result.output)
  483. assert result_json["generic_node_count"] == 2
  484. assert result_json["custom_node_count"] == 0
  485. assert isinstance(result_json.get("dependencies"), dict)
  486. dependencies = result_json["dependencies"]
  487. assert isinstance(dependencies["container_images"], list)
  488. assert len(dependencies["container_images"]) == 1
  489. assert "tensorflow/tensorflow:2.8.0" in dependencies["container_images"], dependencies["container_images"]
  490. #
  491. # Pipeline references multiple container images through script nodes
  492. #
  493. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  494. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  495. assert result.exit_code == 0
  496. result_json = json.loads(result.output)
  497. assert result_json["generic_node_count"] == 3
  498. assert result_json["custom_node_count"] == 0
  499. assert isinstance(result_json.get("dependencies"), dict)
  500. dependencies = result_json["dependencies"]
  501. assert isinstance(dependencies["container_images"], list)
  502. assert len(dependencies["container_images"]) == 2
  503. assert "tensorflow/tensorflow:2.8.0" in dependencies["container_images"], dependencies["container_images"]
  504. assert "tensorflow/tensorflow:2.8.0-gpu" in dependencies["container_images"], dependencies["container_images"]
  505. #
  506. # Pipeline references multiple notebooks and scripts:
  507. #
  508. pipeline_file_path = (
  509. Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks_and_scripts.pipeline"
  510. )
  511. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  512. assert result.exit_code == 0
  513. result_json = json.loads(result.output)
  514. assert result_json["generic_node_count"] == 5
  515. assert result_json["custom_node_count"] == 0
  516. assert isinstance(result_json.get("dependencies"), dict)
  517. dependencies = result_json["dependencies"]
  518. assert isinstance(dependencies["container_images"], list)
  519. assert len(dependencies["container_images"]) == 3
  520. assert "tensorflow/tensorflow:2.8.0" in dependencies["container_images"], dependencies["container_images"]
  521. assert "tensorflow/tensorflow:2.8.0-gpu" in dependencies["container_images"], dependencies["container_images"]
  522. assert "amancevice/pandas:1.4.1" in dependencies["container_images"], dependencies["container_images"]
  523. def test_describe_volumes_report():
  524. """
  525. Test report format output for volumes property when none, one or many volume mounts are present
  526. """
  527. runner = CliRunner()
  528. #
  529. # Pipeline references no volumes
  530. # - Pipeline does not contain nodes -> test_describe_with_no_nodes
  531. # - Pipeline contains only custom components
  532. # - No generic nodes mount a volume
  533. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  534. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  535. assert result.exit_code == 0
  536. assert "Volume dependencies: None specified" in result.output
  537. #
  538. # Pipeline references multiple volumes through notebook nodes:
  539. #
  540. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  541. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  542. assert result.exit_code == 0
  543. assert "Volume dependencies:\n" in result.output
  544. assert "- pvc-claim-1" in result.output, result.output
  545. #
  546. # Pipeline references multiple volumes through script nodes
  547. #
  548. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  549. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  550. assert result.exit_code == 0
  551. assert "Volume dependencies:\n" in result.output, result.output
  552. assert "- pvc-claim-2" in result.output, result.output
  553. assert "- pvc-claim-3" in result.output, result.output
  554. #
  555. # Pipeline references multiple volumes through notebook and script nodes:
  556. #
  557. pipeline_file_path = (
  558. Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks_and_scripts.pipeline"
  559. )
  560. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  561. assert result.exit_code == 0
  562. assert "Volume dependencies:\n" in result.output, result.output
  563. assert "- pvc-claim-1" in result.output, result.output
  564. assert "- pvc-claim-2" in result.output, result.output
  565. assert "- pvc-claim-3" in result.output, result.output
  566. def test_describe_volumes_json():
  567. """
  568. Test JSON output for volumes property when none, one or many volume mounts are present
  569. """
  570. runner = CliRunner()
  571. #
  572. # Pipeline references no volumes
  573. # - Pipeline does not contain nodes -> test_describe_with_no_nodes
  574. # - Pipeline contains only custom components
  575. # - No generic nodes mount a volume
  576. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  577. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  578. assert result.exit_code == 0
  579. result_json = json.loads(result.output)
  580. assert result_json["generic_node_count"] == 0
  581. assert result_json["custom_node_count"] == 3
  582. assert isinstance(result_json.get("dependencies"), dict)
  583. dependencies = result_json["dependencies"]
  584. assert isinstance(dependencies["volumes"], list)
  585. assert len(dependencies["volumes"]) == 0
  586. #
  587. # Pipeline references multiple volumes through notebook nodes:
  588. #
  589. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  590. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  591. assert result.exit_code == 0
  592. result_json = json.loads(result.output)
  593. assert result_json["generic_node_count"] == 2
  594. assert result_json["custom_node_count"] == 0
  595. assert isinstance(result_json.get("dependencies"), dict)
  596. dependencies = result_json["dependencies"]
  597. assert len(dependencies["volumes"]) == 1
  598. assert "pvc-claim-1" in dependencies["volumes"], dependencies["volumes"]
  599. #
  600. # Pipeline references multiple volumes through script nodes
  601. #
  602. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  603. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  604. assert result.exit_code == 0
  605. result_json = json.loads(result.output)
  606. assert result_json["generic_node_count"] == 3
  607. assert result_json["custom_node_count"] == 0
  608. assert isinstance(result_json.get("dependencies"), dict)
  609. dependencies = result_json["dependencies"]
  610. assert len(dependencies["volumes"]) == 2
  611. assert "pvc-claim-2" in dependencies["volumes"], dependencies["volumes"]
  612. assert "pvc-claim-3" in dependencies["volumes"], dependencies["volumes"]
  613. #
  614. # Pipeline references multiple volumes through notebook and script nodes:
  615. #
  616. pipeline_file_path = (
  617. Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks_and_scripts.pipeline"
  618. )
  619. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  620. assert result.exit_code == 0
  621. result_json = json.loads(result.output)
  622. assert result_json["generic_node_count"] == 5
  623. assert result_json["custom_node_count"] == 0
  624. assert isinstance(result_json.get("dependencies"), dict)
  625. dependencies = result_json["dependencies"]
  626. assert len(dependencies["volumes"]) == 3
  627. assert "pvc-claim-1" in dependencies["volumes"], dependencies["volumes"]
  628. assert "pvc-claim-2" in dependencies["volumes"], dependencies["volumes"]
  629. assert "pvc-claim-3" in dependencies["volumes"], dependencies["volumes"]
  630. def test_describe_kubernetes_secrets_report():
  631. """
  632. Test report format output for the 'kubernetes_secrets' dependency property
  633. """
  634. runner = CliRunner()
  635. #
  636. # Pipeline references no Kubernetes secrets
  637. # - Pipeline does not contain nodes -> test_describe_with_no_nodes
  638. # - Pipeline contains only custom components
  639. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  640. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  641. assert result.exit_code == 0
  642. assert "Kubernetes secret dependencies: None specified" in result.output
  643. #
  644. # Pipeline references multiple Kubernetes secrets through notebook nodes:
  645. #
  646. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  647. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  648. assert result.exit_code == 0
  649. assert "Kubernetes secret dependencies:\n" in result.output
  650. assert "- secret-1" in result.output, result.output
  651. #
  652. # Pipeline references multiple Kubernetes secrets through script nodes
  653. #
  654. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  655. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  656. assert result.exit_code == 0
  657. assert "Kubernetes secret dependencies:\n" in result.output
  658. assert "- secret-2" in result.output, result.output
  659. #
  660. # Pipeline references multiple multiple Kubernetes secrets
  661. #
  662. pipeline_file_path = (
  663. Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks_and_scripts.pipeline"
  664. )
  665. result = runner.invoke(pipeline, ["describe", str(pipeline_file_path)])
  666. assert result.exit_code == 0
  667. assert "Kubernetes secret dependencies:\n" in result.output
  668. assert "- secret-1" in result.output, result.output
  669. assert "- secret-2" in result.output, result.output
  670. assert "- secret-3" in result.output, result.output
  671. def test_describe_kubernetes_secrets_json():
  672. """
  673. Test JSON output for the 'kubernetes_secrets' dependency property
  674. """
  675. runner = CliRunner()
  676. #
  677. # Pipeline references no Kubernetes secrets
  678. # - Pipeline does not contain nodes -> test_describe_with_no_nodes
  679. # - Pipeline contains only custom components
  680. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  681. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  682. assert result.exit_code == 0
  683. result_json = json.loads(result.output)
  684. assert result_json["generic_node_count"] == 0
  685. assert result_json["custom_node_count"] == 3
  686. assert isinstance(result_json.get("dependencies"), dict)
  687. dependencies = result_json["dependencies"]
  688. assert isinstance(dependencies["kubernetes_secrets"], list)
  689. assert len(dependencies["kubernetes_secrets"]) == 0
  690. #
  691. # Pipeline references one Kubernetes secret through notebook nodes
  692. #
  693. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks.pipeline"
  694. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  695. assert result.exit_code == 0
  696. result_json = json.loads(result.output)
  697. assert result_json["generic_node_count"] == 2
  698. assert result_json["custom_node_count"] == 0
  699. assert isinstance(result_json.get("dependencies"), dict)
  700. dependencies = result_json["dependencies"]
  701. assert isinstance(dependencies["kubernetes_secrets"], list)
  702. assert len(dependencies["kubernetes_secrets"]) == 1
  703. assert "secret-1" in dependencies["kubernetes_secrets"], dependencies["kubernetes_secrets"]
  704. #
  705. # Pipeline references one Kubernetes secret through script nodes
  706. #
  707. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_scripts.pipeline"
  708. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  709. assert result.exit_code == 0
  710. result_json = json.loads(result.output)
  711. assert result_json["generic_node_count"] == 3
  712. assert result_json["custom_node_count"] == 0
  713. assert isinstance(result_json.get("dependencies"), dict)
  714. dependencies = result_json["dependencies"]
  715. assert isinstance(dependencies["kubernetes_secrets"], list)
  716. assert len(dependencies["kubernetes_secrets"]) == 1
  717. assert "secret-2" in dependencies["kubernetes_secrets"], dependencies["kubernetes_secrets"]
  718. #
  719. # Pipeline references multiple Kubernetes secrets
  720. #
  721. pipeline_file_path = (
  722. Path(__file__).parent / "resources" / "pipelines" / "pipeline_with_notebooks_and_scripts.pipeline"
  723. )
  724. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  725. assert result.exit_code == 0
  726. result_json = json.loads(result.output)
  727. assert result_json["generic_node_count"] == 5
  728. assert result_json["custom_node_count"] == 0
  729. assert isinstance(result_json.get("dependencies"), dict)
  730. dependencies = result_json["dependencies"]
  731. assert isinstance(dependencies["kubernetes_secrets"], list)
  732. assert len(dependencies["kubernetes_secrets"]) == 3
  733. assert "secret-1" in dependencies["kubernetes_secrets"], dependencies["kubernetes_secrets"]
  734. assert "secret-2" in dependencies["kubernetes_secrets"], dependencies["kubernetes_secrets"]
  735. assert "secret-3" in dependencies["kubernetes_secrets"], dependencies["kubernetes_secrets"]
  736. def test_describe_custom_component_dependencies_json():
  737. """
  738. Test JSON output for the 'custom_components' dependency property
  739. """
  740. runner = CliRunner()
  741. #
  742. # - Pipeline contains only custom components
  743. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  744. result = runner.invoke(pipeline, ["describe", "--json", str(pipeline_file_path)])
  745. assert result.exit_code == 0
  746. result_json = json.loads(result.output)
  747. assert result_json["generic_node_count"] == 0
  748. assert result_json["custom_node_count"] == 3
  749. assert isinstance(result_json.get("dependencies"), dict)
  750. dependencies = result_json["dependencies"]
  751. assert isinstance(dependencies["custom_components"], list)
  752. assert len(dependencies["custom_components"]) == 3
  753. assert dependencies["custom_components"][0]["catalog_type"] == "elyra-kfp-examples-catalog"
  754. assert dependencies["custom_components"][1]["catalog_type"] == "elyra-kfp-examples-catalog"
  755. assert dependencies["custom_components"][2]["catalog_type"] == "elyra-kfp-examples-catalog"
  756. expected_component_ids = ["download_data.yaml", "filter_text_using_shell_and_grep.yaml", "calculate_hash.yaml"]
  757. assert dependencies["custom_components"][0]["component_ref"]["component-id"] in expected_component_ids
  758. expected_component_ids.remove(dependencies["custom_components"][0]["component_ref"]["component-id"])
  759. assert dependencies["custom_components"][1]["component_ref"]["component-id"] in expected_component_ids
  760. expected_component_ids.remove(dependencies["custom_components"][1]["component_ref"]["component-id"])
  761. assert dependencies["custom_components"][2]["component_ref"]["component-id"] in expected_component_ids
  762. expected_component_ids.remove(dependencies["custom_components"][2]["component_ref"]["component-id"])
  763. # ------------------------------------------------------------------
  764. # end tests for 'describe' command
  765. # ------------------------------------------------------------------
  766. # tests for 'validate' command
  767. # ------------------------------------------------------------------
  768. @pytest.mark.parametrize("catalog_instance", [KFP_COMPONENT_CACHE_INSTANCE], indirect=True)
  769. def test_validate_with_kfp_components(jp_environ, kubeflow_pipelines_runtime_instance, catalog_instance):
  770. runner = CliRunner()
  771. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  772. result = runner.invoke(
  773. pipeline, ["validate", str(pipeline_file_path), "--runtime-config", kubeflow_pipelines_runtime_instance]
  774. )
  775. assert "Validating pipeline..." in result.output
  776. assert result.exit_code == 0, result.output
  777. def test_validate_with_missing_kfp_component(jp_environ, kubeflow_pipelines_runtime_instance):
  778. runner = CliRunner()
  779. with runner.isolated_filesystem():
  780. valid_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  781. pipeline_file_path = Path.cwd() / "foo.pipeline"
  782. with open(pipeline_file_path, "w") as pipeline_file:
  783. with open(valid_file_path) as valid_file:
  784. valid_data = json.load(valid_file)
  785. # Update known component name to trigger a missing component
  786. valid_data["pipelines"][0]["nodes"][0]["op"] = valid_data["pipelines"][0]["nodes"][0]["op"] + "Missing"
  787. pipeline_file.write(json.dumps(valid_data))
  788. result = runner.invoke(
  789. pipeline, ["validate", str(pipeline_file_path), "--runtime-config", kubeflow_pipelines_runtime_instance]
  790. )
  791. assert "Validating pipeline..." in result.output
  792. assert "[Error][Calculate data hash] - This component was not found in the catalog." in result.output
  793. assert result.exit_code != 0
  794. def test_validate_with_no_runtime_config(jp_environ):
  795. runner = CliRunner()
  796. with runner.isolated_filesystem():
  797. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  798. result = runner.invoke(pipeline, ["validate", str(pipeline_file_path)])
  799. assert "Validating pipeline..." in result.output
  800. assert (
  801. "[Error] - This pipeline contains at least one runtime-specific component, "
  802. "but pipeline runtime is 'local'" in result.output
  803. )
  804. assert result.exit_code != 0
  805. # ------------------------------------------------------------------
  806. # tests for 'submit' command
  807. # ------------------------------------------------------------------
  808. def test_submit_invalid_monitor_interval_option(kubeflow_pipelines_runtime_instance):
  809. """Verify that the '--monitor-timeout' option works as expected"""
  810. runner = CliRunner()
  811. with runner.isolated_filesystem():
  812. # dummy pipeline - it's not used
  813. pipeline_file_path = Path(__file__).parent / "resources" / "pipelines" / "kfp_3_node_custom.pipeline"
  814. assert pipeline_file_path.is_file()
  815. # this should fail: '--monitor-timeout' must be an integer
  816. invalid_option_value = "abc"
  817. result = runner.invoke(
  818. pipeline,
  819. [
  820. "submit",
  821. str(pipeline_file_path),
  822. "--runtime-config",
  823. kubeflow_pipelines_runtime_instance,
  824. "--monitor-timeout",
  825. invalid_option_value,
  826. ],
  827. )
  828. assert result.exit_code != 0
  829. assert (
  830. f"Invalid value for '--monitor-timeout': '{invalid_option_value}' is not "
  831. "a valid integer" in result.output
  832. )
  833. # this should fail: '--monitor-timeout' must be a positive integer
  834. invalid_option_value = 0
  835. result = runner.invoke(
  836. pipeline,
  837. [
  838. "submit",
  839. str(pipeline_file_path),
  840. "--runtime-config",
  841. kubeflow_pipelines_runtime_instance,
  842. "--monitor-timeout",
  843. invalid_option_value,
  844. ],
  845. )
  846. assert result.exit_code != 0
  847. assert (
  848. f"Invalid value for '--monitor-timeout': '{invalid_option_value}' is not "
  849. "a positive integer" in result.output
  850. )
  851. # ------------------------------------------------------------------
  852. # end tests for 'submit' command
  853. # ------------------------------------------------------------------
  854. # tests for 'export' command
  855. # ------------------------------------------------------------------
  856. def do_mock_export(output_path: str, dir_only=False):
  857. # simulate export result
  858. p = Path(output_path)
  859. # create parent directories, if required
  860. if not p.parent.is_dir():
  861. p.parent.mkdir(parents=True, exist_ok=True)
  862. if dir_only:
  863. return
  864. # create a mock export file
  865. with open(output_path, "w") as output:
  866. output.write("dummy export output")
  867. def prepare_export_work_dir(work_dir: str, source_dir: str):
  868. """Copies the files in source_dir to work_dir"""
  869. for file in Path(source_dir).glob("*pipeline"):
  870. shutil.copy(str(file), work_dir)
  871. # print for debug purposes; this is only displayed if an assert fails
  872. print(f"Work directory content: {list(Path(work_dir).glob('*'))}")
  873. def test_export_invalid_runtime_config():
  874. """Test user error scenarios: the specified runtime configuration is 'invalid'"""
  875. runner = CliRunner()
  876. # test pipeline; it's not used in this test
  877. pipeline_file = "kubeflow_pipelines.pipeline"
  878. p = Path(__file__).parent / "resources" / "pipelines" / f"{pipeline_file}"
  879. assert p.is_file()
  880. # no runtime configuration was specified
  881. result = runner.invoke(pipeline, ["export", str(p)])
  882. assert result.exit_code != 0, result.output
  883. assert "Error: Missing option '--runtime-config'." in result.output, result.output
  884. # runtime configuration does not exist
  885. config_name = "no-such-config"
  886. result = runner.invoke(pipeline, ["export", str(p), "--runtime-config", config_name])
  887. assert result.exit_code != 0, result.output
  888. assert f"Error: Invalid runtime configuration: {config_name}" in result.output
  889. assert f"No such instance named '{config_name}' was found in the runtimes schemaspace." in result.output
  890. def test_export_incompatible_runtime_config(kubeflow_pipelines_runtime_instance, airflow_runtime_instance):
  891. """
  892. Test user error scenarios: the specified runtime configuration is not compatible
  893. with the pipeline type, e.g. KFP pipeline with Airflow runtime config
  894. """
  895. runner = CliRunner()
  896. # try exporting a KFP pipeline using an Airflow runtime configuration
  897. pipeline_file = "kubeflow_pipelines.pipeline"
  898. p = Path(__file__).parent / "resources" / "pipelines" / f"{pipeline_file}"
  899. assert p.is_file()
  900. # try export using Airflow runtime configuration
  901. result = runner.invoke(pipeline, ["export", str(p), "--runtime-config", airflow_runtime_instance])
  902. assert result.exit_code != 0, result.output
  903. assert (
  904. "The runtime configuration type 'APACHE_AIRFLOW' does not "
  905. "match the pipeline's runtime type 'KUBEFLOW_PIPELINES'." in result.output
  906. )
  907. # try exporting an Airflow pipeline using a Kubeflow Pipelines runtime configuration
  908. pipeline_file = "airflow.pipeline"
  909. p = Path(__file__).parent / "resources" / "pipelines" / f"{pipeline_file}"
  910. assert p.is_file()
  911. # try export using KFP runtime configuration
  912. result = runner.invoke(pipeline, ["export", str(p), "--runtime-config", kubeflow_pipelines_runtime_instance])
  913. assert result.exit_code != 0, result.output
  914. assert (
  915. "The runtime configuration type 'KUBEFLOW_PIPELINES' does not "
  916. "match the pipeline's runtime type 'APACHE_AIRFLOW'." in result.output
  917. )
  918. @pytest.mark.parametrize("catalog_instance", [KFP_COMPONENT_CACHE_INSTANCE], indirect=True)
  919. def test_export_kubeflow_output_option(jp_environ, kubeflow_pipelines_runtime_instance, catalog_instance):
  920. """Verify that the '--output' option works as expected for Kubeflow Pipelines"""
  921. runner = CliRunner()
  922. with runner.isolated_filesystem():
  923. cwd = Path.cwd().resolve()
  924. # copy pipeline file and depencencies
  925. prepare_export_work_dir(str(cwd), Path(__file__).parent / "resources" / "pipelines")
  926. pipeline_file = "kfp_3_node_custom.pipeline"
  927. pipeline_file_path = cwd / pipeline_file
  928. # make sure the pipeline file exists
  929. assert pipeline_file_path.is_file() is True
  930. print(f"Pipeline file: {pipeline_file_path}")
  931. # Test: '--output' not specified; exported file is created
  932. # in current directory and named like the pipeline file with
  933. # a '.yaml' suffix
  934. expected_output_file = pipeline_file_path.with_suffix(".yaml")
  935. # this should succeed
  936. result = runner.invoke(
  937. pipeline, ["export", str(pipeline_file_path), "--runtime-config", kubeflow_pipelines_runtime_instance]
  938. )
  939. assert result.exit_code == 0, result.output
  940. assert f"was exported to '{str(expected_output_file)}" in result.output, result.output
  941. # Test: '--output' specified and ends with '.yaml'
  942. expected_output_file = cwd / "test-dir" / "output.yaml"
  943. # this should succeed
  944. result = runner.invoke(
  945. pipeline,
  946. [
  947. "export",
  948. str(pipeline_file_path),
  949. "--runtime-config",
  950. kubeflow_pipelines_runtime_instance,
  951. "--output",
  952. str(expected_output_file),
  953. ],
  954. )
  955. assert result.exit_code == 0, result.output
  956. assert f"was exported to '{str(expected_output_file)}" in result.output, result.output
  957. # Test: '--output' specified and ends with '.yml'
  958. expected_output_file = cwd / "test-dir-2" / "output.yml"
  959. # this should succeed
  960. result = runner.invoke(
  961. pipeline,
  962. [
  963. "export",
  964. str(pipeline_file_path),
  965. "--runtime-config",
  966. kubeflow_pipelines_runtime_instance,
  967. "--output",
  968. str(expected_output_file),
  969. ],
  970. )
  971. assert result.exit_code == 0, result.output
  972. assert f"was exported to '{str(expected_output_file)}" in result.output, result.output
  973. def test_export_airflow_output_option(airflow_runtime_instance):
  974. """Verify that the '--output' option works as expected for Airflow"""
  975. runner = CliRunner()
  976. with runner.isolated_filesystem():
  977. cwd = Path.cwd().resolve()
  978. # copy pipeline file and depencencies
  979. prepare_export_work_dir(str(cwd), Path(__file__).parent / "resources" / "pipelines")
  980. pipeline_file = "airflow.pipeline"
  981. pipeline_file_path = cwd / pipeline_file
  982. # make sure the pipeline file exists
  983. assert pipeline_file_path.is_file() is True
  984. print(f"Pipeline file: {pipeline_file_path}")
  985. #
  986. # Test: '--output' not specified; exported file is created
  987. # in current directory and named like the pipeline file with
  988. # a '.py' suffix
  989. #
  990. expected_output_file = pipeline_file_path.with_suffix(".py")
  991. print(f"expected_output_file -> {expected_output_file}")
  992. do_mock_export(str(expected_output_file))
  993. # this should fail: default output file already exists
  994. result = runner.invoke(
  995. pipeline, ["export", str(pipeline_file_path), "--runtime-config", airflow_runtime_instance]
  996. )
  997. assert result.exit_code != 0, result.output
  998. assert (
  999. f"Error: Output file '{expected_output_file}' exists and option '--overwrite' "
  1000. "was not specified." in result.output
  1001. ), result.output
  1002. #
  1003. # Test: '--output' specified and ends with '.py' (the value is treated
  1004. # as a file name)
  1005. #
  1006. expected_output_file = cwd / "test-dir-2" / "output.py"
  1007. do_mock_export(str(expected_output_file))
  1008. # this should fail: specified output file already exists
  1009. result = runner.invoke(
  1010. pipeline,
  1011. [
  1012. "export",
  1013. str(pipeline_file_path),
  1014. "--runtime-config",
  1015. airflow_runtime_instance,
  1016. "--output",
  1017. str(expected_output_file),
  1018. ],
  1019. )
  1020. assert result.exit_code != 0, result.output
  1021. assert (
  1022. f"Error: Output file '{expected_output_file}' exists and option '--overwrite' "
  1023. "was not specified." in result.output
  1024. ), result.output
  1025. #
  1026. # Test: '--output' specified and does not end with '.py' (the value
  1027. # is treated as a directory)
  1028. #
  1029. output_dir = cwd / "test-dir-3"
  1030. expected_output_file = output_dir / Path(pipeline_file).with_suffix(".py")
  1031. do_mock_export(str(expected_output_file))
  1032. # this should fail: specified output file already exists
  1033. result = runner.invoke(
  1034. pipeline,
  1035. [
  1036. "export",
  1037. str(pipeline_file_path),
  1038. "--runtime-config",
  1039. airflow_runtime_instance,
  1040. "--output",
  1041. str(output_dir),
  1042. ],
  1043. )
  1044. assert result.exit_code != 0, result.output
  1045. assert (
  1046. f"Error: Output file '{expected_output_file}' exists and option '--overwrite' "
  1047. "was not specified." in result.output
  1048. ), result.output
  1049. @pytest.mark.parametrize("catalog_instance", [KFP_COMPONENT_CACHE_INSTANCE], indirect=True)
  1050. def test_export_kubeflow_overwrite_option(jp_environ, kubeflow_pipelines_runtime_instance, catalog_instance):
  1051. """Verify that the '--overwrite' option works as expected for Kubeflow Pipelines"""
  1052. runner = CliRunner()
  1053. with runner.isolated_filesystem():
  1054. cwd = Path.cwd().resolve()
  1055. # copy pipeline file and depencencies
  1056. prepare_export_work_dir(str(cwd), Path(__file__).parent / "resources" / "pipelines")
  1057. pipeline_file = "kfp_3_node_custom.pipeline"
  1058. pipeline_file_path = cwd / pipeline_file
  1059. # make sure the pipeline file exists
  1060. assert pipeline_file_path.is_file() is True
  1061. print(f"Pipeline file: {pipeline_file_path}")
  1062. # Test: '--overwrite' not specified; exported file is created
  1063. # in current directory and named like the pipeline file with
  1064. # a '.yaml' suffix
  1065. expected_output_file = pipeline_file_path.with_suffix(".yaml")
  1066. # this should succeed
  1067. result = runner.invoke(
  1068. pipeline, ["export", str(pipeline_file_path), "--runtime-config", kubeflow_pipelines_runtime_instance]
  1069. )
  1070. assert result.exit_code == 0, result.output
  1071. assert f"was exported to '{str(expected_output_file)}" in result.output, result.output
  1072. # Test: '--overwrite' not specified; the output already exists
  1073. # this should fail
  1074. result = runner.invoke(
  1075. pipeline, ["export", str(pipeline_file_path), "--runtime-config", kubeflow_pipelines_runtime_instance]
  1076. )
  1077. assert result.exit_code != 0, result.output
  1078. assert f"Output file '{expected_output_file}' exists and option '--overwrite' was not" in result.output
  1079. # Test: '--overwrite' specified; exported file is created
  1080. # in current directory and named like the pipeline file with
  1081. # a '.yaml' suffix
  1082. # this should succeed
  1083. result = runner.invoke(
  1084. pipeline,
  1085. ["export", str(pipeline_file_path), "--runtime-config", kubeflow_pipelines_runtime_instance, "--overwrite"],
  1086. )
  1087. assert result.exit_code == 0, result.output
  1088. assert f"was exported to '{str(expected_output_file)}" in result.output, result.output
  1089. # ------------------------------------------------------------------
  1090. # end tests for 'export' command
  1091. # ------------------------------------------------------------------