package workflow_builder // source_private.go — Phase 4 : sources privées (isReachable = false) // // Pour les ressources (Processing ou Data) dont la source n'est pas // directement accessible (access.IsReachable == false), le protocole est : // // 1. oc-monitord publie un ARGO_KUBE_EVENT(PROCESSING_RESOURCE) sur NATS // avec les informations de couplage (vérification AE côté peer distant). // // 2. Le peer propriétaire valide l'AE, génère une URL pré-signée Minio // éphémère et répond via CONSIDERS_EVENT avec presigned_url + resource_id. // // 3. oc-monitord crée un Secret Kubernetes éphémère contenant l'URL, // labelisé oc-execution-id pour nettoyage post-exécution. // // 4. La step Argo injecte un wrapper sh qui : // • Processing binary : lit l'URL → /dev/shm/.exec → chmod+x → fork → rm → wait // • Data : lit l'URL → destPath/filename (pas de chmod/rm/exec) import ( "encoding/json" "fmt" "strings" "sync" "time" "oc-monitord/conf" . "oc-monitord/models" "oc-monitord/tools" oclib "cloud.o-forge.io/core/oc-lib" "cloud.o-forge.io/core/oc-lib/models/resources" "cloud.o-forge.io/core/oc-lib/models/workflow_execution" octools "cloud.o-forge.io/core/oc-lib/tools" ) // ── Demande NATS d'URL pré-signée ──────────────────────────────────────────── // waitForPresignedURL publie un ARGO_KUBE_EVENT(PROCESSING_RESOURCE) sur NATS // pour demander une URL pré-signée au peer propriétaire de la source privée, // puis attend la réponse via globalSourceCache. // // Résultats envoyés dans urlCh / errCh ; wg.Done() est toujours appelé. func waitForPresignedURL( executionsID string, event ArgoKubeEvent, wg *sync.WaitGroup, urlCh chan<- string, errCh chan<- error, ) { defer wg.Done() b, err := json.Marshal(event) if err != nil { logger.Error().Msg("[source-private] cannot marshal ArgoKubeEvent: " + err.Error()) urlCh <- "" errCh <- err return } octools.NewNATSCaller().SetNATSPub(octools.ARGO_KUBE_EVENT, octools.NATSResponse{ FromApp: "oc-monitord", Datatype: octools.PROCESSING_RESOURCE, User: "root", Method: int(octools.ARGO_KUBE_EVENT), Payload: b, }) key := sourceConsidersKey(executionsID, event.SourcePeerID, event.SourceResourceID) ch, unregister := globalSourceCache.register(key) defer unregister() select { case url := <-ch: logger.Info().Msg(fmt.Sprintf( "[source-private] presigned URL received resource=%s exec=%s", event.SourceResourceID, executionsID, )) urlCh <- url errCh <- nil case <-time.After(5 * time.Minute): ferr := fmt.Errorf( "timeout waiting for presigned URL resource=%s exec=%s", event.SourceResourceID, executionsID, ) logger.Error().Msg(ferr.Error()) urlCh <- "" errCh <- ferr } } // ── Nommage du Secret ───────────────────────────────────────────────────────── // secretNameFor génère un nom de Secret K8s valide (63 chars max, alphanum+-) // à partir d'un nom de step et d'un execution ID. func secretNameFor(stepBaseName, executionID string) string { base := strings.ToLower(strings.ReplaceAll(stepBaseName, "_", "-")) if len(base) > 30 { base = base[:30] } suffix := executionID if len(suffix) > 8 { suffix = suffix[:8] } name := "oc-src-" + base + "-" + suffix // Éliminer les caractères non autorisés par K8s (alphanum + '-'). var clean strings.Builder for _, c := range name { if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { clean.WriteRune(c) } } s := strings.Trim(clean.String(), "-") if len(s) > 63 { s = s[:63] } return s } // ── Injection step Processing (source privée) ───────────────────────────────── // injectPrivateProcessingStep ajoute dans le DAG et dans les templates Argo une // step wrapper pour un Processing à source privée. // // Le script sh : // 1. Lit l'URL pré-signée depuis le Secret monté à /var/oc-secrets/presigned-url // 2. Télécharge le binaire dans /dev/shm/.exec (RAM — jamais sur disque) // 3. Le rend exécutable, le lance en background, supprime le fichier, attend la fin // // Le Secret est déclaré dans spec.volumes (ExistingVolume) et monté en lecture // seule dans le container. // // Retourne le nom Argo de la step créée. func (b *ArgoBuilder) injectPrivateProcessingStep( stepBaseName string, secretName string, dependsOn []string, ) string { stepName := stepBaseName + "-prv-fetch" volName := strings.ReplaceAll(secretName, ".", "-") script := `PRESIGNED=$(cat /var/oc-secrets/presigned-url) curl -fsSL "$PRESIGNED" -o /dev/shm/.exec chmod +x /dev/shm/.exec /dev/shm/.exec & PID=$! rm -f /dev/shm/.exec wait $PID` // Volume Secret dans le spec du workflow (partagé entre toutes les steps). b.addSecretVolumeIfAbsent(volName, secretName) fetchTask := Task{ Name: stepName, Template: stepName, Dependencies: dependsOn, } b.Workflow.getDag().Tasks = append(b.Workflow.getDag().Tasks, fetchTask) fetchTemplate := Template{ Name: stepName, Container: Container{ Image: curlImage, ImagePullPolicy: "IfNotPresent", Command: []string{"sh", "-c"}, Args: []string{script}, VolumeMounts: []VolumeMount{ {Name: volName, MountPath: "/var/oc-secrets", ReadOnly: true}, }, }, } b.Workflow.Spec.Templates = append(b.Workflow.Spec.Templates, fetchTemplate) logger.Info().Msg(fmt.Sprintf( "[source-private] injected private processing step '%s' (secret=%s)", stepName, secretName, )) return stepName } // ── Injection step Data (source privée) ────────────────────────────────────── // injectPrivateDataStep ajoute dans le DAG une step de téléchargement sécurisé // pour une Data à source privée vers le storage lié (pas de /dev/shm, pas de rm). func (b *ArgoBuilder) injectPrivateDataStep( stepBaseName string, secretName string, destPath string, filename string, dependsOn []string, ) string { stepName := stepBaseName + "-prv-fetch" volName := strings.ReplaceAll(secretName, ".", "-") fullDest := destPath + "/" + filename script := fmt.Sprintf( "PRESIGNED=$(cat /var/oc-secrets/presigned-url)\ncurl -fsSL \"$PRESIGNED\" -o '%s'", fullDest, ) b.addSecretVolumeIfAbsent(volName, secretName) fetchTask := Task{ Name: stepName, Template: stepName, Dependencies: dependsOn, } b.Workflow.getDag().Tasks = append(b.Workflow.getDag().Tasks, fetchTask) fetchTemplate := Template{ Name: stepName, Container: Container{ Image: curlImage, ImagePullPolicy: "IfNotPresent", Command: []string{"sh", "-c"}, Args: []string{script}, VolumeMounts: []VolumeMount{ {Name: volName, MountPath: "/var/oc-secrets", ReadOnly: true}, }, }, } b.Workflow.Spec.Templates = append(b.Workflow.Spec.Templates, fetchTemplate) logger.Info().Msg(fmt.Sprintf( "[source-private] injected private data step '%s' → %s (secret=%s)", stepName, fullDest, secretName, )) return stepName } // addSecretVolumeIfAbsent déclare un volume de type Secret dans spec.volumes // uniquement s'il n'est pas déjà présent (déduplication par nom). func (b *ArgoBuilder) addSecretVolumeIfAbsent(volName, secretName string) { for _, v := range b.Workflow.Spec.ExistingVolumes { if v.Name == volName { return } } b.Workflow.Spec.ExistingVolumes = append(b.Workflow.Spec.ExistingVolumes, ExistingVolume{ Name: volName, Secret: &SecretRef{SecretName: secretName}, }) } // ── handlePrivateProcessingSource ──────────────────────────────────────────── // handlePrivateProcessingSource gère le cas où un ProcessingInstance a une source // privée (access.HasSource() && !access.IsReachable). // // Orchestration : // 1. Demande de l'URL pré-signée via NATS (waitForPresignedURL) // 2. Création du Secret K8s éphémère // 3. Injection de la step wrapper dans le DAG // 4. Recâblage des dépendances (processing dépend du step wrapper) func (b *ArgoBuilder) handlePrivateProcessingSource( exec *workflow_execution.WorkflowExecution, graphID string, procResource *resources.ProcessingResource, procInst *resources.ProcessingInstance, argoStepName string, namespace string, ) error { access := procInst.Access if !access.HasSource() || access.Source.IsReachable { return nil } self, err := oclib.GetMySelf() if err != nil { return fmt.Errorf("[source-private] cannot get local peer ID: %w", err) } // Demande de l'URL pré-signée au peer propriétaire. var wg sync.WaitGroup urlCh := make(chan string, 1) errCh := make(chan error, 1) wg.Add(1) go waitForPresignedURL(exec.ExecutionsID, ArgoKubeEvent{ ExecutionsID: exec.ExecutionsID, Type: octools.PROCESSING_RESOURCE, SourcePeerID: procResource.GetCreatorID(), DestPeerID: self.GetID(), OriginID: conf.GetConfig().PeerID, SourceResourceID: procResource.GetID(), }, &wg, urlCh, errCh) wg.Wait() close(urlCh) close(errCh) presignedURL := <-urlCh if ferr := <-errCh; ferr != nil || presignedURL == "" { if ferr == nil { ferr = fmt.Errorf("empty presigned URL for processing '%s'", procResource.GetName()) } return ferr } // Création du Secret K8s contenant l'URL. secretName := secretNameFor(argoStepName, exec.ExecutionsID) kube, kerr := tools.NewKubernetesTool() if kerr != nil { return fmt.Errorf("[source-private] cannot create K8s client: %w", kerr) } if kerr = kube.CreateSourceSecret(secretName, presignedURL, exec.ExecutionsID, namespace); kerr != nil { return fmt.Errorf("[source-private] CreateSourceSecret: %w", kerr) } // Dépendances actuelles de la step processing (seront portées par le step wrapper). existingDeps := b.getArgoDependencies(exec, graphID) // Injection de la step wrapper. fetchStepName := b.injectPrivateProcessingStep(argoStepName, secretName, existingDeps) // Recâblage : la step processing ne dépend plus que du step wrapper. dag := b.Workflow.getDag() for i, task := range dag.Tasks { if task.Name == argoStepName { dag.Tasks[i].Dependencies = []string{fetchStepName} break } } logger.Info().Msg(fmt.Sprintf( "[source-private] processing '%s' wired: %v → %s → %s", procResource.GetName(), existingDeps, fetchStepName, argoStepName, )) return nil } // ── HandlePrivateDataSources ───────────────────────────────────────────────── // HandlePrivateDataSources parcourt tous les items Data dont une instance a // une source privée (access.HasSource() && !access.IsReachable) et injecte // pour chacun une step de téléchargement sécurisé dans le storage lié. // // Appelé depuis HandleDataSources() en fin de createTemplates(). func (b *ArgoBuilder) HandlePrivateDataSources( exec *workflow_execution.WorkflowExecution, namespace string, ) { self, err := oclib.GetMySelf() if err != nil { logger.Error().Msg("[source-private] cannot get local peer ID: " + err.Error()) return } for itemID, item := range b.OriginWorkflow.Graph.Items { if !b.OriginWorkflow.Graph.IsData(item) || item.ItemResource.Data == nil { continue } var sourceURL string var resourceID string var creatorID string for _, inst := range item.ItemResource.Data.Instances { if inst == nil || !inst.Access.HasSource() || inst.Access.Source.IsReachable { continue } sourceURL = inst.Access.Source.Source resourceID = item.ItemResource.Data.GetID() creatorID = item.ItemResource.Data.GetCreatorID() break } if sourceURL == "" { continue } // Storage lié à cette Data. linkedStorageIDs := b.OriginWorkflow.Graph.GetLinkedStorageForData(itemID) if len(linkedStorageIDs) == 0 { logger.Error().Msg(fmt.Sprintf( "[source-private] data '%s' has private source but no storage linked — skipping", item.ItemResource.Data.GetName(), )) continue } storageItem, ok := b.OriginWorkflow.Graph.Items[linkedStorageIDs[0]] if !ok || storageItem.ItemResource.Storage == nil || len(storageItem.ItemResource.Storage.Instances) == 0 { continue } mountPath := storageItem.ItemResource.Storage.Instances[0].Source if mountPath == "" { logger.Error().Msg(fmt.Sprintf( "[source-private] storage linked to data '%s' has no mount path — skipping", item.ItemResource.Data.GetName(), )) continue } // Demande de l'URL pré-signée. var wg sync.WaitGroup urlCh := make(chan string, 1) errCh := make(chan error, 1) wg.Add(1) go waitForPresignedURL(exec.ExecutionsID, ArgoKubeEvent{ ExecutionsID: exec.ExecutionsID, Type: octools.PROCESSING_RESOURCE, SourcePeerID: creatorID, DestPeerID: self.GetID(), OriginID: conf.GetConfig().PeerID, SourceResourceID: resourceID, }, &wg, urlCh, errCh) wg.Wait() close(urlCh) close(errCh) presignedURL := <-urlCh if ferr := <-errCh; ferr != nil || presignedURL == "" { logger.Error().Msg(fmt.Sprintf( "[source-private] data '%s': failed to get presigned URL: %v", item.ItemResource.Data.GetName(), ferr, )) continue } // Création du Secret K8s. fetchBaseName := strings.ToLower(strings.ReplaceAll(item.ItemResource.Data.GetName(), " ", "-")) secretName := secretNameFor(fetchBaseName, exec.ExecutionsID) kube, kerr := tools.NewKubernetesTool() if kerr != nil { logger.Error().Msg("[source-private] cannot create K8s client: " + kerr.Error()) continue } if kerr = kube.CreateSourceSecret(secretName, presignedURL, exec.ExecutionsID, namespace); kerr != nil { logger.Error().Msg("[source-private] cannot create source secret: " + kerr.Error()) continue } // Injection pour chaque processing aval lisant ce storage. downstreamProcIDs := b.processingsThatReadStorage(linkedStorageIDs[0]) filename := sourceFilename(sourceURL) for _, procItemID := range downstreamProcIDs { procItem := b.OriginWorkflow.Graph.Items[procItemID] if procItem.ItemResource.Processing == nil { continue } existingDeps := b.getArgoDependencies(exec, procItemID) fetchStepName := b.injectPrivateDataStep( fetchBaseName, secretName, mountPath, filename, existingDeps, ) // Recâblage des steps processing aval. dag := b.Workflow.getDag() for _, pb := range getAllPeersForItem(exec, procItemID) { procArgoName := getArgoName(procItem.ItemResource.Processing.GetName(), pb.BookingID) for i, task := range dag.Tasks { if task.Name == procArgoName { dag.Tasks[i].Dependencies = []string{fetchStepName} break } } } } } }