// 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" "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 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.Processing.GetSelectedInstance(&index) logger.Info().Msg(fmt.Sprint("Creating template for", item.Processing.GetName(), instance)) if instance == nil || instance.(*resources.ProcessingInstance).Access == nil && instance.(*resources.ProcessingInstance).Access.Container != nil { logger.Error().Msg("Not enough configuration setup, template can't be created : " + item.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.Processing, 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.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.NativeTool.GetSelectedInstance(&index) logger.Info().Msg(fmt.Sprint("Creating template for", item.NativeTool.GetName(), instance)) var err error volumes, firstItems, lastItems, err = b.createArgoTemplates(exec, namespace, item.ID, "", item.ID, item.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.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...) } } } } } // 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)) if obj.GetType() == tools.PROCESSING_RESOURCE.String() { template.CreateContainer(exec, obj.(*resources.ProcessingResource), b.Workflow.getDag()) } else if obj.GetType() == tools.NATIVE_TOOL.String() { template.CreateEventContainer(exec, obj.(*resources.NativeTool), b.Workflow.getDag()) } // 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) // Vérifie si le peer est distant (Admiralty). isReparted, remotePeer := b.isPeerReparted(peerID) 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 } // Si le processing expose un service Kubernetes, on l'enregistre et on // applique le label "app" pour que le Service puisse le sélectionner. if obj.GetType() == tools.PROCESSING_RESOURCE.String() && obj.(*resources.ProcessingResource).IsService { b.CreateService(exec, graphID, obj) template.Metadata.Labels = make(map[string]string) template.Metadata.Labels["app"] = "oc-service-" + obj.GetName() } 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 { storage := r.Node.(*resources.StorageResource) for _, linkToStorage := range r.Links { 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) if rw.Write { // Écriture vers S3 : Path = chemin du fichier dans le pod. art = Artifact{Path: template.ReplacePerEnv(rw.Source, linkToStorage.Env)} art.Name = artifactBaseName + "-input-write" } else { // Lecture depuis S3 : Path = destination dans le pod. art = Artifact{Path: template.ReplacePerEnv(rw.Destination+"/"+rw.FileName, linkToStorage.Env)} 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) } } 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", } } // 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} index := 0 if d, ok := exec.SelectedInstances[processing.GetID()]; ok { index = d } instance := processing.GetSelectedInstance(&index) if instance != nil { // Propagation des variables d'environnement, entrées et sorties // de l'instance vers les paramètres de la tâche Argo. for _, value := range instance.(*resources.ProcessingInstance).Env { step.Arguments.Parameters = append(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: value.Value, }) } for _, value := range instance.(*resources.ProcessingInstance).Inputs { step.Arguments.Parameters = append(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: value.Value, }) } for _, value := range instance.(*resources.ProcessingInstance).Outputs { step.Arguments.Parameters = append(step.Arguments.Parameters, Parameter{ Name: value.Name, Value: value.Value, }) } } 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].Processing != nil { name = b.OriginWorkflow.Graph.Items[graphItemID].Processing.GetName() } if b.OriginWorkflow.Graph.Items[graphItemID].Workflow != nil { name = b.OriginWorkflow.Graph.Items[graphItemID].Workflow.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{} ev.Name = name ev.PersistentVolumeClaim.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].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].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)) } } } 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].Processing if id == link.Destination.ID && source != nil { for _, pb := range getAllPeersForItem(exec, link.Source.ID) { dependencies = append(dependencies, getArgoName(source.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 } // 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 ou stockage S3). // 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 ou STORAGE_RESOURCE. 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. Images []string `json:"images,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 }