@@ -29,6 +29,8 @@ type Deployer struct {
2929 Platform Platform
3030 Namespace string
3131 ModelSource string // "pvc", "hf", or "pvc-snapshot"
32+ MockImage string // if set, replace vLLM image with mock and remove GPU resources
33+ PullSecretName string // override pull secret name to copy (default: auto-detect from manifest)
3234 // LogFunc is called with progress messages. If nil, progress is silent.
3335 LogFunc func (format string , args ... interface {})
3436}
@@ -76,6 +78,11 @@ func (d *Deployer) Deploy(ctx context.Context, tc *config.TestCase) *DeployResul
7678 }
7779 result .Logs = append (result .Logs , fmt .Sprintf ("Namespace %s ready" , ns ))
7880
81+ // Copy image pull secrets referenced in the manifest from istio-system
82+ if err := d .ensurePullSecrets (ctx , tc .Deployment .ManifestPath , ns ); err != nil {
83+ d .logProgress (" Warning: failed to copy pull secrets: %v" , err )
84+ }
85+
7986 // Apply the manifest
8087 manifestPath := tc .Deployment .ManifestPath
8188 if manifestPath == "" {
@@ -436,6 +443,97 @@ func (d *Deployer) ensureNamespace(ctx context.Context, ns string) error {
436443 return err
437444}
438445
446+ // ensurePullSecrets reads the manifest for imagePullSecrets references and copies
447+ // them from istio-system into the target namespace if they don't already exist.
448+ func (d * Deployer ) ensurePullSecrets (ctx context.Context , manifestPath , ns string ) error {
449+ // OCP clusters have pull secrets configured globally — no need to copy
450+ if d .Platform == PlatformOCP {
451+ return nil
452+ }
453+
454+ seen := map [string ]bool {}
455+
456+ // If an explicit pull secret name is set, use that; otherwise auto-detect from manifest
457+ if d .PullSecretName != "" {
458+ seen [d .PullSecretName ] = true
459+ } else {
460+ data , err := os .ReadFile (manifestPath )
461+ if err != nil {
462+ return fmt .Errorf ("reading manifest: %w" , err )
463+ }
464+ // Parse secret names from "- name: <secret>" lines under imagePullSecrets
465+ lines := strings .Split (string (data ), "\n " )
466+ inPullSecrets := false
467+ for _ , line := range lines {
468+ trimmed := strings .TrimSpace (line )
469+ if trimmed == "imagePullSecrets:" {
470+ inPullSecrets = true
471+ continue
472+ }
473+ if inPullSecrets {
474+ if strings .HasPrefix (trimmed , "- name: " ) {
475+ name := strings .TrimPrefix (trimmed , "- name: " )
476+ seen [name ] = true
477+ continue
478+ }
479+ inPullSecrets = false
480+ }
481+ }
482+ }
483+
484+ sourceNamespaces := []string {"istio-system" , "kserve" , "opendatahub" }
485+ for secretName := range seen {
486+ // Skip if already exists in target namespace
487+ if _ , err := d .Kubectl (ctx , "get" , "secret" , secretName , "-n" , ns ); err == nil {
488+ continue
489+ }
490+
491+ // Try to copy from known source namespaces
492+ copied := false
493+ for _ , srcNS := range sourceNamespaces {
494+ if _ , err := d .Kubectl (ctx , "get" , "secret" , secretName , "-n" , srcNS ); err != nil {
495+ continue
496+ }
497+ // Get the secret and re-apply in target namespace
498+ secretYAML , err := d .Kubectl (ctx , "get" , "secret" , secretName , "-n" , srcNS , "-o" , "yaml" )
499+ if err != nil {
500+ continue
501+ }
502+ // Replace namespace and strip cluster-specific metadata
503+ secretYAML = strings .ReplaceAll (secretYAML , "namespace: " + srcNS , "namespace: " + ns )
504+ // Remove resourceVersion, uid, creationTimestamp so it can be created fresh
505+ var cleanLines []string
506+ for _ , l := range strings .Split (secretYAML , "\n " ) {
507+ t := strings .TrimSpace (l )
508+ if strings .HasPrefix (t , "resourceVersion:" ) ||
509+ strings .HasPrefix (t , "uid:" ) ||
510+ strings .HasPrefix (t , "creationTimestamp:" ) {
511+ continue
512+ }
513+ cleanLines = append (cleanLines , l )
514+ }
515+ tmpFile , err := os .CreateTemp ("" , "pull-secret-*.yaml" )
516+ if err != nil {
517+ return fmt .Errorf ("creating temp file: %w" , err )
518+ }
519+ _ , _ = tmpFile .WriteString (strings .Join (cleanLines , "\n " ))
520+ _ = tmpFile .Close ()
521+ _ , err = d .Kubectl (ctx , "apply" , "-n" , ns , "-f" , tmpFile .Name ())
522+ _ = os .Remove (tmpFile .Name ())
523+ if err != nil {
524+ continue
525+ }
526+ d .logProgress (" Copied pull secret %s from %s to %s" , secretName , srcNS , ns )
527+ copied = true
528+ break
529+ }
530+ if ! copied {
531+ return fmt .Errorf ("pull secret %q not found in any of %v" , secretName , sourceNamespaces )
532+ }
533+ }
534+ return nil
535+ }
536+
439537// Kubectl runs a kubectl command with the deployer's kubeconfig and platform settings.
440538func (d * Deployer ) Kubectl (ctx context.Context , args ... string ) (string , error ) {
441539 cmdArgs := make ([]string , 0 , len (args )+ 2 )
@@ -490,6 +588,72 @@ func (d *Deployer) patchManifest(manifestPath string, tc *config.TestCase) (stri
490588 }
491589 }
492590
591+ // Mock mode: replace main vLLM container with mock image (no GPU, no model download)
592+ // Only patches under spec.template.containers, NOT spec.router.scheduler.template.containers
593+ if d .MockImage != "" {
594+ var newLines []string
595+ skip := false
596+ containerIndent := 0
597+ inSchedulerTemplate := false
598+ for _ , line := range lines {
599+ trimmed := strings .TrimSpace (line )
600+ lineIndent := len (line ) - len (strings .TrimLeft (line , " " ))
601+
602+ // Track if we're inside the scheduler template section
603+ if trimmed == "scheduler:" || strings .HasPrefix (trimmed , "scheduler: " ) {
604+ inSchedulerTemplate = true
605+ }
606+ // spec.template / spec.prefill.template are at lower indent than scheduler.template
607+ if trimmed == "template:" && ! inSchedulerTemplate {
608+ // Already outside scheduler — keep as false
609+ } else if trimmed == "template:" && lineIndent <= 4 {
610+ inSchedulerTemplate = false
611+ }
612+
613+ // Replace all "- name: main" under spec.template and spec.prefill.template, not scheduler.template
614+ if trimmed == "- name: main" && ! inSchedulerTemplate {
615+ containerIndent = lineIndent
616+ skip = true
617+ indent := strings .Repeat (" " , containerIndent )
618+ newLines = append (newLines ,
619+ indent + "- name: main" ,
620+ indent + " image: " + d .MockImage ,
621+ indent + " imagePullPolicy: Always" ,
622+ indent + " command: [\" python3\" ]" ,
623+ indent + " args: [\" /app/server.py\" ]" ,
624+ indent + " resources:" ,
625+ indent + " limits:" ,
626+ indent + " cpu: \" 500m\" " ,
627+ indent + " memory: 128Mi" ,
628+ indent + " requests:" ,
629+ indent + " cpu: \" 100m\" " ,
630+ indent + " memory: 64Mi" ,
631+ )
632+ patched = true
633+ continue
634+ }
635+
636+ if skip {
637+ if trimmed != "" && lineIndent <= containerIndent {
638+ skip = false
639+ } else {
640+ continue
641+ }
642+ }
643+
644+ newLines = append (newLines , line )
645+ }
646+ // Filter empty lines left behind by container block removal
647+ var filteredLines []string
648+ for _ , line := range newLines {
649+ if line != "" {
650+ filteredLines = append (filteredLines , line )
651+ }
652+ }
653+ lines = filteredLines
654+ d .logProgress (" Mock mode: using image %s (no GPU)" , d .MockImage )
655+ }
656+
493657 if ! patched {
494658 return manifestPath , nil
495659 }
0 commit comments