mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-10-03 18:45:35 +00:00
* ci: add optional AWS runners for observability benchmarks Signed-off-by: WenyXu <wenymedia@gmail.com> * ci: restore automatic benchmark disk sizing Signed-off-by: WenyXu <wenymedia@gmail.com> --------- Signed-off-by: WenyXu <wenymedia@gmail.com>
407 lines
22 KiB
Python
407 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2023 Greptime Team
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Offline regression coverage for CI runner selection and lifecycle."""
|
|
|
|
import importlib.util
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
SCRIPTS = Path(__file__).parents[2] / ".github/scripts"
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
|
|
def load(name, filename):
|
|
spec = importlib.util.spec_from_file_location(name, SCRIPTS / filename)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
config = load("ci_config_test", "ci-runner-config.py")
|
|
aws = load("aws_provision_test", "aws-ec2-runner-provision.py")
|
|
teardown = load("aws_teardown_test", "aws-ec2-runner-teardown.py")
|
|
|
|
|
|
def provision_config(**overrides):
|
|
return aws.ProvisionConfig(**({
|
|
"region": "ap-southeast-1", "image_id": "ami-test", "instance_type": "c7i.2xlarge",
|
|
"subnet_id": "subnet-test", "security_group_id": "sg-test", "repo": "owner/repo",
|
|
"run_id": "123-2", "github_token": "secret", "system_disk_gib": 80,
|
|
} | overrides))
|
|
|
|
|
|
def client_with_image(size=40, arch="x86_64"):
|
|
client = Mock()
|
|
client.describe_images.return_value = {"Images": [{
|
|
"State": "available", "Architecture": arch, "RootDeviceName": "/dev/sda1",
|
|
"BlockDeviceMappings": [{"DeviceName": "/dev/sda1", "Ebs": {"VolumeSize": size}}],
|
|
}]}
|
|
client.describe_instance_types.return_value = {"InstanceTypes": [{
|
|
"ProcessorInfo": {"SupportedArchitectures": ["x86_64"]},
|
|
}]}
|
|
client.run_instances.return_value = {"Instances": [{"InstanceId": "i-test"}]}
|
|
client.get_console_output.return_value = {"Output": "boot log"}
|
|
return client
|
|
|
|
|
|
class ProviderConfigTest(unittest.TestCase):
|
|
def test_defaults_and_overrides(self):
|
|
for provider, requested, expected in (
|
|
("Aliyun", "auto", "ecs.c9i.2xlarge"), ("AWS", "auto", "c7i.2xlarge"),
|
|
("Aliyun", "ecs.c9i.4xlarge", "ecs.c9i.4xlarge"),
|
|
("AWS", "c6i.4xlarge", "c6i.4xlarge"),
|
|
("AWS", "c7i.metal-24xl", "c7i.metal-24xl"),
|
|
):
|
|
with self.subTest(provider=provider, requested=requested):
|
|
self.assertEqual(config.resolve_instance_type(provider, requested), expected)
|
|
|
|
def test_wrong_provider_and_shell_fragments_rejected(self):
|
|
for provider, requested in (("ec2", "auto"), ("AWS", "ecs.c9i.2xlarge"),
|
|
("Aliyun", "c7i.2xlarge"), ("AWS", ""),
|
|
("AWS", "c7i.2xlarge; touch /tmp/unexpected")):
|
|
with self.subTest(provider=provider, requested=requested), self.assertRaises(ValueError):
|
|
config.resolve_instance_type(provider, requested)
|
|
|
|
def test_cli_writes_resolved_output(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
output = Path(tmp) / "output"
|
|
result = subprocess.run([sys.executable, str(SCRIPTS / "ci-runner-config.py"),
|
|
"--provider", "AWS"],
|
|
env=os.environ | {"GITHUB_OUTPUT": str(output)},
|
|
capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(output.read_text(), "instance_type=c7i.2xlarge\n")
|
|
|
|
|
|
|
|
class BenchmarkSetupTest(unittest.TestCase):
|
|
def run_setup(self, missing_tools="", docker_installed=True, docker_ready=True):
|
|
action = (SCRIPTS.parent / "actions/setup-benchmark/action.yml").read_text()
|
|
steps = re.findall(r" run: \|\n((?: .*\n|\n)+)", action)
|
|
self.assertEqual(len(steps), 2)
|
|
script = "\n".join(textwrap.dedent(step) for step in steps)
|
|
# Execute the actual action twice with host commands stubbed: installation
|
|
# changes availability, so the second pass must perform no mutations.
|
|
prelude = r"""
|
|
function . { ID=ubuntu; VERSION_CODENAME=noble; }
|
|
uname() { echo x86_64; }
|
|
dpkg() { echo amd64; }
|
|
curl() { echo fixture-key; }
|
|
command() {
|
|
if [[ "$1" == -v ]]; then
|
|
if [[ "$2" == docker ]]; then [[ "$DOCKER_INSTALLED" == true ]]; return; fi
|
|
if [[ " $MISSING_TOOLS " == *" $2 "* ]]; then return 1; fi
|
|
return 0
|
|
fi
|
|
builtin command "$@"
|
|
}
|
|
docker() { [[ "$DOCKER_READY" == true ]]; }
|
|
sudo() {
|
|
printf '%s\n' "$*" >> "$CALLS"
|
|
if [[ "$*" == *' apt-get install '* ]]; then
|
|
MISSING_TOOLS=''
|
|
[[ "$*" != *' docker-ce '* ]] || DOCKER_INSTALLED=true
|
|
fi
|
|
if [[ "$*" == *'systemctl enable --now docker' ]]; then DOCKER_READY=true; fi
|
|
if [[ "$*" == *' tee '* ]]; then while IFS= read -r line; do :; done; fi
|
|
return 0
|
|
}
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
calls = Path(tmp) / "calls"
|
|
calls.touch()
|
|
env = os.environ | {"CALLS": str(calls), "MISSING_TOOLS": missing_tools,
|
|
"DOCKER_INSTALLED": str(docker_installed).lower(),
|
|
"DOCKER_READY": str(docker_ready).lower()}
|
|
result = subprocess.run(["bash", "-c", prelude + script +
|
|
'\nprintf "SECOND PASS\\n" >> "$CALLS"\n' + script],
|
|
env=env, capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
first, second = calls.read_text().split("SECOND PASS\n")
|
|
self.assertEqual(second, "", "Second setup must reuse installed dependencies")
|
|
return first
|
|
|
|
def test_prepared_runner_skips_all_privileged_changes(self):
|
|
self.assertEqual(self.run_setup(), "")
|
|
|
|
def test_missing_tools_are_installed_without_changing_docker(self):
|
|
calls = self.run_setup(missing_tools="jq rsync")
|
|
self.assertIn("--no-install-recommends jq rsync", calls)
|
|
self.assertIn("NEEDRESTART_MODE=l", calls)
|
|
self.assertNotIn("docker", calls)
|
|
|
|
def test_missing_docker_is_installed_once(self):
|
|
calls = self.run_setup(docker_installed=False, docker_ready=False)
|
|
self.assertIn("NEEDRESTART_MODE=l", calls)
|
|
self.assertIn("docker-ce docker-ce-cli containerd.io docker-buildx-plugin", calls)
|
|
self.assertIn("systemctl enable --now docker", calls)
|
|
|
|
def test_existing_stopped_docker_is_started_without_installation(self):
|
|
self.assertEqual(self.run_setup(docker_ready=False), "-n systemctl enable --now docker\n")
|
|
|
|
|
|
class AwsProvisionTest(unittest.TestCase):
|
|
def test_host_policy_matches_aliyun_before_runner_activation(self):
|
|
aliyun = load("aliyun_host_policy_test", "aliyun-ecs-runner-provision.py")
|
|
ecs_script = aliyun.render_user_data("name", "label", "token", "owner/repo")
|
|
aws_script = aws.render_user_data("name", "token", "owner/repo", "https://example.com/runner.tgz")
|
|
start = ecs_script.index("# Mask systemd-oomd")
|
|
end = ecs_script.index("free --human", start) + len("free --human")
|
|
policy = ecs_script[start:end]
|
|
self.assertIn(policy, aws_script)
|
|
self.assertLess(aws_script.index(policy), aws_script.index("./svc.sh start"))
|
|
self.assertLess(aws_script.index("OOMPolicy=continue"), aws_script.index("./svc.sh start"))
|
|
self.assertLess(aws_script.index("./svc.sh install runner"), aws_script.index("service_name=$(cat .service)"))
|
|
self.assertIn('"/etc/systemd/system/$service_name.d/oom.conf"', aws_script)
|
|
|
|
def test_ubuntu_image_resolution_and_override(self):
|
|
client = Mock()
|
|
client.describe_images.return_value = {"Images": [
|
|
{"ImageId": "ami-new", "CreationDate": "2026-09-04", "Name": "noble-new"},
|
|
{"ImageId": "ami-old", "CreationDate": "2026-08-04", "Name": "noble-old"},
|
|
]}
|
|
self.assertEqual(aws.resolve_image(client, "auto"), "ami-new")
|
|
request = client.describe_images.call_args.kwargs
|
|
self.assertEqual(request["Owners"], ["099720109477"])
|
|
self.assertIn({"Name": "name", "Values": [
|
|
"ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]}, request["Filters"])
|
|
client.reset_mock()
|
|
self.assertEqual(aws.resolve_image(client, "ami-pinned"), "ami-pinned")
|
|
client.describe_images.assert_not_called()
|
|
client.describe_images.return_value = {"Images": []}
|
|
with self.assertRaisesRegex(ValueError, "No Canonical Ubuntu"):
|
|
aws.resolve_image(client, "auto")
|
|
|
|
def test_disk_size_and_architecture_fail_before_allocation(self):
|
|
for client, pattern in ((client_with_image(size=500), "smaller than AMI"),
|
|
(client_with_image(arch="arm64"), "x86_64")):
|
|
with self.subTest(pattern=pattern), patch.object(aws, "create_registration_token") as token:
|
|
with self.assertRaisesRegex(ValueError, pattern):
|
|
aws.run_instance(client, provision_config())
|
|
client.run_instances.assert_not_called()
|
|
token.assert_not_called()
|
|
|
|
def test_arm_instance_rejected_before_registration(self):
|
|
client = client_with_image()
|
|
client.describe_instance_types.return_value["InstanceTypes"][0]["ProcessorInfo"]["SupportedArchitectures"] = ["arm64"]
|
|
with patch.object(aws, "create_registration_token") as token:
|
|
with self.assertRaisesRegex(ValueError, "x86_64"):
|
|
aws.run_instance(client, provision_config(instance_type="r8g.2xlarge"))
|
|
token.assert_not_called()
|
|
client.run_instances.assert_not_called()
|
|
|
|
def test_create_request_and_recovery_tags(self):
|
|
client = client_with_image()
|
|
with patch.object(aws, "create_registration_token", return_value="short-lived-token"), \
|
|
patch.object(aws, "runner_download", return_value="https://example.com/runner.tar.gz"):
|
|
self.assertEqual(aws.run_instance(client, provision_config(system_disk_gib=100)), "i-test")
|
|
request = client.run_instances.call_args.kwargs
|
|
self.assertEqual(request["BlockDeviceMappings"], [{"DeviceName": "/dev/sda1", "Ebs": {
|
|
"VolumeSize": 100, "VolumeType": "gp3", "Iops": 3000, "Throughput": 125,
|
|
"DeleteOnTermination": True}}])
|
|
self.assertEqual(request["ClientToken"], "ci-ec2-123-2")
|
|
self.assertNotIn("InstanceMarketOptions", request)
|
|
self.assertNotIn("IamInstanceProfile", request)
|
|
for specification in request["TagSpecifications"]:
|
|
tags = {t["Key"]: t["Value"] for t in specification["Tags"]}
|
|
self.assertEqual(tags["managed-by"], "query-regression-ci")
|
|
self.assertEqual(tags[aws.REPO_TAG], "owner/repo")
|
|
self.assertEqual(tags["query-regression-run-id"], "123-2")
|
|
self.assertEqual(tags["runner-ttl-hours"], "8")
|
|
self.assertNotIn("secret", request["UserData"])
|
|
self.assertIn("--token short-lived-token", request["UserData"])
|
|
self.assertNotIn("apt-get install", request["UserData"])
|
|
self.assertIn('for tool in git curl tar sudo; do command -v "$tool"; done', request["UserData"])
|
|
self.assertLess(request["UserData"].index("usermod -aG docker runner"),
|
|
request["UserData"].index("./svc.sh start"))
|
|
self.assertIn("visudo -cf /etc/sudoers.d/ci-runner", request["UserData"])
|
|
self.assertLess(request["UserData"].index("$nrconf{override_rc}"),
|
|
request["UserData"].index("./svc.sh start"))
|
|
check = subprocess.run(["bash", "-n"], input=request["UserData"], text=True, capture_output=True)
|
|
self.assertEqual(check.returncode, 0, check.stderr)
|
|
|
|
def test_partial_provision_keeps_outputs_and_console(self):
|
|
client = client_with_image()
|
|
client.get_waiter.return_value.wait.side_effect = TimeoutError("not running")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
output = Path(tmp) / "output"
|
|
with patch.dict(os.environ, {"GITHUB_OUTPUT": str(output)}), patch.object(aws, "run_instance", return_value="i-test"):
|
|
with self.assertRaises(TimeoutError):
|
|
aws.provision(client, provision_config())
|
|
self.assertEqual(output.read_text().splitlines(), [
|
|
"instance_id=i-test", "label=ci-ec2-123-2",
|
|
"runner_name=ci-ec2-123-2"])
|
|
client.get_console_output.assert_called_once()
|
|
|
|
def test_online_poll_and_timeout(self):
|
|
for online in (True, False):
|
|
with self.subTest(online=online), patch.object(aws, "run_instance", return_value="i-test"), \
|
|
patch.object(aws, "find_runner_by_name", side_effect=[None, {"status": "online"}]), \
|
|
patch.object(aws.time, "sleep"), \
|
|
patch.object(aws.time, "monotonic", side_effect=[0, 1, 2 if online else 601]):
|
|
client = client_with_image()
|
|
if online:
|
|
self.assertEqual(aws.provision(client, provision_config()), 0)
|
|
else:
|
|
with self.assertRaises(TimeoutError):
|
|
aws.provision(client, provision_config())
|
|
client.get_console_output.assert_called_once()
|
|
|
|
|
|
class AwsTeardownTest(unittest.TestCase):
|
|
NOW = datetime(2026, 9, 23, 10, tzinfo=timezone.utc)
|
|
|
|
def setUp(self):
|
|
sleeper = patch.object(teardown.time, "sleep")
|
|
sleeper.start()
|
|
self.addCleanup(sleeper.stop)
|
|
|
|
def instance(self, identity="i-test", age=9, ttl="8"):
|
|
tags = [{"Key": teardown.RUNNER_TAG, "Value": "ci-ec2-123-2"}]
|
|
if ttl is not None:
|
|
tags.append({"Key": teardown.TTL_TAG_KEY, "Value": ttl})
|
|
return {"InstanceId": identity, "LaunchTime": self.NOW - timedelta(hours=age), "Tags": tags}
|
|
|
|
def test_pagination_and_ownership_filters(self):
|
|
client = Mock()
|
|
client.get_paginator.return_value.paginate.return_value = [
|
|
{"Reservations": [{"Instances": [self.instance("i-a")]}]},
|
|
{"Reservations": [{"Instances": [self.instance("i-b")]}]},
|
|
]
|
|
self.assertEqual([i["InstanceId"] for i in teardown.managed_instances(client, "owner/repo", "123-2")], ["i-a", "i-b"])
|
|
self.assertEqual(client.get_paginator.return_value.paginate.call_args.kwargs["Filters"], [
|
|
{"Name": "tag:managed-by", "Values": ["query-regression-ci"]},
|
|
{"Name": "tag:github-repository", "Values": ["owner/repo"]},
|
|
{"Name": "tag:query-regression-run-id", "Values": ["123-2"]},
|
|
])
|
|
|
|
def test_sweep_expiry_and_missing_or_invalid_ttl(self):
|
|
instances = [self.instance("expired", age=8), self.instance("fresh", age=7),
|
|
self.instance("invalid", ttl="bad"),
|
|
self.instance("missing-expired", age=4, ttl=None),
|
|
self.instance("missing-fresh", age=3, ttl=None)]
|
|
with patch.object(teardown, "managed_instances", return_value=instances), \
|
|
patch.object(teardown, "delete_instance", return_value=True) as delete, \
|
|
patch.object(teardown, "deregister_runner", return_value=True) as unregister:
|
|
self.assertEqual(teardown.teardown(Mock(), "owner/repo", "token", now=self.NOW), 0)
|
|
self.assertEqual([c.args[1] for c in delete.call_args_list], ["expired", "missing-expired"])
|
|
self.assertEqual(unregister.call_count, 2)
|
|
|
|
def test_ttl_decisions_match_aliyun(self):
|
|
aliyun = load("aliyun_ttl_policy_test", "aliyun-ecs-runner-teardown.py")
|
|
for age, ttl in [(3, None), (4, None), (7, "8"), (8, "8"), (9, "8"),
|
|
(10, "bad"), (10, ""), (10, "0"), (200, "169"), (168, "168")]:
|
|
instance = self.instance(age=age, ttl=ttl)
|
|
created = instance["LaunchTime"].strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
expected = aliyun.expired_instance_names(
|
|
[(instance["InstanceId"], "runner", created, ttl)], self.NOW, timedelta(hours=4))
|
|
with self.subTest(age=age, ttl=ttl), \
|
|
patch.object(teardown, "managed_instances", return_value=[instance]), \
|
|
patch.object(teardown, "delete_instance", return_value=True) as delete, \
|
|
patch.object(teardown, "deregister_runner", return_value=True):
|
|
self.assertEqual(teardown.teardown(Mock(), "owner/repo", "token", now=self.NOW), 0)
|
|
self.assertEqual([call.args[1] for call in delete.call_args_list],
|
|
[identity for identity, _ in expected])
|
|
|
|
def test_targeted_cleanup_recovers_missing_outputs_without_waiting_for_ttl(self):
|
|
with patch.object(teardown, "managed_instances", return_value=[self.instance(age=0)]), \
|
|
patch.object(teardown, "delete_instance", return_value=True) as delete, \
|
|
patch.object(teardown, "deregister_runner", return_value=True) as unregister:
|
|
self.assertEqual(teardown.teardown(Mock(), "owner/repo", "token", run_id="123-2"), 0)
|
|
delete.assert_called_once()
|
|
unregister.assert_called_once_with("token", "owner/repo", "ci-ec2-123-2")
|
|
|
|
def test_independent_cloud_and_github_cleanup_failures(self):
|
|
for cloud_ok, github_result in ((False, True), (True, RuntimeError("GitHub unavailable"))):
|
|
with self.subTest(cloud_ok=cloud_ok), \
|
|
patch.object(teardown, "managed_instances", return_value=[self.instance()]), \
|
|
patch.object(teardown, "delete_instance", return_value=cloud_ok) as delete, \
|
|
patch.object(teardown, "deregister_runner", side_effect=[github_result]) as unregister:
|
|
self.assertEqual(teardown.teardown(Mock(), "owner/repo", "token", run_id="123-2"), 1)
|
|
delete.assert_called_once()
|
|
unregister.assert_called_once()
|
|
|
|
def test_already_gone_instance_still_unregisters(self):
|
|
with patch.object(teardown, "managed_instances", return_value=[]), \
|
|
patch.object(teardown, "deregister_runner", return_value=True) as unregister:
|
|
self.assertEqual(teardown.teardown(Mock(), "owner/repo", "token", run_id="123-2"), 0)
|
|
unregister.assert_called_once()
|
|
|
|
def test_retried_teardown_uses_original_instance_and_runner(self):
|
|
client = Mock()
|
|
client.get_paginator.return_value.paginate.return_value = [
|
|
{"Reservations": [{"Instances": [self.instance()]}]},
|
|
]
|
|
with patch.object(teardown, "delete_instance", return_value=True) as delete, \
|
|
patch.object(teardown, "deregister_runner", return_value=True) as unregister:
|
|
self.assertEqual(teardown.teardown(client, "owner/repo", "token", run_id="123-3",
|
|
instance_id="i-test", runner_name="ci-ec2-123-2"), 0)
|
|
filters = client.get_paginator.return_value.paginate.call_args.kwargs["Filters"]
|
|
self.assertIn({"Name": "instance-id", "Values": ["i-test"]}, filters)
|
|
self.assertFalse(any(f["Name"] == "tag:query-regression-run-id" for f in filters))
|
|
delete.assert_called_once_with(client, "i-test")
|
|
unregister.assert_called_once_with("token", "owner/repo", "ci-ec2-123-2")
|
|
client.get_paginator.return_value.paginate.return_value = []
|
|
with patch.object(teardown, "deregister_runner", return_value=True) as unregister:
|
|
self.assertEqual(teardown.teardown(client, "owner/repo", "token", run_id="123-3",
|
|
instance_id="i-test", runner_name="ci-ec2-123-2"), 0)
|
|
unregister.assert_called_once_with("token", "owner/repo", "ci-ec2-123-2")
|
|
|
|
def test_terminate_waits_and_handles_not_found(self):
|
|
client = client_with_image()
|
|
self.assertTrue(teardown.delete_instance(client, "i-test"))
|
|
client.get_waiter.assert_called_with("instance_terminated")
|
|
error = RuntimeError("gone")
|
|
error.response = {"Error": {"Code": "InvalidInstanceID.NotFound"}}
|
|
client.terminate_instances.side_effect = error
|
|
with patch.object(teardown.time, "sleep"):
|
|
self.assertTrue(teardown.delete_instance(client, "i-test"))
|
|
|
|
def test_transient_not_found_is_retried(self):
|
|
client = client_with_image()
|
|
error = RuntimeError("not visible yet")
|
|
error.response = {"Error": {"Code": "InvalidInstanceID.NotFound"}}
|
|
client.terminate_instances.side_effect = [error, {}]
|
|
self.assertTrue(teardown.delete_instance(client, "i-test"))
|
|
self.assertEqual(client.terminate_instances.call_count, 2)
|
|
|
|
def test_missing_tags_are_polled_before_cleanup(self):
|
|
with patch.object(teardown, "managed_instances", side_effect=[[], [self.instance()]]), \
|
|
patch.object(teardown, "delete_instance", return_value=True) as delete, \
|
|
patch.object(teardown, "deregister_runner", return_value=True):
|
|
self.assertEqual(teardown.teardown(Mock(), "owner/repo", "token", run_id="123-2"), 0)
|
|
delete.assert_called_once()
|
|
|
|
def test_github_system_exit_does_not_skip_other_instances(self):
|
|
with patch.object(teardown, "managed_instances", return_value=[self.instance("i-a"), self.instance("i-b")]), \
|
|
patch.object(teardown, "delete_instance", return_value=True) as delete, \
|
|
patch.object(teardown, "deregister_runner", side_effect=[SystemExit("HTTP 403"), True]):
|
|
self.assertEqual(teardown.teardown(Mock(), "owner/repo", "token", run_id="123-2"), 1)
|
|
self.assertEqual([c.args[1] for c in delete.call_args_list], ["i-a", "i-b"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|