From 85314baac33ce5427bdd92a766eabd28bdfae64d Mon Sep 17 00:00:00 2001 From: mr Date: Wed, 18 Mar 2026 08:30:02 +0100 Subject: [PATCH] PlantUML doc & Human Readable commentary --- docs/plantuml-human-readable.md | 214 ++++++++++++++++++++++++++++++++ models/workflow/workflow.go | 108 ++++++++++++---- 2 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 docs/plantuml-human-readable.md diff --git a/docs/plantuml-human-readable.md b/docs/plantuml-human-readable.md new file mode 100644 index 0000000..0cde791 --- /dev/null +++ b/docs/plantuml-human-readable.md @@ -0,0 +1,214 @@ +# PlantUML — Format de commentaire human-readable + +Ce document décrit la syntaxe des commentaires attachés aux ressources et aux liens +dans les fichiers PlantUML importés par OpenCloud. + +--- + +## Syntaxe générale + +```plantuml +TypeRessource(varName, "Nom affiché") ' clé: valeur, clé.sous_clé: valeur +``` + +### Règles de parsing + +| Règle | Détail | +|---|---| +| Séparateur de paires | `,` | +| Séparateur clé/valeur | premier `:` de la paire (les URLs `http://...` sont gérées) | +| Sous-objets | notation pointée `access.container.image: nginx` | +| Types | auto-inférés : `bool` > `float64` > `string` | +| Fallback | JSON brut si le commentaire commence par `{` (compatibilité ascendante) | + +### Comportement à l'import + +Chaque ressource reçoit automatiquement une **instance par défaut**, seedée avec les +attributs de la ressource parente. Le commentaire vient ensuite **surcharger** uniquement +les champs explicitement renseignés. + +> **Exception :** `WorkflowEvent` n'a pas d'instance (voir section dédiée). + +--- + +## Ressources disponibles + +### `Data(var, "nom")` — Données + +Ressource de données. Les attributs qualifient le modèle de données **et** son instance +(source d'accès). + +| Clé | Type | Description | +|---|---|---| +| `type` | string | Type de données (`raster`, `vector`, `tabular`…) | +| `quality` | string | Niveau de qualité | +| `open_data` | bool | Données en accès libre | +| `static` | bool | Données statiques (pas de mise à jour) | +| `personal_data` | bool | Contient des données personnelles | +| `anonymized_personal_data` | bool | Données personnelles anonymisées | +| `size` | float64 | Taille en GB | +| `access_protocol` | string | Protocole d'accès (`http`, `s3`, `ftp`…) | +| `country` | string | Code pays ISO (`FR`, `DE`…) | +| `location.latitude` | float64 | Latitude géographique | +| `location.longitude` | float64 | Longitude géographique | +| `source` | string | URL / endpoint d'accès à la donnée | + +```plantuml +Data(d1, "Satellites L2A") ' type: raster, open_data: true, size: 120.5, source: https://catalogue.example.com, country: FR +``` + +--- + +### `Processing(var, "nom")` — Traitement + +Ressource de traitement (algorithme, conteneur, service). Les attributs qualifient +le modèle de traitement **et** sa configuration d'exécution. + +| Clé | Type | Description | +|---|---|---| +| `infrastructure` | int | Infrastructure cible : `0`=DOCKER, `1`=KUBERNETES, `2`=SLURM, `3`=HW, `4`=CONDOR | +| `is_service` | bool | Traitement persistant (service long-running) | +| `open_source` | bool | Code source ouvert | +| `license` | string | Licence (`MIT`, `Apache-2.0`, `GPL-3.0`…) | +| `maturity` | string | Maturité (`prototype`, `beta`, `production`…) | +| `access_protocol` | string | Protocole d'accès | +| `country` | string | Code pays ISO | +| `location.latitude` | float64 | Latitude | +| `location.longitude` | float64 | Longitude | +| `access.container.image` | string | Image du conteneur | +| `access.container.command` | string | Commande de démarrage | +| `access.container.args` | string | Arguments de la commande | + +```plantuml +Processing(p1, "NDVI Calc") ' infrastructure: 0, open_source: true, license: MIT, maturity: production, access.container.image: myrepo/ndvi:1.2 +``` + +--- + +### `Storage(var, "nom")` — Stockage + +Ressource de stockage. Produit une instance live (`LiveStorage`) à l'import. + +| Clé | Type | Description | +|---|---|---| +| `storage_type` | int | Type de stockage (enum) | +| `source` | string | URL / endpoint du stockage | +| `path` | string | Chemin ou bucket dans le stockage | +| `local` | bool | Stockage local | +| `security_level` | string | Niveau de sécurité | +| `size` | float64 | Taille allouée en GB | +| `encryption` | bool | Chiffrement activé | +| `redundancy` | string | Politique de redondance | +| `throughput` | string | Débit cible | +| `access_protocol` | string | Protocole (`s3`, `nfs`, `smb`…) | +| `country` | string | Code pays ISO | +| `location.latitude` | float64 | Latitude | +| `location.longitude` | float64 | Longitude | + +```plantuml +Storage(s1, "Minio OVH") ' source: http://minio.example.com:9000, path: /bucket/data, access_protocol: s3, encryption: true, size: 500, country: FR +``` + +--- + +### `ComputeUnit(var, "nom")` — Unité de calcul + +Ressource de calcul (datacenter, cluster). Produit une instance live (`LiveDatacenter`) +à l'import. + +| Clé | Type | Description | +|---|---|---| +| `architecture` | string | Architecture CPU (`x86_64`, `arm64`…) | +| `infrastructure` | int | `0`=DOCKER, `1`=KUBERNETES, `2`=SLURM, `3`=HW, `4`=CONDOR | +| `source` | string | URL de l'API du datacenter | +| `security_level` | string | Niveau de sécurité | +| `annual_co2_emissions` | float64 | Émissions CO₂ annuelles (kg) | +| `access_protocol` | string | Protocole d'accès | +| `country` | string | Code pays ISO | +| `location.latitude` | float64 | Latitude | +| `location.longitude` | float64 | Longitude | + +```plantuml +ComputeUnit(c1, "Datacenter Rennes") ' source: https://api.dc-rennes.example.com, infrastructure: 1, country: FR, location.latitude: 48.11, location.longitude: -1.68, security_level: high +``` + +--- + +### `WorkflowEvent(var, "nom")` — Événement déclencheur de workflow + +Crée directement un `NativeTool` de type `WORKFLOW_EVENT` (Kind = 0). +Représente le point de départ d'un workflow. + +> **Pas d'instance. Pas de commentaire.** +> Le nom du `NativeTool` est forcé à `WORKFLOW_EVENT` à l'import. + +```plantuml +WorkflowEvent(e1, "Start") +``` + +--- + +## Liens + +Les commentaires sur les liens qualifient la connexion entre deux ressources +(typiquement entre un traitement et un stockage). + +### Syntaxe + +```plantuml +source --> destination ' clé: valeur +source <-- destination ' clé: valeur +source -- destination ' clé: valeur (non directionnel) +``` + +### Attributs disponibles + +| Clé | Type | Description | +|---|---|---| +| `storage_link_infos.write` | bool | `true` = écriture, `false` = lecture | +| `storage_link_infos.source` | string | Chemin source dans le lien | +| `storage_link_infos.destination` | string | Chemin destination dans le lien | +| `storage_link_infos.filename` | string | Nom du fichier échangé | + +```plantuml +p1 --> s1 ' storage_link_infos.write: true, storage_link_infos.filename: output.tif +d1 --> p1 +``` + +--- + +## Exemple complet + +```plantuml +@startuml +!include opencloud.puml + +WorkflowEvent(e1, "Start") + +Data(d1, "Satellites L2A") ' type: raster, open_data: true, size: 120.5, source: https://catalogue.example.com, country: FR + +Processing(p1, "NDVI") ' infrastructure: 0, open_source: true, license: MIT, access.container.image: myrepo/ndvi:1.2 + +Storage(s1, "Minio résultats") ' source: http://minio.example.com:9000, path: /results, access_protocol: s3, encryption: true, size: 500, country: FR + +ComputeUnit(c1, "DC Rennes") ' source: https://api.dc.example.com, infrastructure: 1, country: FR, location.latitude: 48.11, location.longitude: -1.68 + +e1 --> p1 +d1 --> p1 +p1 --> s1 ' storage_link_infos.write: true, storage_link_infos.filename: ndvi.tif +s1 --> c1 + +@enduml +``` + +--- + +## Récapitulatif des types de ressources + +| Mot-clé PlantUML | Type Go | Instance | Live | Commentaire | +|---|---|---|---|---| +| `Data` | `DataResource` | `DataInstance` | non | oui | +| `Processing` | `ProcessingResource` | `ProcessingInstance` | non | oui | +| `Storage` | `StorageResource` | `StorageResourceInstance` | oui → `LiveStorage` | oui | +| `ComputeUnit` | `ComputeResource` | `ComputeResourceInstance` | oui → `LiveDatacenter` | oui | +| `WorkflowEvent` | `NativeTool` (Kind=WORKFLOW_EVENT) | aucune | non | non | diff --git a/models/workflow/workflow.go b/models/workflow/workflow.go index fdaa165..83c0254 100644 --- a/models/workflow/workflow.go +++ b/models/workflow/workflow.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "mime/multipart" + "strconv" "strings" "time" @@ -17,6 +18,7 @@ import ( "cloud.o-forge.io/core/oc-lib/models/live" "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" "cloud.o-forge.io/core/oc-lib/models/utils" "cloud.o-forge.io/core/oc-lib/models/workflow/graph" "cloud.o-forge.io/core/oc-lib/tools" @@ -133,12 +135,18 @@ func (d *Workflow) ExtractFromPlantUML(plantUML multipart.File, request *tools.A }, "ComputeUnit": func() resources.ResourceInterface { return &resources.ComputeResource{ - AbstractInstanciatedResource: resources.AbstractInstanciatedResource[*resources.ComputeResourceInstance]{ Instances: []*resources.ComputeResourceInstance{}, }, } }, + // WorkflowEvent creates a NativeTool of Kind=WORKFLOW_EVENT directly, + // without DB lookup. It has no user-defined instance. + "WorkflowEvent": func() resources.ResourceInterface { + return &resources.NativeTool{ + Kind: int(native_tools.WORKFLOW_EVENT), + } + }, } graphVarName := map[string]graph.GraphItem{} scanner := bufio.NewScanner(plantUML) @@ -231,6 +239,60 @@ func (d *Workflow) generateResource(datas []resources.ResourceInterface, request return nil } +// setNestedKey sets a value in a nested map using dot-notation path. +// "access.container.image" → m["access"]["container"]["image"] = value +func setNestedKey(m map[string]any, path string, value any) { + parts := strings.SplitN(path, ".", 2) + if len(parts) == 1 { + m[path] = value + return + } + key, rest := parts[0], parts[1] + if _, ok := m[key]; !ok { + m[key] = map[string]any{} + } + if sub, ok := m[key].(map[string]any); ok { + setNestedKey(sub, rest, value) + } +} + +// parseHumanFriendlyAttrs converts a human-friendly comment into JSON bytes. +// Supports: +// - flat: "source: http://example.com, encryption: true, size: 500" +// - nested: "access.container.image: nginx, access.container.tag: latest" +// - raw JSON passthrough (backward-compat): '{"key": "value"}' +// +// Values are auto-typed: bool, float64, or string. +// Note: the first ':' in each pair is the key/value separator, +// so URLs like "http://..." are handled correctly. +func parseHumanFriendlyAttrs(comment string) []byte { + comment = strings.TrimSpace(comment) + if strings.HasPrefix(comment, "{") { + return []byte(comment) + } + m := map[string]any{} + for _, pair := range strings.Split(comment, ",") { + pair = strings.TrimSpace(pair) + parts := strings.SplitN(pair, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + var typed any + if b, err := strconv.ParseBool(val); err == nil { + typed = b + } else if n, err := strconv.ParseFloat(val, 64); err == nil { + typed = n + } else { + typed = val + } + setNestedKey(m, key, typed) + } + b, _ := json.Marshal(m) + return b +} + func (d *Workflow) extractLink(line string, graphVarName map[string]graph.GraphItem, pattern string, reverse bool) error { splitted := strings.Split(line, pattern) if len(splitted) < 2 { @@ -255,8 +317,8 @@ func (d *Workflow) extractLink(line string, graphVarName map[string]graph.GraphI } splittedComments := strings.Split(line, "'") if len(splittedComments) > 1 { - comment := strings.ReplaceAll(splittedComments[1], "'", "") // for now it's a json. - json.Unmarshal([]byte(comment), link) + comment := strings.ReplaceAll(splittedComments[1], "'", "") + json.Unmarshal(parseHumanFriendlyAttrs(comment), link) } d.Graph.Links = append(d.Graph.Links, *link) return nil @@ -268,7 +330,7 @@ func (d *Workflow) extractResourcePlantUML(line string, resource resources.Resou return "", nil, errors.New("Can't deserialize Object, there's no func") } splittedParams := strings.Split(splittedFunc[1], ",") - if len(splittedFunc) <= 1 { + if len(splittedParams) <= 1 { return "", nil, errors.New("Can't deserialize Object, there's no params") } @@ -281,18 +343,22 @@ func (d *Workflow) extractResourcePlantUML(line string, resource resources.Resou } resource.SetName(strings.ReplaceAll(splitted[1], "\\n", " ")) - splittedComments := strings.Split(line, "'") - if len(splittedComments) > 1 { - comment := strings.ReplaceAll(splittedComments[1], "'", "") // for now it's a json. - instance := d.getNewInstance(dataName, splitted[1], peerID) - if instance == nil { - return "", nil, errors.New("No instance found.") + // Resources with instances get a default one seeded from the parent resource, + // then overridden by any explicit comment attributes. + // Event (NativeTool) has no instance: getNewInstance returns nil and is skipped. + instance := d.getNewInstance(dataName, splitted[1], peerID) + if instance != nil { + if b, err := json.Marshal(resource); err == nil { + json.Unmarshal(b, instance) + } + splittedComments := strings.Split(line, "'") + if len(splittedComments) > 1 { + comment := strings.ReplaceAll(splittedComments[1], "'", "") + json.Unmarshal(parseHumanFriendlyAttrs(comment), instance) } resource.AddInstances(instance) - - json.Unmarshal([]byte(comment), instance) } - // deserializer les instances... une instance doit par défaut avoir certaines valeurs d'accès. + item := d.getNewGraphItem(dataName, resource) if item != nil { d.Graph.Items[item.ID] = *item @@ -318,15 +384,13 @@ func (d *Workflow) getNewGraphItem(dataName string, resource resources.ResourceI d.Processings = append(d.Processings, resource.GetID()) d.ProcessingResources = append(d.ProcessingResources, resource.(*resources.ProcessingResource)) graphItem.Processing = resource.(*resources.ProcessingResource) - case "Event": - access := resources.NewAccessor[*resources.NativeTool](tools.NATIVE_TOOL, &tools.APIRequest{ - Admin: true, - }, func() utils.DBObject { return &resources.NativeTool{} }) - t, _, err := access.Search(nil, "WORKFLOW_EVENT", false) - if err == nil && len(t) > 0 { - d.NativeTool = append(d.NativeTool, t[0].GetID()) - graphItem.NativeTool = t[0].(*resources.NativeTool) - } + case "WorkflowEvent": + // The resource is already a *NativeTool with Kind=WORKFLOW_EVENT set by the + // catalog factory. We use it directly without any DB lookup. + nt := resource.(*resources.NativeTool) + nt.Name = native_tools.WORKFLOW_EVENT.String() + d.NativeTool = append(d.NativeTool, nt.GetID()) + graphItem.NativeTool = nt case "Storage": d.Storages = append(d.Storages, resource.GetID()) d.StorageResources = append(d.StorageResources, resource.(*resources.StorageResource))