occystrap 0.3.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 (39) 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/inputs/registry.py +260 -0
  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.3.0.dist-info → occystrap-0.4.1.dist-info}/WHEEL +1 -1
  31. {occystrap-0.3.0.dist-info → occystrap-0.4.1.dist-info}/entry_points.txt +0 -1
  32. occystrap/docker_extract.py +0 -36
  33. occystrap/docker_registry.py +0 -192
  34. occystrap-0.3.0.dist-info/METADATA +0 -131
  35. occystrap-0.3.0.dist-info/RECORD +0 -20
  36. occystrap-0.3.0.dist-info/pbr.json +0 -1
  37. {occystrap-0.3.0.dist-info → occystrap-0.4.1.dist-info/licenses}/AUTHORS +0 -0
  38. {occystrap-0.3.0.dist-info → occystrap-0.4.1.dist-info/licenses}/LICENSE +0 -0
  39. {occystrap-0.3.0.dist-info → occystrap-0.4.1.dist-info}/top_level.txt +0 -0
occystrap/uri.py ADDED
@@ -0,0 +1,231 @@
1
+ """URI parsing for occystrap pipeline specification.
2
+
3
+ This module provides URI-style parsing for input sources, output destinations,
4
+ and filter specifications.
5
+
6
+ URI formats:
7
+ Input:
8
+ registry://[user:pass@]host/image:tag[?arch=X&os=Y&variant=Z]
9
+ docker://image:tag[?socket=/path/to/socket]
10
+ tar:///path/to/file.tar
11
+ file:///path/to/file.tar (alias for tar)
12
+
13
+ Output:
14
+ tar:///path/to/output.tar
15
+ dir:///path/to/directory[?unique_names=true&expand=true]
16
+ directory:///path/... (alias for dir)
17
+ oci:///path/to/bundle
18
+ mounts:///path/to/directory
19
+ docker://image:tag[?socket=/path/to/socket]
20
+ registry://host/image:tag[?insecure=true]
21
+
22
+ Filter specs:
23
+ filter-name
24
+ filter-name:option=value
25
+ filter-name:opt1=val1,opt2=val2
26
+ """
27
+
28
+ from collections import namedtuple
29
+ from urllib.parse import urlparse, parse_qs, unquote
30
+
31
+
32
+ # Named tuples for parsed specifications
33
+ URISpec = namedtuple('URISpec', ['scheme', 'host', 'path', 'options'])
34
+ FilterSpec = namedtuple('FilterSpec', ['name', 'options'])
35
+
36
+
37
+ # Scheme classifications
38
+ INPUT_SCHEMES = {'registry', 'docker', 'tar', 'file'}
39
+ OUTPUT_SCHEMES = {'tar', 'dir', 'directory', 'oci', 'mounts', 'docker', 'registry'}
40
+
41
+ # Scheme aliases
42
+ SCHEME_ALIASES = {
43
+ 'file': 'tar',
44
+ 'directory': 'dir',
45
+ }
46
+
47
+
48
+ class URIParseError(Exception):
49
+ """Raised when a URI cannot be parsed."""
50
+ pass
51
+
52
+
53
+ def parse_uri(uri_string):
54
+ """Parse a URI string into components.
55
+
56
+ Args:
57
+ uri_string: A URI like 'registry://docker.io/library/busybox:latest'
58
+
59
+ Returns:
60
+ URISpec(scheme, host, path, options)
61
+
62
+ Raises:
63
+ URIParseError: If the URI is malformed.
64
+ """
65
+ # Handle URIs without :// (e.g., 'tar:foo.tar' -> 'tar://foo.tar')
66
+ if '://' not in uri_string and ':' in uri_string:
67
+ scheme, rest = uri_string.split(':', 1)
68
+ if not rest.startswith('//'):
69
+ uri_string = '%s://%s' % (scheme, rest)
70
+
71
+ parsed = urlparse(uri_string)
72
+
73
+ if not parsed.scheme:
74
+ raise URIParseError('Missing scheme in URI: %s' % uri_string)
75
+
76
+ scheme = parsed.scheme.lower()
77
+ scheme = SCHEME_ALIASES.get(scheme, scheme)
78
+
79
+ # Parse query string into options dict
80
+ options = {}
81
+ if parsed.query:
82
+ qs = parse_qs(parsed.query)
83
+ for key, values in qs.items():
84
+ # Convert single-value lists to scalars
85
+ if len(values) == 1:
86
+ value = values[0]
87
+ # Convert string booleans
88
+ if value.lower() in ('true', 'yes', '1'):
89
+ options[key] = True
90
+ elif value.lower() in ('false', 'no', '0'):
91
+ options[key] = False
92
+ else:
93
+ # Try to convert to int
94
+ try:
95
+ options[key] = int(value)
96
+ except ValueError:
97
+ options[key] = value
98
+ else:
99
+ options[key] = values
100
+
101
+ # Build the path, handling different schemes
102
+ host = parsed.netloc
103
+ path = unquote(parsed.path)
104
+
105
+ # For file-based schemes, the host might be part of the path
106
+ if scheme in ('tar', 'dir', 'oci', 'mounts'):
107
+ if host and not path:
108
+ # tar://foo.tar -> host='foo.tar', path=''
109
+ path = host
110
+ host = ''
111
+ elif host:
112
+ # tar://localhost/path/to/file -> path='/path/to/file'
113
+ # But we want to preserve absolute paths
114
+ if host != 'localhost':
115
+ path = host + path
116
+ host = ''
117
+
118
+ return URISpec(scheme=scheme, host=host, path=path, options=options)
119
+
120
+
121
+ def parse_filter(filter_string):
122
+ """Parse a filter specification string.
123
+
124
+ Args:
125
+ filter_string: A filter spec like 'normalize-timestamps:timestamp=0'
126
+
127
+ Returns:
128
+ FilterSpec(name, options)
129
+
130
+ Raises:
131
+ URIParseError: If the filter spec is malformed.
132
+ """
133
+ if not filter_string:
134
+ raise URIParseError('Empty filter specification')
135
+
136
+ # Split on first colon
137
+ if ':' in filter_string:
138
+ name, opts_string = filter_string.split(':', 1)
139
+ options = {}
140
+
141
+ # Parse comma-separated key=value pairs
142
+ for pair in opts_string.split(','):
143
+ pair = pair.strip()
144
+ if not pair:
145
+ continue
146
+ if '=' not in pair:
147
+ raise URIParseError(
148
+ 'Invalid filter option (missing =): %s' % pair)
149
+ key, value = pair.split('=', 1)
150
+ key = key.strip()
151
+ value = value.strip()
152
+
153
+ # Convert string booleans
154
+ if value.lower() in ('true', 'yes', '1'):
155
+ options[key] = True
156
+ elif value.lower() in ('false', 'no', '0'):
157
+ options[key] = False
158
+ else:
159
+ # Try to convert to int
160
+ try:
161
+ options[key] = int(value)
162
+ except ValueError:
163
+ options[key] = value
164
+ else:
165
+ name = filter_string
166
+ options = {}
167
+
168
+ return FilterSpec(name=name.strip(), options=options)
169
+
170
+
171
+ def parse_registry_uri(uri_spec):
172
+ """Parse registry URI into (registry, image, tag) tuple.
173
+
174
+ Handles formats like:
175
+ registry://docker.io/library/busybox:latest
176
+ registry://ghcr.io/owner/repo:v1.0
177
+
178
+ Returns:
179
+ Tuple of (registry_host, image_path, tag)
180
+ """
181
+ if uri_spec.scheme != 'registry':
182
+ raise URIParseError('Expected registry:// URI, got %s' % uri_spec.scheme)
183
+
184
+ host = uri_spec.host
185
+ path = uri_spec.path.lstrip('/')
186
+
187
+ # Split off tag
188
+ if ':' in path:
189
+ # Find the last colon that's part of the tag (not in the image name)
190
+ # e.g., 'library/busybox:latest' or 'my-image:v1.0'
191
+ last_colon = path.rfind(':')
192
+ image = path[:last_colon]
193
+ tag = path[last_colon + 1:]
194
+ else:
195
+ image = path
196
+ tag = 'latest'
197
+
198
+ return (host, image, tag)
199
+
200
+
201
+ def parse_docker_uri(uri_spec):
202
+ """Parse docker URI into (image, tag, socket) tuple.
203
+
204
+ Handles formats like:
205
+ docker://busybox:latest
206
+ docker://busybox:latest?socket=/run/podman/podman.sock
207
+ docker://library/busybox:v1
208
+
209
+ Returns:
210
+ Tuple of (image, tag, socket_path)
211
+ """
212
+ if uri_spec.scheme != 'docker':
213
+ raise URIParseError('Expected docker:// URI, got %s' % uri_spec.scheme)
214
+
215
+ # The image:tag is in the host+path
216
+ image_tag = uri_spec.host
217
+ if uri_spec.path:
218
+ image_tag += uri_spec.path
219
+
220
+ # Split off tag
221
+ if ':' in image_tag:
222
+ last_colon = image_tag.rfind(':')
223
+ image = image_tag[:last_colon]
224
+ tag = image_tag[last_colon + 1:]
225
+ else:
226
+ image = image_tag
227
+ tag = 'latest'
228
+
229
+ socket = uri_spec.options.get('socket', '/var/run/docker.sock')
230
+
231
+ return (image, tag, socket)
occystrap/util.py CHANGED
@@ -3,10 +3,16 @@ import logging
3
3
  from oslo_concurrency import processutils
