occystrap 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
occystrap/common.py ADDED
@@ -0,0 +1,40 @@
1
+ import json
2
+
3
+ from occystrap.constants import RUNC_SPEC_TEMPLATE
4
+
5
+
6
+ def write_container_config(container_config_filename, runtime_config_filename,
7
+ container_template=RUNC_SPEC_TEMPLATE,
8
+ container_values=None):
9
+ if not container_values:
10
+ container_values = {}
11
+
12
+ # Read the container config
13
+ with open(container_config_filename) as f:
14
+ image_conf = json.loads(f.read())
15
+
16
+ # Write a runc specification for the container
17
+ container_conf = json.loads(container_template)
18
+
19
+ container_conf['process']['terminal'] = True
20
+ cwd = image_conf['config']['WorkingDir']
21
+ if cwd == '':
22
+ cwd = '/'
23
+ container_conf['process']['cwd'] = cwd
24
+
25
+ entrypoint = image_conf['config'].get('Entrypoint', [])
26
+ if not entrypoint:
27
+ entrypoint = []
28
+ cmd = image_conf['config'].get('Cmd', [])
29
+ if cmd:
30
+ entrypoint.extend(cmd)
31
+ container_conf['process']['args'] = entrypoint
32
+
33
+ # terminal = false means "pass through existing file descriptors"
34
+ container_conf['process']['terminal'] = False
35
+
36
+ container_conf['hostname'] = container_values.get(
37
+ 'hostname', 'occystrap')
38
+
39
+ with open(runtime_config_filename, 'w') as f:
40
+ f.write(json.dumps(container_conf, indent=4, sort_keys=True))
occystrap/constants.py CHANGED
@@ -1,2 +1,186 @@
1
1
  CONFIG_FILE = 'config_file'
2
2
  IMAGE_LAYER = 'image_layer'
3
+
4
+ RUNC_SPEC_TEMPLATE = """{
5
+ "ociVersion": "1.0.2-dev",
6
+ "process": {
7
+ "terminal": false,
8
+ "user": {
9
+ "uid": 0,
10
+ "gid": 0
11
+ },
12
+ "args": [
13
+ "sh"
14
+ ],
15
+ "env": [
16
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
17
+ "TERM=xterm"
18
+ ],
19
+ "cwd": "/",
20
+ "capabilities": {
21
+ "bounding": [
22
+ "CAP_AUDIT_WRITE",
23
+ "CAP_KILL",
24
+ "CAP_NET_BIND_SERVICE"
25
+ ],
26
+ "effective": [
27
+ "CAP_AUDIT_WRITE",
28
+ "CAP_KILL",
29
+ "CAP_NET_BIND_SERVICE"
30
+ ],
31
+ "inheritable": [
32
+ "CAP_AUDIT_WRITE",
33
+ "CAP_KILL",
34
+ "CAP_NET_BIND_SERVICE"
35
+ ],
36
+ "permitted": [
37
+ "CAP_AUDIT_WRITE",
38
+ "CAP_KILL",
39
+ "CAP_NET_BIND_SERVICE"
40
+ ],
41
+ "ambient": [
42
+ "CAP_AUDIT_WRITE",
43
+ "CAP_KILL",
44
+ "CAP_NET_BIND_SERVICE"
45
+ ]
46
+ },
47
+ "rlimits": [
48
+ {
49
+ "type": "RLIMIT_NOFILE",
50
+ "hard": 1024,
51
+ "soft": 1024
52
+ }
53
+ ],
54
+ "noNewPrivileges": true
55
+ },
56
+ "root": {
57
+ "path": "rootfs",
58
+ "readonly": true
59
+ },
60
+ "hostname": "runc",
61
+ "mounts": [
62
+ {
63
+ "destination": "/proc",
64
+ "type": "proc",
65
+ "source": "proc"
66
+ },
67
+ {
68
+ "destination": "/dev",
69
+ "type": "tmpfs",
70
+ "source": "tmpfs",
71
+ "options": [
72
+ "nosuid",
73
+ "strictatime",
74
+ "mode=755",
75
+ "size=65536k"
76
+ ]
77
+ },
78
+ {
79
+ "destination": "/dev/pts",
80
+ "type": "devpts",
81
+ "source": "devpts",
82
+ "options": [
83
+ "nosuid",
84
+ "noexec",
85
+ "newinstance",
86
+ "ptmxmode=0666",
87
+ "mode=0620",
88
+ "gid=5"
89
+ ]
90
+ },
91
+ {
92
+ "destination": "/dev/shm",
93
+ "type": "tmpfs",
94
+ "source": "shm",
95
+ "options": [
96
+ "nosuid",
97
+ "noexec",
98
+ "nodev",
99
+ "mode=1777",
100
+ "size=65536k"
101
+ ]
102
+ },
103
+ {
104
+ "destination": "/dev/mqueue",
105
+ "type": "mqueue",
106
+ "source": "mqueue",
107
+ "options": [
108
+ "nosuid",
109
+ "noexec",
110
+ "nodev"
111
+ ]
112
+ },
113
+ {
114
+ "destination": "/sys",
115
+ "type": "sysfs",
116
+ "source": "sysfs",
117
+ "options": [
118
+ "nosuid",
119
+ "noexec",
120
+ "nodev",
121
+ "ro"
122
+ ]
123
+ },
124
+ {
125
+ "destination": "/sys/fs/cgroup",
126
+ "type": "cgroup",
127
+ "source": "cgroup",
128
+ "options": [
129
+ "nosuid",
130
+ "noexec",
131
+ "nodev",
132
+ "relatime",
133
+ "ro"
134
+ ]
135
+ }
136
+ ],
137
+ "linux": {
138
+ "resources": {
139
+ "devices": [
140
+ {
141
+ "allow": false,
142
+ "access": "rwm"
143
+ }
144
+ ]
145
+ },
146
+ "namespaces": [
147
+ {
148
+ "type": "pid"
149
+ },
150
+ {
151
+ "type": "network"
152
+ },
153
+ {
154
+ "type": "ipc"
155
+ },
156
+ {
157
+ "type": "uts"
158
+ },
159
+ {
160
+ "type": "mount"
161
+ },
162
+ {
163
+ "type": "cgroup"
164
+ }
165
+ ],
166
+ "maskedPaths": [
167
+ "/proc/acpi",
168
+ "/proc/asound",
169
+ "/proc/kcore",
170
+ "/proc/keys",
171
+ "/proc/latency_stats",
172
+ "/proc/timer_list",
173
+ "/proc/timer_stats",
174
+ "/proc/sched_debug",
175
+ "/sys/firmware",
176
+ "/proc/scsi"
177
+ ],
178
+ "readonlyPaths": [
179
+ "/proc/bus",
180
+ "/proc/fs",
181
+ "/proc/irq",
182
+ "/proc/sys",
183
+ "/proc/sysrq-trigger"
184
+ ]
185
+ }
186
+ }"""
@@ -1,14 +1,16 @@
1
1
  # A simple implementation of a docker registry client. Fetches an image to a tarball.
