diff --git a/.github/workflows/publish-helm-charts.yml b/.github/workflows/publish-helm-charts.yml index 0b42cf52..90942460 100644 --- a/.github/workflows/publish-helm-charts.yml +++ b/.github/workflows/publish-helm-charts.yml @@ -30,7 +30,8 @@ jobs: path: charts/image-loader - name: openhands path: charts/openhands - + # because openhands depends on runtime-api + max-parallel: 1 steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 14782b39..08c56918 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ charts/**/charts **/.DS_Store +__pycache__/ # Files generated by JetBrains IDEs, e.g. IntelliJ IDEA .idea/ diff --git a/scripts/update_openhands_charts/README.md b/scripts/update_openhands_charts/README.md new file mode 100644 index 00000000..ded6ed79 --- /dev/null +++ b/scripts/update_openhands_charts/README.md @@ -0,0 +1,58 @@ +# Description + +Updates the OpenHands and runtime-api helm charts to cut a new enterprise chart release. + +## Prerequisites + +- [uv](https://docs.astral.sh/uv/) must be installed +- A GitHub token with read access to the OpenHands repository + +## Usage + +1. Set the `GITHUB_TOKEN` environment variable: + + ```bash + export GITHUB_TOKEN=your_github_token + ``` + + > Try getting with: `gh auth status --show-token` + +2. Run the script: + + ```bash + ./scripts/update_openhands_charts/update_openhands_charts.py + ``` + + Or using uv directly: + + ```bash + uv run scripts/update_openhands_charts/update_openhands_charts.py + ``` + + > View help for available arguments: `uv run scripts/update_openhands_charts/update_openhands_charts.py --help` + +### DRY RUN mode + +```bash +./scripts/update_openhands_charts/update_openhands_charts.py --dry-run +``` + +Or using uv directly: + +```bash +uv run scripts/update_openhands_charts/update_openhands_charts.py --dry-run +``` + +## Tests + +Run the tests: + +```bash +./scripts/update_openhands_charts/test_update_openhands_charts.py +``` + +Or using uv directly: + +```bash +uv run scripts/update_openhands_charts/test_update_openhands_charts.py +``` diff --git a/scripts/update_openhands_charts/test_update_openhands_charts.py b/scripts/update_openhands_charts/test_update_openhands_charts.py new file mode 100755 index 00000000..f3ffb721 --- /dev/null +++ b/scripts/update_openhands_charts/test_update_openhands_charts.py @@ -0,0 +1,697 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.12" +# dependencies = ["PyGithub", "ruamel.yaml", "requests", "pytest"] +# /// +"""Unit tests for update_openhands_charts.py.""" + +import importlib.util +import tempfile +from pathlib import Path + +import pytest +from ruamel.yaml import YAML + +# Load the script as a module +spec = importlib.util.spec_from_file_location( + "update_openhands_charts", + Path(__file__).parent / "update_openhands_charts.py", +) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +bump_patch_version = module.bump_patch_version +update_openhands_chart = module.update_openhands_chart +update_openhands_values = module.update_openhands_values +update_runtime_api_chart = module.update_runtime_api_chart +update_runtime_api_values = module.update_runtime_api_values +get_short_sha = module.get_short_sha +format_sha_tag = module.format_sha_tag +get_semver_tag_containing_commit = module.get_semver_tag_containing_commit +DeployConfig = module.DeployConfig +SEMVER_PATTERN = module.SEMVER_PATTERN +SHORT_SHA_LENGTH = module.SHORT_SHA_LENGTH +OPENHANDS_REPO_PATH = module.OPENHANDS_REPO_PATH + + +class TestSemverPattern: + """Tests for SEMVER_PATTERN regex.""" + + def test_valid_semver(self): + assert SEMVER_PATTERN.match("1.2.3") + assert SEMVER_PATTERN.match("0.0.0") + assert SEMVER_PATTERN.match("10.20.30") + assert SEMVER_PATTERN.match("123.456.789") + + def test_invalid_semver(self): + assert not SEMVER_PATTERN.match("v1.2.3") + assert not SEMVER_PATTERN.match("1.2") + assert not SEMVER_PATTERN.match("1.2.3.4") + assert not SEMVER_PATTERN.match("1.2.3-beta") + assert not SEMVER_PATTERN.match("1.2.3+build") + assert not SEMVER_PATTERN.match("latest") + assert not SEMVER_PATTERN.match("") + + +class TestGetShortSha: + """Tests for get_short_sha function.""" + + def test_returns_first_seven_chars(self): + assert get_short_sha("abcdefghijklmnop") == "abcdefg" + + def test_full_sha_length(self): + sha = "6ccd42bb2975866f1abc21e635c01d2afbdd1acf" + assert get_short_sha(sha) == "6ccd42b" + + def test_exactly_seven_chars(self): + assert get_short_sha("1234567") == "1234567" + + def test_short_sha_length_constant(self): + assert SHORT_SHA_LENGTH == 7 + + def test_numeric_sha(self): + assert get_short_sha("1234567890abcdef") == "1234567" + + +class TestFormatShaTag: + """Tests for format_sha_tag function.""" + + def test_formats_with_sha_prefix(self): + assert format_sha_tag("abcdefghijklmnop") == "sha-abcdefg" + + def test_full_sha_to_tag(self): + sha = "6ccd42bb2975866f1abc21e635c01d2afbdd1acf" + assert format_sha_tag(sha) == "sha-6ccd42b" + + def test_numeric_sha_to_tag(self): + assert format_sha_tag("1234567890abcdef") == "sha-1234567" + + def test_exactly_seven_chars(self): + assert format_sha_tag("abcdefg") == "sha-abcdefg" + + def test_real_world_sha(self): + # Test with actual SHA from deploy workflow + sha = "743f6256a690efc388af6e960ad8009f5952e721" + assert format_sha_tag(sha) == "sha-743f625" + + +class TestBumpPatchVersion: + """Tests for bump_patch_version function.""" + + def test_bump_simple_version(self): + assert bump_patch_version("1.2.3") == "1.2.4" + + def test_bump_zero_patch(self): + assert bump_patch_version("1.0.0") == "1.0.1" + + def test_bump_high_patch(self): + assert bump_patch_version("1.2.99") == "1.2.100" + + def test_bump_preserves_major_minor(self): + assert bump_patch_version("5.10.15") == "5.10.16" + + +class TestUpdateChart: + """Tests for update_chart function.""" + + @pytest.fixture + def sample_chart_yaml(self): + """Create a sample Chart.yaml content.""" + return """\ +apiVersion: v2 +description: Test chart +name: test-chart +appVersion: 1.0.0 +version: 0.1.0 +maintainers: + - name: test +dependencies: + - name: runtime-api + repository: oci://ghcr.io/all-hands-ai/helm-charts + version: 0.1.10 + condition: runtime-api.enabled + - name: other-dep + version: 1.0.0 +""" + + @pytest.fixture + def temp_chart_file(self, sample_chart_yaml): + """Create a temporary Chart.yaml file.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as f: + f.write(sample_chart_yaml) + f.flush() + yield Path(f.name) + Path(f.name).unlink(missing_ok=True) + + def test_update_app_version(self, temp_chart_file): + """Test that appVersion is updated correctly.""" + update_openhands_chart(temp_chart_file, "2.0.0", None) + + yaml = YAML() + chart_data = yaml.load(temp_chart_file) + assert chart_data["appVersion"] == "2.0.0" + + def test_bump_chart_version(self, temp_chart_file): + """Test that version is bumped correctly.""" + update_openhands_chart(temp_chart_file, "2.0.0", None) + + yaml = YAML() + chart_data = yaml.load(temp_chart_file) + assert chart_data["version"] == "0.1.1" + + def test_update_runtime_api_version(self, temp_chart_file): + """Test that runtime-api dependency version is updated.""" + update_openhands_chart(temp_chart_file, "2.0.0", "0.2.0") + + yaml = YAML() + chart_data = yaml.load(temp_chart_file) + runtime_api_dep = next( + d for d in chart_data["dependencies"] if d["name"] == "runtime-api" + ) + assert runtime_api_dep["version"] == "0.2.0" + + def test_runtime_api_unchanged_when_same_version(self, temp_chart_file, capsys): + """Test that runtime-api is not updated when version is the same.""" + update_openhands_chart(temp_chart_file, "2.0.0", "0.1.10") + + yaml = YAML() + chart_data = yaml.load(temp_chart_file) + runtime_api_dep = next( + d for d in chart_data["dependencies"] if d["name"] == "runtime-api" + ) + assert runtime_api_dep["version"] == "0.1.10" + + captured = capsys.readouterr() + assert "runtime-api version unchanged" in captured.out + + def test_other_dependencies_unchanged(self, temp_chart_file): + """Test that other dependencies are not affected.""" + update_openhands_chart(temp_chart_file, "2.0.0", "0.2.0") + + yaml = YAML() + chart_data = yaml.load(temp_chart_file) + other_dep = next( + d for d in chart_data["dependencies"] if d["name"] == "other-dep" + ) + assert other_dep["version"] == "1.0.0" + + def test_preserves_yaml_structure(self, temp_chart_file): + """Test that YAML structure is preserved.""" + update_openhands_chart(temp_chart_file, "2.0.0", "0.2.0") + + yaml = YAML() + chart_data = yaml.load(temp_chart_file) + + # Verify structure is preserved + assert chart_data["apiVersion"] == "v2" + assert chart_data["description"] == "Test chart" + assert chart_data["name"] == "test-chart" + assert len(chart_data["maintainers"]) == 1 + assert len(chart_data["dependencies"]) == 2 + + +class TestDeployConfig: + """Tests for DeployConfig dataclass.""" + + def test_deploy_config_creation(self): + """Test that DeployConfig can be created with all fields.""" + config = DeployConfig( + openhands_sha="abc1234567890", + openhands_runtime_image_tag="abc1234-nikolaik", + runtime_api_sha="def5678901234", + ) + assert config.openhands_sha == "abc1234567890" + assert config.openhands_runtime_image_tag == "abc1234-nikolaik" + assert config.runtime_api_sha == "def5678901234" + + +class TestUpdateValues: + """Tests for update_values function.""" + + @pytest.fixture + def sample_values_yaml(self): + """Create a sample values.yaml content.""" + return """\ +allowedUsers: null + +image: + repository: ghcr.io/openhands/enterprise-server + tag: sha-oldsha1 + +runtime: + image: + repository: ghcr.io/openhands/runtime + tag: oldsha1234567890-nikolaik + runAsRoot: true + +runtime-api: + enabled: true + replicaCount: 1 + image: + tag: sha-oldsha2 + warmRuntimes: + enabled: true + count: 1 + configs: + - name: default + image: "ghcr.io/openhands/runtime:oldsha1234567890-nikolaik" + working_dir: "/openhands/code/" +""" + + @pytest.fixture + def temp_values_file(self, sample_values_yaml): + """Create a temporary values.yaml file.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as f: + f.write(sample_values_yaml) + f.flush() + yield Path(f.name) + Path(f.name).unlink(missing_ok=True) + + def test_update_enterprise_server_tag(self, temp_values_file): + """Test that enterprise-server image tag is updated correctly.""" + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + ) + + content = temp_values_file.read_text() + assert "tag: sha-newsha1" in content + + def test_update_runtime_api_tag(self, temp_values_file): + """Test that runtime-api image tag is updated correctly.""" + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + ) + + content = temp_values_file.read_text() + assert "tag: sha-newapi1" in content + + def test_update_runtime_tag(self, temp_values_file): + """Test that runtime image tag is updated correctly.""" + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + ) + + content = temp_values_file.read_text() + assert "tag: newruntime123-nikolaik" in content + + def test_update_warm_runtimes_tag(self, temp_values_file): + """Test that warmRuntimes image tag is updated correctly.""" + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + ) + + content = temp_values_file.read_text() + assert 'image: "ghcr.io/openhands/runtime:newruntime123-nikolaik"' in content + + def test_unchanged_when_same_values(self, temp_values_file, capsys): + """Test messages when values are already up to date.""" + # First update to set the values + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + ) + + # Second update with same values + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + ) + + captured = capsys.readouterr() + assert "enterprise-server image tag unchanged" in captured.out + assert "runtime-api image tag unchanged" in captured.out + assert "runtime image tag unchanged" in captured.out + assert "warmRuntimes image tag unchanged" in captured.out + + def test_short_sha_format(self, temp_values_file): + """Test that SHA is correctly shortened to 7 characters.""" + update_openhands_values( + temp_values_file, + openhands_sha="abcdefghijklmnop", # 16 chars + runtime_api_sha="1234567890abcdef", # 16 chars + runtime_image_tag="full-tag-unchanged", + ) + + content = temp_values_file.read_text() + # enterprise-server should have sha-abcdefg (7 chars) + assert "tag: sha-abcdefg" in content + # runtime-api should have sha-1234567 (7 chars) + assert "tag: sha-1234567" in content + + def test_preserves_other_content(self, temp_values_file): + """Test that other content in values.yaml is preserved.""" + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + ) + + content = temp_values_file.read_text() + assert "allowedUsers: null" in content + assert "runAsRoot: true" in content + assert "replicaCount: 1" in content + assert 'working_dir: "/openhands/code/"' in content + + +class TestDryRun: + """Tests for dry-run functionality.""" + + @pytest.fixture + def sample_chart_yaml(self): + """Create a sample Chart.yaml content.""" + return """\ +apiVersion: v2 +description: Test chart +name: test-chart +appVersion: 1.0.0 +version: 0.1.0 +dependencies: + - name: runtime-api + version: 0.1.10 +""" + + @pytest.fixture + def sample_values_yaml(self): + """Create a sample values.yaml content.""" + return """\ +image: + repository: ghcr.io/openhands/enterprise-server + tag: sha-oldsha1 + +runtime: + image: + repository: ghcr.io/openhands/runtime + tag: oldsha1234567890-nikolaik + +runtime-api: + enabled: true + image: + tag: sha-oldsha2 + warmRuntimes: + configs: + - name: default + image: "ghcr.io/openhands/runtime:oldsha1234567890-nikolaik" +""" + + @pytest.fixture + def temp_chart_file(self, sample_chart_yaml): + """Create a temporary Chart.yaml file.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as f: + f.write(sample_chart_yaml) + f.flush() + yield Path(f.name) + Path(f.name).unlink(missing_ok=True) + + @pytest.fixture + def temp_values_file(self, sample_values_yaml): + """Create a temporary values.yaml file.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as f: + f.write(sample_values_yaml) + f.flush() + yield Path(f.name) + Path(f.name).unlink(missing_ok=True) + + def test_update_chart_dry_run_no_file_changes(self, temp_chart_file): + """Test that dry-run doesn't modify Chart.yaml.""" + original_content = temp_chart_file.read_text() + + update_openhands_chart(temp_chart_file, "2.0.0", "0.2.0", dry_run=True) + + assert temp_chart_file.read_text() == original_content + + def test_update_chart_dry_run_prints_changes(self, temp_chart_file, capsys): + """Test that dry-run still prints what would be changed.""" + update_openhands_chart(temp_chart_file, "2.0.0", "0.2.0", dry_run=True) + + captured = capsys.readouterr() + assert "Updated appVersion: 1.0.0 -> 2.0.0" in captured.out + assert "Updated version: 0.1.0 -> 0.1.1" in captured.out + assert "Updated runtime-api version: 0.1.10 -> 0.2.0" in captured.out + + def test_update_values_dry_run_no_file_changes(self, temp_values_file): + """Test that dry-run doesn't modify values.yaml.""" + original_content = temp_values_file.read_text() + + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + dry_run=True, + ) + + assert temp_values_file.read_text() == original_content + + def test_update_values_dry_run_prints_changes(self, temp_values_file, capsys): + """Test that dry-run still prints what would be changed.""" + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + dry_run=True, + ) + + captured = capsys.readouterr() + assert "Updated enterprise-server image tag:" in captured.out + assert "Updated runtime-api image tag:" in captured.out + assert "Updated runtime image tag:" in captured.out + assert "Updated warmRuntimes image tag:" in captured.out + + def test_update_chart_without_dry_run_modifies_file(self, temp_chart_file): + """Test that without dry-run, Chart.yaml is modified.""" + original_content = temp_chart_file.read_text() + + update_openhands_chart(temp_chart_file, "2.0.0", "0.2.0", dry_run=False) + + assert temp_chart_file.read_text() != original_content + + def test_update_values_without_dry_run_modifies_file(self, temp_values_file): + """Test that without dry-run, values.yaml is modified.""" + original_content = temp_values_file.read_text() + + update_openhands_values( + temp_values_file, + openhands_sha="newsha1234567890", + runtime_api_sha="newapi1234567890", + runtime_image_tag="newruntime123-nikolaik", + dry_run=False, + ) + + assert temp_values_file.read_text() != original_content + + +class TestUpdateRuntimeApiChart: + """Tests for update_runtime_api_chart function.""" + + @pytest.fixture + def sample_runtime_api_chart_yaml(self): + """Create a sample runtime-api Chart.yaml content.""" + return """\ +apiVersion: v2 +name: runtime-api +description: A Helm chart for the Flask application +version: 0.1.20 # Change this to trigger a new helm chart version being published +appVersion: "1.0.0" +dependencies: + - name: postgresql + version: 15.x.x + repository: https://charts.bitnami.com/bitnami + condition: postgresql.enabled +""" + + @pytest.fixture + def temp_runtime_api_chart_file(self, sample_runtime_api_chart_yaml): + """Create a temporary runtime-api Chart.yaml file.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as f: + f.write(sample_runtime_api_chart_yaml) + f.flush() + yield Path(f.name) + Path(f.name).unlink(missing_ok=True) + + def test_bump_runtime_api_version(self, temp_runtime_api_chart_file): + """Test that runtime-api chart version is bumped correctly.""" + new_version = update_runtime_api_chart(temp_runtime_api_chart_file) + + yaml = YAML() + chart_data = yaml.load(temp_runtime_api_chart_file) + assert chart_data["version"] == "0.1.21" + assert new_version == "0.1.21" + + def test_preserves_other_fields(self, temp_runtime_api_chart_file): + """Test that other fields are preserved.""" + update_runtime_api_chart(temp_runtime_api_chart_file) + + yaml = YAML() + chart_data = yaml.load(temp_runtime_api_chart_file) + assert chart_data["apiVersion"] == "v2" + assert chart_data["name"] == "runtime-api" + assert chart_data["appVersion"] == "1.0.0" + assert len(chart_data["dependencies"]) == 1 + + def test_dry_run_no_file_changes(self, temp_runtime_api_chart_file): + """Test that dry-run doesn't modify the file.""" + original_content = temp_runtime_api_chart_file.read_text() + + update_runtime_api_chart(temp_runtime_api_chart_file, dry_run=True) + + assert temp_runtime_api_chart_file.read_text() == original_content + + def test_dry_run_returns_new_version(self, temp_runtime_api_chart_file): + """Test that dry-run still returns the new version.""" + new_version = update_runtime_api_chart(temp_runtime_api_chart_file, dry_run=True) + assert new_version == "0.1.21" + + +class TestUpdateRuntimeApiValues: + """Tests for update_runtime_api_values function.""" + + @pytest.fixture + def sample_runtime_api_values_yaml(self): + """Create a sample runtime-api values.yaml content.""" + return """\ +nameOverride: "" +fullnameOverride: "" + +replicaCount: 1 + +image: + repository: ghcr.io/openhands/runtime-api + tag: sha-0c907c9 + pullPolicy: Always + +warmRuntimes: + enabled: false + configMapName: warm-runtimes-config + count: 0 + configs: + - name: default + image: "ghcr.io/openhands/runtime:4ea3e4b1fd850ae07e7b972feb36fca6e789d7eb-nikolaik" + working_dir: "/openhands/code/" + environment: {} +""" + + @pytest.fixture + def temp_runtime_api_values_file(self, sample_runtime_api_values_yaml): + """Create a temporary runtime-api values.yaml file.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as f: + f.write(sample_runtime_api_values_yaml) + f.flush() + yield Path(f.name) + Path(f.name).unlink(missing_ok=True) + + def test_update_image_tag(self, temp_runtime_api_values_file): + """Test that runtime-api image tag is updated correctly.""" + update_runtime_api_values( + temp_runtime_api_values_file, + runtime_api_sha="abc1234567890def", + runtime_image_tag="9d0a19cf8f9b45af4d42eb0534cfb9fab18342f2-nikolaik", + ) + + content = temp_runtime_api_values_file.read_text() + assert "tag: sha-abc1234" in content + + def test_update_warm_runtimes_image(self, temp_runtime_api_values_file): + """Test that warmRuntimes image tag is updated correctly.""" + update_runtime_api_values( + temp_runtime_api_values_file, + runtime_api_sha="abc1234567890def", + runtime_image_tag="9d0a19cf8f9b45af4d42eb0534cfb9fab18342f2-nikolaik", + ) + + content = temp_runtime_api_values_file.read_text() + assert 'image: "ghcr.io/openhands/runtime:9d0a19cf8f9b45af4d42eb0534cfb9fab18342f2-nikolaik"' in content + + def test_unchanged_when_same_value(self, temp_runtime_api_values_file, capsys): + """Test message when value is already up to date.""" + # First update + update_runtime_api_values( + temp_runtime_api_values_file, + runtime_api_sha="abc1234567890def", + runtime_image_tag="newruntime123-nikolaik", + ) + + # Second update with same value + update_runtime_api_values( + temp_runtime_api_values_file, + runtime_api_sha="abc1234567890def", + runtime_image_tag="newruntime123-nikolaik", + ) + + captured = capsys.readouterr() + assert "runtime-api image tag unchanged" in captured.out + assert "runtime-api warmRuntimes image tag unchanged" in captured.out + + def test_preserves_other_content(self, temp_runtime_api_values_file): + """Test that other content is preserved.""" + update_runtime_api_values( + temp_runtime_api_values_file, + runtime_api_sha="abc1234567890def", + runtime_image_tag="newruntime123-nikolaik", + ) + + content = temp_runtime_api_values_file.read_text() + assert "replicaCount: 1" in content + assert 'working_dir: "/openhands/code/"' in content + + def test_dry_run_no_file_changes(self, temp_runtime_api_values_file): + """Test that dry-run doesn't modify the file.""" + original_content = temp_runtime_api_values_file.read_text() + + update_runtime_api_values( + temp_runtime_api_values_file, + runtime_api_sha="abc1234567890def", + runtime_image_tag="newruntime123-nikolaik", + dry_run=True, + ) + + assert temp_runtime_api_values_file.read_text() == original_content + + +class TestGetSemverTagContainingCommit: + """Tests for get_semver_tag_containing_commit function.""" + + def test_returns_none_for_nonexistent_repo(self): + """Test that function returns None for non-existent repository.""" + result = get_semver_tag_containing_commit( + Path("/nonexistent/repo"), "abc1234" + ) + assert result is None + + def test_openhands_repo_path_constant(self): + """Test that OPENHANDS_REPO_PATH is correctly set relative to REPO_ROOT.""" + # The path should be at the parent of REPO_ROOT (OpenHands-Cloud) + assert OPENHANDS_REPO_PATH.name == "OpenHands" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/scripts/update_openhands_charts/update_openhands_charts.py b/scripts/update_openhands_charts/update_openhands_charts.py new file mode 100755 index 00000000..c5b8d3e2 --- /dev/null +++ b/scripts/update_openhands_charts/update_openhands_charts.py @@ -0,0 +1,440 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.12" +# dependencies = ["PyGithub", "ruamel.yaml", "requests"] +# /// +"""Update OpenHands chart script.""" + +import argparse +import base64 +import io +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import requests +from github import Auth, Github +from ruamel.yaml import YAML + +SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") +SHORT_SHA_LENGTH = 7 +SCRIPT_DIR = Path(__file__).parent +REPO_ROOT = SCRIPT_DIR.parent.parent +CHART_PATH = REPO_ROOT / "charts" / "openhands" / "Chart.yaml" +VALUES_PATH = REPO_ROOT / "charts" / "openhands" / "values.yaml" +RUNTIME_API_CHART_PATH = REPO_ROOT / "charts" / "runtime-api" / "Chart.yaml" +RUNTIME_API_VALUES_PATH = REPO_ROOT / "charts" / "runtime-api" / "values.yaml" +OPENHANDS_REPO_PATH = REPO_ROOT.parent / "OpenHands" + + +def get_short_sha(sha: str) -> str: + """Return the first 7 characters of a SHA hash.""" + return sha[:SHORT_SHA_LENGTH] + + +def format_sha_tag(sha: str) -> str: + """Format a SHA hash into a sha-SHORT_SHA tag format.""" + return f"sha-{get_short_sha(sha)}" + + +@dataclass +class DeployConfig: + """Configuration values from the deploy workflow.""" + + openhands_sha: str + openhands_runtime_image_tag: str + runtime_api_sha: str + + +def get_latest_semver_tag(token: str, repo_name: str) -> str | None: + """Fetch the latest semantic version tag (x.y.z) from a GitHub repository.""" + # TODO: use github package or use api but not both in the script + gh = Github(auth=Auth.Token(token)) + try: + repo = gh.get_repo(repo_name) + tags = repo.get_tags() + for tag in tags: + if SEMVER_PATTERN.match(tag.name): + return tag.name + except Exception as e: + print(f"Error fetching tags from {repo_name}: {e}") + return None + + +def get_semver_tag_containing_commit(repo_path: Path, commit_sha: str) -> str | None: + """Get the latest semantic version tag containing a specific commit from a local git repo. + + This function: + 1. Checks out main branch + 2. Runs git pull to fetch the latest updates + 3. Runs git tag --contains to get all tags containing the commit + 4. Filters for semantic version tags and returns the latest one + """ + if not repo_path.exists(): + print(f"Repository not found at {repo_path}") + return None + + try: + # Checkout main branch + subprocess.run( + ["git", "checkout", "main"], + cwd=repo_path, + check=True, + capture_output=True, + ) + + # Pull latest updates + subprocess.run( + ["git", "pull"], + cwd=repo_path, + check=True, + capture_output=True, + ) + + # Get tags containing the commit + result = subprocess.run( + ["git", "tag", "--contains", commit_sha], + cwd=repo_path, + check=True, + capture_output=True, + text=True, + ) + + tags = result.stdout.strip().split("\n") + # Filter for semantic version tags + semver_tags = [t for t in tags if t and SEMVER_PATTERN.match(t)] + + if semver_tags: + # Sort by version number (descending) and return the latest + semver_tags.sort(key=lambda v: list(map(int, v.split("."))), reverse=True) + return semver_tags[0] + + return None + except subprocess.CalledProcessError as e: + print(f"Git command failed: {e}") + return None + except Exception as e: + print(f"Error getting tags containing commit: {e}") + return None + + +def get_deploy_config(token: str, repo_name: str, ref: str | None = None) -> DeployConfig | None: + """Fetch deployment config values from deploy.yaml workflow.""" + headers = {"Authorization": f"Bearer {token}"} + url = f"https://api.github.com/repos/{repo_name}/contents/.github/workflows/deploy.yaml" + if ref: + url += f"?ref={ref}" + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + + content = base64.b64decode(response.json()["content"]).decode("utf-8") + yaml = YAML() + workflow = yaml.load(io.StringIO(content)) + + env = workflow.get("env", {}) + return DeployConfig( + openhands_sha=env.get("OPENHANDS_SHA", ""), + openhands_runtime_image_tag=env.get("OPENHANDS_RUNTIME_IMAGE_TAG", ""), + runtime_api_sha=env.get("RUNTIME_API_SHA", ""), + ) + except Exception as e: + print(f"Error fetching deploy config: {e}") + return None + + +def bump_patch_version(version: str) -> str: + """Bump the patch version of a semantic version string.""" + major, minor, patch = version.split(".") + return f"{major}.{minor}.{int(patch) + 1}" + + +def update_openhands_chart( + chart_path: Path, + new_app_version: str, + new_runtime_api_version: str | None, + dry_run: bool = False, +) -> None: + """Update appVersion, bump patch version, and update runtime-api dependency.""" + yaml = YAML() + yaml.preserve_quotes = True + yaml.indent(mapping=2, sequence=4, offset=2) + + chart_data = yaml.load(chart_path) + + old_app_version = chart_data.get("appVersion") + chart_data["appVersion"] = new_app_version + print(f"Updated appVersion: {old_app_version} -> {new_app_version}") + + old_version = chart_data.get("version") + new_version = bump_patch_version(old_version) + chart_data["version"] = new_version + print(f"Updated version: {old_version} -> {new_version}") + + if new_runtime_api_version: + for dep in chart_data.get("dependencies", []): + if dep.get("name") == "runtime-api": + old_runtime_version = dep.get("version") + if old_runtime_version == new_runtime_api_version: + print( + f"runtime-api version unchanged: {old_runtime_version} (already latest)" + ) + else: + dep["version"] = new_runtime_api_version + print( + f"Updated runtime-api version: {old_runtime_version} -> {new_runtime_api_version}" + ) + break + + if not dry_run: + yaml.dump(chart_data, chart_path) + + +def update_openhands_values( + values_path: Path, + openhands_sha: str, + runtime_api_sha: str, + runtime_image_tag: str, + dry_run: bool = False, +) -> None: + """Update image tags in values.yaml.""" + content = values_path.read_text() + + # Update enterprise-server image tag + enterprise_new_tag = format_sha_tag(openhands_sha) + + enterprise_pattern = r"(image:\s*\n\s*repository:\s*ghcr\.io/openhands/enterprise-server\s*\n\s*tag:\s*)(\S+)" + enterprise_match = re.search(enterprise_pattern, content) + + if enterprise_match: + old_tag = enterprise_match.group(2) + if old_tag == enterprise_new_tag: + print(f"enterprise-server image tag unchanged: {old_tag} (already latest)") + else: + content = re.sub(enterprise_pattern, rf"\g<1>{enterprise_new_tag}", content) + print(f"Updated enterprise-server image tag: {old_tag} -> {enterprise_new_tag}") + else: + print("Could not find enterprise-server image tag in values.yaml") + + # Update runtime-api image tag + runtime_api_new_tag = format_sha_tag(runtime_api_sha) + + runtime_api_pattern = r"(runtime-api:\s*\n(?:.*\n)*?\s*image:\s*\n\s*tag:\s*)(\S+)" + runtime_api_match = re.search(runtime_api_pattern, content) + + if runtime_api_match: + old_tag = runtime_api_match.group(2) + if old_tag == runtime_api_new_tag: + print(f"runtime-api image tag unchanged: {old_tag} (already latest)") + else: + content = re.sub(runtime_api_pattern, rf"\g<1>{runtime_api_new_tag}", content) + print(f"Updated runtime-api image tag: {old_tag} -> {runtime_api_new_tag}") + else: + print("Could not find runtime-api image tag in values.yaml") + + # Update runtime image tag (under runtime.image.tag) + runtime_pattern = r"(runtime:\s*\n\s*image:\s*\n\s*repository:\s*ghcr\.io/openhands/runtime\s*\n\s*tag:\s*)(\S+)" + runtime_match = re.search(runtime_pattern, content) + + if runtime_match: + old_tag = runtime_match.group(2) + if old_tag == runtime_image_tag: + print(f"runtime image tag unchanged: {old_tag} (already latest)") + else: + content = re.sub(runtime_pattern, rf"\g<1>{runtime_image_tag}", content) + print(f"Updated runtime image tag: {old_tag} -> {runtime_image_tag}") + else: + print("Could not find runtime image tag in values.yaml") + + # Update warmRuntimes image (contains full image path with tag) + warm_runtime_pattern = r'(image:\s*"ghcr\.io/openhands/runtime:)([^"]+)"' + warm_runtime_match = re.search(warm_runtime_pattern, content) + + if warm_runtime_match: + old_tag = warm_runtime_match.group(2) + if old_tag == runtime_image_tag: + print(f"warmRuntimes image tag unchanged: {old_tag} (already latest)") + else: + content = re.sub(warm_runtime_pattern, rf'\g<1>{runtime_image_tag}"', content) + print(f"Updated warmRuntimes image tag: {old_tag} -> {runtime_image_tag}") + else: + print("Could not find warmRuntimes image tag in values.yaml") + + if not dry_run: + values_path.write_text(content) + + +def update_runtime_api_chart( + chart_path: Path, + dry_run: bool = False, +) -> str: + """Bump the patch version of the runtime-api chart and return the new version.""" + yaml = YAML() + yaml.preserve_quotes = True + yaml.indent(mapping=2, sequence=4, offset=2) + + chart_data = yaml.load(chart_path) + + old_version = chart_data.get("version") + new_version = bump_patch_version(old_version) + chart_data["version"] = new_version + print(f"Updated runtime-api chart version: {old_version} -> {new_version}") + + if not dry_run: + yaml.dump(chart_data, chart_path) + + return new_version + + +def update_runtime_api_values( + values_path: Path, + runtime_api_sha: str, + runtime_image_tag: str, + dry_run: bool = False, +) -> None: + """Update image tag and warmRuntimes default config image in runtime-api values.yaml.""" + content = values_path.read_text() + + # Update image.tag with sha-SHORT_SHA format + new_image_tag = format_sha_tag(runtime_api_sha) + image_tag_pattern = r'(image:\n\s+repository: ghcr\.io/openhands/runtime-api\n\s+tag: )(sha-[a-f0-9]+)' + image_tag_match = re.search(image_tag_pattern, content) + + if image_tag_match: + old_tag = image_tag_match.group(2) + if old_tag == new_image_tag: + print(f"runtime-api image tag unchanged: {old_tag} (already latest)") + else: + content = re.sub(image_tag_pattern, rf'\g<1>{new_image_tag}', content) + print(f"Updated runtime-api image tag: {old_tag} -> {new_image_tag}") + else: + print("Could not find runtime-api image tag in values.yaml") + + # Update warmRuntimes image (contains full image path with tag) + warm_runtime_pattern = r'(image:\s*"ghcr\.io/openhands/runtime:)([^"]+)"' + warm_runtime_match = re.search(warm_runtime_pattern, content) + + if warm_runtime_match: + old_tag = warm_runtime_match.group(2) + if old_tag == runtime_image_tag: + print(f"runtime-api warmRuntimes image tag unchanged: {old_tag} (already latest)") + else: + content = re.sub(warm_runtime_pattern, rf'\g<1>{runtime_image_tag}"', content) + print(f"Updated runtime-api warmRuntimes image tag: {old_tag} -> {runtime_image_tag}") + else: + print("Could not find warmRuntimes image tag in runtime-api values.yaml") + + if not dry_run: + values_path.write_text(content) + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Update OpenHands and runtime-api charts based on a SaaS deploy." + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be updated without making changes.", + ) + parser.add_argument( + "--deploy-tag", + type=str, + default=None, + help="A tag of deploy repo to use instead of fetching the latest semantic version.", + ) + return parser.parse_args() + + +def main(dry_run: bool = False, deploy_tag: str | None = None) -> None: + if dry_run: + print("=" * 60) + print("DRY RUN MODE - No changes will be made") + print("=" * 60) + print() + + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("Environment variable GITHUB_TOKEN is required. Try getting with: gh auth status --show-token") + return + + print("=" * 60) + print("Fetching latest versions...") + print("=" * 60) + + if deploy_tag: + print(f"Using specified deploy tag: {deploy_tag}") + else: + deploy_tag = get_latest_semver_tag(token, "OpenHands/deploy") + if deploy_tag: + print(f"Latest deploy tag: {deploy_tag}") + else: + print("No deploy semantic version tag found") + return + + # Fetch deploy config from the tagged version + deploy_config = get_deploy_config(token, "OpenHands/deploy", ref=deploy_tag) + if not deploy_config: + print("Could not fetch deploy config") + return + + print(f"Deploy config (from {deploy_tag}):") + print(f" OPENHANDS_SHA: {deploy_config.openhands_sha}") + print(f" OPENHANDS_RUNTIME_IMAGE_TAG: {deploy_config.openhands_runtime_image_tag}") + print(f" RUNTIME_API_SHA: {deploy_config.runtime_api_sha}") + + # Get the semver tag containing the OPENHANDS_SHA from local OpenHands repo + openhands_version = get_semver_tag_containing_commit( + OPENHANDS_REPO_PATH, deploy_config.openhands_sha + ) + if openhands_version: + print(f"OpenHands version (latest tag containing OPENHANDS_SHA): {openhands_version}") + else: + print(f"No semantic version tag found containing commit {deploy_config.openhands_sha[:7]}") + return + + # Update runtime-api chart first to get the new version + print() + print("=" * 60) + print("Updating runtime-api chart...") + print("=" * 60) + + print("Updating runtime-api Chart.yaml...") + runtime_api_version = update_runtime_api_chart(RUNTIME_API_CHART_PATH, dry_run=dry_run) + + print() + print("Updating runtime-api values.yaml...") + update_runtime_api_values( + RUNTIME_API_VALUES_PATH, + deploy_config.runtime_api_sha, + deploy_config.openhands_runtime_image_tag, + dry_run=dry_run, + ) + + # Update openhands chart using the bumped runtime-api version + print() + print("=" * 60) + print("Updating openhands chart...") + print("=" * 60) + + print("Updating openhands Chart.yaml...") + update_openhands_chart(CHART_PATH, openhands_version, runtime_api_version, dry_run=dry_run) + + print() + print("Updating openhands values.yaml...") + update_openhands_values( + VALUES_PATH, + deploy_config.openhands_sha, + deploy_config.runtime_api_sha, + deploy_config.openhands_runtime_image_tag, + dry_run=dry_run, + ) + + +if __name__ == "__main__": + args = parse_args() + main(dry_run=args.dry_run, deploy_tag=args.deploy_tag)