Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save ndeloof/911ecfa55c6bf3f46b94d0a6c96d8f48 to your computer and use it in GitHub Desktop.

Select an option

Save ndeloof/911ecfa55c6bf3f46b94d0a6c96d8f48 to your computer and use it in GitHub Desktop.
compose-go v3 — Plan de refonte autour de yaml.Node (lazy interpolation, source diagnostics, in-place rewrite)

compose-go v3 — Refonte autour de yaml.Node

Contexte

La librairie compose-go parse les fichiers compose.yaml pour Docker Compose et d'autres outils tiers. La v2 présente un défaut conceptuel : chaque fichier est désérialisé en map[string]any immédiatement après parsing, puis interpolé avant la fusion multi-fichiers. Le contexte de chargement (variables d'environnement, répertoire de travail, fichier d'origine, position ligne/colonne) est perdu dès la conversion. Conséquence :

  • include: permet de redéfinir project_directory et env_file pour le fichier inclus, mais comme l'interpolation est eager et par-fichier, elle ne peut pas être différée. Toute reconsidération ultérieure du contexte est impossible.
  • extends souffre d'un problème analogue (un service étendu depuis un fichier B devrait être interpolé dans le contexte de B, ce qui marche partiellement aujourd'hui mais via du code spécifique).
  • La résolution des chemins relatifs utilise toujours le WorkingDir racine, même pour des chemins déclarés dans un fichier inclus avec son propre project_directory (bug latent v2).
  • Les diagnostics (erreurs de validation, interpolation, schema) ne peuvent pas indiquer file:line:col car cette information est perdue dès convertToStringKeysRecursive.

Objectif v3 : préserver l'arbre *yaml.Node brut tout au long du pipeline. Accumuler les layers (yaml.Node + SourceContext) issus des fichiers multiples, include, extends sans fusionner eagerly. Une phase finale de résolution applique les règles de merge (override / append) sur les arbres Node. L'interpolation s'exécute après merge, chaque scalaire utilisant le SourceContext de son layer d'origine (lazy interpolation). Les positions sources sont conservées pour offrir un meilleur diagnostic.

Décisions arbitrées avec l'utilisateur :

  • Refonte in-place (pas de packages sibling, pas de feature flag) — branche dédiée v3.
  • Décodage final via yaml.v4 natif ((*yaml.Node).Decode) — abandon complet de mapstructure.
  • Diagnostics : erreurs enrichies + API publique Project.Sources opt-in.
  • Aucune compatibilité ascendante requise.

Architecture cible

Nouveau pipeline

LoadWithContext(ctx, ConfigDetails, options...) (*Project, error)
│
├─ 1. Parse layer-by-layer
│     Pour chaque ConfigFile :
│       a. yaml.Decoder → *yaml.Node (un par document YAML, multi-doc OK)
│       b. ResolveResetOverride(node) → tree + paths !reset / !override
│       c. NormalizeAliases(node) → aliases dépliés (deep clone, cycles protégés)
│       d. Construire Layer{Node, SourceContext{File, WorkingDir, Environment, EnvFiles, Parent}}
│       e. originMap : *yaml.Node → *SourceContext pour chaque scalaire du sous-arbre
│
├─ 2. Récursion include / extends
│     a. Repérer `include:` dans chaque layer
│        - Interpoler UNIQUEMENT le bloc include dans le contexte du layer parent
│          (pour résoudre ${VAR} dans path / project_directory / env_file)
│        - Calculer child SourceContext (project_directory, env_file chargés)
│        - Parser récursivement les fichiers inclus → child Layers
│     b. Repérer `services.*.extends.file` similairement → sub-Layers
│     Résultat : liste plate de Layers ordonnée selon priorité d'override
│     Détection des cycles par filename absolu et (filename, serviceName)
│
├─ 3. Merge des Layers
│     MergeLayers([]Layer) → Layer unique
│     - Walk parallèle de tous les arbres yaml.Node
│     - À chaque path tree.Path, applique la règle override (mergeSpecials)
│     - Synthèse de nodes pour short-form→long-form si besoin
│     - Chaque scalaire du résultat conserve son origine via originMap
│     - Application des !reset / !override paths collectés en phase 1
│
├─ 4. Interpolate (LAZY — la correction clé)
│     Walk de l'arbre fusionné. Pour chaque ScalarNode :
│     - Récupérer SourceContext via originMap[node]
│     - Substituer ${VAR} avec ctx.Environment
│     - Conserver le Style (DoubleQuoted, Plain, etc.) pour round-trip
│
├─ 5. TagNormalizer (remplace le cast hook mapstructure)
│     Pour chaque path dans interpolateTypeCastMapping :
│     - Forcer node.Tag = "!!int" / "!!bool" / "!!float"
│     - yaml.v4 fera la conversion au Decode
│
├─ 6. Canonical transform (short→long form) sur yaml.Node
├─ 7. ResolveRelativePaths sur yaml.Node (per-scalar WorkingDir via originMap)
├─ 8. Schema validation
│     Conversion temporaire vers map[string]any pour jsonschema (Option A)
│     Les erreurs schema sont re-mappées vers les nodes via le path
├─ 9. Normalize (implicit deps, default network, etc.) sur yaml.Node
└─10. Project projection
      (*yaml.Node).Decode(&Project) — yaml natif, UnmarshalYAML par type
      Populate Project.Sources si Options.Diagnostics activé

