Skip to content

Bug : TerraformLayer nécessite que le TerraformRepository soit synchronisé avant de créer un run #760

@olahouze

Description

@olahouze

⚠️ Statut : Bug potentiel identifié dans le code source mais non confirmé en production
📦 Version analysée : v0.9.0-rc.2
📅 Date d'analyse : 10 novembre 2025

🐛 Résumé du problème

Lorsqu'un TerraformRepository et TerraformLayer sont déployés simultanément (via kubectl apply -f), le TerraformRepository pourrait ne jamais se synchroniser avec Git, et le TerraformLayer ne pourrait pas créer de run (plan/apply) car il manquerait l'annotation webhook.terraform.padok.cloud/relevant-commit.

Le repository pourrait rester bloqué dans l'état SyncNeeded indéfiniment, sauf si :

  1. Le TerraformLayer est supprimé puis recréé
  2. Le timer repositorySync (par défaut 30m) expire
  3. L'annotation api.terraform.padok.cloud/sync-now est ajoutée manuellement

📊 Symptômes

  • Le TerraformRepository reste dans l'état SyncNeeded indéfiniment (jusqu'à 30 minutes)
  • Le TerraformLayer reste dans l'état Idle ou PlanNeeded sans créer de pod
  • Message dans les logs du contrôleur : "layer <name> has no last relevant commit annotation, run not created"
  • Event Kubernetes sur le TerraformLayer : "Layer has no last relevant commit annotation, Plan run not created"
  • Aucun TerraformRun n'est créé même si le repository est accessible
  • ⚠️ Affecte principalement les repositories PRIVÉS avec credentials (GitLab/GitHub avec token)
  • ✅ Les repositories PUBLICS (sans credentials) semblent fonctionner correctement

� Analyse Approfondie

Pourquoi les Repositories PUBLICS fonctionnent ?

Test de validation :

# Repository public GitHub sans credentials
kubectl apply -f terraform-layer-demo.yaml
# → TerraformRepository sync immédiatement après création du layer ✅

Explication technique :

Dans internal/repository/repository.go (lignes 15-20) :

func GetGitProviderFromRepository(store *credentials.CredentialStore, repo *configv1alpha1.TerraformRepository) (types.GitProvider, error) {
    creds, err := store.GetCredentials(repo)
    // If no credentials, it may be a standard public repository
    if err != nil {
        return getStandardGitNoAuth(repo.Spec.Repository.Url), nil  // ✅ PAS D'ERREUR !
    }
    // ...
}

Pour les repositories publics :

  • GetCredentials() échoue (pas de secret trouvé)
  • La fonction retourne getStandardGitNoAuth() SANS erreur
  • Le code continue et arrive à la vérification isThereANewLayer()
  • Lors de la recréation d'un layer, isThereANewLayer() retourne true (layer sans annotation LastBranchCommit)
  • La condition de skip devient falsesync exécuté

Pourquoi les Repositories PRIVÉS échouent ?

Pour les repositories privés (GitLab/GitHub avec token) :

  • GetCredentials() échoue à cause du bug du cache (credentials dans burrito-system mais recherchés dans burrito-project)
  • Comme c'est un repo privé qui DEVRAIT avoir des credentials, cette erreur remonte
  • Dans internal/controllers/terraformrepository/states.go lignes 52-56 :
    gitProvider, err := repo.GetGitProviderFromRepository(r.Credentials, repository)
    if err != nil {
        log.Errorf("failed to get git provider...")
        return ctrl.Result{}, branchStates  // ⚠️ RETOUR IMMÉDIAT !
    }
  • Le code ne va jamais jusqu'à la vérification isThereANewLayer()
  • Aucun sync n'est tenté → layer reste en Idle

Conclusion

Le bug affecte UNIQUEMENT les repositories privés nécessitant des credentials, car :

  1. Le bug du cache empêche la récupération des credentials
  2. L'échec de récupération bloque toute tentative de sync
  3. Les repositories publics contournent le problème en utilisant getStandardGitNoAuth() sans erreur

Root Cause

Le contrôleur TerraformRepository a une logique de skip sync qui empêche la synchronisation si :

  1. Un layer existe déjà (len(layers) > 0)
  2. ET tous les layers ont l'annotation LastBranchCommit (!isThereANewLayer(layersForRef))
  3. ET le dernier sync est trop récent (!nextSyncTime.Before(now))

