Skip to content

Commit b1416b5

Browse files
authored
feat: add DockerHub image reconciliation to Ingeminator (#100)
Add automatic detection of missing Docker images on DockerHub and dispatch retries for any gaps found. This completes the architectural fix for Unity versions appearing in official releases before unity-changeset (e.g., 6000.4.10f1). How it works: - Runs at end of each build cycle after normal scheduling - Checks DockerHub for missing image tags across recent versions - Dispatches retry workflows for any gaps found - Rate-limited: max 20 images per cycle to avoid API overload - Logs findings and sends Discord alerts Changes: - New: DockerImageReconciler class to check and retry missing images - Updated: GitHubWorkflow model with OS-specific event types - Updated: scheduleBuildsFromTheQueue to call reconciler Related: Closed game-ci/docker#288 (moved logic here from docker repo)
1 parent 700657b commit b1416b5

3 files changed

Lines changed: 201 additions & 13 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { Octokit } from '@octokit/rest';
2+
import { logger } from 'firebase-functions/v2';
3+
import { Discord } from '../../service/discord';
4+
import { GitHubWorkflow } from '../../model/gitHubWorkflow';
5+
import { EditorVersionInfo } from '../../model/editorVersionInfo';
6+
import { RepoVersionInfo } from '../../model/repoVersionInfo';
7+
8+
const DOCKERHUB_API = 'https://hub.docker.com/v2/repositories';
9+
const MAX_IMAGES_PER_CYCLE = 20;
10+
const RECENT_VERSIONS_TO_CHECK = 5;
11+
12+
interface DockerImage {
13+
repository: string;
14+
tag: string;
15+
baseOs: string;
16+
imageType: 'base' | 'hub' | 'editor';
17+
targetPlatform?: string;
18+
editorVersion?: string;
19+
changeset?: string;
20+
}
21+
22+
interface MissingImage {
23+
image: DockerImage;
24+
dispatchedRetry: boolean;
25+
error?: string;
26+
}
27+
28+
export class DockerImageReconciler {
29+
private gitHubClient: Octokit;
30+
private repoVersionFull: string;
31+
private repoVersionMinor: string;
32+
private repoVersionMajor: string;
33+
private imagesChecked = 0;
34+
private missingImages: MissingImage[] = [];
35+
36+
constructor(gitHubClient: Octokit, repoVersionInfo: RepoVersionInfo) {
37+
this.gitHubClient = gitHubClient;
38+
this.repoVersionFull = `${repoVersionInfo.major}.${repoVersionInfo.minor}.${repoVersionInfo.patch}`;
39+
this.repoVersionMinor = `${repoVersionInfo.major}.${repoVersionInfo.minor}`;
40+
this.repoVersionMajor = String(repoVersionInfo.major);
41+
}
42+
43+
private async isDockerImageMissing(repository: string, tag: string): Promise<boolean> {
44+
try {
45+
const response = await fetch(`${DOCKERHUB_API}/${repository}/tags/${tag}`, {
46+
headers: { 'User-Agent': 'game-ci-versioning-backend/1.0' },
47+
});
48+
if (response.status === 404) return true;
49+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
50+
return false;
51+
} catch (error) {
52+
logger.warn(`DockerHub API error checking ${repository}:${tag}`, error);
53+
return false;
54+
}
55+
}
56+
57+
async reconcileEditorImages(versions: EditorVersionInfo[]): Promise<void> {
58+
if (versions.length === 0) return;
59+
const versionsToCheck = versions.slice(0, RECENT_VERSIONS_TO_CHECK);
60+
for (const version of versionsToCheck) {
61+
if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) break;
62+
await this.checkVersionImages(version);
63+
}
64+
await this.reportResults();
65+
}
66+
67+
private async checkVersionImages(version: EditorVersionInfo): Promise<void> {
68+
const { version: editorVersion, changeSet: changeset } = version;
69+
for (const { repo, tag, os } of [
70+
{ repo: 'base', tag: `ubuntu-${this.repoVersionFull}`, os: 'ubuntu' },
71+
{ repo: 'base', tag: `windows-${this.repoVersionFull}`, os: 'windows' },
72+
{ repo: 'hub', tag: `ubuntu-${this.repoVersionFull}`, os: 'ubuntu' },
73+
{ repo: 'hub', tag: `windows-${this.repoVersionFull}`, os: 'windows' },
74+
]) {
75+
const image: DockerImage = { repository: `unityci/${repo}`, tag, baseOs: os, imageType: repo as any };
76+
await this.checkImage(image);
77+
}
78+
for (const platform of ['base', 'linux-il2cpp', 'windows-mono', 'mac-mono', 'ios', 'android', 'webgl']) {
79+
if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) break;
80+
await this.checkImage({
81+
repository: 'unityci/editor',
82+
tag: `ubuntu-${editorVersion}-${platform}-${this.repoVersionFull}`,
83+
baseOs: 'ubuntu',
84+
imageType: 'editor',
85+
targetPlatform: platform,
86+
editorVersion,
87+
changeset,
88+
});
89+
}
90+
for (const platform of ['base', 'windows-il2cpp', 'universal-windows-platform', 'appletv', 'android']) {
91+
if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) break;
92+
await this.checkImage({
93+
repository: 'unityci/editor',
94+
tag: `windows-${editorVersion}-${platform}-${this.repoVersionFull}`,
95+
baseOs: 'windows',
96+
imageType: 'editor',
97+
targetPlatform: platform,
98+
editorVersion,
99+
changeset,
100+
});
101+
}
102+
}
103+
104+
private async checkImage(image: DockerImage): Promise<void> {
105+
this.imagesChecked += 1;
106+
try {
107+
const isMissing = await this.isDockerImageMissing(image.repository, image.tag);
108+
if (!isMissing) {
109+
logger.debug(`OK ${image.repository}:${image.tag}`);
110+
return;
111+
}
112+
logger.warn(`Missing: ${image.repository}:${image.tag}`);
113+
const dispatchedRetry = await this.dispatchRetry(image);
114+
this.missingImages.push({ image, dispatchedRetry });
115+
} catch (error) {
116+
this.missingImages.push({ image, dispatchedRetry: false, error: String(error) });
117+
}
118+
}
119+
120+
private async dispatchRetry(image: DockerImage): Promise<boolean> {
121+
try {
122+
const eventType = this.getEventType(image);
123+
const payload: any = {
124+
jobId: `reconciliation-${Date.now()}-${image.imageType}-${image.tag}`,
125+
repoVersionFull: this.repoVersionFull,
126+
repoVersionMinor: this.repoVersionMinor,
127+
repoVersionMajor: this.repoVersionMajor,
128+
baseOs: image.baseOs,
129+
};
130+
if (image.editorVersion) payload.editorVersion = image.editorVersion;
131+
if (image.changeset) payload.changeSet = image.changeset;
132+
if (image.targetPlatform) payload.targetPlatform = image.targetPlatform;
133+
134+
const response = await this.gitHubClient.repos.createDispatchEvent({
135+
owner: 'unity-ci',
136+
repo: 'docker',
137+
event_type: eventType,
138+
client_payload: payload,
139+
});
140+
141+
return response.status >= 200 && response.status < 300;
142+
} catch (error) {
143+
logger.error(`Error dispatching`, error);
144+
return false;
145+
}
146+
}
147+
148+
private getEventType(image: DockerImage): string {
149+
if (image.imageType === 'base') {
150+
return image.baseOs === 'ubuntu'
151+
? GitHubWorkflow.eventTypes.newUbuntuBaseImages
152+
: GitHubWorkflow.eventTypes.newWindowsBaseImages;
153+
}
154+
if (image.imageType === 'hub') {
155+
return image.baseOs === 'ubuntu'
156+
? GitHubWorkflow.eventTypes.newUbuntuHubImages
157+
: GitHubWorkflow.eventTypes.newWindowsHubImages;
158+
}
159+
return image.baseOs === 'ubuntu'
160+
? GitHubWorkflow.eventTypes.retryUbuntuEditorImage
161+
: GitHubWorkflow.eventTypes.retryWindowsEditorImage;
162+
}
163+
164+
private async reportResults(): Promise<void> {
165+
if (this.missingImages.length === 0) {
166+
await Discord.sendDebug(`Checked ${this.imagesChecked} images - OK`);
167+
return;
168+
}
169+
const successful = this.missingImages.filter((m) => m.dispatchedRetry).length;
170+
await Discord.sendAlert(`DockerHub Reconciliation: Found ${this.missingImages.length} missing, retried ${successful}`);
171+
}
172+
}

