Skip to content

Commit e41c23a

Browse files
authored
Merge pull request #31 from chipfoundry/fix/push-integrity-upload
Make SFTP project uploads atomic
2 parents eef6bc5 + aa39095 commit e41c23a

4 files changed

Lines changed: 105 additions & 21 deletions

File tree

chipfoundry_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""ChipFoundry CLI package: Automate project submission to SFTP."""
2-
__version__ = "2.5.3"
2+
__version__ = "2.5.4"

chipfoundry_cli/main.py

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,6 +1735,39 @@ def _sha256_file(path: Path) -> str:
17351735
return h.hexdigest()
17361736

17371737

1738+
def _ordered_sftp_uploads(upload_map: dict) -> list:
1739+
"""Return payload files first and project.json last as the commit marker."""
1740+
config_path = upload_map.get(".cf/project.json")
1741+
if not config_path:
1742+
raise ValueError("SFTP push requires .cf/project.json")
1743+
payloads = [
1744+
(rel_path, local_path)
1745+
for rel_path, local_path in upload_map.items()
1746+
if rel_path != ".cf/project.json" and local_path
1747+
]
1748+
payloads.append((".cf/project.json", config_path))
1749+
return payloads
1750+
1751+
1752+
def _prepared_wrapper_hash(project_json_path: str) -> str:
1753+
with open(project_json_path, "r") as f:
1754+
data = json.load(f)
1755+
value = str((data.get("project") or {}).get("user_project_wrapper_hash") or "").strip()
1756+
if not value:
1757+
raise ValueError("project.json is missing project.user_project_wrapper_hash")
1758+
return value
1759+
1760+
1761+
def _assert_wrapper_hash_unchanged(wrapper_path: str, expected_hash: str) -> None:
1762+
current_hash = _sha256_file(Path(wrapper_path))
1763+
if current_hash != expected_hash:
1764+
raise RuntimeError(
1765+
"Wrapper GDS changed while it was being uploaded. "
1766+
"project.json was not uploaded; stop any process modifying the GDS "
1767+
"and run cf push --force-overwrite again."
1768+
)
1769+
1770+
17381771
def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, str]]:
17391772
"""Return [(rel_path, abs_path, size, kind)] for files the platform
17401773
accepts via --https.
@@ -2111,17 +2144,22 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
21112144
cf_dir = ensure_cf_directory(project_root)
21122145

21132146
# Find the GDS file path for hash calculation
2114-
gds_path = None
2115-
for gds_key, gds_path in collected.items():
2147+
wrapper_path = None
2148+
for gds_key, local_path in collected.items():
21162149
if gds_key.startswith("gds/"):
2150+
wrapper_path = local_path
21172151
break
2152+
if not wrapper_path:
2153+
console.print("[red]No wrapper GDS found in collected project files.[/red]")
2154+
raise click.Abort()
21182155

21192156
project_json_path = update_or_create_project_json(
21202157
cf_dir=str(cf_dir),
2121-
gds_path=gds_path,
2158+
gds_path=wrapper_path,
21222159
cli_overrides=cli_overrides,
21232160
existing_json_path=collected.get(".cf/project.json")
21242161
)
2162+
prepared_wrapper_hash = _prepared_wrapper_hash(project_json_path)
21252163