Quand le TerraformRepository et TerraformLayer sont créés en même temps :

  • Le repository fait une première tentative de sync avant que le layer existe
  • Cette tentative échoue ou est skippée (aucun layer trouvé)
  • Mais le branchState.LastSyncDate est quand même mis à jour
  • Quand le layer apparaît quelques millisecondes plus tard, le repository considère que le sync est "trop récent" et skip indéfiniment
  • Le layer n'obtient jamais l'annotation LastRelevantCommit nécessaire pour créer un run

Cercle vicieux :

  • Layer ne peut pas créer de run → manque annotation LastRelevantCommit
  • Repository ne se synchronise pas → layer existe sans annotation LastBranchCommit MAIS branchState.LastSyncDate est récent
  • La vérification isThereANewLayer() retourne true (layer sans annotation)
  • MAIS la vérification !nextSyncTime.Before(now) retourne false (sync trop récent)
  • → Skip sync pendant 30 minutes (timer repositorySync)

Code concerné

Fichier: internal/controllers/terraformrepository/controller.go

Lignes 114-122 : Le repository watch les créations de TerraformLayer

Watches(&configv1alpha1.TerraformLayer{}, handler.EnqueueRequestsFromMapFunc(
    func(ctx context.Context, obj client.Object) []reconcile.Request {
        log.Infof("repository controller has detected the following layer creation: %s/%s", obj.GetNamespace(), obj.GetName())
        layer := obj.(*configv1alpha1.TerraformLayer)
        return []reconcile.Request{
            {NamespacedName: types.NamespacedName{Namespace: layer.Spec.Repository.Namespace, Name: layer.Spec.Repository.Name}},
        }  // ⚠️ Déclenche une réconciliation du repository à chaque création de layer
    },
))

Lignes 126-130 : Le repository ignore les updates de layer (watch uniquement les créations)

UpdateFunc: func(e event.UpdateEvent) bool {
    // Ignore updates on TerraformLayer objects, we only watch their creation
    if _, ok := e.ObjectNew.(*configv1alpha1.TerraformLayer); ok {
        return false  // ❌ Ignore les updates, watch UNIQUEMENT les créations
    }

Conséquence : Quand un layer est créé, le repository passe en état SyncNeeded mais refuse de sync si LastSyncDate est récent.

Fichier: internal/controllers/terraformrepository/states.go

Lignes 61-65 : Le repository refuse de se synchroniser si aucun layer n'existe

if len(layers) == 0 {
    log.Warningf("no managed layers found for repository %s/%s, have you created TerraformLayer resources?", repository.Namespace, repository.Name)
    return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction}, []configv1alpha1.BranchState{}
}

Lignes 73-82 : Skip sync si le dernier sync est trop récent ET qu'il n'y a pas de nouveau layer

// Skip sync if last sync is too recent and no new layer
if !syncNow && !nextSyncTime.Before(now) && branch.LastSyncStatus == SyncStatusSuccess && !isThereANewLayer(layersForRef) {
    log.Infof("skipping sync for repository %s/%s ref %s: last sync was at %s and no new layer for this branch", 
        repository.Namespace, repository.Name, branch.Name, lastSync.Format(time.UnixDate))
    continue  // ⚠️ Skip la synchronisation !
}

Fichier: internal/controllers/terraformrepository/polling.go

Lignes 50-57 : Détection d'un "nouveau layer" (sans annotation LastBranchCommit)

func isThereANewLayer(layers []configv1alpha1.TerraformLayer) bool {
    for _, layer := range layers {
        if _, ok := layer.Annotations[annotations.LastBranchCommit]; !ok {
            return true  // ✅ Layer sans annotation = nouveau layer
        }
    }
    return false
}

Problème : Même si isThereANewLayer() retourne true, le sync est skip si !nextSyncTime.Before(now) (dernier sync < 30 minutes).

Fichier: internal/controllers/terraformlayer/states.go

Lignes 61-67 : Le layer ne peut pas créer de run sans l'annotation

