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+ }
0 commit comments