Skip to content

Commit 1e815c5

Browse files
committed
feat: reconcile ALL versions via bulk DockerHub tag listing
Replace per-image tag existence check with paginated tags-list API call per repo. Compute expected image set from all EditorVersionInfo records and current RepoVersionInfo, then dispatch retries for any tag missing on DockerHub. Why: previous reconciler limited to 5 most recent versions (RECENT_VERSIONS_TO_CHECK=5), missing scenarios like 6000.3.17f1 being released after newer 6000.4.x versions were ingested. Older versions with missing images on the current repo version were invisible to reconciliation. Approach: one paginated GET per unityci repo (base, hub, editor) returns existing tags. Cap retries at MAX_RETRIES_PER_CYCLE=30 to avoid GitHub dispatch overload; remaining missing images retry next cycle. Tests cover: empty input, all-present case, mixed old/new version coverage, API failure, pagination, retry capping.
1 parent 03767d7 commit 1e815c5

2 files changed

Lines changed: 305 additions & 105 deletions

File tree

functions/src/logic/buildQueue/dockerImageReconciler.ts

Lines changed: 144 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,30 @@ import { EditorVersionInfo } from '../../model/editorVersionInfo';
66
import { RepoVersionInfo } from '../../model/repoVersionInfo';
77

88
const DOCKERHUB_API = 'https://hub.docker.com/v2/repositories';
9-
const MAX_IMAGES_PER_CYCLE = 20;
10-
const RECENT_VERSIONS_TO_CHECK = 5;
9+
const TAG_PAGE_SIZE = 100;
10+
const MAX_TAG_PAGES = 50;
11+
const MAX_RETRIES_PER_CYCLE = 30;
12+
const UBUNTU_PLATFORMS = [
13+
'base',
14+
'linux-il2cpp',
15+
'windows-mono',
16+
'mac-mono',
17+
'ios',
18+
'android',
19+
'webgl',
20+
] as const;
21+
const WINDOWS_PLATFORMS = [
22+
'base',
23+
'windows-il2cpp',
24+
'universal-windows-platform',
25+
'appletv',
26+
'android',
27+
] as const;
1128