2
2
  # With a big nod to https://github.com/NotGlop/docker-drag/blob/master/docker_pull.py
3
3
 
4
+ # https://docs.docker.com/registry/spec/manifest-v2-2/ documents the image manifest
5
+ # format, noting that the response format you get back varies based on what you have
6
+ # in your accept header for the request.
7
+
4
8
  import hashlib
5
9
  import io
6
- import json
7
10
  import logging
8
11
  import os
9
12
  import re
10
13
  import sys
11
- import tarfile
12
14
  import tempfile
13
15
  import zlib
14
16
 
@@ -18,7 +20,7 @@ from occystrap import util
18
20
  LOG = logging.getLogger(__name__)
19
21
  LOG.setLevel(logging.INFO)
20
22
 
21
- DELETED_FILE_RE = re.compile('.*/\.wh\.(.*)$')
23
+ DELETED_FILE_RE = re.compile(r'.*/\.wh\.(.*)$')
22
24
 
23
25
 
24
26
  def always_fetch():
@@ -26,10 +28,16 @@ def always_fetch():
26
28
 
27
29
 
28
30
  class Image(object):
29
- def __init__(self, registry, image, tag):
31
+ def __init__(self, registry, image, tag, os='linux', architecture='amd64', variant='',
32
+ secure=True):
30
33
  self.registry = registry
31
34
  self.image = image
32
35
  self.tag = tag
36
+ self.os = os
37
+ self.architecture = architecture
38
+ self.variant = variant
39
+ self.secure = secure
40
+
33
41
  self._cached_auth = None
34
42
 
35
43
  def request_url(self, method, url, headers=None, data=None, stream=False):
@@ -58,36 +66,79 @@ class Image(object):
58
66
 
59
67
  def fetch(self, fetch_callback=always_fetch):
60
68
  LOG.info('Fetching manifest')
69
+ moniker = 'https'
70
+ if not self.secure:
71
+ moniker = 'http'
72
+
61
73
  r = self.request_url(
62
74
  'GET',
63
- 'https://%(registry)s/v2/%(image)s/manifests/%(tag)s'
75
+ '%(moniker)s://%(registry)s/v2/%(image)s/manifests/%(tag)s'
64
76
  % {
77
+ 'moniker': moniker,
65
78
  'registry': self.registry,
66
79
  'image': self.image,
67
80
  'tag': self.tag
68
81
  },
69
- headers={'Accept': 'application/vnd.docker.distribution.manifest.v2+json'})
70
- manifest = r.json()
82
+ headers={'Accept': ('application/vnd.docker.distribution.manifest.v2+json,'
83
+ 'application/vnd.docker.distribution.manifest.list.v2+json')})
84
+
85
+ config_digest = None
86
+ if r.headers['Content-Type'] == 'application/vnd.docker.distribution.manifest.v2+json':
87
+ manifest = r.json()
88
+ config_digest = manifest['config']['digest']
89
+ elif r.headers['Content-Type'] == 'application/vnd.docker.distribution.manifest.list.v2+json':
90
+ for m in r.json()['manifests']:
91
+ if 'variant' in m['platform']:
92
+ LOG.info('Found manifest for %s on %s %s'
93
+ % (m['platform']['os'], m['platform']['architecture'],
94
+ m['platform']['variant']))
95
+ else:
96
+ LOG.info('Found manifest for %s on %s'
97
+ % (m['platform']['os'], m['platform']['architecture']))
98
+
99
+ if (m['platform']['os'] == self.os and
100
+ m['platform']['architecture'] == self.architecture and
101
+ m['platform'].get('variant', '') == self.variant):
102
+ LOG.info('Fetching matching manifest')
103
+ r = self.request_url(
104
+ 'GET',
105
+ '%(moniker)s://%(registry)s/v2/%(image)s/manifests/%(tag)s'
106
+ % {
107
+ 'moniker': moniker,
108
+ 'registry': self.registry,
109
+ 'image': self.image,
110
+ 'tag': m['digest']
111
+ },
112
+ headers={'Accept': ('application/vnd.docker.distribution.manifest.v2+json')})
113
+ manifest = r.json()
114
+ config_digest = manifest['config']['digest']
115
+
116
+ if not config_digest:
117
+ raise Exception('Could not find a matching manifest for this '
118
+ 'os / architecture / variant')
119
+ else:
120
+ raise Exception('Unknown manifest content type %s!' %
121
+ r.headers['Content-Type'])
71
122
 
