-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathmetaflow_environment.py
More file actions
291 lines (259 loc) · 11.2 KB
/
Copy pathmetaflow_environment.py
File metadata and controls
291 lines (259 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
import json
import os
import platform
import sys
from .util import get_username
from . import metaflow_version
from . import metaflow_git
from metaflow.exception import MetaflowException
from metaflow.extension_support import dump_module_info
from metaflow.mflog import BASH_MFLOG, BASH_FLUSH_LOGS
from metaflow.package import MetaflowPackage
from . import R
class InvalidEnvironmentException(MetaflowException):
headline = "Incompatible environment"
class MetaflowEnvironment(object):
TYPE = "local"
def __init__(self, flow):
pass
def init_environment(self, echo):
"""
Run before any step decorators are initialized.
"""
pass
def validate_environment(self, echo, datastore_type):
"""
Run before any command to validate that we are operating in
a desired environment.
"""
pass
def decospecs(self):
"""
Environment may insert decorators, equivalent to setting --with
options on the command line.
"""
return ()
def bootstrap_commands(self, step_name, datastore_type):
"""
A list of shell commands to bootstrap this environment in a remote runtime.
"""
return []
def add_to_package(self):
"""
Called to add custom files needed for this environment. This hook will be
called in the `MetaflowPackage` class where metaflow compiles the code package
tarball. This hook can return one of two things (the first is for backwards
compatibility -- move to the second):
- a generator yielding a tuple of `(file_path, arcname)` to add files to
the code package. `file_path` is the path to the file on the local filesystem
and `arcname` is the path relative to the packaged code.
- a generator yielding a tuple of `(content, arcname, type)` where:
- type is one of
ContentType.{USER_CONTENT, CODE_CONTENT, MODULE_CONTENT, OTHER_CONTENT}
- for USER_CONTENT:
- the file will be included relative to the directory containing the
user's flow file.
- content: path to the file to include
- arcname: path relative to the directory containing the user's flow file
- for CODE_CONTENT:
- the file will be included relative to the code directory in the package.
This will be the directory containing `metaflow`.
- content: path to the file to include
- arcname: path relative to the code directory in the package
- for MODULE_CONTENT:
- the module will be added to the code package as a python module. It will
be accessible as usual (import <module_name>)
- content: name of the module
- arcname: None (ignored)
- for OTHER_CONTENT:
- the file will be included relative to any other configuration/metadata
files for the flow
- content: path to the file to include
- arcname: path relative to the config directory in the package
"""
return []
def pylint_config(self):
"""
Environment may override pylint config.
"""
return []
@classmethod
def get_client_info(cls, flow_name, metadata):
"""
Environment may customize the information returned to the client about the environment
Parameters
----------
flow_name : str
Name of the flow
metadata : dict
Metadata information regarding the task
Returns
-------
str : Information printed and returned to the user
"""
return "Local environment"
def _get_download_code_package_cmd(self, code_package_url, datastore_type):
"""Return a command that downloads the code package from the datastore. We use various
cloud storage CLI tools because we don't have access to Metaflow codebase (which we
are about to download in the command).
The command should download the package to "job.tar" in the current directory.
It should work silently if everything goes well.
"""
if datastore_type == "s3":
from .plugins.aws.aws_utils import parse_s3_full_path
bucket, s3_object = parse_s3_full_path(code_package_url)
# NOTE: the script quoting is extremely sensitive due to the way shlex.split operates and this being inserted
# into a quoted command elsewhere.
# NOTE: Reason for the extra conditionals in the script are because
# Boto3 does not play well with passing None or an empty string to endpoint_url
return "{python} -c '{script}'".format(
python=self._python(),
script='import boto3, os; ep=os.getenv(\\"METAFLOW_S3_ENDPOINT_URL\\"); boto3.client(\\"s3\\", **({\\"endpoint_url\\":ep} if ep else {})).download_file(\\"%s\\", \\"%s\\", \\"job.tar\\")'
% (bucket, s3_object),
)
elif datastore_type == "azure":
from .plugins.azure.azure_utils import parse_azure_full_path
container_name, blob = parse_azure_full_path(code_package_url)
# remove a trailing slash, if present
blob_endpoint = "${METAFLOW_AZURE_STORAGE_BLOB_SERVICE_ENDPOINT%/}"
return "download-azure-blob --blob-endpoint={blob_endpoint} --container={container} --blob={blob} --output-file=job.tar".format(
blob_endpoint=blob_endpoint,
blob=blob,
container=container_name,
)
elif datastore_type == "gs":
from .plugins.gcp.gs_utils import parse_gs_full_path
bucket_name, gs_object = parse_gs_full_path(code_package_url)
return (
"download-gcp-object --bucket=%s --object=%s --output-file=job.tar"
% (bucket_name, gs_object)
)
else:
raise NotImplementedError(
"We don't know how to generate a download code package cmd for datastore %s"
% datastore_type
)
def _get_install_dependencies_cmd(self, datastore_type):
base_cmd = "{} -m pip install -qqq --no-compile --no-cache-dir --disable-pip-version-check".format(
self._python()
)
datastore_packages = {
"s3": ["boto3"],
"azure": [
"azure-identity",
"azure-storage-blob",
"azure-keyvault-secrets",
"simple-azure-blob-downloader",
],
"gs": [
"google-cloud-storage",
"google-auth",
"simple-gcp-object-downloader",
"google-cloud-secret-manager",
"packaging",
],
}
if datastore_type not in datastore_packages:
raise NotImplementedError(
"Unknown datastore type: {}".format(datastore_type)
)
cmd = "{} {}".format(
base_cmd, " ".join(datastore_packages[datastore_type] + ["requests"])
)
# skip pip installs if we know that packages might already be available
return "if [ -z $METAFLOW_SKIP_INSTALL_DEPENDENCIES ]; then {}; fi".format(cmd)
def get_package_commands(
self, code_package_url, datastore_type, code_package_metadata=None
):
# HACK: We want to keep forward compatibility with compute layers so that
# they can still call get_package_commands and NOT pass any metadata. If
# there is no additional information, we *assume* that it is the default
# used.
if code_package_metadata is None:
code_package_metadata = json.dumps(
{
"version": 0,
"archive_format": "tgz",
"mfcontent_version": 1,
}
)
extra_exports = []
for k, v in MetaflowPackage.get_post_extract_env_vars(
code_package_metadata, dest_dir="$(pwd)"
).items():
if k.endswith(":"):
# If the value ends with a colon, we override the existing value
extra_exports.append("export %s=%s" % (k[:-1], v))
else:
extra_exports.append(
"export %s=%s:$(printenv %s)" % (k, v.replace('"', '\\"'), k)
)
cmds = (
[
BASH_MFLOG,
BASH_FLUSH_LOGS,
"mflog 'Setting up task environment.'",
self._get_install_dependencies_cmd(datastore_type),
"mkdir metaflow",
"cd metaflow",
"mkdir .metaflow", # mute local datastore creation log
"i=0; while [ $i -le 5 ]; do "
"mflog 'Downloading code package...'; "
+ self._get_download_code_package_cmd(code_package_url, datastore_type)
+ " && mflog 'Code package downloaded.' && break; "
"sleep 10; i=$((i+1)); "
"done",
"if [ $i -gt 5 ]; then "
"mflog 'Failed to download code package from %s "
"after 6 tries. Exiting...' && exit 1; "
"fi" % code_package_url,
]
+ MetaflowPackage.get_extract_commands(
code_package_metadata, "job.tar", dest_dir="."
)
+ extra_exports
+ [
"mflog 'Task is starting.'",
"flush_mflogs",
]
)
return cmds
def get_environment_info(self, include_ext_info=False):
# note that this dict goes into the code package
# so variables here should be relatively stable (no
# timestamps) so the hash won't change all the time
env = {
"platform": platform.system(),
"username": get_username(),
"production_token": os.environ.get("METAFLOW_PRODUCTION_TOKEN"),
"runtime": os.environ.get("METAFLOW_RUNTIME_NAME", "dev"),
"app": os.environ.get("APP"),
"environment_type": self.TYPE,
"use_r": R.use_r(),
"python_version": sys.version,
"python_version_code": "%d.%d.%d" % sys.version_info[:3],
"metaflow_version": metaflow_version.get_version(),
"script": os.path.basename(os.path.abspath(sys.argv[0])),
# Add git info
**metaflow_git.get_repository_info(
path=os.path.dirname(os.path.abspath(sys.argv[0]))
),
}
if R.use_r():
env["metaflow_r_version"] = R.metaflow_r_version()
env["r_version"] = R.r_version()
env["r_version_code"] = R.r_version_code()
if include_ext_info:
# Information about extension modules (to load them in the proper order)
ext_key, ext_val = dump_module_info()
env[ext_key] = ext_val
return {k: v for k, v in env.items() if v is not None and v != ""}
def executable(self, step_name, default=None):
if default is not None:
return default
return self._python()
def _python(self):
if R.use_r():
return "python3"
else:
return "python"