21262164
# SFTP upload or dry-run
21272165
final_project_name = project_name or (
@@ -2135,16 +2173,14 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
21352173
upload_map["verilog/rtl/user_defines.v"] = collected.get("verilog/rtl/user_defines.v")
21362174

21372175
# Add the appropriate GDS file based on what was collected
2138-
for gds_key, gds_path in collected.items():
2176+
for gds_key, local_path in collected.items():
21392177
if gds_key.startswith("gds/"):
2140-
upload_map[gds_key] = gds_path
2178+
upload_map[gds_key] = local_path
21412179

21422180
if dry_run:
21432181
console.print("[bold]Files to upload:[/bold]")
2144-
for rel_path, local_path in upload_map.items():
2145-
if local_path:
2146-
remote_path = os.path.join(sftp_base, rel_path)
2147-
console.print(f" {os.path.basename(local_path)}{rel_path}")
2182+
for rel_path, local_path in _ordered_sftp_uploads(upload_map):
2183+
console.print(f" {os.path.basename(local_path)}{rel_path}")
21482184
return
21492185

21502186
console.print(f"Connecting to {sftp_host}...")
@@ -2163,15 +2199,16 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
21632199
raise click.Abort()
21642200

21652201
try:
2166-
for rel_path, local_path in upload_map.items():
2167-
if local_path:
2168-
remote_path = os.path.join(sftp_base, rel_path)
2169-
upload_with_progress(
2170-
sftp,
2171-
local_path=local_path,
2172-
remote_path=remote_path,
2173-
force_overwrite=force_overwrite
2174-
)
2202+
for rel_path, local_path in _ordered_sftp_uploads(upload_map):
2203+
if rel_path == ".cf/project.json":
2204+
_assert_wrapper_hash_unchanged(wrapper_path, prepared_wrapper_hash)
2205+
remote_path = os.path.join(sftp_base, rel_path)
2206+
upload_with_progress(
2207+
sftp,
2208+
local_path=local_path,
2209+
remote_path=remote_path,
2210+
force_overwrite=force_overwrite
2211+
)
21752212
console.print(f"[green]✓ Uploaded to {sftp_base}[/green]")
21762213

21772214
except Exception as e:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "chipfoundry-cli"
3-
version = "2.5.3"
3+
version = "2.5.4"
44
description = "CLI tool to automate ChipFoundry project submission to SFTP server"
55
authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
66
readme = "README.md"

tests/test_push_command.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
"""
44
import pytest
55
from click.testing import CliRunner
6-
from chipfoundry_cli.main import main
6+
from chipfoundry_cli.main import (
7+
_assert_wrapper_hash_unchanged,
8+
_ordered_sftp_uploads,
9+
_prepared_wrapper_hash,
10+
main,
11+
)
712
from pathlib import Path
813
import tempfile
914
import shutil
@@ -71,5 +76,47 @@ def test_push_with_all_options(self, temp_project_dir):
7176
assert result.exit_code != 0 or 'dry-run' in result.output.lower()
7277

7378

79+
def test_sftp_uploads_project_json_last():
80+
uploads = _ordered_sftp_uploads({
81+
".cf/project.json": "/tmp/project.json",
82+
"verilog/rtl/user_defines.v": "/tmp/user_defines.v",
83+
"gds/user_project_wrapper.gds": "/tmp/user_project_wrapper.gds",
84+
})
85+
86+
assert [item[0] for item in uploads] == [
87+
"verilog/rtl/user_defines.v",
88+
"gds/user_project_wrapper.gds",
89+
".cf/project.json",
90+
]
91+
92+
93+
def test_sftp_uploads_require_project_json():
94+
with pytest.raises(ValueError, match="requires .cf/project.json"):
95+
_ordered_sftp_uploads({"gds/user_project_wrapper.gds": "/tmp/wrapper.gds"})
96+
97+
98+
def test_prepared_wrapper_hash_is_required(tmp_path):
99+
config = tmp_path / "project.json"
100+
config.write_text('{"project": {}}')
101+
102+
with pytest.raises(ValueError, match="user_project_wrapper_hash"):
103+
_prepared_wrapper_hash(str(config))
104+
105+
106+
def test_prepared_wrapper_hash_reads_project_json(tmp_path):
107+
config = tmp_path / "project.json"
108+
config.write_text('{"project": {"user_project_wrapper_hash": "abc123"}}')
109+
110+
assert _prepared_wrapper_hash(str(config)) == "abc123"
111+
112+
113+
def test_wrapper_hash_change_aborts_before_project_json(tmp_path):
114+
wrapper = tmp_path / "user_project_wrapper.gds"
115+
wrapper.write_bytes(b"new bytes")
116+
117+
with pytest.raises(RuntimeError, match="project.json was not uploaded"):
118+
_assert_wrapper_hash_unchanged(str(wrapper), "stale-hash")
119+
120+
74121
if __name__ == '__main__':
75122
pytest.main([__file__, '-v'])

0 commit comments

Comments
 (0)