Types centraux (nouveaux)

// internal/node/layer.go
type SourceContext struct {
    File        string         // chemin absolu ou "(inline)"
    WorkingDir  string         // pour résolution de chemins relatifs
    Environment types.Mapping  // pour interpolation
    EnvFiles    []string       // ordonnés, déjà chargés dans Environment
    Parent      *SourceContext // chaîne include/extends pour diagnostics
}

type Layer struct {
    Node    *yaml.Node      // racine MappingNode du document
    Context *SourceContext
    origins map[*yaml.Node]*SourceContext // construit lazy, utilisé par interpolation/paths
}

// types/diagnostics.go (public)
type Location struct {
    File   string `json:"file,omitempty"`
    Line   int    `json:"line,omitempty"`
    Column int    `json:"column,omitempty"`
}

type Diagnostics struct {
    Sources map[string]Location // clé = tree.Path en notation pointée
}

// errdefs/diagnostic.go (public)
type Diagnostic struct {
    Path     tree.Path
    Location Location
    Cause    error
}
func (d *Diagnostic) Error() string { ... }
func (d *Diagnostic) Unwrap() error { return d.Cause }

Modifications API publique

// loader/loader.go
func LoadWithContext(ctx, ConfigDetails, options...) (*Project, error)  // signature inchangée

type Options struct {
    // ... champs conservés (SkipValidation, SkipInterpolation, etc.) ...
    Diagnostics *types.Diagnostics  // nouveau, opt-in
}

func WithDiagnostics(d *types.Diagnostics) func(*Options)  // nouveau

// types/config.go
type ConfigFile struct {
    Filename string
    Content  []byte
    Node     *yaml.Node  // nouveau, préféré quand le caller a déjà parsé
    Config   map[string]any  // SUPPRIMÉ en v3 (pas de retrocompat)
}

// types/project.go
type Project struct {
    // ... champs existants ...
    Sources map[string]types.Location `yaml:"-" json:"-"`  // peuplé si Options.Diagnostics non nul
}

Plan d'exécution — PR par PR

Refonte in-place sur une branche v3. Pas de coexistence v2/v3 — chaque PR fait avancer la branche. La compatibilité testdata garantit qu'aucune fonctionnalité ne régresse.

Phase A — Fondations (PRs 1-3)

PR 1 : internal/node/ package

  • Créer internal/node/layer.go : Layer, SourceContext, accessors
  • Créer internal/node/walk.go : walker générique Walk(*yaml.Node, tree.Path, visit) avec gestion des Kinds (Document, Mapping, Sequence, Scalar, Alias)
  • Créer internal/node/origins.go : side-table *yaml.Node*SourceContext, helpers de propagation
  • Pas de callers, aucun impact runtime. Tests unitaires du walker.

