konduktor-nightly 0.1.0.dev20250915104603__py3-none-any.whl → 0.1.0.dev20251107104752__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.
@@ -5,15 +5,12 @@ import json
5
5
  import tempfile
6
6
  import time
7
7
  import typing
8
- from datetime import datetime, timezone
8
+ from datetime import datetime, timedelta, timezone
9
9
  from typing import Any, Dict, Optional, Tuple
10
10
 
11
11
  import click
12
12
  import colorama
13
13
 
14
- if typing.TYPE_CHECKING:
15
- from datetime import timedelta
16
-
17
14
  import konduktor
18
15
  from konduktor import kube_client, logging
19
16
  from konduktor.backends import constants as backend_constants
@@ -428,7 +425,9 @@ def _parse_timestamp_filter(timestamp_str: str) -> datetime:
428
425
  seconds=abs(local_offset)
429
426
  )
430
427
  else:
431
- dt = dt.replace(tzinfo=timezone.utc)
428
+ # Handle date-only format (local midnight --> UTC)
429
+ local_tz = datetime.now().astimezone().tzinfo
430
+ return dt.replace(tzinfo=local_tz).astimezone(timezone.utc)
432
431
  return dt
433
432
  except ValueError:
434
433
  continue
@@ -450,7 +449,8 @@ def _format_timestamp(timestamp: str) -> str:
450
449
 
451
450
 
452
451
  def _get_job_start_time(job: Dict[str, Any]) -> str:
453
- for condition in job['status'].get('conditions', []):
452
+ status = job.get('status', {})
453
+ for condition in status.get('conditions', []):
454
454
  if condition['reason'] == 'ResumeJobs':
455
455
  return condition.get('lastTransitionTime', '')
456
456
  return '-'
@@ -153,7 +153,9 @@ def create_pod_spec(task: 'konduktor.Task') -> Dict[str, Any]:
153
153
  git_ssh_secret_name = None
154
154
  env_secret_envs = []
155
155
  default_secrets = []
156
+ basename_by_k8s: Dict[str, str] = {}
156
157
 
158
+ # only get own secrets
157
159
  user_hash = common_utils.get_user_hash()
158
160
  label_selector = f'{backend_constants.SECRET_OWNER_LABEL}={user_hash}'
