Skip to content

Commit ca46009

Browse files
committed
Introduce a bootstrap command to auto-install packages
This commit introduces a new boostrap command that is shipped as part of the opentelemetry-auto-instrumentation package. The command detects installed libraries and installs the relevant auto-instrumentation packages.
1 parent 5913d4a commit ca46009

File tree

3 files changed

+303
-0
lines changed

3 files changed

+303
-0
lines changed

opentelemetry-auto-instrumentation/setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ where = src
4949
[options.entry_points]
5050
console_scripts =
5151
opentelemetry-auto-instrumentation = opentelemetry.auto_instrumentation.auto_instrumentation:run
52+
opentelemetry-bootstrap = opentelemetry.auto_instrumentation.bootstrap:run
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import argparse
18+
import pkgutil
19+
import subprocess
20+
import sys
21+
from logging import getLogger
22+
23+
logger = getLogger(__file__)
24+
25+
_ACTION_INSTALL = "install"
26+
_ACTION_REQUIREMENTS = "requirements"
27+
28+
29+
# target library to desired instrumentor path/versioned package name
30+
instrumentations = {
31+
"dbapi": "opentelemetry-ext-dbapi>=0.6b0",
32+
"flask": "opentelemetry-ext-flask>=0.6b0",
33+
"grpc": "opentelemetry-ext-grpc>=0.6b0",
34+
"requests": "opentelemetry-ext-requests>=0.6b0",
35+
"mysql": "opentelemetry-ext-mysql>=0.6b0",
36+
"psycopg2": "opentelemetry-ext-psycopg2>=0.6b0",
37+
"pymongo": "opentelemetry-ext-pymongo>=0.6b0",
38+
"pymysql": "opentelemetry-ext-pymysql",
39+
"redis": "opentelemetry-ext-redis",
40+
"sqlalchemy": "opentelemetry-ext-sqlalchemy",
41+
"wsgi": "opentelemetry-ext-wsgi>=0.6b0",
42+
}
43+
44+
# relevant instrumentors and tracers to uninstall and check for conflicts for target libraries
45+
libraries = {
46+
"dbapi": ("opentelemetry-ext-dbapi",),
47+
"flask": ("opentelemetry-ext-flask",),
48+
"grpc": ("opentelemetry-ext-grpc",),
49+
"requests": ("opentelemetry-ext-requests",),
50+
"mysql": ("opentelemetry-ext-mysql",),
51+
"psycopg2": ("opentelemetry-ext-psycopg2",),
52+
"pymongo": ("opentelemetry-ext-pymongo",),
53+
"pymysql": ("opentelemetry-ext-pymysql",),
54+
"redis": ("opentelemetry-ext-redis",),
55+
"sqlalchemy": ("opentelemetry-ext-sqlalchemy",),
56+
"wsgi": ("opentelemetry-ext-wsgi",),
57+
}
58+
59+
60+
def _install_package(library, instrumentation):
61+
"""
62+
Ensures that desired version is installed w/o upgrading its dependencies
63+
by uninstalling where necessary (if `target` is not provided).
64+
65+
66+
OpenTelemetry auto-instrumentation packages often have traced libraries
67+
as instrumentation dependency (e.g. flask for opentelemetry-ext-flask),
68+
so using -I on library could cause likely undesired Flask upgrade.
69+
Using --no-dependencies alone would leave potential for nonfunctional
70+
installations.
71+
"""
72+
pip_list = _pip_freeze()
73+
for package in libraries[library]:
74+
if "{}==".format(package).lower() in pip_list:
75+
logger.info(
76+
"Existing %s installation detected. Uninstalling.", package
77+
)
78+
_pip_uninstall(package)
79+
_pip_install(instrumentation)
80+
81+
82+
def _pip_freeze():
83+
return (
84+
subprocess.check_output([sys.executable, "-m", "pip", "freeze"])
85+
.decode()
86+
.lower()
87+
)
88+
89+
90+
def _pip_install(package):
91+
# explicit upgrade strategy to override potential pip config
92+
subprocess.check_call(
93+
[
94+
sys.executable,
95+
"-m",
96+
"pip",
97+
"install",
98+
"-U",
99+
"--upgrade-strategy",
100+
"only-if-needed",
101+
package,
102+
]
103+
)
104+
105+
106+
def _pip_uninstall(package):
107+
subprocess.check_call(
108+
[sys.executable, "-m", "pip", "uninstall", "-y", package]
109+
)
110+
111+
112+
def _pip_check():
113+
"""Ensures none of the instrumentations have dependency conflicts.
114+
Clean check reported as:
115+
'No broken requirements found.'
116+
Dependency conflicts are reported as:
117+
'opentelemetry-ext-flask 1.0.1 has requirement opentelemetry-sdk<2.0,>=1.0, but you have opentelemetry-sdk 0.5.'
118+
To not be too restrictive, we'll only check for relevant packages.
119+
"""
120+
check_pipe = subprocess.Popen(
121+
[sys.executable, "-m", "pip", "check"], stdout=subprocess.PIPE
122+
)
123+
pip_check = check_pipe.communicate()[0].decode()
124+
pip_check_lower = pip_check.lower()
125+
for package_tup in libraries.values():
126+
for package in package_tup:
127+
if package.lower() in pip_check_lower:
128+
raise RuntimeError(
129+
"Dependency conflict found: {}".format(pip_check)
130+
)
131+
132+
133+
def _is_installed(library):
134+
return library in sys.modules or pkgutil.find_loader(library) is not None
135+
136+
137+
def _find_installed_libraries():
138+
return {k: v for k, v in instrumentations.items() if _is_installed(k)}
139+
140+
141+
def _run_requirements(packages):
142+
print("\n".join(packages.values()), end="")
143+
144+
145+
def _run_install(packages):
146+
for pkg, inst in packages.items():
147+
_install_package(pkg, inst)
148+
149+
_pip_check()
150+
151+
152+
def run() -> None:
153+
parser = argparse.ArgumentParser(
154+
description="""
155+
opentelemetry-bootstrap detects installed libraries and automatically
156+
installs the relevant instrumentation packages for them.
157+
"""
158+
)
159+
parser.add_argument(
160+
"-a",
161+
"--action",
162+
choices=[_ACTION_INSTALL, _ACTION_REQUIREMENTS],
163+
default=_ACTION_REQUIREMENTS,
164+
help="""
165+
install - uses pip to install the new requirements using to the
166+
currently active site-package.
167+
requirements - prints out the new requirements to stdout. Action can
168+
be piped and appended to a requirements.txt file.
169+
""",
170+
)
171+
args = parser.parse_args()
172+
173+
cmd = {
174+
_ACTION_INSTALL: _run_install,
175+
_ACTION_REQUIREMENTS: _run_requirements,
176+
}[args.action]
177+
cmd(_find_installed_libraries())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
# type: ignore
15+
16+
from functools import reduce
17+
from io import StringIO
18+
from random import sample
19+
from unittest import TestCase
20+
from unittest.mock import call, patch
21+
22+
from opentelemetry.auto_instrumentation import bootstrap
23+
24+
25+
def sample_packages(packages, rate):
26+
sampled = sample(list(packages), int(len(packages) * rate),)
27+
return {k: v for k, v in packages.items() if k in sampled}
28+
29+
30+
class TestBootstrap(TestCase):
31+
32+
installed_libraries = {}
33+
installed_instrumentations = {}
34+
35+
@classmethod
36+
def setUpClass(cls):
37+
# select random 60% of instrumentations
38+
cls.installed_libraries = sample_packages(
39+
bootstrap.instrumentations, 0.6
40+
)
41+
42+
# treat 50% of sampled packages as pre-installed
43+
cls.installed_instrumentations = sample_packages(
44+
cls.installed_libraries, 0.5
45+
)
46+
47+
cls.pkg_patcher = patch(
48+
"opentelemetry.auto_instrumentation.bootstrap._find_installed_libraries",
49+
return_value=cls.installed_libraries,
50+
)
51+
52+
pip_freeze_output = []
53+
for inst in cls.installed_instrumentations.values():
54+
inst = inst.replace(">=", "==")
55+
if "==" not in inst:
56+
inst = "{}==x.y".format(inst)
57+
pip_freeze_output.append(inst)
58+
59+
cls.pip_freeze_patcher = patch(
60+
"opentelemetry.auto_instrumentation.bootstrap._pip_freeze",
61+
return_value="\n".join(pip_freeze_output),
62+
)
63+
cls.pip_install_patcher = patch(
64+
"opentelemetry.auto_instrumentation.bootstrap._pip_install",
65+
)
66+
cls.pip_uninstall_patcher = patch(
67+
"opentelemetry.auto_instrumentation.bootstrap._pip_uninstall",
68+
)
69+
cls.pip_check_patcher = patch(
70+
"opentelemetry.auto_instrumentation.bootstrap._pip_check",
71+
)
72+
73+
cls.pkg_patcher.start()
74+
cls.mock_pip_freeze = cls.pip_freeze_patcher.start()
75+
cls.mock_pip_install = cls.pip_install_patcher.start()
76+
cls.mock_pip_uninstall = cls.pip_uninstall_patcher.start()
77+
cls.mock_pip_check = cls.pip_check_patcher.start()
78+
79+
@classmethod
80+
def tearDownClass(cls):
81+
cls.pip_check_patcher.start()
82+
cls.pip_uninstall_patcher.start()
83+
cls.pip_install_patcher.start()
84+
cls.pip_freeze_patcher.start()
85+
cls.pkg_patcher.stop()
86+
87+
@patch("sys.argv", ["bootstrap", "-a", "pipenv"])
88+
def test_run_unknown_cmd(self):
89+
with self.assertRaises(SystemExit):
90+
bootstrap.run()
91+
92+
@patch("sys.argv", ["bootstrap", "-a", "requirements"])
93+
def test_run_cmd_print(self):
94+
with patch("sys.stdout", new=StringIO()) as fake_out:
95+
bootstrap.run()
96+
self.assertEqual(
97+
fake_out.getvalue(),
98+
"\n".join(self.installed_libraries.values()),
99+
)
100+
101+
@patch("sys.argv", ["bootstrap", "-a", "install"])
102+
def test_run_cmd_install(self):
103+
bootstrap.run()
104+
105+
self.assertEqual(
106+
self.mock_pip_freeze.call_count, len(self.installed_libraries)
107+
)
108+
109+
to_uninstall = reduce(
110+
lambda x, y: x + y,
111+
[
112+
pkgs
113+
for lib, pkgs in bootstrap.libraries.items()
114+
if lib in self.installed_instrumentations
115+
],
116+
)
117+
self.mock_pip_uninstall.assert_has_calls(
118+
[call(i) for i in to_uninstall], any_order=True
119+
)
120+
121+
self.mock_pip_install.assert_has_calls(
122+
[call(i) for i in self.installed_libraries.values()],
123+
any_order=True,
124+
)
125+
self.assertEqual(self.mock_pip_check.call_count, 1)

0 commit comments

Comments
 (0)