From f11041d4e7d2a94be19af8ec45932c5aad4f50f6 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Feb 2026 09:49:13 -0600 Subject: [PATCH 1/3] db migration --- .../migrations/02_cache_app_version/migration.sql | 10 ++++++++++ src/lib/prisma/schema.prisma | 8 ++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/lib/prisma/migrations/02_cache_app_version/migration.sql diff --git a/src/lib/prisma/migrations/02_cache_app_version/migration.sql b/src/lib/prisma/migrations/02_cache_app_version/migration.sql new file mode 100644 index 0000000..f0a4f7a --- /dev/null +++ b/src/lib/prisma/migrations/02_cache_app_version/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "public"."appVersion" ( + "appName" TEXT NOT NULL, + "version" TEXT NOT NULL, + "imageHash" TEXT NOT NULL, + "created" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated" TIMESTAMP(6), + + CONSTRAINT "appVersion_pkey" PRIMARY KEY ("appName") +); diff --git a/src/lib/prisma/schema.prisma b/src/lib/prisma/schema.prisma index aa40408..845ea48 100644 --- a/src/lib/prisma/schema.prisma +++ b/src/lib/prisma/schema.prisma @@ -104,3 +104,11 @@ model release { @@index([build_id], map: "idx_release_build_id") } + +model appVersion { + appName String @id + version String + imageHash String + created DateTime @default(now()) @db.Timestamp(6) + updated DateTime? @updatedAt @db.Timestamp(6) +} From 6ea4b2df3bcd1eda2ec1f2c6723eef065f67ed72 Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Feb 2026 10:51:37 -0600 Subject: [PATCH 2/3] Move app version check to recurring job --- src/lib/server/bullmq/BullWorker.ts | 35 ++- src/lib/server/bullmq/queues.ts | 17 +- src/lib/server/bullmq/types.ts | 19 +- src/lib/server/job-executors/system.ts | 341 ++++++++++++++++++++ src/routes/(api)/system/check/+server.ts | 379 +---------------------- 5 files changed, 411 insertions(+), 380 deletions(-) diff --git a/src/lib/server/bullmq/BullWorker.ts b/src/lib/server/bullmq/BullWorker.ts index 0afd3d0..394884e 100644 --- a/src/lib/server/bullmq/BullWorker.ts +++ b/src/lib/server/bullmq/BullWorker.ts @@ -22,7 +22,7 @@ export abstract class BullWorker { abstract run(job: Job): Promise; } -export class SystemStartup extends BullWorker { +export class SystemStartup extends BullWorker { private jobsLeft = 0; constructor() { super(BullMQ.QueueName.System_Startup); @@ -32,6 +32,12 @@ export class SystemStartup extends BullWorker { { type: BullMQ.JobType.System_CreateCodeBuildProject } + ], + [ + 'Refresh AppVersions (Startup)', + { + type: BullMQ.JobType.System_RefreshAppVersions + } ] ] as const; startupJobs.forEach(([name, data]) => { @@ -49,6 +55,33 @@ export class SystemStartup extends BullWorker { return Executor.System.createCodeBuildProject( job as Job ); + case BullMQ.JobType.System_RefreshAppVersions: + return Executor.System.refreshAppVersions(job as Job); + } + } +} + +export class SystemRecurring extends BullWorker { + constructor() { + super(BullMQ.QueueName.System_Recurring); + getQueues().SystemRecurring.upsertJobScheduler( + BullMQ.JobSchedulerId.RefreshAppVersions, + { + pattern: '@hourly', + immediately: false + }, + { + name: 'Refresh AppVersions', + data: { + type: BullMQ.JobType.System_RefreshAppVersions + } + } + ); + } + async run(job: Job) { + switch (job.data.type) { + case BullMQ.JobType.System_RefreshAppVersions: + return Executor.System.refreshAppVersions(job as Job); } } } diff --git a/src/lib/server/bullmq/queues.ts b/src/lib/server/bullmq/queues.ts index 5ff0b39..387e413 100644 --- a/src/lib/server/bullmq/queues.ts +++ b/src/lib/server/bullmq/queues.ts @@ -1,6 +1,14 @@ import { Queue } from 'bullmq'; import { Redis } from 'ioredis'; -import type { BuildJob, PollJob, ProjectJob, PublishJob, S3Job, SystemJob } from './types'; +import type { + BuildJob, + PollJob, + ProjectJob, + PublishJob, + RecurringJob, + S3Job, + StartupJob +} from './types'; import { QueueName } from './types'; import { env } from '$env/dynamic/private'; @@ -98,14 +106,17 @@ function createQueues() { /** Queue for jobs that poll BuildEngine, such as checking the status of a build */ const Polling = new Queue(QueueName.Polling, getQueueConfig()); /** Queue for jobs that run on startup, such as creating the CodeBuild project */ - const SystemStartup = new Queue(QueueName.System_Startup, getQueueConfig()); + const SystemStartup = new Queue(QueueName.System_Startup, getQueueConfig()); + /** Queue for default recurring jobs such as refreshing the cached AppVersions */ + const SystemRecurring = new Queue(QueueName.System_Recurring, getQueueConfig()); return { Builds, S3, Projects, Releases, Polling, - SystemStartup + SystemStartup, + SystemRecurring }; } export function getQueues() { diff --git a/src/lib/server/bullmq/types.ts b/src/lib/server/bullmq/types.ts index fe866e4..f9513e8 100644 --- a/src/lib/server/bullmq/types.ts +++ b/src/lib/server/bullmq/types.ts @@ -21,7 +21,8 @@ export enum QueueName { Projects = 'Projects', Releases = 'Releases', Polling = 'Polling', - System_Startup = 'System (Startup)' + System_Startup = 'System (Startup)', + System_Recurring = 'System (Recurring)' } export enum JobType { @@ -38,7 +39,12 @@ export enum JobType { S3_CopyArtifacts = 'Copy Artifacts to S3', S3_CopyError = 'Copy Errors to S3', // System Jobs - System_CreateCodeBuildProject = 'Create CodeBuild Project' + System_CreateCodeBuildProject = 'Create CodeBuild Project', + System_RefreshAppVersions = 'Refresh AppVersions' +} + +export enum JobSchedulerId { + RefreshAppVersions = 'RefreshAppVersions' } export namespace Build { @@ -96,6 +102,9 @@ export namespace System { export interface CreateCodeBuildProject { type: JobType.System_CreateCodeBuildProject; } + export interface RefreshAppVersions { + type: JobType.System_RefreshAppVersions; + } } export type Job = JobTypeMap[keyof JobTypeMap]; @@ -105,7 +114,10 @@ export type S3Job = JobTypeMap[JobType.S3_CopyArtifacts | JobType.S3_CopyError]; export type PublishJob = JobTypeMap[JobType.Release_Product]; export type PollJob = JobTypeMap[JobType.Poll_Build | JobType.Poll_Release]; export type ProjectJob = JobTypeMap[JobType.Project_Create]; -export type SystemJob = JobTypeMap[JobType.System_CreateCodeBuildProject]; +export type StartupJob = JobTypeMap[ + | JobType.System_CreateCodeBuildProject + | JobType.System_RefreshAppVersions]; +export type RecurringJob = JobTypeMap[JobType.System_RefreshAppVersions]; export type JobTypeMap = { [JobType.Build_Product]: Build.Product; @@ -116,5 +128,6 @@ export type JobTypeMap = { [JobType.S3_CopyArtifacts]: S3.CopyArtifacts; [JobType.S3_CopyError]: S3.CopyErrors; [JobType.System_CreateCodeBuildProject]: System.CreateCodeBuildProject; + [JobType.System_RefreshAppVersions]: System.RefreshAppVersions; // Add more mappings here as needed }; diff --git a/src/lib/server/job-executors/system.ts b/src/lib/server/job-executors/system.ts index 9a727f4..7b96eaa 100644 --- a/src/lib/server/job-executors/system.ts +++ b/src/lib/server/job-executors/system.ts @@ -1,10 +1,22 @@ import type { ProjectCache, ProjectSource } from '@aws-sdk/client-codebuild'; +import { + BatchGetImageCommand, + DescribeImagesCommand, + DescribeRepositoriesCommand, + ECRClient, + ECRServiceException, + GetDownloadUrlForLayerCommand, + type ImageDetail, + RepositoryNotFoundException +} from '@aws-sdk/client-ecr'; import type { Job } from 'bullmq'; import { join } from 'node:path'; import { CodeBuild } from '../aws/codebuild'; import { IAmWrapper } from '../aws/iamwrapper'; import { S3 } from '../aws/s3'; import type { BullMQ } from '../bullmq'; +import { prisma } from '../prisma'; +import { AWSCommon } from '$lib/server/aws/common'; type Logger = (msg: string) => void; @@ -105,3 +117,332 @@ async function copyFolder(sourceFolder: string, bucket: string, log: Logger) { log(`${e}`); } } + +const appNames = [ + 'scriptureappbuilder', + 'readingappbuilder', + 'dictionaryappbuilder', + 'keyboardappbuilder' +] as const; + +type App = (typeof appNames)[number]; + +export async function refreshAppVersions( + job: Job +): Promise { + // db connectivity handled by server hooks + // Prepare default response structure + let versions: Map | null = null; + let imageHash: string | null = null; + + const repoConfig = AWSCommon.getCodeBuildImageRepo(); + const tagFilter = AWSCommon.getCodeBuildImageTag(); + const region = AWSCommon.getArtifactsBucketRegion(); + + // Status: log repo config presence + job.log(`repoConfig=${repoConfig ?? '(none)'}`); + + // Try to query ECR if AWS SDK is available and repo is configured + if (repoConfig) { + job.log(`AwsEcrEcrClient available, attempting ECR query (region=${region})`); + try { + const client = new ECRClient({ + region + }); + + job.log('EcrClient constructed'); + + // repositoryName for ECR is typically the last path segment if repo includes a path + let repoName = repoConfig; + if (repoName.includes('/')) { + const parts = repoName.split('/'); + repoName = parts.at(-1)!; + } + + job.log(`resolved repositoryName=${repoName}`); + + job.updateProgress(10); + + // Verify repository exists before calling describeImages + job.log(`*** Verify ECR Repository ***`); + const repoMeta = await verifyEcrRepositoryExists(client, repoName, (msg) => job.log(msg)); + job.log(`*****************************`); + + job.updateProgress(30); + + let imageDetails: ImageDetail[] = []; + if (!repoMeta) { + job.log( + `repository verification failed or repository not found: ${repoName} - skipping describeImages.` + ); + } else { + // Describe tagged images + job.log('calling describeImages for ' + repoName); + const resp = await client.send( + new DescribeImagesCommand({ repositoryName: repoName, filter: { tagStatus: 'TAGGED' } }) + ); + imageDetails = resp['imageDetails'] ?? []; + + job.log(`describeImages returned ${imageDetails.length} imageDetails`); + } + + job.updateProgress(40); + + // Look for version information in image manifests + for (const img of imageDetails) { + if (!img['imageTags']) { + continue; + } + + job.log(`image: ${JSON.stringify(img)}`); + + // Extract image digest (hash) from the image details - this is available without fetching manifest + const imageDigest = img['imageDigest'] || null; + job.log( + `found imageDigest: ${imageDigest ?? 'none'} for image with tags: ${img['imageTags'].join(', ')}` + ); + + for (const imgTag of img['imageTags']) { + // Only process tags that match the tagFilter (if set) + if (tagFilter && !imgTag.includes(tagFilter)) { + job.log(`skipping tag ${imgTag} (does not match tagFilter)`); + continue; + } + + // Extract versions for all apps from the image manifest/config + job.log(`*** Fetch AppVersions ***`); + versions = await fetchAllAppVersionsFromManifest(client, repoName, imgTag, (msg) => + job.log(msg) + ); + job.log(`*************************`); + + if (versions?.size) { + imageHash = imageDigest; + } else { + job.log(`no app versions found in manifest for tag ${imgTag}`); + } + } + } + } catch (e) { + // If ECR query fails (missing creds/permissions or network), leave versions empty + // Do not throw: the health check should still succeed if DB is OK + job.log(`ECR query failed: ${e}`); + // Detect AccessDenied and add an extra hint to logs + if (e instanceof Error && e.message.match(/AccessDenied/i)) { + job.log( + 'ECR Access Denied. Ensure IAM principal has ecr:DescribeImages for the repository and correct region/account.' + ); + } + } + } else { + job.log('skipping ECR query (no repoConfig or ECR client not available)'); + } + + job.updateProgress(90); + + const entries = await Promise.all( + versions?.entries().map(([appName, version]) => + prisma.appVersion.upsert({ + where: { appName }, + update: { version, imageHash: imageHash! }, + create: { appName, version, imageHash: imageHash! } + }) + ) ?? [] + ); + + job.updateProgress(100); + + return entries; +} + +/** + * Verify that an ECR repository exists and return its metadata. + * Returns repository array on success, or null on not found / error. + * Logs info/warnings for common AWS error codes. + */ +async function verifyEcrRepositoryExists(client: ECRClient, repoName: string, log: Logger) { + try { + log(`calling describeRepositories for ${repoName}`); + const resp = await client.send( + new DescribeRepositoriesCommand({ + repositoryNames: [repoName] + }) + ); + const repos = resp['repositories'] ?? []; + if (repos.length) { + log(`repository found: ${repos[0]['repositoryArn'] ?? repoName}`); + return repos[0]; + } + log(`describeRepositories returned empty for ${repoName}`); + } catch (e) { + if (e instanceof ECRServiceException) { + log(`AwsException: ${e.message} awsCode=${e.name}`); + if (e instanceof RepositoryNotFoundException) { + log(`repository not found: ${repoName}`); + } else if (e.message.match(/AccessDenied/i) || e.name === 'AccessDeniedException') { + log( + `access denied for describeRepositories on ${repoName}. Ensure IAM permissions include ecr:DescribeRepositories and ecr:DescribeImages.` + ); + } else { + // Other AWS error: log and return null + log(`unexpected ECR error: ${e.message}`); + } + } else { + log(`unexpected error: ${e}`); + } + } + + return null; +} + +/** + * Fetch the image manifest/config and extract version information for all apps. + * Returns an associative array of app names to version strings. + * Does NOT manage caching - that's the caller's responsibility. + */ +async function fetchAllAppVersionsFromManifest( + client: ECRClient, + repoName: string, + imageTag: string, + log: Logger +): Promise | null> { + try { + log(`batchGetImage for ${imageTag}`); + const resp = await client.send( + new BatchGetImageCommand({ + repositoryName: repoName, + imageIds: [{ imageTag: imageTag }], + acceptedMediaTypes: [ + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.oci.image.manifest.v1+json' + ] + }) + ); + + const images = resp['images']; + if (!images) { + return fail(log(`no images returned for tag ${imageTag}`)); + } + + log(`received ${images.length} image(s) for tag ${imageTag}`); + + const imageManifest = images[0]['imageManifest']; + if (!imageManifest) { + return fail(log(`imageManifest missing for ${imageTag}`)); + } + + log(`parsing manifest JSON for ${imageTag}`); + let manifest; + try { + manifest = JSON.parse(imageManifest); + } catch { + return fail( + log(`unable to decode manifest JSON for ${imageTag}\nManifest:\n${imageManifest ?? ''}`) + ); + } + + log(`manifest schema version: ${manifest['schemaVersion'] ?? 'unknown'} for ${imageTag}`); + + // Locate config digest in manifest (schema v2) to fetch the image config with labels + const configDigest = manifest['config']['digest']; + if (!configDigest) { + return fail(log(`no config digest found in manifest for ${imageTag}`)); + } + + log(`found config digest: ${configDigest} for ${imageTag}`); + + // Get a download URL for the config blob and fetch it + log(`getDownloadUrlForLayer for ${configDigest}`); + const dl = await client.send( + new GetDownloadUrlForLayerCommand({ + repositoryName: repoName, + layerDigest: configDigest + }) + ); + const url = dl['downloadUrl']; + if (!url) { + return fail(log(`no downloadUrl for config ${configDigest}`)); + } + + log(`fetching config from URL for ${imageTag}`); + + // Fetch the config JSON + const configContent = await fetch(url).then((r) => r.text()); + if (!configContent) { + return fail(log(`failed to fetch config from ${url}`)); + } + + log(`received config content (${configContent.length} bytes) for ${imageTag}`); + + let config; + try { + config = JSON.parse(configContent); + } catch { + return fail( + log(`unable to decode config JSON for ${imageTag}\nConfig:\n${configContent ?? ''}`) + ); + } + + log(`parsed config JSON successfully for ${imageTag}`); + + // Common places for labels + let labels: Record = {}; + if (config['config']['Labels']) { + labels = config['config']['Labels']; + log(`found ${labels.length} labels in config.Labels for ${imageTag}`); + } else if (config['container_config']['Labels']) { + labels = config['container_config']['Labels']; + log(`found ${labels.length} labels in container_config.Labels for ${imageTag}`); + } else { + log(`no labels found in config for ${imageTag}`); + } + + // Log all label keys for debugging + if (Object.keys(labels).length) { + log(`available label keys: [${Object.keys(labels).join(', ')}] for ${imageTag}`); + } + + // Extract version for each app from labels + const appVersions = new Map( + appNames + .map((app) => { + const labelKey = `org.opencontainers.image.version_${app}`; + if (labels[labelKey]) { + const version = labels[labelKey]; + log(`found version for ${app}: ${version} (label: ${labelKey}) for ${imageTag}`); + return [app, version] as [App, string]; + } else { + log(`no version found for ${app} (looked for label: ${labelKey}) for ${imageTag}`); + return null; + } + }) + .filter((p) => !!p) + ); + + log( + `extracted ${appVersions.size} app versions for ${imageTag}: [${appVersions + .entries() + .map(([k, v]) => `${k}=${v}`) + .toArray() + .join(', ')}]` + ); + + return appVersions; + } catch (e) { + if (e instanceof ECRServiceException) { + log(`AwsException: ${e.message} awsCode=${e.name}`); + if (e.name === 'AccessDeniedException') { + log( + 'AccessDenied. Ensure ecr:BatchGetImage and ecr:GetDownloadUrlForLayer permissions are present.' + ); + } + } else { + log(`unexpected error: ${e}`); + } + } + return null; +} + +function fail(_: void) { + return null; +} diff --git a/src/routes/(api)/system/check/+server.ts b/src/routes/(api)/system/check/+server.ts index ef6a110..3721011 100644 --- a/src/routes/(api)/system/check/+server.ts +++ b/src/routes/(api)/system/check/+server.ts @@ -1,138 +1,16 @@ -import { - BatchGetImageCommand, - DescribeImagesCommand, - DescribeRepositoriesCommand, - ECRClient, - ECRServiceException, - GetDownloadUrlForLayerCommand, - type ImageDetail, - RepositoryNotFoundException -} from '@aws-sdk/client-ecr'; import type { RequestHandler } from './$types'; -import { AWSCommon } from '$lib/server/aws/common'; +import { prisma } from '$lib/server/prisma'; // GET system/check export const GET: RequestHandler = async () => { - // db connectivity handled by server hooks - // Prepare default response structure - const versions: Record = {}; - let imageHash: string | null = null; - - const repoConfig = AWSCommon.getCodeBuildImageRepo(); - const tagFilter = AWSCommon.getCodeBuildImageTag(); - const region = AWSCommon.getArtifactsBucketRegion(); - - // Status: log repo config presence - console.log(`system/check:GET - repoConfig=${repoConfig ?? '(none)'}`); - - // Try to query ECR if AWS SDK is available and repo is configured - if (repoConfig) { - console.log( - `system/check:GET - AwsEcrEcrClient available, attempting ECR query (region=${region})` - ); - try { - const client = new ECRClient({ - region - }); - - console.log('system/check:GET - EcrClient constructed'); - - // repositoryName for ECR is typically the last path segment if repo includes a path - let repoName = repoConfig; - if (repoName.includes('/')) { - const parts = repoName.split('/'); - repoName = parts.at(-1)!; - } - - console.log(`system/check:GET - resolved repositoryName=${repoName}`); - - // Verify repository exists before calling describeImages - const repoMeta = await verifyEcrRepositoryExists(client, repoName); - let imageDetails: ImageDetail[] = []; - if (!repoMeta) { - console.warn( - `system/check:GET - repository verification failed or repository not found: ${repoName} - skipping describeImages.` - ); - } else { - // Describe tagged images - console.log('system/check:GET - calling describeImages for ' + repoName); - const resp = await client.send( - new DescribeImagesCommand({ repositoryName: repoName, filter: { tagStatus: 'TAGGED' } }) - ); - imageDetails = resp['imageDetails'] ?? []; - - console.log( - `system/check:GET - describeImages returned ${imageDetails.length} imageDetails` - ); - } - - // Look for version information in image manifests - for (const img of imageDetails) { - if (!img['imageTags']) { - continue; - } - - console.log(`system/check:GET - image: ${JSON.stringify(img)}`); - - // Extract image digest (hash) from the image details - this is available without fetching manifest - const imageDigest = img['imageDigest'] || null; - console.log( - `system/check:GET - found imageDigest: ${imageDigest ?? 'none'} for image with tags: ${img['imageTags'].join(', ')}` - ); - - for (const imgTag of img['imageTags']) { - // Only process tags that match the tagFilter (if set) - if (tagFilter && !imgTag.includes(tagFilter)) { - console.log(`system/check:GET - skipping tag ${imgTag} (does not match tagFilter)`); - continue; - } - - // Extract versions for all apps from the image manifest/config - const appVersions = await fetchAllAppVersionsFromManifest(client, repoName, imgTag); - - if (Object.keys(appVersions).length) { - imageHash = imageDigest; - - for (const [app, version] of Object.entries(appVersions)) { - versions[app] = version; - console.log( - `system/check:GET - manifest-derived version for tag ${imgTag} : ${app} ${version}` - ); - } - } else { - console.log(`system/check:GET - no app versions found in manifest for tag ${imgTag}`); - } - } - } - } catch (e) { - // If ECR query fails (missing creds/permissions or network), leave versions empty - // Do not throw: the health check should still succeed if DB is OK - console.warn(`system/check:GET - ECR query failed: ${e}`); - // Detect AccessDenied and add an extra hint to logs - if (e instanceof Error && e.message.match(/AccessDenied/i)) { - console.warn( - 'system/check:GET - ECR Access Denied. Ensure IAM principal has ecr:DescribeImages for the repository and correct region/account.' - ); - } - } - } else { - console.log( - 'system/check:GET - skipping ECR query (no repoConfig or ECR client not available)' - ); - } - - // Build timestamps - const created = new Date(); - - // Debug logging - console.log(`system/check:GET - Final versions structure: ${JSON.stringify(versions)}`); + const versions = await prisma.appVersion.findMany(); return new Response( JSON.stringify({ - versions, - created, - updated: created, - imageHash, + versions: Object.fromEntries(versions.map(({ appName, version }) => [appName, version])), + created: versions[0].created, + updated: versions[0].updated, + imageHash: versions[0].imageHash, _links: { self: { href: `${process.env.ORIGIN || 'http://localhost:8443'}/system/check` @@ -141,248 +19,3 @@ export const GET: RequestHandler = async () => { }) ); }; - -const appNames = [ - 'scriptureappbuilder', - 'readingappbuilder', - 'dictionaryappbuilder', - 'keyboardappbuilder' -] as const; - -/** - * Verify that an ECR repository exists and return its metadata. - * Returns repository array on success, or null on not found / error. - * Logs info/warnings for common AWS error codes. - */ -async function verifyEcrRepositoryExists(client: ECRClient, repoName: string) { - try { - console.log( - `system/check:verifyEcrRepositoryExists - calling describeRepositories for ${repoName}` - ); - const resp = await client.send( - new DescribeRepositoriesCommand({ - repositoryNames: [repoName] - }) - ); - const repos = resp['repositories'] ?? []; - if (repos.length) { - console.log( - `system/check:verifyEcrRepositoryExists - repository found: ${repos[0]['repositoryArn'] ?? repoName}` - ); - return repos[0]; - } - console.warn( - `system/check:verifyEcrRepositoryExists - describeRepositories returned empty for ${repoName}` - ); - } catch (e) { - if (e instanceof ECRServiceException) { - console.warn( - `system/check:verifyEcrRepositoryExists - AwsException: ${e.message} awsCode=${e.name}` - ); - if (e instanceof RepositoryNotFoundException) { - console.warn(`system/check:verifyEcrRepositoryExists - repository not found: ${repoName}`); - } else if (e.message.match(/AccessDenied/i) || e.name === 'AccessDeniedException') { - console.warn( - `system/check:verifyEcrRepositoryExists - access denied for describeRepositories on ${repoName}. Ensure IAM permissions include ecr:DescribeRepositories and ecr:DescribeImages.` - ); - } else { - // Other AWS error: log and return null - console.warn(`system/check:verifyEcrRepositoryExists - unexpected ECR error: ${e.message}`); - } - } else { - console.warn(`system/check:verifyEcrRepositoryExists - unexpected error: ${e}`); - } - } - - return null; -} - -/** - * Fetch the image manifest/config and extract version information for all apps. - * Returns an associative array of app names to version strings. - * Does NOT manage caching - that's the caller's responsibility. - */ -async function fetchAllAppVersionsFromManifest( - client: ECRClient, - repoName: string, - imageTag: string -) { - try { - console.log(`system/check:fetchAllAppVersionsFromManifest - batchGetImage for ${imageTag}`); - const resp = await client.send( - new BatchGetImageCommand({ - repositoryName: repoName, - imageIds: [{ imageTag: imageTag }], - acceptedMediaTypes: [ - 'application/vnd.docker.distribution.manifest.v2+json', - 'application/vnd.oci.image.manifest.v1+json' - ] - }) - ); - - const images = resp['images']; - if (!images) { - console.log( - `system/check:fetchAllAppVersionsFromManifest - no images returned for tag ${imageTag}` - ); - return {}; - } - - console.log( - `system/check:fetchAllAppVersionsFromManifest - received ${images.length} image(s) for tag ${imageTag}` - ); - - const imageManifest = images[0]['imageManifest']; - if (!imageManifest) { - console.log( - `system/check:fetchAllAppVersionsFromManifest - imageManifest missing for ${imageTag}` - ); - return {}; - } - - console.log( - `system/check:fetchAllAppVersionsFromManifest - parsing manifest JSON for ${imageTag}` - ); - let manifest; - try { - manifest = JSON.parse(imageManifest); - } catch { - console.warn( - `system/check:fetchAllAppVersionsFromManifest - unable to decode manifest JSON for ${imageTag}\nManifest:\n${imageManifest ?? ''}` - ); - return {}; - } - - console.log( - `system/check:fetchAllAppVersionsFromManifest - manifest schema version: ${manifest['schemaVersion'] ?? 'unknown'} for ${imageTag}` - ); - - // Locate config digest in manifest (schema v2) to fetch the image config with labels - const configDigest = manifest['config']['digest']; - if (!configDigest) { - console.log( - `system/check:fetchAllAppVersionsFromManifest - no config digest found in manifest for ${imageTag}` - ); - return {}; - } - - console.log( - `system/check:fetchAllAppVersionsFromManifest - found config digest: ${configDigest} for ${imageTag}` - ); - - // Get a download URL for the config blob and fetch it - console.log( - `system/check:fetchAllAppVersionsFromManifest - getDownloadUrlForLayer for ${configDigest}` - ); - const dl = await client.send( - new GetDownloadUrlForLayerCommand({ - repositoryName: repoName, - layerDigest: configDigest - }) - ); - const url = dl['downloadUrl']; - if (!url) { - console.warn( - `system/check:fetchAllAppVersionsFromManifest - no downloadUrl for config ${configDigest}` - ); - return {}; - } - - console.log( - `system/check:fetchAllAppVersionsFromManifest - fetching config from URL for ${imageTag}` - ); - - // Fetch the config JSON - const configContent = await fetch(url).then((r) => r.text()); - if (!configContent) { - console.warn( - `system/check:fetchAllAppVersionsFromManifest - failed to fetch config from ${url}` - ); - return {}; - } - - console.log( - `system/check:fetchAllAppVersionsFromManifest - received config content (${configContent.length} bytes) for ${imageTag}` - ); - - let config; - try { - config = JSON.parse(configContent); - } catch { - console.warn( - `system/check:fetchAllAppVersionsFromManifest - unable to decode config JSON for ${imageTag}\nConfig:\n${configContent ?? ''}` - ); - return {}; - } - - console.log( - `system/check:fetchAllAppVersionsFromManifest - parsed config JSON successfully for ${imageTag}` - ); - - // Common places for labels - let labels: Record = {}; - if (config['config']['Labels']) { - labels = config['config']['Labels']; - console.log( - `system/check:fetchAllAppVersionsFromManifest - found ${labels.length} labels in config.Labels for ${imageTag}` - ); - } else if (config['container_config']['Labels']) { - labels = config['container_config']['Labels']; - console.log( - `system/check:fetchAllAppVersionsFromManifest - found ${labels.length} labels in container_config.Labels for ${imageTag}` - ); - } else { - console.log( - `system/check:fetchAllAppVersionsFromManifest - no labels found in config for ${imageTag}` - ); - } - - // Log all label keys for debugging - if (Object.keys(labels).length) { - console.log( - `system/check:fetchAllAppVersionsFromManifest - available label keys: [${Object.keys(labels).join(', ')}] for ${imageTag}` - ); - } - - // Extract version for each app from labels - const appVersions: Record = {}; - for (const app of appNames) { - const labelKey = `org.opencontainers.image.version_${app}`; - if (labels[labelKey]) { - const version = labels[labelKey]; - appVersions[app] = version; - console.log( - `system/check:fetchAllAppVersionsFromManifest - found version for ${app}: ${version} (label: ${labelKey}) for ${imageTag}` - ); - } else { - console.log( - `system/check:fetchAllAppVersionsFromManifest - no version found for ${app} (looked for label: ${labelKey}) for ${imageTag}` - ); - } - } - - console.log( - `system/check:fetchAllAppVersionsFromManifest - extracted ${Object.keys(appVersions).length} app versions for ${imageTag}: [${Object.entries( - appVersions - ) - .map(([k, v]) => `${k}=${v}`) - .join(', ')}]` - ); - - return appVersions; - } catch (e) { - if (e instanceof ECRServiceException) { - console.warn( - `system/check:fetchAllAppVersionsFromManifest - AwsException: ${e.message} awsCode=${e.name}` - ); - if (e.name === 'AccessDeniedException') { - console.warn( - 'system/check:fetchAllAppVersionsFromManifest - AccessDenied. Ensure ecr:BatchGetImage and ecr:GetDownloadUrlForLayer permissions are present.' - ); - } - } else { - console.warn(`system/check:fetchAllAppVersionsFromManifest - unexpected error: ${e}`); - } - } - return {}; -} From 61ea87bbe63f99de66de1c2afc057bfed03c5b1a Mon Sep 17 00:00:00 2001 From: Aidan Jones Date: Thu, 5 Feb 2026 11:09:45 -0600 Subject: [PATCH 3/3] Display version info in about page --- src/routes/(ui)/about/+page.server.ts | 8 +++++++ src/routes/(ui)/about/+page.svelte | 30 ++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/routes/(ui)/about/+page.server.ts diff --git a/src/routes/(ui)/about/+page.server.ts b/src/routes/(ui)/about/+page.server.ts new file mode 100644 index 0000000..af07b7d --- /dev/null +++ b/src/routes/(ui)/about/+page.server.ts @@ -0,0 +1,8 @@ +import type { PageServerLoad } from './$types'; +import { prisma } from '$lib/server/prisma'; + +export const load = (async () => { + return { + appVersions: await prisma.appVersion.findMany() + }; +}) satisfies PageServerLoad; diff --git a/src/routes/(ui)/about/+page.svelte b/src/routes/(ui)/about/+page.svelte index 450dc24..8e9439f 100644 --- a/src/routes/(ui)/about/+page.svelte +++ b/src/routes/(ui)/about/+page.svelte @@ -1,8 +1,15 @@ -
@@ -38,5 +45,26 @@ SIL Global ) : Programming

+

AppBuilder Versions

+

+ Hash: + {data.appVersions[0].imageHash} +

+

+ Updated: + {data.appVersions[0].updated?.toLocaleString()} +

+
+ + + {#each data.appVersions.toSorted((a, b) => a.appName.localeCompare(b.appName)) as version} + + + + + {/each} + +
{version.appName}:{version.version}
+