PlantUML doc & Human Readable commentary

This commit is contained in:
mr
2026-03-18 08:30:02 +01:00
parent cec8033ddc
commit 85314baac3
2 changed files with 300 additions and 22 deletions

View File

@@ -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 |

View File

@@ -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", " "))
// 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], "'", "") // for now it's a json.
instance := d.getNewInstance(dataName, splitted[1], peerID)
if instance == nil {
return "", nil, errors.New("No instance found.")
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))