// NACEF / kaissflow.com delivery pipeline.
//
// Jenkins runs on the same VPS as the stack and drives it through the Docker
// socket, so "deploy" is a local operation: fast-forward /opt/nacef/app to the
// commit that was just tested, then run deploy/scripts/deploy.sh.
//
// Push to master -> open jenkins.kaissflow.com -> Build Now.
// Set up the GitLab webhook and the push alone is enough.

pipeline {
  agent any

  options {
    timestamps()
    ansiColor('xterm')
    // Two deploys at once would fight over the same containers and databases.
    disableConcurrentBuilds()
    buildDiscarder(logRotator(numToKeepStr: '3', artifactNumToKeepStr: '1'))
    timeout(time: 90, unit: 'MINUTES')
  }

  triggers {
    // Webhook: GitLab > Settings > Webhooks > https://jenkins.kaissflow.com/project/nacef
    gitlab(triggerOnPush: true, triggerOnMergeRequest: false, branchFilterType: 'All')
  }

  parameters {
    booleanParam(name: 'RUN_TESTS', defaultValue: true,
                 description: 'Run the module test suite in a throwaway database.')
    booleanParam(name: 'SKIP_BACKUP', defaultValue: false,
                 description: 'Skip the pre-deploy pg_dump. Hotfixes only.')
    string(name: 'ONLY_DB', defaultValue: '',
           description: 'Update a single database instead of all of them. Blank = all.')
    booleanParam(name: 'AUTO_ROLLBACK', defaultValue: false,
                 description: 'Restore the pre-deploy backup automatically if the deploy fails.')
  }

  environment {
    APP_DIR    = '/opt/nacef/app'
    DEPLOY_DIR = '/opt/nacef/app/deploy'
    ODOO_IMAGE = 'odoo:17'
    // Every nacef module, in dependency order, for the test install.
    ALL_MODULES = 'nacef_smdf_client,nacef_theme,nacef_fiscal_core,nacef_pos,nacef_cockpit,nacef_control_plane'
  }

  stages {

    stage('Checkout') {
      steps {
        checkout scm
        script {
          env.GIT_SHA   = sh(returnStdout: true, script: 'git rev-parse HEAD').trim()
          env.GIT_SHORT = env.GIT_SHA.take(8)
          env.GIT_MSG   = sh(returnStdout: true, script: 'git log -1 --pretty=%s').trim()

          // BRANCH_NAME only exists in a multibranch job. A plain
          // "Pipeline script from SCM" job sets GIT_BRANCH instead, and
          // `when { branch 'master' }` would quietly evaluate false there —
          // a green build that deployed nothing. Resolve it once, explicitly.
          def b = env.BRANCH_NAME ?: env.GIT_BRANCH ?: ''
          b = b.replaceFirst(/^origin\//, '')
          if (!b || b == 'HEAD') {
            b = sh(returnStdout: true, script:
                   "git branch -r --contains HEAD --format='%(refname:lstrip=3)' | head -1").trim()
          }
          env.DEPLOY_BRANCH = b

          currentBuild.description = "${b} ${env.GIT_SHORT} ${env.GIT_MSG}"
          echo "branch=${b} sha=${env.GIT_SHA}"
          if (b != 'master') {
            echo "Not master: static checks and tests only, nothing will be deployed."
          }
        }
      }
    }

    stage('Static checks') {
      steps {
        sh '''
          set -e
          echo "--- python syntax"
          docker run --rm -v "$WORKSPACE":/src -w /src python:3.11-slim \
            python -m compileall -q addons tools

          echo "--- xml well-formedness"
          docker run --rm -v "$WORKSPACE":/src -w /src python:3.11-slim \
            python - <<'PY'
import sys, pathlib, xml.etree.ElementTree as ET
bad = []
for p in pathlib.Path('addons').rglob('*.xml'):
    try:
        ET.parse(p)
    except ET.ParseError as e:
        bad.append(f"{p}: {e}")
if bad:
    print("\\n".join(bad)); sys.exit(1)
print("ok")
PY

          echo "--- manifests are importable"
          docker run --rm -v "$WORKSPACE":/src -w /src python:3.11-slim \
            python - <<'PY'
import ast, pathlib, sys
for m in sorted(pathlib.Path('addons').glob('*/__manifest__.py')):
    d = ast.literal_eval(m.read_text())
    assert 'depends' in d, f"{m}: no depends"
    print(f"  {m.parent.name}: {d.get('version','?')}")
PY
        '''
      }
    }

    stage('Tests') {
      when { expression { params.RUN_TESTS } }
      steps {
        script {
          // Throwaway network + postgres, torn down in post regardless.
          env.TEST_NET = "nacef-ci-${env.BUILD_NUMBER}"
          env.TEST_DB_C = "nacef-ci-db-${env.BUILD_NUMBER}"
        }
        sh '''
          set -e
          docker network create "$TEST_NET"
          docker run -d --name "$TEST_DB_C" --network "$TEST_NET" \
            -e POSTGRES_USER=odoo -e POSTGRES_PASSWORD=odoo -e POSTGRES_DB=postgres \
            postgres:16

          for i in $(seq 1 60); do
            docker exec "$TEST_DB_C" pg_isready -U odoo >/dev/null 2>&1 && break
            sleep 1
          done

          # Install every module into a fresh database with tests enabled.
          # odoo exits non-zero when a test fails, which fails the stage.
          docker run --rm --network "$TEST_NET" \
            -e HOST="$TEST_DB_C" -e USER=odoo -e PASSWORD=odoo \
            -v "$WORKSPACE/addons":/mnt/extra-addons:ro \
            "$ODOO_IMAGE" \
            odoo -d ci_$BUILD_NUMBER \
                 --addons-path=/mnt/extra-addons,/usr/lib/python3/dist-packages/odoo/addons \
                 -i "$ALL_MODULES" \
                 --test-enable --test-tags /nacef_smdf_client,/nacef_fiscal_core,/nacef_pos,/nacef_cockpit,/nacef_control_plane \
                 --without-demo=all \
                 --log-level=test \
                 --stop-after-init
        '''
      }
      post {
        always {
          sh '''
            docker rm -f "$TEST_DB_C" >/dev/null 2>&1 || true
            docker network rm "$TEST_NET" >/dev/null 2>&1 || true
          '''
        }
      }
    }

    stage('Publish code') {
      when { expression { env.DEPLOY_BRANCH == 'master' } }
      steps {
        sh '''
          set -e
          # Seed the deployed checkout from the workspace, not from the remote.
          # The remote needs credentials that only exist inside this job's
          # credential binding, and re-fetching origin could also pick up a
          # branch tip that advanced while the tests were running. Cloning
          # locally pins it to the exact commit that just passed.
          if [ ! -d "$APP_DIR/.git" ]; then
            mkdir -p "$(dirname "$APP_DIR")"
            git clone --no-hardlinks "$WORKSPACE" "$APP_DIR"
          fi
          git -C "$APP_DIR" fetch --no-tags --force "$WORKSPACE" \
            "+HEAD:refs/remotes/jenkins/deployed"
          git -C "$APP_DIR" checkout --force "$GIT_SHA"
          git -C "$APP_DIR" clean -fd addons tools
          echo "deployed commit: $(git -C "$APP_DIR" rev-parse --short HEAD)"
        '''
      }
    }

    stage('Deploy') {
      when { expression { env.DEPLOY_BRANCH == 'master' } }
      steps {
        script {
          def args = []
          if (params.SKIP_BACKUP)      { args << '--no-backup' }
          if (params.ONLY_DB?.trim())  { args << "--db ${params.ONLY_DB.trim()}" }
          env.DEPLOY_ARGS = args.join(' ')
          // Lets the post block tell "failed before touching the server" from
          // "failed mid-deploy", which are very different situations.
          env.DEPLOY_STARTED = 'yes'
        }
        sh '''
          set -e
          # Stamp the commit into the backup dir so rollback.sh can put the
          # code back where the dumps came from.
          . "$DEPLOY_DIR/.env"
          mkdir -p "$BACKUP_DIR"

          bash "$DEPLOY_DIR/scripts/deploy.sh" $DEPLOY_ARGS

          if [ -f "$BACKUP_DIR/LAST_GOOD" ]; then
            echo "$GIT_SHA" > "$BACKUP_DIR/$(cat "$BACKUP_DIR/LAST_GOOD")/COMMIT"
          fi
        '''
      }
    }

    stage('Verify') {
      when { expression { env.DEPLOY_BRANCH == 'master' } }
      steps {
        sh '''
          set -e
          echo "--- TLS + routing through nginx"
          curl -fsS -o /dev/null -w 'admin  %{http_code}\\n' https://admin.kaissflow.com/web/login

          echo "--- container health"
          docker compose -f "$DEPLOY_DIR/docker-compose.yml" ps
        '''
      }
    }
  }

  post {
    failure {
      script {
        // Nothing on the server was touched unless the Deploy stage started,
        // so there is nothing to roll back and no .env to read. Saying
        // otherwise sends people chasing a deploy that never happened.
        if (env.DEPLOY_STARTED != 'yes') {
          echo "Build failed at ${env.GIT_SHORT} before the deploy stage. The server is untouched."
        } else if (params.AUTO_ROLLBACK && env.DEPLOY_BRANCH == 'master') {
          sh '''
            set -e
            if [ ! -r "$DEPLOY_DIR/.env" ] || [ ! -f "$BACKUP_DIR/LAST_GOOD" ]; then
              echo "No backup to roll back to. Leaving the server as-is."
              exit 0
            fi
            . "$DEPLOY_DIR/.env"
            STAMP="$(cat "$BACKUP_DIR/LAST_GOOD")"
            # rollback.sh prompts interactively; feed it the confirmation.
            echo "$STAMP" | bash "$DEPLOY_DIR/scripts/rollback.sh" "$STAMP"
          '''
        } else {
          echo """Deploy failed at ${env.GIT_SHORT} after it had begun changing the server.
To roll back by hand:
    ${env.DEPLOY_DIR}/scripts/rollback.sh"""
        }
      }
    }
    always {
      sh 'docker system prune -f --filter "until=168h" >/dev/null 2>&1 || true'
    }
  }
}
