From bc6bb78973562a445342d20e964c8f54874939ad Mon Sep 17 00:00:00 2001 From: niko Date: Mon, 22 Jun 2026 10:37:15 +0300 Subject: [PATCH] Feature/gitops (#37) Co-authored-by: moilanik Reviewed-on: https://gitea.app.keskikuja.site/niko/gitea-ci-library/pulls/37 --- .gitea/workflows/example-gitea-env.conf | 1 + .gitea/workflows/example-main.yml | 20 +- .gitea/workflows/git-pages.ci-main.yml | 17 +- .gitea/workflows/git-pages.gitea-env.conf | 1 + .gitea/workflows/gitops-dispatch.yml | 58 +++ .gitea/workflows/report-summary.yml | 23 + README.md | 17 +- docs/config-model.md | 8 +- docs/shared-scripts.md | 29 +- docs/workflows.md | 124 +++++- scripts/dispatch-workflow.sh | 42 +- scripts/gitops-dispatch.sh | 44 ++ scripts/gitops-update.sh | 114 +++++ skills/gitops-update/SKILL.md | 410 ++++++++++++++++++ tests/dispatch-workflow.bats | 39 +- tests/features/gitops-update.feature | 42 ++ .../step_definitions/gitops-update.steps.js | 172 ++++++++ .../step_definitions/test-execution.steps.js | 24 +- tests/gitops-update.bats | 176 ++++++++ tests/helpers/git | 40 ++ tests/helpers/mock-api.sh | 2 +- tests/helpers/yq | 2 + 22 files changed, 1347 insertions(+), 58 deletions(-) create mode 100644 .gitea/workflows/gitops-dispatch.yml create mode 100644 scripts/gitops-dispatch.sh create mode 100755 scripts/gitops-update.sh create mode 100644 skills/gitops-update/SKILL.md create mode 100644 tests/features/gitops-update.feature create mode 100644 tests/features/step_definitions/gitops-update.steps.js create mode 100644 tests/gitops-update.bats create mode 100755 tests/helpers/git create mode 100755 tests/helpers/yq diff --git a/.gitea/workflows/example-gitea-env.conf b/.gitea/workflows/example-gitea-env.conf index 7d35737..8e789e7 100644 --- a/.gitea/workflows/example-gitea-env.conf +++ b/.gitea/workflows/example-gitea-env.conf @@ -4,3 +4,4 @@ DOCKER_REGISTRY=gitea.app.keskikuja.site/niko DOCKER_IMAGE_NAME=gitea-ci-library-test-image DOCKER_UI_URL=https://gitea.app.keskikuja.site/niko/-/packages/container #DOCKERFILE=Dockerfile.platform + diff --git a/.gitea/workflows/example-main.yml b/.gitea/workflows/example-main.yml index a734123..be19866 100644 --- a/.gitea/workflows/example-main.yml +++ b/.gitea/workflows/example-main.yml @@ -39,7 +39,7 @@ jobs: with: env_json: ${{ needs.load-config.outputs.env_json }} - build-push: + docker-build-push: name: Build & Push Docker needs: [load-config, check-version, bats, cucumber] if: needs.check-version.outputs.artifact_exists != 'true' @@ -49,18 +49,32 @@ jobs: env_json: ${{ needs.load-config.outputs.env_json }} version: ${{ needs.check-version.outputs.version }} + docker-gitops: + name: GitOps + needs: [docker-build-push] + uses: niko/gitea-ci-library/.gitea/workflows/gitops-dispatch.yml@main + secrets: inherit + with: + env_json: ${{ needs.load-config.outputs.env_json }} + version: ${{ needs.check-version.outputs.version }} + GITOPS_FILE: dev/values.yaml + GITOPS_YQ_TPL: '.service.tag = "{{VERSION}}"' + GITOPS_REPO: niko/gitea-ci-gitops-tests + report-summary: name: Report Summary - needs: [load-config, build-push] + needs: [load-config, check-version, docker-build-push, docker-gitops] if: always() uses: niko/gitea-ci-library/.gitea/workflows/report-summary.yml@main with: env_json: ${{ needs.load-config.outputs.env_json }} suites: bats cucumber + gitops: | + ${{ needs.docker-gitops.outputs.summary }} tag-maintenance: name: Move provider version tag - needs: [build-push] + needs: [docker-gitops] if: success() uses: niko/gitea-ci-library/.gitea/workflows/tag-maintenance.yml@main secrets: inherit diff --git a/.gitea/workflows/git-pages.ci-main.yml b/.gitea/workflows/git-pages.ci-main.yml index 868fcfd..af37b10 100644 --- a/.gitea/workflows/git-pages.ci-main.yml +++ b/.gitea/workflows/git-pages.ci-main.yml @@ -5,7 +5,6 @@ on: - main paths: - git-pages/** - - .gitea/workflows/helm-build-push.yml - .gitea/workflows/git-pages.* workflow_dispatch: @@ -36,11 +35,25 @@ jobs: version: ${{ needs.check-version.outputs.version }} chart_path: git-pages + chart-gitops: + name: Update chart to the cluster + needs: [helm-push] + uses: niko/gitea-ci-library/.gitea/workflows/gitops-dispatch.yml + secrets: inherit + with: + env_json: ${{ needs.load-config.outputs.env_json }} + version: ${{ needs.check-version.outputs.version }} + GITOPS_FILE: dev/Chart.yaml + GITOPS_YQ_TPL: '(.dependencies[] | select(.name == "git-pages") | .version) = "{{VERSION}}"' + GITOPS_REPO: niko/gitea-ci-gitops-tests + report-summary: name: Report Summary - needs: [load-config, helm-push] + needs: [load-config, helm-push, chart-gitops] if: always() uses: niko/gitea-ci-library/.gitea/workflows/report-summary.yml@main with: env_json: ${{ needs.load-config.outputs.env_json }} suites: "" + gitops: | + ${{ needs.chart-gitops.outputs.summary }} diff --git a/.gitea/workflows/git-pages.gitea-env.conf b/.gitea/workflows/git-pages.gitea-env.conf index c497b13..eea1639 100644 --- a/.gitea/workflows/git-pages.gitea-env.conf +++ b/.gitea/workflows/git-pages.gitea-env.conf @@ -3,3 +3,4 @@ HELM_REGISTRY=gitea.app.keskikuja.site/niko HELM_UI_URL=https://gitea.app.keskikuja.site/niko/-/packages/container GIT_TAG_PREFIX=git-pages/ VERSION_FILE=git-pages/Chart.yaml + diff --git a/.gitea/workflows/gitops-dispatch.yml b/.gitea/workflows/gitops-dispatch.yml new file mode 100644 index 0000000..38bbdea --- /dev/null +++ b/.gitea/workflows/gitops-dispatch.yml @@ -0,0 +1,58 @@ +name: GitOps Dispatch +on: + workflow_call: + inputs: + env_json: + required: true + type: string + version: + required: true + type: string + GITOPS_FILE: + required: true + type: string + GITOPS_YQ_TPL: + required: true + type: string + GITOPS_REPO: + required: true + type: string + secrets: + GITOPS_DISPATCH_TOKEN: + required: true + outputs: + summary: + description: 'Pipe-format: component|version|status|commit_sha|repo' + value: ${{ jobs.dispatch.outputs.summary }} + +env: + GITOPS_VERSION: ${{ inputs.version }} + GITOPS_FILE: ${{ inputs.GITOPS_FILE }} + GITOPS_YQ_TPL: ${{ inputs.GITOPS_YQ_TPL }} + GITOPS_REPO: ${{ inputs.GITOPS_REPO }} + GITOPS_SOURCE_REPO: ${{ github.repository }} + GITOPS_SOURCE_COMMIT: ${{ github.sha }} + GITEA_API_URL: ${{ fromJson(inputs.env_json).GITEA_API_URL }} + GITOPS_TAG_PREFIX: ${{ fromJson(inputs.env_json).GIT_TAG_PREFIX || '' }} + GITOPS_WORKFLOW: gitops-service.yaml + +jobs: + dispatch: + runs-on: ubuntu-latest + outputs: + summary: ${{ steps.run.outputs.GITOPS_SUMMARY }} + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: niko/gitea-ci-library + path: .ci + - name: Run gitops dispatch + id: run + env: + GITEA_TOKEN: ${{ secrets.GITOPS_DISPATCH_TOKEN }} + run: | + OUTPUT=$(bash .ci/scripts/gitops-dispatch.sh) + echo "$OUTPUT" + SUMMARY=$(awk -F= '/^GITOPS_SUMMARY=/ {print $2}' <<<"$OUTPUT") + echo "GITOPS_SUMMARY=$SUMMARY" >> "$GITHUB_OUTPUT" diff --git a/.gitea/workflows/report-summary.yml b/.gitea/workflows/report-summary.yml index 7b96be0..ff4eacc 100644 --- a/.gitea/workflows/report-summary.yml +++ b/.gitea/workflows/report-summary.yml @@ -9,6 +9,10 @@ on: required: true type: string description: Space-separated suite names published to git-pages + gitops: + required: false + type: string + description: 'Pipe-separated rows: component|version|status|commit_sha|repo' env: GIT_PAGES_URL: ${{ fromJson(inputs.env_json).GIT_PAGES_URL }} @@ -32,3 +36,22 @@ jobs: echo "| ${suite} | [View report](${BASE}/${suite}/) |" done } >> "${GITHUB_STEP_SUMMARY}" + + if [ -n "${{ inputs.gitops }}" ]; then + GITEA_URL="${{ fromJson(inputs.env_json).GITEA_API_URL }}" + { + echo "" + echo "## GitOps updates" + echo "" + echo "| Component | Version | Status | GitOps commit |" + echo "|-----------|---------|--------|--------------|" + echo '${{ inputs.gitops }}' | while IFS='|' read -r comp ver status sha repo; do + [ -z "$comp" ] && continue + if [ -n "$sha" ]; then + echo "| $comp | $ver | $status | [link]($GITEA_URL/$repo/commit/$sha) |" + else + echo "| $comp | $ver | $status | — |" + fi + done + } >> "${GITHUB_STEP_SUMMARY}" + fi diff --git a/README.md b/README.md index a94da3a..586b11b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Reusable workflow -kirjasto Gitea Actionsille. Lisätietoja: [docs/](docs/) **Consumer-käyttöönotto:** [skills/consumer-pipelines/SKILL.md](skills/consumer-pipelines/SKILL.md) — pipeline-standardit ja säännöt consumer-projekteille +**GitOps-päivitys:** [skills/gitops-update/SKILL.md](skills/gitops-update/SKILL.md) — GitOps-repon job-template, dispatch ja token-ohjeet + **Single repo & monorepo:** Kirjasto toimii molemmissa. Monorepo-tuki polkusuodatuksella, komponenttikohtaisilla versioilla ja git-tägien etuliitteillä — jokainen komponentti julkaistaan itsenäisesti omassa @@ -220,7 +222,9 @@ Consumer-repossa on oltava seuraavat asetukset: |--------|--------| | `GIT_PAGES_PUBLISH_TOKEN` | Git-pages-palvelimen BasicAuth-token. Nimi on lukittu — tämä tarkka nimi vaaditaan. | -`GITEA_TOKEN` on Gitean sisäinen secret (`secrets.GITEA_TOKEN`), joka on automauttisesti saatavilla — sitä ei tarvitse erikseen luoda. +`GITEA_TOKEN` on Gitean automaattisesti jokaiselle workflow-runille generoima token (`secrets.GITEA_TOKEN`). Se on scopeutettu **siihen repoon**, jossa workflow ajaa — ei toimi toiseen repoon dispatchaukseen eikä toisen repon commit-statusin asettamiseen. Ei tarvitse erikseen luoda. + +Jos workflow tarvitsee oikeuksia **toiseen** repoon (esim. dispatch GitOps-repoon), tarvitaan manuaalinen token. Katso [skills/gitops-update/SKILL.md](skills/gitops-update/SKILL.md). ### Config-tiedosto (`.gitea/workflows/gitea-env.conf`) @@ -256,6 +260,17 @@ Jokaisen jobin alussa `ci-validate.sh` tarkistaa: Jos validointi epäonnistuu, job keskeytyy exit-koodilla 1 ja Gitean commit-status näyttää epäonnistumisen linkkinä lokiin. +### GitOps-päivitys + +Artifact buildin jälkeen voidaan dispatchata GitOps-repoon, joka päivittää +konfiguraatiotiedoston (esim. Chart.yaml version) ja pushaa muutoksen. + +Kaksi skriptiä: +- `scripts/dispatch-workflow.sh` — lähettää workflow_dispatch-pyynnön ja pollaa valmistumista +- `scripts/gitops-update.sh` — kloonaa, päivittää yq:llä, committaa ja pushaa + +Tarkka asennus: [skills/gitops-update/SKILL.md](skills/gitops-update/SKILL.md) + ### Muuta | Muuttuja | Kuvaus | diff --git a/docs/config-model.md b/docs/config-model.md index 0f7503f..75dcaba 100644 --- a/docs/config-model.md +++ b/docs/config-model.md @@ -60,9 +60,15 @@ Salaisuudet eivät ole `.conf`-tiedostossa. Ne määritellään Gitean organization/repository secrets -mekanismissa ja välitetään workflowlle `secrets: inherit` -direktiivillä. +**`secrets.GITEA_TOKEN` on Gitean automaattisesti generoima token, +scopeutuu siihen repoon jossa workflow ajaa.** Se ei oikeuta +dispatchaamaan toiseen repoon eikä kirjoittamaan toisen repon +commit-statusta. Cross-repo-operaatioihin tarvitaan manuaalinen +org-tason token. + | 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` (GitOps-repossa) | | `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/shared-scripts.md b/docs/shared-scripts.md index f173037..3940913 100644 --- a/docs/shared-scripts.md +++ b/docs/shared-scripts.md @@ -113,17 +113,38 @@ Lukee tiedoston polun `CI_CONF_FILE`-env-muuttujasta (oletus: `.gitea/workflows/ Dispatchaa workflow'n toisessa repossa ja pollaa sen valmistumista synkronisesti. Käytetään GitOps-deploymentissa ja klusteritestien ketjutuksessa (tuleva). +Generoi automaattisesti `dispatch_id`-tunnisteen, lisää sen dispatch- +inputteihin ja tunnistaa workflow-runin kohdereposta `display_title`- +kentän perusteella. Toimii luotettavasti vaikka samassa repossa olisi +useita samanaikaisia ajoja. + +**Kohde-workflow'ssa on oltava `dispatch_id`-input ja `run-name`-kenttä +`display_title`-matchausta varten.** Katso `skills/gitops-update/SKILL.md`. + ### Rajapinta ```bash -dispatch-workflow.sh [timeout_minutes] +dispatch-workflow.sh [timeout_minutes] ``` +| Parametri | Pakollinen | Kuvaus | +|-----------|------------|--------| +| `target_repo` | Kyllä | `owner/repo` | +| `workflow_file` | Kyllä | Workflow-tiedosto (esim. `ci-main.yml`) | +| `ref` | Kyllä | Branch | +| `inputs_json` | Kyllä | JSON-objekti dispatch-inputteina | +| `gitea_api_url` | Kyllä | Gitean API-URL | +| `gitea_token` | Kyllä | Gitea API -token (write kohderepoon) | +| `timeout_minutes` | Ei | Aikakatkaisu (oletus 360) | + ### Toiminta -1. **Dispatch:** `POST /api/v1/repos/{target_repo}/actions/workflows/{workflow_file}/dispatches` -2. **Poll:** `GET /api/v1/repos/{target_repo}/actions/runs` → odota valmistumista -3. **Palauta:** `conclusion` (`success`/`failure`/`timeout`) +1. **Generoi `dispatch_id`** — 8-hex uniikki tunniste +2. **Injektoi** `dispatch_id` inputteihin +3. **Dispatch:** `POST /api/v1/repos/{target_repo}/actions/workflows/{workflow_file}/dispatches` +4. **Etsi run:** pollaa rinnakkaisia `workflow_dispatch`-runeja, matchaa `display_title` sisältää `dispatch_id`:n +5. **Poll:** `GET /api/v1/repos/{target_repo}/actions/runs/{run_id}` — odota valmistumista +6. **Palauta:** exit 0 (success), exit 1 (failure), exit 124 (timeout) --- diff --git a/docs/workflows.md b/docs/workflows.md index 1ab629e..43983d5 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -136,6 +136,29 @@ eikä toimi air gap -ympäristössä. Korvaa tarvittaessa custom-kontilla --- +### `gitops-dispatch.yml` — GitOps-päivityksen dispatch + +**Trigger:** `workflow_call` + +**Inputit:** + +| Parametri | Pakollinen | Kuvaus | +|-----------|------------|--------| +| `env_json` | Kyllä | Konffi, josta luetaan `GITOPS_FILE`, `GITOPS_YQ_TPL`, `GITOPS_REPO`, `GIT_TAG_PREFIX` | +| `version` | Kyllä | Päivitettävä versio (check-version output) | +| `component` | Kyllä | `chart` tai `container` — tunniste summary-riville | + +**Secretit:** `GITOPS_TOKEN` + +**Outputit:** `summary` — pipe-formaatti: `{component}|{version}|{status}|{commit_sha}|{repo}` + +**Steppi-kaavio:** +``` +checkout → gitops-dispatch.sh → dispatch-workflow.sh → GITOPS_SUMMARY output +``` + +--- + ## Consumer-esimerkki (`example-*`) ### `example-feature.yml` — Feature-haaran CI @@ -151,11 +174,23 @@ load-config → bats + cucumber → report-summary (always) **Trigger:** `push` [branches: main] ``` -load-config → check-version → - [artifact exists] → done - [no artifact] → bats + cucumber → report-summary (always) → docker-build-push +load-config ───────────────────────────────────────────────────────┐ +load-config-helm ───────────────────────────────────────────┐ │ + │ │ +check-version ←─────────────────────────────────────────────┘ │ + │ │ + └→ bats + cucumber │ + ├─ docker-build-push → gitops-container ─┐ │ + └─ helm-build-push → gitops-chart ──────┤ │ + ├→ report-summary ←┘ + tag-maintenance ←────────────────────────┘ ``` +GitOps-jobit (`gitops-chart`, `gitops-container`) käyttävät +`gitops-dispatch.yml`-provider-workflowia. Kaksisuuntainen track: +dispatch-workflow.sh → GITOPS_COMMIT + GITOPS_SUMMARY. +Katso [skills/gitops-update/SKILL.md](../skills/gitops-update/SKILL.md). + ### `example-bats-tests.yml` — Bats unit-testit **Trigger:** `workflow_call` @@ -174,7 +209,12 @@ commit-statuksen linkillä raporttiin. **Trigger:** `workflow_call` — ajetaan `if: always()` testien jälkeen -**Inputs:** `env_json`, `suites` (space-separated lista suite-nimistä) +**Inputs:** `env_json`, `suites` (space-separated lista suite-nimistä), `gitops` (optional JSON array) + +**GitOps-tuki:** Jos `gitops` input on annettu (JSON array objekteilla +`component`, `version`, `status`, `commit`, `repo`), workflow lisää +GitOps-päivitystaulukon testiraporttien perään. Jokaiselle riville +muodostuu linkki GitOps-repon committiin. Generoi Markdown-taulukon `GITHUB_STEP_SUMMARY`:yn kaikista julkaistuista raporteista. Renderöityy HTML:ksi Gitea 1.27+ Summary-välilehdellä. @@ -182,7 +222,77 @@ 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 +(kaksisuuntainen track): + +| Status | Mihin repo | Context | Linkki | +|---|---|---|---| +| ✅ | **GitOps-repo** | `source/{repo}` | Code-repon committiin | +| ✅ | **Code-repo** (dispatchin jälkeen) | `gitops/{repo} {RUN_ID}` | GitOps-repon committiin | + +**Input-ympäristömuuttujat (ajetaan GitOps-repon workflow'ssa):** + +| 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-repo slug | +| `GITEA_API_URL` | Kyllä | Gitean API-URL | +| `GITEA_TOKEN` | Kyllä | Gitea API-token (write GitOps-repoon) | +| `GITOPS_BRANCH` | Ei | GitOps-repon branch (oletus `main`) | +| `GIT_TAG_PREFIX` | Ei | Komponentin tag-prefix status-nimeämiseen | + +**Commit-status (GitOps-repoon):** +| Kenttä | Formaatti | Esimerkki | +|--------|-----------|-----------| +| Context | `source/{repo}` | `source/gitea-ci-library` | +| Description | `Install to {env} {version}` | `Install to dev 0.2.0` | +| Target URL | Linkki code-repon committiin | `/org/repo/commit/sha` | + +`{env}` parsitaan `INPUT_FILE`:stä (`dev/Chart.yaml` → `dev`). + +**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. Jos muutoksia: commit + push `[skip ci]`, muuten status `— no change` +6. Asettaa commit-statuksen GitOps-repoon (source-konteksti, linkki code-repoon) + +**Scriptiä ei ajeta code reposta.** Se ajaa GitOps-repon workflow'ssa. + +### Code-repon commit-status (dispatchin jälkeen) + +GitOps-päivityksen valmistuttua `dispatch-workflow.sh` tulostaa +`GITOPS_COMMIT=` (GitOps-repon commitin SHA). Code repo asettaa +oman commit-statusinsa linkillä GitOps-committiin: + +| Kenttä | Formaatti | Esimerkki | +|--------|-----------|-----------| +| Context | `gitops/{repo} {RUN_ID}` | `gitops/gitea-ci-library 473` | +| Description | `Install to {env} {version}` | `Install to dev 0.2.0` | +| Target URL | Linkki GitOps-repon committiin | `/niko/gitea-ci-gitops-tests/commit/def456` | + +### Loppuraportti (GITHUB_STEP_SUMMARY) + +`report-summary.yml` (optio `gitops`-inputti) lisää GitOps-rivit +GITHUB_STEP_SUMMARYyn: + +| Component | Version | Status | GitOps commit | +|---|---|---|---| +| helm | 0.2.0 | success | [link](...) | + +Kokonainen esimerkki molemmista puolista: [skills/gitops-update/SKILL.md](../skills/gitops-update/SKILL.md) +ja [.gitea/workflows/example-main.yml](../.gitea/workflows/example-main.yml). + +--- diff --git a/scripts/dispatch-workflow.sh b/scripts/dispatch-workflow.sh index 1564c84..c482bca 100755 --- a/scripts/dispatch-workflow.sh +++ b/scripts/dispatch-workflow.sh @@ -17,6 +17,11 @@ POLL_INTERVAL="${DISPATCH_POLL_INTERVAL:-10}" [ -z "$GITEA_API_URL" ] && echo "ERROR: gitea_api_url argument is required" >&2 && exit 1 [ -z "$GITEA_TOKEN" ] && echo "ERROR: gitea_token argument is required" >&2 && exit 1 +# Generate unique dispatch_id for display_title matching +# Can be overridden via DISPATCH_ID env var (for tests) +DISPATCH_ID="${DISPATCH_ID:-$(xxd -l 4 -p /dev/urandom 2>/dev/null || openssl rand -hex 4 2>/dev/null || od -An -N4 -tx1 /dev/urandom | tr -d ' \n')}" +INPUTS_JSON=$(echo "$INPUTS_JSON" | jq --arg id "$DISPATCH_ID" '. + {dispatch_id: $id}') + DISPATCH_URL="$GITEA_API_URL/api/v1/repos/$TARGET_REPO/actions/workflows/$WORKFLOW_FILE/dispatches" DISPATCH_BODY=$(jq -nc --arg ref "$REF" --argjson inputs "$INPUTS_JSON" '{ref: $ref, inputs: $inputs}') @@ -32,19 +37,30 @@ if [ "$DISPATCH_CODE" != "201" ]; then exit 1 fi -RUNS_URL="$GITEA_API_URL/api/v1/repos/$TARGET_REPO/actions/runs?status=running" -RUNS_RESP=$(curl -s --connect-timeout 5 --max-time 10 \ - -H "Authorization: token $GITEA_TOKEN" "$RUNS_URL") - -RUN_ID=$(echo "$RUNS_RESP" | jq -r '.workflow_runs[0].id // empty') -if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then - echo "ERROR: Could not find dispatched workflow run" >&2 - exit 1 -fi - +# Poll: find dispatched run by display_title matching +RUN_ID="" TIMEOUT_SECONDS=$(awk "BEGIN {printf \"%.3f\", $TIMEOUT_MINUTES * 60}") START_TIME=$(date +%s) +while [ -z "$RUN_ID" ]; do + NOW=$(date +%s) + ELAPSED=$((NOW - START_TIME)) + if awk -v e="$ELAPSED" -v t="$TIMEOUT_SECONDS" 'BEGIN { exit !(e >= t) }'; then + echo "ERROR: Timeout after ${TIMEOUT_MINUTES} minutes — run not found" >&2 + exit 124 + fi + + RUNS_RESP=$(curl -s --connect-timeout 5 --max-time 10 \ + "$GITEA_API_URL/api/v1/repos/$TARGET_REPO/actions/runs?event=workflow_dispatch&limit=10" \ + -H "Authorization: token $GITEA_TOKEN") + + RUN_ID=$(echo "$RUNS_RESP" | jq -r --arg id "$DISPATCH_ID" \ + '[.workflow_runs[] | select(.display_title | contains($id))] | .[0].id // empty') + + [ -z "$RUN_ID" ] && sleep "$POLL_INTERVAL" +done + +# Poll: wait for run to complete while true; do NOW=$(date +%s) ELAPSED=$((NOW - START_TIME)) @@ -61,6 +77,12 @@ while true; do if [ "$STATUS" = "completed" ]; then CONCLUSION=$(echo "$RUN_RESP" | jq -r '.conclusion // "failure"') if [ "$CONCLUSION" = "success" ]; then + GITOPS_COMMIT="" + BRANCH_RESP=$(curl -s --connect-timeout 5 --max-time 10 \ + "$GITEA_API_URL/api/v1/repos/$TARGET_REPO/branches/$REF" \ + -H "Authorization: token $GITEA_TOKEN") || true + GITOPS_COMMIT=$(echo "$BRANCH_RESP" | jq -r '.commit.id // empty') + echo "GITOPS_COMMIT=$GITOPS_COMMIT" exit 0 fi echo "ERROR: Workflow completed with conclusion: $CONCLUSION" >&2 diff --git a/scripts/gitops-dispatch.sh b/scripts/gitops-dispatch.sh new file mode 100644 index 0000000..e02ffcc --- /dev/null +++ b/scripts/gitops-dispatch.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GITOPS_FILE:?}" +: "${GITOPS_YQ_TPL:?}" +: "${GITOPS_VERSION:?}" +: "${GITOPS_SOURCE_REPO:?}" +: "${GITOPS_SOURCE_COMMIT:?}" +: "${GITOPS_REPO:?}" +: "${GITOPS_WORKFLOW:?}" +: "${GITEA_API_URL:?}" +: "${GITEA_TOKEN:?}" + +TIMEOUT="${GITOPS_DISPATCH_TIMEOUT:-30}" + +INPUTS=$(jq -nc \ + --arg file "$GITOPS_FILE" \ + --arg yq_tpl "$GITOPS_YQ_TPL" \ + --arg version "$GITOPS_VERSION" \ + --arg source_repo "$GITOPS_SOURCE_REPO" \ + --arg source_commit "$GITOPS_SOURCE_COMMIT" \ + --arg git_tag_prefix "${GITOPS_TAG_PREFIX:-}" \ + '{file: $file, yq_tpl: $yq_tpl, version: $version, source_repo: $source_repo, source_commit: $source_commit, git_tag_prefix: $git_tag_prefix}') + +DIR="$(cd "$(dirname "$0")" && pwd)" +set +e +OUTPUT=$(bash "$DIR/dispatch-workflow.sh" \ + "$GITOPS_REPO" "$GITOPS_WORKFLOW" "main" \ + "$INPUTS" "$GITEA_API_URL" "$GITEA_TOKEN" "$TIMEOUT" 2>&1) +EXIT=$? +set -e + +echo "$OUTPUT" + +STATUS="failure" +GITOPS_SHA="" +if [ "$EXIT" = "0" ]; then + STATUS="success" + GITOPS_SHA=$(echo "$OUTPUT" | grep '^GITOPS_COMMIT=' | cut -d= -f2) +fi + +COMPONENT="${GITOPS_TAG_PREFIX:-${GITOPS_FILE}}" +echo "GITOPS_SUMMARY=${COMPONENT}|${GITOPS_VERSION}|${STATUS}|${GITOPS_SHA}|${GITOPS_REPO}" +exit "$EXIT" diff --git a/scripts/gitops-update.sh b/scripts/gitops-update.sh new file mode 100755 index 0000000..cc3410e --- /dev/null +++ b/scripts/gitops-update.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_gitops_fail() { + local MSG="${1:-GitOps update failed}" + echo "[ERROR] ${MSG}" >&2 + + if [ -n "${GITOPS_REPO:-}" ] && [ -n "${GITOPS_SHA:-}" ] && \ + [ -n "${SOURCE_REPO:-}" ] && [ -n "${SOURCE_COMMIT:-}" ] && \ + [ -n "${GITEA_API_URL:-}" ] && [ -n "${GITEA_TOKEN:-}" ]; then + local env repo context + env=$(dirname "${INPUT_FILE}") + repo=$(basename "${SOURCE_REPO}") + context="${repo} ${GITHUB_RUN_ID:-unknown}" + [ -n "${GIT_TAG_PREFIX:-}" ] && context="${repo}/${GIT_TAG_PREFIX} ${GITHUB_RUN_ID:-unknown}" + + local SOURCE_URL="${GITEA_API_URL}/${SOURCE_REPO}/commit/${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 "Install to ${env} ${VERSION}" \ + "${context}" "" "${SOURCE_URL}" 2>/dev/null || true + 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 env repo context + env=$(dirname "${INPUT_FILE}") + repo=$(basename "${SOURCE_REPO}") + context="${repo} ${GITHUB_RUN_ID:-unknown}" + [ -n "${GIT_TAG_PREFIX:-}" ] && context="${repo}/${GIT_TAG_PREFIX} ${GITHUB_RUN_ID:-unknown}" + + local SOURCE_URL="${GITEA_API_URL}/${SOURCE_REPO}/commit/${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" success \ + "Install to ${env} ${VERSION}" \ + "${context}" "" "${SOURCE_URL}" +} + +_gitops_nochange() { + local env repo context + env=$(dirname "${INPUT_FILE}") + repo=$(basename "${SOURCE_REPO}") + context="${repo} ${GITHUB_RUN_ID:-unknown}" + [ -n "${GIT_TAG_PREFIX:-}" ] && context="${repo}/${GIT_TAG_PREFIX} ${GITHUB_RUN_ID:-unknown}" + + local SOURCE_URL="${GITEA_API_URL}/${SOURCE_REPO}/commit/${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" success \ + "Install to ${env} ${VERSION} — no change" \ + "${context}" "" "${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}" + + if git diff --cached --quiet; then + echo "No changes — ${INPUT_FILE} already at ${VERSION}" + GITOPS_SHA="$(git rev-parse HEAD)" + _gitops_nochange + exit 0 + fi + + git -c user.name="gitea-ci-bot" \ + -c user.email="ci@keskikuja.site" \ + 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="${GITOPS_CLONE_URL:-https://${GITEA_TOKEN}@${GITEA_HOST}/${GITOPS_REPO}.git}" + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + _gitops_update +fi diff --git a/skills/gitops-update/SKILL.md b/skills/gitops-update/SKILL.md new file mode 100644 index 0000000..cc6b450 --- /dev/null +++ b/skills/gitops-update/SKILL.md @@ -0,0 +1,410 @@ +--- +name: gitops-update +description: | + Setting up GitOps version updates: GitOps-repo workflow template, code + repo dispatch, secret requirements, and two-repo commit-status pattern. + Activates when the user needs to wire up artifact builds to GitOps + configuration updates. +activation-gate: | + User mentions GitOps update, gitops-update, dispatch to another repo, + two-repo version bump, cross-repo deployment, or wiring build output to + config repo. +category: ci +impact: high +--- + +# GitOps Update — Provider-palvelu + +`scripts/gitops-update.sh` ja `scripts/dispatch-workflow.sh` muodostavat +GitOps-päivityspalvelun. Artifact buildataan code repossa, minkä jälkeen +code repo dispatchaa GitOps-repoon, joka päivittää konfiguraatiotiedoston +ja pushaa muutoksen. + +## Arkkitehtuuri + +Kaksi erillistä repoa, eristetyt oikeudet: + +``` +Code repo GitOps repo +(build & push artifact) (konfiguraatiot) + +build & push onnistuu (v0.2.3) + │ + │ dispatch ci-main.yml + │ {file, yq_tpl, version, source_repo, source_commit} + │ + └────────────────────────────────────→┐ + │ + dispatch-workflow.sh pollaa ←─────────┘ + │ + code repo asettaa │ git clone, yq update, + oman commit-statusnsa │ git commit + push + dispatchin exit-koodilla │ status GitOps-repoon +``` + +**Token-periaate:** Vain GitOps-repoon kirjoitetaan. Code repo asettaa +oman commit-statusnsa dispatch-kutsun exit-koodin perusteella omalla +auto-tokenillaan. GitOps-repon auto-token ei tarvitse oikeuksia code +repoon. + +## GitOps-repon workflow (ci-main.yml) + +GitOps-repoon luodaan `.gitea/workflows/ci-main.yml`: + +```yaml +name: GitOps Update +run-name: "GitOps Service (${{ inputs.dispatch_id || 'manual' }})" +on: + workflow_dispatch: + inputs: + file: + required: true + type: string + yq_tpl: + required: true + type: string + version: + required: true + type: string + source_repo: + required: true + type: string + source_commit: + required: true + type: string + dispatch_id: + required: false + type: string + git_tag_prefix: + required: false + type: string + +env: + INPUT_FILE: ${{ inputs.file }} + YQ_TPL: ${{ inputs.yq_tpl }} + VERSION: ${{ inputs.version }} + SOURCE_REPO: ${{ inputs.source_repo }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + GITOPS_REPO: ${{ github.repository }} + GITOPS_BRANCH: ${{ github.ref_name }} + GITEA_API_URL: ${{ gitea.server_url }} + GIT_TAG_PREFIX: ${{ inputs.git_tag_prefix || '' }} + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: niko/gitea-ci-library + path: .ci + + - name: Install yq + run: | + wget -qO /usr/local/bin/yq \ + https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + chmod +x /usr/local/bin/yq + + - name: Run GitOps update + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + bash .ci/scripts/gitops-update.sh +``` + +**Huomiot:** +- `GITEA_TOKEN` on Gitean auto-token — scopeutuu GitOps-repoon, riittää + cloneen, committiin, pushiin ja commit-statusiin GitOps-repossa +- `run-name` ja `dispatch_id` mahdollistavat dispatchaavan skriptin tunnistaa + tämän workflow-runin yksiselitteisesti `display_title`-kentästä, vaikka + samassa repossa olisi samanaikaisia ajoja +- yq ladataan lennossa (kompromissi, ks. "Tuleva CI-kontti") + +### Tulossa: custom CI-kontti + +Nykyinen job lataa yq:n lennossa. Myöhemmin rakennetaan oma kontti +(`ci-gitops`), jossa on nodejs + git + yq valmiina. Sama patterni kuin +`ci-bats` ja `ci-cucumber`. Ks. `skills/ci-container-build/SKILL.md`. + +## Code-repon dispatch-step + +Code repo dispatchaa GitOps-repon workflown artifact buildin onnistuttua: + +```yaml +gitops-update: + needs: [helm-build-push] + if: success() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: niko/gitea-ci-library + path: .ci + + - name: Dispatch GitOps update + id: dispatch + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + INPUTS=$(jq -nc \ + --arg file "dev/Chart.yaml" \ + --arg yq_tpl '(.dependencies[] | select(.name == "agent-platform-helm") | .version) = "{{VERSION}}"' \ + --arg version "${{ needs.check-version.outputs.version }}" \ + --arg source_repo "${{ github.repository }}" \ + --arg source_commit "${{ github.sha }}" \ + '{file: $file, yq_tpl: $yq_tpl, version: $version, source_repo: $source_repo, source_commit: $source_commit}') + OUTPUT=$(bash .ci/scripts/dispatch-workflow.sh \ + "niko/agent-platform-gitops" \ + "ci-main.yml" \ + "main" \ + "$INPUTS" \ + "${{ fromJson(needs.load-config.outputs.env_json).GITEA_API_URL }}" \ + "${{ secrets.GITEA_TOKEN }}" \ + "30") + echo "$OUTPUT" + GITOPS_COMMIT=$(echo "$OUTPUT" | grep '^GITOPS_COMMIT=' | cut -d= -f2) + echo "gitops_commit=$GITOPS_COMMIT" >> "$GITHUB_OUTPUT" +``` + +### Multi-artifact pipeline (kontti + helm) + +Yksi main-haaran build tuottaa usein sekä Docker-imagen että Helm-chartin. +Kumpikin artefakti dispatchaa oman GitOps-päivityksensä rinnakkain: + +```yaml +gitops-helm: + needs: [helm-build-push] + if: success() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: niko/gitea-ci-library + path: .ci + - name: Update helm version + id: helm + run: | + INPUTS=$(jq -nc \ + --arg file "dev/Chart.yaml" \ + --arg yq_tpl '(.dependencies[] | select(.name == "git-pages") | .version) = "{{VERSION}}"' \ + --arg version "${{ needs.check-version.outputs.version }}" \ + --arg source_repo "${{ github.repository }}" \ + --arg source_commit "${{ github.sha }}" \ + --arg git_tag_prefix "helm" \ + '{dispatch_id: "", file: $file, yq_tpl: $yq_tpl, version: $version, source_repo: $source_repo, source_commit: $source_commit, git_tag_prefix: $git_tag_prefix}') + OUTPUT=$(bash .ci/scripts/dispatch-workflow.sh \ + "niko/gitea-ci-gitops-tests" "gitops-service.yaml" "main" \ + "$INPUTS" "${{ fromJson(needs.load-config.outputs.env_json).GITEA_API_URL }}" \ + "${{ secrets.GITOPS_DISPATCH_TOKEN }}" "30") + echo "$OUTPUT" + echo "helm_commit=$(echo "$OUTPUT" | grep '^GITOPS_COMMIT=' | cut -d= -f2)" >> "$GITHUB_OUTPUT" + +gitops-docker: + needs: [docker-build-push] + if: success() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: niko/gitea-ci-library + path: .ci + - name: Update docker tag + id: docker + run: | + INPUTS=$(jq -nc \ + --arg file "dev/values.yaml" \ + --arg yq_tpl '.service.tag = "{{VERSION}}"' \ + --arg version "${{ needs.check-version.outputs.version }}" \ + --arg source_repo "${{ github.repository }}" \ + --arg source_commit "${{ github.sha }}" \ + --arg git_tag_prefix "docker" \ + '{dispatch_id: "", file: $file, yq_tpl: $yq_tpl, version: $version, source_repo: $source_repo, source_commit: $source_commit, git_tag_prefix: $git_tag_prefix}') + OUTPUT=$(bash .ci/scripts/dispatch-workflow.sh \ + "niko/gitea-ci-gitops-tests" "gitops-service.yaml" "main" \ + "$INPUTS" "${{ fromJson(needs.load-config.outputs.env_json).GITEA_API_URL }}" \ + "${{ secrets.GITOPS_DISPATCH_TOKEN }}" "30") + echo "$OUTPUT" + echo "docker_commit=$(echo "$OUTPUT" | grep '^GITOPS_COMMIT=' | cut -d= -f2)" >> "$GITHUB_OUTPUT" +``` + +Kaksi dispatchia, kaksi eri tiedostoa, kaksi eri `GIT_TAG_PREFIX`-arvoa. +Kummallakin on oma commit-status-linja ja oma summary-rivi. +`dispatch-workflow.sh` hoitaa rinnakkaisuuden `display_title`-matchauksella. + +**GITEA_TOKEN dispatch-vaiheessa:** Tarvitaan manuaalinen token, +jolla on **write-oikeus GitOps-repoon** (esim. org-tason token). +Code-repon auto-token ei oikeuta dispatchaamaan toiseen repoon. +Token luodaan Giteassa: `Settings → Applications → Generate Token` +ja asetetaan code-repoon Actions Secretiksi. + +### Commit-status dispatchin perusteella + +`dispatch-workflow.sh` tulostaa `GITOPS_COMMIT=` stdoutiin onnistuneen +GitOps-päivityksen jälkeen. Code repo parsii sen ja asettaa commit-statusin +linkillä GitOps-committiin: + +```yaml + - name: Set commit-status with GitOps link + if: always() + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_API_URL: ${{ fromJson(needs.load-config.outputs.env_json).GITEA_API_URL }} + GITOPS_COMMIT: ${{ steps.dispatch.outputs.gitops_commit }} + VERSION: ${{ needs.check-version.outputs.version }} + run: | + GITOPS_URL="${GITEA_API_URL}/niko/agent-platform-gitops/commit/${GITOPS_COMMIT}" + CTX="gitops/$(basename ${{ github.repository }})" + DESC="Deploy to dev ${VERSION}" + if [ -n "$GITOPS_COMMIT" ]; then + bash .ci/scripts/report-status.sh success "$DESC" "$CTX" "" "$GITOPS_URL" + else + bash .ci/scripts/report-status.sh success "$DESC" "$CTX" + fi +``` + +`dispatch-workflow.sh` palauttaa: +- exit 0 = GitOps-päivitys onnistui (+ `GITOPS_COMMIT=`) +- exit 1 = GitOps-päivitys failasi +- exit 124 = aikakatkaisu (360 min oletus) + +### Loppuraportti (report-summary) + +Code-repon viimeinen job (`report-summary`) lisää GitOps-päivityksestä +rivin GITHUB_STEP_SUMMARYyn: + +```yaml + - name: GitOps summary + if: always() + env: + GITEA_API_URL: ${{ fromJson(needs.load-config.outputs.env_json).GITEA_API_URL }} + GITOPS_COMMIT: ${{ steps.dispatch.outputs.gitops_commit }} + VERSION: ${{ needs.check-version.outputs.version }} + run: | + if [ -n "$GITOPS_COMMIT" ]; then + LINK="${GITEA_API_URL}/niko/agent-platform-gitops/commit/${GITOPS_COMMIT}" + else + LINK="#" + fi + cat >> "$GITHUB_STEP_SUMMARY" << 'GITOPS' + + ## GitOps updates + + | Component | Version | Status | Commit | + |-----------|---------|--------|--------| + | agent-platform-helm | __VERSION__ | __STATUS__ | [link](__LINK__) | + GITOPS + sed -i "s|__VERSION__|${VERSION}|; s|__STATUS__|${{ job.status }}|; s|__LINK__|${LINK}|" \ + "$GITHUB_STEP_SUMMARY" +``` + +## Secretit ja tokenit + +| Secret | Missä | Scope | Kuvaus | +|--------|-------|-------|--------| +| `GITEA_TOKEN` (auto) | Code repo | Vain code repo | Asettaa commit-statusin dispatchin jälkeen | +| `GITEA_TOKEN` (auto) | GitOps repo | Vain GitOps repo | Klooni, push, commit-status GitOps-repossa | +| `GITOPS_DISPATCH_TOKEN` (manuaalinen) | Code repo | Write GitOps-repoon | Dispatchaa GitOps-repon workflow | + +**Tokenin luonti:** +1. Gitea → `Settings` → `Applications` → `Generate Token` +2. Valitse repo-oikeudet: valitse GitOps-repo, anna write-oikeudet +3. Token asetetaan code-repoon: `{repo} → Settings → Actions Secrets` +4. Salaisuuden nimi: esim. `GITOPS_DISPATCH_TOKEN` + +## Provider-skriptit + +### `scripts/gitops-update.sh` + +Ajaan GitOps-repon workflow'ssa. Päivittää konfiguraatiotiedoston yq:llä, +committaa ja pushaa. Asettaa commit-statuksen vain GitOps-repoon. + +**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ä | Code-repo slug (esim. `org/app`) | +| `SOURCE_COMMIT` | Kyllä | Code-repon commit SHA | +| `GITOPS_REPO` | Kyllä | GitOps-repo slug | +| `GITEA_API_URL` | Kyllä | Gitean API-URL | +| `GITEA_TOKEN` | Kyllä | Gitea API-token (write GitOps-repoon) | +| `GITOPS_BRANCH` | Ei | Branch (oletus `main`) | +| `GIT_TAG_PREFIX` | Ei | Komponentin tag-prefix status-nimeämiseen (esim. `agent-platform-helm`) | +| `GITOPS_CLONE_URL` | Ei | Yliajaa clone-URL (esim. eri protokolla) | +| `GITOPS_TARGET_DIR` | Ei | Yliajaa clone-kohdehakemisto | + +**Commit-status muoto:** + +GitOps-repoon asetetaan commit-status: + +| Kenttä | Formaatti | Esimerkki | +|--------|-----------|-----------| +| Context | `{repo}/{GIT_TAG_PREFIX} {RUN_ID}` tai `{repo} {RUN_ID}` | `gitea-ci-library/agent-platform-helm 473` | +| Description | `Install to {env} {version}` | `Install to dev 0.2.0` | +| Target URL | Linkki code-repon committiin | `/niko/gitea-ci-library/commit/abc123` | + +Jos tiedosto on jo halutussa versiossa (ei muutoksia), status saa descriptionin `Install to {env} {version} — no change`. Commit-pushia ei tehdä, GitOps-repo pysyy muuttumattomana. + +- `{env}` parsitaan `INPUT_FILE`:stä (`dev/Chart.yaml` → `dev`) +- `{repo}` parsitaan `SOURCE_REPO`:sta (`niko/gitea-ci-library` → `gitea-ci-library`) +- `{GIT_TAG_PREFIX}` tulee env-varista (sama kuin `gitea-env.conf`:ssa) + +### `scripts/dispatch-workflow.sh` + +Dispatchaa workflow_dispatchin kohderepoon ja pollaa valmistumista. +Generoi automaattisesti `dispatch_id`-tunnisteen, lisää sen dispatch- +inputteihin ja tunnistaa workflow-runin kohdereposta `display_title`- +kentän perusteella. Toimii luotettavasti vaikka samassa repossa olisi +useita samanaikaisia dispatch-attribuutioita. + +**Argumentit:** + +| # | Pakollinen | Kuvaus | +|---|------------|--------| +| 1 | Kyllä | Kohderepo (esim. `niko/agent-platform-gitops`) | +| 2 | Kyllä | Workflow-tiedosto (esim. `ci-main.yml`) | +| 3 | Kyllä | Branch/ref | +| 4 | Kyllä | Inputs JSON | +| 5 | Kyllä | Gitea API URL | +| 6 | Kyllä | Gitea token | +| 7 | Ei | Aikakatkaisu minuutteina (oletus 360) | + +Kutsujan ei tarvitse välittää `dispatch_id`:tä — skripti generoi sen +itse ja lisää inputteihin ennen dispatchia. + +## [skip ci] + +Commit-viestissä on `[skip ci]`, joka estää GitActions-runneria +triggeröimästä uutta CI-ajoa GitOps-repoon pushista. Näin vältetään +ääretön trigger-loop. + +## Race condition + +`dispatch-workflow.sh` tunnistaa jokaisen dispatchatun runin uniikilla +`dispatch_id`-tunnisteella `display_title`-kentästä. Vaikka useampi +artifakti dispatchaisi samaan aikaan ja useita workflow-runeja olisi +käynnissä rinnakkain, jokainen skripti löytää oikean runinsa. + +## Sääntöjä + +1. **Token ei kirjoita code repoon.** GitOps-repon workflow ei tarvitse + oikeuksia code repoon. Kaikki status-kutsut kohdistuvat vain + GitOps-repoon. Code repo asettaa oman statusnsa itse. +2. **Ei provider-workflowta.** GitOps-päivitys ei ole reusable workflow. + GitOps-repo ajaa `scripts/gitops-update.sh`:n suoraan. +3. **Vain `workflow_dispatch`.** GitOps-repon workflow:ta ei triggeröidä + pushista — se laukeaa vain dispatch-kutsusta. +4. **Dispatch ei palauta tarkkaa SHA:**ta. Code repo ei tiedä GitOps- + commitin SHA:ta ennen dispatch-valmistumista. Status asetetaan + dispatchin exit-koodin perusteella, ei GitOps-commitin tiedoilla. +5. **`dispatch_id` on pakollinen kohde-workflow'ssa** — ilman sitä + `dispatch-workflow.sh` ei löydä oikeaa runia moniajo-tilanteessa. +6. **`[skip ci]` commit-viestissä.** Pakollinen trigger-loopin estoon. diff --git a/tests/dispatch-workflow.bats b/tests/dispatch-workflow.bats index 3f271de..778ea81 100644 --- a/tests/dispatch-workflow.bats +++ b/tests/dispatch-workflow.bats @@ -3,6 +3,7 @@ setup() { source tests/helpers/mock-api.sh export DISPATCH_POLL_INTERVAL="0.1" + export DISPATCH_ID="test123" } teardown() { @@ -12,8 +13,7 @@ teardown() { @test "dispatch succeeds: POST 201, poll running x3 then success → exit 0" { mock_set_sequence '[ {"code":201}, - {"code":200,"body":{"workflow_runs":[{"id":1,"status":"running"}]}}, - {"code":200,"body":{"id":1,"status":"running"}}, + {"code":200,"body":{"workflow_runs":[{"id":1,"display_title":"POC (test123)","run_number":42,"status":"running"}]}}, {"code":200,"body":{"id":1,"status":"running"}}, {"code":200,"body":{"id":1,"status":"running"}}, {"code":200,"body":{"id":1,"status":"completed","conclusion":"success"}} @@ -26,7 +26,7 @@ teardown() { @test "dispatch: poll returns failure conclusion → exit 1" { mock_set_sequence '[ {"code":201}, - {"code":200,"body":{"workflow_runs":[{"id":1,"status":"running"}]}}, + {"code":200,"body":{"workflow_runs":[{"id":1,"display_title":"POC (test123)","run_number":42,"status":"running"}]}}, {"code":200,"body":{"id":1,"status":"running"}}, {"code":200,"body":{"id":1,"status":"completed","conclusion":"failure"}} ]' @@ -38,7 +38,7 @@ teardown() { @test "dispatch: poll returns cancelled conclusion → exit 1" { mock_set_sequence '[ {"code":201}, - {"code":200,"body":{"workflow_runs":[{"id":1,"status":"running"}]}}, + {"code":200,"body":{"workflow_runs":[{"id":1,"display_title":"POC (test123)","run_number":42,"status":"running"}]}}, {"code":200,"body":{"id":1,"status":"running"}}, {"code":200,"body":{"id":1,"status":"completed","conclusion":"cancelled"}} ]' @@ -47,18 +47,18 @@ teardown() { [ "$status" -eq 1 ] } -@test "timeout: poll never completes, exceeds timeout_minutes → exit 124" { +@test "timeout: no matching run found, exceeds timeout_minutes → exit 124" { mock_set_sequence '[ {"code":201}, - {"code":200,"body":{"workflow_runs":[{"id":1,"status":"running"}]}}, - {"code":200,"body":{"id":1,"status":"running"}}, - {"code":200,"body":{"id":1,"status":"running"}}, - {"code":200,"body":{"id":1,"status":"running"}}, - {"code":200,"body":{"id":1,"status":"running"}}, - {"code":200,"body":{"id":1,"status":"running"}}, - {"code":200,"body":{"id":1,"status":"running"}}, - {"code":200,"body":{"id":1,"status":"running"}}, - {"code":200,"body":{"id":1,"status":"running"}} + {"code":200,"body":{"workflow_runs":[]}}, + {"code":200,"body":{"workflow_runs":[]}}, + {"code":200,"body":{"workflow_runs":[]}}, + {"code":200,"body":{"workflow_runs":[]}}, + {"code":200,"body":{"workflow_runs":[]}}, + {"code":200,"body":{"workflow_runs":[]}}, + {"code":200,"body":{"workflow_runs":[]}}, + {"code":200,"body":{"workflow_runs":[]}}, + {"code":200,"body":{"workflow_runs":[]}} ]' mock_start run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" "0.001" @@ -77,7 +77,7 @@ teardown() { @test "POST dispatch is called with correct URL and payload" { mock_set_sequence '[ {"code":201}, - {"code":200,"body":{"workflow_runs":[{"id":1,"status":"running"}]}}, + {"code":200,"body":{"workflow_runs":[{"id":1,"display_title":"POC (test123)","run_number":42,"status":"running"}]}}, {"code":200,"body":{"id":1,"status":"completed","conclusion":"success"}} ]' mock_start @@ -91,6 +91,7 @@ teardown() { [[ "$body" == *'"ref":"main"'* ]] [[ "$body" == *'"inputs"'* ]] [[ "$body" == *'"version":"1.2.3"'* ]] + [[ "$body" == *'"dispatch_id":"test123"'* ]] } @test "missing gitea_api_url argument → exit 1 with error message" { @@ -120,15 +121,15 @@ teardown() { [ "$status" -eq 1 ] } -@test "dispatch: no workflow run found after dispatch → exit 1" { +@test "dispatch: no workflow run found after dispatch → exit 124 (timeout)" { mock_set_sequence '[ {"code":201}, {"code":200,"body":{"workflow_runs":[]}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{}' "http://localhost:18080" "test-token-abc123" - [ "$status" -eq 1 ] - [[ "$output" == *"ERROR"* ]] + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{}' "http://localhost:18080" "test-token-abc123" "0.001" + [ "$status" -eq 124 ] + [[ "$output" == *"ERROR"* || "$output" == *"Timeout"* ]] } @test "missing inputs_json argument → exit 1" { diff --git a/tests/features/gitops-update.feature b/tests/features/gitops-update.feature new file mode 100644 index 0000000..83c200e --- /dev/null +++ b/tests/features/gitops-update.feature @@ -0,0 +1,42 @@ +Feature: GitOps update + As a GitOps repository + I want to update version references and report results 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 + Scenario: Not enough env vars — script fails, no status set + Given insufficient environment variables are provided for the GitOps update + When the GitOps update script runs + Then the script exits with error + + @mock + Scenario: GitOps job fails — no status set (SHA not yet known) + Given the GitOps repository clone will fail + When the GitOps update script runs + Then the script exits with error + + @mock + Scenario: Everything succeeds — GitOps repo gets success status with link to caller + Given a valid GitOps update dispatch + When the GitOps update script runs + Then the script exits successfully + And the GitOps repo commit shows a success status with a link to the caller commit + + @mock + Scenario: GitOps push fails — GitOps repo gets 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 GitOps repo commit shows a failure status linking to the caller commit + + @mock + Scenario: No changes — GitOps repo gets "no change" status + Given the version file already has the target version + When the GitOps update script runs + Then the script exits successfully + And the GitOps repo commit shows a "no change" status + And no Git commit or push was performed 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..997a0a8 --- /dev/null +++ b/tests/features/step_definitions/gitops-update.steps.js @@ -0,0 +1,172 @@ +const { spawnSync, execSync } = require('child_process'); +const { Before, After, Given, 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 REQ_FILE = '/tmp/gitops-mock-requests.log'; + +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}`; + try { execSync('rm -f /tmp/gitops-mock-requests.log', { stdio: 'ignore' }); } catch (_) {} + // Restart mock with known request file path + const result = spawnSync('bash', ['-c', ` + source "${MOCK_SCRIPT}" + mock_stop 2>/dev/null + MOCK_REQUEST_FILE="${REQ_FILE}" + mock_start + sleep 0.5 + curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:18080/api/v1/repos/health + `], { + cwd: PROJECT_ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] + }); + const code = result.stdout.trim(); + if (!code.startsWith('2') && !code.startsWith('4')) { + throw new Error(`GitOps mock restart failed: ${result.stderr.substring(0,200)}`); + } +}); + +After({ tags: '@mock' }, function () { + spawnSync('bash', ['-c', `source "${MOCK_SCRIPT}" && mock_stop 2>/dev/null`], { stdio: 'ignore' }); + try { execSync('rm -f /tmp/gitops-mock-requests.log /tmp/gitops-git-calls.log', { stdio: 'ignore' }); } catch (_) {} +}); + +function bash(cmd) { + const result = spawnSync('bash', ['-c', cmd], { + cwd: PROJECT_ROOT, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '' }; +} + +function getFirstBody() { + return bash(`grep -A1 '^POST ' "${REQ_FILE}" 2>/dev/null | head -2 | tail -1 || echo ""`).stdout.trim(); +} + +function getFirstPath() { + return bash(`grep '^POST ' "${REQ_FILE}" 2>/dev/null | head -1 | awk '{print $2}' || echo ""`).stdout.trim(); +} + +function getLastBody() { + return bash(`grep -A1 '^POST ' "${REQ_FILE}" 2>/dev/null | grep -v '^POST ' | tail -1 || echo ""`).stdout.trim(); +} + +function getLastPath() { + return bash(`grep '^POST ' "${REQ_FILE}" 2>/dev/null | tail -1 | awk '{print $2}' || echo ""`).stdout.trim(); +} + +function requestCount() { + return parseInt(bash(`grep -c '^POST ' "${REQ_FILE}" 2>/dev/null || echo 0`).stdout.trim(), 10) || 0; +} + +function gitCalls() { + const callsFile = process.env.GIT_CALLS_FILE || '/dev/null'; + const out = bash(`cat "${callsFile}" 2>/dev/null || echo ""`).stdout; + return out.split('\n').filter(l => l.length > 0); +} + +function runScript(envOverrides) { + const env = { ...BASE_ENV, ...envOverrides }; + const scriptPath = `/tmp/gitops-run-${Date.now()}.sh`; + const exports = Object.entries(env) + .map(([k, v]) => `export ${k}="${v.replace(/"/g, '\\"')}"`) + .join('\n'); + require('fs').writeFileSync(scriptPath, `${exports}\nexport PATH="${MOCK_HELPERS}:$PATH"\nset -euo pipefail\nbash "${GITOPS_SCRIPT}"\nsync\n`, 'utf8'); + try { + return bash(`bash "${scriptPath}"`); + } finally { + require('fs').unlinkSync(scriptPath); + } +} + +Given('insufficient environment variables are provided for the GitOps update', function () { + this.envOverrides = { INPUT_FILE: '' }; +}); + +Given('the GitOps repository clone will fail', function () { + this.envOverrides = { GIT_MOCK_FAIL: '1' }; +}); + +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' }; +}); + +Given('the version file already has the target version', function () { + this.envOverrides = { + GIT_MOCK_DIFF_NO_CHANGES: '1', + GIT_CALLS_FILE: '/tmp/gitops-git-calls.log', + }; +}); + +When('the GitOps update script runs', function () { + this.result = runScript(this.envOverrides || {}); +}); + +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.substring(0,200)}`); +}); + +Then('the script exits successfully', function () { + if (this.result.status !== 0) throw new Error(`Expected exit 0, got ${this.result.status}: ${this.result.stderr.substring(0,200)}`); +}); + +Then('the GitOps repo commit shows a success status with a link to the caller commit', function () { + const count = requestCount(); + if (count < 1) throw new Error(`Expected at least 1 request, got ${count}`); + const body = getFirstBody(); + if (!body.includes('"state":"success"')) throw new Error(`Expected success state, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"app ')) throw new Error(`Expected context "app unknown", body: ${body.substring(0,200)}`); + if (!body.includes('"description":"Install to dev 0.2.3"')) throw new Error(`Expected description, body: ${body.substring(0,200)}`); + if (!body.includes('niko/app/commit/abc123def456')) throw new Error(`Expected link to caller commit, body: ${body.substring(0,200)}`); + const pathStr = getFirstPath(); + 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 commit', function () { + const count = requestCount(); + if (count < 1) throw new Error(`Expected at least 1 request, got ${count}`); + const body = getFirstBody(); + if (!body.includes('"state":"failure"')) throw new Error(`Expected failure state, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"app ')) throw new Error(`Expected context "app unknown", body: ${body.substring(0,200)}`); + if (!body.includes('"description":"Install to dev 0.2.3"')) throw new Error(`Expected description, body: ${body.substring(0,200)}`); + if (!body.includes('niko/app/commit/abc123def456')) throw new Error(`Expected link to caller commit, body: ${body.substring(0,200)}`); + const pathStr = getFirstPath(); + if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error(`Expected gitops repo path, got: ${pathStr}`); +}); + +Then('the GitOps repo commit shows a "no change" status', function () { + const count = requestCount(); + if (count < 1) throw new Error(`Expected at least 1 request, got ${count}`); + const body = getFirstBody(); + if (!body.includes('"state":"success"')) throw new Error(`Expected success state, body: ${body.substring(0,200)}`); + if (!body.includes('"description":"Install to dev 0.2.3 \u2014 no change"')) { + throw new Error(`Expected "no change" description, body: ${body.substring(0,200)}`); + } + const pathStr = getFirstPath(); + if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error(`Expected gitops repo path, got: ${pathStr}`); +}); + +Then('no Git commit or push was performed', function () { + const calls = gitCalls(); + if (calls.some(l => l.includes(' commit ') || l.includes(' push '))) { + throw new Error(`Expected no commit or push, got: ${calls.join(', ')}`); + } +}); diff --git a/tests/features/step_definitions/test-execution.steps.js b/tests/features/step_definitions/test-execution.steps.js index 8657eb7..27fe35d 100644 --- a/tests/features/step_definitions/test-execution.steps.js +++ b/tests/features/step_definitions/test-execution.steps.js @@ -15,7 +15,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 || '' }; } @@ -54,7 +54,7 @@ function setupMock(seqJson) { } function runDispatch(args) { - return bash(`export DISPATCH_POLL_INTERVAL="0.1"; bash "${DISPATCH_SCRIPT}" ${args}`); + return bash(`export DISPATCH_ID="test123"; export DISPATCH_POLL_INTERVAL="0.1"; bash "${DISPATCH_SCRIPT}" ${args}`); } Given('a deployment has completed in the target environment', function () { @@ -66,7 +66,7 @@ Given('the test project repository exists with test definitions', function () { When('a test workflow is dispatched to a test project', function () { setupMock(JSON.stringify([ { code: 201 }, - { code: 200, body: { workflow_runs: [{ id: 1, status: 'running' }] } }, + { code: 200, body: { workflow_runs: [{ id: 1, display_title: 'Workflow (test123)', run_number: 42, status: 'running' }] } }, { code: 200, body: { id: 1, status: 'completed', conclusion: 'success' } }, ])); const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123"'); @@ -84,7 +84,7 @@ Then('the pipeline continues only after receiving a success result', function () When('a test workflow is dispatched and the tests fail', function () { setupMock(JSON.stringify([ { code: 201 }, - { code: 200, body: { workflow_runs: [{ id: 1, status: 'running' }] } }, + { code: 200, body: { workflow_runs: [{ id: 1, display_title: 'Workflow (test123)', run_number: 42, status: 'running' }] } }, { code: 200, body: { id: 1, status: 'completed', conclusion: 'failure' } }, ])); const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123"'); @@ -98,15 +98,19 @@ Then('the calling pipeline reports failure', function () { When('a test workflow is dispatched but does not finish within the allowed time', function () { setupMock(JSON.stringify([ { code: 201 }, - { code: 200, body: { workflow_runs: [{ id: 1, status: 'running' }] } }, - { code: 200, body: { id: 1, status: 'running' } }, - { code: 200, body: { id: 1, status: 'running' } }, - { code: 200, body: { id: 1, status: 'running' } }, + { code: 200, body: { workflow_runs: [] } }, + { code: 200, body: { workflow_runs: [] } }, + { code: 200, body: { workflow_runs: [] } }, + { code: 200, body: { workflow_runs: [] } }, + { code: 200, body: { workflow_runs: [] } }, ])); - const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123" "0.001"'); + const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123" "0.05"'); this.dispatchResult = r.status; + this.dispatchStderr = r.stderr; }); Then('the calling pipeline reports a timeout error', function () { - if (this.dispatchResult !== 124) throw new Error(`Expected timeout exit 124, got ${this.dispatchResult}`); + if (this.dispatchResult !== 124) { + throw new Error(`Expected timeout exit 124, got ${this.dispatchResult}. stderr: ${(this.dispatchStderr || '').substring(0,300)}`); + } }); diff --git a/tests/gitops-update.bats b/tests/gitops-update.bats new file mode 100644 index 0000000..17afb2a --- /dev/null +++ b/tests/gitops-update.bats @@ -0,0 +1,176 @@ +#!/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 "one commit-status call: gitops-repo only" { + source tests/helpers/mock-api.sh + mock_set_sequence '[ + {"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 ] + path=$(mock_get_first_request_path) + body=$(mock_get_first_request_body) + [[ "$path" == *"/repos/niko/app-gitops/statuses/"* ]] + [[ "$body" == *'"context":"app '* ]] + [[ "$body" == *'"description":"Install to dev 0.2.3"'* ]] + [[ "$body" == *'"state":"success"'* ]] + 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..5173d84 --- /dev/null +++ b/tests/helpers/git @@ -0,0 +1,40 @@ +#!/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 + +# Skip -c config arguments +while [ "${1:-}" = "-c" ]; do + shift 2 +done + +case "$1" in + clone) + TARGET_DIR="${@: -1}" + mkdir -p "${TARGET_DIR}/$(dirname "$INPUT_FILE")" + echo 'version: 0.1.0' > "${TARGET_DIR}/${INPUT_FILE}" + echo "Cloning into '$TARGET_DIR'..." + ;; + add|commit|push|config|init) + ;; + diff) + # Default: exit 1 = has changes → proceed to commit + # GIT_MOCK_DIFF_NO_CHANGES=1 → exit 0 = no changes → "no change" path + if [ -n "${GIT_MOCK_DIFF_NO_CHANGES:-}" ]; then + exit 0 + fi + exit 1 + ;; + rev-parse) + echo "mock-sha-9876543210fedcba9876543210fedcba98765432" + ;; + *) + echo "git: unknown command: $*" >&2 + exit 1 + ;; +esac diff --git a/tests/helpers/mock-api.sh b/tests/helpers/mock-api.sh index e6ad4b5..826ccb1 100644 --- a/tests/helpers/mock-api.sh +++ b/tests/helpers/mock-api.sh @@ -46,7 +46,7 @@ mock_clear_sequence() { mock_start() { MOCK_RESPONSE_CODE="${MOCK_RESPONSE_CODE:-201}" - MOCK_REQUEST_FILE=$(mktemp) + MOCK_REQUEST_FILE="${MOCK_REQUEST_FILE:-$(mktemp)}" echo "$MOCK_REQUEST_FILE" > "$MOCK_STATE_FILE" MOCK_CONFIG_FILE=$(mktemp) 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}"