func (s *PlanNeeded) getHandler() Handler {
	return func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) (ctrl.Result, *configv1alpha1.TerraformRun) {
		log := log.WithContext(ctx)
		// Check for sync windows that would block the apply action
		if isActionBlocked(r, layer, repository, syncwindow.PlanAction) {
			return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction}, nil
		}
		revision, ok := layer.Annotations[annotations.LastRelevantCommit]  // ⚠️ Bloque si l'annotation n'existe pas
		if !ok {
			r.Recorder.Event(layer, corev1.EventTypeWarning, "Reconciliation", "Layer has no last relevant commit annotation, Plan run not created")
			log.Errorf("layer %s has no last relevant commit annotation, run not created", layer.Name)
			return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError}, nil  // ⚠️ Requeue avec délai d'erreur (10s par défaut)
		}
		run := r.getRun(layer, revision, PlanAction)
		// ...

Lignes 90-96 : Même vérification pour Apply

func (s *ApplyNeeded) getHandler() Handler {
	return func(ctx context.Context, r *Reconciler, layer *configv1alpha1.TerraformLayer, repository *configv1alpha1.TerraformRepository) (ctrl.Result, *configv1alpha1.TerraformRun) {
		log := log.WithContext(ctx)
		autoApply := configv1alpha1.GetAutoApplyEnabled(repository, layer)
		if !autoApply {
			log.Infof("autoApply is disabled for layer %s, no apply action taken", layer.Name)
			return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.DriftDetection}, nil
		}
		// Check for sync windows that would block the apply action
		if isActionBlocked(r, layer, repository, syncwindow.ApplyAction) {
			return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.WaitAction}, nil
		}
		revision, ok := layer.Annotations[annotations.LastRelevantCommit]  // ⚠️ Bloque également pour Apply
		if !ok {
			r.Recorder.Event(layer, corev1.EventTypeWarning, "Reconciliation", "Layer has no last relevant commit annotation, Apply run not created")
			log.Errorf("layer %s has no last relevant commit annotation, run not created", layer.Name)
			return ctrl.Result{RequeueAfter: r.Config.Controller.Timers.OnError}, nil
		}
		// ...

Fichier: internal/annotations/annotations.go

Lignes 19-20 : Définition des annotations attendues

const (
	// ...
	LastRelevantCommit     string = "webhook.terraform.padok.cloud/relevant-commit"
	LastRelevantCommitDate string = "webhook.terraform.padok.cloud/relevant-commit-date"
	// ...
)

Séquence de création problématique

Cas 1 : Déploiement simultané (repository et layer créés en même temps)

  1. T0 : kubectl apply -f layer.yaml crée simultanément Secret, TerraformRepository, TerraformLayer
  2. T0+50ms : TerraformRepository contrôleur démarre sa réconciliation
  3. T0+60ms : TerraformRepository vérifie len(layers)0 (layer pas encore créé)
  4. T0+70ms : TerraformRepository skip sync et met à jour branchState.LastSyncDate = T0+70ms
  5. T0+100ms : TerraformLayer est créé dans Kubernetes
  6. T0+110ms : TerraformRepository détecte la création du layer (via Watch) et se met en état SyncNeeded
  7. T0+120ms : TerraformRepository vérifie len(layers)1
  8. T0+130ms : TerraformRepository vérifie isThereANewLayer()true (layer sans annotation) ✅
  9. T0+140ms : TerraformRepository vérifie !nextSyncTime.Before(now)false
    • nextSyncTime = T0+70ms + 30m = T0+30m+70ms
    • now = T0+140ms
    • T0+30m+70ms n'est PAS avant T0+140msSKIP SYNC
  10. T0+1m : TerraformRepository reste en SyncNeeded mais skip sync (même vérification)
  11. T0+2m : TerraformRepository skip sync (même vérification)
  12. ... : Repository reste en SyncNeeded et skip sync indéfiniment pendant 30 minutes
  13. T0+30m : TerraformRepository se synchronise ENFIN avec Git ✅
  14. T0+30m+1s : TerraformRepository ajoute l'annotation LastRelevantCommit au layer
  15. T0+30m+10s : TerraformLayer crée le run ✅

