Fix federation flows and add versioned updater
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
const DEFAULT_IMAGE_REPO = 'ghcr.io/gnosyslabs/synapsis';
|
||||
const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface BuildInfo {
|
||||
version: string;
|
||||
commit: string | null;
|
||||
buildDate: string | null;
|
||||
githubUrl: string | null;
|
||||
imageDigest: string | null;
|
||||
imageRepo: string;
|
||||
sourceRepo: string;
|
||||
}
|
||||
|
||||
type CachedLatest = {
|
||||
expiresAt: number;
|
||||
value: PublishedBuildInfo | null;
|
||||
};
|
||||
|
||||
export interface PublishedBuildInfo extends BuildInfo {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
let latestVersionCache: CachedLatest | null = null;
|
||||
|
||||
function normalizeImageRepo(value: string): string {
|
||||
if (!value) {
|
||||
return DEFAULT_IMAGE_REPO;
|
||||
}
|
||||
|
||||
return value.startsWith('ghcr.io/') ? value : `ghcr.io/${value}`;
|
||||
}
|
||||
|
||||
function imageRepoPath(value: string): string {
|
||||
return normalizeImageRepo(value).replace(/^ghcr\.io\//, '');
|
||||
}
|
||||
|
||||
function buildGithubUrl(sourceRepo: string, commit: string | null): string | null {
|
||||
if (!commit || !sourceRepo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${sourceRepo.replace(/\/$/, '')}/commit/${commit}`;
|
||||
}
|
||||
|
||||
export function getCurrentBuildInfo(): BuildInfo {
|
||||
const sourceRepo = process.env.APP_SOURCE_REPO || 'https://github.com/GnosysLabs/Synapsis';
|
||||
const version = process.env.APP_VERSION || packageJson.version || 'dev';
|
||||
const commit = process.env.APP_COMMIT || null;
|
||||
const buildDate = process.env.APP_BUILD_DATE || null;
|
||||
const imageDigest = process.env.APP_IMAGE_DIGEST || null;
|
||||
const imageRepo = normalizeImageRepo(process.env.APP_IMAGE_REPO || DEFAULT_IMAGE_REPO);
|
||||
const githubUrl = process.env.APP_GITHUB_URL || buildGithubUrl(sourceRepo, commit);
|
||||
|
||||
return {
|
||||
version,
|
||||
commit,
|
||||
buildDate,
|
||||
githubUrl,
|
||||
imageDigest,
|
||||
imageRepo,
|
||||
sourceRepo,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseVersionTuple(version: string): [number, number, number, number] | null {
|
||||
const match = /^(\d{4})\.(\d{2})\.(\d{2})\.(\d+)$/.exec(version);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
Number(match[1]),
|
||||
Number(match[2]),
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
];
|
||||
}
|
||||
|
||||
export function compareBuildVersions(a: string, b: string): number {
|
||||
const aTuple = parseVersionTuple(a);
|
||||
const bTuple = parseVersionTuple(b);
|
||||
|
||||
if (!aTuple && !bTuple) {
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
if (!aTuple) {
|
||||
return -1;
|
||||
}
|
||||
if (!bTuple) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < aTuple.length; i += 1) {
|
||||
if (aTuple[i] !== bTuple[i]) {
|
||||
return aTuple[i] - bTuple[i];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function fetchGhcrToken(repoPath: string): Promise<string | null> {
|
||||
const tokenResponse = await fetch(`https://ghcr.io/token?scope=repository:${repoPath}:pull`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
return tokenData.token || null;
|
||||
}
|
||||
|
||||
async function fetchRegistryJson(
|
||||
repoPath: string,
|
||||
reference: string,
|
||||
accept: string,
|
||||
token: string
|
||||
) {
|
||||
const response = await fetch(`https://ghcr.io/v2/${repoPath}/manifests/${reference}`, {
|
||||
headers: {
|
||||
Accept: accept,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Registry request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
digest: response.headers.get('docker-content-digest'),
|
||||
body: await response.json(),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRegistryBlob(repoPath: string, digest: string, token: string) {
|
||||
const response = await fetch(`https://ghcr.io/v2/${repoPath}/blobs/${digest}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Registry blob request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadLatestPublishedBuild(): Promise<PublishedBuildInfo | null> {
|
||||
const current = getCurrentBuildInfo();
|
||||
const repoPath = imageRepoPath(current.imageRepo);
|
||||
const token = await fetchGhcrToken(repoPath);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifestAccept = [
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
].join(', ');
|
||||
|
||||
const latestManifest = await fetchRegistryJson(repoPath, 'latest', manifestAccept, token);
|
||||
const latestBody = latestManifest.body;
|
||||
|
||||
let configDigest: string | undefined;
|
||||
|
||||
if (Array.isArray(latestBody.manifests)) {
|
||||
const platformManifest =
|
||||
latestBody.manifests.find(
|
||||
(manifest: any) =>
|
||||
manifest.platform?.os === 'linux' && manifest.platform?.architecture === 'amd64'
|
||||
) ||
|
||||
latestBody.manifests.find((manifest: any) => manifest.platform?.os && manifest.platform?.architecture);
|
||||
|
||||
if (!platformManifest?.digest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platformManifestResponse = await fetchRegistryJson(repoPath, platformManifest.digest, manifestAccept, token);
|
||||
configDigest = platformManifestResponse.body?.config?.digest;
|
||||
} else {
|
||||
configDigest = latestBody?.config?.digest;
|
||||
}
|
||||
|
||||
if (!configDigest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configBlob = await fetchRegistryBlob(repoPath, configDigest, token);
|
||||
const labels = configBlob?.config?.Labels || {};
|
||||
const sourceRepo = labels['org.opencontainers.image.source'] || current.sourceRepo;
|
||||
const commit = labels['org.opencontainers.image.revision'] || null;
|
||||
const version = labels['org.opencontainers.image.version'] || null;
|
||||
|
||||
if (!version) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: 'latest',
|
||||
version,
|
||||
commit,
|
||||
buildDate: labels['org.opencontainers.image.created'] || null,
|
||||
githubUrl: buildGithubUrl(sourceRepo, commit),
|
||||
imageDigest: latestManifest.digest || null,
|
||||
imageRepo: current.imageRepo,
|
||||
sourceRepo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLatestPublishedBuild(): Promise<PublishedBuildInfo | null> {
|
||||
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
|
||||
return latestVersionCache.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const latest = await loadLatestPublishedBuild();
|
||||
latestVersionCache = {
|
||||
expiresAt: Date.now() + VERSION_CACHE_TTL_MS,
|
||||
value: latest,
|
||||
};
|
||||
return latest;
|
||||
} catch (error) {
|
||||
console.error('[Version] Failed to fetch latest published build:', error);
|
||||
latestVersionCache = {
|
||||
expiresAt: Date.now() + VERSION_CACHE_TTL_MS,
|
||||
value: null,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user