functions/src/logic/buildQueue/scheduleBuildsFromTheQueue.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,9 @@ import { Scheduler } from './scheduler';
33
import { Discord } from '../../service/discord';
44
import { CiJobs } from '../../model/ciJobs';
55
import { Cleaner } from './cleaner';
6+
import { DockerImageReconciler } from './dockerImageReconciler';
7+
import { EditorVersionInfo } from '../../model/editorVersionInfo';
68

7-
/**
8-
* When a new Unity version gets ingested:
9-
* - a CI Job for that version gets created.
10-
*
11-
* When a new repository version gets ingested
12-
* - a CI Job for a new base image gets created
13-
* - a CI Job for a new hub image gets created
14-
* - a CI Job for every Unity version gets created
15-
* - Any CI Jobs for older repository versions get status "superseded"
16-
*
17-
* This schedule is based on that knowledge and assumption
18-
*/
199
export const scheduleBuildsFromTheQueue = async (
2010
githubPrivateKey: string,
2111
githubClientSecret: string,
@@ -66,5 +56,15 @@ export const scheduleBuildsFromTheQueue = async (
6656
return;
6757
}
6858

59+
// Reconcile DockerHub images with expected versions
60+
try {
61+
const gitHub = (scheduler as any).gitHub;
62+
const versions = await EditorVersionInfo.getAll();
63+
const reconciler = new DockerImageReconciler(gitHub, repoVersionInfo);
64+
await reconciler.reconcileEditorImages(versions);
65+
} catch (error) {
66+
await Discord.sendDebug(`[Build queue] DockerHub reconciliation error: ${error}`);
67+
}
68+
6969
await Discord.sendDebug('[Build queue] Idle 🎈');
7070
};

