poetry-plugin-ivcap 0.4.2__tar.gz → 0.5.1__tar.gz
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.
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/PKG-INFO +1 -1
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/poetry_plugin_ivcap/ivcap.py +189 -75
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/pyproject.toml +1 -1
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/AUTHORS.md +0 -0
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/LICENSE +0 -0
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/README.md +0 -0
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/poetry_plugin_ivcap/constants.py +0 -0
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/poetry_plugin_ivcap/docker.py +0 -0
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/poetry_plugin_ivcap/plugin.py +0 -0
- {poetry_plugin_ivcap-0.4.2 → poetry_plugin_ivcap-0.5.1}/poetry_plugin_ivcap/util.py +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# Use of this source code is governed by a BSD-style license that can be
|
|
4
4
|
# found in the LICENSE file. See the AUTHORS file for names of contributors.
|
|
5
5
|
#
|
|
6
|
+
import argparse
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
8
9
|
import subprocess
|
|
@@ -12,6 +13,9 @@ import uuid
|
|
|
12
13
|
import humanize
|
|
13
14
|
import subprocess
|
|
14
15
|
import requests
|
|
16
|
+
import time
|
|
17
|
+
import json
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
from .constants import DEF_POLICY, PLUGIN_NAME, POLICY_OPT, SERVICE_FILE_OPT, SERVICE_ID_OPT, DEF_IVCAP_BASE_URL
|
|
17
21
|
|
|
@@ -143,28 +147,26 @@ def exec_job(data, args, is_silent, line):
|
|
|
143
147
|
requests.Response: The response object from the API call.
|
|
144
148
|
"""
|
|
145
149
|
# Parse 'args' for run options
|
|
150
|
+
p = exec_parser()
|
|
146
151
|
if not isinstance(args, list) or len(args) < 1:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
except ValueError:
|
|
156
|
-
raise Exception("Timeout value must be an integer")
|
|
157
|
-
else:
|
|
158
|
-
raise Exception("args must be [file_name] or [file_name, '--timeout', value]")
|
|
159
|
-
|
|
152
|
+
p.error("missing request file name")
|
|
153
|
+
# raise Exception("args must be a list with at least one element")
|
|
154
|
+
file_name = args.pop(0)
|
|
155
|
+
if file_name == "-h" or file_name == "--help":
|
|
156
|
+
p.print_help()
|
|
157
|
+
return
|
|
158
|
+
pa = p.parse_args(args)
|
|
159
|
+
timeout = 0 if pa.stream else pa.timeout
|
|
160
160
|
# Get access token using ivcap CLI
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
token = pa.auth_token
|
|
162
|
+
if not token:
|
|
163
|
+
try:
|
|
164
|
+
token = subprocess.check_output(
|
|
165
|
+
["ivcap", "--silent", "context", "get", "access-token", "--refresh-token"],
|
|
166
|
+
text=True
|
|
167
|
+
).strip()
|
|
168
|
+
except Exception as e:
|
|
169
|
+
raise RuntimeError(f"Failed to get IVCAP access token: {e}")
|
|
168
170
|
|
|
169
171
|
# Get IVCAP deployment URL
|
|
170
172
|
try:
|
|
@@ -202,34 +204,6 @@ def exec_job(data, args, is_silent, line):
|
|
|
202
204
|
except Exception as e:
|
|
203
205
|
raise RuntimeError(f"Job submission failed: {e}")
|
|
204
206
|
|
|
205
|
-
# Handle response according to requirements
|
|
206
|
-
import time
|
|
207
|
-
import json
|
|
208
|
-
|
|
209
|
-
def handle_response(resp):
|
|
210
|
-
content_type = resp.headers.get("content-type", "")
|
|
211
|
-
if resp.status_code >= 300:
|
|
212
|
-
line(f"<warning>WARNING: Received status code {resp.status_code}</warning>")
|
|
213
|
-
line(f"<info>Headers: {str(resp.headers)}</info>")
|
|
214
|
-
line(f"<info>Body: {str(resp.text)}</info>")
|
|
215
|
-
elif resp.status_code == 200:
|
|
216
|
-
if "application/json" in content_type:
|
|
217
|
-
try:
|
|
218
|
-
parsed = resp.json()
|
|
219
|
-
status = parsed.get("status")
|
|
220
|
-
if status and (not status in ["succeeded", "failed", "error"]):
|
|
221
|
-
return status
|
|
222
|
-
print(json.dumps(parsed, indent=2, sort_keys=True))
|
|
223
|
-
return None
|
|
224
|
-
except Exception as e:
|
|
225
|
-
line(f"<warning>Failed to parse JSON response: {e}</warning>")
|
|
226
|
-
line(f"<warning>Headers: {str(resp.headers)}</warning>")
|
|
227
|
-
else:
|
|
228
|
-
line(f"<info>Headers: {str(resp.headers)}</info>")
|
|
229
|
-
else:
|
|
230
|
-
line(f"<warning>Received status code {resp.status_code}</warning>")
|
|
231
|
-
line(f"<warning>Headers: {str(resp.headers)}</warning>")
|
|
232
|
-
return "unknown"
|
|
233
207
|
|
|
234
208
|
if response.status_code == 202:
|
|
235
209
|
try:
|
|
@@ -239,36 +213,176 @@ def exec_job(data, args, is_silent, line):
|
|
|
239
213
|
location = f"{payload.get('location')}"
|
|
240
214
|
job_id = payload.get("job-id")
|
|
241
215
|
retry_later = payload.get("retry-later", 5)
|
|
242
|
-
if not is_silent:
|
|
243
|
-
line(f"<debug>Job '{job_id}' accepted, but no result yet. Polling in {retry_later} seconds.</debug>")
|
|
244
|
-
while True:
|
|
245
|
-
time.sleep(retry_later)
|
|
246
|
-
poll_headers = {
|
|
247
|
-
"Authorization": f"Bearer {token}"
|
|
248
|
-
}
|
|
249
|
-
poll_resp = requests.get(location, headers=poll_headers)
|
|
250
|
-
if poll_resp.status_code == 202:
|
|
251
|
-
try:
|
|
252
|
-
poll_payload = poll_resp.json()
|
|
253
|
-
location = poll_payload.get("location", location)
|
|
254
|
-
retry_later = poll_payload.get("retry-later", retry_later)
|
|
255
|
-
if not is_silent:
|
|
256
|
-
line(f"<debug>Still processing. Next poll in {retry_later} seconds.</debug>")
|
|
257
|
-
except Exception as e:
|
|
258
|
-
line(f"<error>Failed to parse polling response: {e}</error>")
|
|
259
|
-
break
|
|
260
|
-
else:
|
|
261
|
-
status = handle_response(poll_resp)
|
|
262
|
-
if status:
|
|
263
|
-
if not is_silent:
|
|
264
|
-
line(f"<debug>Status: '{status}'. Next poll in {retry_later} seconds.</debug>")
|
|
265
|
-
else:
|
|
266
|
-
break
|
|
267
|
-
|
|
268
216
|
except Exception as e:
|
|
269
217
|
line(f"<error>Failed to handle 202 response: {e}</error>")
|
|
218
|
+
|
|
219
|
+
if pa.stream:
|
|
220
|
+
stream_result(location, job_id, token, pa, is_silent, line)
|
|
221
|
+
else:
|
|
222
|
+
poll_for_result(location, job_id, retry_later, token, is_silent, line)
|
|
223
|
+
|
|
270
224
|
else:
|
|
271
|
-
handle_response(response)
|
|
225
|
+
handle_response(response, line)
|
|
226
|
+
|
|
227
|
+
def handle_response(resp, line):
|
|
228
|
+
content_type = resp.headers.get("content-type", "")
|
|
229
|
+
if resp.status_code >= 300:
|
|
230
|
+
line(f"<warning>WARNING: Received status code {resp.status_code}</warning>")
|
|
231
|
+
line(f"<info>Headers: {str(resp.headers)}</info>")
|
|
232
|
+
line(f"<info>Body: {str(resp.text)}</info>")
|
|
233
|
+
elif resp.status_code == 200:
|
|
234
|
+
if "application/json" in content_type:
|
|
235
|
+
try:
|
|
236
|
+
parsed = resp.json()
|
|
237
|
+
status = parsed.get("status")
|
|
238
|
+
if status and (not status in ["succeeded", "failed", "error"]):
|
|
239
|
+
return status
|
|
240
|
+
print(json.dumps(parsed, indent=2, sort_keys=True))
|
|
241
|
+
return None
|
|
242
|
+
except Exception as e:
|
|
243
|
+
line(f"<warning>Failed to parse JSON response: {e}</warning>")
|
|
244
|
+
line(f"<warning>Headers: {str(resp.headers)}</warning>")
|
|
245
|
+
else:
|
|
246
|
+
line(f"<info>Headers: {str(resp.headers)}</info>")
|
|
247
|
+
else:
|
|
248
|
+
line(f"<warning>Received status code {resp.status_code}</warning>")
|
|
249
|
+
line(f"<warning>Headers: {str(resp.headers)}</warning>")
|
|
250
|
+
return "unknown"
|
|
251
|
+
|
|
252
|
+
def poll_for_result(location, job_id, retry_later, token, is_silent, line):
|
|
253
|
+
if not is_silent:
|
|
254
|
+
line(f"<debug>Job '{job_id}' accepted, but no result yet. Polling in {retry_later} seconds.</debug>")
|
|
255
|
+
while True:
|
|
256
|
+
time.sleep(retry_later)
|
|
257
|
+
poll_headers = {
|
|
258
|
+
"Authorization": f"Bearer {token}"
|
|
259
|
+
}
|
|
260
|
+
poll_resp = requests.get(location, headers=poll_headers)
|
|
261
|
+
if poll_resp.status_code == 202:
|
|
262
|
+
try:
|
|
263
|
+
poll_payload = poll_resp.json()
|
|
264
|
+
location = poll_payload.get("location", location)
|
|
265
|
+
retry_later = poll_payload.get("retry-later", retry_later)
|
|
266
|
+
if not is_silent:
|
|
267
|
+
line(f"<debug>Still processing. Next poll in {retry_later} seconds.</debug>")
|
|
268
|
+
except Exception as e:
|
|
269
|
+
line(f"<error>Failed to parse polling response: {e}</error>")
|
|
270
|
+
break
|
|
271
|
+
else:
|
|
272
|
+
status = handle_response(poll_resp, line)
|
|
273
|
+
if status:
|
|
274
|
+
if not is_silent:
|
|
275
|
+
line(f"<debug>Status: '{status}'. Next poll in {retry_later} seconds.</debug>")
|
|
276
|
+
else:
|
|
277
|
+
break
|
|
278
|
+
|
|
279
|
+
CONNECT_TIMEOUT = 5
|
|
280
|
+
READ_TIMEOUT = 120 # ensure server sends heartbeat within this window
|
|
281
|
+
|
|
282
|
+
def stream_result(location, job_id, token, pa, is_silent, line):
|
|
283
|
+
"""
|
|
284
|
+
Stream the result content from the given location using the provided token.
|
|
285
|
+
"""
|
|
286
|
+
if not is_silent:
|
|
287
|
+
line(f"<debug>Job '{job_id}' accepted, Waiting for events now.</debug>")
|
|
288
|
+
headers = {
|
|
289
|
+
"Authorization": f"Bearer {token}",
|
|
290
|
+
"Accept": "text/event-stream",
|
|
291
|
+
"Cache-Control": "no-cache",
|
|
292
|
+
"Connection": "keep-alive",
|
|
293
|
+
}
|
|
294
|
+
url = location + "/events"
|
|
295
|
+
session = requests.Session()
|
|
296
|
+
last_event_id = None
|
|
297
|
+
backoff = 1.0
|
|
298
|
+
while True:
|
|
299
|
+
try:
|
|
300
|
+
if last_event_id:
|
|
301
|
+
headers["Last-Event-ID"] = last_event_id
|
|
302
|
+
with session.get(url, stream=True, headers=headers, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT)) as r:
|
|
303
|
+
r.raise_for_status()
|
|
304
|
+
# Reset backoff on successful connect
|
|
305
|
+
backoff = 1.0
|
|
306
|
+
for row in r.iter_lines(decode_unicode=True, chunk_size=1):
|
|
307
|
+
if row is None:
|
|
308
|
+
continue
|
|
309
|
+
if row.startswith(":"):
|
|
310
|
+
# comment/heartbeat
|
|
311
|
+
continue
|
|
312
|
+
if row.startswith("id:"):
|
|
313
|
+
last_event_id = row[3:].strip()
|
|
314
|
+
continue
|
|
315
|
+
if print_sse_row(row, pa, line): # raw SSE lines (e.g., "data: {...}", "event: message")
|
|
316
|
+
return # done
|
|
317
|
+
|
|
318
|
+
except (requests.exceptions.ChunkedEncodingError,
|
|
319
|
+
requests.exceptions.ConnectionError,
|
|
320
|
+
requests.exceptions.ReadTimeout) as e:
|
|
321
|
+
if not is_silent:
|
|
322
|
+
line(f"<debug>stream error: {e}; reconnecting in {backoff:.1f}s</debug>")
|
|
323
|
+
time.sleep(backoff)
|
|
324
|
+
backoff = min(backoff * 2, 30.0) # cap backoff
|
|
325
|
+
continue
|
|
326
|
+
except requests.HTTPError as e:
|
|
327
|
+
# Non-200 or similar; backoff and retry
|
|
328
|
+
if not is_silent:
|
|
329
|
+
line(f"<debug>http error: {e}; reconnecting in {backoff:.1f}s</debug>")
|
|
330
|
+
time.sleep(backoff)
|
|
331
|
+
backoff = min(backoff * 2, 60.0)
|
|
332
|
+
except Exception:
|
|
333
|
+
line(f"<error>Failed to fetch events: {type(e)} - {e}</error>")
|
|
334
|
+
break
|
|
335
|
+
|
|
336
|
+
# except requests.exceptions.ChunkedEncodingError as ce:
|
|
337
|
+
# line(f"<error>Chunked encoding error: {ce}</error>")
|
|
338
|
+
# except Exception as e:
|
|
339
|
+
# line(f"<error>Failed to fetch events: {type(e)} - {e}</error>")
|
|
340
|
+
|
|
341
|
+
def print_sse_row(row, pa, line) -> bool:
|
|
342
|
+
if pa.raw_events:
|
|
343
|
+
print(row)
|
|
344
|
+
return False # continue streaming
|
|
345
|
+
elif row.startswith("data: "):
|
|
346
|
+
# JSON data
|
|
347
|
+
print("----")
|
|
348
|
+
try:
|
|
349
|
+
data = json.loads(row[6:])
|
|
350
|
+
print(json.dumps(data, indent=2, sort_keys=True))
|
|
351
|
+
return check_if_done(data)
|
|
352
|
+
except json.JSONDecodeError as e:
|
|
353
|
+
line(f"<error>Failed to decode JSON: {e}</error>")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def check_if_done(data) -> bool:
|
|
357
|
+
# {
|
|
358
|
+
# "Data": {
|
|
359
|
+
# "job-urn": "urn:ivcap:job:3e466031-ec8b-44eb-aec6-dc2f8212bec3",
|
|
360
|
+
# "status": "succeeded"
|
|
361
|
+
# },
|
|
362
|
+
# "Type": "ivcap.job.status"
|
|
363
|
+
# }
|
|
364
|
+
if "Type" in data and data["Type"] == "ivcap.job.status":
|
|
365
|
+
if "Data" in data and "status" in data["Data"]:
|
|
366
|
+
status = data["Data"]["status"]
|
|
367
|
+
if status in ["succeeded", "failed", "error"]:
|
|
368
|
+
return True
|
|
369
|
+
return False # continue streaming
|
|
370
|
+
|
|
371
|
+
def exec_parser():
|
|
372
|
+
p = argparse.ArgumentParser(prog="poetry ivcap job-exec request_file --")
|
|
373
|
+
p.add_argument("--timeout", type=nonneg_int, default=5, help="[5] seconds; 0 allowed")
|
|
374
|
+
p.add_argument("--with-result-content", dest="with_result_content", action="store_true",
|
|
375
|
+
help="include result content in the response")
|
|
376
|
+
p.add_argument("--stream", action="store_true", help="stream the result content")
|
|
377
|
+
p.add_argument("--raw-events", action="store_true", help="print raw SSE events")
|
|
378
|
+
p.add_argument("--auth-token", help="alternative auth token to use")
|
|
379
|
+
return p
|
|
380
|
+
|
|
381
|
+
def nonneg_int(s: str) -> int:
|
|
382
|
+
v = int(s)
|
|
383
|
+
if v < 0:
|
|
384
|
+
raise argparse.ArgumentTypeError("timeout must be >= 0")
|
|
385
|
+
return v
|
|
272
386
|
|
|
273
387
|
def get_account_id(data, line, is_silent=False):
|
|
274
388
|
check_ivcap_cmd(line)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|