Skip to content

Commit 0bd266d

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 c074853 commit 0bd266d

File tree

3 files changed

+300
-0
lines changed

3 files changed

+300
-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,174 @@
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+
_OUTPUT_INSTALL = "install"
26+
_OUTPUT_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-http-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-http-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 by uninstalling where necessary (if
63+
`target` is not provided).
64+
65+
66+
OpenTelemetry auto-instrumentation packages often have traced libraries as instrumentation dependency
67+
(e.g. flask for opentelemetry-ext-flask), so using -I on library could cause likely undesired Flask upgrade.
68+
Using --no-dependencies alone would leave potential for nonfunctional installations.
69+
"""
70+
pip_list = _pip_freeze()
71+
for package in libraries[library]:
72+
if "{}==".format(package).lower() in pip_list:
73+
logger.info(
74+
"Existing %s installation detected. Uninstalling.", package
75+
)
76+
_pip_uninstall(package)
77+
_pip_install(instrumentation)
78+
79+
80+
def _pip_freeze():
81+
return (
82+
subprocess.check_output([sys.executable, "-m", "pip", "freeze"])
83+
.decode()
84+
.lower()
85+
)
86+
87+
88+
def _pip_install(package):
89+
# explicit upgrade strategy to override potential pip config
90+
subprocess.check_call(
91+
[
92+
sys.executable,
93+
"-m",
94+
"pip",
95+
"install",
96+
"-U",
97+
"--upgrade-strategy",
98+
"only-if-needed",
99+
package,
100+
]
101+
)
102+
103+
104+
def _pip_uninstall(package):
105+
subprocess.check_call(
106+
[sys.executable, "-m", "pip", "uninstall", "-y", package]
107+
)
108+
109+
110+
def _pip_check():
111+
"""Ensures none of the instrumentations have dependency conflicts.
112+
Clean check reported as:
113+
'No broken requirements found.'
114+
Dependency conflicts are reported as:
115+
'opentelemetry-ext-flask 1.0.1 has requirement opentelemetry-sdk<2.0,>=1.0, but you have opentelemetry-sdk 0.5.'
116+
To not be too restrictive, we'll only check for relevant packages.
117+
"""
118+
check_pipe = subprocess.Popen(
119+
[sys.executable, "-m", "pip", "check"], stdout=subprocess.PIPE
120+
)
121+
pip_check = check_pipe.communicate()[0].decode()
122+
pip_check_lower = pip_check.lower()
123+
for package_tup in libraries.values():
124+
for package in package_tup:
125+
if package.lower() in pip_check_lower:
126+
raise RuntimeError(
127+
"Dependency conflict found: {}".format(pip_check)
128+
)
129+
130+
131+
def _is_installed(library):
132+
return library in sys.modules or pkgutil.find_loader(library) is not None
133+
134+
135+
def _find_installed_libraries():
136+
return {k: v for k, v in instrumentations.items() if _is_installed(k)}
137+
138+
139+
def _run_requirments(packages):
140+
print("\n".join(packages.values()), end="")
141+
142+
143+
def _run_install(packages):
144+
for pkg, inst in packages.items():
145+
_install_package(pkg, inst)
146+
147+
_pip_check()
148+
149+
150+
def run() -> None:
151+
parser = argparse.ArgumentParser(
152+
description="""
153+
opentelemetry-bootstrap detects installed libraries and automatically installs the relevant
154+
instrumentation packages for them.
155+
"""
156+
)
157+
parser.add_argument(
158+
"-o",
159+
"--output",
160+
choices=[_OUTPUT_INSTALL, _OUTPUT_REQUIREMENTS],
161+
default=_OUTPUT_INSTALL,
162+
help="""
163+
install - uses pip to install the new requirements using to the currently active site-package.
164+
requirements - prints out the new requirements to stdout. Output can be piped and appended to
165+
a requirements.txt file.
166+
""",
167+
)
168+
args = parser.parse_args()
169+
170+
cmd = {
171+
_OUTPUT_INSTALL: _run_install,
172+
_OUTPUT_REQUIREMENTS: _run_requirments,
173+
}[args.output]
174+
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", "-o", "pipenv"])
88+
def test_run_unknown_cmd(self):
89+
with self.assertRaises(SystemExit):
90+
bootstrap.run()
91+
92+
@patch("sys.argv", ["bootstrap", "-o", "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", "-o", "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)