diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 9f53c5eb..8c7a3733 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -9,17 +9,27 @@ on: - 'helm/**' - 'docker-compose/**' - 'structures-js/**' - - 'webdocs/**' + - 'webdocs/**' - 'website/**' + pull_request: + branches: + - develop + paths-ignore: + - 'helm/**' + - 'docker-compose/**' + - 'structures-js/**' + - 'webdocs/**' + - 'website/**' + +concurrency: + group: gradle-build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: gradle_build_and_publish: name: Build and Publish runs-on: ubuntu-latest steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 - - name: Checkout code uses: actions/checkout@v4 @@ -34,16 +44,39 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + # gradle.properties holds the plain version. Effective version by event: + # PR -> -pr. (pullable image to test the PR) + # develop push -> -SNAPSHOT (development build) + # main push -> (release, published as-is) + - name: Determine build version + id: ver + run: | + BASE=$(grep '^structuresVersion=' gradle.properties | cut -d= -f2) + BASE=${BASE%-SNAPSHOT} + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) + VERSION="${BASE}-pr${{ github.event.pull_request.number }}.${SHORT_SHA}" + echo "gradle_args=-PstructuresVersion=${VERSION}" >> "$GITHUB_OUTPUT" + elif [[ "${{ github.ref_name }}" == "main" ]]; then + VERSION="${BASE}" + echo "gradle_args=-Prelease" >> "$GITHUB_OUTPUT" + else + VERSION="${BASE}-SNAPSHOT" + echo "gradle_args=" >> "$GITHUB_OUTPUT" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Building version ${VERSION}" + - name: Build with Gradle id: gradle_build continue-on-error: true env: TESTCONTAINERS_RYUK_DISABLED: true - run: ./gradlew build + run: ./gradlew build ${{ steps.ver.outputs.gradle_args }} - name: Publish to Maven Central with JReleaser - if: ${{ steps.gradle_build.outcome == 'success' }} - run: ./gradlew publish && ./gradlew jreleaserDeploy --info + if: ${{ github.event_name == 'push' && steps.gradle_build.outcome == 'success' }} + run: ./gradlew publish ${{ steps.ver.outputs.gradle_args }} && ./gradlew jreleaserDeploy --info ${{ steps.ver.outputs.gradle_args }} env: JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.MAVEN_TOKEN_USERNAME }} JRELEASER_MAVENCENTRAL_TOKEN: ${{ secrets.MAVEN_TOKEN_PASSWORD }} @@ -52,9 +85,11 @@ jobs: JRELEASER_GPG_SECRET_KEY: ${{ secrets.GPG_KEY_NEW }} JRELEASER_GPG_PASSPHRASE: ${{ secrets.GPG_KEY_PASS_NEW }} + # Fork and dependabot PRs do not receive the Docker Hub secrets, so only + # publish images for pushes and same-repo PRs. - name: Publish to Docker Hub - if: ${{ steps.gradle_build.outcome == 'success' }} - run: ./gradlew bootBuildImage --publishImage + if: ${{ steps.gradle_build.outcome == 'success' && (github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) }} + run: ./gradlew bootBuildImage --publishImage ${{ steps.ver.outputs.gradle_args }} env: DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} @@ -92,6 +127,10 @@ jobs: - name: Run E2E Tests id: run_e2e_tests continue-on-error: true + env: + # Shell env overrides the gradle.properties env-file in compose + # interpolation, so e2e pulls the image this run just published + structuresVersion: ${{ steps.ver.outputs.version }} run: | cd structures-js/structures-e2e gradle pnpmInstall @@ -111,7 +150,7 @@ jobs: path: gh-pages - name: Generate Allure report - uses: simple-elf/allure-report-action@v1.13 + uses: simple-elf/allure-report-action@v1.15 id: allure-report with: allure_results: allure-results @@ -135,7 +174,7 @@ jobs: context: 'Test Report' description: 'Passed' state: 'success' - sha: ${{ github.sha }} + sha: ${{ github.event.pull_request.head.sha || github.sha }} target_url: https://mindsignited.github.io/structures/allure/${{ github.run_number }} - name: Check If Failure @@ -151,5 +190,3 @@ jobs: # Optionally mark the job as failed exit 1 - - https://mindsignited.github.io/structures/webdocs/guide/overview.html diff --git a/.github/workflows/structures-js-publish.yml b/.github/workflows/structures-js-publish.yml new file mode 100644 index 00000000..28ba9600 --- /dev/null +++ b/.github/workflows/structures-js-publish.yml @@ -0,0 +1,96 @@ +name: Publish structures-js packages + +# Not wired up to automatic triggers yet — run manually from the Actions tab. +# To enable automatic publishing, replace `on:` with: +# on: +# push: +# branches: +# - develop +# tags: +# - 'v*' +on: + workflow_dispatch: + +concurrency: + group: structures-js-publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish: + name: Publish @kinotic/structures-api and @kinotic/structures-cli + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: 'https://registry.npmjs.org' + scope: '@kinotic' + cache: pnpm + cache-dependency-path: | + structures-js/structures-api/pnpm-lock.yaml + structures-js/structures-cli/pnpm-lock.yaml + + - name: Determine version and dist-tag + id: ver + run: | + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + DIST_TAG="latest" + else + BASE=$(node -p "require('./structures-js/structures-api/package.json').version") + BASE=${BASE%%-*} + SHORT_SHA=$(git rev-parse --short=7 HEAD) + VERSION="${BASE}-dev.${SHORT_SHA}" + DIST_TAG="dev" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "dist_tag=${DIST_TAG}" >> "$GITHUB_OUTPUT" + echo "Publishing version ${VERSION} with dist-tag ${DIST_TAG}" + + - name: Set version in structures-api + working-directory: structures-js/structures-api + run: npm version --no-git-tag-version --allow-same-version "${{ steps.ver.outputs.version }}" + + - name: Install structures-api deps + working-directory: structures-js/structures-api + run: pnpm install --frozen-lockfile + + - name: Build structures-api + working-directory: structures-js/structures-api + run: pnpm build + + - name: Publish structures-api to npm + working-directory: structures-js/structures-api + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm publish --access public --no-git-checks --tag "${{ steps.ver.outputs.dist_tag }}" + + - name: Set version and pin api dep in structures-cli + working-directory: structures-js/structures-cli + run: | + npm version --no-git-tag-version --allow-same-version "${{ steps.ver.outputs.version }}" + node -e " + const fs = require('fs'); + const p = JSON.parse(fs.readFileSync('package.json', 'utf8')); + p.dependencies['@kinotic/structures-api'] = '${{ steps.ver.outputs.version }}'; + fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n'); + " + + - name: Install structures-cli deps + working-directory: structures-js/structures-cli + run: pnpm install --no-frozen-lockfile + + - name: Publish structures-cli to npm + working-directory: structures-js/structures-cli + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm publish --access public --no-git-checks --tag "${{ steps.ver.outputs.dist_tag }}" \ No newline at end of file diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml new file mode 100644 index 00000000..47b3216d --- /dev/null +++ b/.github/workflows/version-check.yml @@ -0,0 +1,54 @@ +name: Version Check + +on: + pull_request: + branches: + - main + +jobs: + require_version_bump: + name: Require version bump + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compare version with base branch + run: | + HEAD_VERSION=$(grep '^structuresVersion=' gradle.properties | cut -d= -f2) + + git fetch origin "${{ github.base_ref }}" --depth=1 + BASE_VERSION=$(git show "origin/${{ github.base_ref }}:gradle.properties" | grep '^structuresVersion=' | cut -d= -f2) + + # The numeric core ignores any pre-release suffix (-SNAPSHOT, -rc1, ...) + HEAD_CORE=${HEAD_VERSION%%-*} + BASE_CORE=${BASE_VERSION%%-*} + + echo "Base (${{ github.base_ref }}): ${BASE_VERSION}" + echo "Head: ${HEAD_VERSION}" + + fail() { echo "::error::$1"; exit 1; } + + if [[ "${HEAD_CORE}" == "${BASE_CORE}" ]]; then + if [[ "${HEAD_VERSION}" == "${BASE_VERSION}" ]]; then + fail "structuresVersion is still ${HEAD_VERSION}; bump it in gradle.properties before merging to ${{ github.base_ref }} (releases are immutable in Maven Central)" + elif [[ "${HEAD_VERSION}" == "${HEAD_CORE}" ]]; then + # pre-release -> release promotion of the same version (e.g. 3.6.0-rc1 -> 3.6.0) + echo "Version promotion OK: ${BASE_VERSION} -> ${HEAD_VERSION}" + elif [[ "${BASE_VERSION}" == "${BASE_CORE}" ]]; then + fail "structuresVersion ${HEAD_VERSION} is a pre-release of ${BASE_VERSION}, which is already released" + else + # both pre-releases of the same version: head must sort after base + HIGHEST=$(printf '%s\n%s\n' "${BASE_VERSION}" "${HEAD_VERSION}" | sort -V | tail -1) + if [[ "${HIGHEST}" != "${HEAD_VERSION}" ]]; then + fail "structuresVersion ${HEAD_VERSION} is lower than ${BASE_VERSION} on ${{ github.base_ref }}" + fi + echo "Version bump OK: ${BASE_VERSION} -> ${HEAD_VERSION}" + fi + else + HIGHEST=$(printf '%s\n%s\n' "${BASE_CORE}" "${HEAD_CORE}" | sort -V | tail -1) + if [[ "${HIGHEST}" != "${HEAD_CORE}" ]]; then + fail "structuresVersion ${HEAD_VERSION} is lower than ${BASE_VERSION} on ${{ github.base_ref }}" + fi + echo "Version bump OK: ${BASE_VERSION} -> ${HEAD_VERSION}" + fi diff --git a/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle b/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle index c4a79e69..dede9ec5 100644 --- a/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle +++ b/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle @@ -34,18 +34,51 @@ dependencies { // bootBuildImage task source https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java // Paketo options here https://paketo.io/docs/howto/java/#configure-the-jvm-at-runtime // https://paketo.io/docs/reference/java-reference/ + +// Full --add-opens set recommended by Apache Ignite for JDK 11+ +// https://ignite.apache.org/docs/latest/quick-start/java#running-ignite-with-java-11-or-later +// A missing entry surfaces at runtime as InaccessibleObjectException during Ignite marshalling +def igniteAddOpens = [ + 'java.base/jdk.internal.access', + 'java.base/jdk.internal.misc', + 'java.base/sun.nio.ch', + 'java.base/sun.util.calendar', + 'java.management/com.sun.jmx.mbeanserver', + 'jdk.internal.jvmstat/sun.jvmstat.monitor', + 'java.base/sun.reflect.generics.reflectiveObjects', + 'jdk.management/com.sun.management.internal', + 'java.base/java.io', + 'java.base/java.nio', + 'java.base/java.net', + 'java.base/java.util', + 'java.base/java.util.concurrent', + 'java.base/java.util.concurrent.locks', + 'java.base/java.util.concurrent.atomic', + 'java.base/java.lang', + 'java.base/java.lang.invoke', + 'java.base/java.lang.reflect', + 'java.base/java.math', + 'java.sql/java.sql', + 'java.base/java.time', + 'java.base/java.text', + 'java.management/sun.management', + 'java.desktop/java.awt.font' +].collect { "--add-opens=${it}=ALL-UNNAMED" }.join(' ') + bootBuildImage { network = "host" - publish = true + // Local builds only produce the image; CI publishes by passing --publishImage + publish = false imageName = "mindsignited/${project.name}:${project.version}" - tags = (!version.endsWith('SNAPSHOT') ? + // Only release versions (no -SNAPSHOT / -prN.sha suffix) may claim the latest tag + tags = (!version.contains('-') ? [ "mindsignited/${project.name}:latest" ] : []) environment = [ "BPE_DELIM_JAVA_TOOL_OPTIONS" : " ", - "BPE_APPEND_JAVA_TOOL_OPTIONS": "-XX:+UseG1GC -XX:+ScavengeBeforeFullGC -XX:+DisableExplicitGC --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED --add-opens=java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED" + "BPE_APPEND_JAVA_TOOL_OPTIONS": "-XX:+UseG1GC -XX:+ScavengeBeforeFullGC -XX:+DisableExplicitGC ${igniteAddOpens}".toString() ] docker { publishRegistry { diff --git a/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle b/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle index 7a3b3000..7e0a4a2a 100644 --- a/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle +++ b/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle @@ -5,7 +5,18 @@ plugins { } group 'org.kinotic' -version "${structuresVersion}" + +// gradle.properties holds the plain version (e.g. 3.5.8). Development builds get +// -SNAPSHOT appended automatically; CI passes -Prelease on main to publish the +// version as-is (stripping a leftover -SNAPSHOT so the guard and the build agree), +// and PR builds pass an explicit -PstructuresVersion override. +def effectiveVersion = "${structuresVersion}" +if (hasProperty('release')) { + effectiveVersion = effectiveVersion - '-SNAPSHOT' +} else if (!effectiveVersion.contains('-')) { + effectiveVersion += '-SNAPSHOT' +} +version effectiveVersion sourceCompatibility = '21' repositories { diff --git a/dev-tools/kind/KIND_IMAGE_LOADING.md b/dev-tools/kind/KIND_IMAGE_LOADING.md index a591095b..7202e18e 100644 --- a/dev-tools/kind/KIND_IMAGE_LOADING.md +++ b/dev-tools/kind/KIND_IMAGE_LOADING.md @@ -1,199 +1,61 @@ # KinD Image Loading Strategy -**Issue**: KinD clusters cannot pull images directly from Docker registries (docker.io, gcr.io, etc.) because they're isolated from the internet. +KinD cluster nodes run containerd with normal outbound network access, so they +**pull public registry images directly** (docker.elastic.co, docker.io, ghcr.io, +etc.) exactly like any other Kubernetes cluster. No manual loading is needed for +published images — the Helm charts reference the image/tag in their values files +and the nodes pull with `imagePullPolicy: IfNotPresent`. -**Additional Issue**: Multi-platform images (like Elasticsearch) can fail with `kind load docker-image` due to manifest format issues. +`kind load` is only for images that exist **solely in the host Docker daemon** +— i.e. locally built images that no registry serves: -**Solution**: -1. Pre-pull images on the host machine with explicit platform -2. Use `docker save` to create a tarball -3. Load tarball into KinD cluster using `kind load image-archive` +| Image | Source | How it reaches the cluster | +|-------|--------|---------------------------| +| `mindsignited/structures-server` (local build) | `./gradlew :structures-server:bootBuildImage` | `kind load docker-image` + `pullPolicy: Never` | +| `mindsignited/structures-migration` (local build) | `bootBuildImage` | `kind load docker-image` + `pullPolicy: Never` | +| Elasticsearch, Keycloak, PostgreSQL, ingress-nginx, cert-manager | Public registries | Pulled by the nodes directly | -## Image Loading Methods +## Loading a locally built image -### Method 1: Direct Load (Simple Images) -For simple single-platform images: ```bash -docker pull docker.io/bitnami/postgresql:15.5.0 -kind load docker-image docker.io/bitnami/postgresql:15.5.0 --name structures-cluster +./gradlew :structures-server:bootBuildImage +kind load docker-image mindsignited/structures-server: --name structures-cluster ``` -**Use for**: PostgreSQL, Keycloak, structures-server +The structures-server values file sets `pullPolicy: Never` so the node uses the +loaded image and never tries to pull a tag that only exists locally. -### Method 2: Tarball Load (Multi-Platform Images) -For complex multi-platform images: -```bash -# Pull for specific platform -docker pull --platform linux/amd64 docker.elastic.co/elasticsearch/elasticsearch:8.18.1 - -# Save to tarball -docker save docker.elastic.co/elasticsearch/elasticsearch:8.18.1 -o /tmp/elasticsearch.tar - -# Load tarball into cluster -kind load image-archive /tmp/elasticsearch.tar --name structures-cluster - -# Clean up -rm /tmp/elasticsearch.tar -``` - -**Use for**: Elasticsearch (official Elastic images) - -## Why Two Methods? - -### Multi-Platform Image Problem - -Official Elasticsearch images are **multi-platform** (support amd64, arm64, etc.). When you run: -```bash -kind load docker-image docker.elastic.co/elasticsearch/elasticsearch:8.18.1 -``` - -KinD tries to load the image but encounters: -``` -ERROR: failed to load image: ctr: content digest sha256:xxx: not found -``` - -This happens because: -1. Docker stores multi-platform images with **manifests** pointing to platform-specific layers -2. `kind load docker-image` doesn't handle these manifests correctly -3. The containerd runtime in KinD nodes can't find the referenced content - -### Tarball Method Fixes This - -Using `docker save` + `kind load image-archive`: -1. ✅ `docker save` flattens the image into a single-platform tarball -2. ✅ `kind load image-archive` imports the complete image with all layers -3. ✅ No manifest issues - everything is in the tarball -4. ✅ Works reliably for all image types - -## Updated Functions - -All deployment functions now follow this pattern: - -### 1. **Elasticsearch** (v8.11.1) -```bash -# 1. Check if image exists locally -# 2. Pull if needed: docker pull docker.io/bitnami/elasticsearch:8.11.1 -# 3. Load into cluster: kind load docker-image --name structures-cluster -# 4. Deploy with Helm, specifying --set image.tag=8.11.1 -``` - -### 2. **PostgreSQL** (v15.5.0) -```bash -# Same pattern: -# docker.io/bitnami/postgresql:15.5.0 -``` - -### 3. **Keycloak** (v26.0.2) -```bash -# Same pattern: -# docker.io/bitnami/keycloak:26.0.2 -``` - -## How It Works +## History: why Elasticsearch used to be pre-loaded -```bash -# Example: Loading Elasticsearch - -# Step 1: Pull image from Docker Hub to local Docker -docker pull docker.io/bitnami/elasticsearch:8.11.1 - -# Step 2: Load image into KinD cluster nodes -kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster - -# Step 3: Verify image is available in cluster -docker exec structures-cluster-control-plane crictl images | grep elasticsearch - -# Step 4: Deploy with Helm (image.pullPolicy defaults to IfNotPresent) -helm upgrade --install elasticsearch bitnami/elasticsearch --set image.tag=8.11.1 -``` - -## Benefits - -✅ **Predictable versions** - Pin to known working versions -✅ **No network issues** - Images loaded from local Docker -✅ **Faster deployments** - No waiting for image pulls -✅ **Offline capable** - Works without internet once images are cached -✅ **Consistent behavior** - Same images used across deployments - -## Version Selection Rationale - -| Component | Version | Reason | -|-----------|---------|--------| -| Elasticsearch | 8.11.1 | Stable LTS version, compatible with Structures | -| PostgreSQL | 15.5.0 | Latest stable PG 15.x, matches production usage | -| Keycloak | 26.0.2 | Matches docker-compose setup exactly | +Earlier versions of these scripts pre-pulled the Elasticsearch image on the +host, re-tagged it as `localhost/elasticsearch:`, and pushed it into +the nodes with `kind load docker-image`. That existed to work around +multi-platform manifest issues (`ctr: content digest ... not found`) in the +`kind load` path — but `kind load` itself was the only reason the image needed +to touch the host Docker daemon at all. Deploying the chart and letting the +nodes pull the image avoids the entire problem, and also avoids version skew +between a hardcoded pre-load tag in the script and the tag in +`config/elasticsearch/values.yaml` (they had already drifted: 8.18.1 vs +8.19.13). ## Troubleshooting -### Image Pull Fails +### Pod stuck in `ImagePullBackOff` (public image) ```bash -# Check Docker Hub rate limits -docker pull docker.io/bitnami/elasticsearch:8.11.1 +# Check events for the actual pull error +kubectl get events --sort-by='.lastTimestamp' | tail -20 -# If rate limited, authenticate: -docker login +# Docker Hub rate limiting? Authenticate the nodes or pre-load as a workaround: +docker pull && kind load docker-image --name structures-cluster ``` -### Image Load Fails +### Pod stuck in `ErrImageNeverPull` (local image) +The image was not loaded into the nodes. Re-run: ```bash -# Check KinD cluster exists -kind get clusters - -# Check cluster is running -kubectl cluster-info --context kind-structures-cluster - -# Manual load test -kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster +kind load docker-image --name structures-cluster ``` -### Pod Still Shows ImagePullBackOff +### Verify what images a node has ```bash -# Check image is in cluster docker exec structures-cluster-control-plane crictl images - -# Check pod image specification -kubectl get pod -o yaml | grep image: - -# Make sure image.pullPolicy is not Always -kubectl get deployment -o yaml | grep pullPolicy ``` - -## Manual Image Loading - -If you need to pre-load images before deployment: - -```bash -# Pre-load all dependency images -docker pull docker.io/bitnami/elasticsearch:8.11.1 -docker pull docker.io/bitnami/postgresql:15.5.0 -docker pull docker.io/bitnami/keycloak:26.0.2 - -kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster -kind load docker-image docker.io/bitnami/postgresql:15.5.0 --name structures-cluster -kind load docker-image docker.io/bitnami/keycloak:26.0.2 --name structures-cluster - -# Verify -docker exec structures-cluster-control-plane crictl images | grep bitnami -``` - -## Automated in Deploy Script - -The `deploy.sh` functions now automatically: -1. Check if image exists locally (`docker image inspect`) -2. Pull if missing (`docker pull`) -3. Load into KinD (`kind load docker-image`) -4. Deploy with Helm (with pinned version) - -No manual intervention needed! Just run: -```bash -./dev-tools/kind/kind-cluster.sh deploy -``` - -## Future Improvements - -Consider adding: -- Image pre-caching script (`load-images.sh`) -- Version configuration in `config/versions.yaml` -- Multi-architecture support (arm64/amd64) -- Local registry for faster loading (optional) - diff --git a/dev-tools/kind/config/elasticsearch/values.yaml b/dev-tools/kind/config/elasticsearch/values.yaml index 6b1224da..d51d0c2c 100644 --- a/dev-tools/kind/config/elasticsearch/values.yaml +++ b/dev-tools/kind/config/elasticsearch/values.yaml @@ -9,7 +9,7 @@ replicas: 2 minimumMasterNodes: 1 image: "docker.elastic.co/elasticsearch/elasticsearch" -imageTag: "8.18.1" +imageTag: "8.19.13" imagePullPolicy: IfNotPresent # Disable all security for local dev (matching docker-compose) diff --git a/dev-tools/kind/config/structures-server/values.yaml b/dev-tools/kind/config/structures-server/values.yaml index f6da89e2..813303ad 100644 --- a/dev-tools/kind/config/structures-server/values.yaml +++ b/dev-tools/kind/config/structures-server/values.yaml @@ -32,10 +32,11 @@ nameOverride: "" image: # Repository matches bootBuildImage output repository: mindsignited/structures-server - # Tag from gradle.properties version (structuresVersion=3.5.3-SNAPSHOT) - tag: 3.5.7 - # Never pull - use images loaded into KinD cluster - pullPolicy: Never + # Published tag pulled from Docker Hub. Overridden by deploy --tag, or by + # deploy --build-local which uses the locally built gradle.properties version. + tag: 3.5.8-pr7.023aa91 + # Pull from Docker Hub by default; deploy --build-local overrides this to Never + pullPolicy: IfNotPresent sha: "" # Migration configuration @@ -46,9 +47,10 @@ migration: activeDeadlineSeconds: 300 image: repository: mindsignited/structures-migration - tag: 3.5.7 - # Never pull - use images loaded into KinD cluster - pullPolicy: Never + # Keep in sync with image.tag above; deploy --tag sets both + tag: 3.5.8-pr7.023aa91 + # Pull from Docker Hub by default; deploy --build-local overrides this to Never + pullPolicy: IfNotPresent sha: "" ## eviction-tracking uses the evictionTracking logic and paths for tracking eviction events diff --git a/dev-tools/kind/kind-cluster.sh b/dev-tools/kind/kind-cluster.sh index 0e697905..210ad546 100755 --- a/dev-tools/kind/kind-cluster.sh +++ b/dev-tools/kind/kind-cluster.sh @@ -294,6 +294,8 @@ cmd_deploy() { local helm_values_override="" local helm_sets=() local wait_timeout_override="" + local build_local="0" + local image_tag="" # Parse subcommand options while [[ $# -gt 0 ]]; do @@ -334,6 +336,14 @@ cmd_deploy() { DEPLOY_LOAD_GENERATOR="1" shift ;; + --build-local) + build_local="1" + shift + ;; + --tag) + image_tag="$2" + shift 2 + ;; --wait-timeout) wait_timeout_override="$2" shift 2 @@ -354,6 +364,10 @@ Options: --with-keycloak, -k Deploy Keycloak + PostgreSQL and enable OIDC authentication --with-observability Deploy observability stack (OTEL, Prometheus, Grafana) --with-load-generator Run load generator after deployment (generates schemas/test data) + --build-local Build structures-server/migration images from source and load + them into the cluster (default: pull published images from Docker Hub) + --tag Image tag to pull from Docker Hub (default: tag from values file; + ignored with --build-local, which uses the gradle.properties version) --wait-timeout Deployment timeout (default: 5m) --help, -h Show this help message @@ -383,6 +397,12 @@ Examples: # Deploy with inline override $(basename "$0") deploy --set replicaCount=3 + # Deploy a specific published tag (e.g. a PR image) + $(basename "$0") deploy --tag 3.5.8-pr7.023aa91 + + # Build from source and load into the cluster instead of pulling + $(basename "$0") deploy --build-local + EOF return "${EXIT_SUCCESS}" ;; @@ -498,54 +518,77 @@ EOF # Deploy structures-server section "Deploying structures-server" - - # Build additional sets string - local additional_sets="" - + # Add OIDC configuration if Keycloak is deployed if [[ "${DEPLOY_KEYCLOAK}" == "1" ]]; then progress "Enabling OIDC authentication (oidc.enabled=true)" helm_sets+=("--set" "oidc.enabled=true") fi - - if [[ ${#helm_sets[@]} -gt 0 ]]; then - additional_sets="${helm_sets[*]}" - fi - # Build and load structures-server - cmd_build "--load" - - # Build and load structures-migration (used by Helm pre-upgrade hook) - section "Building structures-migration" - progress "Running: ./gradlew :structures-migration:bootBuildImage" - progress "This may take a few minutes..." - blank_line - - export RUNNING_KIND_CLUSTER="true" - if ! execute ./gradlew ":structures-migration:bootBuildImage" 2>&1 | while IFS= read -r line; do - if [[ "${line}" == *"BUILD"* ]] || \ - [[ "${line}" == *"Successfully built"* ]] || \ - [[ "${line}" == *"Paketo"* ]] || \ - [[ "${line}" == *"Error"* ]] || \ - [[ "${line}" == *"FAIL"* ]] || \ - [[ "${VERBOSE}" == "1" ]]; then - echo " ${line}" + if [[ "${build_local}" == "1" ]]; then + # Opt-in: build the images from source and load them into the cluster + local built_version + built_version=$(get_structures_version) || return "${EXIT_DEPLOYMENT_FAILED}" + progress "Building images locally (version ${built_version})" + helm_sets+=("--set" "image.tag=${built_version}") + helm_sets+=("--set" "image.pullPolicy=Never") + helm_sets+=("--set" "migration.image.tag=${built_version}") + helm_sets+=("--set" "migration.image.pullPolicy=Never") + + # Build and load structures-server + if ! cmd_build "--load"; then + return "${EXIT_DEPLOYMENT_FAILED}" + fi + + # Build and load structures-migration (used by Helm pre-upgrade hook) + section "Building structures-migration" + progress "Running: ./gradlew :structures-migration:bootBuildImage" + progress "This may take a few minutes..." + blank_line + + export RUNNING_KIND_CLUSTER="true" + if ! execute ./gradlew ":structures-migration:bootBuildImage" 2>&1 | while IFS= read -r line; do + if [[ "${line}" == *"BUILD"* ]] || \ + [[ "${line}" == *"Successfully built"* ]] || \ + [[ "${line}" == *"Paketo"* ]] || \ + [[ "${line}" == *"Error"* ]] || \ + [[ "${line}" == *"FAIL"* ]] || \ + [[ "${VERBOSE}" == "1" ]]; then + echo " ${line}" + fi + done; then + error "Failed to build structures-migration image" + return "${EXIT_DEPLOYMENT_FAILED}" + fi + + local migration_image + migration_image=$(get_migration_image_name) || return "${EXIT_DEPLOYMENT_FAILED}" + + section "Loading Migration Image into Cluster" + if ! load_image_into_cluster "${CLUSTER_NAME}" "${migration_image}"; then + error "Failed to load migration image into cluster" + return "${EXIT_DEPLOYMENT_FAILED}" + fi + blank_line + else + # Default: cluster nodes pull the published images from Docker Hub + progress "Using published images from Docker Hub (use --build-local to build from source)" + helm_sets+=("--set" "image.pullPolicy=IfNotPresent") + helm_sets+=("--set" "migration.image.pullPolicy=IfNotPresent") + if [[ -n "${image_tag}" ]]; then + progress "Image tag: ${image_tag}" + helm_sets+=("--set" "image.tag=${image_tag}") + helm_sets+=("--set" "migration.image.tag=${image_tag}") fi - done; then - error "Failed to build structures-migration image" - return "${EXIT_DEPLOYMENT_FAILED}" fi - - local migration_image - migration_image=$(get_migration_image_name) || return "${EXIT_DEPLOYMENT_FAILED}" - - section "Loading Migration Image into Cluster" - if ! load_image_into_cluster "${CLUSTER_NAME}" "${migration_image}"; then - error "Failed to load migration image into cluster" - return "${EXIT_DEPLOYMENT_FAILED}" + + # Build additional sets string + local additional_sets="" + if [[ ${#helm_sets[@]} -gt 0 ]]; then + additional_sets="${helm_sets[*]}" fi - blank_line - + + if ! deploy_structures_server "${CLUSTER_NAME}" "${additional_sets}"; then error "Deployment failed" echo "" diff --git a/dev-tools/kind/lib/config.sh b/dev-tools/kind/lib/config.sh index 6d94edf6..94258c96 100755 --- a/dev-tools/kind/lib/config.sh +++ b/dev-tools/kind/lib/config.sh @@ -246,21 +246,35 @@ get_coredns_template_path() { # version=$(get_structures_version) # get_structures_version() { + # Explicit override wins, e.g. deploying the released image from a release + # checkout: structuresVersion=3.5.8 ./kind-cluster.sh deploy + if [[ -n "${structuresVersion:-}" ]]; then + echo "${structuresVersion}" + return 0 + fi + local gradle_props="./gradle.properties" - + if [[ ! -f "${gradle_props}" ]]; then error "gradle.properties not found" return 1 fi - + local version version=$(grep '^structuresVersion=' "${gradle_props}" | cut -d'=' -f2) - + if [[ -z "${version}" ]]; then error "structuresVersion not found in gradle.properties" return 1 fi - + + # Mirror the build convention: plain versions are development builds and get + # -SNAPSHOT appended (see org.kinotic.java-common-conventions.gradle); this + # matches what a local ./gradlew build on the same checkout produces + if [[ "${version}" != *-* ]]; then + version="${version}-SNAPSHOT" + fi + echo "${version}" } diff --git a/dev-tools/kind/lib/deploy.sh b/dev-tools/kind/lib/deploy.sh index ebc907b9..f1f1ed51 100755 --- a/dev-tools/kind/lib/deploy.sh +++ b/dev-tools/kind/lib/deploy.sh @@ -720,63 +720,23 @@ deploy_elasticsearch() { local context="kind-${cluster_name}" progress "Deploying Elasticsearch..." - - # Elasticsearch version (matching docker-compose compose.ek-stack.yml) - local es_version="8.18.1" - local es_image="docker.elastic.co/elasticsearch/elasticsearch:${es_version}" - local local_tag="localhost/elasticsearch:${es_version}" - - # Detect host platform for image pulling - local platform="linux/amd64" - if [[ "$(uname -m)" == "arm64" ]] || [[ "$(uname -m)" == "aarch64" ]]; then - platform="linux/arm64" - fi - - # Pre-pull image for current platform and re-tag to avoid multi-platform issues - progress "Pre-loading Elasticsearch image into cluster..." - if ! docker image inspect "${local_tag}" &>/dev/null; then - progress "Pulling ${es_image} for ${platform}..." - # Pull for specific platform - if ! docker pull --platform "${platform}" "${es_image}"; then - error "Failed to pull Elasticsearch image" - return 1 - fi - - # Re-tag to local name to create clean single-platform reference - progress "Re-tagging image to local reference..." - if ! docker tag "${es_image}" "${local_tag}"; then - error "Failed to tag Elasticsearch image" - return 1 - fi - fi - - # Load the locally-tagged image into KinD (avoids multi-platform issues) - progress "Loading image into KinD cluster..." - if ! kind load docker-image "${local_tag}" --name "${cluster_name}"; then - error "Failed to load Elasticsearch image into cluster" - return 1 - fi - - # Also tag in the cluster as the original name so pods can find it - progress "Tagging image in cluster nodes..." - for node in $(kind get nodes --name "${cluster_name}"); do - docker exec "${node}" ctr -n k8s.io images tag "${local_tag}" "${es_image}" || true - done - + # Get Elasticsearch values file from config directory local values_flags values_flags=$(get_service_helm_flags "elasticsearch") || return 1 - + progress "Using Elasticsearch configuration from: $(get_service_values_path elasticsearch)" - - # Deploy using external values file + + # Deploy using external values file. The image/tag come from the values file + # and cluster nodes pull directly from the registry; manual kind-load is only + # needed for locally built images. Timeout allows for the initial image pull. local helm_output # shellcheck disable=SC2086 helm_output=$(helm upgrade --install elasticsearch elastic/elasticsearch \ --kube-context "${context}" \ --version 8.5.1 \ ${values_flags} \ - --wait --timeout 5m 2>&1) + --wait --timeout 10m 2>&1) local exit_code=$? diff --git a/gradle.properties b/gradle.properties index ef2f06f3..2ccc3b6a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -structuresVersion=3.5.7 +structuresVersion=3.5.8 allureVersion=2.32.0 antlrVersion=4.13.1 diff --git a/structures-frontend/src/plugins/StructuresUI.ts b/structures-frontend/src/plugins/StructuresUI.ts index 7bf65517..71ba56f6 100644 --- a/structures-frontend/src/plugins/StructuresUI.ts +++ b/structures-frontend/src/plugins/StructuresUI.ts @@ -5,15 +5,18 @@ import {StructuresStates} from '@/states/index' export function createStructuresUI(): Plugin { return { install(_: App, options: {router: Router}) { - options.router.beforeEach((to: RouteLocationNormalized, _: RouteLocationNormalized, next: NavigationGuardNext) => { + options.router.beforeEach(async (to: RouteLocationNormalized, _: RouteLocationNormalized, next: NavigationGuardNext) => { const { authenticationRequired } = to.meta - if ((authenticationRequired === undefined || authenticationRequired) - && !StructuresStates.getUserState().isAuthenticated()){ - next({ path: '/login', query: { referer: to.fullPath } }) - } else { - next() + if (authenticationRequired === undefined || authenticationRequired) { + const userState = StructuresStates.getUserState() + if (!userState.isAuthenticated() + && !(await userState.restoreSession())) { + next({ path: '/login', query: { referer: to.fullPath } }) + return + } } + next() }) StructuresStates.getApplicationState().initialize(options.router) diff --git a/structures-frontend/src/states/IUserState.ts b/structures-frontend/src/states/IUserState.ts index 8117e4a3..62ffae0e 100644 --- a/structures-frontend/src/states/IUserState.ts +++ b/structures-frontend/src/states/IUserState.ts @@ -1,13 +1,29 @@ import { ConnectedInfo, ConnectionInfo, Continuum } from '@kinotic/continuum-client' import { reactive } from 'vue' import Cookies from 'js-cookie' -import { User } from 'oidc-client-ts' +import { User, UserManager } from 'oidc-client-ts' import { createDebug } from '@/util/debug' const debug = createDebug('user-state'); import { oidcSessionManager } from '@/util/OidcSessionManager' import { configService } from '@/util/config' import { createConnectionInfo } from '../util/helpers' +import { selectToken, writeTokenCookie } from '@/util/tokenCookie' +import { createUserManagerSettings } from '@/pages/login/OidcConfiguration' + +// Deliberately outside the 'oidc.' prefix so AuthenticationManager.clearOidcState() +// does not wipe it during error recovery. The provider name is not a secret; it is +// needed to rebuild the UserManager when restoring a session after a page refresh. +const AUTH_PROVIDER_KEY = 'auth.provider' + +// Prefix used by the oidc-client-ts WebStorageStateStore for the persisted user +const OIDC_USER_KEY_PREFIX = 'oidc.user:' + +/** + * Thrown when the frontend role gate rejects an otherwise valid token. Lets the + * restore path tell a definitive denial apart from a transient failure. + */ +class FrontendRoleDeniedError extends Error {} export interface IUserState { connectedInfo: ConnectedInfo | null @@ -17,6 +33,7 @@ export interface IUserState { isAuthenticated(): boolean authenticate(login: string, passcode: string): Promise handleOidcLogin(user: User, provider: string): Promise + restoreSession(): Promise logout(): Promise } @@ -25,6 +42,7 @@ export class UserState implements IUserState { public oidcUser: User | null = null private authenticated: boolean = false private accessDenied: boolean = false + private restorePromise: Promise | null = null public async authenticate(login: string, passcode: string): Promise { try { @@ -62,22 +80,111 @@ export class UserState implements IUserState { debug('No existing connection to disconnect') } - const connectionInfo: ConnectionInfo = createConnectionInfo() + try { + await this.establishConnection(user, provider) + } catch (reason: any) { + this.accessDenied = true + if (reason instanceof Error) { + throw reason + } else if (reason) { + throw new Error(reason) + } else { + throw new Error('OIDC authentication failed') + } + } + } - let tokenToUse = user.access_token; + /** + * Attempt to restore an OIDC session persisted by oidc-client-ts in localStorage. + * Called by the router guard on page refresh before redirecting to login. + * Resolves true if a session was restored and the Continuum connection + * re-established; never rejects, so the guard always reaches a decision. + */ + public restoreSession(): Promise { + if (!this.restorePromise) { + this.restorePromise = this.doRestoreSession() + .catch(error => { + debug('Session restore failed unexpectedly: %O', error) + return false + }) + .finally(() => { + this.restorePromise = null + }) + } + return this.restorePromise + } - if (user.access_token && !this.isValidJWT(user.access_token)) { - debug('Access token is not a valid JWT, using ID token for Microsoft social login'); - tokenToUse = user.id_token || user.access_token; + private async doRestoreSession(): Promise { + if (this.isAuthenticated()) { + return true } - connectionInfo.connectHeaders = { - Authorization: `Bearer ${tokenToUse}` + const provider = localStorage.getItem(AUTH_PROVIDER_KEY) + if (!provider) { + return false } try { - this.connectedInfo = await Continuum.connect(connectionInfo) + // Temporary manager just to read/renew the persisted user. Renewal and + // session monitoring stay off so its timers never race the long-lived + // oidcSessionManager that takes over once the session is established. + const settings = await createUserManagerSettings(provider) + const userManager = new UserManager({ + ...settings, + automaticSilentRenew: false, + monitorSession: false + }) + + let user = await userManager.getUser() + if (!user) { + this.clearPersistedSession() + return false + } + + if (user.expired) { + if (!user.refresh_token) { + debug('Persisted OIDC session expired with no refresh token') + this.clearPersistedSession() + return false + } + debug('Persisted OIDC session expired, attempting silent renew') + user = await userManager.signinSilent() + if (!user) { + return false + } + } + + // The backend is the source of truth: only treat the session as + // restored once Continuum.connect accepts the token. + await this.establishConnection(user, provider) + debug('OIDC session restored for provider %s', provider) + return true + } catch (error) { + debug('Session restore failed: %O', error) + // Only discard the persisted session on a definitive denial. Transient + // failures (backend restarting, network blip) keep it so the next + // navigation can retry the restore. + if (error instanceof FrontendRoleDeniedError) { + this.clearPersistedSession() + } + return false + } + } + + /** + * Connect to Continuum with the user's token, enforce the frontend role gate, + * and persist what is needed to survive a page refresh. + * Shared by the initial OIDC login and session restore paths. + */ + private async establishConnection(user: User, provider: string): Promise { + const connectionInfo: ConnectionInfo = createConnectionInfo() + connectionInfo.connectHeaders = { + Authorization: `Bearer ${selectToken(user)}` + } + + this.connectedInfo = await Continuum.connect(connectionInfo) + try { // Frontend role gate. The backend has already validated the token; this is a // UI-only admission check using frontEndRoles configured on the OIDC provider. const providerConfig = await configService.getOidcProviderByName(provider) @@ -85,60 +192,57 @@ export class UserState implements IUserState { const userRoles = this.connectedInfo.participant.roles ?? [] const hasRequiredRole = providerConfig.frontEndRoles.some(r => userRoles.includes(r)) if (!hasRequiredRole) { - try { await Continuum.disconnect() } catch { /* best effort */ } - this.connectedInfo = null this.accessDenied = true - throw new Error(`User does not have any required frontend role. Required one of: ${providerConfig.frontEndRoles.join(', ')}`) + throw new FrontendRoleDeniedError(`User does not have any required frontend role. Required one of: ${providerConfig.frontEndRoles.join(', ')}`) } } - this.authenticated = true - this.accessDenied = false - this.oidcUser = user - - const useSecureCookies = window.location.protocol === 'https:' - - Cookies.set('token', tokenToUse, { - sameSite: 'strict', - secure: useSecureCookies, - expires: new Date(user.expires_at! * 1000) - }) - - if (user.refresh_token) { - const refreshExpiry = this.parseJwtExpiry(user.refresh_token) - Cookies.set('oidc_refresh_token', user.refresh_token, { - sameSite: 'strict', - secure: true, - expires: refreshExpiry ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }) - } - - // Initialize session manager for automatic token refresh + // Initialize automatic token refresh before marking the session + // authenticated so a failure here leaves no half-authenticated state await oidcSessionManager.initialize(provider, async () => { console.warn('Token refresh failed, logging out') await this.logout() }) - } catch (reason: any) { - this.accessDenied = true - if (reason) { - throw new Error(reason) - } else { - throw new Error('OIDC authentication failed') - } + } catch (error) { + try { await Continuum.disconnect() } catch { /* best effort */ } + this.connectedInfo = null + throw error + } + + this.authenticated = true + this.accessDenied = false + this.oidcUser = user + + // Best-effort persistence: a storage failure must not tear down the + // established session, it only costs restore-on-refresh. + try { + localStorage.setItem(AUTH_PROVIDER_KEY, provider) + // The token cookie is only for the GraphQL/OpenAPI playgrounds; the session + // itself is restored from the oidc-client-ts user store in localStorage. + writeTokenCookie(user) + } catch (error) { + debug('Failed to persist session state: %O', error) } } /** - * Parse the expiry date from a JWT token + * Remove the persisted OIDC user and provider so a page refresh cannot restore + * the session. Deletes the oidc-client-ts store entries by key prefix so no + * async config load is needed and a config failure cannot strand tokens. */ - private parseJwtExpiry(token: string): Date | null { + private clearPersistedSession(): void { try { - const parts = token.split('.') - if (parts.length !== 3) return null - const payload = JSON.parse(atob(parts[1])) - return payload.exp ? new Date(payload.exp * 1000) : null - } catch { - return null + const keysToRemove: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith(OIDC_USER_KEY_PREFIX)) { + keysToRemove.push(key) + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)) + localStorage.removeItem(AUTH_PROVIDER_KEY) + } catch (error) { + debug('Failed to clear persisted session: %O', error) } } @@ -154,7 +258,10 @@ export class UserState implements IUserState { } } + this.clearPersistedSession() + Cookies.remove('token') + // Legacy cleanup: refresh tokens are no longer written to cookies Cookies.remove('oidc_refresh_token') this.connectedInfo = null @@ -171,22 +278,6 @@ export class UserState implements IUserState { // Check if we have an active Continuum connection return this.authenticated && this.connectedInfo !== null } - - private isValidJWT(token: string): boolean { - try { - const parts = token.split('.'); - if (parts.length !== 3) { - return false; - } - - const header = JSON.parse(atob(parts[0])); - const payload = JSON.parse(atob(parts[1])); - - return !!(header.alg && payload.iss && payload.aud); - } catch (error) { - return false; - } - } } export const USER_STATE: IUserState = reactive(new UserState()) diff --git a/structures-frontend/src/util/OidcSessionManager.ts b/structures-frontend/src/util/OidcSessionManager.ts index d8c43d9c..b5ff65f1 100644 --- a/structures-frontend/src/util/OidcSessionManager.ts +++ b/structures-frontend/src/util/OidcSessionManager.ts @@ -1,6 +1,6 @@ import { User, UserManager } from 'oidc-client-ts' import { createUserManagerSettings } from '@/pages/login/OidcConfiguration' -import Cookies from 'js-cookie' +import { writeTokenCookie } from '@/util/tokenCookie' /** * OidcSessionManager maintains a persistent UserManager instance with event listeners @@ -40,7 +40,7 @@ class OidcSessionManager { // Create bound handlers so we can properly remove them later this.boundHandlers.userLoaded = (user: User) => { console.log('Token refreshed automatically') - this.updateCookies(user) + writeTokenCookie(user) } this.boundHandlers.silentRenewError = (error: Error) => { @@ -96,60 +96,6 @@ class OidcSessionManager { return this.userManager } - /** - * Update cookies with fresh tokens from the refreshed user - */ - private updateCookies(user: User): void { - let tokenToUse = user.access_token - - // Some providers (like Microsoft) return opaque access tokens - // In that case, use the ID token instead - if (!this.isValidJWT(user.access_token) && user.id_token) { - console.log('Access token is not a valid JWT, using ID token') - tokenToUse = user.id_token - } - - Cookies.set('token', tokenToUse, { - sameSite: 'strict', - secure: true, - expires: new Date(user.expires_at! * 1000) - }) - - if (user.refresh_token) { - const refreshExpiry = this.parseJwtExpiry(user.refresh_token) - Cookies.set('oidc_refresh_token', user.refresh_token, { - sameSite: 'strict', - secure: true, - expires: refreshExpiry ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }) - } - } - - /** - * Check if a token is a valid JWT (has 3 parts separated by dots) - */ - private isValidJWT(token: string): boolean { - try { - const parts = token.split('.') - return parts.length === 3 - } catch { - return false - } - } - - /** - * Parse the expiry date from a JWT token - */ - private parseJwtExpiry(token: string): Date | null { - try { - const parts = token.split('.') - if (parts.length !== 3) return null - const payload = JSON.parse(atob(parts[1])) - return payload.exp ? new Date(payload.exp * 1000) : null - } catch { - return null - } - } } export const oidcSessionManager = new OidcSessionManager() diff --git a/structures-frontend/src/util/tokenCookie.ts b/structures-frontend/src/util/tokenCookie.ts new file mode 100644 index 00000000..c0d794cf --- /dev/null +++ b/structures-frontend/src/util/tokenCookie.ts @@ -0,0 +1,48 @@ +import Cookies from 'js-cookie' +import { User } from 'oidc-client-ts' +import { createDebug } from '@/util/debug' + +const debug = createDebug('token-cookie') + +/** + * Check if a token is a structurally valid JWT + */ +function isValidJWT(token: string): boolean { + try { + const parts = token.split('.') + if (parts.length !== 3) { + return false + } + + const header = JSON.parse(atob(parts[0])) + const payload = JSON.parse(atob(parts[1])) + + return !!(header.alg && payload.iss && payload.aud) + } catch { + return false + } +} + +/** + * Pick the token to present to the backend. Some providers (like Microsoft social + * login) return opaque access tokens; in that case fall back to the ID token. + */ +export function selectToken(user: User): string { + if (user.access_token && !isValidJWT(user.access_token)) { + debug('Access token is not a valid JWT, using ID token') + return user.id_token || user.access_token + } + return user.access_token +} + +/** + * Write the token cookie used by the GraphQL/OpenAPI playgrounds. + * The session itself is persisted by the oidc-client-ts user store, not this cookie. + */ +export function writeTokenCookie(user: User): void { + Cookies.set('token', selectToken(user), { + sameSite: 'strict', + secure: window.location.protocol === 'https:', + expires: new Date(user.expires_at! * 1000) + }) +} diff --git a/structures-js/structures-e2e/test/setup.ts b/structures-js/structures-e2e/test/setup.ts index ac50bc13..c4ca575f 100644 --- a/structures-js/structures-e2e/test/setup.ts +++ b/structures-js/structures-e2e/test/setup.ts @@ -2,9 +2,40 @@ import path from 'node:path' // @ts-ignore import os from 'node:os' +// @ts-ignore +import fs from 'node:fs' import {StartedDockerComposeEnvironment, DockerComposeEnvironment, Wait} from 'testcontainers' import {TestProject} from 'vitest/node.js' +/** + * Load gradle.properties as the docker-compose environment, with + * structuresVersion resolved to the effective image tag: an explicit + * env override wins (CI exports a PR tag); otherwise a plain version is a + * development build and gets -SNAPSHOT appended to mirror + * org.kinotic.java-common-conventions.gradle. Passing everything through a + * single withEnvironment call avoids depending on compose env-file precedence. + */ +function composeEnvironment(): Record { + const env: Record = {} + try { + const content = fs.readFileSync(path.resolve('../../', 'gradle.properties'), 'utf8') + for (const line of content.split('\n')) { + const match = line.match(/^([\w.]+)=(.*)$/) + if (match) { + env[match[1]] = match[2].trim() + } + } + } catch { + // fall through to compose defaults + } + if (process.env.structuresVersion) { + env.structuresVersion = process.env.structuresVersion + } else if (env.structuresVersion && !env.structuresVersion.includes('-')) { + env.structuresVersion += '-SNAPSHOT' + } + return env +} + let environment: StartedDockerComposeEnvironment @@ -28,7 +59,7 @@ export async function setup(project: TestProject) { environment = await new DockerComposeEnvironment(resolvedPath, files) .withWaitStrategy('structures-elasticsearch', Wait.forHttp('/_cluster/health', 9200)) .withWaitStrategy('structures-server', Wait.forHttp('/health', 9090)) - .withEnvironmentFile(path.resolve('../../', 'gradle.properties')) + .withEnvironment(composeEnvironment()) .up(['structures-elasticsearch', 'structures-server']) const container = environment.getContainer('structures-server')