4
4
  from pbr.version import VersionInfo
5
5
  import requests
6
+ from requests.exceptions import ChunkedEncodingError, ConnectionError
7
+ import time
6
8
 
7
9
 
8
10
  LOG = logging.getLogger(__name__)
9
11
 
12
+ # Retry configuration
13
+ MAX_RETRIES = 3
14
+ RETRY_BACKOFF_BASE = 2 # Exponential backoff: 2^attempt seconds
15
+
10
16
 
11
17
  class APIException(Exception):
12
18
  pass
@@ -29,52 +35,75 @@ def get_user_agent():
29
35
  return 'Mozilla/5.0 (Ubuntu; Linux x86_64) Occy Strap/%s' % version
30
36
 
31
37
 
32
- def request_url(method, url, headers=None, data=None, stream=False):
38
+ def request_url(method, url, headers=None, data=None, stream=False, auth=None,
39
+ retries=MAX_RETRIES):
33
40
  if not headers:
34
41
  headers = {}
35
42
  headers.update({'User-Agent': get_user_agent()})
36
43
  if data:
37
44
  headers['Content-Type'] = 'application/json'
38
- r = requests.request(method, url,
39
- data=json.dumps(data),
40
- headers=headers,
41
- stream=stream)
42
-
43
- LOG.debug('-------------------------------------------------------')
44
- LOG.debug('API client requested: %s %s (stream=%s)'
45
- % (method, url, stream))
46
- for h in headers:
47
- LOG.debug('Header: %s = %s' % (h, headers[h]))
48
- if data:
49
- LOG.debug('Data:\n %s'
50
- % ('\n '.join(json.dumps(data,
51
- indent=4,
52
- sort_keys=True).split('\n'))))
53
- LOG.debug('API client response: code = %s' % r.status_code)
54
- for h in r.headers:
55
- LOG.debug('Header: %s = %s' % (h, r.headers[h]))
56
- if not stream:
57
- if r.text:
58
- try:
45
+
46
+ last_exception = None
47
+ for attempt in range(retries + 1):
48
+ try:
49
+ r = requests.request(method, url,
50
+ data=json.dumps(data),
51
+ headers=headers,
52
+ stream=stream,
53
+ auth=auth)
54
+
55
+ LOG.debug('-------------------------------------------------------')
56
+ LOG.debug('API client requested: %s %s (stream=%s)'
57
+ % (method, url, stream))
58
+ for h in headers:
59
+ LOG.debug('Header: %s = %s' % (h, headers[h]))
60
+ if data:
59
61
  LOG.debug('Data:\n %s'
60
- % ('\n '.join(json.dumps(json.loads(r.text),
62
+ % ('\n '.join(json.dumps(data,
61
63
  indent=4,
62
64
  sort_keys=True).split('\n'))))
63
- except Exception:
64
- LOG.debug('Text:\n %s'
65
- % ('\n '.join(r.text.split('\n'))))
66
- else:
67
- LOG.debug('Result content not logged for streaming requests')
68
- LOG.debug('-------------------------------------------------------')
69
-
70
- if r.status_code in STATUS_CODES_TO_ERRORS:
71
- raise STATUS_CODES_TO_ERRORS[r.status_code](
72
- 'API request failed', method, url, r.status_code, r.text, r.headers)
73
-
74
- if r.status_code != 200:
75
- raise APIException(
76
- 'API request failed', method, url, r.status_code, r.text, r.headers)
77
- return r
65
+ LOG.debug('API client response: code = %s' % r.status_code)
66
+ for h in r.headers:
67
+ LOG.debug('Header: %s = %s' % (h, r.headers[h]))
68
+ if not stream:
69
+ if r.text:
70
+ try:
71
+ LOG.debug('Data:\n %s'
72
+ % ('\n '.join(json.dumps(
73
+ json.loads(r.text),
74
+ indent=4,
75
+ sort_keys=True).split('\n'))))
76
+ except Exception:
77
+ LOG.debug('Text:\n %s'
78
+ % ('\n '.join(r.text.split('\n'))))
79
+ else:
80
+ LOG.debug('Result content not logged for streaming requests')
81
+ LOG.debug('-------------------------------------------------------')
82
+
83
+ if r.status_code in STATUS_CODES_TO_ERRORS:
84
+ raise STATUS_CODES_TO_ERRORS[r.status_code](
85
+ 'API request failed', method, url, r.status_code,
86
+ r.text, r.headers)
87
+
88
+ if r.status_code != 200:
89
+ raise APIException(
90
+ 'API request failed', method, url, r.status_code,
91
+ r.text, r.headers)
92
+ return r
93
+
94
+ except (ChunkedEncodingError, ConnectionError) as e:
95
+ last_exception = e
96
+ if attempt < retries:
97
+ wait_time = RETRY_BACKOFF_BASE ** attempt
98
+ LOG.warning('Request failed (attempt %d/%d): %s. '
99
+ 'Retrying in %d seconds...'
100
+ % (attempt + 1, retries + 1, str(e), wait_time))
101
+ time.sleep(wait_time)
102
+ else:
103
+ LOG.error('Request failed after %d attempts: %s'
104
+ % (retries + 1, str(e)))
105
+
106
+ raise last_exception
78
107
 
79
108
 
80
109
  def execute(command, check_exit_code=[0], env_variables=None,