72
123
  LOG.info('Fetching config file')
73
124
  r = self.request_url(
74
125
  'GET',
75
- 'https://%(registry)s/v2/%(image)s/blobs/%(config)s'
126
+ '%(moniker)s://%(registry)s/v2/%(image)s/blobs/%(config)s'
76
127
  % {
128
+ 'moniker': moniker,
77
129
  'registry': self.registry,
78
130
  'image': self.image,
79
- 'config': manifest['config']['digest']
131
+ 'config': config_digest
80
132
  })
81
133
  config = r.content
82
134
  h = hashlib.sha256()
83
135
  h.update(config)
84
- if h.hexdigest() != manifest['config']['digest'].split(':')[1]:
136
+ if h.hexdigest() != config_digest.split(':')[1]:
85
137
  LOG.error('Hash verification failed for image config blob (%s vs %s)'
86
- % (manifest['config']['digest'].split(':')[1], h.hexdigest()))
138
+ % (config_digest.split(':')[1], h.hexdigest()))
87
139
  sys.exit(1)
88
140
 
89
- config_filename = ('%s.json'
90
- % manifest['config']['digest'].split(':')[1])
141
+ config_filename = ('%s.json' % config_digest.split(':')[1])
91
142
  yield (constants.CONFIG_FILE, config_filename,
92
143
  io.BytesIO(config))
93
144
 
@@ -96,15 +147,16 @@ class Image(object):
96
147
  layer_filename = layer['digest'].split(':')[1]
97
148
  if not fetch_callback(layer_filename):
98
149
  LOG.info('Fetch callback says skip layer %s' % layer['digest'])
99
- yield(constants.IMAGE_LAYER, layer_filename, None)
150
+ yield (constants.IMAGE_LAYER, layer_filename, None)
100
151
  continue
101
152
 
102
153
  LOG.info('Fetching layer %s (%d bytes)'
103
154
  % (layer['digest'], layer['size']))