159
161
  user_secrets = kubernetes_utils.list_secrets(
@@ -162,19 +164,36 @@ def create_pod_spec(task: 'konduktor.Task') -> Dict[str, Any]:
162
164
 
163
165
  for secret in user_secrets:
164
166
  kind = kubernetes_utils.get_secret_kind(secret)
167
+
168
+ # incase the user modified their secret to have no key:value data
169
+ if secret.data is None:
170
+ secret.data = {}
171
+
172
+ # fill the map for *all* secrets we see
173
+ k8s_name = secret.metadata.name
174
+ lbls = secret.metadata.labels or {}
175
+ base = lbls.get(
176
+ backend_constants.SECRET_BASENAME_LABEL,
177
+ # fallback: strip trailing "-<something>" once if present
178
+ k8s_name.rsplit('-', 1)[0] if '-' in k8s_name else k8s_name,
179
+ )
180
+ basename_by_k8s[k8s_name] = base
181
+
165
182
  if kind == 'git-ssh' and git_ssh_secret_name is None:
166
183
  git_ssh_secret_name = secret.metadata.name
167
184
  elif kind == 'env':
168
185
  env_secret_name = secret.metadata.name
169
- key = next(iter(secret.data))
170
- env_secret_envs.append(
171
- {
172
- 'name': key,
173
- 'valueFrom': {
174
- 'secretKeyRef': {'name': env_secret_name, 'key': key}
175
- },
176
- }
177
- )
186
+ # iterate ALL keys, not just one (ex. if user made a multi-key env secret)
187
+ for key, _ in secret.data.items():
188
+ # wire the env var to read its value from a k8s secret
189
+ env_secret_envs.append(
190
+ {
191
+ 'name': key,
192
+ 'valueFrom': {
193
+ 'secretKeyRef': {'name': env_secret_name, 'key': key}
194
+ },
195
+ }
196
+ )
178
197
  elif kind == 'default':
179
198
  default_secret_name = secret.metadata.name
180
199
  basename = secret.metadata.labels.get(
@@ -184,6 +203,22 @@ def create_pod_spec(task: 'konduktor.Task') -> Dict[str, Any]:
184
203
  {'k8s_name': default_secret_name, 'mount_name': basename}
185
204
  )
186
205
 
206
+ # Check if the task references KONDUKTOR_DEFAULT_SECRETS and that it exists
207
+ uses_default_secret_var = (
208
+ 'KONDUKTOR_DEFAULT_SECRETS' in (task.run or '')
209
+ or 'KONDUKTOR_DEFAULT_SECRETS' in (task.setup or '')
210
+ or '/konduktor/default-secrets/' in (task.run or '')
211
+ or '/konduktor/default-secrets/' in (task.setup or '')
212
+ )
213
+ if uses_default_secret_var and not default_secrets:
214
+ raise exceptions.MissingSecretError(
215
+ f'Task references KONDUKTOR_DEFAULT_SECRETS or '
216
+ f'/konduktor/default-secrets but '
217
+ f'user {common_utils.get_cleaned_username()} '
218
+ f'has no default secrets. Paths like '
219
+ f'$KONDUKTOR_DEFAULT_SECRETS/<secret_name>/... will not exist.'
220
+ )
221
+
187
222
  # Inject --served-model-name, --host, and --port into serving run command
188
223
  if task.serving and task.run and 'vllm.entrypoints.openai.api_server' in task.run:
189
224
  if '--served-model-name' and '--host' and '--port' not in task.run:
@@ -262,31 +297,111 @@ def create_pod_spec(task: 'konduktor.Task') -> Dict[str, Any]:
262
297
  },
263
298
  temp.name,
264
299
  )
300
+
301
+ # Capture the template env names BEFORE user config is merged
302
+ pod_config_template = common_utils.read_yaml(temp.name)
303
+ tmpl_envs = pod_config_template['kubernetes']['pod_config']['spec'][
304
+ 'containers'
305
+ ][0].get('env', [])
306
+ tmpl_env_names = {e['name'] for e in tmpl_envs}
307
+
265
308
  pod_config = common_utils.read_yaml(temp.name)
266
- # merge with `~/.konduktor/config.yaml``
309
+ # merge with `~/.konduktor/config.yaml`` (config.yaml overrides template)
267
310
  kubernetes_utils.combine_pod_config_fields(temp.name, pod_config)
268
311
  pod_config = common_utils.read_yaml(temp.name)
269
312
 
