Skip to content

Commit fcacbd9

Browse files
Add Integration tests (canonical#294)
Add integration tests and remove the current CI running example and cloud-init tests. Since running on real clouds require credentials, but GH Actions doesn't allow the use of secrets in forks, the cloud tests have been changed to run post-merge. Late is better than never. Since it's hard to check post-merge behavior on a PR, I temporary had the `main_check.yaml` workflow run on PR. Got a successful run of the post-merge tests at https://github.com/canonical/pycloudlib/actions/runs/4296379520/jobs/7488021663 , so it has now been changed to run post-merge.
1 parent 87c8195 commit fcacbd9

File tree

17 files changed

+261
-52
lines changed

17 files changed

+261
-52
lines changed

.github/workflows/ci.yaml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ name: Pycloudlib CI
22

33
on: [pull_request]
44

5+
concurrency:
6+
group: "ci-${{ github.workflow }}-${{ github.ref }}"
7+
cancel-in-progress: true
8+
59
jobs:
6-
tox:
10+
tox-defaults:
711
runs-on: ubuntu-20.04
812
steps:
913
- name: Install dependencies
@@ -12,3 +16,26 @@ jobs:
1216
uses: actions/checkout@v3
1317
- name: Run tox
1418
run: tox
19+
integration-tests:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Install dependencies
23+
run: |
24+
sudo apt-get update -q
25+
sudo apt-get install -qqy distro-info tox
26+
sudo snap install lxd
27+
- name: Initialize LXD
28+
run: |
29+
ssh-keygen -P "" -q -f ~/.ssh/id_rsa
30+
mkdir -p ~/.config
31+
echo "[lxd]" > ~/.config/pycloudlib.toml
32+
sudo adduser $USER lxd
33+
# Jammy GH Action runners have docker installed, which edits iptables
34+
# in a way that is incompatible with lxd.
35+
# https://linuxcontainers.org/lxd/docs/master/howto/network_bridge_firewalld/#prevent-issues-with-lxd-and-docker
36+
sudo iptables -I DOCKER-USER -j ACCEPT
37+
sudo lxd init --auto
38+
- name: Git checkout
39+
uses: actions/checkout@v3
40+
- name: Run CI integration tests
41+
run: sg lxd -c 'tox -e integration-tests-ci'

.github/workflows/main_check.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Pycloudlib Post Merge Check
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
jobs:
9+
post-merge-tests:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Install dependencies
13+
run: |
14+
sudo apt-get update -q
15+
sudo apt-get install -qqy distro-info tox
16+
sudo apt-get remove --yes --purge azure-cli
17+
- name: Initialize Pycloudlib
18+
env:
19+
GCE_CREDENTIALS_JSON: ${{ secrets.GCE_CREDENTIALS_JSON }}
20+
PYCLOUDLIB_TOML: ${{ secrets.PYCLOUDLIB_TOML }}
21+
run: |
22+
ssh-keygen -P "" -q -f ~/.ssh/cloudinit_id_rsa
23+
mkdir -p ~/.config
24+
echo "$GCE_CREDENTIALS_JSON" > ~/.config/gce_credentials
25+
echo "$PYCLOUDLIB_TOML" > ~/.config/pycloudlib.toml
26+
- name: Git checkout
27+
uses: actions/checkout@v3
28+
- name: Run CI integration tests
29+
run: tox -e integration-tests-main-check

.travis.yml

Lines changed: 0 additions & 42 deletions
This file was deleted.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1!2.0.0
1+
1!2.0.1

pycloudlib/cloud.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,10 @@ def __init__(
5555
user = getpass.getuser()
5656
self.key_pair = KeyPair(
5757
public_key_path=os.path.expandvars(
58-
os.path.expanduser(
59-
self.config.get(
60-
"public_key_path", f"~{user}/.ssh/id_rsa.pub"
61-
)
62-
)
58+
self.config.get("public_key_path", f"~{user}/.ssh/id_rsa.pub")
6359
),
6460
private_key_path=os.path.expandvars(
65-
os.path.expanduser(self.config.get("private_key_path", ""))
61+
self.config.get("private_key_path", "")
6662
),
6763
name=self.config.get("key_name", user),
6864
)

pycloudlib/instance.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,12 @@ def clean(self):
199199
200200
This will clean out specifically the cloud-init files and system logs.
201201
"""
202-
self.execute("sudo cloud-init clean --logs")
202+
result = self.execute("sudo cloud-init clean --logs --machine-id")
203+
if result.failed:
204+
# --machine-id likely isn't supported on this version.
205+
# Manually reset machine-id even though this is less portable
206+
self.execute("sudo cloud-init clean --logs")
207+
self.execute("sudo echo 'uninitialized' > /etc/machine-id")
203208
self.execute("sudo rm -rf /var/log/syslog")
204209

205210
def _run_command(self, command, stdin):

pycloudlib/key.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# This file is part of pycloudlib. See LICENSE file for license information.
22
"""Base Key Class."""
33

4+
import os
5+
46

57
class KeyPair:
68
"""Key Class."""
@@ -24,6 +26,9 @@ def __init__(self, public_key_path, private_key_path=None, name=None):
2426
else:
2527
self.private_key_path = self.public_key_path.replace(".pub", "")
2628

29+
self.public_key_path = os.path.expanduser(self.public_key_path)
30+
self.private_key_path = os.path.expanduser(self.private_key_path)
31+
2732
def __str__(self):
2833
"""Create string representation of class."""
2934
return "KeyPair({}, {}, name={})".format(

pycloudlib/oci/instance.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# This file is part of pycloudlib. See LICENSE file for license information.
33
"""OCI instance."""
44

5+
from time import sleep
6+
57
import oci
68

79
from pycloudlib.errors import PycloudlibError
@@ -96,7 +98,23 @@ def delete(self, wait=True):
9698

9799
def _do_restart(self, **kwargs):
98100
"""Restart the instance."""
99-
self.compute_client.instance_action(self.instance_data.id, "RESET")
101+
last_exception = None
102+
for _ in range(30):
103+
try:
104+
self.compute_client.instance_action(
105+
self.instance_data.id, "RESET"
106+
)
107+
return
108+
except oci.exceptions.ServiceError as e:
109+
last_exception = e
110+
if last_exception.status != 409:
111+
raise
112+
self._log.debug(
113+
"Received 409 attempting to RESET instance. Retrying"
114+
)
115+
sleep(0.5)
116+
if last_exception:
117+
raise last_exception
100118

101119
def shutdown(self, wait=True, **kwargs):
102120
"""Shutdown the instance.

tests/__init__.py

Whitespace-only changes.

tests/integration_tests/__init__.py

Whitespace-only changes.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import ipaddress
2+
from contextlib import contextmanager, suppress
3+
from pathlib import Path
4+
from tempfile import TemporaryDirectory
5+
from typing import Generator
6+
7+
import pytest
8+
9+
import pycloudlib
10+
from pycloudlib.cloud import BaseCloud
11+
from pycloudlib.instance import BaseInstance
12+
from pycloudlib.util import LTS_RELEASES
13+
14+
cloud_config = """\
15+
#cloud-config
16+
runcmd:
17+
- echo 'hello' >> /var/tmp/example.txt
18+
"""
19+
20+
21+
@pytest.fixture
22+
def cloud(request):
23+
cloud_instance: BaseCloud = request.param(
24+
tag="pycl-test",
25+
timestamp_suffix=True,
26+
)
27+
28+
if isinstance(cloud_instance, pycloudlib.EC2):
29+
cloud_instance.upload_key(
30+
public_key_path=cloud_instance.config["public_key_path"],
31+
private_key_path=cloud_instance.config["private_key_path"],
32+
name=cloud_instance.tag,
33+
)
34+
35+
yield cloud_instance
36+
37+
if isinstance(cloud_instance, pycloudlib.EC2):
38+
cloud_instance.delete_key(name=cloud_instance.tag)
39+
40+
41+
@contextmanager
42+
def launch_instance(
43+
cloud_instance: BaseCloud, **kwargs
44+
) -> Generator[BaseInstance, None, None]:
45+
instance = cloud_instance.launch(**kwargs)
46+
try:
47+
yield instance
48+
finally:
49+
instance.delete()
50+
51+
52+
def assert_example_output(instance: BaseInstance):
53+
example_output = instance.execute("cat /var/tmp/example.txt").stdout
54+
assert example_output == "hello"
55+
56+
57+
def exercise_push_pull(instance: BaseInstance):
58+
with TemporaryDirectory() as tmpdir:
59+
push_path = Path(tmpdir).joinpath("pushed")
60+
push_path.write_text("pushed", encoding="utf-8")
61+
instance.push_file(str(push_path), "/var/tmp/pushed")
62+
assert (
63+
"pushed" == instance.execute("cat /var/tmp/pushed").stdout.strip()
64+
)
65+
66+
instance.execute("echo 'pulled' > /var/tmp/pulled")
67+
pull_path = Path(tmpdir).joinpath("pulled")
68+
instance.pull_file("/var/tmp/pulled", str(pull_path))
69+
assert pull_path.read_text(encoding="utf-8").strip() == "pulled"
70+
71+
72+
def exercise_instance(instance: BaseInstance):
73+
assert instance.name is not None
74+
assert ipaddress.ip_address(instance.ip)
75+
76+
with suppress(NotImplementedError):
77+
ip_address = instance.add_network_interface()
78+
try:
79+
assert ipaddress.ip_address(ip_address)
80+
finally:
81+
instance.remove_network_interface(ip_address)
82+
83+
exercise_push_pull(instance)
84+
85+
assert_example_output(instance)
86+
boot_id = instance.get_boot_id()
87+
instance.restart()
88+
assert boot_id != instance.get_boot_id()
89+
90+
assert_example_output(instance)
91+
instance.shutdown()
92+
93+
instance.start()
94+
assert_example_output(instance)
95+
96+
97+
@pytest.mark.parametrize(
98+
"cloud",
99+
[
100+
pytest.param(
101+
pycloudlib.Azure, id="azure", marks=pytest.mark.main_check
102+
),
103+
pytest.param(pycloudlib.EC2, id="ec2", marks=pytest.mark.main_check),
104+
pytest.param(pycloudlib.GCE, id="gce", marks=pytest.mark.main_check),
105+
pytest.param(pycloudlib.IBM, id="ibm"),
106+
pytest.param(
107+
pycloudlib.LXDContainer, id="lxd_container", marks=pytest.mark.ci
108+
),
109+
pytest.param(pycloudlib.LXDVirtualMachine, id="lxd_vm"),
110+
pytest.param(pycloudlib.OCI, id="oci"),
111+
# For openstack we first need a reliable way of obtaining the
112+
# image id
113+
# pytest.param(pycloudlib.Openstack, id="openstack"),
114+
],
115+
indirect=True,
116+
)
117+
def test_public_api(cloud: BaseCloud):
118+
"""Shallow test of (most) public functions in the base API."""
119+
latest_lts = LTS_RELEASES[-1]
120+
print(f"Using Ubuntu {latest_lts} release")
121+
try:
122+
image_id = cloud.released_image(release=latest_lts)
123+
except NotImplementedError:
124+
image_id = cloud.daily_image(release=latest_lts)
125+
with suppress(NotImplementedError):
126+
# Not sure there's a great way to test this other than not raising
127+
cloud.image_serial(image_id)
128+
129+
with launch_instance(
130+
cloud, image_id=image_id, user_data=cloud_config, wait=False
131+
) as instance:
132+
instance.wait()
133+
exercise_instance(instance)
134+
135+
instance.clean()
136+
result = instance.execute("sudo rm /var/tmp/example.txt")
137+
snapshot_id = cloud.snapshot(instance)
138+
139+
try:
140+
with launch_instance(
141+
cloud, image_id=snapshot_id, user_data=cloud_config, wait=False
142+
) as instance_from_snapshot:
143+
instance_from_snapshot.wait()
144+
exercise_instance(instance_from_snapshot)
145+
finally:
146+
cloud.delete_image(snapshot_id)
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

tox.ini

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,32 @@ envdir = {[tip]envdir}
8484
deps = {[tip]deps}
8585
commands = {envpython} -m flake8 pycloudlib examples setup.py
8686

87+
[testenv:integration-tests]
88+
commands = {envpython} -m pytest --log-cli-level=INFO -svv {posargs:tests/integration_tests}
89+
deps =
90+
-rrequirements.txt
91+
-rtest-requirements.txt
92+
93+
[testenv:integration-tests-ci]
94+
commands = {envpython} -m pytest -m ci --log-cli-level=INFO -svv {posargs:tests/integration_tests}
95+
deps =
96+
-rrequirements.txt
97+
-rtest-requirements.txt
98+
pytest-xdist
99+
100+
[testenv:integration-tests-main-check]
101+
# Since we can't use GH secrets from a forked PR, run the cloud-based
102+
# tests after the branch has merged. Better late than never
103+
commands = {envpython} -m pytest -n 5 -m main_check --log-cli-level=DEBUG -svv {posargs:tests/integration_tests}
104+
deps = {[testenv:integration-tests-ci]deps}
105+
87106
[flake8]
88107
# E203: whitespace before ':' ... This goes against pep8 and black formatting
89108
# W503: line break before binary operator
90109
ignore = E203, W503
110+
111+
[pytest]
112+
testpaths = tests/unit_tests
113+
markers =
114+
ci: run test on as part of continous integration
115+
main_check: run test after branch has merged to main

0 commit comments

Comments
 (0)