mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-09 08:26:16 +00:00
Docker implementation
Profile fixes Hostgroup fixes TFA fixes
This commit is contained in:
343
backend/src/services/automation/dockerImageUpdateCheck.js
Normal file
343
backend/src/services/automation/dockerImageUpdateCheck.js
Normal file
@@ -0,0 +1,343 @@
|
||||
const { prisma } = require("./shared/prisma");
|
||||
const https = require("node:https");
|
||||
const http = require("node:http");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
/**
|
||||
* Docker Image Update Check Automation
|
||||
* Checks for Docker image updates by comparing local digests with remote registry digests
|
||||
*/
|
||||
class DockerImageUpdateCheck {
|
||||
constructor(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
this.queueName = "docker-image-update-check";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remote digest from Docker registry using HEAD request
|
||||
* Supports Docker Hub, GHCR, and other OCI-compliant registries
|
||||
*/
|
||||
async getRemoteDigest(imageName, tag = "latest") {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Parse image name to determine registry
|
||||
const registryInfo = this.parseImageName(imageName);
|
||||
|
||||
// Construct manifest URL
|
||||
const manifestPath = `/v2/${registryInfo.repository}/manifests/${tag}`;
|
||||
const options = {
|
||||
hostname: registryInfo.registry,
|
||||
path: manifestPath,
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
Accept:
|
||||
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json",
|
||||
"User-Agent": "PatchMon/1.0",
|
||||
},
|
||||
};
|
||||
|
||||
// Add authentication token for Docker Hub if needed
|
||||
if (
|
||||
registryInfo.registry === "registry-1.docker.io" &&
|
||||
registryInfo.isPublic
|
||||
) {
|
||||
// For anonymous public images, we may need to get an auth token first
|
||||
// For now, try without auth (works for public images)
|
||||
}
|
||||
|
||||
// Choose HTTP or HTTPS
|
||||
const client = registryInfo.isSecure ? https : http;
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
// Authentication required - skip for now (would need to implement auth)
|
||||
return reject(
|
||||
new Error(`Authentication required for ${imageName}:${tag}`),
|
||||
);
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(
|
||||
`Registry returned status ${res.statusCode} for ${imageName}:${tag}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Get digest from Docker-Content-Digest header
|
||||
const digest = res.headers["docker-content-digest"];
|
||||
if (!digest) {
|
||||
return reject(
|
||||
new Error(
|
||||
`No Docker-Content-Digest header for ${imageName}:${tag}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up digest (remove sha256: prefix if present)
|
||||
const cleanDigest = digest.startsWith("sha256:")
|
||||
? digest.substring(7)
|
||||
: digest;
|
||||
resolve(cleanDigest);
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Timeout getting digest for ${imageName}:${tag}`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse image name to extract registry, repository, and determine if secure
|
||||
*/
|
||||
parseImageName(imageName) {
|
||||
let registry = "registry-1.docker.io";
|
||||
let repository = imageName;
|
||||
const isSecure = true;
|
||||
let isPublic = true;
|
||||
|
||||
// Handle explicit registries (ghcr.io, quay.io, etc.)
|
||||
if (imageName.includes("/")) {
|
||||
const parts = imageName.split("/");
|
||||
const firstPart = parts[0];
|
||||
|
||||
// Check for known registries
|
||||
if (firstPart.includes(".") || firstPart === "localhost") {
|
||||
registry = firstPart;
|
||||
repository = parts.slice(1).join("/");
|
||||
isPublic = false; // Assume private registries need auth for now
|
||||
} else {
|
||||
// Docker Hub - registry-1.docker.io
|
||||
repository = imageName;
|
||||
}
|
||||
}
|
||||
|
||||
// Docker Hub official images (no namespace)
|
||||
if (!repository.includes("/")) {
|
||||
repository = `library/${repository}`;
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
repository,
|
||||
isSecure,
|
||||
isPublic,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Docker image update check job
|
||||
*/
|
||||
async process(_job) {
|
||||
const startTime = Date.now();
|
||||
console.log("🐳 Starting Docker image update check...");
|
||||
|
||||
try {
|
||||
// Get all Docker images that have a digest and repository
|
||||
const images = await prisma.docker_images.findMany({
|
||||
where: {
|
||||
digest: {
|
||||
not: null,
|
||||
},
|
||||
repository: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
docker_image_updates: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`📦 Found ${images.length} images to check for updates`);
|
||||
|
||||
let checkedCount = 0;
|
||||
let updateCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors = [];
|
||||
|
||||
// Process images in batches to avoid overwhelming the API
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < images.length; i += batchSize) {
|
||||
const batch = images.slice(i, i + batchSize);
|
||||
|
||||
// Process batch concurrently with Promise.allSettled for error tolerance
|
||||
const _results = await Promise.allSettled(
|
||||
batch.map(async (image) => {
|
||||
try {
|
||||
checkedCount++;
|
||||
|
||||
// Skip local images (no digest means they're local)
|
||||
if (!image.digest || image.digest.trim() === "") {
|
||||
return { image, skipped: true, reason: "No digest" };
|
||||
}
|
||||
|
||||
// Get clean digest (remove sha256: prefix if present)
|
||||
const localDigest = image.digest.startsWith("sha256:")
|
||||
? image.digest.substring(7)
|
||||
: image.digest;
|
||||
|
||||
// Get remote digest from registry
|
||||
const remoteDigest = await this.getRemoteDigest(
|
||||
image.repository,
|
||||
image.tag || "latest",
|
||||
);
|
||||
|
||||
// Compare digests
|
||||
if (localDigest !== remoteDigest) {
|
||||
console.log(
|
||||
`🔄 Update found: ${image.repository}:${image.tag} (local: ${localDigest.substring(0, 12)}..., remote: ${remoteDigest.substring(0, 12)}...)`,
|
||||
);
|
||||
|
||||
// Store digest info in changelog_url field as JSON
|
||||
const digestInfo = JSON.stringify({
|
||||
method: "digest_comparison",
|
||||
current_digest: localDigest,
|
||||
available_digest: remoteDigest,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Upsert the update record
|
||||
await prisma.docker_image_updates.upsert({
|
||||
where: {
|
||||
image_id_available_tag: {
|
||||
image_id: image.id,
|
||||
available_tag: image.tag || "latest",
|
||||
},
|
||||
},
|
||||
update: {
|
||||
updated_at: new Date(),
|
||||
changelog_url: digestInfo,
|
||||
severity: "digest_changed",
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
image_id: image.id,
|
||||
current_tag: image.tag || "latest",
|
||||
available_tag: image.tag || "latest",
|
||||
severity: "digest_changed",
|
||||
changelog_url: digestInfo,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update last_checked timestamp on image
|
||||
await prisma.docker_images.update({
|
||||
where: { id: image.id },
|
||||
data: { last_checked: new Date() },
|
||||
});
|
||||
|
||||
updateCount++;
|
||||
return { image, updated: true };
|
||||
} else {
|
||||
// No update - still update last_checked
|
||||
await prisma.docker_images.update({
|
||||
where: { id: image.id },
|
||||
data: { last_checked: new Date() },
|
||||
});
|
||||
|
||||
// Remove existing update record if digest matches now
|
||||
const existingUpdate = image.docker_image_updates?.find(
|
||||
(u) => u.available_tag === (image.tag || "latest"),
|
||||
);
|
||||
if (existingUpdate) {
|
||||
await prisma.docker_image_updates.delete({
|
||||
where: { id: existingUpdate.id },
|
||||
});
|
||||
}
|
||||
|
||||
return { image, updated: false };
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const errorMsg = `Error checking ${image.repository}:${image.tag}: ${error.message}`;
|
||||
errors.push(errorMsg);
|
||||
console.error(`❌ ${errorMsg}`);
|
||||
|
||||
// Still update last_checked even on error
|
||||
try {
|
||||
await prisma.docker_images.update({
|
||||
where: { id: image.id },
|
||||
data: { last_checked: new Date() },
|
||||
});
|
||||
} catch (_updateError) {
|
||||
// Ignore update errors
|
||||
}
|
||||
|
||||
return { image, error: error.message };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Log batch progress
|
||||
if (i + batchSize < images.length) {
|
||||
console.log(
|
||||
`⏳ Processed ${Math.min(i + batchSize, images.length)}/${images.length} images...`,
|
||||
);
|
||||
}
|
||||
|
||||
// Small delay between batches to be respectful to registries
|
||||
if (i + batchSize < images.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.log(
|
||||
`✅ Docker image update check completed in ${executionTime}ms - Checked: ${checkedCount}, Updates: ${updateCount}, Errors: ${errorCount}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
checked: checkedCount,
|
||||
updates: updateCount,
|
||||
errors: errorCount,
|
||||
executionTime,
|
||||
errorDetails: errors,
|
||||
};
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.error(
|
||||
`❌ Docker image update check failed after ${executionTime}ms:`,
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule recurring Docker image update check (daily at 2 AM)
|
||||
*/
|
||||
async schedule() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"docker-image-update-check",
|
||||
{},
|
||||
{
|
||||
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
|
||||
jobId: "docker-image-update-check-recurring",
|
||||
},
|
||||
);
|
||||
console.log("✅ Docker image update check scheduled");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual Docker image update check
|
||||
*/
|
||||
async triggerManual() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"docker-image-update-check-manual",
|
||||
{},
|
||||
{ priority: 1 },
|
||||
);
|
||||
console.log("✅ Manual Docker image update check triggered");
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DockerImageUpdateCheck;
|
||||
Reference in New Issue
Block a user