Feature/gitops #37

Merged
niko merged 23 commits from feature/gitops into main 2026-06-22 10:37:15 +03:00
4 changed files with 232 additions and 68 deletions
Showing only changes of commit 5c9df73a66 - Show all commits
+73 -31
View File
@@ -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
+39 -10
View File
@@ -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
@@ -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}`);
});
+7
View File
@@ -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}"