diff --git a/.containerignore b/.containerignore index f5b3c61..0d8572a 100644 --- a/.containerignore +++ b/.containerignore @@ -1,6 +1,7 @@ build/ td-install/ vendor/td/build/ +vendor/libyuv/build/ tdlib_db/ recordings/ .envrc diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 9c1038f..c374656 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -20,17 +20,24 @@ env: # GHCR image, derived from the repo (e.g. ghcr.io/nikicat/tg-echo-service). # Assumes the repo owner/name are lowercase, as GHCR requires. IMAGE: ghcr.io/${{ github.repository }} - # Registry-backed layer cache. podman has no GHA cache backend, so it - # pushes/pulls intermediate layers to this repo instead, letting the - # expensive TDLib/tgcalls/WebRTC layers survive across runs. - CACHE_REPO: ghcr.io/${{ github.repository }}/buildcache jobs: + # Build each architecture natively on its own runner (emulation is not viable + # for a from-source WebRTC build). The Ubuntu base is multi-arch, so both legs + # share one Containerfile with no per-arch base. build: - runs-on: ubuntu-24.04 # podman 4.9 — supports --layers --cache-to/--cache-from + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} timeout-minutes: 360 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: submodules: recursive @@ -47,12 +54,35 @@ jobs: - name: Build image if: github.event_name != 'push' || github.ref != 'refs/heads/master' run: | - make image IMAGE="$IMAGE" \ - IMAGE_BUILD_ARGS="--layers --cache-from $CACHE_REPO" + make image \ + IMAGE="$IMAGE" TAG="${{ matrix.arch }}" \ + IMAGE_BUILD_ARGS="--layers --cache-from $IMAGE/buildcache-${{ matrix.arch }}" - # master: build, populate the cache, and push the image + tools image. - - name: Build and push image + # master: build, populate the per-arch cache, and push the per-arch tag + # (image + tools image). The manifest job stitches them into :latest. + - name: Build and push per-arch image if: github.event_name == 'push' && github.ref == 'refs/heads/master' run: | - make push IMAGE="$IMAGE" \ - IMAGE_BUILD_ARGS="--layers --cache-from $CACHE_REPO --cache-to $CACHE_REPO" + make push \ + IMAGE="$IMAGE" TAG="${{ matrix.arch }}" \ + IMAGE_BUILD_ARGS="--layers --cache-from $IMAGE/buildcache-${{ matrix.arch }} --cache-to $IMAGE/buildcache-${{ matrix.arch }}" + + # Combine the per-arch tags into multi-arch manifests for the image and the + # tools image. Manifests are arch-agnostic metadata, so this runs on amd64. + manifest: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-24.04 + steps: + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Create and push multi-arch manifests + run: | + for repo in "$IMAGE" "$IMAGE-tools"; do + podman manifest create "$repo:latest" + podman manifest add "$repo:latest" "$repo:amd64" + podman manifest add "$repo:latest" "$repo:arm64" + podman manifest push --all "$repo:latest" "docker://$repo:latest" + podman manifest rm "$repo:latest" + done diff --git a/CMakeLists.txt b/CMakeLists.txt index dcd4e08..c541731 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,8 @@ cmake_minimum_required(VERSION 3.16) -project(call_service LANGUAGES C CXX) +# ASM is needed to assemble openh264's aarch64 NEON .S files on arm64. openh264 +# also ships x86 SIMD, but as NASM/YASM .asm (needs a separate assembler) which +# this build doesn't wire up, so x86_64 falls back to the portable C++ paths. +project(call_service LANGUAGES C CXX ASM) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -11,8 +14,16 @@ set(VENDOR_DIR "${CMAKE_SOURCE_DIR}/vendor") set(TGCALLS_DEPS_DIR "${VENDOR_DIR}") set(STUB_DIR "${CMAKE_SOURCE_DIR}/stub") -# Hack: BuildWebRTC.cmake uses ANDROID_ABI for platform-conditional sources -set(ANDROID_ABI "x86_64") +# Hack: the vendored Build*.cmake files use ANDROID_ABI to select +# platform-conditional sources, SIMD flags, and defines. Map the real target +# arch onto the ABI names they understand. +if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") + set(ANDROID_ABI "arm64-v8a") +elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64|AMD64") + set(ANDROID_ABI "x86_64") +else() + message(FATAL_ERROR "Unsupported architecture: ${CMAKE_SYSTEM_PROCESSOR}") +endif() # -- System library targets (must exist before Build*.cmake includes) -- @@ -88,14 +99,29 @@ list(REMOVE_ITEM _webrtc_sources "${WEBRTC_DIR}/modules/audio_device/audio_device_impl.cc" ) list(APPEND _webrtc_sources - "${TGCALLS_DEPS_DIR}/crc32c/src/crc32c_sse42.cc" "${STUB_DIR}/audio_device_stub.cc" ) + +# Hardware-accelerated crc32c, per arch. The stub crc32c_config.h sets +# HAVE_SSE42 on x86_64 and HAVE_ARM64_CRC32C on aarch64, so the matching impl +# must be compiled in or the dispatcher hits an undefined reference. +if(ANDROID_ABI STREQUAL "x86_64") + list(APPEND _webrtc_sources "${TGCALLS_DEPS_DIR}/crc32c/src/crc32c_sse42.cc") +elseif(ANDROID_ABI STREQUAL "arm64-v8a") + list(APPEND _webrtc_sources "${TGCALLS_DEPS_DIR}/crc32c/src/crc32c_arm64.cc") +endif() + set_target_properties(webrtc PROPERTIES SOURCES "${_webrtc_sources}") -# crc32c_sse42.cc needs SSE4.2 -set_source_files_properties("${TGCALLS_DEPS_DIR}/crc32c/src/crc32c_sse42.cc" - PROPERTIES COMPILE_FLAGS "-msse4.2") +# crc32c_sse42.cc needs SSE4.2; crc32c_arm64.cc needs the ARMv8 CRC (__crc32cd) +# and PMULL (vmull_p64) extensions. +if(ANDROID_ABI STREQUAL "x86_64") + set_source_files_properties("${TGCALLS_DEPS_DIR}/crc32c/src/crc32c_sse42.cc" + PROPERTIES COMPILE_FLAGS "-msse4.2") +elseif(ANDROID_ABI STREQUAL "arm64-v8a") + set_source_files_properties("${TGCALLS_DEPS_DIR}/crc32c/src/crc32c_arm64.cc" + PROPERTIES COMPILE_FLAGS "-march=armv8-a+crc+crypto") +endif() # Add pthread (Linux needs it explicitly) target_link_libraries(webrtc PUBLIC pthread) @@ -113,8 +139,11 @@ target_compile_options(webrtc PRIVATE -Wno-error -Wno-deprecated-declarations "SHELL:-include stdint.h" "SHELL:-include stddef.h") target_compile_options(absl PRIVATE -Wno-error "SHELL:-include stdint.h" "SHELL:-include stddef.h") +# Force-includes are for the C/C++ sources only — openh264's arm64 .S assembly +# sources must not get them (the assembler can't parse stdint.h's typedefs). target_compile_options(openh264 PRIVATE - "SHELL:-include stdint.h" "SHELL:-include stddef.h") + "$<$:SHELL:-include stdint.h>" + "$<$:SHELL:-include stddef.h>") target_compile_options(usrsctp PRIVATE "SHELL:-include stdint.h" "SHELL:-include stddef.h") target_compile_options(srtp PRIVATE @@ -231,7 +260,14 @@ target_include_directories(call_service PRIVATE target_compile_definitions(call_service PRIVATE WEBRTC_POSIX WEBRTC_LINUX - HAVE_SSE2 NDEBUG OPENSSL_SUPPRESS_DEPRECATED ) + +# Arch-specific WebRTC defines, mirroring what BuildWebRTC.cmake sets on the +# webrtc target so inline header code compiles the same way in this TU. +if(ANDROID_ABI STREQUAL "x86_64") + target_compile_definitions(call_service PRIVATE HAVE_SSE2) +elseif(ANDROID_ABI STREQUAL "arm64-v8a") + target_compile_definitions(call_service PRIVATE WEBRTC_ARCH_ARM64 WEBRTC_HAS_NEON) +endif() diff --git a/Containerfile b/Containerfile index 5b19d8b..57d0dbb 100644 --- a/Containerfile +++ b/Containerfile @@ -1,8 +1,16 @@ +# Base image. Ubuntu's official image is glibc + multi-arch (amd64/arm64), so a +# single base serves both architectures. Declared before FROM so both stages +# pick it up; override BASE_IMAGE to pin a different tag. +ARG BASE_IMAGE=docker.io/ubuntu:24.04 + # Stage 1: build -FROM docker.io/archlinux:latest AS builder +FROM ${BASE_IMAGE} AS builder -RUN pacman -Syu --noconfirm \ - base-devel git cmake openssl opus libvpx libyuv ffmpeg zlib gperf lame +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential git cmake pkg-config gperf ca-certificates \ + libssl-dev libopus-dev libvpx-dev libavcodec-dev libavformat-dev libavutil-dev \ + zlib1g-dev libmp3lame-dev \ + && rm -rf /var/lib/apt/lists/* WORKDIR /src @@ -11,14 +19,27 @@ COPY vendor/ vendor/ COPY stub/ stub/ COPY CMakeLists.txt ./ +# WebRTC includes ffmpeg as "third_party/ffmpeg/libav*/...". The vendored +# symlink targets /usr/include (Arch's flat layout); Debian/Ubuntu keep those +# headers under a multiarch triplet dir, so repoint it for this build. +RUN ln -sfn "/usr/include/$(uname -m)-linux-gnu" vendor/third_party/ffmpeg + # Build TDLib RUN cmake -B vendor/td/build -S vendor/td -DCMAKE_BUILD_TYPE=Release && \ cmake --build vendor/td/build -j$(nproc) && \ cmake --install vendor/td/build --prefix /src/td-install +# libyuv isn't packaged on Ubuntu, so build the vendored submodule as a static, +# position-independent lib that links into the (PIE) executable. The project +# locates it via find_library(yuv) and uses the vendored headers directly. +RUN cmake -B vendor/libyuv/build -S vendor/libyuv -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON && \ + cmake --build vendor/libyuv/build -j$(nproc) --target yuv && \ + install -Dm644 vendor/libyuv/build/libyuv.a /usr/local/lib/libyuv.a + # Custom tgcalls platform source (compiled into the tgcalls lib, so it must -# exist at configure time). Copied after TDLib so editing it doesn't bust the -# cached TDLib layer, but before configure since CMakeLists.txt references it. +# exist at configure time). Copied after the deps so editing it doesn't bust the +# cached TDLib/libyuv layers, but before configure since CMakeLists.txt uses it. COPY video_platform.cpp . # Configure and build all deps (tgcalls pulls in webrtc, absl, etc.) @@ -30,14 +51,31 @@ COPY main.cpp . RUN cmake --build build -j$(nproc) --target call_service RUN strip -s build/call_service +# Collect the binary's shared-library closure (minus the glibc core the runtime +# base already ships) into a flat dir. The runtime stage drops these into +# /usr/local/lib; copying flat avoids recreating /lib as a real directory, which +# (via Ubuntu's /lib -> /usr/lib usrmerge symlink) a path-preserving copy onto / +# would clobber, breaking the dynamic linker. This keeps the runtime minimal +# without hand-listing version-suffixed Debian packages, identically on amd64/arm64. +RUN mkdir -p /rootfs && \ + ldd build/call_service | awk '/=> \//{print $3}' | sort -u | \ + grep -vE '/(ld-linux.*|libc|libm|libdl|libpthread|librt|libresolv|libgcc_s)\.so' | \ + xargs -I{} cp -L {} /rootfs/ + # Stage 2: runtime -FROM docker.io/archlinux:latest +FROM ${BASE_IMAGE} -RUN pacman -Syu --noconfirm openssl opus libvpx libyuv ffmpeg zlib lame && \ - pacman -Scc --noconfirm +# ca-certificates for TLS to Telegram; the app's shared libs come from /rootfs. +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app +# /usr/local/lib is on the default loader path; ldconfig registers the copied +# libs (and their soname links) without touching system lib dirs. +COPY --from=builder /rootfs/ /usr/local/lib/ +RUN ldconfig COPY --from=builder /src/build/call_service . ENTRYPOINT ["./call_service"] diff --git a/Containerfile.tools b/Containerfile.tools index 21fc078..0a7a711 100644 --- a/Containerfile.tools +++ b/Containerfile.tools @@ -1,5 +1,8 @@ -FROM docker.io/archlinux:latest -RUN pacman -Syu --noconfirm make ffmpeg curl && pacman -Scc --noconfirm +ARG BASE_IMAGE=docker.io/ubuntu:24.04 +FROM ${BASE_IMAGE} +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + make ffmpeg curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* COPY Makefile /Makefile WORKDIR /out ENTRYPOINT ["make", "-f", "/Makefile"] diff --git a/Makefile b/Makefile index 5ae2a2f..57a7a9e 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,14 @@ IMAGE ?= docker.io/nikicat/tg-echo-service # (e.g. --layers --cache-from/--cache-to ); empty for local builds. IMAGE_BUILD_ARGS ?= +# Container base image. Ubuntu's official image is glibc + multi-arch, so the +# same base builds on both amd64 and arm64. +BASE_IMAGE ?= docker.io/ubuntu:24.04 + +# Image tag. CI uses per-arch tags (amd64/arm64) and combines them into a +# multi-arch :latest manifest; local builds just use :latest. +TAG ?= latest + .PHONY: all submodules tdlib configure build clean run run-only prompt glados-prompt image image-tools push push-tools all: build @@ -73,16 +81,16 @@ run-only: ./build/call_service image-tools: - podman build -t $(IMAGE)-tools -f Containerfile.tools . + podman build --build-arg BASE_IMAGE=$(BASE_IMAGE) -t $(IMAGE)-tools:$(TAG) -f Containerfile.tools . image: image-tools submodules - podman build $(IMAGE_BUILD_ARGS) -t $(IMAGE) . + podman build --build-arg BASE_IMAGE=$(BASE_IMAGE) $(IMAGE_BUILD_ARGS) -t $(IMAGE):$(TAG) . push-tools: image-tools - podman push $(IMAGE)-tools + podman push $(IMAGE)-tools:$(TAG) push: image push-tools - podman push $(IMAGE) + podman push $(IMAGE):$(TAG) clean: rm -rf build vendor/td/build diff --git a/stub/crc32c/crc32c_config.h b/stub/crc32c/crc32c_config.h index 259e11d..b7316c3 100644 --- a/stub/crc32c/crc32c_config.h +++ b/stub/crc32c/crc32c_config.h @@ -17,6 +17,9 @@ #endif #define HAVE_STRONG_GETAUXVAL 1 -#define HAVE_WEAK_GETAUXVAL +// Must have a value: crc32c_arm64_check.h tests it arithmetically as +// `#if (HAVE_STRONG_GETAUXVAL || HAVE_WEAK_GETAUXVAL)`, so an empty define +// expands to a dangling `||`. glibc provides the strong getauxval anyway. +#define HAVE_WEAK_GETAUXVAL 0 #endif // CRC32C_CRC32C_CONFIG_H_