functions/src/model/gitHubWorkflow.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,45 @@ import { EditorVersionInfo } from './editorVersionInfo';
33

44
export type GitHubEventType =
55
| 'new_base_images_requested'
6+
| 'new_ubuntu_base_image_requested'
7+
| 'new_windows_base_image_requested'
68
| 'new_hub_images_requested'
9+
| 'new_ubuntu_hub_image_requested'
10+
| 'new_windows_hub_image_requested'
711
| 'new_legacy_editor_image_requested'
812
| 'new_post_2019_2_editor_image_requested'
913
| 'retry_ubuntu_editor_image_requested'
1014
| 'retry_windows_editor_image_requested';
1115

1216
export type FriendlyEventTypes =
1317
| 'newBaseImages'
18+
| 'newUbuntuBaseImages'
19+
| 'newWindowsBaseImages'
1420
| 'newHubImages'
21+
| 'newUbuntuHubImages'
22+
| 'newWindowsHubImages'
1523
| 'newLegacyImage'
1624
| 'newPost2019dot2Image'
1725
| 'retryUbuntuImage'
18-
| 'retryWindowsImage';
26+
| 'retryWindowsImage'
27+
| 'retryUbuntuEditorImage'
28+
| 'retryWindowsEditorImage';
1929

2030
export class GitHubWorkflow {
2131
public static get eventTypes(): Record<FriendlyEventTypes, GitHubEventType> {
2232
return {
2333
newBaseImages: 'new_base_images_requested',
34+
newUbuntuBaseImages: 'new_ubuntu_base_image_requested',
35+
newWindowsBaseImages: 'new_windows_base_image_requested',
2436
newHubImages: 'new_hub_images_requested',
37+
newUbuntuHubImages: 'new_ubuntu_hub_image_requested',
38+
newWindowsHubImages: 'new_windows_hub_image_requested',
2539
newLegacyImage: 'new_legacy_editor_image_requested',
2640
newPost2019dot2Image: 'new_post_2019_2_editor_image_requested',
2741
retryUbuntuImage: 'retry_ubuntu_editor_image_requested',
2842
retryWindowsImage: 'retry_windows_editor_image_requested',
43+
retryUbuntuEditorImage: 'retry_ubuntu_editor_image_requested',
44+
retryWindowsEditorImage: 'retry_windows_editor_image_requested',
2945
};
3046
}
3147

0 commit comments

Comments
 (0)