Feature/gitops #37
+73
-31
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user