diff --git a/deps.yaml b/deps.yaml index 090afbe..d4fe623 100644 --- a/deps.yaml +++ b/deps.yaml @@ -11,7 +11,7 @@ images: - govulncheck - oras aliases: - helm_v3: helm + helm_v3: helmv3 - name: go1.26 description: "CI image with Go 1.26 toolchain" @@ -25,7 +25,7 @@ images: - govulncheck - oras aliases: - helm_v3: helm + helm_v3: helmv3 - name: python3.11 description: "CI image with Python 3.11 toolchain" @@ -99,7 +99,8 @@ universal: extract: "gh_{version|trimprefix:v}_{os}_{arch}/bin/gh" checksum_template: "gh_{version|trimprefix:v}_checksums.txt" - - name: helm + - name: helmv3 + family: helm source: "https://get.helm.sh" mode: static version: v3.20.2 @@ -111,6 +112,8 @@ universal: extract: "{os}-{arch}/helm" - name: helmv4 + family: helm + family_default: true source: "https://get.helm.sh" mode: static version: v4.1.4 diff --git a/dockerfiles/Dockerfile.charts b/dockerfiles/Dockerfile.charts index b010024..0f184f9 100644 --- a/dockerfiles/Dockerfile.charts +++ b/dockerfiles/Dockerfile.charts @@ -8,6 +8,7 @@ ARG TARGETARCH ENV ARCH=$TARGETARCH ENV GH_TELEMETRY=false ENV DO_NOT_TRACK=true +ENV PATH="/var/ci-tools/active:${PATH}" RUN zypper -n refresh && \ zypper -n install \ @@ -65,14 +66,14 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/gh" && \ rm -rf "${TMP_DIR}" -# helm v3.20.2 +# helmv3 v3.20.2 RUN case "${ARCH}" in \ amd64) CHECKSUM="258e830a9e613c8a7a302d6059b4bb3b9758f2f3e1bb8ea0d707ce10a9a72fea" ;; \ arm64) CHECKSUM="5ea2d6bc2cda3f8edf985e028809f5a9278f404fb8ab24044de9b7cb9b79a691" ;; \ *) echo "Unsupported: ${ARCH}"; exit 1 ;; \ esac && \ export TMP_DIR=$(mktemp -d) && \ - export TMP_FILE="${TMP_DIR}/helm.tar.gz" && \ + export TMP_FILE="${TMP_DIR}/helmv3.tar.gz" && \ case "${ARCH}" in \ amd64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-amd64.tar.gz"; EXTRACT="linux-amd64/helm" ;; \ arm64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-arm64.tar.gz"; EXTRACT="linux-arm64/helm" ;; \ @@ -81,7 +82,7 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${TMP_DIR}/checksum.sha256" && \ sha256sum -c "${TMP_DIR}/checksum.sha256" && \ tar xzf "${TMP_FILE}" -C "${TMP_DIR}" && \ - install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helm" && \ + install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helmv3" && \ rm -rf "${TMP_DIR}" # helmv4 v4.1.4 @@ -213,9 +214,25 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/oras" && \ rm -rf "${TMP_DIR}" +# Family selectors — copy scripts and set up manifest + active symlinks. +# /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update +# the active symlink with: ci-select or select- +COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm +COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select +RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select + # Create a new group with GID 121 and a new user with UID 1001, add the user # to the group, create a home directory for the user. -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +# Also set up CI tool family infrastructure (requires runner group to exist). +RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ + && mkdir -p /var/ci-tools/active \ + && mkdir -p /usr/local/share/ci-tools/families/helm \ + && touch /usr/local/share/ci-tools/families/helm/helmv3 \ + && touch /usr/local/share/ci-tools/families/helm/helmv4 \ + && ln -sf helmv4 /usr/local/share/ci-tools/families/helm/default \ + && ln -sf /usr/local/bin/helmv4 /var/ci-tools/active/helm \ + && chown -R root:runner /var/ci-tools \ + && chmod 2775 /var/ci-tools/active # We trust our base image and the repos that are pulled in workflows. Otherwise # each workflow that uses our base images would have to add the step below. diff --git a/dockerfiles/Dockerfile.go1.25 b/dockerfiles/Dockerfile.go1.25 index d4145f4..4f0680b 100644 --- a/dockerfiles/Dockerfile.go1.25 +++ b/dockerfiles/Dockerfile.go1.25 @@ -8,6 +8,7 @@ ARG TARGETARCH ENV ARCH=$TARGETARCH ENV GH_TELEMETRY=false ENV DO_NOT_TRACK=true +ENV PATH="/var/ci-tools/active:${PATH}" RUN zypper -n refresh && \ zypper -n install \ @@ -64,14 +65,14 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/gh" && \ rm -rf "${TMP_DIR}" -# helm v3.20.2 +# helmv3 v3.20.2 RUN case "${ARCH}" in \ amd64) CHECKSUM="258e830a9e613c8a7a302d6059b4bb3b9758f2f3e1bb8ea0d707ce10a9a72fea" ;; \ arm64) CHECKSUM="5ea2d6bc2cda3f8edf985e028809f5a9278f404fb8ab24044de9b7cb9b79a691" ;; \ *) echo "Unsupported: ${ARCH}"; exit 1 ;; \ esac && \ export TMP_DIR=$(mktemp -d) && \ - export TMP_FILE="${TMP_DIR}/helm.tar.gz" && \ + export TMP_FILE="${TMP_DIR}/helmv3.tar.gz" && \ case "${ARCH}" in \ amd64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-amd64.tar.gz"; EXTRACT="linux-amd64/helm" ;; \ arm64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-arm64.tar.gz"; EXTRACT="linux-arm64/helm" ;; \ @@ -80,7 +81,7 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${TMP_DIR}/checksum.sha256" && \ sha256sum -c "${TMP_DIR}/checksum.sha256" && \ tar xzf "${TMP_FILE}" -C "${TMP_DIR}" && \ - install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helm" && \ + install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helmv3" && \ rm -rf "${TMP_DIR}" # helmv4 v4.1.4 @@ -181,15 +182,31 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/oras" && \ rm -rf "${TMP_DIR}" +# Family selectors — copy scripts and set up manifest + active symlinks. +# /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update +# the active symlink with: ci-select or select- +COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm +COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select +RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select + # Aliases -RUN ln -sf /usr/local/bin/helm /usr/local/bin/helm_v3 +RUN ln -sf /usr/local/bin/helmv3 /usr/local/bin/helm_v3 # Cleanup Go caches RUN go clean -cache -modcache # Create a new group with GID 121 and a new user with UID 1001, add the user # to the group, create a home directory for the user. -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +# Also set up CI tool family infrastructure (requires runner group to exist). +RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ + && mkdir -p /var/ci-tools/active \ + && mkdir -p /usr/local/share/ci-tools/families/helm \ + && touch /usr/local/share/ci-tools/families/helm/helmv3 \ + && touch /usr/local/share/ci-tools/families/helm/helmv4 \ + && ln -sf helmv4 /usr/local/share/ci-tools/families/helm/default \ + && ln -sf /usr/local/bin/helmv4 /var/ci-tools/active/helm \ + && chown -R root:runner /var/ci-tools \ + && chmod 2775 /var/ci-tools/active # We trust our base image and the repos that are pulled in workflows. Otherwise # each workflow that uses our base images would have to add the step below. diff --git a/dockerfiles/Dockerfile.go1.26 b/dockerfiles/Dockerfile.go1.26 index 571efe5..10fb963 100644 --- a/dockerfiles/Dockerfile.go1.26 +++ b/dockerfiles/Dockerfile.go1.26 @@ -8,6 +8,7 @@ ARG TARGETARCH ENV ARCH=$TARGETARCH ENV GH_TELEMETRY=false ENV DO_NOT_TRACK=true +ENV PATH="/var/ci-tools/active:${PATH}" RUN zypper -n refresh && \ zypper -n install \ @@ -64,14 +65,14 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/gh" && \ rm -rf "${TMP_DIR}" -# helm v3.20.2 +# helmv3 v3.20.2 RUN case "${ARCH}" in \ amd64) CHECKSUM="258e830a9e613c8a7a302d6059b4bb3b9758f2f3e1bb8ea0d707ce10a9a72fea" ;; \ arm64) CHECKSUM="5ea2d6bc2cda3f8edf985e028809f5a9278f404fb8ab24044de9b7cb9b79a691" ;; \ *) echo "Unsupported: ${ARCH}"; exit 1 ;; \ esac && \ export TMP_DIR=$(mktemp -d) && \ - export TMP_FILE="${TMP_DIR}/helm.tar.gz" && \ + export TMP_FILE="${TMP_DIR}/helmv3.tar.gz" && \ case "${ARCH}" in \ amd64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-amd64.tar.gz"; EXTRACT="linux-amd64/helm" ;; \ arm64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-arm64.tar.gz"; EXTRACT="linux-arm64/helm" ;; \ @@ -80,7 +81,7 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${TMP_DIR}/checksum.sha256" && \ sha256sum -c "${TMP_DIR}/checksum.sha256" && \ tar xzf "${TMP_FILE}" -C "${TMP_DIR}" && \ - install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helm" && \ + install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helmv3" && \ rm -rf "${TMP_DIR}" # helmv4 v4.1.4 @@ -181,15 +182,31 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/oras" && \ rm -rf "${TMP_DIR}" +# Family selectors — copy scripts and set up manifest + active symlinks. +# /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update +# the active symlink with: ci-select or select- +COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm +COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select +RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select + # Aliases -RUN ln -sf /usr/local/bin/helm /usr/local/bin/helm_v3 +RUN ln -sf /usr/local/bin/helmv3 /usr/local/bin/helm_v3 # Cleanup Go caches RUN go clean -cache -modcache # Create a new group with GID 121 and a new user with UID 1001, add the user # to the group, create a home directory for the user. -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +# Also set up CI tool family infrastructure (requires runner group to exist). +RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ + && mkdir -p /var/ci-tools/active \ + && mkdir -p /usr/local/share/ci-tools/families/helm \ + && touch /usr/local/share/ci-tools/families/helm/helmv3 \ + && touch /usr/local/share/ci-tools/families/helm/helmv4 \ + && ln -sf helmv4 /usr/local/share/ci-tools/families/helm/default \ + && ln -sf /usr/local/bin/helmv4 /var/ci-tools/active/helm \ + && chown -R root:runner /var/ci-tools \ + && chmod 2775 /var/ci-tools/active # We trust our base image and the repos that are pulled in workflows. Otherwise # each workflow that uses our base images would have to add the step below. diff --git a/dockerfiles/Dockerfile.node22 b/dockerfiles/Dockerfile.node22 index 524e5d5..5c5bf35 100644 --- a/dockerfiles/Dockerfile.node22 +++ b/dockerfiles/Dockerfile.node22 @@ -8,6 +8,7 @@ ARG TARGETARCH ENV ARCH=$TARGETARCH ENV GH_TELEMETRY=false ENV DO_NOT_TRACK=true +ENV PATH="/var/ci-tools/active:${PATH}" RUN zypper -n refresh && \ zypper -n install \ @@ -62,14 +63,14 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/gh" && \ rm -rf "${TMP_DIR}" -# helm v3.20.2 +# helmv3 v3.20.2 RUN case "${ARCH}" in \ amd64) CHECKSUM="258e830a9e613c8a7a302d6059b4bb3b9758f2f3e1bb8ea0d707ce10a9a72fea" ;; \ arm64) CHECKSUM="5ea2d6bc2cda3f8edf985e028809f5a9278f404fb8ab24044de9b7cb9b79a691" ;; \ *) echo "Unsupported: ${ARCH}"; exit 1 ;; \ esac && \ export TMP_DIR=$(mktemp -d) && \ - export TMP_FILE="${TMP_DIR}/helm.tar.gz" && \ + export TMP_FILE="${TMP_DIR}/helmv3.tar.gz" && \ case "${ARCH}" in \ amd64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-amd64.tar.gz"; EXTRACT="linux-amd64/helm" ;; \ arm64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-arm64.tar.gz"; EXTRACT="linux-arm64/helm" ;; \ @@ -78,7 +79,7 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${TMP_DIR}/checksum.sha256" && \ sha256sum -c "${TMP_DIR}/checksum.sha256" && \ tar xzf "${TMP_FILE}" -C "${TMP_DIR}" && \ - install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helm" && \ + install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helmv3" && \ rm -rf "${TMP_DIR}" # helmv4 v4.1.4 @@ -119,9 +120,25 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/slsactl" && \ rm -rf "${TMP_DIR}" +# Family selectors — copy scripts and set up manifest + active symlinks. +# /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update +# the active symlink with: ci-select or select- +COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm +COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select +RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select + # Create a new group with GID 121 and a new user with UID 1001, add the user # to the group, create a home directory for the user. -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +# Also set up CI tool family infrastructure (requires runner group to exist). +RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ + && mkdir -p /var/ci-tools/active \ + && mkdir -p /usr/local/share/ci-tools/families/helm \ + && touch /usr/local/share/ci-tools/families/helm/helmv3 \ + && touch /usr/local/share/ci-tools/families/helm/helmv4 \ + && ln -sf helmv4 /usr/local/share/ci-tools/families/helm/default \ + && ln -sf /usr/local/bin/helmv4 /var/ci-tools/active/helm \ + && chown -R root:runner /var/ci-tools \ + && chmod 2775 /var/ci-tools/active # We trust our base image and the repos that are pulled in workflows. Otherwise # each workflow that uses our base images would have to add the step below. diff --git a/dockerfiles/Dockerfile.node24 b/dockerfiles/Dockerfile.node24 index 4a43214..19d126f 100644 --- a/dockerfiles/Dockerfile.node24 +++ b/dockerfiles/Dockerfile.node24 @@ -8,6 +8,7 @@ ARG TARGETARCH ENV ARCH=$TARGETARCH ENV GH_TELEMETRY=false ENV DO_NOT_TRACK=true +ENV PATH="/var/ci-tools/active:${PATH}" RUN zypper -n refresh && \ zypper -n install \ @@ -62,14 +63,14 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/gh" && \ rm -rf "${TMP_DIR}" -# helm v3.20.2 +# helmv3 v3.20.2 RUN case "${ARCH}" in \ amd64) CHECKSUM="258e830a9e613c8a7a302d6059b4bb3b9758f2f3e1bb8ea0d707ce10a9a72fea" ;; \ arm64) CHECKSUM="5ea2d6bc2cda3f8edf985e028809f5a9278f404fb8ab24044de9b7cb9b79a691" ;; \ *) echo "Unsupported: ${ARCH}"; exit 1 ;; \ esac && \ export TMP_DIR=$(mktemp -d) && \ - export TMP_FILE="${TMP_DIR}/helm.tar.gz" && \ + export TMP_FILE="${TMP_DIR}/helmv3.tar.gz" && \ case "${ARCH}" in \ amd64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-amd64.tar.gz"; EXTRACT="linux-amd64/helm" ;; \ arm64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-arm64.tar.gz"; EXTRACT="linux-arm64/helm" ;; \ @@ -78,7 +79,7 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${TMP_DIR}/checksum.sha256" && \ sha256sum -c "${TMP_DIR}/checksum.sha256" && \ tar xzf "${TMP_FILE}" -C "${TMP_DIR}" && \ - install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helm" && \ + install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helmv3" && \ rm -rf "${TMP_DIR}" # helmv4 v4.1.4 @@ -119,9 +120,25 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/slsactl" && \ rm -rf "${TMP_DIR}" +# Family selectors — copy scripts and set up manifest + active symlinks. +# /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update +# the active symlink with: ci-select or select- +COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm +COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select +RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select + # Create a new group with GID 121 and a new user with UID 1001, add the user # to the group, create a home directory for the user. -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +# Also set up CI tool family infrastructure (requires runner group to exist). +RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ + && mkdir -p /var/ci-tools/active \ + && mkdir -p /usr/local/share/ci-tools/families/helm \ + && touch /usr/local/share/ci-tools/families/helm/helmv3 \ + && touch /usr/local/share/ci-tools/families/helm/helmv4 \ + && ln -sf helmv4 /usr/local/share/ci-tools/families/helm/default \ + && ln -sf /usr/local/bin/helmv4 /var/ci-tools/active/helm \ + && chown -R root:runner /var/ci-tools \ + && chmod 2775 /var/ci-tools/active # We trust our base image and the repos that are pulled in workflows. Otherwise # each workflow that uses our base images would have to add the step below. diff --git a/dockerfiles/Dockerfile.python3.11 b/dockerfiles/Dockerfile.python3.11 index 1a9e354..ecf6a12 100644 --- a/dockerfiles/Dockerfile.python3.11 +++ b/dockerfiles/Dockerfile.python3.11 @@ -8,6 +8,7 @@ ARG TARGETARCH ENV ARCH=$TARGETARCH ENV GH_TELEMETRY=false ENV DO_NOT_TRACK=true +ENV PATH="/var/ci-tools/active:${PATH}" RUN zypper -n refresh && \ zypper -n install \ @@ -62,14 +63,14 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/gh" && \ rm -rf "${TMP_DIR}" -# helm v3.20.2 +# helmv3 v3.20.2 RUN case "${ARCH}" in \ amd64) CHECKSUM="258e830a9e613c8a7a302d6059b4bb3b9758f2f3e1bb8ea0d707ce10a9a72fea" ;; \ arm64) CHECKSUM="5ea2d6bc2cda3f8edf985e028809f5a9278f404fb8ab24044de9b7cb9b79a691" ;; \ *) echo "Unsupported: ${ARCH}"; exit 1 ;; \ esac && \ export TMP_DIR=$(mktemp -d) && \ - export TMP_FILE="${TMP_DIR}/helm.tar.gz" && \ + export TMP_FILE="${TMP_DIR}/helmv3.tar.gz" && \ case "${ARCH}" in \ amd64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-amd64.tar.gz"; EXTRACT="linux-amd64/helm" ;; \ arm64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-arm64.tar.gz"; EXTRACT="linux-arm64/helm" ;; \ @@ -78,7 +79,7 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${TMP_DIR}/checksum.sha256" && \ sha256sum -c "${TMP_DIR}/checksum.sha256" && \ tar xzf "${TMP_FILE}" -C "${TMP_DIR}" && \ - install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helm" && \ + install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helmv3" && \ rm -rf "${TMP_DIR}" # helmv4 v4.1.4 @@ -119,9 +120,25 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/slsactl" && \ rm -rf "${TMP_DIR}" +# Family selectors — copy scripts and set up manifest + active symlinks. +# /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update +# the active symlink with: ci-select or select- +COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm +COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select +RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select + # Create a new group with GID 121 and a new user with UID 1001, add the user # to the group, create a home directory for the user. -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +# Also set up CI tool family infrastructure (requires runner group to exist). +RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ + && mkdir -p /var/ci-tools/active \ + && mkdir -p /usr/local/share/ci-tools/families/helm \ + && touch /usr/local/share/ci-tools/families/helm/helmv3 \ + && touch /usr/local/share/ci-tools/families/helm/helmv4 \ + && ln -sf helmv4 /usr/local/share/ci-tools/families/helm/default \ + && ln -sf /usr/local/bin/helmv4 /var/ci-tools/active/helm \ + && chown -R root:runner /var/ci-tools \ + && chmod 2775 /var/ci-tools/active # We trust our base image and the repos that are pulled in workflows. Otherwise # each workflow that uses our base images would have to add the step below. diff --git a/dockerfiles/Dockerfile.python3.13 b/dockerfiles/Dockerfile.python3.13 index daa3425..3b7806f 100644 --- a/dockerfiles/Dockerfile.python3.13 +++ b/dockerfiles/Dockerfile.python3.13 @@ -8,6 +8,7 @@ ARG TARGETARCH ENV ARCH=$TARGETARCH ENV GH_TELEMETRY=false ENV DO_NOT_TRACK=true +ENV PATH="/var/ci-tools/active:${PATH}" RUN zypper -n refresh && \ zypper -n install \ @@ -62,14 +63,14 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/gh" && \ rm -rf "${TMP_DIR}" -# helm v3.20.2 +# helmv3 v3.20.2 RUN case "${ARCH}" in \ amd64) CHECKSUM="258e830a9e613c8a7a302d6059b4bb3b9758f2f3e1bb8ea0d707ce10a9a72fea" ;; \ arm64) CHECKSUM="5ea2d6bc2cda3f8edf985e028809f5a9278f404fb8ab24044de9b7cb9b79a691" ;; \ *) echo "Unsupported: ${ARCH}"; exit 1 ;; \ esac && \ export TMP_DIR=$(mktemp -d) && \ - export TMP_FILE="${TMP_DIR}/helm.tar.gz" && \ + export TMP_FILE="${TMP_DIR}/helmv3.tar.gz" && \ case "${ARCH}" in \ amd64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-amd64.tar.gz"; EXTRACT="linux-amd64/helm" ;; \ arm64) DOWNLOAD_URL="https://get.helm.sh/helm-v3.20.2-linux-arm64.tar.gz"; EXTRACT="linux-arm64/helm" ;; \ @@ -78,7 +79,7 @@ RUN case "${ARCH}" in \ printf "%s %s\n" "${CHECKSUM}" "${TMP_FILE}" > "${TMP_DIR}/checksum.sha256" && \ sha256sum -c "${TMP_DIR}/checksum.sha256" && \ tar xzf "${TMP_FILE}" -C "${TMP_DIR}" && \ - install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helm" && \ + install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/helmv3" && \ rm -rf "${TMP_DIR}" # helmv4 v4.1.4 @@ -119,9 +120,25 @@ RUN case "${ARCH}" in \ install "${TMP_DIR}/${EXTRACT}" "/usr/local/bin/slsactl" && \ rm -rf "${TMP_DIR}" +# Family selectors — copy scripts and set up manifest + active symlinks. +# /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update +# the active symlink with: ci-select or select- +COPY dockerfiles/scripts/select-helm.sh /usr/local/bin/select-helm +COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select +RUN chmod +x /usr/local/bin/select-helm && chmod +x /usr/local/bin/ci-select + # Create a new group with GID 121 and a new user with UID 1001, add the user # to the group, create a home directory for the user. -RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +# Also set up CI tool family infrastructure (requires runner group to exist). +RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ + && mkdir -p /var/ci-tools/active \ + && mkdir -p /usr/local/share/ci-tools/families/helm \ + && touch /usr/local/share/ci-tools/families/helm/helmv3 \ + && touch /usr/local/share/ci-tools/families/helm/helmv4 \ + && ln -sf helmv4 /usr/local/share/ci-tools/families/helm/default \ + && ln -sf /usr/local/bin/helmv4 /var/ci-tools/active/helm \ + && chown -R root:runner /var/ci-tools \ + && chmod 2775 /var/ci-tools/active # We trust our base image and the repos that are pulled in workflows. Otherwise # each workflow that uses our base images would have to add the step below. diff --git a/dockerfiles/scripts/ci-select.sh b/dockerfiles/scripts/ci-select.sh new file mode 100755 index 0000000..375cf5e --- /dev/null +++ b/dockerfiles/scripts/ci-select.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# ci-select — activate a tool from a CI tool family for this job. +# +# The manifest at /usr/local/share/ci-tools/families/ records which tools +# are available per family. The active selection is a symlink in +# /var/ci-tools/active/ which is on PATH ahead of /usr/local/bin. +# +# Usage: +# ci-select list available families +# ci-select FAMILY show tools in FAMILY and the current selection +# ci-select FAMILY TOOL activate TOOL as the default FAMILY command + +set -e + +FAMILIES_DIR=/usr/local/share/ci-tools/families +ACTIVE_DIR=/var/ci-tools/active + +_list_families() { + for _d in "${FAMILIES_DIR}"/*/; do + [ -d "${_d}" ] && basename "${_d}" + done +} + +_list_tools() { + _fam="${1}" + for _f in "${FAMILIES_DIR}/${_fam}"/*; do + _name=$(basename "${_f}") + [ "${_name}" = "default" ] && continue + printf ' %s\n' "${_name}" + done +} + +_current() { + _link="${ACTIVE_DIR}/${1}" + if [ -L "${_link}" ]; then + basename "$(readlink "${_link}")" + else + printf '(none)\n' + fi +} + +FAMILY="${1:-}" +TOOL="${2:-}" + +if [ -z "${FAMILY}" ]; then + printf 'Available CI tool families:\n' + _list_families + printf '\nUsage: ci-select FAMILY [TOOL]\n' + exit 0 +fi + +if [ ! -d "${FAMILIES_DIR}/${FAMILY}" ]; then + printf 'ci-select: unknown family "%s"\n' "${FAMILY}" >&2 + printf 'Available families:\n' >&2 + _list_families >&2 + exit 1 +fi + +if [ -z "${TOOL}" ]; then + printf 'Available %s tools:\n' "${FAMILY}" + _list_tools "${FAMILY}" + printf 'Current: %s\n' "$(_current "${FAMILY}")" + exit 0 +fi + +if [ ! -f "${FAMILIES_DIR}/${FAMILY}/${TOOL}" ]; then + printf 'ci-select: "%s" is not a valid %s tool\n' "${TOOL}" "${FAMILY}" >&2 + printf 'Available:\n' >&2 + _list_tools "${FAMILY}" >&2 + exit 1 +fi + +ln -sf "/usr/local/bin/${TOOL}" "${ACTIVE_DIR}/${FAMILY}" +printf '%s is now: %s\n' "${FAMILY}" "${TOOL}" \ No newline at end of file diff --git a/dockerfiles/scripts/select-helm.sh b/dockerfiles/scripts/select-helm.sh new file mode 100755 index 0000000..db6b75d --- /dev/null +++ b/dockerfiles/scripts/select-helm.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# select-helm — activate a helm version for this CI job. +# Equivalent to: ci-select helm [TOOL] +# +# Usage: +# select-helm show available tools and current selection +# select-helm TOOL activate TOOL as the default 'helm' command +exec ci-select helm "$@" \ No newline at end of file diff --git a/images-lock.yaml b/images-lock.yaml index da76c0e..7f89f52 100644 --- a/images-lock.yaml +++ b/images-lock.yaml @@ -29,11 +29,13 @@ tools: golangci-lint: v2.11.4 goreleaser: v2.15.2 govulncheck: v1.2.0 - helm: v3.20.2 + helmv3: v3.20.2 helmv4: v4.1.4 ob-charts-tool: v0.4.1 oras: v1.3.1 slsactl: v0.1.30 +selectors: + - helm configs: charts: base: registry.suse.com/bci/bci-base:15.7@sha256:3292c81fb9e40b60903e6c88fac34e955b6d5b3acd3eb055d02d5c1538a72aea @@ -50,11 +52,13 @@ configs: - gh - golangci-lint - goreleaser - - helm + - helmv3 - helmv4 - ob-charts-tool - oras - slsactl + family_selectors: + helm: helmv4 description: Rancher charts build environment go1.25: base: registry.suse.com/bci/golang:1.25.9@sha256:6ab7d7eb4a23076273da5e885ea71a1f9c72923da07f41fb53fb02e914123c11 @@ -70,12 +74,14 @@ configs: - golangci-lint - goreleaser - govulncheck - - helm + - helmv3 - helmv4 - oras - slsactl aliases: - helm_v3: helm + helm_v3: helmv3 + family_selectors: + helm: helmv4 go_version: 1.25.9 description: CI image with Go 1.25 toolchain go1.26: @@ -92,12 +98,14 @@ configs: - golangci-lint - goreleaser - govulncheck - - helm + - helmv3 - helmv4 - oras - slsactl aliases: - helm_v3: helm + helm_v3: helmv3 + family_selectors: + helm: helmv4 go_version: 1.26.2 description: CI image with Go 1.26 toolchain node22: @@ -108,9 +116,11 @@ configs: tools: - cosign - gh - - helm + - helmv3 - helmv4 - slsactl + family_selectors: + helm: helmv4 description: CI image with Node 22 toolchain node24: base: registry.suse.com/bci/nodejs:24.14.1@sha256:30f4ce0caa9f329dd6c59578f771b14bdd2cd2fb9cea18bd46bbd6222ddc66a5 @@ -120,9 +130,11 @@ configs: tools: - cosign - gh - - helm + - helmv3 - helmv4 - slsactl + family_selectors: + helm: helmv4 description: CI image with Node 24 toolchain python3.11: base: registry.suse.com/bci/python:3.11.15@sha256:ae46b495c9413de83e4f9b2c8090b2369bbc519df460c66516c5cd8a1f32daed @@ -132,9 +144,11 @@ configs: tools: - cosign - gh - - helm + - helmv3 - helmv4 - slsactl + family_selectors: + helm: helmv4 description: CI image with Python 3.11 toolchain python3.13: base: registry.suse.com/bci/python:3.13.13@sha256:ddc0eb2860df249e2054599e4df0e8452d25254508e05f3af1787359ad757963 @@ -144,7 +158,9 @@ configs: tools: - cosign - gh - - helm + - helmv3 - helmv4 - slsactl + family_selectors: + helm: helmv4 description: CI image with Python 3.13 toolchain diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go index 5317396..c5670a4 100644 --- a/internal/changelog/changelog.go +++ b/internal/changelog/changelog.go @@ -91,6 +91,23 @@ func renderEntry(entry Entry) string { sb.WriteString("\n") } + // Family selector additions/removals affect all images that carry those tools. + if len(entry.Changes.SelectorsAdded) > 0 { + sb.WriteString("### Family Selectors Added\n\n") + for _, s := range entry.Changes.SelectorsAdded { + fmt.Fprintf(&sb, "- `%s` (default: `%s`) — use `ci-select %s ` or `select-%s `\n", + s.Family, s.DefaultTool, s.Family, s.Family) + } + sb.WriteString("\n") + } + if len(entry.Changes.SelectorsRemoved) > 0 { + sb.WriteString("### Family Selectors Removed\n\n") + for _, s := range entry.Changes.SelectorsRemoved { + fmt.Fprintf(&sb, "- `%s`\n", s.Family) + } + sb.WriteString("\n") + } + for _, ic := range entry.Changes.ImageChanges { fmt.Fprintf(&sb, "### Image: %s:%s\n\n", ic.Image, entry.Version) if ic.BaseImageUpdated != nil { @@ -124,6 +141,9 @@ func renderEntry(entry Entry) string { for _, ar := range ic.AliasesRemoved { fmt.Fprintf(&sb, "- Removed alias: `%s`\n", ar.Name) } + for _, sc := range ic.SelectorDefaultChanged { + fmt.Fprintf(&sb, "- `%s` selector default: `%s` → `%s`\n", sc.Family, sc.From, sc.To) + } if len(entry.Changes.PackagesAdded) > 0 || len(entry.Changes.PackagesRemoved) > 0 { sb.WriteString("- Universal package changes\n") } diff --git a/internal/changelog/diff.go b/internal/changelog/diff.go index 69c8c7a..3e268d5 100644 --- a/internal/changelog/diff.go +++ b/internal/changelog/diff.go @@ -78,6 +78,30 @@ func Diff(prev, next *ImagesLock) *Changes { } } + // Global family selector changes. + prevSels := toSet(prev.Selectors) + nextSels := toSet(next.Selectors) + for _, s := range prev.Selectors { + if !nextSels[s] { + c.SelectorsRemoved = append(c.SelectorsRemoved, SelectorChange{Family: s}) + } + } + for _, s := range next.Selectors { + if !prevSels[s] { + // Derive the default tool from the first image config that has it. + defaultTool := "" + for _, cfg := range next.Configs { + if d, ok := cfg.FamilySelectors[s]; ok { + defaultTool = d + break + } + } + c.SelectorsAdded = append(c.SelectorsAdded, SelectorChange{Family: s, DefaultTool: defaultTool}) + } + } + slices.SortFunc(c.SelectorsAdded, func(a, b SelectorChange) int { return strings.Compare(a.Family, b.Family) }) + slices.SortFunc(c.SelectorsRemoved, func(a, b SelectorChange) int { return strings.Compare(a.Family, b.Family) }) + prevImages := toSet(prev.Images) nextImages := toSet(next.Images) @@ -182,6 +206,21 @@ func computeImageChanges(imgName string, prevTools, nextTools map[string]string, slices.SortFunc(ic.AliasesAdded, func(a, b AliasChange) int { return strings.Compare(a.Name, b.Name) }) slices.SortFunc(ic.AliasesRemoved, func(a, b AliasChange) int { return strings.Compare(a.Name, b.Name) }) + // Family selector default changes: detect when the active default tool changes. + // Selector additions/removals are tracked at the global level (Changes.SelectorsAdded/Removed). + for family, nextDefault := range next.FamilySelectors { + if prevDefault, ok := prev.FamilySelectors[family]; ok && prevDefault != nextDefault { + ic.SelectorDefaultChanged = append(ic.SelectorDefaultChanged, SelectorDefaultChange{ + Family: family, + From: prevDefault, + To: nextDefault, + }) + } + } + slices.SortFunc(ic.SelectorDefaultChanged, func(a, b SelectorDefaultChange) int { + return strings.Compare(a.Family, b.Family) + }) + return ic } diff --git a/internal/changelog/diff_test.go b/internal/changelog/diff_test.go index bce6901..fea5163 100644 --- a/internal/changelog/diff_test.go +++ b/internal/changelog/diff_test.go @@ -393,6 +393,118 @@ func TestDiff_AliasChangeTriggersHasChanges(t *testing.T) { } } +// --- Family selector diff tests --- + +func TestDiff_SelectorAdded(t *testing.T) { + prev := &ImagesLock{ + Images: []string{"img"}, + Configs: map[string]ImageConfig{"img": {Base: "alpine"}}, + } + next := &ImagesLock{ + Images: []string{"img"}, + Selectors: []string{"helm"}, + Configs: map[string]ImageConfig{ + "img": {Base: "alpine", FamilySelectors: map[string]string{"helm": "helmv4"}}, + }, + } + + c := Diff(prev, next) + if len(c.SelectorsAdded) != 1 { + t.Fatalf("expected 1 selector added, got %d", len(c.SelectorsAdded)) + } + s := c.SelectorsAdded[0] + if s.Family != "helm" || s.DefaultTool != "helmv4" { + t.Errorf("unexpected SelectorChange: %+v", s) + } + if len(c.SelectorsRemoved) != 0 { + t.Errorf("unexpected SelectorsRemoved: %+v", c.SelectorsRemoved) + } +} + +func TestDiff_SelectorRemoved(t *testing.T) { + prev := &ImagesLock{ + Images: []string{"img"}, + Selectors: []string{"helm"}, + Configs: map[string]ImageConfig{ + "img": {Base: "alpine", FamilySelectors: map[string]string{"helm": "helmv4"}}, + }, + } + next := &ImagesLock{ + Images: []string{"img"}, + Configs: map[string]ImageConfig{"img": {Base: "alpine"}}, + } + + c := Diff(prev, next) + if len(c.SelectorsRemoved) != 1 { + t.Fatalf("expected 1 selector removed, got %d", len(c.SelectorsRemoved)) + } + if c.SelectorsRemoved[0].Family != "helm" { + t.Errorf("unexpected family: %q", c.SelectorsRemoved[0].Family) + } + if len(c.SelectorsAdded) != 0 { + t.Errorf("unexpected SelectorsAdded: %+v", c.SelectorsAdded) + } +} + +func TestDiff_SelectorDefaultChanged(t *testing.T) { + prev := &ImagesLock{ + Images: []string{"img"}, + Selectors: []string{"helm"}, + Configs: map[string]ImageConfig{ + "img": {Base: "alpine", FamilySelectors: map[string]string{"helm": "helmv3"}}, + }, + } + next := &ImagesLock{ + Images: []string{"img"}, + Selectors: []string{"helm"}, + Configs: map[string]ImageConfig{ + "img": {Base: "alpine", FamilySelectors: map[string]string{"helm": "helmv4"}}, + }, + } + + c := Diff(prev, next) + if len(c.SelectorsAdded) != 0 || len(c.SelectorsRemoved) != 0 { + t.Errorf("global selector lists should not change; added=%v removed=%v", c.SelectorsAdded, c.SelectorsRemoved) + } + if len(c.ImageChanges) != 1 { + t.Fatalf("expected 1 image change, got %d", len(c.ImageChanges)) + } + ic := c.ImageChanges[0] + if len(ic.SelectorDefaultChanged) != 1 { + t.Fatalf("expected 1 SelectorDefaultChanged, got %d", len(ic.SelectorDefaultChanged)) + } + sc := ic.SelectorDefaultChanged[0] + if sc.Family != "helm" || sc.From != "helmv3" || sc.To != "helmv4" { + t.Errorf("unexpected SelectorDefaultChange: %+v", sc) + } +} + +func TestDiff_SelectorDefaultUnchanged_NoImageChange(t *testing.T) { + lock := &ImagesLock{ + Images: []string{"img"}, + Selectors: []string{"helm"}, + Configs: map[string]ImageConfig{ + "img": {Base: "alpine", FamilySelectors: map[string]string{"helm": "helmv4"}}, + }, + } + c := Diff(lock, lock) + if !c.IsEmpty() { + t.Errorf("expected no changes when selectors unchanged, got %+v", c) + } +} + +func TestDiff_SelectorDefaultChangedTriggersHasChanges(t *testing.T) { + ic := ImageChanges{ + Image: "img", + SelectorDefaultChanged: []SelectorDefaultChange{ + {Family: "helm", From: "helmv3", To: "helmv4"}, + }, + } + if !ic.HasChanges() { + t.Error("HasChanges() should return true when SelectorDefaultChanged is set") + } +} + func TestDiff_OnlyChangedImageAppears(t *testing.T) { prev := &ImagesLock{ Images: []string{"a", "b"}, diff --git a/internal/changelog/types.go b/internal/changelog/types.go index d8a7cf4..248bb6d 100644 --- a/internal/changelog/types.go +++ b/internal/changelog/types.go @@ -4,21 +4,23 @@ package changelog // It is intentionally separate from the unexported types in internal/cli to // avoid an import cycle. type ImagesLock struct { - Images []string `yaml:"images"` - Packages []string `yaml:"packages,omitempty"` // universal packages installed in every image - Tools map[string]string `yaml:"tools,omitempty"` - Configs map[string]ImageConfig `yaml:"configs"` + Images []string `yaml:"images"` + Packages []string `yaml:"packages,omitempty"` // universal packages installed in every image + Tools map[string]string `yaml:"tools,omitempty"` + Selectors []string `yaml:"selectors,omitempty"` // active family selector names, e.g. ["helm"] + Configs map[string]ImageConfig `yaml:"configs"` } // ImageConfig holds the resolved configuration for one image. type ImageConfig struct { - Base string `yaml:"base"` - Platforms []string `yaml:"platforms"` - Packages []string `yaml:"packages,omitempty"` // image-specific packages only (excludes universal) - Tools []string `yaml:"tools,omitempty"` - Aliases map[string]string `yaml:"aliases,omitempty"` // symlink_name: tool_name - GoVersion string `yaml:"go_version,omitempty"` - Description string `yaml:"description,omitempty"` + Base string `yaml:"base"` + Platforms []string `yaml:"platforms"` + Packages []string `yaml:"packages,omitempty"` // image-specific packages only (excludes universal) + Tools []string `yaml:"tools,omitempty"` + Aliases map[string]string `yaml:"aliases,omitempty"` // symlink_name: tool_name + FamilySelectors map[string]string `yaml:"family_selectors,omitempty"` // family → default tool + GoVersion string `yaml:"go_version,omitempty"` + Description string `yaml:"description,omitempty"` } // Changes summarises what changed between two ImagesLock states. @@ -26,6 +28,9 @@ type Changes struct { // Universal package changes affect every image. PackagesAdded []string PackagesRemoved []string + // Family selector changes (global — a selector was introduced or removed). + SelectorsAdded []SelectorChange + SelectorsRemoved []SelectorChange // ImageChanges holds per-image diffs (only images with at least one change). ImageChanges []ImageChanges // ImagesAdded and ImagesRemoved track images that appeared or disappeared. @@ -46,6 +51,7 @@ func (c *Changes) IsEmpty() bool { return true } return len(c.PackagesAdded) == 0 && len(c.PackagesRemoved) == 0 && + len(c.SelectorsAdded) == 0 && len(c.SelectorsRemoved) == 0 && len(c.ImageChanges) == 0 && len(c.ImagesAdded) == 0 && len(c.ImagesRemoved) == 0 && len(c.DockerfileChanges) == 0 } @@ -66,16 +72,17 @@ func (c *Changes) AffectedImages() []string { // ImageChanges holds all the changes for a single image. type ImageChanges struct { - Image string - BaseImageUpdated *BaseImageChange - PlatformsChanged *PlatformsChange - PackagesAdded []string - PackagesRemoved []string - ToolVersionChanged []ToolVersionChange - ToolsAdded []ToolChange - ToolsRemoved []ToolChange - AliasesAdded []AliasChange - AliasesRemoved []AliasChange + Image string + BaseImageUpdated *BaseImageChange + PlatformsChanged *PlatformsChange + PackagesAdded []string + PackagesRemoved []string + ToolVersionChanged []ToolVersionChange + ToolsAdded []ToolChange + ToolsRemoved []ToolChange + AliasesAdded []AliasChange + AliasesRemoved []AliasChange + SelectorDefaultChanged []SelectorDefaultChange // family selector default tool changed } // HasChanges returns true if the image has any changes. @@ -85,7 +92,22 @@ func (ic ImageChanges) HasChanges() bool { len(ic.PackagesAdded) > 0 || len(ic.PackagesRemoved) > 0 || len(ic.ToolVersionChanged) > 0 || len(ic.ToolsAdded) > 0 || len(ic.ToolsRemoved) > 0 || - len(ic.AliasesAdded) > 0 || len(ic.AliasesRemoved) > 0 + len(ic.AliasesAdded) > 0 || len(ic.AliasesRemoved) > 0 || + len(ic.SelectorDefaultChanged) > 0 +} + +// SelectorChange records a family selector being introduced or removed globally. +type SelectorChange struct { + Family string + DefaultTool string // populated for additions; empty for removals +} + +// SelectorDefaultChange records the default tool for a family selector changing +// in a specific image. +type SelectorDefaultChange struct { + Family string + From string + To string } // AliasChange records a symlink alias being added or removed. diff --git a/internal/cli/generate.go b/internal/cli/generate.go index b63b717..63d6c9b 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -25,10 +25,11 @@ func lockPath(configPath string) string { } const ( - dockerfilesDir = "dockerfiles" - archiveDir = "archive" - defaultConfig = "deps.yaml" - readmePath = "README.md" + dockerfilesDir = "dockerfiles" + dockerScriptsDir = "dockerfiles/scripts" + archiveDir = "archive" + defaultConfig = "deps.yaml" + readmePath = "README.md" ) func runGenerate(args []string) error { @@ -72,6 +73,9 @@ func runGenerate(args []string) error { return err } + // Generate selector scripts (one per family + the generic ci-select). + selectors := dockerfile.GenerateSelectors(cfg) + if err := os.MkdirAll(dockerfilesDir, 0o755); err != nil { return fmt.Errorf("creating output dir %s: %w", dockerfilesDir, err) } @@ -87,11 +91,31 @@ func runGenerate(args []string) error { } } + if err := os.MkdirAll(dockerScriptsDir, 0o755); err != nil { + return fmt.Errorf("creating output dir %s: %w", dockerScriptsDir, err) + } + + for name, content := range selectors { + outputPath := filepath.Join(dockerScriptsDir, name) + changed, err := fileutil.WriteIfChanged(outputPath, []byte(content), 0o755) + if err != nil { + return fmt.Errorf("writing %s: %w", outputPath, err) + } + if changed { + log.Printf("Updated %s", outputPath) + } + } + // Archive any Dockerfiles for images no longer in config. if err := archiveRemovedDockerfiles(files); err != nil { return fmt.Errorf("archiving removed dockerfiles: %w", err) } + // Remove any stale selector scripts for families no longer in config. + if err := cleanupRemovedSelectors(selectors); err != nil { + return fmt.Errorf("cleaning up removed selectors: %w", err) + } + // Write the compiled images lock. imagesLockPath := filepath.Join(filepath.Dir(configPath), "images-lock.yaml") if err := writeImagesLock(cfg, imagesLockPath); err != nil { @@ -116,6 +140,35 @@ func runGenerate(args []string) error { return nil } +// cleanupRemovedSelectors deletes select-*.sh and ci-select.sh files from +// dockerScriptsDir that are no longer produced by the current config. +func cleanupRemovedSelectors(generated map[string]string) error { + entries, err := os.ReadDir(dockerScriptsDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + name := entry.Name() + isSelectorScript := name == "ci-select.sh" || + (strings.HasPrefix(name, "select-") && strings.HasSuffix(name, ".sh")) + if !isSelectorScript { + continue + } + if _, active := generated[name]; active { + continue + } + path := filepath.Join(dockerScriptsDir, name) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + log.Printf("Removed stale selector %s", path) + } + return nil +} + // archiveRemovedDockerfiles moves any Dockerfile. in dockerfilesDir that // is not present in the generated set to archiveDir/Dockerfile.. (UTC). // This preserves history in git when images are removed from config. @@ -155,20 +208,22 @@ func archiveRemovedDockerfiles(generated map[string]string) error { // imagesLock is the structure written to images-lock.yaml. type imagesLock struct { - Images []string `yaml:"images"` - Packages []string `yaml:"packages,omitempty"` // universal packages installed in every image - Tools map[string]string `yaml:"tools,omitempty"` // name → version, all tools across all images - Configs map[string]imageLockConfig `yaml:"configs"` + Images []string `yaml:"images"` + Packages []string `yaml:"packages,omitempty"` // universal packages installed in every image + Tools map[string]string `yaml:"tools,omitempty"` // name → version, all tools across all images + Selectors []string `yaml:"selectors,omitempty"` // active family selector names, e.g. ["helm"] + Configs map[string]imageLockConfig `yaml:"configs"` } type imageLockConfig struct { - Base string `yaml:"base"` - Platforms []string `yaml:"platforms"` - Packages []string `yaml:"packages,omitempty"` // image-specific packages only (excludes universal) - Tools []string `yaml:"tools,omitempty"` // tool names only; versions in top-level tools map - Aliases map[string]string `yaml:"aliases,omitempty"` // symlink_name: tool_name - GoVersion string `yaml:"go_version,omitempty"` - Description string `yaml:"description,omitempty"` + Base string `yaml:"base"` + Platforms []string `yaml:"platforms"` + Packages []string `yaml:"packages,omitempty"` // image-specific packages only (excludes universal) + Tools []string `yaml:"tools,omitempty"` // tool names only; versions in top-level tools map + Aliases map[string]string `yaml:"aliases,omitempty"` // symlink_name: tool_name + FamilySelectors map[string]string `yaml:"family_selectors,omitempty"` // family → default tool + GoVersion string `yaml:"go_version,omitempty"` + Description string `yaml:"description,omitempty"` } // extractGoVersion returns the Go version from a SUSE BCI golang base image @@ -197,9 +252,10 @@ const imagesLockHeader = "# images-lock.yaml — compiled image index generated // optional metadata such as Go version and description. func writeImagesLock(cfg *config.Config, path string) error { lk := imagesLock{ - Packages: cfg.Packages, - Tools: make(map[string]string), - Configs: make(map[string]imageLockConfig, len(cfg.Images)), + Packages: cfg.Packages, + Tools: make(map[string]string), + Selectors: dockerfile.FamilySelectorNames(cfg), + Configs: make(map[string]imageLockConfig, len(cfg.Images)), } // Build a set of universal packages so we can store only image-specific @@ -236,14 +292,33 @@ func writeImagesLock(cfg *config.Config, path string) error { if len(img.Aliases) > 0 { aliases = img.Aliases } + + // Record which family selectors are active for this image and their defaults. + var familySelectors map[string]string + for i := range cfg.Tools { + t := &cfg.Tools[i] + if t.Family == "" || !t.FamilyDefault { + continue + } + // Only include families where at least one family tool is in this image. + if !config.ImageIncludesTool(img, t) { + continue + } + if familySelectors == nil { + familySelectors = make(map[string]string) + } + familySelectors[t.Family] = t.Name + } + lk.Configs[img.Name] = imageLockConfig{ - Base: img.Base, - Platforms: img.Platforms, - Packages: specificPkgs, - Tools: toolNames, - Aliases: aliases, - GoVersion: extractGoVersion(img.Base), - Description: img.Description, + Base: img.Base, + Platforms: img.Platforms, + Packages: specificPkgs, + Tools: toolNames, + Aliases: aliases, + FamilySelectors: familySelectors, + GoVersion: extractGoVersion(img.Base), + Description: img.Description, } } diff --git a/internal/config/config.go b/internal/config/config.go index 3e40e3e..086defb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,17 +21,22 @@ type Image struct { Description string `yaml:"description,omitempty"` // org.opencontainers.image.description; optional } +// ChecksumList is a map of checksums for tools - where key is platform and value is checksum +type ChecksumList map[string]string + // Tool defines a binary tool available for inclusion in images. type Tool struct { - Name string `yaml:"name"` - Source string `yaml:"source"` - Version string `yaml:"version"` - VersionCommit string `yaml:"version_commit,omitempty"` - Mode string `yaml:"mode,omitempty"` // default: "pinned" - Universal bool `yaml:"-"` // set by loader; use universal: section in deps.yaml - Checksums map[string]string `yaml:"checksums,omitempty"` - Release *ReleaseConfig `yaml:"release,omitempty"` - Install InstallConfig `yaml:"install"` + Name string `yaml:"name"` + Family string `yaml:"family,omitempty"` // for grouping tools (e.g. "helm"); tools sharing a family get a runtime selector script + FamilyDefault bool `yaml:"family_default,omitempty"` // this tool is used when the selector env var is not set; requires family to be set + Source string `yaml:"source"` + Version string `yaml:"version"` + VersionCommit string `yaml:"version_commit,omitempty"` + Mode string `yaml:"mode,omitempty"` // default: "pinned" + Universal bool `yaml:"-"` // set by loader; use universal: section in deps.yaml + Checksums ChecksumList `yaml:"checksums,omitempty"` + Release *ReleaseConfig `yaml:"release,omitempty"` + Install InstallConfig `yaml:"install"` } // EffectiveMode returns the tool's mode, defaulting to "pinned". diff --git a/internal/config/validate.go b/internal/config/validate.go index dbb8b5e..808347a 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -35,6 +35,34 @@ func validateConfig(cfg *Config) error { toolsByName[t.Name] = t } + // Pre-compute family → tools mapping. Used both for family validation and + // for detecting alias/selector conflicts during image validation below. + type familyInfo struct { + tools []string + defaults []string + } + families := make(map[string]*familyInfo) + for _, t := range cfg.Tools { + if t.Family == "" { + if t.FamilyDefault { + errs = append(errs, fmt.Sprintf("tool %q: family_default: true requires family to be set", t.Name)) + } + continue + } + if !toolNameRe.MatchString(t.Family) { + errs = append(errs, fmt.Sprintf("tool %q: family %q is invalid (must match ^[a-zA-Z0-9][a-zA-Z0-9._-]*$)", t.Name, t.Family)) + continue + } + if _, ok := families[t.Family]; !ok { + families[t.Family] = &familyInfo{} + } + fi := families[t.Family] + fi.tools = append(fi.tools, t.Name) + if t.FamilyDefault { + fi.defaults = append(fi.defaults, t.Name) + } + } + // Validate images; track which tool names are explicitly listed. referencedByImage := make(map[string]bool) for i, img := range cfg.Images { @@ -91,6 +119,64 @@ func validateConfig(cfg *Config) error { if conflict, ok := toolsByName[aliasName]; ok && (conflict.Universal || seenTools[aliasName]) { errs = append(errs, fmt.Sprintf("image %q: alias name %q conflicts with a tool already installed in this image", img.Name, aliasName)) } + // Alias name must not shadow a family selector — /var/ci-tools/active/{family} + // is on PATH ahead of /usr/local/bin in any image that includes the family's tools. + if fi, ok := families[aliasName]; ok { + familyActiveInImage := false + for _, toolName := range fi.tools { + if t, exists := toolsByName[toolName]; exists && (t.Universal || seenTools[toolName]) { + familyActiveInImage = true + break + } + } + if familyActiveInImage { + errs = append(errs, fmt.Sprintf("image %q: alias %q shadows the %q family selector; remove the alias and use 'ci-select %s ' instead", img.Name, aliasName, aliasName, aliasName)) + } + } + } + + // If any tool from a family is included in this image, the family's default + // tool must also be included; otherwise selector_setup.tmpl renders an + // ln -sf with an empty target and the Docker build fails. + for family, fi := range families { + if len(fi.defaults) != 1 { + continue // misconfigured family; already reported elsewhere + } + defaultTool := fi.defaults[0] + dt, dtOk := toolsByName[defaultTool] + defaultInImage := dtOk && (dt.Universal || seenTools[defaultTool]) + if defaultInImage { + continue + } + for _, toolName := range fi.tools { + t, ok := toolsByName[toolName] + if !ok { + continue + } + if t.Universal || seenTools[toolName] { + errs = append(errs, fmt.Sprintf("image %q: includes tool(s) from family %q but not the default tool %q; add it to image.tools or mark a different tool as family_default", img.Name, family, defaultTool)) + break + } + } + } + } + + // Validate family constraints: ≥2 tools per family, exactly one default, + // and no family name that collides with a defined tool name. + for family, fi := range families { + if len(fi.tools) < 2 { + errs = append(errs, fmt.Sprintf("family %q: must have at least 2 tools (found: %s)", family, strings.Join(fi.tools, ", "))) + } + switch len(fi.defaults) { + case 0: + errs = append(errs, fmt.Sprintf("family %q: no tool has family_default: true; exactly one is required", family)) + case 1: + // valid + default: + errs = append(errs, fmt.Sprintf("family %q: multiple tools have family_default: true (%s); exactly one is required", family, strings.Join(fi.defaults, ", "))) + } + if _, ok := toolsByName[family]; ok { + errs = append(errs, fmt.Sprintf("family %q: name conflicts with a defined tool name", family)) } } diff --git a/internal/dockerfile/build.go b/internal/dockerfile/build.go index 4cac2b9..2d481b3 100644 --- a/internal/dockerfile/build.go +++ b/internal/dockerfile/build.go @@ -58,6 +58,36 @@ func NewDockerfileVars(cfg *config.Config, img config.Image, sourceURL string) ( return DockerfileVars{}, fmt.Errorf("%s", strings.Join(errs, "\n")) } + // Collect family selectors: one per unique family across all tools in this image. + type familySel struct { + defaultTool string + validTools []string + } + familyMap := make(map[string]*familySel) + for _, t := range tools { + if t.Family == "" { + continue + } + if _, ok := familyMap[t.Family]; !ok { + familyMap[t.Family] = &familySel{} + } + fs := familyMap[t.Family] + fs.validTools = append(fs.validTools, t.Name) + if t.FamilyDefault { + fs.defaultTool = t.Name + } + } + selectors := make([]SelectorInstall, 0, len(familyMap)) + for family, fs := range familyMap { + slices.Sort(fs.validTools) + selectors = append(selectors, SelectorInstall{ + Family: family, + DefaultTool: fs.defaultTool, + ValidTools: fs.validTools, + }) + } + slices.SortFunc(selectors, func(a, b SelectorInstall) int { return strings.Compare(a.Family, b.Family) }) + aliases := make([]AliasInstall, 0, len(img.Aliases)) for name, target := range img.Aliases { aliases = append(aliases, AliasInstall{Name: name, Target: target}) @@ -68,6 +98,7 @@ func NewDockerfileVars(cfg *config.Config, img config.Image, sourceURL string) ( Base: img.Base, Packages: img.Packages, Tools: toolInstalls, + Selectors: selectors, Aliases: aliases, SourceURL: sourceURL, Title: "Rancher " + img.Name + " CI image", diff --git a/internal/dockerfile/generate.go b/internal/dockerfile/generate.go index 73267ec..452e033 100644 --- a/internal/dockerfile/generate.go +++ b/internal/dockerfile/generate.go @@ -2,6 +2,7 @@ package dockerfile import ( "fmt" + "slices" "github.com/rancher/ci-image/internal/config" ) @@ -20,3 +21,74 @@ func Generate(cfg *config.Config, sourceURL string) (map[string]string, error) { } return result, nil } + +// GenerateSelectors returns the generated selector script files that must be +// written alongside the Dockerfiles. The map key is the filename +// (e.g. "ci-select.sh") and the value is the script content. +// Returns nil if no families are defined in cfg. +func GenerateSelectors(cfg *config.Config) map[string]string { + // Collect unique families across all tools. + type familyInfo struct { + defaultTool string + validTools []string + } + families := make(map[string]*familyInfo) + for _, t := range cfg.Tools { + if t.Family == "" { + continue + } + if _, ok := families[t.Family]; !ok { + families[t.Family] = &familyInfo{} + } + fi := families[t.Family] + fi.validTools = append(fi.validTools, t.Name) + if t.FamilyDefault { + fi.defaultTool = t.Name + } + } + if len(families) == 0 { + return nil + } + + result := make(map[string]string, len(families)+1) + + // One thin per-family wrapper that just calls ci-select. + for family := range families { + result["select-"+family+".sh"] = selectFamilyScript(family) + } + + // One generic ci-select script that handles all families. + result["ci-select.sh"] = ciSelectScript() + + return result +} + +// selectFamilyScript returns the content of the per-family selector script. +// It is a minimal wrapper around ci-select so all logic lives in one place. +func selectFamilyScript(family string) string { + return executeTemplate("select-family.tmpl", map[string]string{"Family": family}) +} + +// ciSelectScript returns the content of the generic ci-select script. +// It discovers available families and tools from the manifest written at image +// build time under /usr/local/share/ci-tools/families/. +func ciSelectScript() string { + return executeTemplate("ci-select.tmpl", nil) +} + +// FamilySelectorNames returns the sorted list of family names that have +// selector scripts, for use in cleanup logic. +func FamilySelectorNames(cfg *config.Config) []string { + seen := make(map[string]bool) + for _, t := range cfg.Tools { + if t.Family != "" { + seen[t.Family] = true + } + } + names := make([]string, 0, len(seen)) + for f := range seen { + names = append(names, f) + } + slices.Sort(names) + return names +} diff --git a/internal/dockerfile/spec.go b/internal/dockerfile/spec.go index 6062d47..e942e4a 100644 --- a/internal/dockerfile/spec.go +++ b/internal/dockerfile/spec.go @@ -83,6 +83,15 @@ type AliasInstall struct { Target string // target binary name } +// SelectorInstall describes a family selector that is active for an image. +// At image build time this creates the manifest and default active symlink; +// the runner can later call 'ci-select {Family} ' to change it. +type SelectorInstall struct { + Family string // family name, e.g. "helm" + DefaultTool string // tool name that is active by default, e.g. "helmv4" + ValidTools []string // all tool names in the family, sorted +} + // DockerfileVars is the fully-resolved spec for one image's Dockerfile. // Once constructed, Render() cannot fail — all template rendering and // checksum resolution has already been performed. @@ -91,10 +100,21 @@ type DockerfileVars struct { Base string Packages []string Tools []ToolInstall - Aliases []AliasInstall // sorted by Name for determinism - SourceURL string // org.opencontainers.image.source - Title string // org.opencontainers.image.title - Description string // org.opencontainers.image.description; empty → no label emitted + Selectors []SelectorInstall // family selectors active in this image; sorted by Family + Aliases []AliasInstall // sorted by Name for determinism + SourceURL string // org.opencontainers.image.source + Title string // org.opencontainers.image.title + Description string // org.opencontainers.image.description; empty → no label emitted +} + +// SelectorSetupCmd renders the shell command string for the family selector +// infrastructure RUN block. Returns "" if there are no selectors. +// Called by dockerfile.tmpl. +func (v DockerfileVars) SelectorSetupCmd() string { + if len(v.Selectors) == 0 { + return "" + } + return executeTemplate("selector_setup.tmpl", v.Selectors) } // HasGoInstall reports whether any tool in the image uses go-install. diff --git a/internal/dockerfile/tmpl/ci-select.tmpl b/internal/dockerfile/tmpl/ci-select.tmpl new file mode 100644 index 0000000..568c089 --- /dev/null +++ b/internal/dockerfile/tmpl/ci-select.tmpl @@ -0,0 +1,74 @@ +#!/bin/sh +# ci-select — activate a tool from a CI tool family for this job. +# +# The manifest at /usr/local/share/ci-tools/families/ records which tools +# are available per family. The active selection is a symlink in +# /var/ci-tools/active/ which is on PATH ahead of /usr/local/bin. +# +# Usage: +# ci-select list available families +# ci-select FAMILY show tools in FAMILY and the current selection +# ci-select FAMILY TOOL activate TOOL as the default FAMILY command + +set -e + +FAMILIES_DIR=/usr/local/share/ci-tools/families +ACTIVE_DIR=/var/ci-tools/active + +_list_families() { + for _d in "${FAMILIES_DIR}"/*/; do + [ -d "${_d}" ] && basename "${_d}" + done +} + +_list_tools() { + _fam="${1}" + for _f in "${FAMILIES_DIR}/${_fam}"/*; do + _name=$(basename "${_f}") + [ "${_name}" = "default" ] && continue + printf ' %s\n' "${_name}" + done +} + +_current() { + _link="${ACTIVE_DIR}/${1}" + if [ -L "${_link}" ]; then + basename "$(readlink "${_link}")" + else + printf '(none)\n' + fi +} + +FAMILY="${1:-}" +TOOL="${2:-}" + +if [ -z "${FAMILY}" ]; then + printf 'Available CI tool families:\n' + _list_families + printf '\nUsage: ci-select FAMILY [TOOL]\n' + exit 0 +fi + +if [ ! -d "${FAMILIES_DIR}/${FAMILY}" ]; then + printf 'ci-select: unknown family "%s"\n' "${FAMILY}" >&2 + printf 'Available families:\n' >&2 + _list_families >&2 + exit 1 +fi + +if [ -z "${TOOL}" ]; then + printf 'Available %s tools:\n' "${FAMILY}" + _list_tools "${FAMILY}" + printf 'Current: %s\n' "$(_current "${FAMILY}")" + exit 0 +fi + +if [ ! -f "${FAMILIES_DIR}/${FAMILY}/${TOOL}" ]; then + printf 'ci-select: "%s" is not a valid %s tool\n' "${TOOL}" "${FAMILY}" >&2 + printf 'Available:\n' >&2 + _list_tools "${FAMILY}" >&2 + exit 1 +fi + +ln -sf "/usr/local/bin/${TOOL}" "${ACTIVE_DIR}/${FAMILY}" +printf '%s is now: %s\n' "${FAMILY}" "${TOOL}" diff --git a/internal/dockerfile/tmpl/dockerfile.tmpl b/internal/dockerfile/tmpl/dockerfile.tmpl index 91cfaf6..9a3df38 100644 --- a/internal/dockerfile/tmpl/dockerfile.tmpl +++ b/internal/dockerfile/tmpl/dockerfile.tmpl @@ -8,12 +8,20 @@ ARG TARGETARCH ENV ARCH=$TARGETARCH ENV GH_TELEMETRY=false ENV DO_NOT_TRACK=true - +{{if .Selectors}}ENV PATH="/var/ci-tools/active:${PATH}" +{{end}} {{template "zypper.tmpl" .Packages}} {{range .Tools}}# {{.Name}} {{.Version}} {{.Install.Render}} +{{end}}{{if .Selectors}}# Family selectors — copy scripts and set up manifest + active symlinks. +# /var/ci-tools/active is on PATH ahead of /usr/local/bin; runner can update +# the active symlink with: ci-select or select- +{{range .Selectors}}COPY dockerfiles/scripts/select-{{.Family}}.sh /usr/local/bin/select-{{.Family}} +{{end}}COPY dockerfiles/scripts/ci-select.sh /usr/local/bin/ci-select +RUN {{range .Selectors}}chmod +x /usr/local/bin/select-{{.Family}} && {{end}}chmod +x /usr/local/bin/ci-select + {{end}}{{if .Aliases}}# Aliases RUN {{range $i, $a := .Aliases}}{{if $i}} && \ {{end}}ln -sf /usr/local/bin/{{$a.Target}} /usr/local/bin/{{$a.Name}}{{end}} @@ -23,7 +31,13 @@ RUN go clean -cache -modcache {{end}}# Create a new group with GID 121 and a new user with UID 1001, add the user # to the group, create a home directory for the user. +{{- if .Selectors}} +# Also set up CI tool family infrastructure (requires runner group to exist). +RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner \ + && {{.SelectorSetupCmd}} +{{- else}} RUN groupadd -g 121 runner && useradd -u 1001 -g 121 -m runner +{{- end}} {{if .HasAnyOfPackages "git" "git-core"}}# We trust our base image and the repos that are pulled in workflows. Otherwise # each workflow that uses our base images would have to add the step below. diff --git a/internal/dockerfile/tmpl/select-family.tmpl b/internal/dockerfile/tmpl/select-family.tmpl new file mode 100644 index 0000000..1b1c6b6 --- /dev/null +++ b/internal/dockerfile/tmpl/select-family.tmpl @@ -0,0 +1,8 @@ +#!/bin/sh +# select-{{.Family}} — activate a {{.Family}} version for this CI job. +# Equivalent to: ci-select {{.Family}} [TOOL] +# +# Usage: +# select-{{.Family}} show available tools and current selection +# select-{{.Family}} TOOL activate TOOL as the default '{{.Family}}' command +exec ci-select {{.Family}} "$@" diff --git a/internal/dockerfile/tmpl/selector_setup.tmpl b/internal/dockerfile/tmpl/selector_setup.tmpl new file mode 100644 index 0000000..a220982 --- /dev/null +++ b/internal/dockerfile/tmpl/selector_setup.tmpl @@ -0,0 +1,8 @@ +{{- /* selector_setup.tmpl — rendered with []SelectorInstall */ -}} +mkdir -p /var/ci-tools/active{{range .}}{{$sel := .}} \ + && mkdir -p /usr/local/share/ci-tools/families/{{$sel.Family}}{{range $sel.ValidTools}} \ + && touch /usr/local/share/ci-tools/families/{{$sel.Family}}/{{.}}{{end}} \ + && ln -sf {{$sel.DefaultTool}} /usr/local/share/ci-tools/families/{{$sel.Family}}/default \ + && ln -sf /usr/local/bin/{{$sel.DefaultTool}} /var/ci-tools/active/{{$sel.Family}}{{end}} \ + && chown -R root:runner /var/ci-tools \ + && chmod 2775 /var/ci-tools/active \ No newline at end of file