diff --git a/.cspell.yml b/.cspell.yml index f9895b30..33aa308f 100644 --- a/.cspell.yml +++ b/.cspell.yml @@ -121,6 +121,12 @@ words: - Kubermatic - kubevirt - Kueue + - hami + - Qwen + - Shenzhen + - TPOT + - TTFT + - verl - kyverno - MGMT - MPLS diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efda6c17..c482134e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,14 +132,16 @@ Rules: record and member data use. - The page's front matter is the only place an architecture's metadata is written. Alongside `title`, an `architecture:` block carries `organization`, - `summary`, `industries`, `tags`, `projects`, `sourceUrl`, exactly one of + `summary`, `industries`, `tags`, `projects`, `sourceUrl`, optionally one of `sourceCommit` (a cncf/architecture import) or `sourceIssue` (a submission - issue), `published` and `updated`. The record's `id` is the directory name and - its `assets` are the files in `images/`; neither is written by hand. After - editing a page or adding one, run `npm run generate:architectures` and commit - the regenerated `data/architectures/catalog.json` with it; the catalog - component, member directory and data-link checks read that mirror, and - `validate:architectures` fails when it is behind the pages + issue), `published` and `updated`. A page authored directly from another + source sets neither and cites its `sourceUrl` in the attribution line. The + record's `id` is the directory name and its `assets` are the files in + `images/`; neither is written by hand. After editing a page or adding one, run + `npm run generate:architectures` and commit the regenerated + `data/architectures/catalog.json` with it; the catalog component, member + directory and data-link checks read that mirror, and `validate:architectures` + fails when it is behind the pages ([ADR 0010](adr/0010-architecture-pages-are-the-metadata-source.md)). - Architecture `tags` are written with spaces, not underscores (`private cloud`, not `private_cloud`): they appear as search chips on the catalog. The importer diff --git a/adr/0010-architecture-pages-are-the-metadata-source.md b/adr/0010-architecture-pages-are-the-metadata-source.md index 8313abc8..1511949c 100644 --- a/adr/0010-architecture-pages-are-the-metadata-source.md +++ b/adr/0010-architecture-pages-are-the-metadata-source.md @@ -70,8 +70,9 @@ pages), and a nested block keeps the catalog's vocabulary out of the theme's. - The front matter of `docs/architectures//index.md` is the only place an architecture's metadata is written. Alongside `title`, an `architecture:` block carries `organization`, `summary`, `industries`, `tags`, `projects`, - `sourceUrl`, exactly one of `sourceCommit` or `sourceIssue`, `published` and - `updated`. + `sourceUrl`, at most one of `sourceCommit` or `sourceIssue` (amended + 2026-10-28: a page authored directly from another source sets neither), + `published` and `updated`. - `id` is the directory name and `assets` is the sorted listing of regular files in `images/`. Neither is authored; both are derived when the catalog is generated. diff --git a/data/architectures/catalog.json b/data/architectures/catalog.json index 1744dee6..9b22dc7e 100644 --- a/data/architectures/catalog.json +++ b/data/architectures/catalog.json @@ -103,6 +103,29 @@ "/img/architectures/cern-scientific-computing/use-cases.png" ] }, + { + "id": "china-merchants-bank", + "title": "Unified Training and Inference on Kubernetes for Heterogeneous AI Compute at China Merchants Bank", + "organization": "China Merchants Bank", + "summary": "China Merchants Bank runs large-model training, LoRA fine-tuning, and online inference on one Kubernetes-based pool of nearly 10,000 heterogeneous accelerator cards, with separate operating policies for each workload type.", + "industries": ["Financial Services"], + "tags": [ + "ai", + "machine learning", + "llm training", + "llm inference", + "gpu scheduling", + "heterogeneous accelerators", + "multi-tenancy", + "autoscaling", + "platform engineering" + ], + "projects": ["Kubernetes", "KEDA", "Prometheus", "HAMi", "Fluid"], + "sourceUrl": "https://github.com/cncf/tab/issues/140", + "published": "2026-10-28", + "updated": "2026-10-28", + "assets": ["/img/architectures/china-merchants-bank/architecture.png"] + }, { "id": "colopl", "title": "Colopl's FESTIBATTLE Infrastructure Architecture: Dedicated Game Servers with Agones on Kubernetes", diff --git a/data/members.json b/data/members.json index 088fe7ab..d5b19345 100644 --- a/data/members.json +++ b/data/members.json @@ -681,6 +681,25 @@ ], "membershipStatus": "member" }, + { + "id": "china-merchants-bank", + "name": "China Merchants Bank", + "slug": "china-merchants-bank", + "logo": null, + "industries": ["Financial Services"], + "projects": ["Fluid", "HAMi", "KEDA", "Kubernetes", "Prometheus"], + "architectures": [ + { + "id": "china-merchants-bank", + "title": "Unified Training and Inference on Kubernetes for Heterogeneous AI Compute at China Merchants Bank", + "sourceUrl": "https://github.com/cncf/tab/issues/140" + } + ], + "awards": [], + "sourceAttribution": ["https://github.com/cncf/tab/issues/140"], + "membershipSources": [], + "membershipStatus": "unknown" + }, { "id": "citi", "name": "Citi", diff --git a/docs/architectures/china-merchants-bank/images/architecture.png b/docs/architectures/china-merchants-bank/images/architecture.png new file mode 100644 index 00000000..58563072 Binary files /dev/null and b/docs/architectures/china-merchants-bank/images/architecture.png differ diff --git a/docs/architectures/china-merchants-bank/index.md b/docs/architectures/china-merchants-bank/index.md new file mode 100644 index 00000000..fc70423b --- /dev/null +++ b/docs/architectures/china-merchants-bank/index.md @@ -0,0 +1,198 @@ +--- +title: "Unified Training and Inference on Kubernetes for Heterogeneous AI Compute at China Merchants Bank" +sidebar_label: "China Merchants Bank" +architecture: + organization: China Merchants Bank + summary: China Merchants Bank runs large-model training, LoRA fine-tuning, and online inference on one Kubernetes-based pool of nearly 10,000 heterogeneous accelerator cards, with separate operating policies for each workload type. + industries: + - Financial Services + tags: + - ai + - machine learning + - llm training + - llm inference + - gpu scheduling + - heterogeneous accelerators + - multi-tenancy + - autoscaling + - platform engineering + projects: + - Kubernetes + - KEDA + - Prometheus + - HAMi + - Fluid + sourceUrl: https://github.com/cncf/tab/issues/140 + published: 2026-10-28 + updated: 2026-10-28 +--- + +import CNCFProjectCard from '@site/src/components/CNCFProjectCard'; + +> Submitted by China Merchants Bank to the CNCF End User Technical Advisory Board via [cncf/tab#140](https://github.com/cncf/tab/issues/140). Documentation is distributed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). + +## Relevant CNCF projects + + + + + + + + + + + +## Organization + +China Merchants Bank (CMB) is a nationwide joint-stock commercial bank headquartered in Shenzhen, China. The bank operates an enterprise AI platform with nearly 10,000 heterogeneous accelerator cards sourced from three mainstream domestic vendors. The platform supports Qwen models ranging from 4 billion to 1.6 trillion parameters, along with DeepSeek, GLM, and other model families, and serves large-model training, fine-tuning, online inference, and other AI use cases for the bank. + +## Teams + +This architecture is owned by the Architecture Management Team in China Merchants Bank's Information Technology Department. The team integrates open source infrastructure and AI runtimes with CMB's internal platforms through Kubernetes-native interfaces. + +## Architecture overview and goals + +### Context and problem space + +China Merchants Bank operates an enterprise AI platform with nearly 10,000 heterogeneous accelerator cards sourced from three mainstream domestic vendors. The platform supports Qwen models ranging from 4 billion to 1.6 trillion parameters, along with DeepSeek, GLM, and other model families. Its workloads include large-model training, fine-tuning, online inference, and other AI use cases. These workloads share the same high-value compute pool but have fundamentally different operating requirements. + +Distributed training jobs are long-running and throughput-oriented. They require stable capacity and should begin only when all required workers and accelerator resources are available. Online inference is request-driven and latency-sensitive, so it must scale with changing traffic while avoiding the cost of keeping excess capacity continuously active. Multi-tenant LoRA fine-tuning adds another challenge: tenant state must remain isolated without loading a separate copy of the same large base model for every tenant. + +The bank's goal was not simply to add more accelerator cards. It was to use existing capacity more efficiently while preserving workload isolation, operational control, and the ability for training and inference technologies to evolve independently. + +### Goals + +- Provide one Kubernetes-based control plane while preserving separate execution policies for training and inference. +- Manage training admission and quotas before compute and data resources are provisioned. +- Scale inference services from live demand signals rather than static peak-capacity reservations. +- Share heterogeneous accelerator capacity at fine granularity without allowing smaller workloads to monopolize a full card. +- Reuse one base-model runtime across multiple LoRA tenants while keeping adapters, configurations, and tenant state separate. +- Provide shared access to datasets, model weights, checkpoints, the model registry, and operational metrics. +- Allow training frameworks, inference engines, and models to evolve without tightly coupling the entire platform to one runtime or vendor stack. + +### Architecture + +The architecture uses one shared Kubernetes foundation with two decoupled workload paths. + +**Training path:** Training requests enter through the Training API, a CMB-built pipeline, UI, and scheduler entry point. Kueue applies queue-, quota-, and admission-based control before the workload starts. After admission, Kubernetes Scheduler selects suitable nodes, HAMi assigns accelerator capacity, and Fluid provides access to datasets and checkpoints. Twinkle runs multi-tenant training on Ray and passes the resulting checkpoints into the downstream model-management process. Built on verl, verl-SpeCo supports draft-model training and integrates speculative decoding with vLLM and SGLang. + +**Inference path:** Online requests enter through the Inference Gateway. In production, RBG orchestrates role-based and multi-node LLM inference workloads served by vLLM or SGLang, with Ray providing the distributed runtime. Prometheus collects service and demand metrics, and KEDA uses those signals to add or remove replicas. Kubernetes Scheduler and HAMi place new Pods and allocate accelerator resources, while Fluid supplies cached model weights so newly created replicas can become ready more quickly. + +Both paths share the Kubernetes control plane, accelerator node pool, data and model access layer, model registry, and observability foundation. They remain separate at the workload-policy and runtime levels so each can use the behavior best suited to its demand pattern. + +![Kubernetes-native training and inference architecture. A workload layer holds a training deployment (Twinkle multi-tenant LoRA training, verl-SpeCo draft-model training, and a Ray runtime) fed by the Training API, and an inference deployment (RBG role-based orchestration, vLLM or SGLang serving engine, and a Ray runtime) fed by the Inference Gateway. A platform control layer holds KEDA event-driven autoscaling, Kueue admission and quota, Kubernetes Scheduler Pod placement, HAMi vGPU, memory, and core quota, and the Fluid control plane for datasets, runtimes, and PVCs. A data and resource layer holds the heterogeneous accelerator pool of nearly 10,000 cards from three vendors with the HAMi device plugin, the Fluid cache runtime for datasets, models, and checkpoints, object storage, and the model registry. Arrows show business requests, data and model paths, control and scaling signals, and GPU allocation, with a model lifecycle of train, checkpoint, evaluate and register, deploy, online metrics, and retrain trigger.](./images/architecture.png) + +### Shared model lifecycle + +Training produces model or adapter checkpoints for evaluation and registration. The model registry tracks versions, adapters, and tokenizers; inference services load the selected version through Fluid. Prometheus metrics inform capacity adjustments and subsequent model iterations. This lifecycle connects the two workload paths through shared model assets and operational feedback, as described in the [CNCF case study](https://www.cncf.io/case-studies/china-merchants-bank-2/). + +### Component ownership and responsibility boundaries + +The architecture combines four types of components. CMB's proprietary platforms integrate the open source infrastructure and AI runtimes through Kubernetes-native interfaces and provide the bank's internal platform capabilities. + +| Category | Components and platforms | +| --- | --- | +| CNCF-hosted and Kubernetes ecosystem open source projects | Kubernetes, Kueue (Kubernetes SIG), KEDA, Prometheus, HAMi, and Fluid | +| Third-party open source projects | Ray, RBG, vLLM, SGLang, and verl | +| Open source projects jointly developed by CMB and the community | Twinkle and verl-SpeCo | +| CMB proprietary platforms | CMB-built MaaS, training, heterogeneous accelerator management, model asset, and data asset platforms | + +The proprietary platforms do not replace the responsibilities of the open source components. Kueue controls workload admission, Kubernetes Scheduler places Pods, HAMi allocates accelerator capacity, Fluid provides data and model access, and RBG orchestrates LLM inference workloads. + +### Key design decisions and trade-offs + +- **Share infrastructure without forcing one operating policy.** Training uses queue- and quota-based admission through Kueue, while inference uses metric-driven elasticity through Prometheus and KEDA. +- **Separate admission from placement and allocation.** Kueue decides when a training workload may enter the cluster; Kubernetes Scheduler and HAMi determine where Pods run and how accelerator capacity is allocated. +- **Treat compute sharing and model sharing as different concerns.** HAMi allocates physical accelerator capacity, while Twinkle enables multiple LoRA tenants to reuse one base-model instance. Neither mechanism replaces the other. +- **Treat model readiness as part of inference elasticity.** Creating a replica is not sufficient if it must still wait for model weights. Fluid's compute-side cache reduces this model-loading delay. +- **Use unit cost to assess efficiency at growing scale.** Cost per one million input and output tokens is more meaningful than total spend when business volume is changing. +- **Prefer a composable Kubernetes-native architecture over a vertically integrated platform.** CMB also evaluated vertically integrated platforms, which could reduce initial integration effort within a single supported stack. CMB selected the Kubernetes-native approach because its modular design supports independent evolution of admission control, scheduling, accelerator allocation, data access, and workload runtimes across a heterogeneous hardware environment. This flexibility enabled CMB to introduce Twinkle's shared base-model runtime independently, contributing to the reported fivefold increase in tenant density. The trade-off is the need for ongoing integration and compatibility testing. + +### Operating context for the reported results + +The reported results come from a shared production platform managing nearly 10,000 heterogeneous accelerator cards sourced from three mainstream domestic vendors. The platform supports Qwen models ranging from 4 billion to 1.6 trillion parameters, along with DeepSeek, GLM, and other model families. For online inference, the primary service-level objectives are a time to first token (TTFT) of no more than 5 seconds and a time per output token (TPOT) of no more than 50 milliseconds. In a Qwen3-14B startup test with model weights cached by Fluid, the measured model-loading time was under 2 seconds. + +## Can you expand on why you are using those projects/services? + +The value of the architecture comes from the interaction of admission, scheduling, accelerator allocation, data access, observability, elasticity, and model-runtime reuse rather than from any single component. + +### CNCF projects + +- **Kubernetes:** Provides the shared control plane, Pod orchestration, Deployments, scheduling foundation, and common resource interfaces for both workload paths. +- **KEDA:** Converts service and demand signals into scaling decisions for online inference workloads. +- **Prometheus:** Collects QPS, request-queue depth, inference latency, and other operational metrics used for observability and demand-driven scaling. +- **HAMi:** Pools heterogeneous accelerator resources and allocates accelerator capacity at fine granularity for workloads that can safely share it. +- **Fluid:** Provides Kubernetes-native access to datasets, model weights, and checkpoints, and caches model data near compute to reduce repeated remote reads and model-loading delays. + +### Kubernetes ecosystem projects + +- **Kueue (Kubernetes SIG subproject):** Manages training queues, quotas, and workload admission so distributed jobs do not partially start and hold capacity while waiting for the remaining resources. + +### Third-party open source projects and technologies + +- **Ray:** Provides the distributed runtime used by both the training and inference paths. +- **RBG:** Provides the Kubernetes workload abstraction used in production for role-based and multi-node LLM inference services. +- **vLLM / SGLang:** Serve online inference workloads behind the Inference Gateway. +- **verl:** Provides the upstream reinforcement-learning training framework used by verl-SpeCo. + +### Open source projects jointly developed by CMB and the community + +- **Twinkle:** Runs multi-tenant LoRA training on Ray, enabling tenants to share one base-model instance while retaining separate adapters, configurations, and state. +- **verl-SpeCo:** Extends verl with draft-model training and speculative-decoding integration for vLLM and SGLang. + +## What has worked well? + +### Production outcomes + +| Metric | Result | +| --- | --- | +| Average accelerator compute utilization | Increased from 35% to more than 60% | +| Inference unit cost | Decreased by more than 60% per one million tokens, counting input and output tokens together | +| Duplicated base-model runtime footprint for the default five-tenant configuration | Decreased by 80% by reducing base-model replicas from five to one | +| Multi-tenant training density | Increased fivefold; eight concurrent tenants have been validated and five are used by default in production | +| Fluid-cached model loading | Under 2 seconds in a Qwen3-14B startup test | + +These results use the bank's internally consistent before-and-after methodology and comparable model and service conditions. + +### Reusable lessons + +- A shared compute pool does not require training and inference to use the same operating policy. +- Inference elasticity depends on model readiness, not only on replica scheduling. +- Physical accelerator sharing and base-model runtime reuse solve different sources of waste. +- Kubernetes-native interfaces allow the control plane, resource layer, data path, monitoring system, and workload runtimes to evolve with limited coupling. +- Accelerator utilization, inference cost per token, and training density measure different problems and should be defined and evaluated separately. + +## What has not worked well? + +A current limitation is that support maturity varies across combinations of accelerator generations, model architectures, and training or inference runtimes. CMB mitigates this by qualifying each new combination before production rollout and continues to expand compatibility coverage and operational automation while preserving the Kubernetes-native interfaces described above. + +The composable, Kubernetes-native approach also carries a standing cost: because admission control, scheduling, accelerator allocation, data access, and workload runtimes evolve independently, CMB must perform ongoing integration and compatibility testing that a vertically integrated platform would absorb within a single supported stack. + +## What sort of "glue" have you had to develop to enable usage of your architecture? + +CMB-built platforms integrate the open source components through Kubernetes-native interfaces and provide the bank's internal platform capabilities: + +- MaaS and inference service capabilities, including the Inference Gateway that fronts production inference services. +- Training platform capabilities, including the Training API (pipeline, UI, and scheduler entry point) that submits workloads for Kueue admission. +- Heterogeneous accelerator management capabilities. +- Model asset management capabilities. +- Data asset management capabilities. + +These platforms do not replace the responsibilities of the open source components: Kueue controls workload admission, Kubernetes Scheduler places Pods, HAMi allocates accelerator capacity, Fluid provides data and model access, and RBG orchestrates LLM inference workloads. + +CMB also co-developed two open source projects with the community to close gaps in the training path: + +- **Twinkle** runs multi-tenant LoRA training on Ray so that multiple tenants share one base-model instance while keeping adapters, configurations, and state separate. +- **verl-SpeCo** extends verl with draft-model training and speculative-decoding integration for vLLM and SGLang. + +## What's next for your architecture? What are you looking to do next? + +CMB continues to expand compatibility coverage across accelerator generations, model architectures, and training and inference runtimes, and to increase operational automation, while preserving the Kubernetes-native interfaces that keep the control plane, resource layer, data path, monitoring system, and workload runtimes loosely coupled. + +The next steps are: + +- **Dynamic tenant concurrency:** Plan to tune Twinkle concurrency using model size, LoRA settings, memory pressure, and service objectives, building on the eight-tenant validation. +- **Unit-cost-based capacity management:** Use utilization, Kueue queue conditions, Prometheus latency, and token cost to inform training and inference capacity allocation. +- **Serverless inference with KEDA:** Extend autoscaling to allow zero replicas when service objectives permit, restoring capacity from live demand signals and using Fluid caching to shorten model-loading delays. +- **Broader hardware and runtime support:** Extend heterogeneous accelerator support through HAMi and add training and serving backends while preserving independent workload paths. diff --git a/scripts/generate-members.mjs b/scripts/generate-members.mjs index 16d3a466..5a2c5109 100644 --- a/scripts/generate-members.mjs +++ b/scripts/generate-members.mjs @@ -75,12 +75,15 @@ function pickLogo(allAssets, awardEntries, slug) { if (namedLogo) return namedLogo; const exactMatch = allAssets.find((a) => basename(a) === slug); if (exactMatch) return exactMatch; - const svgs = allAssets.filter((a) => a.toLowerCase().endsWith('.svg')); + const logoCandidates = allAssets.filter( + (a) => basename(a) !== 'architecture', + ); + const svgs = logoCandidates.filter((a) => a.toLowerCase().endsWith('.svg')); if (svgs.length > 0) { svgs.sort((a, b) => basename(a).length - basename(b).length); return svgs[0]; } - const png = allAssets.find((a) => a.toLowerCase().endsWith('.png')); + const png = logoCandidates.find((a) => a.toLowerCase().endsWith('.png')); if (png) return png; return awardEntries[0]?.logo || null; } @@ -115,11 +118,15 @@ function legacyMembers(catalog, awardsData) { const projects = [ ...new Set(catalogEntries.flatMap((e) => e.projects || [])), ].sort(); + // A catalog record carries at most one provenance field (sourceCommit for + // a cncf/architecture import, sourceIssue for a submission issue) or none + // when the page was authored directly; forward whichever is present. const architectures = catalogEntries.map((e) => ({ id: e.id, title: e.title, sourceUrl: e.sourceUrl, - sourceCommit: e.sourceCommit, + ...(e.sourceCommit !== undefined && { sourceCommit: e.sourceCommit }), + ...(e.sourceIssue !== undefined && { sourceIssue: e.sourceIssue }), })); const awards = awardEntries.map((a) => ({ year: a.year, diff --git a/scripts/validate-architectures.mjs b/scripts/validate-architectures.mjs index bd14db42..67c4c6f7 100644 --- a/scripts/validate-architectures.mjs +++ b/scripts/validate-architectures.mjs @@ -116,11 +116,11 @@ for (const record of records) { severity: 'error', message: `${FRONT_MATTER_KEY}.${key} must be a list of non-empty strings`, }); - if (!('sourceCommit' in record) === !('sourceIssue' in record)) + if ('sourceCommit' in record && 'sourceIssue' in record) errors.push({ path: record.page, severity: 'error', - message: `${FRONT_MATTER_KEY} must set exactly one of sourceCommit (cncf/architecture import) or sourceIssue (submission issue)`, + message: `${FRONT_MATTER_KEY} must set at most one of sourceCommit (cncf/architecture import) or sourceIssue (submission issue)`, }); if ('sourceIssue' in record && !Number.isInteger(record.sourceIssue)) errors.push({ diff --git a/static/img/cncf-projects/fluid-fluid-icon-color.svg b/static/img/cncf-projects/fluid-fluid-icon-color.svg new file mode 100644 index 00000000..577e8a3c --- /dev/null +++ b/static/img/cncf-projects/fluid-fluid-icon-color.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/cncf-projects/hami-hami-icon-color.svg b/static/img/cncf-projects/hami-hami-icon-color.svg new file mode 100644 index 00000000..ea51f003 --- /dev/null +++ b/static/img/cncf-projects/hami-hami-icon-color.svg @@ -0,0 +1,28 @@ + + + + + + diff --git a/tests/architecture-catalog-contract.test.mjs b/tests/architecture-catalog-contract.test.mjs index 781cad32..af686f53 100644 --- a/tests/architecture-catalog-contract.test.mjs +++ b/tests/architecture-catalog-contract.test.mjs @@ -23,25 +23,29 @@ const docsDir = join(repoRoot, 'docs/architectures'); const catalog = JSON.parse(readFileSync(catalogPath, 'utf8')); -// Two provenance shapes coexist in the catalog. Records that originated in +// Three provenance shapes coexist in the catalog. Records that originated in // the CNCF Cloud Native Reference Architecture project are pinned to the // cncf/architecture commit they were taken from (`sourceCommit` + a // commit-pinned `sourceUrl`), which is what their CC BY 4.0 attribution // cites. scripts/import-architecture-issue.mjs turns a submission issue into -// a record whose source IS the issue (`sourceIssue` + the issue URL); there is -// no upstream commit to pin, so the commit-pinning assertions below apply -// only to records without `sourceIssue`. +// a record whose source IS the issue (`sourceIssue` + the issue URL). A page +// authored directly in this repository from some other source carries neither +// and cites only its `sourceUrl`, so each pinning assertion below applies only +// to records that carry its field. function isIssueSourced(record) { return typeof record?.sourceIssue === 'number'; } +function isCommitSourced(record) { + return typeof record?.sourceCommit === 'string'; +} + const REQUIRED_STRING_FIELDS = [ 'id', 'title', 'organization', 'summary', 'sourceUrl', - 'sourceCommit', 'published', 'updated', ]; @@ -80,10 +84,7 @@ test('catalog.json is a non-empty array of uniquely identified records', () => { test('every catalog record carries the fields the site renders', () => { for (const record of catalog) { - const requiredStrings = isIssueSourced(record) - ? REQUIRED_STRING_FIELDS.filter((field) => field !== 'sourceCommit') - : REQUIRED_STRING_FIELDS; - for (const field of requiredStrings) { + for (const field of REQUIRED_STRING_FIELDS) { assert.equal( typeof record[field], 'string', @@ -118,7 +119,7 @@ test('every catalog record carries the fields the site renders', () => { test('provenance pins an upstream commit that the source URL points at', () => { for (const record of catalog) { - if (isIssueSourced(record)) continue; + if (!isCommitSourced(record)) continue; assert.match( record.sourceCommit, /^[0-9a-f]{40}$/, @@ -206,7 +207,7 @@ test('doc frontmatter reuses the record title and labels the sidebar', () => { } }); -test('each doc page attributes the revision its record was imported from', () => { +test('each doc page attributes the source its record came from', () => { for (const record of catalog) { const body = readFileSync(docPath(record.id), 'utf8'); if (isIssueSourced(record)) { @@ -214,11 +215,16 @@ test('each doc page attributes the revision its record was imported from', () => body.includes(`issues/${record.sourceIssue}`), `${record.id}: doc page does not cite submission issue #${record.sourceIssue}`, ); - } else { + } else if (isCommitSourced(record)) { assert.ok( body.includes(record.sourceCommit), `${record.id}: doc page does not cite sourceCommit ${record.sourceCommit}`, ); + } else { + assert.ok( + body.includes(record.sourceUrl), + `${record.id}: doc page does not cite sourceUrl ${record.sourceUrl}`, + ); } assert.match( body, diff --git a/tests/e2e/architecture-detail.spec.js b/tests/e2e/architecture-detail.spec.js index d77118bc..4db1b01a 100644 --- a/tests/e2e/architecture-detail.spec.js +++ b/tests/e2e/architecture-detail.spec.js @@ -61,10 +61,15 @@ test.describe('reference architecture detail pages', () => { ).toBeVisible(); // CC BY 4.0 obliges the site to carry attribution for text that - // originated in cncf/architecture, and the pinned revision records - // exactly what was taken. Both live in the page's leading blockquote. + // originated elsewhere, and the pinned revision or submission issue + // records exactly what was taken when the record carries one. Both + // live in the page's leading blockquote. const attribution = page.locator('article blockquote').first(); - await expect(attribution).toContainText(record.sourceCommit); + if (record.sourceCommit) { + await expect(attribution).toContainText(record.sourceCommit); + } else if (record.sourceIssue) { + await expect(attribution).toContainText(`#${record.sourceIssue}`); + } await expect(attribution).toContainText('CC BY 4.0'); // Each declared project is rendered by . A card links diff --git a/tests/generate-members.test.mjs b/tests/generate-members.test.mjs index 74c38104..1452e97d 100644 --- a/tests/generate-members.test.mjs +++ b/tests/generate-members.test.mjs @@ -583,6 +583,31 @@ test('projects only the documented architecture and award fields', () => { assert.equal(member.awards[0].talkUrl, null); }); +test('forwards whichever provenance field a catalog record carries', () => { + const result = run({ + catalog: [ + catalogEntry({ + id: 'acme-issue', + sourceCommit: undefined, + sourceIssue: 42, + }), + catalogEntry({ id: 'acme-direct', sourceCommit: undefined }), + ], + }); + + const { architectures } = memberById(result, 'acme'); + const issue = architectures.find((entry) => entry.id === 'acme-issue'); + const direct = architectures.find((entry) => entry.id === 'acme-direct'); + assert.deepEqual(Object.keys(issue).sort(), [ + 'id', + 'sourceIssue', + 'sourceUrl', + 'title', + ]); + assert.equal(issue.sourceIssue, 42); + assert.deepEqual(Object.keys(direct).sort(), ['id', 'sourceUrl', 'title']); +}); + test('collects source attribution from catalog and award URLs, skipping nulls', () => { const result = run({ catalog: [catalogEntry({ sourceUrl: 'https://example.test/architecture' })], @@ -703,6 +728,18 @@ test('falls back to a PNG when no SVG asset exists', () => { ); }); +test('does not use an architecture diagram as a member logo', () => { + const result = run({ + catalog: [ + catalogEntry({ + assets: ['/img/architectures/acme/architecture.png'], + }), + ], + }); + + assert.equal(memberById(result, 'acme').logo, null); +}); + test('falls back to the award logo when no usable catalog asset exists', () => { const result = run({ catalog: [ diff --git a/tests/members-data.test.mjs b/tests/members-data.test.mjs index ac2ce79c..a8360122 100644 --- a/tests/members-data.test.mjs +++ b/tests/members-data.test.mjs @@ -230,7 +230,7 @@ test('member logos live under /img/ and exist in the repository', () => { test('member architecture entries carry their provenance', () => { for (const member of members) { for (const architecture of member.architectures) { - for (const field of ['id', 'title', 'sourceUrl', 'sourceCommit']) { + for (const field of ['id', 'title', 'sourceUrl']) { assert.ok( architecture[field], `${member.id} architecture is missing ${field}`, @@ -240,11 +240,25 @@ test('member architecture entries carry their provenance', () => { architecture.sourceUrl, `${member.id} architecture sourceUrl`, ); - assert.match( - architecture.sourceCommit, - /^[0-9a-f]{40}$/, - `${member.id} architecture sourceCommit must be a full commit sha`, + // Provenance mirrors the catalog record: at most one of a pinned + // cncf/architecture commit or a submission issue, or neither for a + // page authored directly in this repository. + assert.ok( + !('sourceCommit' in architecture && 'sourceIssue' in architecture), + `${member.id} architecture must not carry both sourceCommit and sourceIssue`, ); + if ('sourceCommit' in architecture) + assert.match( + architecture.sourceCommit, + /^[0-9a-f]{40}$/, + `${member.id} architecture sourceCommit must be a full commit sha`, + ); + if ('sourceIssue' in architecture) + assert.ok( + Number.isInteger(architecture.sourceIssue) && + architecture.sourceIssue > 0, + `${member.id} architecture sourceIssue must be a positive issue number`, + ); } } }); diff --git a/tests/validate-architectures.test.mjs b/tests/validate-architectures.test.mjs index 55b26098..a6634d97 100644 --- a/tests/validate-architectures.test.mjs +++ b/tests/validate-architectures.test.mjs @@ -318,9 +318,10 @@ for (const [label, sourceUrl] of [ }); } -// A page is either pinned to the cncf/architecture commit it was taken from or -// cites the submission issue it came from; the attribution line on the page -// and the member directory both depend on knowing which. +// A page is pinned to the cncf/architecture commit it was taken from, cites +// the submission issue it came from, or (when authored directly in this +// repository from another source) carries only its sourceUrl; it never claims +// two origins, because the attribution line on the page depends on which. test('rejects a page that sets both sourceCommit and sourceIssue', () => { const result = runScriptWithFixtures( SCRIPT, @@ -329,17 +330,16 @@ test('rejects a page that sets both sourceCommit and sourceIssue', () => { assert.equal(result.status, 1); assert.match( result.stderr, - /must set exactly one of sourceCommit \(cncf\/architecture import\) or sourceIssue \(submission issue\)/, + /must set at most one of sourceCommit \(cncf\/architecture import\) or sourceIssue \(submission issue\)/, ); }); -test('rejects a page that sets neither sourceCommit nor sourceIssue', () => { +test('accepts a page that sets neither sourceCommit nor sourceIssue', () => { const result = runScriptWithFixtures( SCRIPT, pageFixture([withAuthored({ sourceCommit: undefined })]), ); - assert.equal(result.status, 1); - assert.match(result.stderr, /must set exactly one of sourceCommit/); + assert.equal(result.status, 0, result.stderr); }); test('accepts an issue-sourced page', () => {