Cas 2 : Layer créé APRÈS synchronisation du repository (workaround qui échoue aussi !)

  1. T0 : kubectl apply -f repository.yaml crée le TerraformRepository
  2. T0+500ms : TerraformRepository se synchronise avec Git et passe en état Synced
  3. T0+600ms : branchState.LastSyncDate = T0+600ms
  4. T0+5s : kubectl apply -f layer.yaml crée le TerraformLayer
  5. T0+5.1s : TerraformRepository détecte la création du layer (via Watch) et se met en état SyncNeeded
  6. T0+5.2s : TerraformRepository vérifie isThereANewLayer()true (nouveau layer sans annotation) ✅
  7. T0+5.3s : TerraformRepository vérifie !nextSyncTime.Before(now)false
    • nextSyncTime = T0+600ms + 30m = T0+30m+600ms
    • now = T0+5.3s
    • T0+30m+600ms n'est PAS avant T0+5.3sSKIP SYNC
  8. T0+1m : TerraformRepository reste en SyncNeeded mais skip sync
  9. ... : Repository reste bloqué en SyncNeeded pendant ~30 minutes
  10. T0+30m : TerraformRepository se synchronise ENFIN ✅

Problème aggravant : Même le workaround "déployer le repository en premier" ne fonctionne pas si vous créez le layer dans les 30 minutes suivantes !

🧪 Reproduction

Scénario 1 : Repository PUBLIC (✅ FONCTIONNE - pas de bug)

Fichier : terraform-layer-demo.yaml

---
apiVersion: config.terraform.padok.cloud/v1alpha1
kind: TerraformRepository
metadata:
  name: my-repository
  namespace: burrito-project
spec:
  repository:
    url: https://github.com/padok-team/burrito-examples  # Repository PUBLIC
  terraform:
    enabled: true

---
apiVersion: config.terraform.padok.cloud/v1alpha1
kind: TerraformLayer
metadata:
  name: my-layer
  namespace: burrito-project
spec:
  branch: main
  path: terraform
  remediationStrategy:
    autoApply: false
  repository:
    name: my-repository
    namespace: burrito-project

Test - Déploiement simultané :

# Déployer tout en une fois
kubectl apply -f terraform-layer-demo.yaml

# Vérifier immédiatement l'état (< 10 secondes après apply)
watch -n 1 'kubectl get tfr,tfl -n burrito-project'

Résultat attendu :

NAME                                          STATE    AGE
terraformrepository.config.terraform.padok.cloud/my-repository   Synced   5s

NAME                                       STATE         LAST RESULT
terraformlayer.config.terraform.padok.cloud/my-layer      PlanNeeded    Layer has never been planned

Logs attendus :

time="..." level=info msg="repository my-repository is in sync with remote"
time="..." level=info msg="layer my-layer has an outdated plan, creating a new run"
time="..." level=info msg="Created TerraformRun for Plan action"

Pourquoi ça fonctionne ? Repository public → pas de credentials → utilise getStandardGitNoAuth() → pas d'erreur → sync immédiat ✅

Scénario 2 : Repository PRIVÉ avec credentials (❌ ÉCHOUE POTENTIELLEMENT pendant ~30 minutes)

Fichier : terraform-layer-drift-simple.yaml

---
apiVersion: v1
kind: Secret
metadata:
  name: gitlab-credentials-simple
  namespace: burrito-system
type: credentials.burrito.tf/repository
stringData:
  provider: gitlab
  url: https://gitlab.example.com/my-org/terraform-modules/my-module
  gitlabToken: "glpat-xxxxxxxxxxxxxxxxxxxx"
  webhookSecret: "my-webhook-secret"

---
apiVersion: config.terraform.padok.cloud/v1alpha1
kind: TerraformRepository
metadata:
  name: my-terraform-module-simple
  namespace: burrito-system
spec:
  repository:
    url: https://gitlab.example.com/my-org/terraform-modules/my-module
  terraform:
    enabled: true

---
apiVersion: config.terraform.padok.cloud/v1alpha1
kind: TerraformLayer
metadata:
  name: my-layer-drift-simple
  namespace: burrito-system
spec:
  branch: main
  remediationStrategy:
    autoApply: false
  repository:
    name: my-terraform-module-simple
    namespace: burrito-system

Test - Déploiement simultané (reproduit le bug potentiel) :

# Supprimer les ressources existantes si présentes
kubectl delete -f terraform-layer-drift-simple.yaml --ignore-not-found

# Attendre 2 secondes
sleep 2