270
- # Priority order: task.envs > secret envs > existing pod_config envs
271
- existing_envs = pod_config['kubernetes']['pod_config']['spec']['containers'][0].get(
313
+ # Find what came from user config (appeared after combine, not in template)
314
+ premerge_envs = pod_config['kubernetes']['pod_config']['spec']['containers'][0].get(
272
315
  'env', []
273
316
  )
274
- env_map = {env['name']: env for env in existing_envs}
317
+ premerge_names = {e['name'] for e in premerge_envs}
318
+ config_env_names0 = premerge_names - tmpl_env_names
275
319
 
276
- # Inject secret envs
320
+ # Build final env list
321
+ env_map = {env['name']: env for env in premerge_envs}
322
+
323
+ # Inject secret envs (env secrets override config.yaml)
277
324
  for env in env_secret_envs:
278
325
  env_map[env['name']] = env
279
326
 
280
- # Inject task.envs
327
+ # Inject task envs
328
+ # CLI+task.yaml overrides everything else
329
+ # CLI already overrode task.yaml in other code
281
330
  for k, v in task.envs.items():
282
331
  env_map[k] = {'name': k, 'value': v}
283
332
 
284
- # Replace the container's env section with the merged and prioritized map
285
- pod_config['kubernetes']['pod_config']['spec']['containers'][0]['env'] = list(
286
- env_map.values()
333
+ final_envs_list = list(env_map.values())
334
+ pod_config['kubernetes']['pod_config']['spec']['containers'][0]['env'] = (
335
+ final_envs_list
287
336
  )
337
+ container = pod_config['kubernetes']['pod_config']['spec']['containers'][0]
338
+ final_envs = container['env']
339
+ final_names = {e['name'] for e in final_envs}
340
+
288
341
  logger.debug(f'rendered pod spec: \n\t{json.dumps(pod_config, indent=2)}')
289
342
 
343
+ # 1) Get secret envs actually used in the final env list
344
+ secret_details = sorted(
345
+ (e['name'], e['valueFrom']['secretKeyRef']['name'])
346
+ for e in final_envs
347
+ if isinstance(e, dict)
348
+ and e.get('valueFrom', {})
349
+ and e['valueFrom'].get('secretKeyRef')
350
+ )
351
+ secret_names = [n for n, _ in secret_details]
352
+
353
+ # 2) Get task-sourced (CLI+task.yaml) envs actually used in the final env list
354
+ task_all_names = sorted(
355
+ n
356
+ for n in (task.envs or {}).keys()
357
+ if n in final_names and n not in secret_names
358
+ )
359
+
360
+ # 3) Get Config.yaml envs actually used in the final env list
361
+ config_names = sorted(
362
+ n
363
+ for n in config_env_names0
364
+ if n in final_names and n not in secret_names and n not in task_all_names
365
+ )
366
+
367
+ # 4) Get other envs (template/system) actually used in the final env list
368
+ other_names = sorted(
369
+ final_names - set(secret_names) - set(task_all_names) - set(config_names)
370
+ )
371
+
372
+ # Export helper envs for the startup script (names only)
373
+ def _append_helper(name: str, values):
374
+ container['env'].append({'name': name, 'value': ','.join(values)})
375
+
376
+ # to show user basenames of k8s secrets instead of actual
377
+ # k8s secret names (which have added suffixes)
378
+ secret_map_pairs = [
379
+ f'{var}={basename_by_k8s.get(secret_k8s, secret_k8s)}'
380
+ for (var, secret_k8s) in secret_details
381
+ ]
382
+
383
+ # Priority order: CLI > task.yaml > env secret > config > template/system
384
+ _append_helper(
385
+ 'KONDUKTOR_ENV_SECRETS_HOPEFULLY_NO_NAME_COLLISION',
386
+ secret_names,
387
+ )
388
+ _append_helper(
389
+ 'KONDUKTOR_ENV_SECRETS_MAP_HOPEFULLY_NO_NAME_COLLISION',
390
+ secret_map_pairs,
391
+ )
392
+ _append_helper(
393
+ 'KONDUKTOR_ENV_TASK_ALL_HOPEFULLY_NO_NAME_COLLISION',
394
+ task_all_names,
395
+ )
396
+ _append_helper(
397
+ 'KONDUKTOR_ENV_CONFIG_HOPEFULLY_NO_NAME_COLLISION',
398
+ config_names,
399
+ )
400
+ _append_helper(
401
+ 'KONDUKTOR_ENV_OTHER_HOPEFULLY_NO_NAME_COLLISION',
402
+ other_names,
403
+ )
404
+
290
405
  # validate pod spec using json schema
291
406
  try:
292
407
  validator.validate_pod_spec(pod_config['kubernetes']['pod_config']['spec'])
konduktor/cli.py CHANGED
@@ -54,6 +54,7 @@ from konduktor import logging
54
54
  from konduktor.backends import constants as backend_constants
55
55
  from konduktor.backends import deployment_utils, jobset_utils
56
56
  from konduktor.utils import (
57
+ base64_utils,
57
58
  common_utils,
58
59
  kubernetes_utils,
59
60
  log_utils,
@@ -161,7 +162,9 @@ def _make_task_with_overrides(
161
162
  if workdir is not None:
162
163
  task.workdir = workdir
163
164
 
164
- task.set_resources_override(override_params)
165
+ # perform overrides from CLI
166
+ if override_params:
167
+ task.set_resources_override(override_params)
165
168
  if task.serving:
166
169
  task.set_serving_override(serving_override_params)
167
170
 
@@ -653,28 +656,23 @@ def status(
653
656
  all_users: bool, limit: Optional[int], after: Optional[str], before: Optional[str]
654
657
  ):
655
658
  # NOTE(dev): Keep the docstring consistent between the Python API and CLI.
656
- """Shows list of all the jobs with optional filtering and pagination
657
-
658
- Args:
659
- all_users (bool): whether to show all jobs for all users
660
- limit (Optional[int]): maximum number of jobs to display
661
- after (Optional[str]): show jobs created after this timestamp
662
- before (Optional[str]): show jobs created before this timestamp
659
+ """Shows list of all the jobs with optional filtering and pagination.
663
660
 
661
+ \b
664
662
  Examples:
665
- konduktor status --limit 10
666
- konduktor status --before "08/06/25 03:53PM"
667
- konduktor status --all-users --limit 10 --after "08/06/25 03:53PM"
668
-
669
- Note:
670
- When using --before or --after timestamps, passing in "08/06/25" is
671
- equivalent to passing in "08/06/25 00:00".
672
- When using --before or --after timestamps, passing in "03:53PM" is
673
- equivalent to passing in "03:53:00PM".
674
- Timestamps shown in "konduktor startus" are truncated and are in the
675
- local timezone. ex. "03:53:55PM" --> "03:53PM"
676
- and would show up in --after "03:53PM" but not in --before "03:53PM"
677
- despite status showing as "03:53PM".
663
+ konduktor status --limit 10
664
+ konduktor status --before "08/06/25 03:53PM"
665
+ konduktor status --all-users --limit 10 --after "08/06/25 03:53PM"
666
+
667
+ \b
668
+ Notes:
669
+ When using --before or --after timestamps, "08/06/25"
670
+ is equivalent to "08/06/25 00:00".
671
+ • "03:53PM" is equivalent to "03:53:00PM".
672
+ Timestamps shown in "konduktor status" are truncated
673
+ and are in the local timezone.
674
+ Example: "03:53:55PM" "03:53PM" would show up in
675
+ --after "03:53PM" but not in --before "03:53PM".
678
676
  """
679
677
  context = kubernetes_utils.get_current_kube_config_context_name()
680
678
  namespace = kubernetes_utils.get_kube_config_context_namespace(context)
@@ -802,6 +800,13 @@ def logs(
802
800
  # pylint: disable=bad-docstring-quotes
803
801
  help='Skip confirmation prompt.',
804
802
  )
803
+ @click.option(
804
+ '--skip-image-check',
805
+ '-s',
806
+ is_flag=True,
807
+ default=False,
808
+ help='Skip Docker image validation checks for faster startup.',
809
+ )
805
810
  def launch(
806
811
  entrypoint: Tuple[str, ...],
807
812
  dryrun: bool,
@@ -820,6 +825,7 @@ def launch(
820
825
  env: List[Tuple[str, str]],
821
826
  disk_size: Optional[int],
822
827
  yes: bool,
828
+ skip_image_check: bool,
823
829
  ):
824
830
  """Launch a task.
825
831
 
@@ -829,6 +835,9 @@ def launch(
829
835
  # NOTE(dev): Keep the docstring consistent between the Python API and CLI.
830
836
  env = _merge_env_vars(env_file, env)
831
837
 
838
+ if skip_image_check:
839
+ os.environ['KONDUKTOR_SKIP_IMAGE_CHECK'] = '1'
840
+
832
841
  task = _make_task_with_overrides(
833
842
  entrypoint=entrypoint,
834
843
  name=name,
@@ -973,7 +982,9 @@ def down(
973
982
 
974
983
  if all:
975
984
  assert jobs_specs is not None, f'No jobs found in namespace {namespace}'
976
- assert len(jobs_specs) > 0, f'No jobs found in namespace {namespace}'
985
+ if len(jobs_specs) == 0:
986
+ click.secho(f'No jobs found in namespace {namespace}', fg='yellow')
987
+ return
977
988
  jobs = [job['metadata']['name'] for job in jobs_specs]
978
989
  elif jobs:
979
990
  # Get all available jobs to match against patterns
@@ -1481,12 +1492,21 @@ def create(kind, from_file, from_directory, inline, name):
1481
1492
  data = {}
1482
1493
  if from_directory:
1483
1494
  click.echo(f'Creating secret from directory: {from_directory}')
1484
- base_path = pathlib.Path(from_directory)
1485
- for path in base_path.rglob('*'):
1486
- if path.is_file():
1487
- rel_path = path.relative_to(base_path)
1488
- with open(path, 'rb') as f:
1489
- data[str(rel_path)] = b64encode(f.read()).decode()
1495
+ # Use ABSOLUTE directory path so the top-level folder name is preserved
1496
+ base_dir_abs = os.path.abspath(os.path.expanduser(from_directory))
1497
+ if not os.path.isdir(base_dir_abs):
1498
+ raise click.BadParameter(
1499
+ f"--from-directory {from_directory} doesn't exist or is not a directory"
1500
+ )
1501
+ # Ensure there is at least one file inside
1502
+ if not any(p.is_file() for p in pathlib.Path(base_dir_abs).rglob('*')):
1503
+ raise click.BadParameter(f'--from-directory {from_directory} is empty.')
1504
+
1505
+ # Zip + base64 the WHOLE directory (this preserves the inner structure)
1506
+ archive_b64 = base64_utils.zip_base64encode([base_dir_abs])
1507
+
1508
+ # Store as a single key; pod will unzip to the expanded path
1509
+ data = {'payload.zip': archive_b64}
1490
1510
  elif from_file:
1491
1511
  click.echo(f'Creating secret from file: {from_file}')
1492
1512
  key = os.path.basename(from_file)
@@ -1630,7 +1650,7 @@ def list_secrets(all_users: bool):
1630
1650
 
1631
1651
  @cli.group(cls=_NaturalOrderGroup)
1632
1652
  def serve():
1633
- """Manage LLM serving with Konduktor.
1653
+ """Manage deployment serving with Konduktor.
1634
1654
 
1635
1655
  USAGE: konduktor serve COMMAND
1636
1656
 
@@ -1692,6 +1712,13 @@ def serve():
1692
1712
  # pylint: disable=bad-docstring-quotes
1693
1713
  help='Skip confirmation prompt.',
1694
1714
  )
1715
+ @click.option(
1716
+ '--skip-image-check',
1717
+ '-s',
1718
+ is_flag=True,
1719
+ default=False,
1720
+ help='Skip Docker image validation checks for faster startup.',
1721
+ )
1695
1722
  def serve_launch(
1696
1723
  entrypoint: Tuple[str, ...],
1697
1724
  dryrun: bool,
@@ -1714,6 +1741,7 @@ def serve_launch(
1714
1741
  ports: Optional[int],
1715
1742
  probe: Optional[str],
1716
1743
  yes: bool,
1744
+ skip_image_check: bool = False,
1717
1745
  ):
1718
1746
  """Launch a deployment to serve.
1719
1747
 
@@ -1723,6 +1751,9 @@ def serve_launch(
1723
1751
  # NOTE(dev): Keep the docstring consistent between the Python API and CLI.
1724
1752
  env = _merge_env_vars(env_file, env)
1725
1753
 
1754
+ if skip_image_check:
1755
+ os.environ['KONDUKTOR_SKIP_IMAGE_CHECK'] = '1'
1756
+
1726
1757
  task = _make_task_with_overrides(
1727
1758
  entrypoint=entrypoint,
1728
1759
  name=name,
@@ -1737,6 +1768,7 @@ def serve_launch(
1737
1768
  image_id=image_id,
1738
1769
  env=env,
1739
1770
  disk_size=disk_size,
1771
+ # serving stuff
1740
1772
  min_replicas=min_replicas,
1741
1773
  max_replicas=max_replicas,
1742
1774
  ports=ports,