1229
interface DockerImage {
1330
repository: string;
1431
tag: string;
15-
baseOs: string;
32+
baseOs: 'ubuntu' | 'windows';
1633
imageType: 'base' | 'hub' | 'editor';
1734
targetPlatform?: string;
1835
editorVersion?: string;
@@ -30,8 +47,8 @@ export class DockerImageReconciler {
3047
private repoVersionFull: string;
3148
private repoVersionMinor: string;
3249
private repoVersionMajor: string;
33-
private imagesChecked = 0;
3450
private missingImages: MissingImage[] = [];
51+
private retriesDispatched = 0;
3552

3653
constructor(gitHubClient: Octokit, repoVersionInfo: RepoVersionInfo) {
3754
this.gitHubClient = gitHubClient;
@@ -41,120 +58,137 @@ export class DockerImageReconciler {
4158
this.repoVersionMajor = String(major);
4259
}
4360

44-
private async isDockerImageMissing(repository: string, tag: string): Promise<boolean> {
45-
try {
46-
const response = await fetch(`${DOCKERHUB_API}/${repository}/tags/${tag}`, {
47-
headers: { 'User-Agent': 'game-ci-versioning-backend/1.0' },
48-
});
49-
if (response.status === 404) return true;
50-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
51-
return false;
52-
} catch (error) {
53-
logger.warn(`DockerHub API error checking ${repository}:${tag}`, error);
54-
return false;
61+
async reconcileEditorImages(versions: EditorVersionInfo[]): Promise<void> {
62+
if (versions.length === 0) {
63+
return;
5564
}
56-
}
5765

58-
async reconcileEditorImages(versions: EditorVersionInfo[]): Promise<void> {
59-
if (versions.length === 0) return;
60-
const versionsToCheck = versions.slice(0, RECENT_VERSIONS_TO_CHECK);
61-
for (const version of versionsToCheck) {
62-
if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) {
66+
const [baseTags, hubTags, editorTags] = await Promise.all([
67+
this.fetchExistingTags('unityci/base'),
68+
this.fetchExistingTags('unityci/hub'),
69+
this.fetchExistingTags('unityci/editor'),
70+
]);
71+
72+
const expectedImages = this.computeExpectedImages(versions);
73+
const tagSetByRepo: Record<string, Set<string>> = {
74+
'unityci/base': baseTags,
75+
'unityci/hub': hubTags,
76+
'unityci/editor': editorTags,
77+
};
78+
79+
for (const image of expectedImages) {
80+
if (this.retriesDispatched >= MAX_RETRIES_PER_CYCLE) {
6381
break;
6482
}
65-
await this.checkVersionImages(version);
83+
const existing = tagSetByRepo[image.repository];
84+
if (existing.has(image.tag)) {
85+
continue;
86+
}
87+
const dispatchedRetry = await this.dispatchRetry(image);
88+
if (dispatchedRetry) {
89+
this.retriesDispatched += 1;
90+
}
91+
this.missingImages.push({ image, dispatchedRetry });
6692
}
67-
await this.reportResults();
68-
}
69-
70-
private async checkVersionImages(version: EditorVersionInfo): Promise<void> {
71-
const { version: editorVersion, changeSet: changeset } = version;
7293

73-
const baseImages = [
74-
{ repo: 'base' as const, tag: `ubuntu-${this.repoVersionFull}`, os: 'ubuntu' as const },
75-
{ repo: 'base' as const, tag: `windows-${this.repoVersionFull}`, os: 'windows' as const },
76-
{ repo: 'hub' as const, tag: `ubuntu-${this.repoVersionFull}`, os: 'ubuntu' as const },
77-
{ repo: 'hub' as const, tag: `windows-${this.repoVersionFull}`, os: 'windows' as const },
78-
];
94+
await this.reportResults(expectedImages.length);
95+
}
7996

80-
for (const { repo, tag, os } of baseImages) {
81-
const image: DockerImage = {
82-
repository: `unityci/${repo}`,
83-
tag,
84-
baseOs: os,
85-
imageType: repo,
86-
};
87-
await this.checkImage(image);
97+
private async fetchExistingTags(repository: string): Promise<Set<string>> {
98+
const tags = new Set<string>();
99+
let nextUrl: string | null =
100+
`${DOCKERHUB_API}/${repository}/tags?page_size=${TAG_PAGE_SIZE}&name=${this.repoVersionFull}`;
101+
let pagesFetched = 0;
102+
103+
while (nextUrl && pagesFetched < MAX_TAG_PAGES) {
104+
try {
105+
const response = await fetch(nextUrl, {
106+
headers: { 'User-Agent': 'game-ci-versioning-backend/1.0' },
107+
});
108+
if (!response.ok) {
109+
logger.warn(`DockerHub list tags failed for ${repository}: HTTP ${response.status}`);
110+
break;
111+
}
112+
const body = (await response.json()) as {
113+
results?: { name: string }[];
114+
next?: string | null;
115+
};
116+
for (const result of body.results ?? []) {
117+
tags.add(result.name);
118+
}
119+
nextUrl = body.next ?? null;
120+
pagesFetched += 1;
121+
} catch (error) {
122+
logger.warn(`DockerHub list tags error for ${repository}`, error);
123+
break;
124+
}
88125
}
89126

90-
const ubuntuPlatforms = [
91-
'base',
92-
'linux-il2cpp',
93-
'windows-mono',
94-
'mac-mono',
95-
'ios',
96-
'android',
97-
'webgl',
98-
] as const;
99-
for (const platform of ubuntuPlatforms) {
100-
if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) break;
101-
await this.checkImage({
102-
repository: 'unityci/editor',
103-
tag: `ubuntu-${editorVersion}-${platform}-${this.repoVersionFull}`,
104-
baseOs: 'ubuntu',
105-
imageType: 'editor',
106-
targetPlatform: platform,
107-
editorVersion,
108-
changeset,
109-
});
110-
}
127+
return tags;
128+
}
111129

112-
const windowsPlatforms = [
113-
'base',
114-
'windows-il2cpp',
115-
'universal-windows-platform',
116-
'appletv',
117-
'android',
118-
] as const;
119-
for (const platform of windowsPlatforms) {
120-
if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) break;
121-
await this.checkImage({
122-
repository: 'unityci/editor',
123-
tag: `windows-${editorVersion}-${platform}-${this.repoVersionFull}`,
130+
private computeExpectedImages(versions: EditorVersionInfo[]): DockerImage[] {
131+
const expected: DockerImage[] = [
132+
{
133+
repository: 'unityci/base',
134+
tag: `ubuntu-${this.repoVersionFull}`,
135+
baseOs: 'ubuntu',
136+
imageType: 'base',
137+
},
138+
{
139+
repository: 'unityci/base',
140+
tag: `windows-${this.repoVersionFull}`,
124141
baseOs: 'windows',
125-
imageType: 'editor',
126-
targetPlatform: platform,
127-
editorVersion,
128-
changeset,
129-
});
130-
}
131-
}
142+
imageType: 'base',
143+
},
144+
{
145+
repository: 'unityci/hub',
146+
tag: `ubuntu-${this.repoVersionFull}`,
147+
baseOs: 'ubuntu',
148+
imageType: 'hub',
149+
},
150+
{
151+
repository: 'unityci/hub',
152+
tag: `windows-${this.repoVersionFull}`,
153+
baseOs: 'windows',
154+
imageType: 'hub',
155+
},
156+
];
132157

133-
private async checkImage(image: DockerImage): Promise<void> {
134-
this.imagesChecked += 1;
135-
try {
136-
const isMissing = await this.isDockerImageMissing(image.repository, image.tag);
137-
if (!isMissing) {
138-
logger.debug(`OK ${image.repository}:${image.tag}`);
139-
return;
158+
for (const version of versions) {
159+
const { version: editorVersion, changeSet: changeset } = version;
160+
for (const platform of UBUNTU_PLATFORMS) {
161+
expected.push({
162+
repository: 'unityci/editor',
163+
tag: `ubuntu-${editorVersion}-${platform}-${this.repoVersionFull}`,
164+
baseOs: 'ubuntu',
165+
imageType: 'editor',
166+
targetPlatform: platform,
167+
editorVersion,
168+
changeset,
169+
});
170+
}
171+
for (const platform of WINDOWS_PLATFORMS) {
172+
expected.push({
173+
repository: 'unityci/editor',
174+
tag: `windows-${editorVersion}-${platform}-${this.repoVersionFull}`,
175+
baseOs: 'windows',
176+
imageType: 'editor',
177+
targetPlatform: platform,
178+
editorVersion,
179+
changeset,
180+
});
140181
}
141-
logger.warn(`Missing: ${image.repository}:${image.tag}`);
142-
const dispatchedRetry = await this.dispatchRetry(image);
143-
this.missingImages.push({ image, dispatchedRetry });
144-
} catch (error) {
145-
this.missingImages.push({
146-
image,
147-
dispatchedRetry: false,
148-
error: String(error),
149-
});
150182
}
183+
184+
return expected;
151185
}
152186

153187
private async dispatchRetry(image: DockerImage): Promise<boolean> {
154188
try {
155189
const eventType = this.getEventType(image);
156-
const payload: Record<string, any> = {
157-
jobId: `reconciliation-${Date.now()}-${image.imageType}-${image.tag}`,
190+
const payload: Record<string, unknown> = {
191+
jobId: `reconciliation-${image.imageType}-${image.tag}`,
158192
repoVersionFull: this.repoVersionFull,
159193
repoVersionMinor: this.repoVersionMinor,
160194
repoVersionMajor: this.repoVersionMajor,
@@ -173,7 +207,7 @@ export class DockerImageReconciler {
173207

174208
return response.status >= 200 && response.status < 300;
175209
} catch (error) {
176-
logger.error('Error dispatching', error);
210+
logger.error(`Error dispatching retry for ${image.tag}`, error);
177211
return false;
178212
}
179213
}
@@ -194,18 +228,23 @@ export class DockerImageReconciler {
194228
: GitHubWorkflow.eventTypes.retryWindowsEditorImage;
195229
}
196230

197-
private async reportResults(): Promise<void> {
231+
private async reportResults(expectedCount: number): Promise<void> {
198232
if (this.missingImages.length === 0) {
199-
await Discord.sendDebug(`[DockerImageReconciler] Checked ${this.imagesChecked} images OK`);
233+
await Discord.sendDebug(
234+
`[DockerImageReconciler] Verified ${expectedCount} expected images, all present`,
235+
);
200236
return;
201237
}
202238

203239
const successful = this.missingImages.filter((m) => m.dispatchedRetry).length;
204-
const failedCount = this.missingImages.length - successful;
240+
const failed = this.missingImages.length - successful;
205241

206242
await Discord.sendAlert(
207-
`DockerHub Reconciliation: Found ${this.missingImages.length} missing, ` +
208-
`retried ${successful}, failed ${failedCount}`,
243+
`DockerHub Reconciliation: ${this.missingImages.length} missing of ${expectedCount} expected; ` +
244+
`retried ${successful}, failed ${failed}` +
245+
(this.retriesDispatched >= MAX_RETRIES_PER_CYCLE
246+
? ` (capped at ${MAX_RETRIES_PER_CYCLE} retries; remaining will retry next cycle)`
247+
: ''),
209248
);
210249
}
211250
}

0 commit comments

Comments
 (0)