occystrap 0.4.0__py3-none-any.whl → 0.4.1__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.
Files changed (38) hide show
  1. occystrap/_version.py +34 -0
  2. occystrap/filters/__init__.py +10 -0
  3. occystrap/filters/base.py +67 -0
  4. occystrap/filters/exclude.py +136 -0
  5. occystrap/filters/inspect.py +179 -0
  6. occystrap/filters/normalize_timestamps.py +123 -0
  7. occystrap/filters/search.py +177 -0
  8. occystrap/inputs/__init__.py +1 -0
  9. occystrap/inputs/base.py +40 -0
  10. occystrap/inputs/docker.py +171 -0
  11. occystrap/{docker_registry.py → inputs/registry.py} +112 -50
  12. occystrap/inputs/tarfile.py +88 -0
  13. occystrap/main.py +330 -31
  14. occystrap/outputs/__init__.py +1 -0
  15. occystrap/outputs/base.py +46 -0
  16. occystrap/{output_directory.py → outputs/directory.py} +10 -9
  17. occystrap/outputs/docker.py +137 -0
  18. occystrap/{output_mounts.py → outputs/mounts.py} +2 -1
  19. occystrap/{output_ocibundle.py → outputs/ocibundle.py} +1 -1
  20. occystrap/outputs/registry.py +240 -0
  21. occystrap/{output_tarfile.py → outputs/tarfile.py} +18 -2
  22. occystrap/pipeline.py +297 -0
  23. occystrap/tarformat.py +122 -0
  24. occystrap/tests/test_inspect.py +355 -0
  25. occystrap/tests/test_tarformat.py +199 -0
  26. occystrap/uri.py +231 -0
  27. occystrap/util.py +67 -38
  28. occystrap-0.4.1.dist-info/METADATA +444 -0
  29. occystrap-0.4.1.dist-info/RECORD +38 -0
  30. {occystrap-0.4.0.dist-info → occystrap-0.4.1.dist-info}/WHEEL +1 -1
  31. {occystrap-0.4.0.dist-info → occystrap-0.4.1.dist-info}/entry_points.txt +0 -1
  32. occystrap/docker_extract.py +0 -36
  33. occystrap-0.4.0.dist-info/METADATA +0 -131
  34. occystrap-0.4.0.dist-info/RECORD +0 -20
  35. occystrap-0.4.0.dist-info/pbr.json +0 -1
  36. {occystrap-0.4.0.dist-info → occystrap-0.4.1.dist-info/licenses}/AUTHORS +0 -0
  37. {occystrap-0.4.0.dist-info → occystrap-0.4.1.dist-info/licenses}/LICENSE +0 -0
  38. {occystrap-0.4.0.dist-info → occystrap-0.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,137 @@
1
+ # Load images into the local Docker or Podman daemon via the Docker Engine API.
2
+ # This communicates over a Unix domain socket (default: /var/run/docker.sock).
3
+ #
4
+ # Docker Engine API documentation:
5
+ # https://docs.docker.com/engine/api/
6
+ #
7
+ # Podman compatibility:
8
+ # Podman provides a Docker-compatible API via podman.socket. Use the socket
9
+ # option to point to the Podman socket:
10
+ # - Rootful: /run/podman/podman.sock
11
+ # - Rootless: /run/user/<uid>/podman/podman.sock
12
+ # See: https://docs.podman.io/en/latest/markdown/podman-system-service.1.html
13
+ #
14
+ # The API accepts images in the same format as 'docker load', which is the
15
+ # v1.2 tarball format that outputs/tarfile.py creates.
16
+
17
+ import io
18
+ import json
19
+ import logging
20
+ import os
21
+ import tarfile
22
+ import tempfile
23
+
24
+ import requests_unixsocket
25
+
26
+ from occystrap import constants
27
+ from occystrap.outputs.base import ImageOutput
28
+
29
+
30
+ LOG = logging.getLogger(__name__)
31
+ LOG.setLevel(logging.INFO)
32
+
33
+ DEFAULT_SOCKET_PATH = '/var/run/docker.sock'
34
+
35
+
36
+ class DockerWriter(ImageOutput):
37
+ """Loads images into the local Docker daemon.
38
+
39
+ This output writer builds a v1.2 format tarball and loads it into
40
+ the Docker daemon using the POST /images/load API endpoint. This is
41
+ equivalent to running 'docker load'.
42
+
43
+ Uses USTAR format for the outer tarball which contains only short paths
44
+ (SHA256 hashes and small filenames), avoiding PAX extended headers.
45
+ """
46
+
47
+ def __init__(self, image, tag, socket_path=DEFAULT_SOCKET_PATH):
48
+ """Initialize the Docker writer.
49
+
50
+ Args:
51
+ image: The image name.
52
+ tag: The image tag.
53
+ socket_path: Path to the Docker socket (default: /var/run/docker.sock).
54
+ """
55
+ self.image = image
56
+ self.tag = tag
57
+ self.socket_path = socket_path
58
+ self._session = None
59
+
60
+ self._temp_file = tempfile.NamedTemporaryFile(delete=False)
61
+ self._image_tar = tarfile.open(fileobj=self._temp_file, mode='w',
62
+ format=tarfile.USTAR_FORMAT)
63
+
64
+ self._tar_manifest = [{
65
+ 'Layers': [],
66
+ 'RepoTags': ['%s:%s' % (self.image.split('/')[-1], self.tag)]
67
+ }]
68
+
69
+ def _get_session(self):
70
+ if self._session is None:
71
+ self._session = requests_unixsocket.Session()
72
+ return self._session
73
+
74
+ def _socket_url(self, path):
75
+ encoded_socket = self.socket_path.replace('/', '%2F')
76
+ return 'http+unix://%s%s' % (encoded_socket, path)
77
+
78
+ def fetch_callback(self, digest):
79
+ """Always fetch all layers."""
80
+ return True
81
+
82
+ def process_image_element(self, element_type, name, data):
83
+ """Process an image element, adding it to the tarball."""
84
+ if element_type == constants.CONFIG_FILE:
85
+ LOG.info('Adding config file to tarball')
86
+
87
+ ti = tarfile.TarInfo(name)
88
+ ti.size = len(data.read())
89
+ data.seek(0)
90
+ self._image_tar.addfile(ti, data)
91
+ self._tar_manifest[0]['Config'] = name
92
+
93
+ elif element_type == constants.IMAGE_LAYER:
94
+ LOG.info('Adding layer to tarball')
95
+
96
+ name += '/layer.tar'
97
+ ti = tarfile.TarInfo(name)
98
+ data.seek(0, os.SEEK_END)
99
+ ti.size = data.tell()
100
+ data.seek(0)
101
+ self._image_tar.addfile(ti, data)
102
+ self._tar_manifest[0]['Layers'].append(name)
103
+
104
+ def finalize(self):
105
+ """Write manifest and load the image into Docker."""
106
+ LOG.info('Writing manifest to tarball')
107
+ encoded_manifest = json.dumps(self._tar_manifest).encode('utf-8')
108
+ ti = tarfile.TarInfo('manifest.json')
109
+ ti.size = len(encoded_manifest)
110
+ self._image_tar.addfile(ti, io.BytesIO(encoded_manifest))
111
+ self._image_tar.close()
112
+
113
+ temp_path = self._temp_file.name
114
+ self._temp_file.close()
115
+
116
+ try:
117
+ LOG.info('Loading image into Docker daemon at %s' % self.socket_path)
118
+ session = self._get_session()
119
+ url = self._socket_url('/images/load')
120
+
121
+ with open(temp_path, 'rb') as f:
122
+ r = session.post(
123
+ url,
124
+ data=f,
125
+ headers={'Content-Type': 'application/x-tar'}
126
+ )
127
+
128
+ if r.status_code != 200:
129
+ raise Exception(
130
+ 'Docker API error %d: %s' % (r.status_code, r.text))
131
+
132
+ LOG.info('Image loaded successfully: %s:%s'
133
+ % (self.image, self.tag))
134
+
135
+ finally:
136
+ if os.path.exists(temp_path):
137
+ os.unlink(temp_path)
@@ -7,13 +7,14 @@ import tarfile
7
7
  from occystrap import common
8
8
  from occystrap import constants
9
9
  from occystrap import util
10
+ from occystrap.outputs.base import ImageOutput
10
11
 
11
12
 
12
13
  LOG = logging.getLogger(__name__)
13
14
  LOG.setLevel(logging.INFO)
14
15
 
15
16
 
16
- class MountWriter(object):
17
+ class MountWriter(ImageOutput):
17
18
  def __init__(self, image, tag, image_path):
18
19
  self.image = image
19
20
  self.tag = tag
@@ -8,7 +8,7 @@ import shutil
8
8
 
9
9
  from occystrap.constants import RUNC_SPEC_TEMPLATE
10
10
  from occystrap import common
11
- from occystrap.output_directory import DirWriter
11
+ from occystrap.outputs.directory import DirWriter
12
12
 
13
13
 
14
14
  LOG = logging.getLogger(__name__)
@@ -0,0 +1,240 @@
1
+ # Push images to a Docker/OCI registry via the Docker Registry HTTP API V2.
2
+ #
3
+ # Docker Registry API documentation:
4
+ # https://docs.docker.com/registry/spec/api/
5
+ #
6
+ # OCI Distribution Spec:
7
+ # https://github.com/opencontainers/distribution-spec/blob/main/spec.md
8
+ #
9
+ # The push process:
10
+ # 1. For each layer blob:
11
+ # a. Check if blob exists: HEAD /v2/<name>/blobs/<digest>
12
+ # b. If not, initiate upload: POST /v2/<name>/blobs/uploads/
13
+ # c. Upload blob: PUT <location>?digest=<digest>
14
+ # 2. Upload config blob (same as layer)
15
+ # 3. Push manifest: PUT /v2/<name>/manifests/<tag>
16
+
17
+ import gzip
18
+ import hashlib
19
+ import io
20
+ import json
21
+ import logging
22
+ import re
23
+
24
+ import requests
25
+
26
+ from occystrap import constants
27
+ from occystrap.outputs.base import ImageOutput
28
+ from occystrap import util
29
+
30
+
31
+ LOG = logging.getLogger(__name__)
32
+ LOG.setLevel(logging.INFO)
33
+
34
+
35
+ class RegistryWriter(ImageOutput):
36
+ """Pushes images to a Docker/OCI registry.
37
+
38
+ This output writer uploads image layers and config to a registry
39
+ using the Docker Registry HTTP API V2, then pushes a manifest to
40
+ make the image available.
41
+ """
42
+
43
+ def __init__(self, registry, image, tag, secure=True,
44
+ username=None, password=None):
45
+ """Initialize the registry writer.
46
+
47
+ Args:
48
+ registry: Registry hostname (e.g., 'docker.io', 'ghcr.io').
49
+ image: Image name/path (e.g., 'library/busybox', 'myuser/myimage').
50
+ tag: Image tag (e.g., 'latest', 'v1.0').
51
+ secure: If True, use HTTPS (default). If False, use HTTP.
52
+ username: Username for authentication (optional).
53
+ password: Password/token for authentication (optional).
54
+ """
55
+ self.registry = registry
56
+ self.image = image
57
+ self.tag = tag
58
+ self.secure = secure
59
+ self.username = username
60
+ self.password = password
61
+
62
+ self._cached_auth = None
63
+ self._moniker = 'https' if secure else 'http'
64
+
65
+ self._config_digest = None
66
+ self._config_size = None
67
+ self._layers = []
68
+
69
+ def _request(self, method, url, headers=None, data=None, stream=False):
70
+ """Make an authenticated request to the registry."""
71
+ if not headers:
72
+ headers = {}
73
+
74
+ headers['User-Agent'] = util.get_user_agent()
75
+
76
+ if self._cached_auth:
77
+ headers['Authorization'] = 'Bearer %s' % self._cached_auth
78
+
79
+ r = requests.request(method, url, headers=headers, data=data,
80
+ stream=stream)
81
+
82
+ if r.status_code == 401:
83
+ auth_header = r.headers.get('Www-Authenticate', '')
84
+ auth_re = re.compile(r'Bearer realm="([^"]*)",service="([^"]*)"')
85
+ m = auth_re.match(auth_header)
86
+ if m:
87
+ scope = 'repository:%s:pull,push' % self.image
88
+ auth_url = '%s?service=%s&scope=%s' % (m.group(1), m.group(2),
89
+ scope)
90
+ if self.username and self.password:
91
+ auth_r = requests.get(auth_url,
92
+ auth=(self.username, self.password))
93
+ else:
94
+ auth_r = requests.get(auth_url)
95
+
96
+ if auth_r.status_code == 200:
97
+ token = auth_r.json().get('token')
98
+ self._cached_auth = token
99
+ headers['Authorization'] = 'Bearer %s' % token
100
+
101
+ r = requests.request(method, url, headers=headers,
102
+ data=data, stream=stream)
103
+
104
+ return r
105
+
106
+ def _blob_exists(self, digest):
107
+ """Check if a blob already exists in the registry."""
108
+ url = '%s://%s/v2/%s/blobs/%s' % (self._moniker, self.registry,
109
+ self.image, digest)
110
+ r = self._request('HEAD', url)
111
+ return r.status_code == 200
112
+
113
+ def _upload_blob(self, digest, data, size):
114
+ """Upload a blob to the registry.
115
+
116
+ Args:
117
+ digest: The sha256 digest of the blob (e.g., 'sha256:abc123...').
118
+ data: File-like object containing the blob data.
119
+ size: Size of the blob in bytes.
120
+ """
121
+ if self._blob_exists(digest):
122
+ LOG.info('Blob %s already exists, skipping upload' % digest[:19])
123
+ return
124
+
125
+ LOG.info('Uploading blob %s (%d bytes)' % (digest[:19], size))
126
+
127
+ url = '%s://%s/v2/%s/blobs/uploads/' % (self._moniker, self.registry,
128
+ self.image)
129
+ r = self._request('POST', url)
130
+
131
+ if r.status_code not in (200, 202):
132
+ raise Exception('Failed to initiate blob upload: %d %s'
133
+ % (r.status_code, r.text))
134
+
135
+ location = r.headers.get('Location')
136
+ if not location:
137
+ raise Exception('No Location header in upload response')
138
+
139
+ if not location.startswith('http'):
140
+ location = '%s://%s%s' % (self._moniker, self.registry, location)
141
+
142
+ if '?' in location:
143
+ upload_url = '%s&digest=%s' % (location, digest)
144
+ else:
145
+ upload_url = '%s?digest=%s' % (location, digest)
146
+
147
+ data.seek(0)
148
+ r = self._request('PUT', upload_url,
149
+ headers={'Content-Type': 'application/octet-stream',
150
+ 'Content-Length': str(size)},
151
+ data=data)
152
+
153
+ if r.status_code not in (200, 201, 202):
154
+ raise Exception('Failed to upload blob: %d %s'
155
+ % (r.status_code, r.text))
156
+
157
+ LOG.info('Blob uploaded successfully')
158
+
159
+ def fetch_callback(self, digest):
160
+ """Always fetch all layers for pushing."""
161
+ return True
162
+
163
+ def process_image_element(self, element_type, name, data):
164
+ """Process an image element, uploading it to the registry."""
165
+ if element_type == constants.CONFIG_FILE and data is not None:
166
+ LOG.info('Processing config file')
167
+
168
+ data.seek(0)
169
+ config_data = data.read()
170
+
171
+ h = hashlib.sha256()
172
+ h.update(config_data)
173
+ self._config_digest = 'sha256:%s' % h.hexdigest()
174
+ self._config_size = len(config_data)
175
+
176
+ self._upload_blob(self._config_digest, io.BytesIO(config_data),
177
+ self._config_size)
178
+
179
+ elif element_type == constants.IMAGE_LAYER and data is not None:
180
+ LOG.info('Processing layer %s' % name)
181
+
182
+ data.seek(0)
183
+ layer_data = data.read()
184
+
185
+ compressed = io.BytesIO()
186
+ with gzip.GzipFile(fileobj=compressed, mode='wb') as gz:
187
+ gz.write(layer_data)
188
+ compressed.seek(0)
189
+ compressed_data = compressed.read()
190
+
191
+ h = hashlib.sha256()
192
+ h.update(compressed_data)
193
+ layer_digest = 'sha256:%s' % h.hexdigest()
194
+ layer_size = len(compressed_data)
195
+
196
+ self._upload_blob(layer_digest, io.BytesIO(compressed_data),
197
+ layer_size)
198
+
199
+ self._layers.append({
200
+ 'mediaType': 'application/vnd.docker.image.rootfs.diff.tar.gzip',
201
+ 'size': layer_size,
202
+ 'digest': layer_digest
203
+ })
204
+
205
+ def finalize(self):
206
+ """Push the image manifest to the registry."""
207
+ if not self._config_digest:
208
+ raise Exception('No config file was processed')
209
+
210
+ LOG.info('Pushing manifest for %s:%s' % (self.image, self.tag))
211
+
212
+ manifest = {
213
+ 'schemaVersion': 2,
214
+ 'mediaType': 'application/vnd.docker.distribution.manifest.v2+json',
215
+ 'config': {
216
+ 'mediaType': 'application/vnd.docker.container.image.v1+json',
217
+ 'size': self._config_size,
218
+ 'digest': self._config_digest
219
+ },
220
+ 'layers': self._layers
221
+ }
222
+
223
+ manifest_json = json.dumps(manifest, separators=(',', ':'))
224
+
225
+ url = '%s://%s/v2/%s/manifests/%s' % (self._moniker, self.registry,
226
+ self.image, self.tag)
227
+ r = self._request(
228
+ 'PUT', url,
229
+ headers={
230
+ 'Content-Type':
231
+ 'application/vnd.docker.distribution.manifest.v2+json'
232
+ },
233
+ data=manifest_json.encode('utf-8'))
234
+
235
+ if r.status_code not in (200, 201, 202):
236
+ raise Exception('Failed to push manifest: %d %s'
237
+ % (r.status_code, r.text))
238
+
239
+ LOG.info('Image pushed successfully: %s/%s:%s'
240
+ % (self.registry, self.image, self.tag))
@@ -5,18 +5,33 @@ import os
5
5
  import tarfile
6
6
 
7
7
  from occystrap import constants
8
+ from occystrap.outputs.base import ImageOutput
8
9
 
9
10
 
10
11
  LOG = logging.getLogger(__name__)
11
12
  LOG.setLevel(logging.INFO)
12
13
 
13
14
 
14
- class TarWriter(object):
15
+ # This code creates v1.2 format image tarballs.
16
+ # v1.2 is documented at https://github.com/moby/docker-image-spec/blob/v1.2.0/v1.2.md
17
+ # v2 is documented at https://github.com/opencontainers/image-spec/blob/main/
18
+
19
+ class TarWriter(ImageOutput):
20
+ """Creates docker-loadable tarballs in v1.2 format.
21
+
22
+ To normalize timestamps for reproducible builds, use the
23
+ TimestampNormalizer filter before this output.
24
+
25
+ Uses USTAR format for the outer tarball which contains only short paths
26
+ (SHA256 hashes and small filenames), avoiding PAX extended headers.
27
+ """
28
+
15
29
  def __init__(self, image, tag, image_path):
16
30
  self.image = image
17
31
  self.tag = tag
18
32
  self.image_path = image_path
19
- self.image_tar = tarfile.open(image_path, 'w')
33
+ self.image_tar = tarfile.open(image_path, 'w',
34
+ format=tarfile.USTAR_FORMAT)
20
35
 
21
36
  self.tar_manifest = [{
22
37
  'Layers': [],
@@ -53,3 +68,4 @@ class TarWriter(object):
53
68
  ti = tarfile.TarInfo('manifest.json')
54
69
  ti.size = len(encoded_manifest)
55
70
  self.image_tar.addfile(ti, io.BytesIO(encoded_manifest))
71
+ self.image_tar.close()