]>
git.proxmox.com Git - ceph.git/blob - ceph/src/arrow/dev/archery/archery/docker.py
1 # Licensed to the Apache Software Foundation (ASF) under one
2 # or more contributor license agreements. See the NOTICE file
3 # distributed with this work for additional information
4 # regarding copyright ownership. The ASF licenses this file
5 # to you under the Apache License, Version 2.0 (the
6 # "License"); you may not use this file except in compliance
7 # with the License. You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing,
12 # software distributed under the License is distributed on an
13 # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 # KIND, either express or implied. See the License for the
15 # specific language governing permissions and limitations
21 from io
import StringIO
23 from dotenv
import dotenv_values
24 from ruamel
.yaml
import YAML
26 from .utils
.command
import Command
, default_bin
27 from .compat
import _ensure_path
30 def flatten(node
, parents
=None):
31 parents
= list(parents
or [])
32 if isinstance(node
, str):
34 elif isinstance(node
, list):
36 yield from flatten(value
, parents
=parents
)
37 elif isinstance(node
, dict):
38 for key
, value
in node
.items():
40 yield from flatten(value
, parents
=parents
+ [key
])
45 def _sanitize_command(cmd
):
46 if isinstance(cmd
, list):
48 return re
.sub(r
"\s+", " ", cmd
)
51 class UndefinedImage(Exception):
57 def __init__(self
, config_path
, dotenv_path
, compose_bin
, params
=None):
58 config_path
= _ensure_path(config_path
)
60 dotenv_path
= _ensure_path(dotenv_path
)
62 dotenv_path
= config_path
.parent
/ '.env'
63 self
._read
_env
(dotenv_path
, params
)
64 self
._read
_config
(config_path
, compose_bin
)
66 def _read_env(self
, dotenv_path
, params
):
68 Read .env and merge it with explicitly passed parameters.
70 self
.dotenv
= dotenv_values(str(dotenv_path
))
74 self
.params
= {k
: v
for k
, v
in params
.items() if k
in self
.dotenv
}
76 # forward the process' environment variables
77 self
.env
= os
.environ
.copy()
78 # set the defaults from the dotenv files
79 self
.env
.update(self
.dotenv
)
80 # override the defaults passed as parameters
81 self
.env
.update(self
.params
)
83 # translate docker's architecture notation to a more widely used one
84 arch
= self
.env
.get('ARCH', 'amd64')
90 arch_short_aliases
= {
95 self
.env
['ARCH_ALIAS'] = arch_aliases
.get(arch
, arch
)
96 self
.env
['ARCH_SHORT_ALIAS'] = arch_short_aliases
.get(arch
, arch
)
98 def _read_config(self
, config_path
, compose_bin
):
100 Validate and read the docker-compose.yml
103 with config_path
.open() as fp
:
104 config
= yaml
.load(fp
)
106 services
= config
['services'].keys()
107 self
.hierarchy
= dict(flatten(config
.get('x-hierarchy', {})))
108 self
.with_gpus
= config
.get('x-with-gpus', [])
109 nodes
= self
.hierarchy
.keys()
112 for name
in self
.with_gpus
:
113 if name
not in services
:
115 'Service `{}` defined in `x-with-gpus` bot not in '
116 '`services`'.format(name
)
118 for name
in nodes
- services
:
120 'Service `{}` is defined in `x-hierarchy` bot not in '
121 '`services`'.format(name
)
123 for name
in services
- nodes
:
125 'Service `{}` is defined in `services` but not in '
126 '`x-hierarchy`'.format(name
)
129 # trigger docker-compose's own validation
130 compose
= Command('docker-compose')
131 args
= ['--file', str(config_path
), 'config']
132 result
= compose
.run(*args
, env
=self
.env
, check
=False,
133 stderr
=subprocess
.PIPE
, stdout
=subprocess
.PIPE
)
135 if result
.returncode
!= 0:
136 # strip the intro line of docker-compose errors
137 errors
+= result
.stderr
.decode().splitlines()
140 msg
= '\n'.join([' - {}'.format(msg
) for msg
in errors
])
142 'Found errors with docker-compose:\n{}'.format(msg
)
145 rendered_config
= StringIO(result
.stdout
.decode())
146 self
.path
= config_path
147 self
.config
= yaml
.load(rendered_config
)
149 def get(self
, service_name
):
151 service
= self
.config
['services'][service_name
]
153 raise UndefinedImage(service_name
)
154 service
['name'] = service_name
155 service
['need_gpu'] = service_name
in self
.with_gpus
156 service
['ancestors'] = self
.hierarchy
[service_name
]
159 def __getitem__(self
, service_name
):
160 return self
.get(service_name
)
163 class Docker(Command
):
165 def __init__(self
, docker_bin
=None):
166 self
.bin
= default_bin(docker_bin
, "docker")
169 class DockerCompose(Command
):
171 def __init__(self
, config_path
, dotenv_path
=None, compose_bin
=None,
173 compose_bin
= default_bin(compose_bin
, 'docker-compose')
174 self
.config
= ComposeConfig(config_path
, dotenv_path
, compose_bin
,
176 self
.bin
= compose_bin
177 self
.pull_memory
= set()
179 def clear_pull_memory(self
):
180 self
.pull_memory
= set()
182 def _execute_compose(self
, *args
, **kwargs
):
183 # execute as a docker compose command
185 result
= super().run('--file', str(self
.config
.path
), *args
,
186 env
=self
.config
.env
, **kwargs
)
187 result
.check_returncode()
188 except subprocess
.CalledProcessError
as e
:
189 def formatdict(d
, template
):
191 template
.format(k
, v
) for k
, v
in sorted(d
.items())
194 "`{cmd}` exited with a non-zero exit code {code}, see the "
195 "process log above.\n\nThe docker-compose command was "
196 "invoked with the following parameters:\n\nDefaults defined "
197 "in .env:\n{dotenv}\n\nArchery was called with:\n{params}"
203 dotenv
=formatdict(self
.config
.dotenv
, template
=' {}: {}'),
205 self
.config
.params
, template
=' export {}={}'
210 def _execute_docker(self
, *args
, **kwargs
):
211 # execute as a plain docker cli command
213 result
= Docker().run(*args
, **kwargs
)
214 result
.check_returncode()
215 except subprocess
.CalledProcessError
as e
:
217 "{} exited with non-zero exit code {}".format(
218 ' '.join(e
.cmd
), e
.returncode
222 def pull(self
, service_name
, pull_leaf
=True, using_docker
=False):
225 if service
['image'] in self
.pull_memory
:
230 self
._execute
_docker
(*args
, service
['image'])
231 except Exception as e
:
232 # better --ignore-pull-failures handling
235 args
.append('--ignore-pull-failures')
236 self
._execute
_compose
(*args
, service
['name'])
238 self
.pull_memory
.add(service
['image'])
240 service
= self
.config
.get(service_name
)
241 for ancestor
in service
['ancestors']:
242 _pull(self
.config
.get(ancestor
))
246 def build(self
, service_name
, use_cache
=True, use_leaf_cache
=True,
247 using_docker
=False, using_buildx
=False):
248 def _build(service
, use_cache
):
249 if 'build' not in service
:
254 cache_from
= list(service
.get('build', {}).get('cache_from', []))
256 for image
in cache_from
:
257 if image
not in self
.pull_memory
:
259 self
._execute
_docker
('pull', image
)
260 except Exception as e
:
263 self
.pull_memory
.add(image
)
265 args
.append('--no-cache')
267 # turn on inline build cache, this is a docker buildx feature
268 # used to bundle the image build cache to the pushed image manifest
269 # so the build cache can be reused across hosts, documented at
270 # https://github.com/docker/buildx#--cache-tonametypetypekeyvalue
271 if self
.config
.env
.get('BUILDKIT_INLINE_CACHE') == '1':
272 args
.extend(['--build-arg', 'BUILDKIT_INLINE_CACHE=1'])
275 for k
, v
in service
['build'].get('args', {}).items():
276 args
.extend(['--build-arg', '{}={}'.format(k
, v
)])
279 cache_ref
= '{}-cache'.format(service
['image'])
280 cache_from
= 'type=registry,ref={}'.format(cache_ref
)
282 'type=registry,ref={},mode=max'.format(cache_ref
)
285 '--cache-from', cache_from
,
286 '--cache-to', cache_to
,
290 '--output', 'type=docker',
291 '-f', service
['build']['dockerfile'],
292 '-t', service
['image'],
293 service
['build'].get('context', '.')
295 self
._execute
_docker
("buildx", "build", *args
)
298 for k
, v
in service
['build'].get('args', {}).items():
299 args
.extend(['--build-arg', '{}={}'.format(k
, v
)])
300 for img
in cache_from
:
301 args
.append('--cache-from="{}"'.format(img
))
303 '-f', service
['build']['dockerfile'],
304 '-t', service
['image'],
305 service
['build'].get('context', '.')
307 self
._execute
_docker
("build", *args
)
309 self
._execute
_compose
("build", *args
, service
['name'])
311 service
= self
.config
.get(service_name
)
312 # build ancestor services
313 for ancestor
in service
['ancestors']:
314 _build(self
.config
.get(ancestor
), use_cache
=use_cache
)
315 # build the leaf/target service
316 _build(service
, use_cache
=use_cache
and use_leaf_cache
)
318 def run(self
, service_name
, command
=None, *, env
=None, volumes
=None,
319 user
=None, using_docker
=False):
320 service
= self
.config
.get(service_name
)
324 args
.extend(['-u', user
])
327 for k
, v
in env
.items():
328 args
.extend(['-e', '{}={}'.format(k
, v
)])
330 if volumes
is not None:
331 for volume
in volumes
:
332 args
.extend(['--volume', volume
])
334 if using_docker
or service
['need_gpu']:
335 # use gpus, requires docker>=19.03
336 if service
['need_gpu']:
337 args
.extend(['--gpus', 'all'])
339 if service
.get('shm_size'):
340 args
.extend(['--shm-size', service
['shm_size']])
342 # append env variables from the compose conf
343 for k
, v
in service
.get('environment', {}).items():
344 args
.extend(['-e', '{}={}'.format(k
, v
)])
346 # append volumes from the compose conf
347 for v
in service
.get('volumes', []):
348 if not isinstance(v
, str):
349 # if not the compact string volume definition
350 v
= "{}:{}".format(v
['source'], v
['target'])
351 args
.extend(['-v', v
])
353 # infer whether an interactive shell is desired or not
354 if command
in ['cmd.exe', 'bash', 'sh', 'powershell']:
357 # get the actual docker image name instead of the compose service
358 # name which we refer as image in general
359 args
.append(service
['image'])
361 # add command from compose if it wasn't overridden
362 if command
is not None:
365 # replace whitespaces from the preformatted compose command
366 cmd
= _sanitize_command(service
.get('command', ''))
370 # execute as a plain docker cli command
371 self
._execute
_docker
('run', '--rm', *args
)
373 # execute as a docker-compose command
374 args
.append(service_name
)
375 if command
is not None:
377 self
._execute
_compose
('run', '--rm', *args
)
379 def push(self
, service_name
, user
=None, password
=None, using_docker
=False):
382 return self
._execute
_docker
('push', service
['image'])
384 return self
._execute
_compose
('push', service
['name'])
388 # TODO(kszucs): have an option for a prompt
389 self
._execute
_docker
('login', '-u', user
, '-p', password
)
390 except subprocess
.CalledProcessError
:
392 msg
= ('Failed to push `{}`, check the passed credentials'
393 .format(service_name
))
394 raise RuntimeError(msg
) from None
396 service
= self
.config
.get(service_name
)
397 for ancestor
in service
['ancestors']:
398 _push(self
.config
.get(ancestor
))
402 return sorted(self
.config
.hierarchy
.keys())