# Déployer tout en une fois
kubectl apply -f terraform-layer-drift-simple.yaml

# Observer immédiatement (< 5 secondes)
kubectl get tfr,tfl -n burrito-system -w

Résultat attendu (si bug présent) :

# Pendant les premières 30 minutes :
NAME                                                      STATE        AGE
terraformrepository.../my-terraform-module-simple         SyncNeeded   10s

NAME                                                   STATE      LAST RESULT
terraformlayer.../my-layer-drift-simple                Idle       Layer has never been planned

# Après ~30 minutes (timer repositorySync expire) :
NAME                                                      STATE    AGE
terraformrepository.../my-terraform-module-simple         Synced   30m5s

NAME                                                   STATE         LAST RESULT
terraformlayer.../my-layer-drift-simple                PlanNeeded    Layer has an outdated plan

Logs attendus (si bug présent) :

# Vérifier les logs pendant les 30 premières secondes
kubectl logs -n burrito-system deployment/burrito-controllers --tail=100 | grep -A2 -B2 "my-layer-drift-simple\|my-terraform-module-simple"
time="..." level=info msg="no managed layers found for repository burrito-system/my-terraform-module-simple, have you created TerraformLayer resources?"
time="..." level=info msg="repository burrito-system/my-terraform-module-simple is in state SyncNeeded"
time="..." level=info msg="layer my-layer-drift-simple has no last relevant commit annotation, run not created"
time="..." level=info msg="skipping sync for repository burrito-system/my-terraform-module-simple ref main: last sync was at ... and no new layer for this branch"

Vérifier les annotations du layer :

kubectl get tfl my-layer-drift-simple -n burrito-system -o jsonpath='{.metadata.annotations}' | jq

Résultat attendu (si bug présent) :

{}  // Aucune annotation pendant ~30 minutes

Résumé des scénarios de reproduction

Scénario Type de repo Déploiement Résultat attendu Délai avant sync
1 - Demo Public GitHub Simultané ✅ Fonctionne ~5 secondes
2 - Drift Simple Privé GitLab Simultané ❌ Bug potentiel ~30 minutes

🔍 Preuve du bug

Timeline des événements (déploiement simultané repository privé)

Test de reproduction :

# Supprimer les ressources existantes
kubectl delete -f terraform-layer-drift-simple.yaml --ignore-not-found

# Attendre 2 secondes
sleep 2

# Déployer tout en une fois avec horodatage
kubectl apply -f terraform-layer-drift-simple.yaml && date

# Observer les events en temps réel
kubectl get events -n burrito-system -w --field-selector involvedObject.kind=TerraformLayer

Logs attendus (si bug présent) :

T+0.1s   Normal    Reconciliation   Repository my-terraform-module-simple: no managed layers found
T+0.2s   Warning   Reconciliation   Layer my-layer-drift-simple: has no last relevant commit annotation
T+0.5s   Normal    Reconciliation   Repository my-terraform-module-simple: needs to be synced
T+1.0s   Normal    Reconciliation   Repository: skipping sync (last sync too recent and no new layer)
T+10.0s  Warning   Reconciliation   Layer my-layer-drift-simple: has no last relevant commit annotation
T+20.0s  Normal    Reconciliation   Repository: skipping sync (last sync too recent)
... (continue pendant ~30 minutes)
T+30m    Normal    Reconciliation   Repository my-terraform-module-simple: is in sync with remote
T+30m+5s Normal    Reconciliation   Layer my-layer-drift-simple: Created TerraformRun for Plan  ✅

⏱️ Impact sur le déploiement

Délai observé

Scénario Délai avant création du run État
Repository public (demo) ~5 secondes ✅ Fonctionne toujours
Repository privé - Déploiement simultané ~30 minutes ❌ Bug potentiel - Bloqué jusqu'au timer

Configuration du timer

Fichier: install-values.yaml

config:
  burrito:
    controller:
      timers:
        onError: 10s  # ⚠️ Délai de retry quand l'annotation est manquante
        waitAction: 1m
        driftDetection: 10m

🔗 Références

  • Controller TerraformLayer : internal/controllers/terraformlayer/states.go
  • Controller TerraformRepository : internal/controllers/terraformrepository/controller.go
  • Annotations : internal/annotations/annotations.go

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Prioritized

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions