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.
- occystrap/_version.py +34 -0
- occystrap/filters/__init__.py +10 -0
- occystrap/filters/base.py +67 -0
- occystrap/filters/exclude.py +136 -0
- occystrap/filters/inspect.py +179 -0
- occystrap/filters/normalize_timestamps.py +123 -0
- occystrap/filters/search.py +177 -0
- occystrap/inputs/__init__.py +1 -0
- occystrap/inputs/base.py +40 -0
- occystrap/inputs/docker.py +171 -0
- occystrap/inputs/registry.py +260 -0
- occystrap/inputs/tarfile.py +88 -0
- occystrap/main.py +330 -31
- occystrap/outputs/__init__.py +1 -0
- occystrap/outputs/base.py +46 -0
- occystrap/{output_directory.py → outputs/directory.py} +10 -9
- occystrap/outputs/docker.py +137 -0
- occystrap/{output_mounts.py → outputs/mounts.py} +2 -1
- occystrap/{output_ocibundle.py → outputs/ocibundle.py} +1 -1
- occystrap/outputs/registry.py +240 -0
- occystrap/{output_tarfile.py → outputs/tarfile.py} +18 -2
- occystrap/pipeline.py +297 -0
- occystrap/tarformat.py +122 -0
- occystrap/tests/test_inspect.py +355 -0
- occystrap/tests/test_tarformat.py +199 -0
- occystrap/uri.py +231 -0
- occystrap/util.py +67 -38
- occystrap-0.4.1.dist-info/METADATA +444 -0
- occystrap-0.4.1.dist-info/RECORD +38 -0
- {occystrap-0.3.0.dist-info → occystrap-0.4.1.dist-info}/WHEEL +1 -1
- {occystrap-0.3.0.dist-info → occystrap-0.4.1.dist-info}/entry_points.txt +0 -1
- occystrap/docker_extract.py +0 -36
- occystrap/docker_registry.py +0 -192
- occystrap-0.3.0.dist-info/METADATA +0 -131
- occystrap-0.3.0.dist-info/RECORD +0 -20
- occystrap-0.3.0.dist-info/pbr.json +0 -1
- {occystrap-0.3.0.dist-info → occystrap-0.4.1.dist-info/licenses}/AUTHORS +0 -0
- {occystrap-0.3.0.dist-info → occystrap-0.4.1.dist-info/licenses}/LICENSE +0 -0
- {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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
62
|
+
% ('\n '.join(json.dumps(data,
|
|
61
63
|
indent=4,
|
|
62
64
|
sort_keys=True).split('\n'))))
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|