diff --git a/docs/config-model.md b/docs/config-model.md index 0f7503f..8288206 100644 --- a/docs/config-model.md +++ b/docs/config-model.md @@ -62,7 +62,7 @@ organization/repository secrets -mekanismissa ja välitetään workflowlle | Secret | Pakollinen | Käyttäjä | |---|---|---| -| `GITEA_TOKEN` | Kyllä | `report-status.sh`, `check-version.yml`, `docker-build-push.yml` | +| `GITEA_TOKEN` | Kyllä | `report-status.sh`, `check-version.yml`, `docker-build-push.yml`, `gitops-update.sh` | | `GIT_PAGES_PUBLISH_TOKEN` | Kyllä | `publish-git-pages.sh`, `config-provider.yml` (validointi) | | `DOCKER_USERNAME` | Ei | `docker-build-push.yml` (oletus: `github.actor`, ei pakollinen kaikissa registryissä) | | `DOCKER_PASSWORD` | Kyllä | `docker-build-push.yml` | diff --git a/docs/workflows.md b/docs/workflows.md index 1ab629e..a9de492 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -182,7 +182,51 @@ Forward-compatibeli — ei haittaa vanhemmilla Gitea-versioilla. --- -## Suunnitteilla +## Provider-skriptit -- `deploy.yml` — GitOps-deployment (dispatch-workflow.sh-pohjainen) -- `test.yml` — Klusteritason test flow +### `gitops-update.sh` — GitOps-version päivitys + +**Riippuvuudet:** `yq`, `scripts/report-status.sh`, `git` + +Päivittää GitOps-repon konfiguraatiotiedoston versionumeron `yq`:lla, +committaa muutoksen ja asettaa commit-statuksen molempiin repoihin. + +**Input-ympäristömuuttujat:** + +| Muuttuja | Pakollinen | Kuvaus | +|---|---|---| +| `INPUT_FILE` | Kyllä | Tiedosto GitOps-repossa (esim. `dev/Chart.yaml`) | +| `YQ_TPL` | Kyllä | `yq`-lauseke `{{VERSION}}`-placeholderilla | +| `VERSION` | Kyllä | Uusi versio (esim. `0.2.3`) | +| `SOURCE_REPO` | Kyllä | Lähdekoodirepo (esim. `org/app`) | +| `SOURCE_COMMIT` | Kyllä | Lähdekoodin commit-SHA | +| `GITOPS_REPO` | Kyllä | GitOps-konfiguraatiorepo (esim. `org/app-gitops`) | +| `GITEA_API_URL` | Kyllä | Gitean API-URL | +| `GITEA_TOKEN` | Kyllä | Gitea API-token | +| `GITOPS_BRANCH` | Ei | GitOps-repon branch (oletus `main`) | + +**Steppikuvaus:** +1. Korvaa `YQ_TPL`:n `{{VERSION}}` versiolla +2. Muodostaa `CLONE_URL` tokenilla ja hostilla +3. Kloonaa GitOps-repon +4. Ajaa `yq eval -i` päivittääkseen tiedoston +5. Commit + push `[skip ci]` +6. Asettaa commit-statuksen: code-repoon (gitops-konteksti) ja GitOps-repoon (source-konteksti) + +**Esimerkki dispatchistä:** +```yaml +- name: Update GitOps + run: | + export INPUT_FILE=dev/Chart.yaml + export YQ_TPL='(.version) = "{{VERSION}}"' + export VERSION=0.2.3 + export SOURCE_REPO=org/app + export SOURCE_COMMIT=${{ github.sha }} + export GITOPS_REPO=org/app-gitops + bash scripts/gitops-update.sh + env: + GITEA_API_URL: ${{ vars.GITEA_API_URL }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} +``` + +--- diff --git a/scripts/gitops-update.sh b/scripts/gitops-update.sh new file mode 100755 index 0000000..bf7a0ad --- /dev/null +++ b/scripts/gitops-update.sh @@ -0,0 +1,46 @@ +#!/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}" + +_gitops_substitute() { + echo "$1" | sed "s/{{VERSION}}/$2/g" +} + +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}" +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + _gitops_update +fi diff --git a/tests/features/gitops-update.feature b/tests/features/gitops-update.feature new file mode 100644 index 0000000..303647c --- /dev/null +++ b/tests/features/gitops-update.feature @@ -0,0 +1,15 @@ +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 + + 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 diff --git a/tests/features/step_definitions/gitops-update.steps.js b/tests/features/step_definitions/gitops-update.steps.js new file mode 100644 index 0000000..71f71cf --- /dev/null +++ b/tests/features/step_definitions/gitops-update.steps.js @@ -0,0 +1,71 @@ +const { execSync } = require('child_process'); +const { 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'); + +function bash(cmd) { + try { + const out = execSync(`bash -c '${cmd}'`, { + cwd: PROJECT_ROOT, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { status: 0, stdout: out }; + } catch (e) { + return { status: e.status, stdout: e.stdout || '', stderr: e.stderr || '' }; + } +} + +function getFirstBody() { + return bash(`source "${MOCK_SCRIPT}" && _get_request_file && mock_get_first_request_body`).stdout.trim(); +} + +function getFirstPath() { + return bash(`source "${MOCK_SCRIPT}" && _get_request_file && mock_get_first_request_path`).stdout.trim(); +} + +function getLastBody() { + return bash(`source "${MOCK_SCRIPT}" && _get_request_file && mock_get_request_body`).stdout.trim(); +} + +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}`); +}); + +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"`); +}); + +Then('the code repo shows a gitops status link to the GitOps commit', 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'); + const pathStr = getFirstPath(); + if (!pathStr.includes('/repos/niko/app/statuses/')) throw new Error('Expected source repo status path'); +}); + +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'); +}); diff --git a/tests/gitops-update.bats b/tests/gitops-update.bats new file mode 100644 index 0000000..30d163f --- /dev/null +++ b/tests/gitops-update.bats @@ -0,0 +1,179 @@ +#!/usr/bin/env bats + +setup() { + export INPUT_FILE=dev/Chart.yaml + export YQ_TPL='version = "{{VERSION}}"' + export VERSION=1.0.0 + export SOURCE_REPO=niko/app + export SOURCE_COMMIT=abc123def456 + export GITOPS_REPO=niko/app-gitops + export GITEA_TOKEN=test-token + export GITEA_API_URL=http://localhost:18080 +} + +teardown() { + if type mock_stop &>/dev/null 2>&1; then + mock_stop 2>/dev/null || true + fi +} + +@test "missing GITEA_API_URL causes exit 1" { + unset GITEA_API_URL + run bash scripts/gitops-update.sh + [ "$status" -eq 1 ] + [[ "$output" == *"GITEA_API_URL"* ]] +} + +@test "missing GITEA_TOKEN causes exit 1" { + unset GITEA_TOKEN + run bash scripts/gitops-update.sh + [ "$status" -eq 1 ] + [[ "$output" == *"GITEA_TOKEN"* ]] +} + +@test "missing INPUT_FILE causes exit 1" { + unset INPUT_FILE + run bash scripts/gitops-update.sh + [ "$status" -eq 1 ] + [[ "$output" == *"INPUT_FILE"* ]] +} + +@test "missing YQ_TPL causes exit 1" { + unset YQ_TPL + run bash scripts/gitops-update.sh + [ "$status" -eq 1 ] + [[ "$output" == *"YQ_TPL"* ]] +} + +@test "missing VERSION causes exit 1" { + unset VERSION + run bash scripts/gitops-update.sh + [ "$status" -eq 1 ] + [[ "$output" == *"VERSION"* ]] +} + +@test "missing SOURCE_REPO causes exit 1" { + unset SOURCE_REPO + run bash scripts/gitops-update.sh + [ "$status" -eq 1 ] + [[ "$output" == *"SOURCE_REPO"* ]] +} + +@test "missing SOURCE_COMMIT causes exit 1" { + unset SOURCE_COMMIT + run bash scripts/gitops-update.sh + [ "$status" -eq 1 ] + [[ "$output" == *"SOURCE_COMMIT"* ]] +} + +@test "_gitops_substitute replaces {{VERSION}}" { + run bash -c ' + source scripts/gitops-update.sh >/dev/null 2>&1 + _gitops_substitute "(.version) = \"{{VERSION}}\"" "0.2.3" + ' + [ "$status" -eq 0 ] + [[ "$output" == '(.version) = "0.2.3"' ]] +} + +@test "CLONE_URL is constructed correctly from GITEA_API_URL" { + export GITEA_API_URL=https://gitea.app.keskikuja.site + export GITEA_TOKEN=secret123 + export GITOPS_REPO=niko/app-gitops + run bash -c ' + source scripts/gitops-update.sh >/dev/null 2>&1 + echo "$CLONE_URL" + ' + [ "$status" -eq 0 ] + [ "$output" = "https://secret123@gitea.app.keskikuja.site/niko/app-gitops.git" ] +} + +@test "CLONE_URL works with http:// URL" { + export GITEA_API_URL=http://localhost:18080 + export GITEA_TOKEN=token + export GITOPS_REPO=owner/repo + run bash -c ' + source scripts/gitops-update.sh >/dev/null 2>&1 + echo "$CLONE_URL" + ' + [ "$status" -eq 0 ] + [ "$output" = "https://token@localhost:18080/owner/repo.git" ] +} + +@test "_gitops_substitute handles multiple {{VERSION}} occurrences" { + run bash -c ' + source scripts/gitops-update.sh >/dev/null 2>&1 + _gitops_substitute "version = \"{{VERSION}}\"; tag = \"v{{VERSION}}\"" "1.2.3" + ' + [ "$status" -eq 0 ] + [[ "$output" == 'version = "1.2.3"; tag = "v1.2.3"' ]] +} + +@test "git flow: clone yq add commit push" { + source tests/helpers/mock-api.sh + mock_set_sequence '[ + {"code":201}, + {"code":201} + ]' + mock_start + export GIT_CALLS_FILE=$(mktemp) + export YQ_CALLS_FILE=$(mktemp) + export PATH="${BATS_TEST_DIRNAME}/helpers:$PATH" + export INPUT_FILE=dev/Chart.yaml + export YQ_TPL='(.version) = "{{VERSION}}"' + export VERSION=0.2.3 + export SOURCE_REPO=niko/app + export SOURCE_COMMIT=abc123def456 + export GITOPS_REPO=niko/app-gitops + export GITEA_API_URL=http://localhost:18080 + export GITEA_TOKEN=test-token + run bash scripts/gitops-update.sh + [ "$status" -eq 0 ] + git_calls=$(cat "$GIT_CALLS_FILE") + [[ "$git_calls" == *"clone"* ]] + [[ "$git_calls" == *"add"* ]] + [[ "$git_calls" == *"commit"* ]] + [[ "$git_calls" == *"push"* ]] + yq_calls=$(cat "$YQ_CALLS_FILE") + [[ "$yq_calls" == *"eval -i"* ]] + rm -f "$GIT_CALLS_FILE" "$YQ_CALLS_FILE" + mock_stop +} + +@test "two commit-status calls: code-repo and gitops-repo" { + source tests/helpers/mock-api.sh + mock_set_sequence '[ + {"code":201}, + {"code":201} + ]' + mock_start + export GIT_CALLS_FILE=$(mktemp) + export YQ_CALLS_FILE=$(mktemp) + export PATH="${BATS_TEST_DIRNAME}/helpers:$PATH" + export INPUT_FILE=dev/Chart.yaml + export YQ_TPL='(.version) = "{{VERSION}}"' + export VERSION=0.2.3 + export SOURCE_REPO=niko/app + export SOURCE_COMMIT=abc123def456 + export GITOPS_REPO=niko/app-gitops + export GITEA_API_URL=http://localhost:18080 + export GITEA_TOKEN=test-token + run bash scripts/gitops-update.sh + [ "$status" -eq 0 ] + path1=$(mock_get_first_request_path) + body1=$(mock_get_first_request_body) + [[ "$path1" == *"/repos/niko/app/statuses/"* ]] + [[ "$body1" == *'"context":"gitops/niko/app"'* ]] + path2=$(mock_get_request_path) + body2=$(mock_get_request_body) + [[ "$path2" == *"/repos/niko/app-gitops/statuses/"* ]] + [[ "$body2" == *'"context":"source/niko/app"'* ]] + rm -f "$GIT_CALLS_FILE" "$YQ_CALLS_FILE" + mock_stop +} + +@test "missing GITOPS_REPO causes exit 1" { + unset GITOPS_REPO + run bash scripts/gitops-update.sh + [ "$status" -eq 1 ] + [[ "$output" == *"GITOPS_REPO"* ]] +} diff --git a/tests/helpers/git b/tests/helpers/git new file mode 100755 index 0000000..37ac5d9 --- /dev/null +++ b/tests/helpers/git @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +echo "git $*" >> "${GIT_CALLS_FILE:-/dev/null}" + +case "$1" in + clone) + TARGET_DIR="${@: -1}" + mkdir -p "$TARGET_DIR" + cd "$TARGET_DIR" + git init --initial-branch=main 2>/dev/null + git config user.email "mock@test.com" + git config user.name "Mock Test" + mkdir -p "$(dirname "$INPUT_FILE")" + echo 'version: 0.1.0' > "$INPUT_FILE" + git add -A 2>/dev/null + git commit -m "initial" 2>/dev/null + echo "Cloning into '$TARGET_DIR'..." + ;; + add|commit|push|config|init) + ;; + rev-parse) + echo "mock-sha-9876543210fedcba9876543210fedcba98765432" + ;; + *) + echo "git: unknown command: $*" >&2 + exit 1 + ;; +esac diff --git a/tests/helpers/yq b/tests/helpers/yq new file mode 100755 index 0000000..b23a267 --- /dev/null +++ b/tests/helpers/yq @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "yq $*" >> "${YQ_CALLS_FILE:-/dev/null}"