2525 */
2626
2727const path = require ( 'path' ) ;
28+ const https = require ( 'https' ) ;
2829
2930// Load models from parent directory
3031const db = require ( path . join ( __dirname , '..' , 'models' ) ) ;
@@ -33,6 +34,90 @@ const { Container, Node, Site } = db;
3334// Load utilities
3435const { parseArgs } = require ( path . join ( __dirname , '..' , 'utils' , 'cli' ) ) ;
3536
37+ /**
38+ * Fetch JSON from a URL with optional headers
39+ * @param {string } url - The URL to fetch
40+ * @param {object } headers - Optional headers
41+ * @returns {Promise<object> } Parsed JSON response
42+ */
43+ function fetchJson ( url , headers = { } ) {
44+ return new Promise ( ( resolve , reject ) => {
45+ const req = https . get ( url , { headers } , ( res ) => {
46+ let data = '' ;
47+ res . on ( 'data' , chunk => data += chunk ) ;
48+ res . on ( 'end' , ( ) => {
49+ if ( res . statusCode >= 400 ) {
50+ reject ( new Error ( `HTTP ${ res . statusCode } : ${ data } ` ) ) ;
51+ } else {
52+ try {
53+ resolve ( JSON . parse ( data ) ) ;
54+ } catch ( e ) {
55+ reject ( new Error ( `Failed to parse JSON: ${ e . message } ` ) ) ;
56+ }
57+ }
58+ } ) ;
59+ } ) ;
60+ req . on ( 'error' , reject ) ;
61+ } ) ;
62+ }
63+
64+ /**
65+ * Get the digest (sha256 hash) of a Docker/OCI image from the registry
66+ * Handles both single-arch and multi-arch (manifest list) images
67+ * @param {string } registry - Registry hostname (e.g., 'docker.io')
68+ * @param {string } repo - Repository (e.g., 'library/nginx')
69+ * @param {string } tag - Tag (e.g., 'latest')
70+ * @returns {Promise<string> } Short digest (first 12 chars of sha256)
71+ */
72+ async function getImageDigest ( registry , repo , tag ) {
73+ let headers = { } ;
74+
75+ // Docker Hub requires auth token
76+ if ( registry === 'docker.io' || registry === 'registry-1.docker.io' ) {
77+ const tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${ repo } :pull` ;
78+ const tokenData = await fetchJson ( tokenUrl ) ;
79+ headers [ 'Authorization' ] = `Bearer ${ tokenData . token } ` ;
80+ }
81+
82+ const registryHost = registry === 'docker.io' ? 'registry-1.docker.io' : registry ;
83+
84+ // Fetch manifest - accept both single manifest and manifest list
85+ headers [ 'Accept' ] = [
86+ 'application/vnd.docker.distribution.manifest.v2+json' ,
87+ 'application/vnd.oci.image.manifest.v1+json' ,
88+ 'application/vnd.docker.distribution.manifest.list.v2+json' ,
89+ 'application/vnd.oci.image.index.v1+json'
90+ ] . join ( ', ' ) ;
91+
92+ const manifestUrl = `https://${ registryHost } /v2/${ repo } /manifests/${ tag } ` ;
93+ let manifest = await fetchJson ( manifestUrl , headers ) ;
94+
95+ // Handle manifest list (multi-arch) - select amd64/linux
96+ if ( manifest . manifests && Array . isArray ( manifest . manifests ) ) {
97+ const amd64Manifest = manifest . manifests . find ( m =>
98+ m . platform ?. architecture === 'amd64' && m . platform ?. os === 'linux'
99+ ) ;
100+ if ( ! amd64Manifest ) {
101+ throw new Error ( 'No amd64/linux manifest found in manifest list' ) ;
102+ }
103+
104+ // Fetch the actual manifest for amd64
105+ headers [ 'Accept' ] = 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json' ;
106+ const archManifestUrl = `https://${ registryHost } /v2/${ repo } /manifests/${ amd64Manifest . digest } ` ;
107+ manifest = await fetchJson ( archManifestUrl , headers ) ;
108+ }
109+
110+ // Get config digest from manifest
111+ const configDigest = manifest . config ?. digest ;
112+ if ( ! configDigest ) {
113+ throw new Error ( 'No config digest in manifest' ) ;
114+ }
115+
116+ // Return short hash (sha256:abc123... -> abc123...)
117+ const hash = configDigest . replace ( 'sha256:' , '' ) ;
118+ return hash . substring ( 0 , 12 ) ;
119+ }
120+
36121/**
37122 * Check if a template is a Docker image reference (contains '/')
38123 * @param {string } template - The template string
@@ -63,14 +148,15 @@ function parseDockerRef(ref) {
63148
64149/**
65150 * Generate a filename for a pulled Docker image
66- * Replaces special chars with underscores
151+ * Replaces special chars with underscores, includes digest for cache busting
67152 * Note: Proxmox automatically appends .tar, so we don't include it here
68153 * @param {object } parsed - Parsed Docker ref components
69- * @returns {string } Sanitized filename (e.g., "docker.io_library_nginx_latest")
154+ * @param {string } digest - Short digest hash
155+ * @returns {string } Sanitized filename (e.g., "docker.io_library_nginx_latest_abc123def456")
70156 */
71- function generateImageFilename ( parsed ) {
157+ function generateImageFilename ( parsed , digest ) {
72158 const { registry, namespace, image, tag } = parsed ;
73- const sanitized = `${ registry } _${ namespace } _${ image } _${ tag } ` . replace ( / [ / : ] / g, '_' ) ;
159+ const sanitized = `${ registry } _${ namespace } _${ image } _${ tag } _ ${ digest } ` . replace ( / [ / : ] / g, '_' ) ;
74160 return sanitized ;
75161}
76162
@@ -199,24 +285,39 @@ async function main() {
199285 const parsed = parseDockerRef ( container . template ) ;
200286 console . log ( `Docker image: ${ parsed . registry } /${ parsed . namespace } /${ parsed . image } :${ parsed . tag } ` ) ;
201287
202- const filename = generateImageFilename ( parsed ) ;
203- console . log ( `Target filename: ${ filename } ` ) ;
204-
205288 const storage = node . imageStorage || 'local' ;
206289 console . log ( `Using storage: ${ storage } ` ) ;
207290
208- // Pull the image from OCI registry using full image reference
209- const imageRef = container . template ;
210- console . log ( `Pulling image ${ imageRef } ...` ) ;
211- const pullUpid = await client . pullOciImage ( node . name , storage , {
212- reference : imageRef ,
213- filename
214- } ) ;
215- console . log ( `Pull task started: ${ pullUpid } ` ) ;
291+ // Get image digest from registry to create unique filename
292+ const repo = parsed . namespace ? `${ parsed . namespace } /${ parsed . image } ` : parsed . image ;
293+ console . log ( `Fetching digest for ${ parsed . registry } /${ repo } :${ parsed . tag } ...` ) ;
294+ const digest = await getImageDigest ( parsed . registry , repo , parsed . tag ) ;
295+ console . log ( `Image digest: ${ digest } ` ) ;
216296
217- // Wait for pull to complete
218- await client . waitForTask ( node . name , pullUpid ) ;
219- console . log ( 'Image pulled successfully' ) ;
297+ const filename = generateImageFilename ( parsed , digest ) ;
298+ console . log ( `Target filename: ${ filename } ` ) ;
299+
300+ // Check if image already exists in storage
301+ const existingContents = await client . storageContents ( node . name , storage , 'vztmpl' ) ;
302+ const expectedVolid = `${ storage } :vztmpl/${ filename } .tar` ;
303+ const imageExists = existingContents . some ( item => item . volid === expectedVolid ) ;
304+
305+ if ( imageExists ) {
306+ console . log ( `Image already exists in storage: ${ expectedVolid } ` ) ;
307+ } else {
308+ // Pull the image from OCI registry
309+ const imageRef = container . template ;
310+ console . log ( `Pulling image ${ imageRef } ...` ) ;
311+ const pullUpid = await client . pullOciImage ( node . name , storage , {
312+ reference : imageRef ,
313+ filename
314+ } ) ;
315+ console . log ( `Pull task started: ${ pullUpid } ` ) ;
316+
317+ // Wait for pull to complete
318+ await client . waitForTask ( node . name , pullUpid ) ;
319+ console . log ( 'Image pulled successfully' ) ;
320+ }
220321
221322 // Create container from the pulled image (Proxmox adds .tar to the filename)
222323 console . log ( `Creating container from ${ filename } .tar...` ) ;
0 commit comments