From 5c9df73a6686ba3f97275dccc373951c7795c7cd Mon Sep 17 00:00:00 2001 From: moilanik Date: Sun, 21 Jun 2026 16:10:41 +0300 Subject: [PATCH] =?UTF-8?q?cucumber=20testej=C3=A4=20lis=C3=A4=C3=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/gitops-update.sh | 104 +++++++++---- tests/features/gitops-update.feature | 49 ++++-- .../step_definitions/gitops-update.steps.js | 140 ++++++++++++++---- tests/helpers/git | 7 + 4 files changed, 232 insertions(+), 68 deletions(-) diff --git a/scripts/gitops-update.sh b/scripts/gitops-update.sh index bf7a0ad..3537d04 100755 --- a/scripts/gitops-update.sh +++ b/scripts/gitops-update.sh @@ -1,45 +1,87 @@ #!/usr/bin/env bash set -euo pipefail -INPUT_FILE="${INPUT_FILE:?}" -YQ_TPL="${YQ_TPL:?}" -VERSION="${VERSION:?}" -SOURCE_REPO="${SOURCE_REPO:?}" -SOURCE_COMMIT="${SOURCE_COMMIT:?}" -GITOPS_REPO="${GITOPS_REPO:?}" -GITEA_TOKEN="${GITEA_TOKEN:?}" -GITEA_API_URL="${GITEA_API_URL:?}" -GITOPS_BRANCH="${GITOPS_BRANCH:-main}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_gitops_fail() { + local MSG="${1:-GitOps update failed}" + echo "[ERROR] ${MSG}" >&2 + + if [ -n "${SOURCE_REPO:-}" ] && [ -n "${SOURCE_COMMIT:-}" ] && \ + [ -n "${GITEA_API_URL:-}" ] && [ -n "${GITEA_TOKEN:-}" ]; then + local GITOPS_URL="${GITEA_API_URL}/${GITOPS_REPO:-unknown}/commits/${GITOPS_SHA:-unknown}" + ROOT_REPO="${SOURCE_REPO}" ROOT_COMMIT="${SOURCE_COMMIT}" \ + GITEA_API_URL="${GITEA_API_URL}" GITEA_TOKEN="${GITEA_TOKEN}" \ + bash "${SCRIPT_DIR}/report-status.sh" failure "${MSG}" \ + "gitops/${SOURCE_REPO}" "" "${GITOPS_URL}" 2>/dev/null || true + + if [ -n "${GITOPS_REPO:-}" ] && [ -n "${GITOPS_SHA:-}" ]; then + local SOURCE_URL="${GITEA_API_URL}/${SOURCE_REPO}/commits/${SOURCE_COMMIT}" + ROOT_REPO="${GITOPS_REPO}" ROOT_COMMIT="${GITOPS_SHA}" \ + GITEA_API_URL="${GITEA_API_URL}" GITEA_TOKEN="${GITEA_TOKEN}" \ + bash "${SCRIPT_DIR}/report-status.sh" failure "${MSG}" \ + "source/${SOURCE_REPO}" "" "${SOURCE_URL}" 2>/dev/null || true + fi + fi + + exit 1 +} + +_gitops_validate() { + [ -n "${INPUT_FILE:-}" ] || _gitops_fail "INPUT_FILE is required" + [ -n "${YQ_TPL:-}" ] || _gitops_fail "YQ_TPL is required" + [ -n "${VERSION:-}" ] || _gitops_fail "VERSION is required" + [ -n "${SOURCE_REPO:-}" ] || _gitops_fail "SOURCE_REPO is required" + [ -n "${SOURCE_COMMIT:-}" ] || _gitops_fail "SOURCE_COMMIT is required" + [ -n "${GITOPS_REPO:-}" ] || _gitops_fail "GITOPS_REPO is required" + [ -n "${GITEA_TOKEN:-}" ] || _gitops_fail "GITEA_TOKEN is required" + [ -n "${GITEA_API_URL:-}" ] || _gitops_fail "GITEA_API_URL is required" +} + +_gitops_success() { + local GITOPS_URL="${GITEA_API_URL}/${GITOPS_REPO}/commits/${GITOPS_SHA}" + local SOURCE_URL="${GITEA_API_URL}/${SOURCE_REPO}/commits/${SOURCE_COMMIT}" + + ROOT_REPO="${SOURCE_REPO}" ROOT_COMMIT="${SOURCE_COMMIT}" \ + GITEA_API_URL="${GITEA_API_URL}" GITEA_TOKEN="${GITEA_TOKEN}" \ + bash "${SCRIPT_DIR}/report-status.sh" success "GitOps updated to ${VERSION}" \ + "gitops/${SOURCE_REPO}" "" "${GITOPS_URL}" + + ROOT_REPO="${GITOPS_REPO}" ROOT_COMMIT="${GITOPS_SHA}" \ + GITEA_API_URL="${GITEA_API_URL}" GITEA_TOKEN="${GITEA_TOKEN}" \ + bash "${SCRIPT_DIR}/report-status.sh" success "Source build" \ + "source/${SOURCE_REPO}" "" "${SOURCE_URL}" +} _gitops_substitute() { echo "$1" | sed "s/{{VERSION}}/$2/g" } +_gitops_update() { + local CLONE_DIR="${GITOPS_TARGET_DIR:-$(mktemp -d)}" + + if [ -n "${GITOPS_CLONE_URL:-}" ]; then + git clone "${GITOPS_CLONE_URL}" "${CLONE_DIR}" || _gitops_fail "Failed to clone GitOps repo" + else + git clone "${CLONE_URL}" "${CLONE_DIR}" || _gitops_fail "Failed to clone GitOps repo" + fi + + cd "${CLONE_DIR}" || _gitops_fail "Failed to enter clone directory" + yq eval -i "${YQ_EXPR}" "${INPUT_FILE}" || _gitops_fail "Failed to update ${INPUT_FILE}" + git add "${INPUT_FILE}" || _gitops_fail "Failed to stage ${INPUT_FILE}" + git commit -m "[skip ci] gitops: update version to ${VERSION}" || _gitops_fail "Failed to commit" + GITOPS_SHA="$(git rev-parse HEAD)" + git push || _gitops_fail "Failed to push" + + _gitops_success +} + +_gitops_validate + YQ_EXPR=$(_gitops_substitute "${YQ_TPL}" "${VERSION}") GITEA_HOST=$(echo "${GITEA_API_URL}" | sed 's|https://||' | sed 's|http://||') -CLONE_URL="https://${GITEA_TOKEN}@${GITEA_HOST}/${GITOPS_REPO}.git" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -_gitops_update() { - CLONE_DIR=$(mktemp -d) - git clone "$CLONE_URL" "$CLONE_DIR" - cd "$CLONE_DIR" - yq eval -i "$YQ_EXPR" "$INPUT_FILE" - git add "$INPUT_FILE" - git commit -m "[skip ci] gitops: update version to ${VERSION}" - GITOPS_SHA=$(git rev-parse HEAD) - git push - ROOT_REPO="${SOURCE_REPO}" ROOT_COMMIT="${SOURCE_COMMIT}" \ - bash "${SCRIPT_DIR}/report-status.sh" success "GitOps updated to ${VERSION}" \ - "gitops/${SOURCE_REPO}" "" \ - "${GITEA_API_URL}/${GITOPS_REPO}/commits/${GITOPS_SHA}" - ROOT_REPO="${GITOPS_REPO}" ROOT_COMMIT="${GITOPS_SHA}" \ - bash "${SCRIPT_DIR}/report-status.sh" success "Source build" \ - "source/${SOURCE_REPO}" "" \ - "${GITEA_API_URL}/${SOURCE_REPO}/commits/${SOURCE_COMMIT}" -} +CLONE_URL="${GITOPS_CLONE_URL:-https://${GITEA_TOKEN}@${GITEA_HOST}/${GITOPS_REPO}.git}" if [ "${BASH_SOURCE[0]}" = "${0}" ]; then _gitops_update diff --git a/tests/features/gitops-update.feature b/tests/features/gitops-update.feature index 303647c..409df30 100644 --- a/tests/features/gitops-update.feature +++ b/tests/features/gitops-update.feature @@ -1,15 +1,44 @@ -Feature: GitOps version update - As a developer - I want to automatically update version references in a GitOps repo - So that deployment is triggered with the correct artifact version +Feature: GitOps update + As a GitOps repository + I want to update version references and report results back to the caller + So that the deployment chain is traceable from source to GitOps commit Background: Given a project repository exists in Gitea And a commit has been pushed to the repository - @mock @real - Scenario: GitOps repo receives version bump dispatch - When a build completes successfully and dispatches a GitOps update - Then the GitOps repo has a new commit with the updated version - And the code repo shows a gitops status link to the GitOps commit - And the GitOps repo shows a source status link to the code commit + @mock + Scenario: Not enough env vars — caller commit gets failure status + Given insufficient environment variables are provided for the GitOps update + When the GitOps update script runs + Then the caller commit shows a failure status with the missing variable name + And the script exits with error + + @mock + Scenario: GitOps job fails — caller commit gets failure status + Given the GitOps repository clone will fail + When the GitOps update script runs + Then the caller commit shows a failure status + And the script exits with error + + @mock + Scenario: Everything succeeds — caller and GitOps get success + Given a valid GitOps update dispatch + When the GitOps update script runs + Then the script exits successfully + And the caller commit shows a success status with a link to the GitOps commit + And the GitOps repo commit shows a success status with a link to the caller + + @mock + Scenario: GitOps push fails — both repos get failure status + Given the GitOps repo push will fail after the version is committed + When the GitOps update script runs + Then the script exits with error + And the caller commit shows a failure status + And the GitOps repo commit shows a failure status linking to the caller + + @mock + Scenario: GitOps update succeeds — this repo commit status links to caller + Given a valid GitOps update dispatch + When the GitOps update script runs + Then the GitOps repo commit shows a source context status linking to the caller commit diff --git a/tests/features/step_definitions/gitops-update.steps.js b/tests/features/step_definitions/gitops-update.steps.js index 71f71cf..f466833 100644 --- a/tests/features/step_definitions/gitops-update.steps.js +++ b/tests/features/step_definitions/gitops-update.steps.js @@ -1,10 +1,26 @@ const { execSync } = require('child_process'); -const { When, Then } = require('@cucumber/cucumber'); +const { Before, When, Then } = require('@cucumber/cucumber'); const path = require('path'); const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); const MOCK_SCRIPT = path.join(PROJECT_ROOT, 'tests', 'helpers', 'mock-api.sh'); const GITOPS_SCRIPT = path.join(PROJECT_ROOT, 'scripts', 'gitops-update.sh'); +const MOCK_HELPERS = path.join(PROJECT_ROOT, 'tests', 'helpers'); + +const BASE_ENV = { + INPUT_FILE: 'dev/Chart.yaml', + YQ_TPL: '(.version) = "{{VERSION}}"', + VERSION: '0.2.3', + SOURCE_REPO: 'niko/app', + SOURCE_COMMIT: 'abc123def456', + GITOPS_REPO: 'niko/app-gitops', + GITEA_API_URL: 'http://localhost:18080', + GITEA_TOKEN: 'test-token', +}; + +Before({ tags: '@mock' }, function () { + process.env.PATH = `${MOCK_HELPERS}:${process.env.PATH}`; +}); function bash(cmd) { try { @@ -13,7 +29,7 @@ function bash(cmd) { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); - return { status: 0, stdout: out }; + return { status: 0, stdout: out, stderr: '' }; } catch (e) { return { status: e.status, stdout: e.stdout || '', stderr: e.stderr || '' }; } @@ -35,37 +51,107 @@ function getLastPath() { return bash(`source "${MOCK_SCRIPT}" && _get_request_file && mock_get_request_path`).stdout.trim(); } -When('a build completes successfully and dispatches a GitOps update', function () { - const env = [ - 'INPUT_FILE=dev/Chart.yaml', - 'YQ_TPL=\'(.version) = "{{VERSION}}"\'', - 'VERSION=0.2.3', - 'SOURCE_REPO=niko/app', - 'SOURCE_COMMIT=abc123def456', - 'GITOPS_REPO=niko/app-gitops', - 'GITEA_API_URL=http://localhost:18080', - 'GITEA_TOKEN=test-token', - ].join(' '); - const r = bash(`${env} bash "${GITOPS_SCRIPT}"`); - if (r.status !== 0) throw new Error(`Expected exit 0, got ${r.status}: ${r.stderr}`); +function requestCount() { + const rf = bash(`source "${MOCK_SCRIPT}" && _get_request_file`).stdout.trim(); + const count = bash(`grep -c '^POST ' "${rf}" 2>/dev/null || echo 0`).stdout.trim(); + return parseInt(count, 10) || 0; +} + +function runScript(envOverrides) { + const env = { ...BASE_ENV, ...envOverrides }; + const envStr = Object.entries(env) + .map(([k, v]) => { + const escaped = v.replace(/'/g, "'\\''"); + return `${k}='${escaped}'`; + }) + .join(' '); + return bash(`${envStr} bash "${GITOPS_SCRIPT}"`); +} + +Given('insufficient environment variables are provided for the GitOps update', function () { + this.missingVar = 'INPUT_FILE'; + this.envOverrides = {}; + this.envOverrides.INPUT_FILE = ''; }); -Then('the GitOps repo has a new commit with the updated version', function () { - const out = bash(`git -C /tmp log --oneline -1 2>/dev/null || echo "no-git-log"`); +Given('the GitOps repository clone will fail', function () { + this.envOverrides = { GIT_MOCK_FAIL: '1' }; }); -Then('the code repo shows a gitops status link to the GitOps commit', function () { +Given('a valid GitOps update dispatch', function () { + this.envOverrides = {}; +}); + +Given('the GitOps repo push will fail after the version is committed', function () { + this.envOverrides = { GIT_MOCK_FAIL_PUSH: '1' }; +}); + +When('the GitOps update script runs', function () { + this.result = runScript(this.envOverrides || {}); +}); + +Then('the caller commit shows a failure status with the missing variable name', function () { const body = getFirstBody(); - if (!body.includes('"state":"success"')) throw new Error('Expected success status'); - if (!body.includes('"context":"gitops/niko/app"')) throw new Error('Expected gitops context'); + if (!body.includes('"state":"failure"')) throw new Error(`Expected failure state, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"gitops/niko/app"')) throw new Error(`Expected gitops context, body: ${body.substring(0,200)}`); + if (!body.includes(`"description":"${this.missingVar} is required`)) { + throw new Error(`Expected description mentioning ${this.missingVar}, body: ${body.substring(0,200)}`); + } const pathStr = getFirstPath(); - if (!pathStr.includes('/repos/niko/app/statuses/')) throw new Error('Expected source repo status path'); + if (!pathStr.includes('/repos/niko/app/statuses/')) throw new Error(`Expected source repo path, got: ${pathStr}`); }); -Then('the GitOps repo shows a source status link to the code commit', function () { - const body = getLastBody(); - if (!body.includes('"state":"success"')) throw new Error('Expected success status'); - if (!body.includes('"context":"source/niko/app"')) throw new Error('Expected source context'); - const pathStr = getLastPath(); - if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error('Expected gitops repo status path'); +Then('the caller commit shows a failure status', function () { + const body = getFirstBody(); + if (!body.includes('"state":"failure"')) throw new Error(`Expected failure state, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"gitops/niko/app"')) throw new Error(`Expected gitops context, body: ${body.substring(0,200)}`); + const pathStr = getFirstPath(); + if (!pathStr.includes('/repos/niko/app/statuses/')) throw new Error(`Expected source repo path, got: ${pathStr}`); +}); + +Then('the script exits with error', function () { + if (this.result.status === 0) throw new Error(`Expected non-zero exit, got 0. stderr: ${this.result.stderr}`); +}); + +Then('the script exits successfully', function () { + if (this.result.status !== 0) throw new Error(`Expected exit 0, got ${this.result.status}: ${this.result.stderr}`); +}); + +Then('the caller commit shows a success status with a link to the GitOps commit', function () { + const body = getFirstBody(); + if (!body.includes('"state":"success"')) throw new Error(`Expected success state, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"gitops/niko/app"')) throw new Error(`Expected gitops context, body: ${body.substring(0,200)}`); + if (!body.includes('niko/app-gitops/commits/')) throw new Error(`Expected link to GitOps commit, body: ${body.substring(0,200)}`); + const pathStr = getFirstPath(); + if (!pathStr.includes('/repos/niko/app/statuses/')) throw new Error(`Expected source repo path, got: ${pathStr}`); +}); + +Then('the GitOps repo commit shows a success status with a link to the caller', function () { + if (requestCount() < 2) throw new Error(`Expected at least 2 requests, got ${requestCount()}`); + const body = getLastBody(); + if (!body.includes('"state":"success"')) throw new Error(`Expected success state, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"source/niko/app"')) throw new Error(`Expected source context, body: ${body.substring(0,200)}`); + if (!body.includes('niko/app/commits/abc123def456')) throw new Error(`Expected link to caller commit, body: ${body.substring(0,200)}`); + const pathStr = getLastPath(); + if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error(`Expected gitops repo path, got: ${pathStr}`); +}); + +Then('the GitOps repo commit shows a failure status linking to the caller', function () { + if (requestCount() < 2) throw new Error(`Expected at least 2 requests, got ${requestCount()}`); + const body = getLastBody(); + if (!body.includes('"state":"failure"')) throw new Error(`Expected failure state, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"source/niko/app"')) throw new Error(`Expected source context, body: ${body.substring(0,200)}`); + if (!body.includes('niko/app/commits/abc123def456')) throw new Error(`Expected link to caller commit, body: ${body.substring(0,200)}`); + const pathStr = getLastPath(); + if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error(`Expected gitops repo path, got: ${pathStr}`); +}); + +Then('the GitOps repo commit shows a source context status linking to the caller commit', function () { + if (requestCount() < 2) throw new Error(`Expected at least 2 requests, got ${requestCount()}`); + const body = getLastBody(); + if (!body.includes('"state":"success"')) throw new Error(`Expected success state, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"source/niko/app"')) throw new Error(`Expected source context, body: ${body.substring(0,200)}`); + if (!body.includes('niko/app/commits/abc123def456')) throw new Error(`Expected link to caller, body: ${body.substring(0,200)}`); + const pathStr = getLastPath(); + if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error(`Expected gitops repo path, got: ${pathStr}`); }); diff --git a/tests/helpers/git b/tests/helpers/git index 37ac5d9..3afe3ed 100755 --- a/tests/helpers/git +++ b/tests/helpers/git @@ -1,6 +1,13 @@ #!/usr/bin/env bash echo "git $*" >> "${GIT_CALLS_FILE:-/dev/null}" +[ -z "${GIT_MOCK_FAIL:-}" ] || { echo "git: mock forced failure" >&2; exit 1; } + +if [ "${1:-}" = "push" ] && [ -n "${GIT_MOCK_FAIL_PUSH:-}" ]; then + echo "git: mock push failure" >&2 + exit 1 +fi + case "$1" in clone) TARGET_DIR="${@: -1}"