PR 2 : Reset/override sur yaml.Node

  • Extraire la logique de loader/reset.go vers internal/node/reset.go
  • Nouvelle fonction node.ResolveResetOverride(*yaml.Node) (cleaned *yaml.Node, resetPaths, overridePaths []tree.Path, err error)
  • Garde le mémo de cycles (alias bomb) et la limite MaxNodeVisits
  • loader/reset.go devient un adapter en attendant que loadYamlFile soit réécrit
  • Tests : tous les loader/reset_test.go doivent passer via le nouveau code

PR 3 : NormalizeAliases sur yaml.Node

  • internal/node/aliases.go : NormalizeAliases(*yaml.Node) error
  • Déplie chaque AliasNode vers une copie profonde du target (pour permettre la mutation par merge sans corruption croisée)
  • Traite explicitement les clés <<: (merge keys YAML) : entrée par entrée, surrounding-wins
  • Protection contre alias-bomb par visited-set (map[*yaml.Node]int avec compteur de profondeur)
  • Tests : aliases imbriqués, anchors partagés, cycles

Phase B — Phases pipeline Node-natives (PRs 4-9)

PR 4 : override.Merge réécrit sur yaml.Node

  • Réécrire override/merge.go pour opérer sur *yaml.Node au lieu de map[string]any
  • Chaque entrée de mergeSpecials (override, append, mergeBuild, mergeLogging, mergeIPAMConfig, mergeExtraHosts, mergeToSequence, mergeDependsOn, mergeModels, mergeNetworks, mergeUlimit) doit avoir une implémentation Node
  • Helpers convertIntoMapping / convertIntoSequence produisent des *yaml.Node synthétiques (Line/Column copiés de l'original, flag Synthetic dans le SourceContext)
  • override.MergeYaml (utilisé par loader/include.go:214) disparaît au profit du nouveau Merge
  • Tests : toute la suite override/merge_test.go adaptée

PR 5 : override.EnforceUnicity réécrit sur yaml.Node

  • Réécriture portant les indexers (par name, par port, par volume, etc.) sur Node values
  • Tests existants conservés

PR 6 : interpolation.Interpolate sur yaml.Node

  • interpolation/interpolation.go : nouvelle signature
    func Interpolate(root *yaml.Node, opts Options) error
  • Options.LookupValue devient une closure par-node :
    type Options struct {
        LookupValue func(node *yaml.Node, key string) (string, bool)
        Substitute  func(template string, mapping template.Mapping) (string, error)
        Casts       map[tree.Path]string  // "!!int" / "!!bool" / "!!float"
    }
  • Walk recursive de l'arbre, substitution scalaire par scalaire
  • Suppression de interpolation/recursive.go (plus de récursion sur map[string]any)
  • Préservation du Style YAML

PR 7 : transform.Canonical sur yaml.Node

  • Réécriture de transform/canonical.go et de chaque transformer enregistré (50+)
  • Chaque TransformerFunc opère sur *yaml.Node et retourne *yaml.Node
  • Cas particuliers : transformPorts, transformVolumeMount, transformBuild, transformFileMount, transformSSH, transformGPUs, transformDevices, transformEnvFile, transformLabelFile, transformDependsOn, transformStringSliceToMap
  • Tests : chaque transform doit avoir une couverture fixture (in-yaml → expected-yaml)

PR 8 : paths.ResolveRelativePaths sur yaml.Node

  • Réécrire paths/resolve.go pour walker l'arbre Node
  • Correction du bug latent v2 : chaque scalaire utilise originMap[node].WorkingDir au lieu du WorkingDir racine
  • Chaque resolver path-spécifique (absContextPath, absPath, absExtendsPath, absVolumeMount, etc.) opère sur Node
  • Tests : ajouter un fixture où un volume relatif dans un fichier inclus doit être résolu contre le project_directory de l'include

PR 9 : validation.Validate et Normalize sur yaml.Node

  • validation/validation.go : porter le map de checks vers Node (la clé reste tree.Path)
  • loader/normalize.go : normalisation (réseaux par défaut, dépendances implicites, etc.) sur Node
  • Pour schema.Validate (jsonschema) : conversion temporaire vers map[string]any (Option A — coût borné, validation = un seul appel). Les erreurs schema sont re-mappées via le path pour retrouver le node et sa Location.

Phase C — Orchestrateur loader (PRs 10-13)

PR 10 : Refonte loadYamlFileloadLayer

  • Réécrire loader/loader.go::loadYamlFile :
    func loadLayer(ctx, file types.ConfigFile, ctx SourceContext, opts *Options) (*node.Layer, error)
  • Plus de convertToStringKeysRecursive, plus de map[string]any intermédiaire
  • Multi-document YAML : produit []Layer pour un seul fichier
  • Tests : single-file fixtures (loader_test.go testaminus include/extends)

PR 11 : Refonte ApplyIncludecollectIncludeLayers

  • Réécrire loader/include.go :
    func collectIncludeLayers(ctx, parent *node.Layer, opts *Options) ([]*node.Layer, error)
  • Interpoler seulement le sous-arbre include: dans le contexte parent (pour ${VAR} dans path/project_directory/env_file)
  • Construire les SourceContext enfants (env_file chargé, project_directory résolu)
  • Détection de cycle via filenames absolus
  • Pas de merge eager — retourne juste les Layers à fusionner plus tard
  • Tests : loader/include_test.go + nouveaux fixtures lazy-interp

PR 12 : Refonte ApplyExtendsapplyExtendsToLayer

  • Réécrire loader/extends.go :
    func applyExtendsToLayer(ctx, layer *node.Layer, ct *cycleTracker, opts *Options) error
  • Charge les fichiers cibles d'extends en one-shot Layers
  • Merge per-service via override.Merge (Node-based)
  • Tests : loader/extends_test.go + scénarios cross-context

PR 13 : Refonte LoadWithContext orchestrateur

  • Réécrire loader/loader.go::load :
    func load(ctx, cd types.ConfigDetails, opts *Options) (*node.Layer, error) {
        layers := []*node.Layer{}
        for _, f := range cd.ConfigFiles {
            layer, err := loadLayer(ctx, f, rootContext, opts)
            children, err := collectIncludeLayers(ctx, layer, opts)
            applyExtendsToLayer(ctx, layer, ct, opts)
            layers = append(layers, children...)
            layers = append(layers, layer)
        }
        merged, err := node.MergeLayers(layers)
        node.ApplyResetPaths(merged, allResetPaths)
        interpolation.Interpolate(merged.Node, opts.Interpolate)
        node.NormalizeTags(merged, casts)
        transform.Canonical(merged.Node, opts.SkipInterpolation)
        paths.ResolveRelativePaths(merged)
        validation.Validate(merged.Node)
        normalize.Normalize(merged.Node)
        return merged, nil
    }
  • Le flux est linéaire et compréhensible.

Phase D — Projection vers Project (PRs 14-16)

PR 14 : UnmarshalYAML sur les types

  • Audit méthodique de types/*.go :
    • Chaque type avec DecodeMapstructureUnmarshalYAML(value *yaml.Node) error
    • Liste à porter : ServicePortConfig, ServiceVolumeConfig, BuildConfig, ServiceConfig, ServiceSecretConfig, ServiceNetworkConfig, ServiceDependency, SecretConfig, ConfigObjConfig, IncludeConfig, HealthCheckConfig, MappingWithEquals, MappingWithColon, Labels, StringList, StringOrNumberList, ShellCommand, HostsList, UlimitsConfig, etc.
  • Pour Services : UnmarshalYAML qui injecte la clé Name (remplace nameServices hook mapstructure)
  • Pour SecretConfig / ConfigObjConfig : lift de extensions.x-content
  • Test reflection : pour chaque type exporté de types, vérifier qu'il a soit un tag yaml:, soit UnmarshalYAML

PR 15 : Project.UnmarshalYAML + Sources

  • (*Project).UnmarshalYAML(value *yaml.Node) error
  • Si Options.Diagnostics est non nul, populate Project.Sources en walkant l'arbre original
  • Suppression de loader/mapstructure.go et de la dépendance go-viper/mapstructure/v2

PR 16 : processExtensions sur yaml.Node

  • Réécrire loader/loader.go::processExtensions pour walker l'arbre Node, déplacer les x-* vers un sous-arbre #extensions
  • Pour Options.KnownExtensions, décoder via (*yaml.Node).Decode(target) au lieu de mapstructure
  • Tests : fixtures x-* existants

Phase E — Diagnostics et messages d'erreur (PR 17)

PR 17 : Erreurs enrichies

  • Refactoring de chaque retour d'erreur dans loader/, override/, interpolation/, transform/, paths/, validation/ pour wrapper en errdefs.Diagnostic
  • Tests : assert que les erreurs contiennent file:line:col pour chaque type de problème
    • Schema validation error
    • Interpolation error (variable non définie en mode strict)
    • Type cast error (port non parseable en int)
    • Cycle detection (include / extends / depends_on)
  • Au moins un test golden pour le format d'erreur

Phase F — Nettoyage et finalisation (PRs 18-19)

PR 18 : Suppression du code mort v2

  • Supprimer convertToStringKeysRecursive, fixEmptyNotNull, OmitEmpty (équivalents Node-natifs ou inutiles)
  • Supprimer loader/mapstructure.go et hooks (nameServices, decoderHook, cast, secretConfigDecoderHook)
  • Supprimer ResolveEnvironment map-based
  • Supprimer interpolation/recursive.go
  • Suppression de la dépendance github.com/go-viper/mapstructure/v2 dans go.mod
  • Renommer le module v2 → v3 dans go.mod

PR 19 : Documentation et release notes

  • Mettre à jour parsing.md avec le nouveau pipeline
  • Notes de migration v2 → v3 (changements API)
  • Documenter les divergences intentionnelles de comportement (lazy interpolation, per-include path resolution)

Stratégie de tests exhaustifs

Fixtures existants à conserver

loader/testdata/ contient ~80 fixtures couvrant :

  • extends/ (base, depends_on, interpolated, nested, ports, reset, sibling, withdir)
  • include/ (basique, project-directory, dir/, dotenv)
  • combined/ (extends + include)
  • remote/ (cycles, env files, nested)
  • subdir/
  • Cycles : compose-include-cycle.yaml, compose-depends-on-cycle.yaml, compose-depends-on-profile-no-cycle.yaml

Stratégie : pour chaque fixture, geler le Project v2 actuel via yaml.Marshal en <fixture>.golden.json. Les tests v3 doivent reproduire ces goldens, à l'exception des cas listés ci-dessous (divergence documentée).

Nouveaux fixtures à créer

testdata/include/lazy-interp/ — la correction phare

compose.yaml :
  include:
    - path: included.yaml
      env_file: env/.env.parent

included.yaml :
  services:
    web:
      image: nginx:${TAG}
      ports: ["${PORT}:80"]

env/.env.parent : TAG=2.0
                  PORT=8080
included-dir/.env : TAG=1.0
                    PORT=80

Variantes :

  • env_file sur l'include vs sur le parent
  • env_file: /dev/null désactive l'héritage
  • Service défini dans parent ET inclus — vérifier que chaque scalaire utilise SON SourceContext

testdata/include/per-include-paths/ — correction du bug WorkingDir Un volume relatif ./data:/data déclaré dans le fichier inclus doit être résolu contre le project_directory de l'include, pas la racine.

testdata/diagnostics/ — file:line dans les erreurs

  • Port non parseable comme int → erreur avec file:line:col
  • Variable d'env non définie en mode strict → idem
  • Service en référence circulaire → chaîne avec positions

testdata/anchors/cross-merge/ — préservation des anchors

  • Anchor déclaré dans fichier A, utilisé par un service overridé dans fichier B
  • <<: *ref avec override key dans B doit gagner
  • Anchor partagé entre plusieurs services avec !reset doit reset à chaque site

Suites de tests à étoffer

  • internal/node/walk_test.go — walker, gestion de tous les Kinds
  • internal/node/aliases_test.go — normalisation aliases, cycles, alias-bomb
  • internal/node/reset_test.go — reset/override sur Node
  • override/merge_node_test.go — chaque règle merge, scenarios short/long form, synthèse de Nodes
  • interpolation/interpolation_test.go — lazy interpolation per-scalar, préservation Style
  • transform/canonical_test.go — chaque transformer (50+), un test par règle
  • paths/resolve_test.go — per-Layer WorkingDir
  • loader/diagnostics_test.go — file:line dans toutes les erreurs

Fuzzing

  • FuzzMergeNode — arbres yaml.Node aléatoires, vérifier termination + forme du résultat
  • FuzzResolveAliases — graphes d'aliases bornés, vérifier termination
  • FuzzInterpolate — env maps et templates aléatoires

Benchmarks

  • Mesurer loadtime_v3 / loadtime_v2 sur un fixture moyen (testdata/extends/withdir/)
  • Fixture synthétique large (~1000 services avec anchors)
  • Objectif : v3 ≤ 1.5× mémoire v2, ≤ 1.2× wall-time v2

CI

  • Suite testdata complète sur chaque PR
  • Fuzzing en mode court (10s par fuzz) sur PR
  • Fuzzing long (1h) sur cron quotidien
  • Benchmark report comparant chaque PR à la branche v2-baseline

Risques et gotchas

Anchors et aliases (<<:, *ref)

  • Décision : normalisation explicite des aliases avant merge cross-fichier
  • Aliases dépliés via deep clone pour autoriser la mutation lors du merge
  • Merge-keys <<: traités explicitement (surrounding-wins, pas d'appel à Decode)
  • Cycles : visited-set keyé par *yaml.Node avec compteur de profondeur
  • Tests de régression : TestAliasBombPrevented, TestResetTagWithSharedAlias

!reset / !override tags

  • ResetProcessor extrait vers internal/node/reset.go (PR 2)
  • Collecte des paths à la phase 1 ; application sur l'arbre fusionné en phase 3
  • !override sur un path court-circuite le merge (replace au lieu de merge) — porté dans MergeLayers
  • !reset dans une séquence (par index) : rejet explicite avec erreur claire pointant la ligne. Le TODO de loader/reset.go:267 est résolu par un échec déterministe plutôt qu'un comportement indéfini. Cas testé dans testdata/diagnostics/reset-in-sequence.yaml.

x-* extensions

  • Walk Node-natif pour détecter et déplacer les x-*
  • Pour les KnownExtensions, décodage via (*yaml.Node).Decode(target)
  • Tests : tous les fixtures x-* existants reproduits

Nodes synthétiques (short→long form)

  • Quand un transformer synthétise un MappingNode (ex: build: .build: { context: . }), copier Line/Column du scalaire original
  • Le SourceContext de la synthèse pointe vers le parent original
  • Flag Synthetic dans l'entrée de l'originMap pour permettre des messages d'erreur du type "(near line N, derived from short form)"

Multi-document YAML

  • Un fichier peut contenir --- séparant plusieurs documents
  • Produit []Layer mergés en ordre

Validation jsonschema

  • santhosh-tekuri/jsonschema/v6 attend any Go-natif
  • Décoder l'arbre Node fusionné en map[string]any uniquement pour validation (throwaway)
  • Re-mapper les erreurs schema vers les Nodes via le path pour Location

Casts de type

  • Suppression du hook mapstructure
  • TagNormalizer parcourt l'arbre, force node.Tag = "!!int" etc. selon interpolateTypeCastMapping
  • yaml.v4 fait la conversion au Decode, même si la source était "80" (chaîne quotée)

Fields YAML tags audit

  • mapstructure faisait du fuzzy match (case-insensitive, snake-case)
  • yaml.v4 est strict
  • Test reflection : pour chaque type exporté de types, vérifier la présence de tags yaml: (ou yaml:"-")

Fichiers critiques à modifier

Création

  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/layer.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/walk.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/origins.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/reset.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/aliases.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/merge.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/types/diagnostics.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/errdefs/diagnostic.go

Réécriture complète

  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/loader.go (entry points + orchestrateur)
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/include.go (collectIncludeLayers)
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/extends.go (applyExtendsToLayer)
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/reset.go (devient adapter vers internal/node)
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/normalize.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/override/merge.go (signature sur *yaml.Node)
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/override/uncity.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/interpolation/interpolation.go (Interpolate sur Node + per-scalar lookup)
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/transform/canonical.go (+ chaque transformer dans le même répertoire)
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/paths/resolve.go (per-Layer WorkingDir)
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/validation/validation.go

Modification (UnmarshalYAML)

  • Tous les types dans /Users/nicolas/go/src/github.com/compose-spec/compose-go/types/ qui ont actuellement DecodeMapstructure

Suppression

  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/mapstructure.go
  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/interpolation/recursive.go (si existe — la récursion sur map devient inutile)
  • Dépendance github.com/go-viper/mapstructure/v2 dans go.mod
  • Fonction publique loader.Transform(source, target) : supprimée. Les consommateurs (Docker Compose CLI, devcontainer, etc.) doivent passer à (*yaml.Node).Decode(&target). Documenté dans les notes de migration.

Module

  • /Users/nicolas/go/src/github.com/compose-spec/compose-go/go.mod — passage de v2 à v3 dans le path

Vérification end-to-end

  1. Tests fixtures : go test ./... doit passer avec tous les goldens regénérés
  2. Suite de divergences documentées : loader/divergence_test.go liste les comportements v3 intentionnellement différents de v2, chacun avec un commentaire justifiant
  3. Diagnostics manuels : prendre 3 fichiers compose réels (un exemple Docker Compose, un avec include, un avec extends), introduire des erreurs ciblées, vérifier que les messages contiennent bien file:line:col
  4. Benchmark : go test -bench=BenchmarkLoad -benchmem ./loader/ comparé à un baseline checkout de la branche main actuelle
  5. Intégration externe : compiler Docker Compose CLI contre la branche v3, exécuter sa propre suite d'intégration pour détecter les ruptures non triviales
  6. Lazy interpolation cas-test manuel : exécuter le fixture testdata/include/lazy-interp/ et vérifier que services.web.image == "nginx:2.0" (depuis .env.parent) tandis que d'autres champs du même service utilisent le .env du project_directory inclus
  7. Préservation des positions : Project.Sources["services.web.image"] doit contenir {File: "included.yaml", Line: 4, Column: 14} (ou équivalent selon la fixture)
  8. Cycles : tous les fixtures de cycle doivent encore détecter le cycle et le rapporter avec la chaîne complète des fichiers/services impliqués

Décisions arrêtées

  1. !reset sur élément de séquence par index : rejet explicite avec erreur claire (file:line). Pas d'implémentation de la suppression par index (ambiguïté entre merges, indices instables). Test dédié dans testdata/diagnostics/reset-in-sequence.yaml.
  2. loader.Transform(source, target) : suppression complète. Les consommateurs migrent vers (*yaml.Node).Decode. V3 étant un major, la rupture est légitime ; documenté en notes de migration.
  3. Ordre de fold du merge multi-fichiers : left-to-right (comportement v2 préservé) : merge(merge(merge(f0, f1), f2), f3). ConfigFiles[0] est la base, chaque fichier suivant override le résultat accumulé. Test de régression sur fixture 3-fichiers.
  4. Project.Sources visibilité : caché par défaut (yaml:"-" json:"-"). Le champ existe sur la struct mais ne pollue jamais le marshaling. Peuplé uniquement si Options.Diagnostics (ou WithDiagnostics()) est utilisé. Compatibilité de sérialisation strictement préservée par défaut.

Points opérationnels (non bloquants)

  • Round-trip anchor preservation : yaml.Marshal(project) ne réémet pas les anchors (snapshot plat). À tester explicitement pour confirmer le comportement, mais pas un objectif fonctionnel.
  • Migration des consommateurs tiers : avant le merge de la branche v3, communiquer en amont avec les maintainers de Docker Compose CLI, devcontainer-cli, et autres consommateurs identifiés via gh search sur compose-go. Une PR de migration de référence dans Docker Compose CLI démontre la procédure (notamment le remplacement de loader.Transform).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment