mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-04 20:33:05 -04:00
Compare commits
4 Commits
fix/backen
...
feat/audio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e485bb251 | ||
|
|
7f97c234ac | ||
|
|
bc1965ad0b | ||
|
|
034ed4223c |
@@ -195,24 +195,6 @@ concurrency:
|
||||
- **PR events** group by PR number → newer pushes to the same PR cancel old runs (intended).
|
||||
- **Push events** group by `github.sha` → each master commit gets its own run; rapid-fire merges don't cancel each other (this was a real issue prior — two master pushes 11 seconds apart would cancel the first's CI).
|
||||
|
||||
### Consequence: builds finish out of commit order
|
||||
|
||||
Because no master run supersedes another, and because the backend queue routinely runs hours deep (measured 259.7 min average queue wait against 18.6 min average execution), **completion order does not track commit order**. A build of an older commit can finish long after a newer one.
|
||||
|
||||
That used to move mutable tags backwards. On 19 Jul 2026 a build of commit `10211948b` (pushed 06:45 UTC) finished at 15:40 UTC and overwrote `master-nvidia-l4t-cuda-13-arm64-longcat-video`, which a 10:41 UTC build of `626ae4d51` had already advanced to a commit containing a merged cuDNN packaging fix. Everyone pulling `master-*` got the pre-fix image for two days, hitting `CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH` at inference.
|
||||
|
||||
## Mutable tag ordering guard
|
||||
|
||||
`scripts/tag-guard.mjs` (logic in `scripts/lib/tag-guard.mjs`, tests in `scripts/lib/tag-guard_test.mjs`, run by `make test-ci-scripts`) refuses to advance a mutable tag unless the incoming commit descends from the one the tag currently points at.
|
||||
|
||||
- **Tag classes**: `sha-<commit>-<suffix>` is immutable and *always* publishes: it is how a human pins a known-good artifact, and it is how the incident above was worked around. `master-*`, `latest-*` and `v<version>-*` are mutable and go through the guard.
|
||||
- **How it decides**: reads `org.opencontainers.image.revision` off whatever the mutable tag currently points at (docker/metadata-action already stamps this at build time on the Linux path), then asks the GitHub compare API how the incoming commit relates to it. `ahead`/`identical` publish; `behind`/`diverged` are withheld.
|
||||
- **Why the compare API and not `git merge-base --is-ancestor`**: the merge and darwin-publish jobs use shallow sparse checkouts. Fetching full history into each of the ~200 merge jobs per push to answer one ancestry question is not worth it.
|
||||
- **Fail-open, loudly**: a tag that doesn't exist yet, an image with no revision label, an unreachable commit, or a registry/API error all publish anyway with a `::warning::` annotation and a job-summary line. A guard that failed closed on an API blip would itself stop shipping merged fixes. Every non-trivial decision is annotated; silence is the bug being fixed.
|
||||
- **Where it runs**: `backend_merge.yml` (both the quay and Docker Hub `imagetools create` steps, which are the multi-arch manifest merge *and* the tagging step for single-arch backends, since `backend_build.yml` pushes by digest only) and `backend_build_darwin.yml`'s publish job.
|
||||
- **Darwin caveat**: darwin images are `crane push`ed from a raw OCI tarball and carried no labels at all, so the publish job now stamps the revision with `crane mutate` after pushing. Until each darwin tag has gone through that step once, the guard has nothing to compare and fails open with a warning.
|
||||
- **Release tags**: `v*` tags go through the guard too. A fresh version tag has never been published, so it hits the "tag does not exist yet" path and always publishes. The guard costs nothing there, and it does protect `latest-*` if an old release build is ever re-run.
|
||||
|
||||
## Self-warming, no separate populator
|
||||
|
||||
There is no cron job that pre-warms the BuildKit cache for individual backends. The production builds *are* the populators. The first master build of a given matrix entry pays the cold cost; subsequent same-entry master builds reuse everything that hasn't changed (apt installs, gRPC compile in the variant `builder-fromsource` stage or skipped entirely when consuming `base-grpc-*`, Python wheel installs, etc.). The base-images workflow's weekly cron is the closest thing to a populator and only refreshes the prebuilt builder bases.
|
||||
|
||||
54
.github/workflows/backend_build_darwin.yml
vendored
54
.github/workflows/backend_build_darwin.yml
vendored
@@ -281,15 +281,6 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Sparse checkout: the publish job needs `scripts/` for the mutable-tag
|
||||
# ordering guard, nothing else from the source tree.
|
||||
- name: Checkout (scripts only)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Download ${{ inputs.backend }}.tar
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -337,47 +328,14 @@ jobs:
|
||||
latest=auto
|
||||
suffix=${{ inputs.tag-suffix }},onlatest=true
|
||||
|
||||
# Mutable tags (master-*, latest-*, v*-*) must never move backwards: a
|
||||
# straggler build of an older commit finishing after a newer one would
|
||||
# silently un-ship whatever the newer one fixed. See
|
||||
# scripts/lib/tag-guard.mjs. Immutable sha-* tags always publish.
|
||||
#
|
||||
# Darwin images are pushed as a raw OCI tarball, so unlike the Linux path
|
||||
# (where docker/metadata-action bakes its labels in at build time) they
|
||||
# carry no org.opencontainers.image.revision label for the guard to read.
|
||||
# `crane mutate` stamps it after the push. Until each tag has been
|
||||
# republished once through this step the guard has nothing to compare and
|
||||
# fails open with a warning, which is the pre-existing behaviour.
|
||||
#
|
||||
# DOCKER_METADATA_OUTPUT_JSON is set per step rather than inherited: this
|
||||
# job runs docker/metadata-action twice, so the ambient env var only ever
|
||||
# holds the last one's output.
|
||||
- name: Push Docker image (DockerHub)
|
||||
env:
|
||||
REGISTRY_PREFIX: 'localai/'
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/tag-guard.mjs > "${RUNNER_TEMP}/allowed-hub-tags.txt"
|
||||
while read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
crane push "${{ inputs.backend }}.tar" "$tag"
|
||||
crane mutate "$tag" -t "$tag" \
|
||||
--label "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
done < "${RUNNER_TEMP}/allowed-hub-tags.txt"
|
||||
for tag in $(echo "${{ steps.meta.outputs.tags }}" | tr ',' '\n'); do
|
||||
crane push ${{ inputs.backend }}.tar $tag
|
||||
done
|
||||
|
||||
- name: Push Docker image (Quay)
|
||||
env:
|
||||
REGISTRY_PREFIX: 'quay.io/'
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.quaymeta.outputs.json }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/tag-guard.mjs > "${RUNNER_TEMP}/allowed-quay-tags.txt"
|
||||
while read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
crane push "${{ inputs.backend }}.tar" "$tag"
|
||||
crane mutate "$tag" -t "$tag" \
|
||||
--label "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
done < "${RUNNER_TEMP}/allowed-quay-tags.txt"
|
||||
for tag in $(echo "${{ steps.quaymeta.outputs.tags }}" | tr ',' '\n'); do
|
||||
crane push ${{ inputs.backend }}.tar $tag
|
||||
done
|
||||
|
||||
75
.github/workflows/backend_merge.yml
vendored
75
.github/workflows/backend_merge.yml
vendored
@@ -47,14 +47,12 @@ jobs:
|
||||
COSIGN_EXPERIMENTAL: '1'
|
||||
steps:
|
||||
# Sparse checkout: the merge job needs `.github/scripts/` (for the
|
||||
# keepalive cleanup script) and `scripts/` (for the mutable-tag ordering
|
||||
# guard) but none of the source tree.
|
||||
- name: Checkout (scripts only)
|
||||
# keepalive cleanup script) but none of the source tree.
|
||||
- name: Checkout (.github/scripts only)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts
|
||||
scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
# `--` separator anchors the glob so we don't over-match sibling
|
||||
@@ -131,44 +129,30 @@ jobs:
|
||||
# manifest list with the user-facing tags. The resulting manifest
|
||||
# list is fully self-contained in local-ai-backends — child digests
|
||||
# only, no embedded references to ci-cache.
|
||||
#
|
||||
# Mutable tags (master-*, latest-*, v*-*) must never move backwards.
|
||||
# Backend CI queues run hours deep and master pushes get a concurrency
|
||||
# group keyed by github.sha, so a straggler build of an older commit can
|
||||
# finish after a newer one and silently un-ship a merged fix (measured on
|
||||
# 19 Jul 2026, see scripts/lib/tag-guard.mjs). The guard drops any mutable
|
||||
# tag whose published image was built from a commit this one does not
|
||||
# descend from; immutable sha-* tags always survive it.
|
||||
- name: Create manifest list and push (quay)
|
||||
if: github.event_name != 'pull_request'
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
REGISTRY_PREFIX: 'quay.io/'
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Via a file, not `mapfile < <(...)`: process substitution hides the
|
||||
# exit status, so a crashing guard would look like "no tags" and
|
||||
# skip the merge silently.
|
||||
node "$GITHUB_WORKSPACE/scripts/tag-guard.mjs" > "${RUNNER_TEMP}/allowed-quay-tags.txt"
|
||||
mapfile -t allowed < "${RUNNER_TEMP}/allowed-quay-tags.txt"
|
||||
if [ "${#allowed[@]}" -eq 0 ] || [ -z "${allowed[0]}" ]; then
|
||||
echo "No publishable quay.io tags; skipping quay merge"
|
||||
tags=$(jq -cr '
|
||||
.tags
|
||||
| map(select(startswith("quay.io/")))
|
||||
| map("-t " + .)
|
||||
| join(" ")
|
||||
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||
if [ -z "$tags" ]; then
|
||||
echo "No quay.io tags from docker/metadata-action; skipping quay merge"
|
||||
exit 0
|
||||
fi
|
||||
tags=()
|
||||
for t in "${allowed[@]}"; do
|
||||
tags+=(-t "$t")
|
||||
done
|
||||
# shellcheck disable=SC2046
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
# shellcheck disable=SC2086
|
||||
docker buildx imagetools create $tags \
|
||||
$(printf 'quay.io/go-skynet/ci-cache@sha256:%s ' *)
|
||||
# Resolve the manifest-list digest (any tag points at it) so
|
||||
# cosign can sign by digest. Signing by tag would leave the
|
||||
# signature orphaned the next time the tag moves. The guard emits
|
||||
# immutable sha-* tags first, so this is the per-commit tag whenever
|
||||
# one exists.
|
||||
first_tag="${allowed[0]}"
|
||||
# signature orphaned the next time the tag moves.
|
||||
first_tag=$(jq -cr '
|
||||
.tags | map(select(startswith("quay.io/"))) | .[0]
|
||||
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')
|
||||
# --recursive walks the list and signs every per-arch entry
|
||||
# too — clients that resolve a tag to a platform-specific
|
||||
@@ -181,25 +165,24 @@ jobs:
|
||||
- name: Create manifest list and push (dockerhub)
|
||||
if: github.event_name != 'pull_request'
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
REGISTRY_PREFIX: 'localai/'
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node "$GITHUB_WORKSPACE/scripts/tag-guard.mjs" > "${RUNNER_TEMP}/allowed-hub-tags.txt"
|
||||
mapfile -t allowed < "${RUNNER_TEMP}/allowed-hub-tags.txt"
|
||||
if [ "${#allowed[@]}" -eq 0 ] || [ -z "${allowed[0]}" ]; then
|
||||
echo "No publishable dockerhub tags; skipping dockerhub merge"
|
||||
tags=$(jq -cr '
|
||||
.tags
|
||||
| map(select(startswith("localai/")))
|
||||
| map("-t " + .)
|
||||
| join(" ")
|
||||
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||
if [ -z "$tags" ]; then
|
||||
echo "No dockerhub tags from docker/metadata-action; skipping dockerhub merge"
|
||||
exit 0
|
||||
fi
|
||||
tags=()
|
||||
for t in "${allowed[@]}"; do
|
||||
tags+=(-t "$t")
|
||||
done
|
||||
# shellcheck disable=SC2046
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
# shellcheck disable=SC2086
|
||||
docker buildx imagetools create $tags \
|
||||
$(printf 'localai/localai-backends@sha256:%s ' *)
|
||||
first_tag="${allowed[0]}"
|
||||
first_tag=$(jq -cr '
|
||||
.tags | map(select(startswith("localai/"))) | .[0]
|
||||
' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')
|
||||
cosign sign --yes --recursive \
|
||||
--registry-referrers-mode=oci-1-1 \
|
||||
|
||||
95
backend/cpp/audio-cpp/CMakeLists.txt
Normal file
95
backend/cpp/audio-cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,95 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(audio-cpp-grpc-server LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
set(AUDIO_CPP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/audio.cpp"
|
||||
CACHE PATH "Path to the audio.cpp source tree")
|
||||
set(LOCALAI_BACKEND_PROTO "${CMAKE_CURRENT_SOURCE_DIR}/../../backend.proto"
|
||||
CACHE FILEPATH "Path to the LocalAI backend protocol")
|
||||
|
||||
option(ENGINE_ENABLE_CUDA "Build audio.cpp with CUDA support" OFF)
|
||||
option(ENGINE_ENABLE_VULKAN "Build audio.cpp with Vulkan support" OFF)
|
||||
option(ENGINE_ENABLE_METAL "Build audio.cpp with Metal support" OFF)
|
||||
option(AUDIO_CPP_BUILD_TESTS "Build LocalAI audio.cpp unit tests" OFF)
|
||||
option(AUDIO_CPP_BUILD_GRPC "Build the LocalAI gRPC server" ON)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
if(NOT EXISTS "${AUDIO_CPP_DIR}/CMakeLists.txt")
|
||||
message(FATAL_ERROR
|
||||
"AUDIO_CPP_DIR does not point to an audio.cpp source tree: ${AUDIO_CPP_DIR}")
|
||||
endif()
|
||||
add_subdirectory("${AUDIO_CPP_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/audio.cpp")
|
||||
|
||||
add_library(localai_audio_cpp_runtime STATIC
|
||||
audio_cpp_runtime.cpp
|
||||
model_config.cpp)
|
||||
target_include_directories(localai_audio_cpp_runtime
|
||||
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries(localai_audio_cpp_runtime PUBLIC engine_runtime)
|
||||
|
||||
if(AUDIO_CPP_BUILD_GRPC)
|
||||
find_package(Protobuf CONFIG REQUIRED)
|
||||
find_package(gRPC CONFIG REQUIRED)
|
||||
find_program(PROTOC_EXECUTABLE NAMES protoc REQUIRED)
|
||||
find_program(GRPC_CPP_PLUGIN_EXECUTABLE NAMES grpc_cpp_plugin REQUIRED)
|
||||
|
||||
get_filename_component(LOCALAI_BACKEND_PROTO_DIR
|
||||
"${LOCALAI_BACKEND_PROTO}" DIRECTORY)
|
||||
set(LOCALAI_PROTO_SOURCES
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.cc"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.cc")
|
||||
set(LOCALAI_PROTO_HEADERS
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.h"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.h")
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT ${LOCALAI_PROTO_SOURCES} ${LOCALAI_PROTO_HEADERS}
|
||||
COMMAND "${PROTOC_EXECUTABLE}"
|
||||
ARGS
|
||||
--cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
|
||||
--grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
|
||||
-I "${LOCALAI_BACKEND_PROTO_DIR}"
|
||||
--plugin=protoc-gen-grpc="${GRPC_CPP_PLUGIN_EXECUTABLE}"
|
||||
"${LOCALAI_BACKEND_PROTO}"
|
||||
DEPENDS "${LOCALAI_BACKEND_PROTO}"
|
||||
VERBATIM)
|
||||
|
||||
add_library(localai_backend_proto STATIC
|
||||
${LOCALAI_PROTO_SOURCES}
|
||||
${LOCALAI_PROTO_HEADERS})
|
||||
target_include_directories(localai_backend_proto
|
||||
PUBLIC "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
target_link_libraries(localai_backend_proto
|
||||
PUBLIC protobuf::libprotobuf gRPC::grpc++)
|
||||
|
||||
# Task 2 replaces this generated entry point with the LocalAI service.
|
||||
set(AUDIO_CPP_SERVER_PLACEHOLDER
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/audio-cpp-grpc-server-placeholder.cpp")
|
||||
file(GENERATE OUTPUT "${AUDIO_CPP_SERVER_PLACEHOLDER}"
|
||||
CONTENT "int main() { return 0; }\n")
|
||||
|
||||
add_executable(audio-cpp-grpc-server "${AUDIO_CPP_SERVER_PLACEHOLDER}")
|
||||
target_link_libraries(audio-cpp-grpc-server PRIVATE
|
||||
localai_audio_cpp_runtime
|
||||
engine_runtime
|
||||
localai_backend_proto
|
||||
gRPC::grpc++
|
||||
gRPC::grpc++_reflection)
|
||||
endif()
|
||||
|
||||
if(AUDIO_CPP_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_executable(audio-cpp-runtime-test tests/runtime_tests.cpp)
|
||||
target_link_libraries(audio-cpp-runtime-test PRIVATE
|
||||
localai_audio_cpp_runtime
|
||||
Threads::Threads)
|
||||
add_test(
|
||||
NAME audio-cpp-runtime-test
|
||||
COMMAND audio-cpp-runtime-test "${AUDIO_CPP_DIR}")
|
||||
endif()
|
||||
67
backend/cpp/audio-cpp/Makefile
Normal file
67
backend/cpp/audio-cpp/Makefile
Normal file
@@ -0,0 +1,67 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
AUDIO_CPP_VERSION?=f8fb0c19739193adfad0d9e58da99f25eda65256
|
||||
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
|
||||
AUDIO_CPP_SRC?=
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
BUILD_DIR := build
|
||||
|
||||
BUILD_TYPE ?=
|
||||
JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
|
||||
UNAME_S := $(shell uname -s)
|
||||
|
||||
CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_CUDA=OFF
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_VULKAN=OFF
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_METAL=OFF
|
||||
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_CUDA=ON
|
||||
else ifeq ($(BUILD_TYPE),vulkan)
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_VULKAN=ON
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_METAL=ON
|
||||
endif
|
||||
|
||||
.PHONY: all grpc-server test test-unit clean purge
|
||||
|
||||
all: grpc-server
|
||||
|
||||
audio.cpp:
|
||||
ifneq ($(AUDIO_CPP_SRC),)
|
||||
ln -sfn $(abspath $(AUDIO_CPP_SRC)) audio.cpp
|
||||
else
|
||||
mkdir -p audio.cpp
|
||||
cd audio.cpp && \
|
||||
git init -q && \
|
||||
git remote add origin $(AUDIO_CPP_REPO) && \
|
||||
git fetch --depth 1 origin $(AUDIO_CPP_VERSION) && \
|
||||
git checkout --detach FETCH_HEAD && \
|
||||
git submodule update --init --recursive --depth 1
|
||||
endif
|
||||
|
||||
grpc-server: audio.cpp
|
||||
mkdir -p $(BUILD_DIR)
|
||||
cd $(BUILD_DIR) && cmake $(CMAKE_ARGS) $(CURRENT_MAKEFILE_DIR)
|
||||
cmake --build $(BUILD_DIR) --config Release \
|
||||
--target audio-cpp-grpc-server -j $(JOBS)
|
||||
cp $(BUILD_DIR)/audio-cpp-grpc-server grpc-server
|
||||
|
||||
test:
|
||||
bash tests/build_contract_test.sh
|
||||
|
||||
test-unit: audio.cpp
|
||||
mkdir -p $(BUILD_DIR)-unit
|
||||
cd $(BUILD_DIR)-unit && cmake $(CMAKE_ARGS) \
|
||||
-DAUDIO_CPP_BUILD_TESTS=ON -DAUDIO_CPP_BUILD_GRPC=OFF \
|
||||
$(CURRENT_MAKEFILE_DIR)
|
||||
cmake --build $(BUILD_DIR)-unit --config Release \
|
||||
--target audio-cpp-runtime-test -j $(JOBS)
|
||||
ctest --test-dir $(BUILD_DIR)-unit --output-on-failure
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) $(BUILD_DIR)-unit grpc-server
|
||||
|
||||
purge: clean
|
||||
rm -rf audio.cpp
|
||||
178
backend/cpp/audio-cpp/audio_cpp_runtime.cpp
Normal file
178
backend/cpp/audio-cpp/audio_cpp_runtime.cpp
Normal file
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "audio_cpp_runtime.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace audio_cpp {
|
||||
namespace {
|
||||
|
||||
using engine::runtime::CapabilitySet;
|
||||
using engine::runtime::RunMode;
|
||||
using engine::runtime::TaskSpec;
|
||||
|
||||
const engine::runtime::TaskCapability * find_task_capability(
|
||||
const CapabilitySet & capabilities,
|
||||
const TaskSpec & task) {
|
||||
const auto it = std::find_if(
|
||||
capabilities.supported_tasks.begin(),
|
||||
capabilities.supported_tasks.end(),
|
||||
[&](const engine::runtime::TaskCapability & capability) {
|
||||
return capability.task == task.task;
|
||||
});
|
||||
return it == capabilities.supported_tasks.end() ? nullptr : &*it;
|
||||
}
|
||||
|
||||
void validate_capability(
|
||||
const engine::runtime::ILoadedVoiceModel & model,
|
||||
const AudioCppModelConfig & config) {
|
||||
if (model.metadata().family != config.family) {
|
||||
throw std::runtime_error(
|
||||
"loaded audio.cpp model family '" + model.metadata().family +
|
||||
"' does not match requested family '" + config.family + "'");
|
||||
}
|
||||
|
||||
const auto * capability = find_task_capability(
|
||||
model.capabilities(),
|
||||
config.task);
|
||||
if (capability == nullptr) {
|
||||
throw std::runtime_error(
|
||||
"loaded audio.cpp model does not support requested task '" +
|
||||
std::string(engine::runtime::to_string(config.task.task)) + "'");
|
||||
}
|
||||
if (std::find(
|
||||
capability->modes.begin(),
|
||||
capability->modes.end(),
|
||||
config.task.mode) == capability->modes.end()) {
|
||||
throw std::runtime_error(
|
||||
"loaded audio.cpp model does not support requested mode '" +
|
||||
std::string(engine::runtime::to_string(config.task.mode)) +
|
||||
"' for task '" +
|
||||
std::string(engine::runtime::to_string(config.task.task)) + "'");
|
||||
}
|
||||
}
|
||||
|
||||
void validate_session(
|
||||
const engine::runtime::IVoiceTaskSession & session,
|
||||
const AudioCppModelConfig & config) {
|
||||
if (session.family() != config.family) {
|
||||
throw std::runtime_error("audio.cpp session returned the wrong family");
|
||||
}
|
||||
if (session.task_kind() != config.task.task) {
|
||||
throw std::runtime_error("audio.cpp session returned the wrong task");
|
||||
}
|
||||
if (session.run_mode() != config.task.mode) {
|
||||
throw std::runtime_error("audio.cpp session returned the wrong mode");
|
||||
}
|
||||
if (config.task.mode == RunMode::Offline &&
|
||||
dynamic_cast<const engine::runtime::IOfflineVoiceTaskSession *>(&session) == nullptr) {
|
||||
throw std::runtime_error("audio.cpp session does not implement offline execution");
|
||||
}
|
||||
if (config.task.mode == RunMode::Streaming &&
|
||||
dynamic_cast<const engine::runtime::IStreamingVoiceTaskSession *>(&session) == nullptr) {
|
||||
throw std::runtime_error("audio.cpp session does not implement streaming execution");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AudioCppRuntime::AudioCppRuntime()
|
||||
: registry_(engine::runtime::make_default_registry()) {}
|
||||
|
||||
AudioCppRuntime::AudioCppRuntime(engine::runtime::ModelRegistry registry)
|
||||
: registry_(std::move(registry)) {}
|
||||
|
||||
AudioCppRuntime::~AudioCppRuntime() {
|
||||
free();
|
||||
}
|
||||
|
||||
void AudioCppRuntime::load(const AudioCppModelConfig & config) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
auto candidate_model = registry_.load(config.load);
|
||||
if (candidate_model == nullptr) {
|
||||
throw std::runtime_error("audio.cpp registry returned a null model");
|
||||
}
|
||||
validate_capability(*candidate_model, config);
|
||||
|
||||
auto candidate_session = candidate_model->create_task_session(
|
||||
config.task,
|
||||
config.session);
|
||||
if (candidate_session == nullptr) {
|
||||
throw std::runtime_error("audio.cpp model returned a null session");
|
||||
}
|
||||
validate_session(*candidate_session, config);
|
||||
|
||||
free_locked();
|
||||
model_ = std::move(candidate_model);
|
||||
session_ = std::move(candidate_session);
|
||||
}
|
||||
|
||||
void AudioCppRuntime::free() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
free_locked();
|
||||
}
|
||||
|
||||
engine::runtime::TaskResult AudioCppRuntime::run(
|
||||
const engine::runtime::TaskRequest & request) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto & session = require_session_locked();
|
||||
session.prepare(engine::runtime::build_preparation_request(request));
|
||||
return require_offline_locked().run(request);
|
||||
}
|
||||
|
||||
void AudioCppRuntime::start_stream(
|
||||
const engine::runtime::TaskRequest & request) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto & session = require_session_locked();
|
||||
session.prepare(engine::runtime::build_preparation_request(request));
|
||||
require_streaming_locked().start_stream(request);
|
||||
}
|
||||
|
||||
engine::runtime::StreamEvent AudioCppRuntime::process_audio_chunk(
|
||||
const engine::runtime::AudioChunk & chunk) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return require_streaming_locked().process_audio_chunk(chunk);
|
||||
}
|
||||
|
||||
engine::runtime::TaskResult AudioCppRuntime::finish_stream() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return require_streaming_locked().finish_stream();
|
||||
}
|
||||
|
||||
engine::runtime::IVoiceTaskSession & AudioCppRuntime::require_session_locked() {
|
||||
if (session_ == nullptr) {
|
||||
throw std::runtime_error("audio.cpp runtime has no loaded session");
|
||||
}
|
||||
return *session_;
|
||||
}
|
||||
|
||||
engine::runtime::IOfflineVoiceTaskSession &
|
||||
AudioCppRuntime::require_offline_locked() {
|
||||
auto * offline = dynamic_cast<engine::runtime::IOfflineVoiceTaskSession *>(
|
||||
&require_session_locked());
|
||||
if (offline == nullptr) {
|
||||
throw std::runtime_error("loaded audio.cpp session is not offline");
|
||||
}
|
||||
return *offline;
|
||||
}
|
||||
|
||||
engine::runtime::IStreamingVoiceTaskSession &
|
||||
AudioCppRuntime::require_streaming_locked() {
|
||||
auto * streaming = dynamic_cast<engine::runtime::IStreamingVoiceTaskSession *>(
|
||||
&require_session_locked());
|
||||
if (streaming == nullptr) {
|
||||
throw std::runtime_error("loaded audio.cpp session is not streaming");
|
||||
}
|
||||
return *streaming;
|
||||
}
|
||||
|
||||
void AudioCppRuntime::free_locked() {
|
||||
session_.reset();
|
||||
model_.reset();
|
||||
}
|
||||
|
||||
} // namespace audio_cpp
|
||||
46
backend/cpp/audio-cpp/audio_cpp_runtime.h
Normal file
46
backend/cpp/audio-cpp/audio_cpp_runtime.h
Normal file
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "model_config.h"
|
||||
|
||||
#include "engine/framework/runtime/registry.h"
|
||||
#include "engine/framework/runtime/session.h"
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
namespace audio_cpp {
|
||||
|
||||
class AudioCppRuntime {
|
||||
public:
|
||||
AudioCppRuntime();
|
||||
explicit AudioCppRuntime(engine::runtime::ModelRegistry registry);
|
||||
~AudioCppRuntime();
|
||||
|
||||
AudioCppRuntime(const AudioCppRuntime &) = delete;
|
||||
AudioCppRuntime & operator=(const AudioCppRuntime &) = delete;
|
||||
|
||||
void load(const AudioCppModelConfig & config);
|
||||
void free();
|
||||
|
||||
engine::runtime::TaskResult run(
|
||||
const engine::runtime::TaskRequest & request);
|
||||
void start_stream(const engine::runtime::TaskRequest & request);
|
||||
engine::runtime::StreamEvent process_audio_chunk(
|
||||
const engine::runtime::AudioChunk & chunk);
|
||||
engine::runtime::TaskResult finish_stream();
|
||||
|
||||
private:
|
||||
engine::runtime::IVoiceTaskSession & require_session_locked();
|
||||
engine::runtime::IOfflineVoiceTaskSession & require_offline_locked();
|
||||
engine::runtime::IStreamingVoiceTaskSession & require_streaming_locked();
|
||||
void free_locked();
|
||||
|
||||
std::mutex mutex_;
|
||||
engine::runtime::ModelRegistry registry_;
|
||||
std::unique_ptr<engine::runtime::ILoadedVoiceModel> model_;
|
||||
std::unique_ptr<engine::runtime::IVoiceTaskSession> session_;
|
||||
};
|
||||
|
||||
} // namespace audio_cpp
|
||||
156
backend/cpp/audio-cpp/model_config.cpp
Normal file
156
backend/cpp/audio-cpp/model_config.cpp
Normal file
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "model_config.h"
|
||||
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace audio_cpp {
|
||||
namespace {
|
||||
|
||||
using engine::core::BackendType;
|
||||
using engine::runtime::RunMode;
|
||||
using engine::runtime::VoiceTaskKind;
|
||||
|
||||
std::string require_option(
|
||||
const std::unordered_map<std::string, std::string> & options,
|
||||
const std::string & name) {
|
||||
const auto it = options.find(name);
|
||||
if (it == options.end() || it->second.empty()) {
|
||||
throw std::invalid_argument("audio.cpp model config requires " + name);
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
VoiceTaskKind parse_task(const std::string & value) {
|
||||
static const std::unordered_map<std::string, VoiceTaskKind> tasks = {
|
||||
{"vad", VoiceTaskKind::Vad},
|
||||
{"asr", VoiceTaskKind::Asr},
|
||||
{"diarization", VoiceTaskKind::Diarization},
|
||||
{"source-separation", VoiceTaskKind::SourceSeparation},
|
||||
{"audio-generation", VoiceTaskKind::AudioGeneration},
|
||||
{"tts", VoiceTaskKind::Tts},
|
||||
{"voice-cloning", VoiceTaskKind::VoiceCloning},
|
||||
{"voice-conversion", VoiceTaskKind::VoiceConversion},
|
||||
{"speech-to-speech", VoiceTaskKind::SpeechToSpeech},
|
||||
{"alignment", VoiceTaskKind::Alignment},
|
||||
{"voice-design", VoiceTaskKind::VoiceDesign},
|
||||
{"speaker-recognition", VoiceTaskKind::SpeakerRecognition},
|
||||
{"svc", VoiceTaskKind::Svc},
|
||||
};
|
||||
const auto it = tasks.find(value);
|
||||
if (it == tasks.end()) {
|
||||
throw std::invalid_argument("unsupported audio.cpp task: " + value);
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
RunMode parse_mode(const std::string & value) {
|
||||
if (value == "offline") {
|
||||
return RunMode::Offline;
|
||||
}
|
||||
if (value == "streaming") {
|
||||
return RunMode::Streaming;
|
||||
}
|
||||
throw std::invalid_argument("unsupported audio.cpp mode: " + value);
|
||||
}
|
||||
|
||||
BackendType parse_backend(const std::string & value) {
|
||||
if (value == "cpu") {
|
||||
return BackendType::Cpu;
|
||||
}
|
||||
if (value == "cuda") {
|
||||
return BackendType::Cuda;
|
||||
}
|
||||
if (value == "vulkan") {
|
||||
return BackendType::Vulkan;
|
||||
}
|
||||
if (value == "metal") {
|
||||
return BackendType::Metal;
|
||||
}
|
||||
if (value == "best") {
|
||||
return BackendType::BestAvailable;
|
||||
}
|
||||
throw std::invalid_argument("unsupported audio.cpp backend: " + value);
|
||||
}
|
||||
|
||||
int parse_integer(
|
||||
const std::string & name,
|
||||
const std::string & value,
|
||||
int minimum) {
|
||||
size_t parsed = 0;
|
||||
long result = 0;
|
||||
try {
|
||||
result = std::stol(value, &parsed);
|
||||
} catch (const std::exception &) {
|
||||
throw std::invalid_argument("invalid audio.cpp " + name + ": " + value);
|
||||
}
|
||||
if (parsed != value.size() ||
|
||||
result < minimum ||
|
||||
result > std::numeric_limits<int>::max()) {
|
||||
throw std::invalid_argument("invalid audio.cpp " + name + ": " + value);
|
||||
}
|
||||
return static_cast<int>(result);
|
||||
}
|
||||
|
||||
void copy_namespaced_option(
|
||||
const std::string & key,
|
||||
const std::string & prefix,
|
||||
const std::string & value,
|
||||
std::unordered_map<std::string, std::string> & destination) {
|
||||
const std::string name = key.substr(prefix.size());
|
||||
if (name.empty()) {
|
||||
throw std::invalid_argument("audio.cpp option namespace requires a name: " + key);
|
||||
}
|
||||
destination[name] = value;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AudioCppModelConfig parse_model_config(
|
||||
const std::filesystem::path & model_path,
|
||||
const std::unordered_map<std::string, std::string> & options) {
|
||||
AudioCppModelConfig config;
|
||||
config.model_path = model_path;
|
||||
config.family = require_option(options, "family");
|
||||
config.task.task = parse_task(require_option(options, "task"));
|
||||
config.task.mode = RunMode::Offline;
|
||||
config.load.model_path = model_path;
|
||||
config.load.family_hint = config.family;
|
||||
config.session.backend.type = BackendType::Cpu;
|
||||
|
||||
if (const auto it = options.find("mode"); it != options.end()) {
|
||||
config.task.mode = parse_mode(it->second);
|
||||
}
|
||||
if (const auto it = options.find("backend"); it != options.end()) {
|
||||
config.session.backend.type = parse_backend(it->second);
|
||||
}
|
||||
if (const auto it = options.find("device"); it != options.end()) {
|
||||
config.session.backend.device = parse_integer("device", it->second, 0);
|
||||
}
|
||||
if (const auto it = options.find("threads"); it != options.end()) {
|
||||
config.session.backend.threads = parse_integer("threads", it->second, 1);
|
||||
}
|
||||
if (const auto it = options.find("model_spec"); it != options.end()) {
|
||||
config.load.model_spec_override = std::filesystem::path(it->second);
|
||||
}
|
||||
if (const auto it = options.find("config_id"); it != options.end()) {
|
||||
config.load.config_id = it->second;
|
||||
}
|
||||
if (const auto it = options.find("weight_id"); it != options.end()) {
|
||||
config.load.weight_id = it->second;
|
||||
}
|
||||
|
||||
for (const auto & [key, value] : options) {
|
||||
if (key.rfind("load.", 0) == 0) {
|
||||
copy_namespaced_option(key, "load.", value, config.load.options);
|
||||
} else if (key.rfind("session.", 0) == 0) {
|
||||
copy_namespaced_option(key, "session.", value, config.session.options);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
} // namespace audio_cpp
|
||||
26
backend/cpp/audio-cpp/model_config.h
Normal file
26
backend/cpp/audio-cpp/model_config.h
Normal file
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "engine/framework/runtime/model.h"
|
||||
#include "engine/framework/runtime/session.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace audio_cpp {
|
||||
|
||||
struct AudioCppModelConfig {
|
||||
std::filesystem::path model_path;
|
||||
std::string family;
|
||||
engine::runtime::TaskSpec task;
|
||||
engine::runtime::ModelLoadRequest load;
|
||||
engine::runtime::SessionOptions session;
|
||||
};
|
||||
|
||||
AudioCppModelConfig parse_model_config(
|
||||
const std::filesystem::path & model_path,
|
||||
const std::unordered_map<std::string, std::string> & options);
|
||||
|
||||
} // namespace audio_cpp
|
||||
164
backend/cpp/audio-cpp/tests/build_contract_test.sh
Executable file
164
backend/cpp/audio-cpp/tests/build_contract_test.sh
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
backend_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp_dir}"' EXIT
|
||||
|
||||
fixture_dir="${tmp_dir}/audio.cpp"
|
||||
prefix_dir="${tmp_dir}/prefix"
|
||||
tools_dir="${tmp_dir}/tools"
|
||||
mkdir -p "${fixture_dir}" "${prefix_dir}/lib/cmake/Protobuf" \
|
||||
"${prefix_dir}/lib/cmake/gRPC" "${tools_dir}"
|
||||
|
||||
cat >"${fixture_dir}/engine_runtime.cpp" <<'EOF'
|
||||
void audio_cpp_build_contract_fixture() {}
|
||||
EOF
|
||||
|
||||
cat >"${fixture_dir}/CMakeLists.txt" <<'EOF'
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(AudioCppBuildContractFixture LANGUAGES CXX)
|
||||
|
||||
option(ENGINE_ENABLE_CUDA "Build with CUDA" OFF)
|
||||
option(ENGINE_ENABLE_VULKAN "Build with Vulkan" OFF)
|
||||
option(ENGINE_ENABLE_METAL "Build with Metal" OFF)
|
||||
|
||||
foreach(wrong_option IN ITEMS
|
||||
AUDIO_CPP_ENABLE_CUDA
|
||||
AUDIO_CPP_ENABLE_VULKAN
|
||||
AUDIO_CPP_ENABLE_METAL
|
||||
AUDIOCPP_ENABLE_CUDA
|
||||
AUDIOCPP_ENABLE_VULKAN
|
||||
AUDIOCPP_ENABLE_METAL
|
||||
ENGINE_CUDA
|
||||
ENGINE_VULKAN
|
||||
ENGINE_METAL
|
||||
GGML_CUDA
|
||||
GGML_VULKAN
|
||||
GGML_METAL)
|
||||
if(DEFINED ${wrong_option})
|
||||
message(FATAL_ERROR "legacy or unsupported audio.cpp option: ${wrong_option}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
add_library(engine_runtime STATIC engine_runtime.cpp)
|
||||
EOF
|
||||
|
||||
cat >"${prefix_dir}/lib/cmake/Protobuf/ProtobufConfig.cmake" <<'EOF'
|
||||
set(Protobuf_FOUND TRUE)
|
||||
set(Protobuf_VERSION 0.0.0)
|
||||
if(NOT TARGET protobuf::libprotobuf)
|
||||
add_library(protobuf::libprotobuf INTERFACE IMPORTED)
|
||||
endif()
|
||||
EOF
|
||||
|
||||
cat >"${prefix_dir}/lib/cmake/gRPC/gRPCConfig.cmake" <<'EOF'
|
||||
set(gRPC_FOUND TRUE)
|
||||
if(NOT TARGET gRPC::grpc++)
|
||||
add_library(gRPC::grpc++ INTERFACE IMPORTED)
|
||||
endif()
|
||||
if(NOT TARGET gRPC::grpc++_reflection)
|
||||
add_library(gRPC::grpc++_reflection INTERFACE IMPORTED)
|
||||
endif()
|
||||
EOF
|
||||
|
||||
cat >"${tools_dir}/protoc" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
exit 0
|
||||
EOF
|
||||
cat >"${tools_dir}/grpc_cpp_plugin" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "${tools_dir}/protoc" "${tools_dir}/grpc_cpp_plugin"
|
||||
|
||||
assert_cache_bool() {
|
||||
local cache_file="$1"
|
||||
local name="$2"
|
||||
local expected="$3"
|
||||
grep -q "^${name}:BOOL=${expected}$" "${cache_file}" || {
|
||||
echo "expected ${name}:BOOL=${expected} in ${cache_file}" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
configure_case() {
|
||||
local name="$1"
|
||||
local cuda="$2"
|
||||
local vulkan="$3"
|
||||
local metal="$4"
|
||||
local build_dir="${tmp_dir}/build-${name}"
|
||||
|
||||
PATH="${tools_dir}:${PATH}" cmake \
|
||||
-S "${backend_dir}" \
|
||||
-B "${build_dir}" \
|
||||
-DCMAKE_PREFIX_PATH="${prefix_dir}" \
|
||||
-DAUDIO_CPP_DIR="${fixture_dir}" \
|
||||
-DENGINE_ENABLE_CUDA="${cuda}" \
|
||||
-DENGINE_ENABLE_VULKAN="${vulkan}" \
|
||||
-DENGINE_ENABLE_METAL="${metal}" \
|
||||
>/dev/null
|
||||
|
||||
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_CUDA "${cuda}"
|
||||
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_VULKAN "${vulkan}"
|
||||
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_METAL "${metal}"
|
||||
|
||||
grep -q 'engine_runtime' \
|
||||
"${build_dir}/CMakeFiles/audio-cpp-grpc-server.dir/link.txt" || {
|
||||
echo "audio-cpp-grpc-server does not link engine_runtime" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
configure_case cpu OFF OFF OFF
|
||||
configure_case cuda ON OFF OFF
|
||||
configure_case vulkan OFF ON OFF
|
||||
configure_case metal OFF OFF ON
|
||||
|
||||
if PATH="${tools_dir}:${PATH}" cmake \
|
||||
-S "${backend_dir}" \
|
||||
-B "${tmp_dir}/build-wrong-option" \
|
||||
-DCMAKE_PREFIX_PATH="${prefix_dir}" \
|
||||
-DAUDIO_CPP_DIR="${fixture_dir}" \
|
||||
-DGGML_CUDA=ON \
|
||||
>/dev/null 2>&1; then
|
||||
echo "strict audio.cpp fixture accepted legacy GGML_CUDA option" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
make_database="${tmp_dir}/make-database"
|
||||
make -C "${backend_dir}" -pn >"${make_database}"
|
||||
audio_cpp_version="$(
|
||||
sed -n 's/^AUDIO_CPP_VERSION = //p' "${make_database}" | head -n 1
|
||||
)"
|
||||
[[ "${audio_cpp_version}" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
echo "AUDIO_CPP_VERSION must be a pinned 40-character commit" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fetch_plan="${tmp_dir}/fetch-plan"
|
||||
make -C "${backend_dir}" -Bn audio.cpp >"${fetch_plan}"
|
||||
grep -q 'github.com/0xShug0/audio.cpp' "${fetch_plan}"
|
||||
grep -q "${audio_cpp_version}" "${fetch_plan}"
|
||||
|
||||
cat >"${tools_dir}/uname" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "-s" ]; then
|
||||
echo Darwin
|
||||
exit 0
|
||||
fi
|
||||
echo "build contract requires uname -s" >&2
|
||||
exit 64
|
||||
EOF
|
||||
chmod +x "${tools_dir}/uname"
|
||||
|
||||
darwin_plan="${tmp_dir}/darwin-plan"
|
||||
PATH="${tools_dir}:${PATH}" make -C "${backend_dir}" -n \
|
||||
AUDIO_CPP_SRC="${fixture_dir}" grpc-server >"${darwin_plan}"
|
||||
grep -q -- '-DENGINE_ENABLE_CUDA=OFF' "${darwin_plan}"
|
||||
grep -q -- '-DENGINE_ENABLE_VULKAN=OFF' "${darwin_plan}"
|
||||
grep -q -- '-DENGINE_ENABLE_METAL=ON' "${darwin_plan}"
|
||||
|
||||
echo "audio.cpp build contract: PASS"
|
||||
490
backend/cpp/audio-cpp/tests/runtime_tests.cpp
Normal file
490
backend/cpp/audio-cpp/tests/runtime_tests.cpp
Normal file
@@ -0,0 +1,490 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "audio_cpp_runtime.h"
|
||||
#include "model_config.h"
|
||||
|
||||
#include "engine/framework/runtime/model.h"
|
||||
#include "engine/framework/runtime/registry.h"
|
||||
#include "engine/framework/runtime/session.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <exception>
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using engine::runtime::AudioChunk;
|
||||
using engine::runtime::CapabilitySet;
|
||||
using engine::runtime::ILoadedVoiceModel;
|
||||
using engine::runtime::IOfflineVoiceTaskSession;
|
||||
using engine::runtime::IStreamingVoiceTaskSession;
|
||||
using engine::runtime::IVoiceModelLoader;
|
||||
using engine::runtime::IVoiceTaskSession;
|
||||
using engine::runtime::ModelInspection;
|
||||
using engine::runtime::ModelLoadRequest;
|
||||
using engine::runtime::ModelMetadata;
|
||||
using engine::runtime::RunMode;
|
||||
using engine::runtime::SessionOptions;
|
||||
using engine::runtime::SessionPreparationRequest;
|
||||
using engine::runtime::StreamEvent;
|
||||
using engine::runtime::TaskCapability;
|
||||
using engine::runtime::TaskRequest;
|
||||
using engine::runtime::TaskResult;
|
||||
using engine::runtime::TaskSpec;
|
||||
using engine::runtime::VoiceTaskKind;
|
||||
|
||||
void require(bool condition, const std::string & message) {
|
||||
if (!condition) {
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename Function>
|
||||
void require_throws(Function && function, const std::string & expected) {
|
||||
try {
|
||||
function();
|
||||
} catch (const std::exception & error) {
|
||||
require(
|
||||
std::string(error.what()).find(expected) != std::string::npos,
|
||||
"expected error containing '" + expected + "', got '" + error.what() + "'");
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("expected exception containing '" + expected + "'");
|
||||
}
|
||||
|
||||
struct SessionGate {
|
||||
std::mutex mutex;
|
||||
std::condition_variable condition;
|
||||
bool first_entered = false;
|
||||
bool release_first = false;
|
||||
std::atomic<int> entries{0};
|
||||
};
|
||||
|
||||
struct FakeState {
|
||||
std::mutex mutex;
|
||||
std::vector<std::string> events;
|
||||
CapabilitySet capabilities;
|
||||
bool fail_load = false;
|
||||
bool fail_session = false;
|
||||
int generation = 0;
|
||||
std::shared_ptr<SessionGate> gate;
|
||||
|
||||
void record(std::string event) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
events.push_back(std::move(event));
|
||||
}
|
||||
};
|
||||
|
||||
class FakeSession final
|
||||
: public IOfflineVoiceTaskSession,
|
||||
public IStreamingVoiceTaskSession {
|
||||
public:
|
||||
FakeSession(
|
||||
std::shared_ptr<FakeState> state,
|
||||
int generation,
|
||||
TaskSpec task,
|
||||
SessionOptions options)
|
||||
: state_(std::move(state)),
|
||||
generation_(generation),
|
||||
task_(task),
|
||||
options_(std::move(options)) {}
|
||||
|
||||
~FakeSession() override {
|
||||
state_->record("session-" + std::to_string(generation_) + "-destroyed");
|
||||
}
|
||||
|
||||
std::string family() const override { return "fake-family"; }
|
||||
VoiceTaskKind task_kind() const override { return task_.task; }
|
||||
RunMode run_mode() const override { return task_.mode; }
|
||||
|
||||
void prepare(const SessionPreparationRequest & request) override {
|
||||
prepared_ = request;
|
||||
}
|
||||
|
||||
TaskResult run(const TaskRequest &) override {
|
||||
if (state_->gate != nullptr) {
|
||||
const int entry = ++state_->gate->entries;
|
||||
if (entry == 1) {
|
||||
std::unique_lock<std::mutex> lock(state_->gate->mutex);
|
||||
state_->gate->first_entered = true;
|
||||
state_->gate->condition.notify_all();
|
||||
state_->gate->condition.wait(
|
||||
lock,
|
||||
[&] { return state_->gate->release_first; });
|
||||
}
|
||||
}
|
||||
|
||||
TaskResult result;
|
||||
result.text_output = engine::runtime::Transcript{
|
||||
"generation-" + std::to_string(generation_),
|
||||
"en",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
engine::runtime::StreamingPolicy streaming_policy() const override {
|
||||
engine::runtime::StreamingPolicy policy;
|
||||
policy.input = engine::runtime::StreamingInputKind::AudioChunks;
|
||||
policy.output = engine::runtime::StreamingOutputKind::PullEvents;
|
||||
policy.preferred_audio_chunk_samples = 160;
|
||||
return policy;
|
||||
}
|
||||
|
||||
void start_stream(const TaskRequest &) override {
|
||||
streaming_ = true;
|
||||
}
|
||||
|
||||
std::optional<StreamEvent> next_stream_event() override {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void set_stream_event_sink(engine::runtime::StreamEventCallback sink) override {
|
||||
sink_ = std::move(sink);
|
||||
}
|
||||
|
||||
TaskResult finish_stream() override {
|
||||
streaming_ = false;
|
||||
return run({});
|
||||
}
|
||||
|
||||
void reset() override {
|
||||
streaming_ = false;
|
||||
}
|
||||
|
||||
StreamEvent process_audio_chunk(const AudioChunk & chunk) override {
|
||||
require(streaming_, "stream was not started");
|
||||
StreamEvent event;
|
||||
event.audio_output = engine::runtime::AudioBuffer{
|
||||
chunk.sample_rate,
|
||||
chunk.channels,
|
||||
chunk.samples,
|
||||
};
|
||||
if (sink_) {
|
||||
sink_(event);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
TaskResult finalize() override {
|
||||
streaming_ = false;
|
||||
return run({});
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<FakeState> state_;
|
||||
int generation_;
|
||||
TaskSpec task_;
|
||||
SessionOptions options_;
|
||||
SessionPreparationRequest prepared_;
|
||||
engine::runtime::StreamEventCallback sink_;
|
||||
bool streaming_ = false;
|
||||
};
|
||||
|
||||
class FakeLoadedModel final : public ILoadedVoiceModel {
|
||||
public:
|
||||
FakeLoadedModel(std::shared_ptr<FakeState> state, int generation)
|
||||
: state_(std::move(state)),
|
||||
generation_(generation) {
|
||||
metadata_.family = "fake-family";
|
||||
metadata_.variant = "complete-fake";
|
||||
metadata_.description = "complete test implementation";
|
||||
metadata_.config_candidates = {"config.json"};
|
||||
metadata_.weight_candidates = {"weights.gguf"};
|
||||
}
|
||||
|
||||
~FakeLoadedModel() override {
|
||||
state_->record("model-" + std::to_string(generation_) + "-destroyed");
|
||||
}
|
||||
|
||||
const ModelMetadata & metadata() const noexcept override {
|
||||
return metadata_;
|
||||
}
|
||||
|
||||
const CapabilitySet & capabilities() const noexcept override {
|
||||
return state_->capabilities;
|
||||
}
|
||||
|
||||
std::unique_ptr<IVoiceTaskSession> create_task_session(
|
||||
const TaskSpec & task,
|
||||
const SessionOptions & options) const override {
|
||||
if (state_->fail_session) {
|
||||
throw std::runtime_error("session creation failed");
|
||||
}
|
||||
return std::make_unique<FakeSession>(state_, generation_, task, options);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<FakeState> state_;
|
||||
int generation_;
|
||||
ModelMetadata metadata_;
|
||||
};
|
||||
|
||||
class FakeLoader final : public IVoiceModelLoader {
|
||||
public:
|
||||
explicit FakeLoader(std::shared_ptr<FakeState> state)
|
||||
: state_(std::move(state)) {}
|
||||
|
||||
std::string family() const override { return "fake-family"; }
|
||||
|
||||
bool can_load(const ModelLoadRequest & request) const override {
|
||||
return request.family_hint == family();
|
||||
}
|
||||
|
||||
ModelInspection inspect(const ModelLoadRequest & request) const override {
|
||||
ModelInspection inspection;
|
||||
inspection.metadata.family = family();
|
||||
inspection.metadata.variant = "complete-fake";
|
||||
inspection.metadata.description = "complete test loader";
|
||||
inspection.metadata.config_candidates = {"config.json"};
|
||||
inspection.metadata.weight_candidates = {"weights.gguf"};
|
||||
inspection.capabilities = state_->capabilities;
|
||||
inspection.model_root = request.model_path;
|
||||
return inspection;
|
||||
}
|
||||
|
||||
std::unique_ptr<ILoadedVoiceModel> load(
|
||||
const ModelLoadRequest &) const override {
|
||||
if (state_->fail_load) {
|
||||
throw std::runtime_error("model load failed");
|
||||
}
|
||||
const int generation = ++state_->generation;
|
||||
return std::make_unique<FakeLoadedModel>(state_, generation);
|
||||
}
|
||||
|
||||
CapabilitySet advertised_capabilities() const override {
|
||||
return state_->capabilities;
|
||||
}
|
||||
|
||||
std::string advertised_instructions_policy() const override {
|
||||
return "explicit";
|
||||
}
|
||||
|
||||
std::vector<std::string> advertised_api_endpoints() const override {
|
||||
return {"/v1/audio/transcriptions"};
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<FakeState> state_;
|
||||
};
|
||||
|
||||
audio_cpp::AudioCppModelConfig offline_asr_config(
|
||||
const std::filesystem::path & model_path) {
|
||||
return audio_cpp::parse_model_config(
|
||||
model_path,
|
||||
{
|
||||
{"family", "fake-family"},
|
||||
{"task", "asr"},
|
||||
{"mode", "offline"},
|
||||
{"backend", "cpu"},
|
||||
{"device", "2"},
|
||||
{"threads", "3"},
|
||||
{"load.cache", "memory"},
|
||||
{"session.language", "en"},
|
||||
});
|
||||
}
|
||||
|
||||
std::unique_ptr<audio_cpp::AudioCppRuntime> make_runtime(
|
||||
const std::shared_ptr<FakeState> & state) {
|
||||
engine::runtime::ModelRegistry registry;
|
||||
registry.register_loader(std::make_shared<FakeLoader>(state));
|
||||
return std::make_unique<audio_cpp::AudioCppRuntime>(std::move(registry));
|
||||
}
|
||||
|
||||
void test_model_config(const std::filesystem::path & model_path) {
|
||||
const auto config = offline_asr_config(model_path);
|
||||
require(config.model_path == model_path, "model path was not preserved");
|
||||
require(config.family == "fake-family", "family was not parsed");
|
||||
require(config.task.task == VoiceTaskKind::Asr, "task was not parsed");
|
||||
require(config.task.mode == RunMode::Offline, "mode was not parsed");
|
||||
require(
|
||||
config.session.backend.type == engine::core::BackendType::Cpu,
|
||||
"backend was not parsed");
|
||||
require(config.session.backend.device == 2, "device was not parsed");
|
||||
require(config.session.backend.threads == 3, "threads were not parsed");
|
||||
require(config.load.options.at("cache") == "memory", "load option prefix was not stripped");
|
||||
require(
|
||||
config.session.options.at("language") == "en",
|
||||
"session option prefix was not stripped");
|
||||
|
||||
require_throws(
|
||||
[&] { audio_cpp::parse_model_config(model_path, {{"task", "asr"}}); },
|
||||
"family");
|
||||
require_throws(
|
||||
[&] { audio_cpp::parse_model_config(model_path, {{"family", "fake-family"}}); },
|
||||
"task");
|
||||
require_throws(
|
||||
[&] {
|
||||
audio_cpp::parse_model_config(
|
||||
model_path,
|
||||
{{"family", "fake-family"}, {"task", "asr"}, {"mode", "batch"}});
|
||||
},
|
||||
"mode");
|
||||
require_throws(
|
||||
[&] {
|
||||
audio_cpp::parse_model_config(
|
||||
model_path,
|
||||
{{"family", "fake-family"}, {"task", "asr"}, {"backend", "tpu"}});
|
||||
},
|
||||
"backend");
|
||||
}
|
||||
|
||||
void test_capability_validation(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Tts, {RunMode::Offline}},
|
||||
};
|
||||
auto runtime = make_runtime(state);
|
||||
|
||||
require_throws(
|
||||
[&] { runtime->load(offline_asr_config(model_path)); },
|
||||
"task");
|
||||
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Streaming}},
|
||||
};
|
||||
require_throws(
|
||||
[&] { runtime->load(offline_asr_config(model_path)); },
|
||||
"mode");
|
||||
}
|
||||
|
||||
void test_atomic_replacement(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Offline}},
|
||||
};
|
||||
auto runtime = make_runtime(state);
|
||||
runtime->load(offline_asr_config(model_path));
|
||||
|
||||
state->fail_load = true;
|
||||
require_throws(
|
||||
[&] { runtime->load(offline_asr_config(model_path)); },
|
||||
"model load failed");
|
||||
require(
|
||||
runtime->run({}).text_output->text == "generation-1",
|
||||
"old model was not retained after load failure");
|
||||
|
||||
state->fail_load = false;
|
||||
state->fail_session = true;
|
||||
require_throws(
|
||||
[&] { runtime->load(offline_asr_config(model_path)); },
|
||||
"session creation failed");
|
||||
require(
|
||||
runtime->run({}).text_output->text == "generation-1",
|
||||
"old model was not retained after session creation failure");
|
||||
}
|
||||
|
||||
void test_teardown_order(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Offline}},
|
||||
};
|
||||
auto runtime = make_runtime(state);
|
||||
runtime->load(offline_asr_config(model_path));
|
||||
runtime->free();
|
||||
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
require(state->events.size() == 2, "expected one session and one model teardown");
|
||||
require(
|
||||
state->events[0] == "session-1-destroyed",
|
||||
"session was not destroyed before model");
|
||||
require(
|
||||
state->events[1] == "model-1-destroyed",
|
||||
"model teardown event was not second");
|
||||
}
|
||||
|
||||
void test_runtime_serializes_calls(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Offline}},
|
||||
};
|
||||
state->gate = std::make_shared<SessionGate>();
|
||||
auto runtime = make_runtime(state);
|
||||
runtime->load(offline_asr_config(model_path));
|
||||
|
||||
auto first = std::async(std::launch::async, [&] { return runtime->run({}); });
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state->gate->mutex);
|
||||
state->gate->condition.wait(
|
||||
lock,
|
||||
[&] { return state->gate->first_entered; });
|
||||
}
|
||||
|
||||
std::promise<void> release_second;
|
||||
std::shared_future<void> second_barrier = release_second.get_future().share();
|
||||
std::promise<void> second_attempted_promise;
|
||||
auto second_attempted = second_attempted_promise.get_future();
|
||||
auto second = std::async(std::launch::async, [&] {
|
||||
second_barrier.wait();
|
||||
second_attempted_promise.set_value();
|
||||
return runtime->run({});
|
||||
});
|
||||
|
||||
release_second.set_value();
|
||||
second_attempted.wait();
|
||||
require(
|
||||
second.wait_for(std::chrono::milliseconds(50)) == std::future_status::timeout,
|
||||
"second call completed while first call held the runtime");
|
||||
require(
|
||||
state->gate->entries.load() == 1,
|
||||
"second call entered the upstream session concurrently");
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->gate->mutex);
|
||||
state->gate->release_first = true;
|
||||
}
|
||||
state->gate->condition.notify_all();
|
||||
first.get();
|
||||
second.get();
|
||||
require(state->gate->entries.load() == 2, "second call never reached the session");
|
||||
}
|
||||
|
||||
void test_streaming_surface(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Streaming}},
|
||||
};
|
||||
auto runtime = make_runtime(state);
|
||||
auto config = offline_asr_config(model_path);
|
||||
config.task.mode = RunMode::Streaming;
|
||||
runtime->load(config);
|
||||
runtime->start_stream({});
|
||||
const auto event = runtime->process_audio_chunk({16000, 1, 0, {0.25f}});
|
||||
require(event.audio_output.has_value(), "streaming chunk result was lost");
|
||||
require(
|
||||
event.audio_output->samples == std::vector<float>{0.25f},
|
||||
"streaming chunk samples changed");
|
||||
require(
|
||||
runtime->finish_stream().text_output->text == "generation-1",
|
||||
"streaming final result was lost");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
try {
|
||||
require(argc == 2, "runtime_test requires an existing model path argument");
|
||||
const std::filesystem::path model_path(argv[1]);
|
||||
test_model_config(model_path);
|
||||
test_capability_validation(model_path);
|
||||
test_atomic_replacement(model_path);
|
||||
test_teardown_order(model_path);
|
||||
test_runtime_serializes_calls(model_path);
|
||||
test_streaming_surface(model_path);
|
||||
std::cout << "audio.cpp runtime unit tests: PASS\n";
|
||||
return 0;
|
||||
} catch (const std::exception & error) {
|
||||
std::cerr << "audio.cpp runtime unit tests: FAIL: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
// Ordering guard for mutable backend image tags.
|
||||
//
|
||||
// Backend images are published to quay.io/go-skynet/local-ai-backends and
|
||||
// localai/localai-backends under two kinds of tag:
|
||||
//
|
||||
// immutable sha-<short>-<suffix> one per commit, never reused
|
||||
// mutable master-<suffix> moved on every master build
|
||||
// latest-<suffix>
|
||||
// v<version>-<suffix>
|
||||
//
|
||||
// Nothing used to check whether an incoming build was newer than whatever a
|
||||
// mutable tag already pointed at, so the last writer won. That is not a
|
||||
// theoretical race: backend CI queues run hours deep (measured ~260 min
|
||||
// average queue wait against ~19 min average execution) and master pushes get
|
||||
// a concurrency group keyed by github.sha, so no run supersedes another.
|
||||
// Completion order does not track commit order. On 19 Jul 2026 a build of a
|
||||
// commit from 06:45 UTC finished at 15:40 UTC and moved
|
||||
// master-nvidia-l4t-cuda-13-arm64-longcat-video back onto a pre-fix image,
|
||||
// silently un-shipping a merged cuDNN packaging fix for two days.
|
||||
//
|
||||
// This module decides, per tag, whether a push may proceed. Everything here is
|
||||
// dependency-free; the registry and GitHub lookups take an injected fetch so
|
||||
// the decision logic is unit-testable without network access
|
||||
// (scripts/lib/tag-guard_test.mjs, run by `make test-ci-scripts`).
|
||||
|
||||
// docker/metadata-action's `type=sha` emits `sha-<7+ hex>`, and our flavor
|
||||
// appends the backend tag-suffix. Anything matching this is a per-commit
|
||||
// artifact: it is how a human pins a known-good build, so it must always push.
|
||||
const IMMUTABLE_TAG_RE = /^sha-[0-9a-f]{7,40}(?:-|$)/;
|
||||
|
||||
const REVISION_LABEL = "org.opencontainers.image.revision";
|
||||
|
||||
const MANIFEST_ACCEPT = [
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
].join(",");
|
||||
|
||||
// Split "quay.io/go-skynet/local-ai-backends:master-foo" into its parts.
|
||||
// Docker Hub refs carry no registry host ("localai/localai-backends:tag"), so
|
||||
// a first segment without a dot or colon means Docker Hub.
|
||||
export function parseImageRef(ref) {
|
||||
const lastColon = ref.lastIndexOf(":");
|
||||
const lastSlash = ref.lastIndexOf("/");
|
||||
if (lastColon === -1 || lastColon < lastSlash) {
|
||||
throw new Error(`image ref has no tag: ${ref}`);
|
||||
}
|
||||
const name = ref.slice(0, lastColon);
|
||||
const tag = ref.slice(lastColon + 1);
|
||||
|
||||
const segments = name.split("/");
|
||||
let host = "docker.io";
|
||||
let repository = name;
|
||||
if (segments.length > 1 && /[.:]/.test(segments[0])) {
|
||||
host = segments[0];
|
||||
repository = segments.slice(1).join("/");
|
||||
} else if (segments.length === 1) {
|
||||
repository = `library/${name}`;
|
||||
}
|
||||
return { host, repository, tag, ref };
|
||||
}
|
||||
|
||||
export function isImmutableTag(ref) {
|
||||
return IMMUTABLE_TAG_RE.test(parseImageRef(ref).tag);
|
||||
}
|
||||
|
||||
// Partition a metadata-action tag list into the tags that always push and the
|
||||
// tags that need an ordering check.
|
||||
export function classifyTags(refs) {
|
||||
const immutable = [];
|
||||
const mutable = [];
|
||||
for (const ref of refs) {
|
||||
(isImmutableTag(ref) ? immutable : mutable).push(ref);
|
||||
}
|
||||
return { immutable, mutable };
|
||||
}
|
||||
|
||||
// Decide a single mutable tag.
|
||||
//
|
||||
// `current` is the outcome of resolving the tag's present revision:
|
||||
// { status: "absent" } tag has never been pushed
|
||||
// { status: "unlabeled" } image carries no revision label
|
||||
// { status: "error", detail } registry lookup failed
|
||||
// { status: "ok", revision } revision is known
|
||||
//
|
||||
// `comparison` is the outcome of comparing that revision to the incoming one:
|
||||
// { status: "ahead" | "identical" } incoming descends from current
|
||||
// { status: "behind" | "diverged" } incoming is older or unrelated
|
||||
// { status: "unresolvable" } a commit is no longer in the repo
|
||||
// { status: "error", detail } compare API failed
|
||||
//
|
||||
// Fail-open cases (unlabeled, unresolvable, lookup errors) push with a loud
|
||||
// warning rather than blocking. A guard that fails closed on a registry or API
|
||||
// blip would itself stop shipping merged fixes, which is the bug we are fixing,
|
||||
// only louder. Every fail-open path is annotated so it is visible in the run.
|
||||
export function decideMutableTag({ tag, incomingSha, current, comparison }) {
|
||||
const short = (incomingSha || "").slice(0, 7);
|
||||
switch (current.status) {
|
||||
case "absent":
|
||||
return {
|
||||
push: true,
|
||||
severity: "info",
|
||||
reason: `${tag}: tag does not exist yet, publishing ${short}`,
|
||||
};
|
||||
case "unlabeled":
|
||||
return {
|
||||
push: true,
|
||||
severity: "warning",
|
||||
reason:
|
||||
`${tag}: current image carries no ${REVISION_LABEL} label, ` +
|
||||
`cannot verify ordering, publishing ${short} anyway`,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
push: true,
|
||||
severity: "warning",
|
||||
reason:
|
||||
`${tag}: could not read the current image (${current.detail}), ` +
|
||||
`cannot verify ordering, publishing ${short} anyway`,
|
||||
};
|
||||
}
|
||||
|
||||
const from = (current.revision || "").slice(0, 7);
|
||||
switch (comparison.status) {
|
||||
case "identical":
|
||||
return {
|
||||
push: true,
|
||||
severity: "info",
|
||||
reason: `${tag}: already at ${short}, republishing the same commit`,
|
||||
};
|
||||
case "ahead":
|
||||
return {
|
||||
push: true,
|
||||
severity: "info",
|
||||
reason: `${tag}: ${short} descends from ${from}, advancing the tag`,
|
||||
};
|
||||
case "behind":
|
||||
return {
|
||||
push: false,
|
||||
severity: "warning",
|
||||
reason:
|
||||
`${tag}: REFUSING to move the tag backwards: ${short} is an ` +
|
||||
`ancestor of the published ${from}. A newer build already won this ` +
|
||||
`race; this straggler would un-ship it. Pin sha-${short} if you ` +
|
||||
`need this exact build.`,
|
||||
};
|
||||
case "diverged":
|
||||
return {
|
||||
push: false,
|
||||
severity: "warning",
|
||||
reason:
|
||||
`${tag}: REFUSING to move the tag: ${short} and the published ` +
|
||||
`${from} have no ancestor relationship. Pin sha-${short} if you ` +
|
||||
`need this exact build.`,
|
||||
};
|
||||
case "unresolvable":
|
||||
return {
|
||||
push: true,
|
||||
severity: "warning",
|
||||
reason:
|
||||
`${tag}: the published revision ${from} is no longer reachable in ` +
|
||||
`the repository, cannot verify ordering, publishing ${short} anyway`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
push: true,
|
||||
severity: "warning",
|
||||
reason:
|
||||
`${tag}: commit comparison failed (${comparison.detail}), ` +
|
||||
`cannot verify ordering, publishing ${short} anyway`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Read the org.opencontainers.image.revision label off whatever a tag
|
||||
// currently points at. Both repositories are public, so anonymous pull tokens
|
||||
// are enough and the guard needs no registry credentials of its own.
|
||||
export async function resolveTagRevision(ref, { fetchImpl = fetch } = {}) {
|
||||
const { host, repository, tag } = parseImageRef(ref);
|
||||
const registry = host === "docker.io" ? "registry-1.docker.io" : host;
|
||||
const base = `https://${registry}/v2/${repository}`;
|
||||
|
||||
let auth = {};
|
||||
try {
|
||||
if (host === "docker.io") {
|
||||
const tokenRes = await fetchImpl(
|
||||
`https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`,
|
||||
);
|
||||
if (!tokenRes.ok) {
|
||||
return { status: "error", detail: `docker hub token ${tokenRes.status}` };
|
||||
}
|
||||
const { token } = await tokenRes.json();
|
||||
auth = { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
const manifestRes = await fetchImpl(`${base}/manifests/${tag}`, {
|
||||
headers: { Accept: MANIFEST_ACCEPT, ...auth },
|
||||
});
|
||||
if (manifestRes.status === 404) {
|
||||
return { status: "absent" };
|
||||
}
|
||||
if (!manifestRes.ok) {
|
||||
return { status: "error", detail: `manifest ${manifestRes.status}` };
|
||||
}
|
||||
let manifest = await manifestRes.json();
|
||||
|
||||
// Multi-arch: every leg of one build is stamped with the same revision, so
|
||||
// the first child is representative.
|
||||
if (Array.isArray(manifest.manifests)) {
|
||||
const child = manifest.manifests.find((m) => m.digest);
|
||||
if (!child) {
|
||||
return { status: "unlabeled" };
|
||||
}
|
||||
const childRes = await fetchImpl(`${base}/manifests/${child.digest}`, {
|
||||
headers: { Accept: MANIFEST_ACCEPT, ...auth },
|
||||
});
|
||||
if (!childRes.ok) {
|
||||
return { status: "error", detail: `child manifest ${childRes.status}` };
|
||||
}
|
||||
manifest = await childRes.json();
|
||||
}
|
||||
|
||||
const configDigest = manifest.config && manifest.config.digest;
|
||||
if (!configDigest) {
|
||||
return { status: "unlabeled" };
|
||||
}
|
||||
const configRes = await fetchImpl(`${base}/blobs/${configDigest}`, {
|
||||
headers: auth,
|
||||
});
|
||||
if (!configRes.ok) {
|
||||
return { status: "error", detail: `config blob ${configRes.status}` };
|
||||
}
|
||||
const config = await configRes.json();
|
||||
const labels =
|
||||
(config.config && config.config.Labels) ||
|
||||
(config.container_config && config.container_config.Labels) ||
|
||||
{};
|
||||
const revision = labels[REVISION_LABEL];
|
||||
return revision ? { status: "ok", revision } : { status: "unlabeled" };
|
||||
} catch (err) {
|
||||
return { status: "error", detail: String(err && err.message ? err.message : err) };
|
||||
}
|
||||
}
|
||||
|
||||
// Ask GitHub how `head` relates to `base`. The compare API answers this
|
||||
// directly, which keeps the guard working in the shallow sparse checkouts the
|
||||
// merge and publish jobs use. `git merge-base --is-ancestor` would need the
|
||||
// full history fetched into every one of the ~200 merge jobs per push.
|
||||
export async function compareCommits({
|
||||
repository,
|
||||
base,
|
||||
head,
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
if (base === head) {
|
||||
return { status: "identical" };
|
||||
}
|
||||
try {
|
||||
const headers = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetchImpl(
|
||||
`https://api.github.com/repos/${repository}/compare/${base}...${head}`,
|
||||
{ headers },
|
||||
);
|
||||
if (res.status === 404) {
|
||||
return { status: "unresolvable" };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { status: "error", detail: `compare ${res.status}` };
|
||||
}
|
||||
const body = await res.json();
|
||||
// GitHub reports ahead/behind/identical/diverged. Unrelated histories in
|
||||
// the same repository come back as "diverged" with no merge base.
|
||||
const known = ["ahead", "behind", "identical", "diverged"];
|
||||
return known.includes(body.status)
|
||||
? { status: body.status }
|
||||
: { status: "error", detail: `unexpected compare status ${body.status}` };
|
||||
} catch (err) {
|
||||
return { status: "error", detail: String(err && err.message ? err.message : err) };
|
||||
}
|
||||
}
|
||||
|
||||
// Full pass over a metadata-action tag list. Returns the tags that may be
|
||||
// pushed plus a decision log for the job summary.
|
||||
export async function guardTags({
|
||||
refs,
|
||||
incomingSha,
|
||||
repository,
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const { immutable, mutable } = classifyTags(refs);
|
||||
const decisions = immutable.map((tag) => ({
|
||||
tag,
|
||||
push: true,
|
||||
severity: "info",
|
||||
reason: `${tag}: immutable per-commit tag, always published`,
|
||||
}));
|
||||
|
||||
for (const tag of mutable) {
|
||||
const current = await resolveTagRevision(tag, { fetchImpl });
|
||||
let comparison = { status: "skipped" };
|
||||
if (current.status === "ok") {
|
||||
comparison = await compareCommits({
|
||||
repository,
|
||||
base: current.revision,
|
||||
head: incomingSha,
|
||||
token,
|
||||
fetchImpl,
|
||||
});
|
||||
}
|
||||
decisions.push({
|
||||
tag,
|
||||
...decideMutableTag({ tag, incomingSha, current, comparison }),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: decisions.filter((d) => d.push).map((d) => d.tag),
|
||||
blocked: decisions.filter((d) => !d.push).map((d) => d.tag),
|
||||
decisions,
|
||||
};
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
// Unit tests for the mutable-tag ordering guard (scripts/lib/tag-guard.mjs).
|
||||
//
|
||||
// Run with `make test-ci-scripts` (plain `node --test`, no dependencies), which
|
||||
// is what .github/workflows/lint.yml runs. The registry and GitHub lookups take
|
||||
// an injected fetch, so nothing here touches the network.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
classifyTags,
|
||||
compareCommits,
|
||||
decideMutableTag,
|
||||
guardTags,
|
||||
isImmutableTag,
|
||||
parseImageRef,
|
||||
} from "./tag-guard.mjs";
|
||||
|
||||
const QUAY = "quay.io/go-skynet/local-ai-backends";
|
||||
const HUB = "localai/localai-backends";
|
||||
const SUFFIX = "-nvidia-l4t-cuda-13-arm64-longcat-video";
|
||||
|
||||
// The commits from the incident this guard exists to prevent.
|
||||
const PRE_FIX = "10211948b5470d332a93c841a9c8fe2b9a737148";
|
||||
const POST_FIX = "626ae4d51000000000000000000000000000000a";
|
||||
|
||||
test("parseImageRef splits a registry-qualified ref", () => {
|
||||
assert.deepEqual(parseImageRef(`${QUAY}:master${SUFFIX}`), {
|
||||
host: "quay.io",
|
||||
repository: "go-skynet/local-ai-backends",
|
||||
tag: `master${SUFFIX}`,
|
||||
ref: `${QUAY}:master${SUFFIX}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseImageRef treats a dotless first segment as Docker Hub", () => {
|
||||
const parsed = parseImageRef(`${HUB}:latest${SUFFIX}`);
|
||||
assert.equal(parsed.host, "docker.io");
|
||||
assert.equal(parsed.repository, "localai/localai-backends");
|
||||
});
|
||||
|
||||
test("parseImageRef rejects a ref with no tag", () => {
|
||||
assert.throws(() => parseImageRef(QUAY), /no tag/);
|
||||
});
|
||||
|
||||
test("sha- tags are immutable, rolling tags are not", () => {
|
||||
assert.equal(isImmutableTag(`${QUAY}:sha-626ae4d${SUFFIX}`), true);
|
||||
assert.equal(isImmutableTag(`${QUAY}:sha-626ae4d`), true);
|
||||
assert.equal(isImmutableTag(`${QUAY}:master${SUFFIX}`), false);
|
||||
assert.equal(isImmutableTag(`${QUAY}:latest${SUFFIX}`), false);
|
||||
assert.equal(isImmutableTag(`${QUAY}:v4.7.0${SUFFIX}`), false);
|
||||
// A tag that merely starts with the letters "sha" is not a commit tag.
|
||||
assert.equal(isImmutableTag(`${QUAY}:shazam${SUFFIX}`), false);
|
||||
});
|
||||
|
||||
test("classifyTags partitions a metadata-action tag list", () => {
|
||||
const { immutable, mutable } = classifyTags([
|
||||
`${QUAY}:master${SUFFIX}`,
|
||||
`${QUAY}:sha-626ae4d${SUFFIX}`,
|
||||
`${HUB}:master${SUFFIX}`,
|
||||
`${HUB}:sha-626ae4d${SUFFIX}`,
|
||||
]);
|
||||
assert.deepEqual(immutable, [
|
||||
`${QUAY}:sha-626ae4d${SUFFIX}`,
|
||||
`${HUB}:sha-626ae4d${SUFFIX}`,
|
||||
]);
|
||||
assert.deepEqual(mutable, [`${QUAY}:master${SUFFIX}`, `${HUB}:master${SUFFIX}`]);
|
||||
});
|
||||
|
||||
test("a descendant commit advances the tag", () => {
|
||||
const d = decideMutableTag({
|
||||
tag: "master",
|
||||
incomingSha: POST_FIX,
|
||||
current: { status: "ok", revision: PRE_FIX },
|
||||
comparison: { status: "ahead" },
|
||||
});
|
||||
assert.equal(d.push, true);
|
||||
assert.match(d.reason, /advancing the tag/);
|
||||
});
|
||||
|
||||
test("the incident case is blocked: an older commit cannot move the tag back", () => {
|
||||
const d = decideMutableTag({
|
||||
tag: `master${SUFFIX}`,
|
||||
incomingSha: PRE_FIX,
|
||||
current: { status: "ok", revision: POST_FIX },
|
||||
comparison: { status: "behind" },
|
||||
});
|
||||
assert.equal(d.push, false);
|
||||
assert.equal(d.severity, "warning");
|
||||
assert.match(d.reason, /REFUSING to move the tag backwards/);
|
||||
// The workaround for the real incident was pinning the sha- tag; the message
|
||||
// has to point there.
|
||||
assert.match(d.reason, /Pin sha-1021194/);
|
||||
});
|
||||
|
||||
test("unrelated histories are blocked", () => {
|
||||
const d = decideMutableTag({
|
||||
tag: "master",
|
||||
incomingSha: POST_FIX,
|
||||
current: { status: "ok", revision: PRE_FIX },
|
||||
comparison: { status: "diverged" },
|
||||
});
|
||||
assert.equal(d.push, false);
|
||||
assert.match(d.reason, /no ancestor relationship/);
|
||||
});
|
||||
|
||||
test("a republish of the same commit is allowed", () => {
|
||||
const d = decideMutableTag({
|
||||
tag: "master",
|
||||
incomingSha: POST_FIX,
|
||||
current: { status: "ok", revision: POST_FIX },
|
||||
comparison: { status: "identical" },
|
||||
});
|
||||
assert.equal(d.push, true);
|
||||
assert.equal(d.severity, "info");
|
||||
});
|
||||
|
||||
test("a tag that does not exist yet is published without a warning", () => {
|
||||
const d = decideMutableTag({
|
||||
tag: "v4.7.0",
|
||||
incomingSha: POST_FIX,
|
||||
current: { status: "absent" },
|
||||
comparison: { status: "skipped" },
|
||||
});
|
||||
assert.equal(d.push, true);
|
||||
assert.equal(d.severity, "info");
|
||||
});
|
||||
|
||||
test("fail-open paths push but always warn", () => {
|
||||
for (const [current, comparison, pattern] of [
|
||||
[{ status: "unlabeled" }, { status: "skipped" }, /no org\.opencontainers\.image\.revision label/],
|
||||
[{ status: "error", detail: "manifest 503" }, { status: "skipped" }, /manifest 503/],
|
||||
[{ status: "ok", revision: PRE_FIX }, { status: "unresolvable" }, /no longer reachable/],
|
||||
[{ status: "ok", revision: PRE_FIX }, { status: "error", detail: "compare 502" }, /compare 502/],
|
||||
]) {
|
||||
const d = decideMutableTag({
|
||||
tag: "master",
|
||||
incomingSha: POST_FIX,
|
||||
current,
|
||||
comparison,
|
||||
});
|
||||
assert.equal(d.push, true);
|
||||
assert.equal(d.severity, "warning");
|
||||
assert.match(d.reason, pattern);
|
||||
}
|
||||
});
|
||||
|
||||
// --- fetch-backed helpers -------------------------------------------------
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
test("compareCommits short-circuits identical commits without a request", async () => {
|
||||
let called = false;
|
||||
const res = await compareCommits({
|
||||
repository: "mudler/LocalAI",
|
||||
base: POST_FIX,
|
||||
head: POST_FIX,
|
||||
fetchImpl: async () => {
|
||||
called = true;
|
||||
return jsonResponse({});
|
||||
},
|
||||
});
|
||||
assert.deepEqual(res, { status: "identical" });
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("compareCommits maps a 404 to unresolvable", async () => {
|
||||
const res = await compareCommits({
|
||||
repository: "mudler/LocalAI",
|
||||
base: PRE_FIX,
|
||||
head: POST_FIX,
|
||||
fetchImpl: async () => jsonResponse({}, 404),
|
||||
});
|
||||
assert.deepEqual(res, { status: "unresolvable" });
|
||||
});
|
||||
|
||||
test("compareCommits surfaces the GitHub status verbatim", async () => {
|
||||
const res = await compareCommits({
|
||||
repository: "mudler/LocalAI",
|
||||
base: POST_FIX,
|
||||
head: PRE_FIX,
|
||||
fetchImpl: async () => jsonResponse({ status: "behind" }),
|
||||
});
|
||||
assert.deepEqual(res, { status: "behind" });
|
||||
});
|
||||
|
||||
test("compareCommits turns a thrown fetch into a non-fatal error", async () => {
|
||||
const res = await compareCommits({
|
||||
repository: "mudler/LocalAI",
|
||||
base: PRE_FIX,
|
||||
head: POST_FIX,
|
||||
fetchImpl: async () => {
|
||||
throw new Error("ECONNRESET");
|
||||
},
|
||||
});
|
||||
assert.equal(res.status, "error");
|
||||
assert.match(res.detail, /ECONNRESET/);
|
||||
});
|
||||
|
||||
// A fake registry serving one multi-arch tag stamped with `revision`.
|
||||
function fakeRegistry(revision) {
|
||||
return async (url) => {
|
||||
if (url.includes("auth.docker.io")) {
|
||||
return jsonResponse({ token: "t" });
|
||||
}
|
||||
if (url.endsWith("/manifests/master-x")) {
|
||||
return jsonResponse({ manifests: [{ digest: "sha256:child" }] });
|
||||
}
|
||||
if (url.endsWith("/manifests/sha256:child")) {
|
||||
return jsonResponse({ config: { digest: "sha256:cfg" } });
|
||||
}
|
||||
if (url.endsWith("/blobs/sha256:cfg")) {
|
||||
return jsonResponse({
|
||||
config: { Labels: { "org.opencontainers.image.revision": revision } },
|
||||
});
|
||||
}
|
||||
return jsonResponse({}, 404);
|
||||
};
|
||||
}
|
||||
|
||||
test("guardTags always keeps immutable tags and drops a backwards mutable tag", async () => {
|
||||
const fetchImpl = async (url, init) => {
|
||||
if (url.startsWith("https://api.github.com/")) {
|
||||
return jsonResponse({ status: "behind" });
|
||||
}
|
||||
return fakeRegistry(POST_FIX)(url, init);
|
||||
};
|
||||
|
||||
const result = await guardTags({
|
||||
refs: [`${QUAY}:sha-1021194-x`, `${QUAY}:master-x`],
|
||||
incomingSha: PRE_FIX,
|
||||
repository: "mudler/LocalAI",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.allowed, [`${QUAY}:sha-1021194-x`]);
|
||||
assert.deepEqual(result.blocked, [`${QUAY}:master-x`]);
|
||||
});
|
||||
|
||||
test("guardTags advances a mutable tag when the incoming commit is newer", async () => {
|
||||
const fetchImpl = async (url, init) => {
|
||||
if (url.startsWith("https://api.github.com/")) {
|
||||
return jsonResponse({ status: "ahead" });
|
||||
}
|
||||
return fakeRegistry(PRE_FIX)(url, init);
|
||||
};
|
||||
|
||||
const result = await guardTags({
|
||||
refs: [`${QUAY}:sha-626ae4d-x`, `${QUAY}:master-x`],
|
||||
incomingSha: POST_FIX,
|
||||
repository: "mudler/LocalAI",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.allowed, [`${QUAY}:sha-626ae4d-x`, `${QUAY}:master-x`]);
|
||||
assert.deepEqual(result.blocked, []);
|
||||
});
|
||||
|
||||
test("guardTags publishes an unlabeled tag rather than stalling on it", async () => {
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.endsWith("/manifests/master-x")) {
|
||||
return jsonResponse({ config: { digest: "sha256:cfg" } });
|
||||
}
|
||||
if (url.endsWith("/blobs/sha256:cfg")) {
|
||||
return jsonResponse({ config: {} });
|
||||
}
|
||||
return jsonResponse({}, 404);
|
||||
};
|
||||
|
||||
const result = await guardTags({
|
||||
refs: [`${QUAY}:master-x`],
|
||||
incomingSha: POST_FIX,
|
||||
repository: "mudler/LocalAI",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.allowed, [`${QUAY}:master-x`]);
|
||||
assert.equal(result.decisions[0].severity, "warning");
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// CI entrypoint for the mutable-tag ordering guard. Reads the tag list
|
||||
// docker/metadata-action produced, decides which tags this build is allowed to
|
||||
// publish (see scripts/lib/tag-guard.mjs for the rules and the incident that
|
||||
// motivated them), prints the surviving tags one per line on stdout, and logs
|
||||
// every decision to stderr, the GitHub job summary and, for anything blocked or
|
||||
// unverifiable, a workflow annotation.
|
||||
//
|
||||
// The whole point of this guard is to be noisy: the bug it fixes ran silently
|
||||
// for two days. Never make a skipped tag a quiet no-op.
|
||||
//
|
||||
// Inputs (environment):
|
||||
// DOCKER_METADATA_OUTPUT_JSON metadata-action output; .tags is used
|
||||
// TAGS newline/comma separated tags (alternative)
|
||||
// GITHUB_SHA commit being published
|
||||
// GITHUB_REPOSITORY owner/name, for the compare API
|
||||
// GITHUB_TOKEN optional, raises the compare API rate limit
|
||||
// REGISTRY_PREFIX optional, keep only tags with this prefix
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 decisions made (blocked tags are not an error; the build still ships
|
||||
// its immutable sha- tag, which is the artifact humans pin)
|
||||
// 1 bad input, e.g. no tag list at all
|
||||
|
||||
import fs from "node:fs";
|
||||
|
||||
import { guardTags } from "./lib/tag-guard.mjs";
|
||||
|
||||
function readTags() {
|
||||
const raw = process.env.DOCKER_METADATA_OUTPUT_JSON;
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed.tags)) {
|
||||
return parsed.tags;
|
||||
}
|
||||
}
|
||||
if (process.env.TAGS) {
|
||||
return process.env.TAGS.split(/[\n,]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function appendSummary(lines) {
|
||||
const path = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.appendFileSync(path, `${lines.join("\n")}\n`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`tag-guard: could not write job summary: ${err}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const prefix = process.env.REGISTRY_PREFIX || "";
|
||||
const refs = readTags().filter((t) => t.startsWith(prefix));
|
||||
if (refs.length === 0) {
|
||||
process.stderr.write(
|
||||
`tag-guard: no tags to consider (REGISTRY_PREFIX=${prefix || "<none>"})\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const incomingSha = process.env.GITHUB_SHA;
|
||||
if (!incomingSha) {
|
||||
throw new Error("GITHUB_SHA is not set");
|
||||
}
|
||||
|
||||
const { allowed, blocked, decisions } = await guardTags({
|
||||
refs,
|
||||
incomingSha,
|
||||
repository: process.env.GITHUB_REPOSITORY || "mudler/LocalAI",
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
|
||||
const summary = ["", `#### Tag ordering guard (${incomingSha.slice(0, 7)})`, ""];
|
||||
for (const d of decisions) {
|
||||
process.stderr.write(`tag-guard: ${d.push ? "PUSH " : "SKIP "} ${d.reason}\n`);
|
||||
summary.push(`- ${d.push ? "published" : "**skipped**"}: ${d.reason}`);
|
||||
// Annotate anything that is not a plain, verified advance so it shows up on
|
||||
// the run page without anyone having to open the log.
|
||||
if (!d.push || d.severity === "warning") {
|
||||
process.stderr.write(`::warning title=backend tag guard::${d.reason}\n`);
|
||||
}
|
||||
}
|
||||
appendSummary(summary);
|
||||
|
||||
if (blocked.length > 0) {
|
||||
process.stderr.write(
|
||||
`tag-guard: ${blocked.length} mutable tag(s) withheld; ` +
|
||||
`${allowed.length} tag(s) will be published\n`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(`${allowed.join("\n")}\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`tag-guard: ${err && err.stack ? err.stack : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user