// Package workflow_builder traduit les informations du graphe d'un Workflow // (ses composants, ses liens) en un fichier YAML Argo Workflow prêt à être // soumis à un cluster Kubernetes. Le point d'entrée principal est ArgoBuilder. package workflow_builder import ( "encoding/json" "fmt" "oc-monitord/conf" . "oc-monitord/models" "sort" "sync" "os" "strings" "time" oclib "cloud.o-forge.io/core/oc-lib" "cloud.o-forge.io/core/oc-lib/logs" "cloud.o-forge.io/core/oc-lib/models/common/enum" "cloud.o-forge.io/core/oc-lib/models/peer" "cloud.o-forge.io/core/oc-lib/models/resources" "cloud.o-forge.io/core/oc-lib/models/resources/native_tools" w "cloud.o-forge.io/core/oc-lib/models/workflow" "cloud.o-forge.io/core/oc-lib/models/workflow/graph" "cloud.o-forge.io/core/oc-lib/models/workflow_execution" "cloud.o-forge.io/core/oc-lib/tools" "github.com/nwtgck/go-fakelish" "github.com/rs/zerolog" "gopkg.in/yaml.v3" ) // logger est le logger zerolog partagé au sein du package, initialisé à // chaque appel de CreateDAG pour récupérer la configuration courante. var logger zerolog.Logger // ArgoBuilder est le constructeur principal du fichier Argo Workflow. // Il porte l'état de la construction (workflow source, templates générés, // services k8s à créer, timeout global, liste des peers distants impliqués). type ArgoBuilder struct { // OriginWorkflow est le workflow métier Open Cloud dont on construit la représentation Argo. OriginWorkflow *w.Workflow // Workflow est la structure YAML Argo en cours de construction. Workflow Workflow // Services liste les services Kubernetes à exposer pour les processings "IsService". Services []*Service // Timeout est la durée maximale d'exécution en secondes (activeDeadlineSeconds). Timeout int // RemotePeers contient les IDs des peers distants détectés via Admiralty. RemotePeers []string // HasLocalCompute indique qu'au moins un processing s'exécute sur le kube local. // Le kube local doit recevoir son propre ArgoKubeEvent COMPUTE_RESOURCE. HasLocalCompute bool // PeerImages associe chaque peer aux images de conteneurs qu'il doit exécuter. // Clé "" désigne le peer local. Utilisé pour le pre-pull et le release post-exec. PeerImages map[string][]string } // Workflow est la structure racine du fichier YAML Argo Workflow. // Elle correspond exactement au format attendu par le contrôleur Argo. type Workflow struct { ApiVersion string `yaml:"apiVersion"` Kind string `yaml:"kind"` Metadata struct { Name string `yaml:"name"` } `yaml:"metadata"` Spec Spec `yaml:"spec,omitempty"` } // getDag retourne le pointeur sur le template "dag" du workflow. // S'il n'existe pas encore, il est créé et ajouté à la liste des templates. func (b *Workflow) getDag() *Dag { for _, t := range b.Spec.Templates { if t.Name == "dag" { return t.Dag } } b.Spec.Templates = append(b.Spec.Templates, Template{Name: "dag", Dag: &Dag{}}) return b.Spec.Templates[len(b.Spec.Templates)-1].Dag } // PodSecurityContext mirrors the subset of k8s PodSecurityContext used by Argo. type PodSecurityContext struct { RunAsUser *int64 `yaml:"runAsUser,omitempty"` RunAsGroup *int64 `yaml:"runAsGroup,omitempty"` FSGroup *int64 `yaml:"fsGroup,omitempty"` } // Spec contient la spécification complète du workflow Argo : // compte de service, point d'entrée, volumes, templates et timeout. type Spec struct { ArtifactRepositoryRef *ArtifactRepositoryRef `yaml:"artifactRepositoryRef,omitempty"` ServiceAccountName string `yaml:"serviceAccountName,omitempty"` Entrypoint string `yaml:"entrypoint"` Arguments []Parameter `yaml:"arguments,omitempty"` Volumes []VolumeClaimTemplate `yaml:"volumeClaimTemplates,omitempty"` ExistingVolumes []ExistingVolume `yaml:"volumes,omitempty"` Templates []Template `yaml:"templates"` Timeout int `yaml:"activeDeadlineSeconds,omitempty"` SecurityContext *PodSecurityContext `yaml:"securityContext,omitempty"` } // CreateDAG est le point d'entrée de la construction du DAG Argo. // Il crée tous les templates (un par processing / native tool / sous-workflow), // configure les volumes persistants, positionne les métadonnées globales du // workflow et retourne : // - le nombre de tâches dans le DAG, // - les noms des premières tâches (sans dépendances), // - les noms des dernières tâches (dont personne ne dépend), // - une éventuelle erreur. // // Le paramètre write est conservé pour usage futur (écriture effective du YAML). // TODO: gérer S3, GCS, Azure selon le type de stockage lié au processing. func (b *ArgoBuilder) CreateDAG(exec *workflow_execution.WorkflowExecution, namespace string, write bool) (int, []string, []string, error) { logger = logs.GetLogger() logger.Info().Msg(fmt.Sprint("Creating DAG ", b.OriginWorkflow.Graph.Items)) // Crée un template Argo pour chaque nœud du graphe et collecte les volumes. firstItems, lastItems, volumes, err := b.createTemplates(exec, namespace) if err != nil { return 0, firstItems, lastItems, err } b.createVolumes(exec, volumes) if b.Timeout > 0 { b.Workflow.Spec.Timeout = b.Timeout } b.Workflow.Spec.ServiceAccountName = "sa-" + namespace b.Workflow.Spec.Entrypoint = "dag" b.Workflow.ApiVersion = "argoproj.io/v1alpha1" b.Workflow.Kind = "Workflow" if !write { return len(b.Workflow.getDag().Tasks), firstItems, lastItems, nil } return len(b.Workflow.getDag().Tasks), firstItems, lastItems, nil } // createTemplates parcourt tous les nœuds du graphe (processings, native tools, // sous-workflows) et génère les templates Argo correspondants. // Elle gère également le recâblage des dépendances DAG entre sous-workflows // imbriqués, et l'ajout du pod de service si nécessaire. // Retourne les premières tâches, les dernières tâches et les volumes à créer. func (b *ArgoBuilder) createTemplates(exec *workflow_execution.WorkflowExecution, namespace string) ([]string, []string, []VolumeMount, error) { volumes := []VolumeMount{} firstItems := []string{} lastItems := []string{} // --- Processings --- for _, item := range b.OriginWorkflow.GetGraphItems(b.OriginWorkflow.Graph.IsProcessing) { index := 0 _, res := item.GetResource() if d, ok := exec.SelectedInstances[res.GetID()]; ok { index = d } instance := item.ItemResource.Processing.GetSelectedInstance(&index) logger.Info().Msg(fmt.Sprint("Creating template for", item.ItemResource.Processing.GetName(), instance)) procInst, _ := instance.(*resources.ProcessingInstance) if instance == nil || procInst == nil || procInst.Access == nil || (procInst.Access.Container == nil && !procInst.Access.HasSource()) { logger.Error().Msg("Not enough configuration setup, template can't be created : " + item.ItemResource.Processing.GetName()) return firstItems, lastItems, volumes, nil } // Un même processing peut être bookié sur plusieurs peers : on crée // un template Argo distinct par peer, déployés en parallèle. for _, pb := range getAllPeersForItem(exec, item.ID) { var err error volumes, firstItems, lastItems, err = b.createArgoTemplates(exec, namespace, item.ID, pb.PeerID, pb.BookingID, item.ItemResource.Processing, volumes, firstItems, lastItems) if err != nil { return firstItems, lastItems, volumes, err } } } // --- Service Resources --- // HOSTED : le creator_id identifie le peer propriétaire du compute à contacter ; // pas de lien avec un compute unit nécessaire. // DEPLOYMENT : le service doit être déployé sur un compute booké (comme un processing). for _, item := range b.OriginWorkflow.GetGraphItems(b.OriginWorkflow.Graph.IsService) { index := 0 _, res := item.GetResource() if d, ok := exec.SelectedInstances[res.GetID()]; ok { index = d } instance := item.ItemResource.Service.GetSelectedInstance(&index) logger.Info().Msg(fmt.Sprint("Creating template for service", item.ItemResource.Service.GetName(), instance)) if instance == nil { logger.Error().Msg("Not enough configuration setup, service template can't be created : " + item.ItemResource.Service.GetName()) continue } svcInst := instance.(*resources.ServiceInstance) if svcInst.Mode == resources.HOSTED { // HOSTED : le creator_id suffit à identifier le peer cible. peerID := item.ItemResource.Service.GetCreatorID() var err error volumes, firstItems, lastItems, err = b.createArgoTemplates(exec, namespace, item.ID, peerID, item.ID, item.ItemResource.Service, volumes, firstItems, lastItems) if err != nil { return firstItems, lastItems, volumes, err } } else { // DEPLOYMENT : un template par peer booké, comme les processings. for _, pb := range getAllPeersForItem(exec, item.ID) { var err error volumes, firstItems, lastItems, err = b.createArgoTemplates(exec, namespace, item.ID, pb.PeerID, pb.BookingID, item.ItemResource.Service, volumes, firstItems, lastItems) if err != nil { return firstItems, lastItems, volumes, err } } } } // --- Native Tools de type WORKFLOW_EVENT uniquement --- for _, item := range b.OriginWorkflow.GetGraphItems(b.OriginWorkflow.Graph.IsNativeTool) { if item.ItemResource.NativeTool.Kind != int(native_tools.WORKFLOW_EVENT) { continue } index := 0 _, res := item.GetResource() if d, ok := exec.SelectedInstances[res.GetID()]; ok { index = d } instance := item.ItemResource.NativeTool.GetSelectedInstance(&index) logger.Info().Msg(fmt.Sprint("Creating template for", item.ItemResource.NativeTool.GetName(), instance)) // Résolution du peer cible : distant si un compute directement connecté // est distant, local sinon (aucun compute ou compute local). peerID := b.getNativeToolPeer(item.ID) var err error volumes, firstItems, lastItems, err = b.createArgoTemplates(exec, namespace, item.ID, peerID, item.ID, item.ItemResource.NativeTool, volumes, firstItems, lastItems) if err != nil { return firstItems, lastItems, volumes, err } } // --- Sous-workflows : chargement, construction récursive et fusion du DAG --- firstWfTasks := map[string][]string{} latestWfTasks := map[string][]string{} relatedWfTasks := map[string][]string{} for _, wf := range b.OriginWorkflow.ResourceSet.Workflows { realWorkflow, code, err := w.NewAccessor(nil).LoadOne(wf) if code != 200 { logger.Error().Msg("Error loading the workflow : " + err.Error()) continue } subBuilder := ArgoBuilder{OriginWorkflow: realWorkflow.(*w.Workflow), Timeout: b.Timeout} _, fi, li, err := subBuilder.CreateDAG(exec, namespace, false) if err != nil { logger.Error().Msg("Error creating the subworkflow : " + err.Error()) continue } firstWfTasks[wf] = fi if ok, depsOfIds := subBuilder.isArgoDependancy(exec, wf); ok { // le sous-workflow est une dépendance d'autre chose latestWfTasks[wf] = li relatedWfTasks[wf] = depsOfIds } // Fusion des tâches, templates, volumes et arguments du sous-workflow dans le DAG principal. subDag := subBuilder.Workflow.getDag() d := b.Workflow.getDag() d.Tasks = append(d.Tasks, subDag.Tasks...) b.Workflow.Spec.Templates = append(b.Workflow.Spec.Templates, subBuilder.Workflow.Spec.Templates...) b.Workflow.Spec.Volumes = append(b.Workflow.Spec.Volumes, subBuilder.Workflow.Spec.Volumes...) b.Workflow.Spec.Arguments = append(b.Workflow.Spec.Arguments, subBuilder.Workflow.Spec.Arguments...) b.Services = append(b.Services, subBuilder.Services...) } // Recâblage : les tâches qui dépendaient du sous-workflow dépendent désormais // de sa dernière tâche réelle (latestWfTasks). for wfID, depsOfIds := range relatedWfTasks { for _, dep := range depsOfIds { for _, task := range b.Workflow.getDag().Tasks { if strings.Contains(task.Name, dep) { index := -1 for i, depp := range task.Dependencies { if strings.Contains(depp, wfID) { index = i break } } if index != -1 { task.Dependencies = append(task.Dependencies[:index], task.Dependencies[index+1:]...) } task.Dependencies = append(task.Dependencies, latestWfTasks[wfID]...) } } } } // Les premières tâches du sous-workflow héritent des dépendances // que le sous-workflow avait vis-à-vis du DAG principal. for wfID, fi := range firstWfTasks { deps := b.getArgoDependencies(exec, wfID) if len(deps) > 0 { for _, dep := range fi { for _, task := range b.Workflow.getDag().Tasks { if strings.Contains(task.Name, dep) { task.Dependencies = append(task.Dependencies, deps...) } } } } } // --- Sources Data (isReachable = true ET false) --- // Injecte les steps curl/wrapper pour les Data avec source, APRÈS que toutes les // steps processing ont été ajoutées au DAG (dépendances câblées). // Phase 3 (public) et Phase 4 (privé) sont gérées dans HandleDataSources. b.HandleDataSources(exec, namespace) // Si des services Kubernetes sont nécessaires, on ajoute le pod dédié. if b.Services != nil { dag := b.Workflow.getDag() dag.Tasks = append(dag.Tasks, Task{Name: "workflow-service-pod", Template: "workflow-service-pod"}) b.addServiceToArgo() } return firstItems, lastItems, volumes, nil } // createArgoTemplates crée le template Argo pour un nœud du graphe (processing // ou native tool) sur un peer donné. Il : // 1. Ajoute la tâche au DAG avec ses dépendances. // 2. Crée le template de container (ou d'événement pour les native tools). // 3. Ajoute les annotations Admiralty si peerID désigne un peer distant. // 4. Crée un service Kubernetes si le processing est déclaré IsService. // 5. Configure les annotations de stockage (S3, volumes locaux). func (b *ArgoBuilder) createArgoTemplates( exec *workflow_execution.WorkflowExecution, namespace string, graphID string, peerID string, bookingID string, obj resources.ResourceInterface, volumes []VolumeMount, firstItems []string, lastItems []string, ) ([]VolumeMount, []string, []string, error) { _, firstItems, lastItems = b.addTaskToArgo(exec, b.Workflow.getDag(), graphID, bookingID, obj, firstItems, lastItems) template := &Template{Name: getArgoName(obj.GetName(), bookingID)} logger.Info().Msg(fmt.Sprint("Creating template for", template.Name)) // Résoudre le peer en amont pour que le NativeTool puisse choisir l'URL NATS cible. isReparted, remotePeer := b.isPeerReparted(peerID) if obj.GetType() == tools.PROCESSING_RESOURCE.String() { proc := obj.(*resources.ProcessingResource) index := 0 if d, ok := exec.SelectedInstances[proc.GetID()]; ok { index = d } if procInst, ok := proc.GetSelectedInstance(&index).(*resources.ProcessingInstance); ok && procInst.Access.HasSource() { argoStepName := getArgoName(proc.GetName(), bookingID) if procInst.Access.Source.IsReachable { // Phase 3 — source publique : injecter une step curl directe. if err := b.handleProcessingSource(exec, graphID, proc, procInst, argoStepName, template); err != nil { logger.Error().Msg("[source-fetch] " + err.Error()) return volumes, firstItems, lastItems, err } } else { // Phase 4 — source privée : NATS + URL pré-signée + Secret K8s. if err := b.handlePrivateProcessingSource(exec, graphID, proc, procInst, argoStepName, namespace); err != nil { logger.Error().Msg("[source-private] " + err.Error()) return volumes, firstItems, lastItems, err } } } else { template.CreateContainer(exec, b.OriginWorkflow, graphID, proc, b.Workflow.getDag()) } } else if obj.GetType() == tools.NATIVE_TOOL.String() { // Pour le cas local, on utilise le FQDN cross-namespace car le pod tourne // dans le namespace executions_id, pas dans OCNamespace (opencloud). natsURL := conf.GetConfig().NATSPodURL() if isReparted && remotePeer != nil && remotePeer.NATSAddress != "" { natsURL = remotePeer.NATSAddress } template.CreateEventContainer(exec, graphID, b.OriginWorkflow, obj.(*resources.NativeTool), b.Workflow.getDag(), natsURL) } else if obj.GetType() == tools.SERVICE_RESOURCE.String() { svc := obj.(*resources.ServiceResource) template.CreateServiceContainer(exec, b.OriginWorkflow, graphID, svc, b.Workflow.getDag()) // Le k8s Service (NodePort/LoadBalancer) et le label "app" ne sont nécessaires // que pour DEPLOYMENT : le service est déployé et doit être exposé. // Pour HOSTED, le service tourne déjà chez son créateur, aucune exposition locale. svcIndex := 0 if d, ok := exec.SelectedInstances[svc.GetID()]; ok { svcIndex = d } if inst, ok := svc.GetSelectedInstance(&svcIndex).(*resources.ServiceInstance); ok && inst.Mode == resources.DEPLOYMENT { b.CreateService(exec, graphID, obj) template.Metadata.Labels = make(map[string]string) template.Metadata.Labels["app"] = "oc-service-" + obj.GetName() } } // Enregistre l'image pour le pre-pull sur le peer cible. // peerID == "" désigne le peer local (clé "" dans PeerImages). b.addPeerImage(peerID, template.Container.Image) if isReparted { logger.Debug().Msg("Reparted processing, on " + remotePeer.GetID()) b.RemotePeers = append(b.RemotePeers, remotePeer.GetID()) template.AddAdmiraltyAnnotations(remotePeer.GetID()) } else { // Processing local : le kube local doit aussi être configuré. b.HasLocalCompute = true } var err error volumes, err = b.addStorageAnnotations(exec, graphID, template, namespace, volumes, isReparted) if err != nil { return volumes, firstItems, lastItems, err } b.Workflow.Spec.Templates = append(b.Workflow.Spec.Templates, *template) return volumes, firstItems, lastItems, nil } // addStorageAnnotations parcourt tous les nœuds de stockage liés au processing // identifié par id. Pour chaque lien de stockage : // - Construit le nom de l'artefact Argo (lecture ou écriture). // - Pour les stockages S3 : appelle waitForConsiders (STORAGE_RESOURCE) pour // attendre la validation PB_CONSIDERS avant de configurer les annotations S3. // - Pour les volumes locaux : ajoute un VolumeMount dans le container. // Si isReparted est true (step Admiralty), le volume local est marqué comme // réparti afin que createVolumes ne génère pas de PVC local-path incompatible // avec les virtual kubelets. func (b *ArgoBuilder) addStorageAnnotations(exec *workflow_execution.WorkflowExecution, id string, template *Template, namespace string, volumes []VolumeMount, isReparted bool) ([]VolumeMount, error) { // Récupère tous les nœuds de stockage connectés au processing courant. related := b.OriginWorkflow.GetByRelatedProcessing(id, b.OriginWorkflow.Graph.IsStorage) for _, r := range related { n := r.Node storage := n.(*resources.StorageResource) for _, linkToStorage := range r.Links { //nolint:govet for _, rw := range linkToStorage.StorageLinkInfos { var art Artifact // Le nom de l'artefact doit être alphanumérique + '-' ou '_'. artifactBaseName := strings.Join(strings.Split(storage.GetName(), " "), "-") + "-" + strings.Replace(rw.FileName, ".", "-", -1) envs := []Parameter{} for _, p := range linkToStorage.Env { envs = append(envs, Parameter{Name: p.Name}) } if rw.Write { // Écriture vers S3 : Path = chemin du fichier dans le pod. art = Artifact{Path: template.ReplacePerEnv(rw.Source, envs)} art.Name = artifactBaseName + "-input-write" } else { // Lecture depuis S3 : Path = destination dans le pod. art = Artifact{Path: template.ReplacePerEnv(rw.Destination+"/"+rw.FileName, envs)} art.Name = artifactBaseName + "-input-read" } if storage.StorageType == enum.S3 { // Pour chaque ressource de compute liée à ce stockage S3, // on notifie via NATS et on attend la validation PB_CONSIDERS // avec DataType = STORAGE_RESOURCE avant de continuer. // Les goroutines tournent en parallèle ; un timeout sur l'une // d'elles est une erreur fatale qui stoppe la suite du build. relatedProcessing := b.getStorageRelatedProcessing(storage.GetID()) var wg sync.WaitGroup errCh := make(chan error, len(relatedProcessing)) for _, r := range relatedProcessing { wg.Add(1) go waitForConsiders(exec.ExecutionsID, tools.STORAGE_RESOURCE, ArgoKubeEvent{ ExecutionsID: exec.ExecutionsID, DestPeerID: r.GetID(), Type: tools.STORAGE_RESOURCE, SourcePeerID: storage.GetCreatorID(), OriginID: conf.GetConfig().PeerID, }, &wg, errCh) } wg.Wait() close(errCh) for err := range errCh { if err != nil { return volumes, err } } // Configure la référence au dépôt d'artefacts S3 dans le Spec. b.addS3annotations(storage, namespace) } if rw.Write { template.Outputs.Artifacts = append(template.Outputs.Artifacts, art) } else { template.Inputs.Artifacts = append(template.Inputs.Artifacts, art) } } } // Si l'instance de stockage est locale, on pré-provisionne le PVC via // oc-datacenter (même pattern que MinIO) puis on monte un volume existant. index := 0 if s, ok := exec.SelectedInstances[storage.GetID()]; ok { index = s } s := storage.Instances[index] if s.Local { var pvcWg sync.WaitGroup pvcErrCh := make(chan error, 1) pvcWg.Add(1) go waitForConsiders(exec.ExecutionsID, tools.STORAGE_RESOURCE, ArgoKubeEvent{ ExecutionsID: exec.ExecutionsID, Type: tools.STORAGE_RESOURCE, SourcePeerID: conf.GetConfig().PeerID, DestPeerID: conf.GetConfig().PeerID, OriginID: conf.GetConfig().PeerID, MinioID: storage.GetID(), Local: true, StorageName: storage.GetName(), }, &pvcWg, pvcErrCh) pvcWg.Wait() close(pvcErrCh) for err := range pvcErrCh { if err != nil { return volumes, err } } volumes = template.Container.AddVolumeMount(VolumeMount{ Name: strings.ReplaceAll(strings.ToLower(storage.GetName()), " ", "-"), MountPath: s.Source, Storage: storage, IsReparted: isReparted, }, volumes) } } // Embedded storages: scan links for compute nodes connected to this processing. // Key in SelectedEmbeddedStorages is the graph item ID (not resource ID), so we // iterate links directly to preserve the graph position identity. for _, link := range b.OriginWorkflow.Graph.Links { var computeGraphID string if link.Source.ID == id && b.OriginWorkflow.Graph.IsCompute(b.OriginWorkflow.Graph.Items[link.Destination.ID]) { computeGraphID = link.Destination.ID } else if link.Destination.ID == id && b.OriginWorkflow.Graph.IsCompute(b.OriginWorkflow.Graph.Items[link.Source.ID]) { computeGraphID = link.Source.ID } if computeGraphID == "" { continue } sel, ok := exec.SelectedEmbeddedStorages[computeGraphID] if !ok || sel == nil { continue } c := b.OriginWorkflow.Graph.Items[computeGraphID] _, computeRes := (&c).GetResource() computeResource := computeRes.(*resources.ComputeResource) computeIdx := 0 if d, ok := exec.SelectedInstances[computeResource.GetID()]; ok { computeIdx = d } if computeIdx >= len(computeResource.Instances) { continue } computeInst := computeResource.Instances[computeIdx] if sel.StorageIndex >= len(computeInst.AvailableStorages) { continue } storage := computeInst.AvailableStorages[sel.StorageIndex] if storage.StorageType == enum.S3 { relatedProcessing := b.getStorageRelatedProcessing(storage.GetID()) var wg sync.WaitGroup errCh := make(chan error, len(relatedProcessing)) for _, rp := range relatedProcessing { wg.Add(1) go waitForConsiders(exec.ExecutionsID, tools.STORAGE_RESOURCE, ArgoKubeEvent{ ExecutionsID: exec.ExecutionsID, DestPeerID: rp.GetID(), Type: tools.STORAGE_RESOURCE, SourcePeerID: storage.GetCreatorID(), OriginID: conf.GetConfig().PeerID, }, &wg, errCh) } wg.Wait() close(errCh) for err := range errCh { if err != nil { return volumes, err } } b.addS3annotations(storage, namespace) } else { // Local volume / Minio: provision PVC via oc-datacenter then mount it. var pvcWg sync.WaitGroup pvcErrCh := make(chan error, 1) pvcWg.Add(1) go waitForConsiders(exec.ExecutionsID, tools.STORAGE_RESOURCE, ArgoKubeEvent{ ExecutionsID: exec.ExecutionsID, Type: tools.STORAGE_RESOURCE, SourcePeerID: conf.GetConfig().PeerID, DestPeerID: conf.GetConfig().PeerID, OriginID: conf.GetConfig().PeerID, MinioID: storage.GetID(), Local: true, StorageName: storage.GetName(), }, &pvcWg, pvcErrCh) pvcWg.Wait() close(pvcErrCh) for err := range pvcErrCh { if err != nil { return volumes, err } } // Use the first instance's source as mount path if available. mountPath := "" if len(storage.Instances) > 0 { mountPath = storage.Instances[0].Source } volumes = template.Container.AddVolumeMount(VolumeMount{ Name: strings.ReplaceAll(strings.ToLower(storage.GetName()), " ", "-"), MountPath: mountPath, Storage: storage, IsReparted: isReparted, }, volumes) } } return volumes, nil } // getStorageRelatedProcessing retourne la liste des ressources de compute // connectées (via un processing intermédiaire) au stockage identifié par storageId. // Ces ressources sont utilisées pour construire les ArgoKubeEvent destinés // à la validation NATS. func (b *ArgoBuilder) getStorageRelatedProcessing(storageId string) (res []resources.ResourceInterface) { var storageLinks []graph.GraphLink // On ne conserve que les liens impliquant ce stockage. for _, link := range b.OriginWorkflow.Graph.Links { if link.Destination.ID == storageId || link.Source.ID == storageId { storageLinks = append(storageLinks, link) } } for _, link := range storageLinks { var resourceId string // L'opposé du lien est soit la source soit la destination selon la direction. if link.Source.ID != storageId { resourceId = link.Source.ID } else { resourceId = link.Destination.ID } // Si l'opposé est un processing, on récupère ses ressources de compute. if b.OriginWorkflow.Graph.IsProcessing(b.OriginWorkflow.Graph.Items[resourceId]) { res = append(res, b.getComputeProcessing(resourceId)...) } } return } // getComputeProcessing retourne toutes les ressources de compute attachées // au processing identifié par processingId dans le graphe du workflow. func (b *ArgoBuilder) getComputeProcessing(processingId string) (res []resources.ResourceInterface) { arr := []resources.ResourceInterface{} computeRel := b.OriginWorkflow.GetByRelatedProcessing(processingId, b.OriginWorkflow.Graph.IsCompute) for _, rel := range computeRel { arr = append(arr, rel.Node) } return arr } // addS3annotations configure la référence au dépôt d'artefacts S3 dans le Spec // du workflow Argo. La ConfigMap et la clé sont dérivées de l'ID du stockage. // Le namespace est conservé en signature pour une évolution future. func (b *ArgoBuilder) addS3annotations(storage *resources.StorageResource, namespace string) { b.Workflow.Spec.ArtifactRepositoryRef = &ArtifactRepositoryRef{ ConfigMap: storage.GetID() + "-artifact-repository", Key: storage.GetID() + "-s3-local", } } func (b *ArgoBuilder) getRealVar(exec *workflow_execution.WorkflowExecution, val string, processing resources.ResourceInterface) string { if strings.Contains(val, "[resource]instance.") { attr := strings.ReplaceAll(val, "[resource]instance.", "") index := 0 if d, ok := exec.SelectedInstances[processing.GetID()]; ok { index = d } instance := processing.GetSelectedInstance(&index) ser := instance.Serialize(instance) return fmt.Sprintf("%v", ser[attr]) } return val } // addTaskToArgo ajoute une tâche au DAG Argo pour le nœud graphItemID. // Elle résout les dépendances DAG, propage les paramètres d'environnement, // d'entrée et de sortie de l'instance sélectionnée, et met à jour les listes // firstItems / lastItems utilisées pour le recâblage des sous-workflows. // bookingID est le nom unique de cette instance (peut varier par peer). func (b *ArgoBuilder) addTaskToArgo(exec *workflow_execution.WorkflowExecution, dag *Dag, graphItemID string, bookingID string, processing resources.ResourceInterface, firstItems []string, lastItems []string) (*Dag, []string, []string) { unique_name := getArgoName(processing.GetName(), bookingID) step := Task{Name: unique_name, Template: unique_name} // Propagation des variables d'environnement, entrées et sorties // de l'instance vers les paramètres de la tâche Argo. // AppendParamIfAbsent évite les doublons quand une variable est définie // à la fois sur le ProcessingResource et sur le Workflow (override). for _, value := range processing.GetEnv() { step.Arguments.Parameters = AppendParamIfAbsent(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: b.getRealVar(exec, value.Value, processing), }) } for _, value := range b.OriginWorkflow.Env[graphItemID] { step.Arguments.Parameters = AppendParamIfAbsent(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: b.getRealVar(exec, value.Value, processing), }) } for _, value := range processing.GetInputs() { step.Arguments.Parameters = AppendParamIfAbsent(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: b.getRealVar(exec, value.Value, processing), }) } for _, value := range b.OriginWorkflow.Inputs[graphItemID] { step.Arguments.Parameters = AppendParamIfAbsent(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: b.getRealVar(exec, value.Value, processing), }) } for _, value := range processing.GetOutputs() { step.Arguments.Parameters = AppendParamIfAbsent(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: b.getRealVar(exec, value.Value, processing), }) } for _, value := range b.OriginWorkflow.Outputs[graphItemID] { step.Arguments.Parameters = AppendParamIfAbsent(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: b.getRealVar(exec, value.Value, processing), }) } // Résolution récursive des références $VAR_NAME entre paramètres. // Les needles sont triées par longueur décroissante pour éviter que $FOO // matche à l'intérieur de $FOOBAR (le plus long est substitué en premier). // On itère jusqu'au point fixe pour gérer les dépendances transitives // (A=$B, B=$C → après deux passes A=valeur de C). sortedParams := make([]Parameter, len(step.Arguments.Parameters)) copy(sortedParams, step.Arguments.Parameters) sort.Slice(sortedParams, func(i, j int) bool { return len(sortedParams[i].Name) > len(sortedParams[j].Name) }) for { changed := false for i := range step.Arguments.Parameters { for _, needle_param := range sortedParams { if step.Arguments.Parameters[i].Name == needle_param.Name { continue } needle := "$" + needle_param.Name if strings.Contains(step.Arguments.Parameters[i].Value, needle) { step.Arguments.Parameters[i].Value = strings.ReplaceAll( step.Arguments.Parameters[i].Value, needle, needle_param.Value, ) changed = true } } } if !changed { break } } step.Dependencies = b.getArgoDependencies(exec, graphItemID) // Détermine si ce nœud est une première ou une dernière tâche du DAG. name := "" if b.OriginWorkflow.Graph.Items[graphItemID].ItemResource.Processing != nil { name = b.OriginWorkflow.Graph.Items[graphItemID].ItemResource.Processing.GetName() } if b.OriginWorkflow.Graph.Items[graphItemID].ItemResource.Workflow != nil { name = b.OriginWorkflow.Graph.Items[graphItemID].ItemResource.Workflow.GetName() } if b.OriginWorkflow.Graph.Items[graphItemID].ItemResource.Service != nil { name = b.OriginWorkflow.Graph.Items[graphItemID].ItemResource.Service.GetName() } if len(step.Dependencies) == 0 && name != "" { firstItems = append(firstItems, getArgoName(name, bookingID)) } if ok, _ := b.isArgoDependancy(exec, graphItemID); !ok && name != "" { lastItems = append(lastItems, getArgoName(name, bookingID)) } dag.Tasks = append(dag.Tasks, step) return dag, firstItems, lastItems } // createVolumes référence les PVCs pré-provisionnés par oc-datacenter comme // volumes existants (ExistingVolumes) dans le Spec Argo. // Le nom du PVC est calculé de manière déterministe : -, // identique à ClaimName() dans oc-datacenter/infrastructure/storage/pvc_setter.go. func (b *ArgoBuilder) createVolumes(exec *workflow_execution.WorkflowExecution, volumes []VolumeMount) { seen := make(map[string]struct{}) for _, volume := range volumes { name := strings.ReplaceAll(strings.ToLower(volume.Name), " ", "-") if _, ok := seen[name]; ok { continue } seen[name] = struct{}{} claimName := name + "-" + exec.ExecutionsID ev := ExistingVolume{ Name: name, PersistentVolumeClaim: &PVCRef{ClaimName: claimName}, } b.Workflow.Spec.ExistingVolumes = append(b.Workflow.Spec.ExistingVolumes, ev) } // hostPath PVs are created as root:root 0755. Ensure pods can read/write // by running as root when local volumes are present. if len(b.Workflow.Spec.ExistingVolumes) > 0 && b.Workflow.Spec.SecurityContext == nil { zero := int64(0) b.Workflow.Spec.SecurityContext = &PodSecurityContext{ RunAsUser: &zero, RunAsGroup: &zero, FSGroup: &zero, } } } // isArgoDependancy vérifie si le nœud identifié par id est une dépendance // d'au moins un autre nœud du DAG (i.e. s'il existe un lien sortant vers // un processing ou un workflow). // Retourne true + la liste des noms Argo des nœuds qui en dépendent. func (b *ArgoBuilder) isArgoDependancy(exec *workflow_execution.WorkflowExecution, id string) (bool, []string) { dependancyOfIDs := []string{} isDeps := false for _, link := range b.OriginWorkflow.Graph.Links { if _, ok := b.OriginWorkflow.Graph.Items[link.Destination.ID]; !ok { logger.Info().Msg(fmt.Sprint("Could not find the source of the link", link.Destination.ID)) continue } source := b.OriginWorkflow.Graph.Items[link.Destination.ID].ItemResource.Processing if id == link.Source.ID && source != nil { isDeps = true for _, pb := range getAllPeersForItem(exec, link.Destination.ID) { dependancyOfIDs = append(dependancyOfIDs, getArgoName(source.GetName(), pb.BookingID)) } } wourceWF := b.OriginWorkflow.Graph.Items[link.Destination.ID].ItemResource.Workflow if id == link.Source.ID && wourceWF != nil { isDeps = true for _, pb := range getAllPeersForItem(exec, link.Destination.ID) { dependancyOfIDs = append(dependancyOfIDs, getArgoName(wourceWF.GetName(), pb.BookingID)) } } sourceSvc := b.OriginWorkflow.Graph.Items[link.Destination.ID].ItemResource.Service if id == link.Source.ID && sourceSvc != nil { isDeps = true for _, pb := range getAllPeersForItem(exec, link.Destination.ID) { dependancyOfIDs = append(dependancyOfIDs, getArgoName(sourceSvc.GetName(), pb.BookingID)) } } } return isDeps, dependancyOfIDs } // getArgoDependencies retourne la liste des noms de tâches Argo dont dépend // le nœud identifié par id (liens entrants depuis des processings). // Si le processing source est bookié sur N peers, toutes ses instances sont // retournées comme dépendances (la tâche courante attend toutes les instances). func (b *ArgoBuilder) getArgoDependencies(exec *workflow_execution.WorkflowExecution, id string) (dependencies []string) { for _, link := range b.OriginWorkflow.Graph.Links { if _, ok := b.OriginWorkflow.Graph.Items[link.Source.ID]; !ok { logger.Info().Msg(fmt.Sprint("Could not find the source of the link", link.Source.ID)) continue } source := b.OriginWorkflow.Graph.Items[link.Source.ID].ItemResource.Processing if id == link.Destination.ID && source != nil { for _, pb := range getAllPeersForItem(exec, link.Source.ID) { dependencies = append(dependencies, getArgoName(source.GetName(), pb.BookingID)) } } sourceSvc := b.OriginWorkflow.Graph.Items[link.Source.ID].ItemResource.Service if id == link.Destination.ID && sourceSvc != nil { for _, pb := range getAllPeersForItem(exec, link.Source.ID) { dependencies = append(dependencies, getArgoName(sourceSvc.GetName(), pb.BookingID)) } } } return } // getArgoName construit le nom unique d'une tâche / template Argo à partir // du nom humain de la ressource et de son ID dans le graphe. // Les espaces sont remplacés par des tirets et tout est mis en minuscules. func getArgoName(raw_name string, component_id string) (formatedName string) { formatedName = strings.ReplaceAll(raw_name, " ", "-") formatedName += "-" + component_id formatedName = strings.ToLower(formatedName) return } // peerBooking associe un peerID à son bookingID pour un item du graphe. type peerBooking struct { PeerID string BookingID string } // getAllPeersForItem retourne tous les (peerID, bookingID) enregistrés dans // PeerBookByGraph pour un item donné. Si aucun booking n'est trouvé (item // non encore planifié ou sous-workflow), retourne une entrée locale de // fallback avec BookingID = graphItemID. func getAllPeersForItem(exec *workflow_execution.WorkflowExecution, graphItemID string) []peerBooking { var result []peerBooking for peerID, byGraph := range exec.PeerBookByGraph { if bookings, ok := byGraph[graphItemID]; ok && len(bookings) > 0 { result = append(result, peerBooking{PeerID: peerID, BookingID: bookings[0]}) } } if len(result) == 0 { result = []peerBooking{{PeerID: "", BookingID: graphItemID}} } return result } // getNativeToolPeer résout le peer cible d'un NativeTool WORKFLOW_EVENT. // Règle : si un compute est directement connecté au NativeTool dans le graphe // et que ce compute appartient à un peer distant, on retourne ce peerID. // Dans tous les autres cas (aucun compute connecté, ou compute local), on retourne "". func (b *ArgoBuilder) getNativeToolPeer(graphItemID string) string { computeRel := b.OriginWorkflow.GetByRelatedProcessing(graphItemID, b.OriginWorkflow.Graph.IsCompute) for _, rel := range computeRel { peerID := rel.Node.GetCreatorID() if peerID == "" { continue } if isReparted, _ := b.isPeerReparted(peerID); isReparted { return peerID } } return "" } // isPeerReparted vérifie si le peerID désigne un peer distant (Relation != 1). // Un peerID vide signifie exécution locale : retourne false sans appel réseau. func (b *ArgoBuilder) isPeerReparted(peerID string) (bool, *peer.Peer) { if peerID == "" { return false, nil } req := oclib.NewRequest(oclib.LibDataEnum(oclib.PEER), "", "", nil, nil) if req == nil { fmt.Println("TODO : handle error when trying to create a request on the Peer Collection") return false, nil } res := req.LoadOne(peerID) if res.Err != "" { fmt.Print("TODO : handle error when requesting PeerID: " + res.Err) return false, nil } p := res.ToPeer() // Relation == 1 signifie "moi-même" : le processing est local. isNotReparted := p.Relation == 1 logger.Info().Msg(fmt.Sprint("Result IsMySelf for ", p.UUID, " : ", isNotReparted)) return !isNotReparted, p } // waitForConsiders publie un ArgoKubeEvent sur NATS puis attend la confirmation // PB_CONSIDERS via le cache global (globalConsidersCache), sans ouvrir de // connexion NATS supplémentaire. Le listener centralisé (StartConsidersListener) // dispatche le message vers le bon canal. // Un timeout de 5 minutes est appliqué pour éviter un blocage indéfini. func waitForConsiders(executionsId string, dataType tools.DataType, event ArgoKubeEvent, wg *sync.WaitGroup, errCh chan<- error) { defer wg.Done() // Sérialise l'événement et le publie sur ARGO_KUBE_EVENT. b, err := json.Marshal(event) if err != nil { logger.Error().Msg("Cannot marshal ArgoKubeEvent: " + err.Error()) errCh <- err return } tools.NewNATSCaller().SetNATSPub(tools.ARGO_KUBE_EVENT, tools.NATSResponse{ FromApp: "oc-monitord", Datatype: dataType, User: "root", Method: int(tools.ARGO_KUBE_EVENT), Payload: b, }) // Enregistrement dans le cache et attente de la confirmation. // Pour COMPUTE_RESOURCE, SourcePeerID différencie le peer compute (local ou distant). // Pour STORAGE_RESOURCE, SourcePeerID est le peer hébergeant le stockage. key := considersKey(executionsId, dataType, event.SourcePeerID) ch, unregister := globalConsidersCache.register(key) defer unregister() select { case <-ch: logger.Info().Msg(fmt.Sprintf("PB_CONSIDERS received for executions_id=%s datatype=%s source_peer=%s dest_peer=%s", executionsId, dataType.String(), event.SourcePeerID, event.DestPeerID)) errCh <- nil case <-time.After(5 * time.Minute): err := fmt.Errorf("timeout waiting for PB_CONSIDERS executions_id=%s datatype=%s", executionsId, dataType.String()) logger.Error().Msg(err.Error()) errCh <- err } } // ArgoKubeEvent est la structure publiée sur NATS lors de la demande de // provisionnement d'une ressource distante (Admiralty, stockage S3, ou source privée). // Le champ OriginID identifie le peer initiateur : c'est vers lui que la // réponse PB_CONSIDERS sera routée par le système de propagation. type ArgoKubeEvent struct { // ExecutionsID est l'identifiant de l'exécution de workflow en cours. ExecutionsID string `json:"executions_id"` // DestPeerID est le peer de destination (compute ou peer S3 cible). DestPeerID string `json:"dest_peer_id"` // Type indique la nature de la ressource : COMPUTE_RESOURCE, STORAGE_RESOURCE // ou PROCESSING_RESOURCE (source privée Phase 4). Type tools.DataType `json:"data_type"` // SourcePeerID est le peer source de la ressource demandée. SourcePeerID string `json:"source_peer_id"` // OriginID est le peer qui a initié la demande de provisionnement ; // la réponse PB_CONSIDERS lui sera renvoyée. OriginID string `json:"origin_id"` // MinioID est l'ID de la ressource storage (Minio ou local PVC). MinioID string `json:"minio_id,omitempty"` // Local signale un storage Local=true (PVC pré-provisionné par oc-datacenter). Local bool `json:"local,omitempty"` // StorageName est le nom normalisé du storage, utilisé pour calculer le claimName. StorageName string `json:"storage_name,omitempty"` // Images est la liste des images de conteneurs à pre-pull sur le peer cible // avant le démarrage du workflow. Vide pour les events STORAGE_RESOURCE / PROCESSING_RESOURCE. Images []string `json:"images,omitempty"` // SourceResourceID est l'ID de la ressource Processing/Data dont on demande // une URL pré-signée (Phase 4, isReachable=false uniquement). SourceResourceID string `json:"source_resource_id,omitempty"` } // addPeerImage enregistre une image à pre-pull pour un peer donné. // Clé "" désigne le peer local. Les doublons sont ignorés. func (b *ArgoBuilder) addPeerImage(peerID, image string) { if image == "" { return } if b.PeerImages == nil { b.PeerImages = make(map[string][]string) } for _, existing := range b.PeerImages[peerID] { if existing == image { return } } b.PeerImages[peerID] = append(b.PeerImages[peerID], image) } // CompleteBuild finalise la construction du workflow Argo après la génération // du DAG. Elle effectue dans l'ordre : // 1. Pour chaque peer distant (Admiralty) : publie un ArgoKubeEvent de type // COMPUTE_RESOURCE et attend la validation PB_CONSIDERS via waitForConsiders. // 2. Met à jour les annotations Admiralty des templates avec le nom de cluster // construit à partir du peerId et de l'executionsId. // 3. Sérialise le workflow en YAML et l'écrit dans ./argo_workflows/. // // Retourne le chemin du fichier YAML généré. func (b *ArgoBuilder) CompleteBuild(executionsId string) (string, error) { logger.Info().Msg("DEV :: Completing build") // --- Étape 1 : validation kube pour tous les peers (local + distants) --- // Les goroutines tournent en parallèle ; un timeout est une erreur fatale. // Déduplique RemotePeers : plusieurs processings peuvent pointer vers le même // peer distant, on ne doit envoyer qu'un seul ArgoKubeEvent par peer. seen := make(map[string]struct{}) uniqueRemotePeers := b.RemotePeers[:0] for _, p := range b.RemotePeers { if _, ok := seen[p]; !ok { seen[p] = struct{}{} uniqueRemotePeers = append(uniqueRemotePeers, p) } } b.RemotePeers = uniqueRemotePeers total := len(b.RemotePeers) if b.HasLocalCompute { total++ } var wg sync.WaitGroup errCh := make(chan error, total) // Le kube local doit aussi être configuré s'il porte au moins un processing. if b.HasLocalCompute { if localPeer, err := oclib.GetMySelf(); err == nil { logger.Info().Msg("DEV :: Launching local kube setup for " + localPeer.GetID()) wg.Add(1) go waitForConsiders(executionsId, tools.COMPUTE_RESOURCE, ArgoKubeEvent{ ExecutionsID: executionsId, Type: tools.COMPUTE_RESOURCE, DestPeerID: localPeer.GetID(), SourcePeerID: localPeer.GetID(), OriginID: localPeer.GetID(), Images: b.PeerImages[""], // images à pre-pull sur le cluster local }, &wg, errCh) } } // Peers distants via Admiralty. for _, peer := range b.RemotePeers { logger.Info().Msg(fmt.Sprint("DEV :: Launching Admiralty Setup for ", peer)) if self, err := oclib.GetMySelf(); err == nil { wg.Add(1) go waitForConsiders(executionsId, tools.COMPUTE_RESOURCE, ArgoKubeEvent{ ExecutionsID: executionsId, Type: tools.COMPUTE_RESOURCE, DestPeerID: self.GetID(), SourcePeerID: peer, OriginID: self.GetID(), Images: b.PeerImages[peer], // images à pre-pull sur le cluster distant (via kubeconfig Admiralty) }, &wg, errCh) } } wg.Wait() close(errCh) for err := range errCh { if err != nil { return "", err } } // --- Étape 2 : génération et écriture du fichier YAML --- random_name := fakelish.GenerateFakeWord(5, 8) + "-" + fakelish.GenerateFakeWord(5, 8) b.Workflow.Metadata.Name = "oc-monitor-" + random_name logger = oclib.GetLogger() yamlified, err := yaml.Marshal(b.Workflow) if err != nil { logger.Error().Msg("Could not transform object to yaml file") return "", err } // Nom de fichier horodaté au format DD_MM_YYYY_hhmmss. current_timestamp := time.Now().UTC().Format("02_01_2006_150405") file_name := random_name + "_" + current_timestamp + ".yml" workflows_dir := "./argo_workflows/" err = os.WriteFile(workflows_dir+file_name, []byte(yamlified), 0660) if err != nil { logger.Error().Msg("Could not write the yaml file") return "", err } return workflows_dir + file_name, nil }