104
155
  r = self.request_url(
105
156
  'GET',
106
- 'https://%(registry)s/v2/%(image)s/blobs/%(layer)s'
157
+ '%(moniker)s://%(registry)s/v2/%(image)s/blobs/%(layer)s'
107
158
  % {
159
+ 'moniker': moniker,
108
160
  'registry': self.registry,
109
161
  'image': self.image,
110
162
  'layer': layer['digest']
@@ -128,18 +180,11 @@ class Image(object):
128
180
 
129
181
  if h.hexdigest() != layer_filename:
130
182
  LOG.error('Hash verification failed for layer (%s vs %s)'
131
- % (name, h.hexdigest()))
183
+ % (layer_filename, h.hexdigest()))
132
184
  sys.exit(1)
133
185
 
134
- with tarfile.open(tf.name) as layer:
135
- for mem in layer.getmembers():
136
- m = DELETED_FILE_RE.match(mem.name)
137
- if m:
138
- LOG.info('Layer tarball contains deleted file: %s'
139
- % mem.name)
140
-
141
186
  with open(tf.name, 'rb') as f:
142
- yield(constants.IMAGE_LAYER, layer_filename, f)
187
+ yield (constants.IMAGE_LAYER, layer_filename, f)
143
188
 
144
189
  finally:
145
190
  os.unlink(tf.name)
occystrap/main.py CHANGED
@@ -1,27 +1,40 @@
1
1
  import click
2
2
  import logging
3
+ import os
4
+ from shakenfist_utilities import logs
5
+ import sys
3
6
 
4
7
  from occystrap import docker_registry
5
8
  from occystrap import output_directory
9
+ from occystrap import output_mounts
10
+ from occystrap import output_ocibundle
6
11
  from occystrap import output_tarfile
7
12
 
8
- logging.basicConfig(level=logging.INFO)
9
13
 
10
- LOG = logging.getLogger(__name__)
11
- LOG.setLevel(logging.INFO)
14
+ LOG = logs.setup_console(__name__)
12
15
 
13
16
 
14
17
  @click.group()
15
18
  @click.option('--verbose', is_flag=True)
19
+ @click.option('--os', default='linux')
20
+ @click.option('--architecture', default='amd64')
21
+ @click.option('--variant', default='')
16
22
  @click.pass_context
17
- def cli(ctx, verbose=None):
23
+ def cli(ctx, verbose=None, os=None, architecture=None, variant=None):
18
24
  if verbose:
19
25
  logging.basicConfig(level=logging.DEBUG)
20
26
  LOG.setLevel(logging.DEBUG)
21
27
 
28
+ if not ctx.obj:
29
+ ctx.obj = {}
30
+ ctx.obj['OS'] = os
31
+ ctx.obj['ARCHITECTURE'] = architecture
32
+ ctx.obj['VARIANT'] = variant
22
33
 
23
- def _fetch(registry, image, tag, output):
24
- img = docker_registry.Image(registry, image, tag)
34
+
35
+ def _fetch(registry, image, tag, output, os, architecture, variant, secure=True):
36
+ img = docker_registry.Image(
37
+ registry, image, tag, os, architecture, variant, secure=secure)
25
38
  for image_element in img.fetch(fetch_callback=output.fetch_callback):
26
39
  output.process_image_element(*image_element)
27
40
  output.finalize()
@@ -34,11 +47,14 @@ def _fetch(registry, image, tag, output):
34
47
  @click.argument('path')
35
48
  @click.option('--use-unique-names', is_flag=True)
36
49
  @click.option('--expand', is_flag=True)
50
+ @click.option('--insecure', is_flag=True, default=False)
37
51
  @click.pass_context
38
- def fetch_to_extracted(ctx, registry, image, tag, path, use_unique_names, expand):
52
+ def fetch_to_extracted(ctx, registry, image, tag, path, use_unique_names,
53
+ expand, insecure):
39
54
  d = output_directory.DirWriter(
40
55
  image, tag, path, unique_names=use_unique_names, expand=expand)
41
- _fetch(registry, image, tag, d)
56
+ _fetch(registry, image, tag, d, ctx.obj['OS'], ctx.obj['ARCHITECTURE'],
57
+ ctx.obj['VARIANT'], secure=(not insecure))
42
58
 
43
59
  if expand:
44
60
  d.write_bundle()
@@ -47,20 +63,63 @@ def fetch_to_extracted(ctx, registry, image, tag, path, use_unique_names, expand
47
63
  cli.add_command(fetch_to_extracted)
48
64
 
49
65
 
66
+ @click.command()
67
+ @click.argument('registry')
68
+ @click.argument('image')
69
+ @click.argument('tag')
70
+ @click.argument('path')
71
+ @click.option('--insecure', is_flag=True, default=False)
72
+ @click.pass_context
73
+ def fetch_to_oci(ctx, registry, image, tag, path, insecure):
74
+ d = output_ocibundle.OCIBundleWriter(image, tag, path)
75
+ _fetch(registry, image, tag, d, ctx.obj['OS'], ctx.obj['ARCHITECTURE'],
76
+ ctx.obj['VARIANT'], secure=(not insecure))
77
+ d.write_bundle()
78
+
79
+
80
+ cli.add_command(fetch_to_oci)
81
+
82
+
50
83
  @click.command()
51
84
  @click.argument('registry')
52
85
  @click.argument('image')
53
86
  @click.argument('tag')
54
87
  @click.argument('tarfile')
88
+ @click.option('--insecure', is_flag=True, default=False)
55
89
  @click.pass_context
56
- def fetch_to_tarfile(ctx, registry, image, tag, tarfile):
90
+ def fetch_to_tarfile(ctx, registry, image, tag, tarfile, insecure):
57
91
  tar = output_tarfile.TarWriter(image, tag, tarfile)
58
- _fetch(registry, image, tag, tar)
92
+ _fetch(registry, image, tag, tar, ctx.obj['OS'], ctx.obj['ARCHITECTURE'],
93
+ ctx.obj['VARIANT'], secure=(not insecure))
59
94
 
60
95
 
61
96
  cli.add_command(fetch_to_tarfile)
62
97
 
63
98
 
99
+ @click.command()
100
+ @click.argument('registry')
101
+ @click.argument('image')
102
+ @click.argument('tag')
103
+ @click.argument('path')
104
+ @click.option('--insecure', is_flag=True, default=False)
105
+ @click.pass_context
106
+ def fetch_to_mounts(ctx, registry, image, tag, path, insecure):
107
+ if not hasattr(os, 'setxattr'):
108
+ print('Sorry, your OS module implementation lacks setxattr')
109
+ sys.exit(1)
110
+ if not hasattr(os, 'mknod'):
111
+ print('Sorry, your OS module implementation lacks mknod')
112
+ sys.exit(1)
113
+
114
+ d = output_mounts.MountWriter(image, tag, path)
115
+ _fetch(registry, image, tag, d, ctx.obj['OS'], ctx.obj['ARCHITECTURE'],
116
+ ctx.obj['VARIANT'], secure=(not insecure))
117
+ d.write_bundle()
118
+
119
+
120
+ cli.add_command(fetch_to_mounts)
121
+
122
+
64
123
  @click.command()
65
124
  @click.argument('path')
66
125
  @click.argument('image')
@@ -1,11 +1,7 @@
1
- import hashlib
2
1
  import json
3
2
  import logging
4
3
  import os
5
- import prettytable
6
- import sys
7
4
  import tarfile
8
- import zlib
9
5
 
10
6
  from occystrap import constants
11
7
 
@@ -128,14 +124,13 @@ class DirWriter(object):
128
124
  def _create_bundle_path(self, path):
129
125
  d = self.bundle
130
126
  for elem in path.split('/'):
131
- if not elem in d:
127
+ if elem not in d:
132
128
  d[elem] = {}
133
129
  d = d[elem]
134
130
  return d
135
131
 
136
132
  def fetch_callback(self, digest):
137
- layer_file_in_dir = os.path.join(self.image_path,
138
- digest, 'layer.tar')
133
+ layer_file_in_dir = os.path.join(self.image_path, digest, 'layer.tar')
139
134
  LOG.info('Layer file is %s' % layer_file_in_dir)
140
135
  return not os.path.exists(layer_file_in_dir)
141
136
 
@@ -168,16 +163,39 @@ class DirWriter(object):
168
163
  # Build a in-memory map of the layout of the final image bundle
169
164
  with tarfile.open(layer_file_in_dir) as layer:
170
165
  for mem in layer.getmembers():
171
- filename = os.path.split(mem.name)[1]
172
- if filename.startswith('.wh.'):
166
+ path = mem.name
167
+ dirname, filename = os.path.split(mem.name)
168
+
169
+ # Some light reading on how this works...
170
+ # https://github.com/opencontainers/image-spec/blob/main/layer.md#opaque-whiteout
171
+ if filename == '.wh..wh..opq':
172
+ # A deleted directory, but only for layers below
173
+ # this one.
174
+ for ent in self.bundle:
175
+ if (ent.startswith(dirname) and
176
+ self.bundle[ent][-1].tarpath != layer_file):
177
+ self.bundle[ent].append(
178
+ BundleDeletedFile(ent, layer_file, mem))
179
+ continue
180
+
181
+ elif filename.startswith('.wh.'):
182
+ # A single deleted element, which might not be a
183
+ # file.
184
+ path = os.path.join(dirname, filename[4:])
185
+ if type(self.bundle[path][-1]) is BundleDirectory:
186
+ for ent in self.bundle:
187
+ if ent.startswith(path):
188
+ self.bundle[ent].append(
189
+ BundleDeletedFile(ent, layer_file, mem))
190
+
173
191
  serialized = BundleDeletedFile(
174
- mem.name, layer_file, mem)
192
+ path, layer_file, mem)
175
193
  else:
176
194
  serialized = TARFILE_TYPE_MAP[mem.type](
177
195
  mem.name, layer_file, mem)
178
196
 
179
- self.bundle.setdefault(mem.name, [])
180
- self.bundle[mem.name].append(serialized)
197
+ self.bundle.setdefault(path, [])
198
+ self.bundle[path].append(serialized)
181
199
 
182
200
  def _log_bundle(self):
183
201
  savings = 0
@@ -185,13 +203,17 @@ class DirWriter(object):
185
203
  for path in self.bundle:
186
204
  versions = len(self.bundle[path])
187
205
  if versions > 1:
188
- LOG.info('Bundle path %s has %d versions'
206
+ path_savings = 0
207
+ LOG.info('Bundle path "%s" has %d versions'
189
208
  % (path, versions))
190
209
  for ver in self.bundle[path][:-1]:
191
- savings += ver.size
210
+ path_savings += ver.size
211
+ if type(self.bundle[path][-1]) is BundleDeletedFile:
212
+ LOG.info('Bundle path "%s" final version is a deleted file, '
213
+ 'which wasted %d bytes.' % (path, path_savings))
214
+ savings += path_savings
192
215
 
193
- LOG.info('Flattening image would save %d bytes'
194
- % savings)
216
+ LOG.info('Flattening image would save %d bytes' % savings)
195
217
 
196
218
  def finalize(self):
197
219
  if self.expand:
@@ -214,13 +236,7 @@ class DirWriter(object):
214
236
  with open(catalog_path, 'w') as f:
215
237
  f.write(json.dumps(c, indent=4, sort_keys=True))
216
238
 
217
- def write_bundle(self):
218
- LOG.info('Writing image bundle')
219
- manifest_filename = self._manifest_filename()
220
- manifest_path = os.path.join(self.image_path, manifest_filename)
221
- if not os.path.exists(manifest_path):
222
- os.makedirs(manifest_path)
223
-
239
+ def _extract_rootfs(self, rootfs_path):
224
240
  # Reading tarfiles is expensive, as tarfile needs to scan the
225
241
  # entire file to find the right entry. It builds a cache while
226
242
  # doing this however, so performance improves if you access a
@@ -250,14 +266,20 @@ class DirWriter(object):
250
266
  for tarpath in entities_by_layer:
251
267
  with tarfile.open(os.path.join(self.image_path, tarpath)) as layer:
252
268
  for ent in entities_by_layer[tarpath]:
253
- entdest = os.path.join(manifest_path, ent.name)
254
- layer.extract(ent.name, path=entdest)
269
+ layer.extract(ent.name, path=rootfs_path)
255
270
 
256
271
  for tarpath in deferred_by_layer:
257
272
  with tarfile.open(os.path.join(self.image_path, tarpath)) as layer:
258
273
  for ent in deferred_by_layer[tarpath]:
259
- entdest = os.path.join(manifest_path, ent.name)
260
- layer.extract(ent.name, path=entdest)
274
+ layer.extract(ent.name, path=rootfs_path)
275
+
276
+ def write_bundle(self):
277
+ manifest_filename = self._manifest_filename()
278
+ manifest_path = os.path.join(self.image_path, manifest_filename)
279
+ if not os.path.exists(manifest_path):
280
+ os.makedirs(manifest_path)
281
+ LOG.info('Writing image bundle to %s' % manifest_path)
282
+ self._extract_rootfs(manifest_path)
261
283
 
262
284
 
263
285
  class NoSuchImageException(Exception):
@@ -276,9 +298,9 @@ class DirReader(object):
276
298
  with open(catalog_path, 'r') as f:
277
299
  c = json.loads(f.read())
278
300
 
279
- if not self.image in c:
301
+ if self.image not in c:
280
302
  raise NoSuchImageException(self.image)
281
- if not self.tag in c[self.image]:
303
+ if self.tag not in c[self.image]:
282
304
  raise NoSuchImageException(self.image)
283
305
 
284
306
  self.manifest_filename = c[self.image][self.tag]
@@ -289,7 +311,7 @@ class DirReader(object):
289
311
 
290
312
  config_filename = manifest[0]['Config']
291
313
  with open(os.path.join(self.path, config_filename), 'rb') as f:
292
- yield(constants.CONFIG_FILE, config_filename, f)
314
+ yield (constants.CONFIG_FILE, config_filename, f)
293
315
 
294
316
  for layer in manifest[0]['Layers']:
295
317
  with open(os.path.join(self.path, layer), 'rb') as f:
@@ -0,0 +1,155 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import stat
5
+ import tarfile
6
+
7
+ from occystrap import common
8
+ from occystrap import constants
9
+ from occystrap import util
10
+
11
+
12
+ LOG = logging.getLogger(__name__)
13
+ LOG.setLevel(logging.INFO)
14
+
15
+
16
+ class MountWriter(object):
17
+ def __init__(self, image, tag, image_path):
18
+ self.image = image
19
+ self.tag = tag
20
+ self.image_path = image_path
21
+
22
+ self.tar_manifest = [{
23
+ 'Layers': [],
24
+ 'RepoTags': ['%s:%s' % (self.image.split('/')[-1], self.tag)]
25
+ }]
26
+
27
+ self.bundle = {}
28
+
29
+ if not os.path.exists(self.image_path):
30
+ os.makedirs(self.image_path)
31
+
32
+ def _manifest_filename(self):
33
+ return 'manifest'
34
+
35
+ def fetch_callback(self, digest):
36
+ layer_file_in_dir = os.path.join(self.image_path, digest, 'layer.tar')
37
+ LOG.info('Layer file is %s' % layer_file_in_dir)
38
+ return not os.path.exists(layer_file_in_dir)
39
+
40
+ def process_image_element(self, element_type, name, data):
41
+ if element_type == constants.CONFIG_FILE:
42
+ with open(os.path.join(self.image_path, name), 'wb') as f:
43
+ d = json.loads(data.read())
44
+ f.write(json.dumps(d, indent=4, sort_keys=True).encode('ascii'))
45
+ self.tar_manifest[0]['Config'] = name
46
+
47
+ elif element_type == constants.IMAGE_LAYER:
48
+ layer_dir = os.path.join(self.image_path, name)
49
+ if not os.path.exists(layer_dir):
50
+ os.makedirs(layer_dir)
51
+
52
+ layer_file = os.path.join(name, 'layer.tar')
53
+ self.tar_manifest[0]['Layers'].append(layer_file)
54
+
55
+ layer_file_in_dir = os.path.join(self.image_path, layer_file)
56
+ if os.path.exists(layer_file_in_dir):
57
+ LOG.info('Skipping layer already in output directory')
58
+ else:
59
+ with open(layer_file_in_dir, 'wb') as f:
60
+ d = data.read(102400)
61
+ while d:
62
+ f.write(d)
63
+ d = data.read(102400)
64
+
65
+ layer_dir_in_dir = os.path.join(self.image_path, name, 'layer')
66
+ os.makedirs(layer_dir_in_dir)
67
+ with tarfile.open(layer_file_in_dir) as layer:
68
+ for mem in layer.getmembers():
69
+ dirname, filename = os.path.split(mem.name)
70
+
71
+ # Some light reading on how this works...
72
+ # https://www.madebymikal.com/interpreting-whiteout-files-in-docker-image-layers/
73
+ # https://github.com/opencontainers/image-spec/blob/main/layer.md#opaque-whiteout
74
+ if filename == '.wh..wh..opq':
75
+ # A deleted directory, but only for layers below
76
+ # this one.
77
+ os.setxattr(os.path.join(layer_dir_in_dir, dirname),
78
+ 'trusted.overlay.opaque', b'y')
79
+
80
+ elif filename.startswith('.wh.'):
81
+ # A single deleted element, which might not be a
82
+ # file.
83
+ os.mknod(os.path.join(layer_dir_in_dir,
84
+ mem.name[4:]),
85
+ mode=stat.S_IFCHR, device=0)
86
+
87
+ else:
88
+ path = mem.name
89
+ layer.extract(path, path=layer_dir_in_dir)
90
+
91
+ def finalize(self):
92
+ manifest_filename = self._manifest_filename() + '.json'
93
+ manifest_path = os.path.join(self.image_path, manifest_filename)
94
+ with open(manifest_path, 'wb') as f:
95
+ f.write(json.dumps(self.tar_manifest, indent=4,
96
+ sort_keys=True).encode('ascii'))
97
+
98
+ c = {}
99
+ catalog_path = os.path.join(self.image_path, 'catalog.json')
100
+ if os.path.exists(catalog_path):
101
+ with open(catalog_path, 'r') as f:
102
+ c = json.loads(f.read())
103
+
104
+ c.setdefault(self.image, {})
105
+ c[self.image][self.tag] = manifest_filename
106
+ with open(catalog_path, 'w') as f:
107
+ f.write(json.dumps(c, indent=4, sort_keys=True))
108
+
109
+ def write_bundle(self, container_template=constants.RUNC_SPEC_TEMPLATE,
110
+ container_values=None):
111
+ if not container_values:
112
+ container_values = {}
113
+
114
+ rootfs_path = os.path.join(self.image_path, 'rootfs')
115
+ if not os.path.exists(rootfs_path):
116
+ os.makedirs(rootfs_path)
117
+ LOG.info('Writing image bundle to %s' % rootfs_path)
118
+
119
+ working_path = os.path.join(self.image_path, 'working')
120
+ if not os.path.exists(working_path):
121
+ os.makedirs(working_path)
122
+
123
+ delta_path = os.path.join(self.image_path, 'delta')
124
+ if not os.path.exists(delta_path):
125
+ os.makedirs(delta_path)
126
+
127
+ # The newest layer is listed first in the mount command
128
+ layer_dirs = []
129
+ self.tar_manifest[0]['Layers'].reverse()
130
+ for layer in self.tar_manifest[0]['Layers']:
131
+ layer_dirs.append(os.path.join(
132
+ self.image_path, layer.replace('.tar', '')))
133
+
134
+ # Extract the rootfs as overlay mounts
135
+ util.execute('mount -t overlay overlay -o lowerdir=%(layers)s,'
136
+ 'upperdir=%(upper)s,workdir=%(working)s %(rootfs)s'
137
+ % {
138
+ 'layers': ':'.join(layer_dirs),
139
+ 'upper': delta_path,
140
+ 'working': working_path,
141
+ 'rootfs': rootfs_path
142
+ })
143
+
144
+ # Rename the container configuration to a well known location. This is
145
+ # not part of the OCI specification, but is convenient for now.
146
+ container_config_filename = os.path.join(self.image_path,
147
+ 'container-config.json')
148
+ runtime_config_filename = os.path.join(self.image_path, 'config.json')
149
+ os.rename(os.path.join(self.image_path, self.tar_manifest[0]['Config']),
150
+ container_config_filename)
151
+
152
+ common.write_container_config(container_config_filename,
153
+ runtime_config_filename,
154
+ container_template=container_template,
155
+ container_values=container_values)
@@ -0,0 +1,53 @@
1
+ # OCI bundles are a special case of our directory output -- they don't contain
2
+ # all of the data that a directory output does, and they place the data they
3
+ # do contain into different locations within the directory structure.
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+
9
+ from occystrap.constants import RUNC_SPEC_TEMPLATE
10
+ from occystrap import common
11
+ from occystrap.output_directory import DirWriter
12
+
13
+
14
+ LOG = logging.getLogger(__name__)
15
+ LOG.setLevel(logging.INFO)
16
+
17
+
18
+ class OCIBundleWriter(DirWriter):
19
+ def __init__(self, image, tag, image_path):
20
+ super(OCIBundleWriter, self).__init__(
21
+ image, tag, image_path, expand=True)
22
+
23
+ def finalize(self):
24
+ self._log_bundle()
25
+
26
+ def write_bundle(self, container_template=RUNC_SPEC_TEMPLATE,
27
+ container_values=None):
28
+ if not container_values:
29
+ container_values = {}
30
+
31
+ rootfs_path = os.path.join(self.image_path, 'rootfs')
32
+ if not os.path.exists(rootfs_path):
33
+ os.makedirs(rootfs_path)
34
+ LOG.info('Writing image bundle to %s' % rootfs_path)
35
+ self._extract_rootfs(rootfs_path)
36
+
37
+ # Remove parts of the output directory which are not present in OCI
38
+ for layer_file in self.tar_manifest[0]['Layers']:
39
+ shutil.rmtree(os.path.join(self.image_path,
40
+ os.path.split(layer_file)[0]))
41
+
42
+ # Rename the container configuration to a well known location. This is
43
+ # not part of the OCI specification, but is convenient for now.
44
+ container_config_filename = os.path.join(self.image_path,
45
+ 'container-config.json')
46
+ runtime_config_filename = os.path.join(self.image_path, 'config.json')
47
+ os.rename(os.path.join(self.image_path, self.tar_manifest[0]['Config']),
48
+ container_config_filename)
49
+
50
+ common.write_container_config(container_config_filename,
51
+ runtime_config_filename,
52
+ container_template=container_template,
53
+ container_values=container_values)
@@ -1,13 +1,8 @@
1
- import hashlib
2
1
  import io
3
2
  import json
4
3
  import logging
5
4
  import os
6
- import re
7
- import sys
8
5
  import tarfile
9
- import tempfile
10
- import zlib
11
6
 
12
7
  from occystrap import constants
13
8
 
occystrap/util.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import logging
3
+ from oslo_concurrency import processutils
3
4
  from pbr.version import VersionInfo
4
5
  import requests
5
6
 
@@ -23,7 +24,7 @@ STATUS_CODES_TO_ERRORS = {
23
24
  def get_user_agent():
24
25
  try:
25
26
  version = VersionInfo('occystrap').version_string()
26
- except:
27
+ except Exception:
27
28
  version = '0.0.0'
28
29
  return 'Mozilla/5.0 (Ubuntu; Linux x86_64) Occy Strap/%s' % version
29
30
 
@@ -74,3 +75,10 @@ def request_url(method, url, headers=None, data=None, stream=False):
74
75
  raise APIException(
75
76
  'API request failed', method, url, r.status_code, r.text, r.headers)
76
77
  return r
78
+
79
+
80
+ def execute(command, check_exit_code=[0], env_variables=None,
81
+ cwd=None):
82
+ return processutils.execute(
83
+ command, check_exit_code=check_exit_code,
84
+ env_variables=env_variables, shell=True, cwd=cwd)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: occystrap
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: occystrap: docker and OCI container tools
5
5
  Home-page: https://github.com/shakenfist/occystrap
6
6
  Author: Michael Still
@@ -16,9 +16,11 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.7
17
17
  Description-Content-Type: text/markdown
18
18
  Requires-Dist: click (>=7.1.1)
19
+ Requires-Dist: oslo.concurrency
19
20
  Requires-Dist: pbr
20
21
  Requires-Dist: prettytable
21
22
  Requires-Dist: requests
23
+ Requires-Dist: shakenfist-utilities
22
24
 
23
25
  # Occy Strap
24
26
 
@@ -98,5 +100,32 @@ occystrap fetch-to-extracted --expand quay.io \
98
100
 
99
101
  Note that layers delete files from previous layers with files named ".wh.$previousfilename". These files are _not_ processed in the expanded layers, so that they are visible to the user. They are however processed in the merged layer named for the manifest file.
100
102
 
103
+ ## Generating an OCI runtime bundle
104
+
105
+ This isn't fully supported yet, but you can extract an image to an OCI image bundle
106
+ with the following command:
107
+
108
+ ```
109
+ occystrap fetch-to-oci registry-1.docker.io library/hello-world latest bar
110
+ ```
111
+
112
+ You should then be able to run that container by doing something like:
113
+
114
+ ```
115
+ cd bar
116
+ sudo apt-get install runc
117
+ sudo runc run id-0001
118
+ ```
119
+
120
+ ## Supporting non-default architectures
121
+
122
+ Docker image repositories can store multiple versions of a single image, with each image corresponding to a different (operating system, cpu architecture, cpu variant) tuple. Occy Strap supports letting you specify which to use with global command line flags. Occy Strap defaults to linux amd64 if you don't specify something different. For example, to fetch the linux arm64 v8 image for busybox, you would run:
123
+
124
+ ```
125
+ occystrap --os linux --architecture arm64 --variant v8 \
126
+ fetch-to-extracted registry-1.docker.io library/busybox \
127
+ latest busybox
128
+ ```
129
+
101
130
 
102
131
 
@@ -0,0 +1,20 @@
1
+ occystrap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ occystrap/common.py,sha256=Zm4hHpn8RgSXp0W86HhZzpyXq19QIsLJgp9SxK_1QQg,1300
3
+ occystrap/constants.py,sha256=kmOt-12settGbDTW1efpT3UENRQouG9f0ZjgOqWdrIA,4399
4
+ occystrap/docker_extract.py,sha256=j2GSIOShZZh0c5gckXeu-SO7201p4S1EJg3fi7WPQHk,1055
5
+ occystrap/docker_registry.py,sha256=C-T1bY1UPJfBZB_Q5gqcZYODikxnrTQU2MfYyoux950,7658
6
+ occystrap/main.py,sha256=sevQkqAqgnZRrt61DMUPz9xzoE4AIcWYx2_ZurO_Lls,4059
7
+ occystrap/output_directory.py,sha256=S-uL8NSHyHsVKeWsvPRT63E7wE1pWaluGHOFVsS09Xg,11408
8
+ occystrap/output_mounts.py,sha256=AH4vouBF9PmhQ5oGfsSZyN1FhatWbs_20IDlI9psn_k,6381
9
+ occystrap/output_ocibundle.py,sha256=lsVW66ltL2Y-Mxh5Hp2KSCdh9bvsME1142CJ_Uh99oo,2154
10
+ occystrap/output_tarfile.py,sha256=Di8N_2TSo-gQz490D3XOsKCYiv5T_bxZzTRFbd4WGBA,1620
11
+ occystrap/util.py,sha256=nrKfAxDUjb_v9ERDXTmMSSNdJSuLgjdtwOMWk46n0a0,2720
12
+ occystrap/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ occystrap-0.3.0.dist-info/AUTHORS,sha256=toKLUaf9c-NkNow00B_akwMGcGtm-S_ihcC_eql9qWc,34
14
+ occystrap-0.3.0.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
15
+ occystrap-0.3.0.dist-info/METADATA,sha256=PaCk2BSIcly80vIV4LiPlpXoQ9CbYKtb7S5UpaAI5BQ,6308
16
+ occystrap-0.3.0.dist-info/WHEEL,sha256=g4nMs7d-Xl9-xC9XovUrsDHGXt-FT0E17Yqo92DEfvY,92
17
+ occystrap-0.3.0.dist-info/entry_points.txt,sha256=51kLRjAxFtC6GWCbTFGezSYMNk5t6xrBmS8Pf7gehiU,50
18
+ occystrap-0.3.0.dist-info/pbr.json,sha256=2zD1Bsq8TK1jHn5K3MGWkgXT6TxdPUw9MsWVY-Pe89c,46
19
+ occystrap-0.3.0.dist-info/top_level.txt,sha256=06nN7FHq2z_Jpp2PZNm3rGOGUA1cIGlUr6MEZrqgOlc,10
20
+ occystrap-0.3.0.dist-info/RECORD,,
@@ -0,0 +1 @@
1
+ {"git_version": "27e37dc", "is_release": true}
@@ -1,17 +0,0 @@
1
- occystrap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- occystrap/constants.py,sha256=wZwFTAzrxq8Gb2kT7ZRGa6cAz0nHyvnuzR4KrrY2RI0,56
3
- occystrap/docker_extract.py,sha256=j2GSIOShZZh0c5gckXeu-SO7201p4S1EJg3fi7WPQHk,1055
4
- occystrap/docker_registry.py,sha256=CLaUWK9uBHKM3KCGfDWw8myF9nndWdEWIztxGd3Fdeg,5416
5
- occystrap/main.py,sha256=xNNbPo-cpcUe8hfQgpIyTt0ORsiDtWMF-55ImPb8330,2003
6
- occystrap/output_directory.py,sha256=vhdX0jQQdzhQoCmsu9MCwl6nl1mon4jvkUXYGUEGmko,9938
7
- occystrap/output_tarfile.py,sha256=R_kdJd_uitEfl1EyczHZ6n_IvafwzkTVuAIjEbV73Q4,1684
8
- occystrap/util.py,sha256=Kdzae1pkIRt5uAm1-pgwJFWLI8C7QA7KYkgstPs9yYQ,2440
9
- occystrap/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- occystrap-0.2.0.dist-info/AUTHORS,sha256=toKLUaf9c-NkNow00B_akwMGcGtm-S_ihcC_eql9qWc,34
11
- occystrap-0.2.0.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
12
- occystrap-0.2.0.dist-info/METADATA,sha256=3wqUvQETACudMvSBxlgzNWbt1o0_yrFN15CgjT2lotg,5269
13
- occystrap-0.2.0.dist-info/WHEEL,sha256=g4nMs7d-Xl9-xC9XovUrsDHGXt-FT0E17Yqo92DEfvY,92
14
- occystrap-0.2.0.dist-info/entry_points.txt,sha256=51kLRjAxFtC6GWCbTFGezSYMNk5t6xrBmS8Pf7gehiU,50
15
- occystrap-0.2.0.dist-info/pbr.json,sha256=kxHs4Gx_jL71k5Snp4MPDUPUNjY6OSZIcmRk9xnPcPY,46
16
- occystrap-0.2.0.dist-info/top_level.txt,sha256=06nN7FHq2z_Jpp2PZNm3rGOGUA1cIGlUr6MEZrqgOlc,10
17
- occystrap-0.2.0.dist-info/RECORD,,
@@ -1 +0,0 @@
1
- {"git_version": "0f9c4a4", "is_release": true}