Skip to content

Commit 5b5d404

Browse files
authored
feat: trigger rolling releases on code-only deploys via source fingerprint (#320)
* feat(build): add compute_source_fingerprint for code change detection Computes a SHA-256 fingerprint of user source files during build. Deterministic, content-sensitive, path-sensitive, and order-independent. Used to detect code-only changes that should trigger rolling releases. * feat(build): write source_fingerprint into manifest during build Calls compute_source_fingerprint after manifest builder produces the manifest dict, injecting the hex digest before writing to disk. * feat(deployment): inject source fingerprint into resource env during reconciliation Code-only changes (no resource config diff) previously took the reuse path and never triggered a rolling release. Injecting the source fingerprint computed at build time into each resource's env field means a code change produces a diff in the comparison JSON, routing to the update path and triggering a rolling release. - Inject _FLASH_SOURCE_FINGERPRINT into resource env before comparison - No-op when source_fingerprint absent (backward compatibility) - Three tests: fingerprint changed triggers update, unchanged takes reuse path, missing fingerprint falls back to config-only comparison * fix: address QA feedback on source fingerprint PR - Move hashlib import to module level (consistency with file conventions) - Add comment clarifying intentional local_manifest mutation in reconciliation - Assert injected fingerprint value and user env var preservation in test * fix: address Copilot feedback on fingerprint framing and test isolation - Normalize paths to POSIX form so Windows and Unix builds of the same project produce the same fingerprint - Add length-prefix framing to fingerprint hash input to eliminate theoretical path/content concatenation ambiguity - Restructure test so the fingerprint value is the only diff between local and state manifests, proving the injection drives the update
1 parent f5884f2 commit 5b5d404

4 files changed

Lines changed: 325 additions & 0 deletions

File tree

src/runpod_flash/cli/commands/build.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Flash build command - Package Flash applications for deployment."""
22

33
import ast
4+
import hashlib
45
import importlib.util
56
import json
67
import logging
@@ -37,6 +38,36 @@
3738

3839
console = Console()
3940

41+
42+
def compute_source_fingerprint(project_dir: Path, files: list[Path]) -> str:
43+
"""Compute a SHA-256 fingerprint of project source files.
44+
45+
Produces a deterministic hash that changes if and only if the user's
46+
source files change. Used to detect code-only changes that should
47+
trigger a rolling release even when resource config is unchanged.
48+
49+
Args:
50+
project_dir: Project root for computing relative paths.
51+
files: List of source file paths (from get_file_tree).
52+
53+
Returns:
54+
Hex digest of the SHA-256 hash.
55+
"""
56+
h = hashlib.sha256()
57+
# Normalize to POSIX form so Windows and POSIX builds of the same project
58+
# produce the same fingerprint. Use length-prefix framing between path and
59+
# content to prevent concatenation ambiguity (e.g., rel='a'+content='bc'
60+
# vs rel='ab'+content='c' would otherwise collide).
61+
for f in sorted(files, key=lambda p: p.relative_to(project_dir).as_posix()):
62+
rel_bytes = f.relative_to(project_dir).as_posix().encode("utf-8")
63+
file_bytes = f.read_bytes()
64+
h.update(len(rel_bytes).to_bytes(8, "big"))
65+
h.update(rel_bytes)
66+
h.update(len(file_bytes).to_bytes(8, "big"))
67+
h.update(file_bytes)
68+
return h.hexdigest()
69+
70+
4071
# Constants
4172
# Timeout for pip install operations (large packages like torch can take 5-10 minutes)
4273
PIP_INSTALL_TIMEOUT_SECONDS = 600
@@ -307,6 +338,9 @@ def run_build(
307338
python_version=python_version,
308339
)
309340
manifest = manifest_builder.build()
341+
manifest["source_fingerprint"] = compute_source_fingerprint(
342+
project_dir, files
343+
)
310344
manifest_path = build_dir / "flash_manifest.json"
311345
manifest_path.write_text(json.dumps(manifest, indent=2))
312346

src/runpod_flash/cli/utils/deployment.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,16 @@ async def reconcile_and_provision_resources(
283283
actions = []
284284
manifest_python_version = local_manifest.get("python_version")
285285

286+
# Inject source fingerprint into each resource's env so that code-only
287+
# changes (no resource config diff) still trigger a rolling release.
288+
# The fingerprint is computed during flash build from user source files.
289+
# Mutation intentional: persisted to state manifest via update_build_manifest below.
290+
source_fingerprint = local_manifest.get("source_fingerprint")
291+
if source_fingerprint:
292+
for resource_config in local_manifest.get("resources", {}).values():
293+
env = resource_config.setdefault("env", {})
294+
env["_FLASH_SOURCE_FINGERPRINT"] = source_fingerprint
295+
286296
# Provision new resources
287297
for resource_name in sorted(to_provision):
288298
resource_config = local_manifest["resources"][resource_name]

tests/unit/cli/commands/test_build.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,3 +865,76 @@ def mock_run(cmd, **kwargs):
865865
break
866866
else:
867867
pytest.fail("--python-version not found in pip command")
868+
869+
870+
class TestComputeSourceFingerprint:
871+
"""Tests for compute_source_fingerprint function."""
872+
873+
def test_deterministic_output(self, tmp_path):
874+
"""Same files produce same hash across calls."""
875+
from runpod_flash.cli.commands.build import compute_source_fingerprint
876+
877+
f1 = tmp_path / "app.py"
878+
f1.write_text("print('hello')")
879+
f2 = tmp_path / "utils.py"
880+
f2.write_text("x = 1")
881+
882+
files = [f1, f2]
883+
hash1 = compute_source_fingerprint(tmp_path, files)
884+
hash2 = compute_source_fingerprint(tmp_path, files)
885+
assert hash1 == hash2
886+
assert len(hash1) == 64 # SHA-256 hex digest length
887+
888+
def test_content_sensitive(self, tmp_path):
889+
"""Changing file content changes the hash."""
890+
from runpod_flash.cli.commands.build import compute_source_fingerprint
891+
892+
f1 = tmp_path / "app.py"
893+
f1.write_text("print('hello')")
894+
hash1 = compute_source_fingerprint(tmp_path, [f1])
895+
896+
f1.write_text("print('world')")
897+
hash2 = compute_source_fingerprint(tmp_path, [f1])
898+
assert hash1 != hash2
899+
900+
def test_path_sensitive(self, tmp_path):
901+
"""Renaming a file (same content) changes the hash."""
902+
from runpod_flash.cli.commands.build import compute_source_fingerprint
903+
904+
f1 = tmp_path / "app.py"
905+
f1.write_text("content")
906+
hash1 = compute_source_fingerprint(tmp_path, [f1])
907+
908+
f2 = tmp_path / "main.py"
909+
f2.write_text("content")
910+
hash2 = compute_source_fingerprint(tmp_path, [f2])
911+
assert hash1 != hash2
912+
913+
def test_order_independent(self, tmp_path):
914+
"""Shuffled input order produces same hash (function sorts internally)."""
915+
from runpod_flash.cli.commands.build import compute_source_fingerprint
916+
917+
f1 = tmp_path / "a.py"
918+
f1.write_text("aaa")
919+
f2 = tmp_path / "b.py"
920+
f2.write_text("bbb")
921+
922+
hash1 = compute_source_fingerprint(tmp_path, [f1, f2])
923+
hash2 = compute_source_fingerprint(tmp_path, [f2, f1])
924+
assert hash1 == hash2
925+
926+
def test_empty_file_list(self, tmp_path):
927+
"""Empty file list returns a valid SHA-256 hash."""
928+
from runpod_flash.cli.commands.build import compute_source_fingerprint
929+
930+
result = compute_source_fingerprint(tmp_path, [])
931+
assert len(result) == 64
932+
933+
def test_handles_binary_files(self, tmp_path):
934+
"""Binary files are hashed without error."""
935+
from runpod_flash.cli.commands.build import compute_source_fingerprint
936+
937+
f1 = tmp_path / "data.bin"
938+
f1.write_bytes(b"\x00\x01\x02\xff")
939+
result = compute_source_fingerprint(tmp_path, [f1])
940+
assert len(result) == 64

tests/unit/cli/utils/test_deployment.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,3 +632,211 @@ async def test_reconciliation_ignores_runtime_fields_in_config_comparison(tmp_pa
632632
await reconcile_and_provision_resources(app, "build-123", "dev", local_manifest)
633633

634634
mock_manager.get_or_deploy_resource.assert_not_called()
635+
636+
637+
@pytest.mark.asyncio
638+
async def test_source_fingerprint_injected_into_resource_env(tmp_path):
639+
"""Source fingerprint from manifest is injected into each resource's env.
640+
641+
Both manifests have identical config and env except for the fingerprint
642+
value. This isolates the fingerprint as the sole driver of the update path.
643+
"""
644+
import json
645+
646+
flash_dir = tmp_path / ".flash"
647+
flash_dir.mkdir()
648+
649+
# Local and state manifests are structurally identical except for the
650+
# fingerprint value in env. If any other field differs, the test would
651+
# not prove the injection is what triggered the update.
652+
local_manifest = {
653+
"source_fingerprint": "new_fingerprint_abc",
654+
"resources": {
655+
"worker": {
656+
"resource_type": "LiveServerless",
657+
"config": "same",
658+
"env": {},
659+
},
660+
"lb_endpoint": {
661+
"resource_type": "CpuLiveLoadBalancer",
662+
"config": "same",
663+
"env": {"USER_VAR": "value"},
664+
},
665+
},
666+
"resources_endpoints": {},
667+
}
668+
(flash_dir / "flash_manifest.json").write_text(json.dumps(local_manifest))
669+
670+
state_manifest = {
671+
"resources": {
672+
"worker": {
673+
"resource_type": "LiveServerless",
674+
"config": "same",
675+
"env": {"_FLASH_SOURCE_FINGERPRINT": "old_fingerprint_000"},
676+
},
677+
"lb_endpoint": {
678+
"resource_type": "CpuLiveLoadBalancer",
679+
"config": "same",
680+
"env": {
681+
"USER_VAR": "value",
682+
"_FLASH_SOURCE_FINGERPRINT": "old_fingerprint_000",
683+
},
684+
},
685+
},
686+
"resources_endpoints": {
687+
"worker": "https://worker.api.runpod.ai",
688+
"lb_endpoint": "https://lb.api.runpod.ai",
689+
},
690+
}
691+
692+
app = AsyncMock()
693+
app.get_build_manifest = AsyncMock(return_value=state_manifest)
694+
app.update_build_manifest = AsyncMock()
695+
696+
mock_resource = MagicMock()
697+
mock_resource.endpoint_url = "https://new.api.runpod.ai"
698+
mock_resource.endpoint_id = "new-endpoint-id"
699+
700+
with (
701+
patch("pathlib.Path.cwd", return_value=tmp_path),
702+
patch("runpod_flash.cli.utils.deployment.ResourceManager") as mock_manager_cls,
703+
patch(
704+
"runpod_flash.cli.utils.deployment.create_resource_from_manifest"
705+
) as mock_create_resource,
706+
):
707+
mock_manager = MagicMock()
708+
mock_manager.get_or_deploy_resource = AsyncMock(return_value=mock_resource)
709+
mock_manager_cls.return_value = mock_manager
710+
mock_create_resource.return_value = MagicMock()
711+
712+
await reconcile_and_provision_resources(
713+
app, "build-123", "dev", local_manifest, show_progress=False
714+
)
715+
716+
# Fingerprint is the only diff -> both resources should have been updated
717+
assert mock_manager.get_or_deploy_resource.call_count == 2
718+
719+
# Verify injection overwrote the fingerprint with the new value
720+
worker_env = local_manifest["resources"]["worker"]["env"]
721+
assert worker_env["_FLASH_SOURCE_FINGERPRINT"] == "new_fingerprint_abc"
722+
723+
lb_env = local_manifest["resources"]["lb_endpoint"]["env"]
724+
assert lb_env["_FLASH_SOURCE_FINGERPRINT"] == "new_fingerprint_abc"
725+
# User-defined env vars preserved after injection
726+
assert lb_env["USER_VAR"] == "value"
727+
728+
729+
@pytest.mark.asyncio
730+
async def test_source_fingerprint_unchanged_takes_reuse_path(tmp_path):
731+
"""When source fingerprint matches state, reuse path is taken (no update)."""
732+
import json
733+
734+
flash_dir = tmp_path / ".flash"
735+
flash_dir.mkdir()
736+
737+
local_manifest = {
738+
"source_fingerprint": "same_fingerprint_abc",
739+
"resources": {
740+
"worker": {
741+
"resource_type": "LiveServerless",
742+
"config": "same",
743+
},
744+
},
745+
"resources_endpoints": {},
746+
}
747+
(flash_dir / "flash_manifest.json").write_text(json.dumps(local_manifest))
748+
749+
# State manifest has SAME fingerprint (code unchanged)
750+
state_manifest = {
751+
"resources": {
752+
"worker": {
753+
"resource_type": "LiveServerless",
754+
"config": "same",
755+
"env": {"_FLASH_SOURCE_FINGERPRINT": "same_fingerprint_abc"},
756+
},
757+
},
758+
"resources_endpoints": {
759+
"worker": "https://worker.api.runpod.ai",
760+
},
761+
}
762+
763+
app = AsyncMock()
764+
app.get_build_manifest = AsyncMock(return_value=state_manifest)
765+
app.update_build_manifest = AsyncMock()
766+
767+
with (
768+
patch("pathlib.Path.cwd", return_value=tmp_path),
769+
patch("runpod_flash.cli.utils.deployment.ResourceManager") as mock_manager_cls,
770+
):
771+
mock_manager = MagicMock()
772+
mock_manager.get_or_deploy_resource = AsyncMock()
773+
mock_manager_cls.return_value = mock_manager
774+
775+
await reconcile_and_provision_resources(
776+
app, "build-123", "dev", local_manifest, show_progress=False
777+
)
778+
779+
# Fingerprint unchanged -> reuse path, no provisioning
780+
mock_manager.get_or_deploy_resource.assert_not_called()
781+
782+
# Endpoint info copied from state
783+
assert (
784+
local_manifest["resources_endpoints"]["worker"]
785+
== "https://worker.api.runpod.ai"
786+
)
787+
788+
789+
@pytest.mark.asyncio
790+
async def test_missing_source_fingerprint_backward_compatible(tmp_path):
791+
"""Manifests without source_fingerprint behave as before (reuse when config matches)."""
792+
import json
793+
794+
flash_dir = tmp_path / ".flash"
795+
flash_dir.mkdir()
796+
797+
# No source_fingerprint key -- older flash version
798+
local_manifest = {
799+
"resources": {
800+
"worker": {
801+
"resource_type": "LiveServerless",
802+
"config": "same",
803+
},
804+
},
805+
"resources_endpoints": {},
806+
}
807+
(flash_dir / "flash_manifest.json").write_text(json.dumps(local_manifest))
808+
809+
state_manifest = {
810+
"resources": {
811+
"worker": {
812+
"resource_type": "LiveServerless",
813+
"config": "same",
814+
},
815+
},
816+
"resources_endpoints": {
817+
"worker": "https://worker.api.runpod.ai",
818+
},
819+
}
820+
821+
app = AsyncMock()
822+
app.get_build_manifest = AsyncMock(return_value=state_manifest)
823+
app.update_build_manifest = AsyncMock()
824+
825+
with (
826+
patch("pathlib.Path.cwd", return_value=tmp_path),
827+
patch("runpod_flash.cli.utils.deployment.ResourceManager") as mock_manager_cls,
828+
):
829+
mock_manager = MagicMock()
830+
mock_manager.get_or_deploy_resource = AsyncMock()
831+
mock_manager_cls.return_value = mock_manager
832+
833+
await reconcile_and_provision_resources(
834+
app, "build-123", "dev", local_manifest, show_progress=False
835+
)
836+
837+
# No fingerprint -> no injection -> config matches -> reuse path
838+
mock_manager.get_or_deploy_resource.assert_not_called()
839+
assert (
840+
local_manifest["resources_endpoints"]["worker"]
841+
== "https://worker.api.runpod.ai"
842+
)

0 commit comments

Comments
 (0)