Skip to content

Commit ce7a20c

Browse files
authored
QVAC-23466 infra: cut Device Farm cost by serving mobile e2e models from an S3 bucket in the Device Farm region (us-west-2) (#3852)
* QVAC-23466 pilot: point OCR+TTS mobile tests at us-west-2 US model bucket (Android-only) * QVAC-23466 fix: skip tag propagation in model sync (role lacks s3:GetObjectTagging) * QVAC-23466 feat: auto-refresh OIDC creds during model sync via credential_process (survive 2h STS cap) * QVAC-23466 fix: unset (not blank) AWS_PROFILE in credential_process helper * infra: seed+presign LLM/diffusion mobile models from US bucket (QVAC-23466 pilot) Pilot: source llm-llamacpp and diffusion-cpp mobile models from the Device-Farm-local US bucket (us-west-2) instead of huggingface.co. - New seed-and-presign composite action: mirrors an addon's pinned models into tether-ai-dev-us if missing (idempotent, sha256-verified) and presigns them, using an auto-refreshing OIDC credential_process so a multi-GB first seed survives the 2h STS cap. Runs before the app build. - LLM: generate-prestage-block.js honors PRESTAGE_URL_MAP to stage from the US presigned URLs (host-side prestage) instead of HF; HF path unchanged when the env is absent. - Diffusion: rewrite models.manifest.json to prepend US presigned URLs before build (models are fetched on-device from the bundled manifest), keeping HF/github as fallbacks. * fix: stream sha256 for >2GB models in mobile model seed (QVAC-23466) readFileSync throws ERR_FS_FILE_TOO_LARGE past the 2 GiB Buffer cap, so hash model files in 8 MiB chunks instead. Diffusion/LLM mobile models reach ~25 GB. * fix: scope mobile model seed to the on-device subset (QVAC-23466) Only seed/presign the models a phone actually stages, not the whole integration manifest: - LLM: the ~28-model / ~26GB mobile set (union of mobile manifest + benchmark matrix), computed from the addon's own resolver, skipping the 19-25GB desktop-only builds. - Diffusion: nearly every image/video test is `skip = isMobile || noGpu`, so on-device only SD v2.1 (+ tiny ESRGAN asset) download; seed just those (~2GB) and let the rewritten manifest keep HF fallbacks for the rest. Adds an optional MODEL_NAMES allowlist to the seed script / composite. * QVAC-23466 pilot: point ASR mobile tests at us-west-2 US model bucket ASR is S3-native and already host-side prestages every model (parakeet GGUFs via adb push/pymobiledevice3, whisper via public HF on the host), so nothing downloads in-test. Repoint the OIDC region + MODEL_S3_BUCKET to the us-west-2 mirror (parakeet/indic live under qvac_models_compiled/ ggml/, already mirrored by the EU->US sync job). Runs both Android + iOS. * QVAC-23466 fix: dedupe embedded URLs in LLM prestage block (Linux 128KB env cap) The prestage block is passed to the Device Farm upload step as the EXTRA_PRE_TEST env var. It base64-embedded a manifest keyed testName -> [{name,url}], so each model URL was repeated across ~70+ test entries. With the pilot's presigned US-bucket URLs (~900-1200 chars each) that blob hit ~259KB, exceeding Linux MAX_ARG_STRLEN (128KB per env string) on the self-hosted Android runner -> execve E2BIG ("Argument list too long"). iOS (macOS) has no such per-string cap, so only Android broke. Embed a normalised { urls: {name:url}, tests: {testName:[name]} } instead so every URL appears once; the host-side parser rebuilds the same deduped prestage list. Block drops to ~57KB. Shrinks the HF path too; no behaviour change for existing callers. * QVAC-23466 pilot: point vla/translation/embed mobile tests at us-west-2 US bucket Roll the US-mirror pilot out to the remaining mobile addons that download models during the Device Farm run: - vla-ggml (S3-native): presign SmolVLA + GR00T against tether-ai-dev-us in us-west-2 instead of the cross-region EU bucket. - translation-nmtcpp (S3-native): presign IndicTrans against the US bucket; Bergamot stays on its public Firefox CDN. - embed-llamacpp (HF-bound): add the seed-and-presign step (mirrors the 2 mobile models into the US bucket, ~0.9GB) + PRESTAGE_URL_MAP so the host stages from us-west-2 instead of huggingface.co, matching the llm pattern. All of these models live under qvac_models_compiled/, already mirrored by the EU->US sync job. Out of scope (documented in the PR): audiogen (on-device registry download, not workflow-wirable), bci-whispercpp / classification-ggml (models bundled into the app at build time -> runner cost, not device-minutes), decoder-audio / inference-addon-cpp / model-fit (no on-device model download). * QVAC-23466 pilot: restore OCR + TTS iOS matrix coverage The initial OCR/TTS pilot commit trimmed their default functional matrices to Android-only to save device-minutes while measuring. That was a testing convenience, not part of the US-bucket migration -- restore the iOS entries so this PR changes only the model source (EU/HF -> us-west-2 US bucket) and leaves test coverage untouched. Any matrix reduction is a separate, explicit decision. * QVAC-23466: validate seed model names, live-default sync, trim comments - security: reject path-traversal / absolute model names in seed-and-presign.mjs before they build an S3 key or local path (the names come from a PR-controlled manifest and the seed runs on shared self-hosted runners). - sync-models-to-s3: default a manual dispatch to a live add-only sync (dry run is now an explicit opt-in); the sync only copies missing objects and never deletes, so a live default is safe. - strip verbose/narrative comments across the seed action, the pilot workflows, and the prestage generators, keeping only non-obvious rationale. * QVAC-23466 fix: restore ASR OIDC trust-policy phrase ci-trust-policy.test.mjs asserts integration-mobile-test-asr-ggml.yml contains "release environment authorizes GitHub OIDC". The earlier comment trim dropped that phrase; restore it (keeping the concise US-bucket note) so policy-tests pass. * QVAC-23466 test: address seed/presign review comments - seed-and-presign: wrap per-model download/verify/upload in try/finally so partial multi-GB files are always removed, even when a model fails (no disk leak on shared self-hosted runners); move workDir cleanup into a finally too. - llm prestage generator: extract normalizeManifest + expandPrestageList as the single source of truth and embed the parser into the host prelude via .toString() so CI and Device Farm can never drift; add round-trip unit tests. - Add unit tests for validateModelName (path-traversal rejection), rewriteManifest, and the PRESTAGE_URL_MAP override path (llm + embed); wire the new seed-and-presign action tests into on-pr-shared-ci-infra. * QVAC-23466 fix: address seed/presign review (mask, fetch allowlist, single-writer) Ian's review on the mobile US-bucket seeding: - Mask presigned URLs on the runner (seed action) and scrub AWS SigV4 credentials (X-Amz-Signature/Credential/Security-Token) from the world- readable console-logs artifacts before upload. - Treat the seeded manifest URLs as untrusted input: restrict the fetch to https on huggingface.co/github.com, add proto-redir + a `--` terminator, and fail closed when a model omits its sha256/bytes pins. - Presign after all seeding completes (not inside the loop) so no URL ages while other multi-GB models download; carry the pinned upstream URL as an on-device fallback for llm/embed; let diffusion's on-device retry rotate to the fallback on a 403/404. - Split seeding from presigning: one non-matrixed seed-models job mirrors the objects (single writer, no concurrent-write race across the platform legs) and each leg presigns for itself (fresh URLs, no cross-job secret). * QVAC-23466 fix: setup node in standalone seed-models jobs The new non-matrixed seed-models job runs the seed action (node) directly, but node is job-managed on the self-hosted runners (only on PATH after setup-node). embed/diffusion lacked the step so seeding failed with "node: command not found"; add Setup Node before the seed action (llm already had it for its compute step).
1 parent 8475a37 commit ce7a20c

21 files changed

Lines changed: 1238 additions & 113 deletions

.github/actions/run-mobile-integration-tests/collect-and-upload-logs/action.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,41 @@ runs:
428428
echo "found=false" >> "$GITHUB_OUTPUT"
429429
fi
430430
431+
# The prestage/download commands can echo presigned S3 URLs into the Device
432+
# Farm test-spec / console output collected above. This repo is public and
433+
# these artifacts are world-readable for 30 days, so redact the AWS SigV4
434+
# credential fields before upload. Runner ::add-mask:: does not cover uploaded
435+
# file contents, so this file-level scrub is required in addition to it.
436+
- name: Scrub presigned credentials from console logs
437+
if: always() && inputs.run-arns != '[]' && inputs.run-arns != ''
438+
shell: bash
439+
run: |
440+
set -euo pipefail
441+
[ -d console-logs ] || exit 0
442+
node -e '
443+
const fs = require("fs")
444+
const path = require("path")
445+
const root = "console-logs"
446+
// Neutralise the signature (the secret) and the access-key-id / session
447+
// token that AWS SigV4 puts in the query string of a presigned URL. The
448+
// value stops at the next query separator, whitespace or quote.
449+
const re = /X-Amz-(Signature|Credential|Security-Token)=[^&\s"<>]+/gi
450+
let scrubbed = 0
451+
const walk = (dir) => {
452+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
453+
const p = path.join(dir, ent.name)
454+
if (ent.isDirectory()) { walk(p); continue }
455+
let text
456+
try { text = fs.readFileSync(p, "utf8") } catch { continue }
457+
if (!text.includes("X-Amz-")) continue
458+
const out = text.replace(re, (m, f) => "X-Amz-" + f + "=REDACTED")
459+
if (out !== text) { fs.writeFileSync(p, out); scrubbed++ }
460+
}
461+
}
462+
walk(root)
463+
console.log("[scrub] redacted presigned credentials in " + scrubbed + " file(s)")
464+
'
465+
431466
- name: Upload console logs
432467
id: upload_console_log
433468
if: always() && inputs.run-arns != '[]' && inputs.run-arns != ''
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
name: Seed & presign mobile models (US bucket)
2+
description: >-
3+
Mirror a HuggingFace-bound addon's pinned models into the Device-Farm-local US
4+
bucket if missing, then presign them, writing a name -> presigned-URL map for
5+
the caller to feed into its staging path. Uses an auto-refreshing OIDC
6+
credential_process so a multi-GB first seed survives the 2h STS session cap.
7+
8+
inputs:
9+
manifest-path:
10+
description: "Path (relative to workspace) to the addon's pinned models.manifest.json"
11+
required: true
12+
s3-bucket:
13+
description: "Destination US bucket"
14+
required: false
15+
default: tether-ai-dev-us
16+
s3-prefix:
17+
description: "Destination key prefix (must end with '/')"
18+
required: true
19+
aws-region:
20+
description: "US region (same as the Device Farm fleet)"
21+
required: false
22+
default: us-west-2
23+
aws-role-to-assume:
24+
description: "OIDC role ARN to assume for S3 access"
25+
required: true
26+
hf-token:
27+
description: "HuggingFace token for gated model downloads"
28+
required: false
29+
default: ""
30+
output-map:
31+
description: "Path to write the name -> presigned-URL JSON map (required for mode=presign|both)"
32+
required: false
33+
default: ""
34+
model-names:
35+
description: "Optional comma-separated allowlist of model names to seed (the mobile subset). Empty = every model in the manifest."
36+
required: false
37+
default: ""
38+
expires-in:
39+
description: "Presigned-URL lifetime in seconds (effective TTL capped by the STS session)"
40+
required: false
41+
default: "7200"
42+
mode:
43+
description: "both = seed then presign (default); seed = mirror-only (one writer, no map); presign = presign already-seeded objects (per matrix leg)"
44+
required: false
45+
default: "both"
46+
47+
runs:
48+
using: composite
49+
steps:
50+
- name: Bootstrap auto-refreshing AWS credentials
51+
shell: bash
52+
env:
53+
OIDC_ROLE_ARN: ${{ inputs.aws-role-to-assume }}
54+
STS_REGION: ${{ inputs.aws-region }}
55+
run: |
56+
set -uo pipefail
57+
# credential_process helper the AWS CLI re-invokes near expiry, so a long
58+
# first seed survives the 2h STS cap. It mints a fresh OIDC JWT and
59+
# exchanges it via assume-role-with-web-identity (no pre-existing creds).
60+
CRED_HELPER="${RUNNER_TEMP}/oidc-cred-process-seed.sh"
61+
cat > "${CRED_HELPER}" <<'HELPER'
62+
#!/usr/bin/env bash
63+
set -euo pipefail
64+
JWT=$(curl -sS -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
65+
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com" | jq -r '.value')
66+
# UNSET (not blank) so the CLI does not recurse into this helper's profile.
67+
unset AWS_PROFILE AWS_DEFAULT_PROFILE
68+
export AWS_CONFIG_FILE=/dev/null
69+
export AWS_SHARED_CREDENTIALS_FILE=/dev/null
70+
CREDS=$(aws sts assume-role-with-web-identity \
71+
--role-arn "${OIDC_ROLE_ARN}" \
72+
--role-session-name seed-presign-mobile-models \
73+
--web-identity-token "${JWT}" \
74+
--duration-seconds 7200 \
75+
--region "${STS_REGION}" \
76+
--output json)
77+
echo "${CREDS}" | jq '{Version:1, AccessKeyId:.Credentials.AccessKeyId, SecretAccessKey:.Credentials.SecretAccessKey, SessionToken:.Credentials.SessionToken, Expiration:.Credentials.Expiration}'
78+
HELPER
79+
chmod +x "${CRED_HELPER}"
80+
81+
AWS_CONFIG_FILE="${RUNNER_TEMP}/aws-config-seed"
82+
cat > "${AWS_CONFIG_FILE}" <<CONFIG
83+
[profile seed]
84+
credential_process = ${CRED_HELPER}
85+
CONFIG
86+
87+
{
88+
echo "AWS_CONFIG_FILE=${AWS_CONFIG_FILE}"
89+
echo "AWS_PROFILE=seed"
90+
echo "OIDC_ROLE_ARN=${OIDC_ROLE_ARN}"
91+
echo "STS_REGION=${STS_REGION}"
92+
} >> "$GITHUB_ENV"
93+
94+
- name: Seed missing models + presign
95+
shell: bash
96+
env:
97+
MANIFEST_PATH: ${{ inputs.manifest-path }}
98+
S3_BUCKET: ${{ inputs.s3-bucket }}
99+
S3_PREFIX: ${{ inputs.s3-prefix }}
100+
AWS_REGION: ${{ inputs.aws-region }}
101+
OUTPUT_MAP: ${{ inputs.output-map }}
102+
MODEL_NAMES: ${{ inputs.model-names }}
103+
EXPIRES_IN: ${{ inputs.expires-in }}
104+
HF_TOKEN: ${{ inputs.hf-token }}
105+
MODE: ${{ inputs.mode }}
106+
run: |
107+
set -euo pipefail
108+
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
109+
node "$GITHUB_ACTION_PATH/seed-and-presign.mjs"
110+
111+
# The presigned URLs carry bearer credentials and this repo is public, so
112+
# register each with the runner so it is redacted wherever the workflow would
113+
# otherwise echo it (e.g. the prestage block / manifest, step inputs). Masking
114+
# only covers this job's log stream; the Device Farm artifact half is scrubbed
115+
# separately in collect-and-upload-logs.
116+
- name: Mask presigned URLs in the runner log
117+
if: inputs.mode != 'seed'
118+
shell: bash
119+
env:
120+
OUTPUT_MAP: ${{ inputs.output-map }}
121+
run: |
122+
set -euo pipefail
123+
node -e '
124+
const fs = require("fs")
125+
const map = JSON.parse(fs.readFileSync(process.env.OUTPUT_MAP, "utf8"))
126+
for (const url of Object.values(map)) {
127+
if (typeof url === "string" && url) console.log("::add-mask::" + url)
128+
}
129+
'
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use strict'
2+
// Prepend the US-bucket presigned URL to each model's urls[] in diffusion's
3+
// on-device manifest before the app is built, keeping the original source(s) as
4+
// fallbacks. sha256/bytes pins are untouched, so integrity checks are unchanged.
5+
import { readFileSync, writeFileSync } from 'node:fs'
6+
import { pathToFileURL } from 'node:url'
7+
8+
function env(name) {
9+
const v = process.env[name]
10+
if (!v) throw new Error(`[rewrite] required env ${name} is not set`)
11+
return v
12+
}
13+
14+
// Prepend each presigned URL to the matching model's urls[], keeping the
15+
// original source(s) as ordered fallbacks and leaving sha256/bytes pins intact.
16+
// Returns the number of models repointed. Mutates `manifest` in place.
17+
function rewriteManifest(manifest, map) {
18+
if (!manifest || !manifest.models) {
19+
throw new Error('[rewrite] manifest has no models object')
20+
}
21+
22+
let rewritten = 0
23+
for (const [name, presignedUrl] of Object.entries(map)) {
24+
const entry = manifest.models[name]
25+
if (!entry) {
26+
console.log(`[rewrite] ${name}: not in manifest, skipping`)
27+
continue
28+
}
29+
const existing = Array.isArray(entry.urls) ? entry.urls : []
30+
const fallbacks = existing.filter((u) => u !== presignedUrl)
31+
entry.urls = [presignedUrl, ...fallbacks]
32+
rewritten++
33+
}
34+
return rewritten
35+
}
36+
37+
function main() {
38+
const manifestPath = env('MANIFEST_PATH')
39+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
40+
const map = JSON.parse(readFileSync(env('URL_MAP'), 'utf8'))
41+
const rewritten = rewriteManifest(manifest, map)
42+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
43+
console.log(`[rewrite] ${rewritten} model URL(s) repointed to US bucket in ${manifestPath}`)
44+
}
45+
46+
if (import.meta.url === pathToFileURL(process.argv[1]).href) main()
47+
48+
export { rewriteManifest }

0 commit comments

Comments
 (0)