mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-07 07:56:05 +00:00
feat(fleet): detect and update from a new sencho-dev:dev build (#1871)
* feat(fleet): add self dev-build detection primitives Split compareLocalToRemoteTag into compareLocalToRemoteTagDetailed (returns the probe's primary digest alongside the match/update/error verdict) with compareLocalToRemoteTag now a thin wrapper, so a caller that needs both the verdict and the digest no longer has to probe the same mutable tag twice. Add detectSelfDevBuildUpdate, which compares the running container's own image against the rolling ghcr.io/studio-saelix/sencho-dev:dev tag using the new detailed comparison, laying the groundwork for surfacing dev-build updates in Fleet. * feat: add isSenchoDevRepository and isSenchoDevFloatingTag predicates Add two pure predicate functions to helpers/selfUpdateCompose.ts for identifying Sencho dev repository references and floating tag variants: - isSenchoDevRepository: checks if a reference is to the ghcr.io/studio-saelix/sencho-dev repository, including digest-pinned and dev-<sha> tag variants - isSenchoDevFloatingTag: checks if a reference is specifically the floating :dev tag on the Sencho dev repository (not digest-pinned, not immutable dev-<sha>) Both functions reuse existing parsing patterns (normalizeImageRepository for repository extraction, classifyImagePin idiom for digest and tag detection) to maintain consistency. Add comprehensive test coverage in self-update-compose.test.ts covering all specified test cases including edge cases (malformed refs, unrelated repos, digest pins, etc.). * feat(gitops): wire dev-build detection into MonitorService Adds a dev_build_update_available notification category and a new checkSenchoDevBuild() cycle in MonitorService that detects when the running container has fallen behind the rolling ghcr.io/studio-saelix/sencho-dev:dev build it is pinned to, using detectSelfDevBuildUpdate() and isSenchoDevFloatingTag(). Availability state is written unconditionally so the Fleet update affordance never depends on notification delivery succeeding, while a separate dedup key prevents re-notifying for a digest already announced. Also guards checkSenchoVersion() so a dev-repo pin no longer produces a false positive stable-release update notification. * feat(fleet): surface dev-image status and build availability Fleet's GET /update-status now reports isDevImage (any reference to the sencho-dev repository, including digest pins) and devBuildUpdateAvailable (the exact floating :dev tag with a newer build observed, read from the system-state key MonitorService already maintains). A dev-pinned local node forces updateAvailable to false and clears any stale stable-release skip, since that skip was computed before image-pin classification and would otherwise leak a bogus "Skipped" state onto a dev row. Made MonitorService's SENCHO_DEV_BUILD_AVAILABLE_KEY constant public so both call sites share one string instead of duplicating it. * fix(fleet): omit targetVersion for a dev-image update trigger updateRequestInit() always forwarded latestVersion (the latest stable release) as targetVersion whenever it was valid semver, even for a dev-pinned node. The backend already ignores targetVersion safely for a floating pin, so this never caused an actual repin, but it produced a misleading "Update to X.Y.Z" button label and confirm-dialog copy for an update that installs the dev image, not that stable release. * feat(fleet): add integration-image badge and dev build update button NodeCard now shows a persistent "Integration image" badge whenever a node's compose image is any sencho-dev reference, independent of update availability, visible to every role. When a newer dev build is available, a solid brand-colored "Update dev build" button appears alongside it, admin-only, reusing the existing update trigger and requireAdmin route. Styled distinctly from the neutral stable "Update to X.Y.Z" button so an operator always knows which channel they're acting on. * feat(fleet): add dev-image copy to the local update confirm dialog LocalUpdateConfirmDialog now recognizes isDevImage and shows a distinct LOCAL - DEV UPDATE kicker plus copy stating the sencho-dev:dev reference will be pulled without rewriting the compose image, and that integration images are unsigned and carry no release attestations. Without this, a dev-pinned node's update confirmation fell through to the generic "Pulls Sencho the latest release" copy. FleetView.tsx threads isDevImage from the node's update status through to the dialog, same source as its other pin fields. * feat(fleet): separate dev and stable availability in the Node Updates sheet The sheet counted stable and dev availability together via the same updateAvailable field, so a dev-pinned node with a build available fell into neither the summary counts nor any row action, and would have misleadingly rendered as "Up to date" once devBuildUpdateAvailable existed. stableAvailable and devAvailable are now tracked separately: the changelog dot lights only from stableAvailable (a dev build has no release changelog), the summary and meta text report the combined total, a dev row shows "Integration build" instead of a stable version in the Latest column, and the existing Update button/badge now also fires for devBuildUpdateAvailable. Update all and Skip stay stable-only, since both already gate on fields a dev row never satisfies. * feat(fleet): bring dev-build detection and update to Mobile Fleet Mobile Fleet previously had no update capability at all: it only polled /fleet/overview and never called useFleetUpdateStatus, so it could not show the stable update flow either. It now fetches update status alongside the overview poll, shows the same "integration" marker as desktop on any dev-pinned node's card (visible to every role), and gives admins a dev-build update action. The action renders as a sibling of the card's own button rather than nested inside it, since the card is itself a <button> and a nested button is invalid HTML with broken touch semantics. It reuses the exact same triggerNodeUpdate/confirmLocalUpdate flow and LocalUpdateConfirmDialog /ReconnectingOverlay components desktop already renders, so there is no parallel API implementation to keep in sync. * feat(notifications): wire dev_build_update_available through the frontend Adds the category to the frontend NotificationCategory union, its bell label, the per-node "mute update notifications" bundle, and the bell's friendly dot-color memo. The changelog navigation and "View changelog" button stay scoped to node_update_available only: a dev build has no release changelog entry to navigate to. * docs: document dev-build detection and update on Fleet Adds the dev_build_update_available notification category, the persistent Integration image marker, and the dev-build update action (desktop and mobile) to the alerts-notifications, verifying-images, fleet-view, remote-updates, and upgrade pages. States the detection cadence explicitly: it polls on a fixed interval and reflects the newest build observed, not necessarily every individual build. * fix(gitops): sanitize the inconclusive-reason debug log for log injection CodeQL flagged the dev-build check's debug log as depending on a user-influenced value (a registry probe failure reason can trace back to external input). Wraps it with sanitizeForLog(), the existing repo-wide remediation for this class of finding, matching how registry-api.ts already handles the same pattern. * test(gitops): cover the no-repin invariant on a dev-build self-update Proves triggerUpdate(), called with neither targetVersion nor targetImageRef (the exact dev-build update call), pulls the current compose-declared ref unchanged and never stages a compose rewrite. * fix(fleet): use the shared busy-button pattern on Mobile Fleet's dev update action Replaces the local Loader2 plus boolean pending logic with BusyButton so busy behavior and interaction locking stay in sync with the rest of the app's async click surfaces. * test(gitops): exercise the production call shape in the no-repin regression Fleet substitutes the stable compare target when the request body omits one, so SelfUpdateService receives a targetVersion even for a dev-build update. The guard that protects a :dev install is therefore the semver check inside the repin branch, not the absence of a target. Drives triggerUpdate with a forwarded target against a floating :dev pin and asserts the reference is pulled unchanged with no staged patch, and pairs it with a semver case so the negative assertions cannot pass vacuously.
This commit is contained in:
@@ -11,7 +11,14 @@ import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
|
||||
import { getLatestVersionInfo } from '../utils/version-check';
|
||||
import { getHostMemory } from '../helpers/hostMemory';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { withTimeout, TimeoutError } from '../utils/withTimeout';
|
||||
import SelfUpdateService from './SelfUpdateService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { parseImageRef } from './registry-api';
|
||||
import { detectSelfDevBuildUpdate } from './selfDevBuildDetect';
|
||||
import { isSenchoDevRepository, isSenchoDevFloatingTag } from '../helpers/selfUpdateCompose';
|
||||
|
||||
const getMetricDetails = (metric: string): { name: string, unit: string } => {
|
||||
switch (metric) {
|
||||
@@ -204,6 +211,16 @@ export class MonitorService {
|
||||
// exits; see backend/src/services/DockerEventService.ts.
|
||||
|
||||
private static readonly SENCHO_UPDATE_NOTIFIED_KEY = 'last_sencho_update_notified_version';
|
||||
// Public: the Fleet route reads this same key to derive devBuildUpdateAvailable
|
||||
// without a second polling loop.
|
||||
static readonly SENCHO_DEV_BUILD_AVAILABLE_KEY = 'sencho_dev_build_available_digest';
|
||||
private static readonly SENCHO_DEV_BUILD_NOTIFIED_KEY = 'last_sencho_dev_build_notified_digest';
|
||||
|
||||
// Cadence gate for checkSenchoDevBuild(): 30 minutes after a conclusive
|
||||
// check (up_to_date, or update with a notification decision made), 5
|
||||
// minutes after an inconclusive one so a transient failure retries soon.
|
||||
private lastDevBuildCheckAt = 0;
|
||||
private lastDevBuildCheckGateMs = 30 * 60 * 1000;
|
||||
|
||||
private constructor() { }
|
||||
|
||||
@@ -375,6 +392,10 @@ export class MonitorService {
|
||||
|
||||
// 4. Sencho version update check (cache-backed; dedup prevents re-notify)
|
||||
await this.checkSenchoVersion();
|
||||
|
||||
// 5. Sencho dev-build update check (only applicable when self-pinned to
|
||||
// the floating ghcr.io/studio-saelix/sencho-dev:dev tag)
|
||||
await this.checkSenchoDevBuild();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,6 +594,14 @@ export class MonitorService {
|
||||
* next eval can retry.
|
||||
*/
|
||||
private async checkSenchoVersion(): Promise<void> {
|
||||
// A dev-repo pin (floating :dev, immutable dev-<sha>, or digest) tracks
|
||||
// rolling builds, not stable semver releases, so the stable-release
|
||||
// update check does not apply; checkSenchoDevBuild() covers it instead.
|
||||
const pin = await SelfUpdateService.getInstance().getPinInfo();
|
||||
if (pin && isSenchoDevRepository(pin.composeImageRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// getSenchoVersion() reads the packaged manifest; npm_package_version
|
||||
// is unset under `node dist/index.js` (Docker).
|
||||
const currentVersion = getSenchoVersion();
|
||||
@@ -625,6 +654,100 @@ export class MonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify when the running Sencho container has fallen behind the rolling
|
||||
* `ghcr.io/studio-saelix/sencho-dev:dev` build it is pinned to. Only
|
||||
* applicable when the compose-declared image is that exact floating tag;
|
||||
* a stable pin, an immutable `dev-<sha>` tag, or a digest pin returns
|
||||
* immediately. Availability key `sencho_dev_build_available_digest`
|
||||
* always reflects the latest detection outcome; dedup key
|
||||
* `last_sencho_dev_build_notified_digest` prevents re-notifying for a
|
||||
* digest already announced.
|
||||
*/
|
||||
private async checkSenchoDevBuild(): Promise<void> {
|
||||
try {
|
||||
const pin = await SelfUpdateService.getInstance().getPinInfo();
|
||||
if (!pin) return;
|
||||
if (!isSenchoDevFloatingTag(pin.composeImageRef)) return;
|
||||
|
||||
if (Date.now() - this.lastDevBuildCheckAt < this.lastDevBuildCheckGateMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageId = SelfIdentityService.getInstance().getIdentity().imageId;
|
||||
if (!imageId) {
|
||||
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev-build check: own image id unknown; will retry sooner');
|
||||
this.lastDevBuildCheckAt = Date.now();
|
||||
this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseImageRef(pin.composeImageRef);
|
||||
if (!parsed) {
|
||||
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev-build check: could not parse compose image ref; will retry sooner');
|
||||
this.lastDevBuildCheckAt = Date.now();
|
||||
this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
|
||||
const result = await detectSelfDevBuildUpdate({
|
||||
runningImageId: imageId,
|
||||
registry: parsed.registry,
|
||||
repo: parsed.repo,
|
||||
tag: parsed.tag,
|
||||
credentials,
|
||||
});
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
if (result.kind === 'up_to_date') {
|
||||
db.setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, '');
|
||||
this.lastDevBuildCheckAt = Date.now();
|
||||
this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
|
||||
if (isDebugEnabled()) console.debug('[Monitor:diag] Sencho dev build is up-to-date');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.kind === 'inconclusive') {
|
||||
if (isDebugEnabled()) console.debug(`[Monitor:diag] Sencho dev-build check inconclusive: ${sanitizeForLog(result.reason)}`);
|
||||
this.lastDevBuildCheckAt = Date.now();
|
||||
this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
|
||||
// result.kind === 'update': availability reflects reality regardless
|
||||
// of whether the notification itself lands.
|
||||
db.setSystemState(MonitorService.SENCHO_DEV_BUILD_AVAILABLE_KEY, result.digest);
|
||||
|
||||
if (db.getSystemState(MonitorService.SENCHO_DEV_BUILD_NOTIFIED_KEY) === result.digest) {
|
||||
if (isDebugEnabled()) console.debug('[Monitor:diag] Already notified for this Sencho dev build digest');
|
||||
this.lastDevBuildCheckAt = Date.now();
|
||||
this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
|
||||
const { persisted } = await NotificationService.getInstance().dispatchAlert(
|
||||
'info',
|
||||
'dev_build_update_available',
|
||||
'A new Sencho dev build is available on ghcr.io/studio-saelix/sencho-dev:dev. '
|
||||
+ "Use the update action on this node's Fleet card to pull and recreate.",
|
||||
);
|
||||
|
||||
this.lastDevBuildCheckAt = Date.now();
|
||||
if (persisted) {
|
||||
db.setSystemState(MonitorService.SENCHO_DEV_BUILD_NOTIFIED_KEY, result.digest);
|
||||
this.lastDevBuildCheckGateMs = 30 * 60 * 1000;
|
||||
} else {
|
||||
// Retry sooner so an unpersisted notification is not silently
|
||||
// deduped; the availability key above already reflects reality.
|
||||
this.lastDevBuildCheckGateMs = 5 * 60 * 1000;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MonitorService] Failed to check Sencho dev build update:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateStackAlerts(db: DatabaseService) {
|
||||
const alerts = db.getStackAlerts();
|
||||
const nodes = db.getNodes();
|
||||
|
||||
@@ -63,6 +63,7 @@ export type NotificationCategory =
|
||||
| 'git_apply_rolled_back'
|
||||
| 'git_create'
|
||||
| 'node_update_available'
|
||||
| 'dev_build_update_available'
|
||||
| 'system';
|
||||
|
||||
export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
|
||||
@@ -71,7 +72,7 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
|
||||
'autoheal_triggered', 'monitor_alert', 'scan_finding',
|
||||
'blueprint_deployed', 'blueprint_deployment_failed',
|
||||
'blueprint_drift_detected', 'blueprint_drift_correction_failed',
|
||||
'node_update_available', 'system',
|
||||
'node_update_available', 'dev_build_update_available', 'system',
|
||||
];
|
||||
|
||||
/** Every category that can appear in notification history / the bell panel. */
|
||||
|
||||
@@ -785,14 +785,17 @@ async function classifyManifest(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare local image digests to the registry's current manifest for a tag.
|
||||
* Any candidate that equals the remote primary or is a member of that primary's
|
||||
* index counts as current (Docker often lists a stale index digest ahead of the
|
||||
* current one). `platform` is the local image's Os/Architecture (from
|
||||
* `docker image inspect`), required to safely match against an index's platform
|
||||
* descriptors; without it, an index mismatch is an error rather than a
|
||||
* speculative match. Never retries against the mutable tag once a primary
|
||||
* digest is established: classification always targets that digest.
|
||||
* Compare local image digests to the registry's current manifest for a tag,
|
||||
* returning the probe's primary digest alongside the verdict so a caller that
|
||||
* needs both (e.g. a self-build detector reporting the new digest) does not
|
||||
* have to re-probe the mutable tag. Any candidate that equals the remote
|
||||
* primary or is a member of that primary's index counts as current (Docker
|
||||
* often lists a stale index digest ahead of the current one). `platform` is
|
||||
* the local image's Os/Architecture (from `docker image inspect`), required
|
||||
* to safely match against an index's platform descriptors; without it, an
|
||||
* index mismatch is an error rather than a speculative match. Never retries
|
||||
* against the mutable tag once a primary digest is established:
|
||||
* classification always targets that digest.
|
||||
*
|
||||
* `update` is returned only after a successful, complete remote classification
|
||||
* with no candidate matching the primary, an exact member, or a same-platform
|
||||
@@ -805,16 +808,20 @@ async function classifyManifest(
|
||||
* instead. Empty/all-malformed candidates, unknown platform when platform
|
||||
* matching is required, an index with no runnable content for the local
|
||||
* platform at all (including an empty or fully-filtered index), and
|
||||
* classification failures also return `error`.
|
||||
* classification failures also return `error`. `primaryDigest` is present
|
||||
* once a valid primary digest has been read from the registry, including on
|
||||
* `error` results from a later classification failure; it is absent only
|
||||
* when the local candidate digests are malformed, the probe itself failed,
|
||||
* or the registry's primary digest fails validation.
|
||||
*/
|
||||
export async function compareLocalToRemoteTag(
|
||||
export async function compareLocalToRemoteTagDetailed(
|
||||
localDigests: readonly string[],
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
platform: { os: string; architecture: string },
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<DigestComparisonResult> {
|
||||
): Promise<{ kind: 'match' | 'update' | 'error'; primaryDigest?: string; reason?: string }> {
|
||||
const candidates = localDigests.filter((d) => SHA256_DIGEST_RE.test(d));
|
||||
if (candidates.length === 0) {
|
||||
return { kind: 'error', reason: 'Local digest is malformed or truncated' };
|
||||
@@ -829,21 +836,21 @@ export async function compareLocalToRemoteTag(
|
||||
if (!SHA256_DIGEST_RE.test(primaryDigest)) {
|
||||
return { kind: 'error', reason: `Registry returned a malformed digest for ${ref}` };
|
||||
}
|
||||
if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match' };
|
||||
if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match', primaryDigest };
|
||||
|
||||
let classification: ManifestClassification;
|
||||
try {
|
||||
classification = await classifyManifest(registry, repo, primaryDigest, contentType, body, authHeaders, ref);
|
||||
} catch (e) {
|
||||
return { kind: 'error', reason: getErrorMessage(e, `Failed to classify remote manifest for ${ref}`) };
|
||||
return { kind: 'error', primaryDigest, reason: getErrorMessage(e, `Failed to classify remote manifest for ${ref}`) };
|
||||
}
|
||||
|
||||
if (classification.kind === 'single') return { kind: 'update' };
|
||||
if (classification.kind === 'single') return { kind: 'update', primaryDigest };
|
||||
|
||||
if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match' };
|
||||
if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match', primaryDigest };
|
||||
|
||||
if (!platform.os || !platform.architecture) {
|
||||
return { kind: 'error', reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
|
||||
return { kind: 'error', primaryDigest, reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
|
||||
}
|
||||
|
||||
const platformDescriptors = classification.descriptors.filter(
|
||||
@@ -860,16 +867,32 @@ export async function compareLocalToRemoteTag(
|
||||
// content, since nothing else claims a different one; that is the one
|
||||
// case where reporting `update` instead of failing closed is safe.
|
||||
if (classification.exactDigests.length === 0) {
|
||||
return { kind: 'error', reason: `Remote image index has no ${platform.os}/${platform.architecture} variant for ${ref}` };
|
||||
return { kind: 'error', primaryDigest, reason: `Remote image index has no ${platform.os}/${platform.architecture} variant for ${ref}` };
|
||||
}
|
||||
if (classification.descriptors.length > 0) {
|
||||
return { kind: 'error', reason: `Remote image index has no confirmed ${platform.os}/${platform.architecture} variant for ${ref}` };
|
||||
return { kind: 'error', primaryDigest, reason: `Remote image index has no confirmed ${platform.os}/${platform.architecture} variant for ${ref}` };
|
||||
}
|
||||
return { kind: 'update' };
|
||||
return { kind: 'update', primaryDigest };
|
||||
}
|
||||
|
||||
const isMember = platformDescriptors.some((d) => candidateSet.has(d.digest.toLowerCase()));
|
||||
return isMember ? { kind: 'match' } : { kind: 'update' };
|
||||
return isMember ? { kind: 'match', primaryDigest } : { kind: 'update', primaryDigest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verdict-only view of {@link compareLocalToRemoteTagDetailed} for callers
|
||||
* that never need the primary digest.
|
||||
*/
|
||||
export async function compareLocalToRemoteTag(
|
||||
localDigests: readonly string[],
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
platform: { os: string; architecture: string },
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<DigestComparisonResult> {
|
||||
const result = await compareLocalToRemoteTagDetailed(localDigests, registry, repo, tag, platform, credentials);
|
||||
return result.kind === 'error' ? { kind: 'error', reason: result.reason ?? 'Unknown error' } : { kind: result.kind };
|
||||
}
|
||||
|
||||
export type TagListCode =
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Detects whether the running Sencho container's own image has fallen behind
|
||||
* the rolling `ghcr.io/studio-saelix/sencho-dev:dev` build it tracks. Reuses
|
||||
* {@link compareLocalToRemoteTagDetailed} so the verdict and the new digest
|
||||
* come from a single registry probe.
|
||||
*/
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import DockerController from './DockerController';
|
||||
import {
|
||||
compareLocalToRemoteTagDetailed,
|
||||
selectLocalRepoDigests,
|
||||
type RegistryCredentials,
|
||||
} from './registry-api';
|
||||
|
||||
export interface SelfDevBuildDetectInput {
|
||||
/** Container's actual running image ID (no "sha256:" prefix). */
|
||||
runningImageId: string;
|
||||
registry: string;
|
||||
repo: string;
|
||||
tag: string;
|
||||
credentials: RegistryCredentials | null;
|
||||
}
|
||||
|
||||
export type SelfDevBuildDetectResult =
|
||||
| { kind: 'up_to_date' }
|
||||
| { kind: 'update'; digest: string }
|
||||
| { kind: 'inconclusive'; reason: string };
|
||||
|
||||
/** The subset of `docker image inspect` output the detector reads. */
|
||||
interface InspectedImage {
|
||||
RepoDigests: string[];
|
||||
Os: string;
|
||||
Architecture: string;
|
||||
}
|
||||
|
||||
async function defaultInspectImage(imageId: string): Promise<InspectedImage> {
|
||||
const inspect = await DockerController.getInstance().getDocker().getImage(`sha256:${imageId}`).inspect();
|
||||
return { RepoDigests: inspect.RepoDigests ?? [], Os: inspect.Os, Architecture: inspect.Architecture };
|
||||
}
|
||||
|
||||
export interface DetectSelfDevBuildUpdateDeps {
|
||||
inspectImage?: typeof defaultInspectImage;
|
||||
compareDetailed?: typeof compareLocalToRemoteTagDetailed;
|
||||
}
|
||||
|
||||
export async function detectSelfDevBuildUpdate(
|
||||
input: SelfDevBuildDetectInput,
|
||||
deps?: DetectSelfDevBuildUpdateDeps,
|
||||
): Promise<SelfDevBuildDetectResult> {
|
||||
const { runningImageId, registry, repo, tag, credentials } = input;
|
||||
const inspectImage = deps?.inspectImage ?? defaultInspectImage;
|
||||
const compareDetailed = deps?.compareDetailed ?? compareLocalToRemoteTagDetailed;
|
||||
|
||||
let inspected: InspectedImage;
|
||||
try {
|
||||
inspected = await inspectImage(runningImageId);
|
||||
} catch (e) {
|
||||
return { kind: 'inconclusive', reason: getErrorMessage(e, 'Failed to inspect the running image') };
|
||||
}
|
||||
|
||||
if (!Array.isArray(inspected.RepoDigests) || inspected.RepoDigests.length === 0) {
|
||||
return { kind: 'inconclusive', reason: 'Running image has no registry digests (not registry-backed or locally built)' };
|
||||
}
|
||||
|
||||
const localDigests = selectLocalRepoDigests(inspected.RepoDigests, { registry, repo, tag });
|
||||
if (localDigests.length === 0) {
|
||||
return { kind: 'inconclusive', reason: `Running image has no digest matching ${registry}/${repo}:${tag}` };
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof compareLocalToRemoteTagDetailed>>;
|
||||
try {
|
||||
result = await compareDetailed(
|
||||
localDigests,
|
||||
registry,
|
||||
repo,
|
||||
tag,
|
||||
{ os: inspected.Os, architecture: inspected.Architecture },
|
||||
credentials,
|
||||
);
|
||||
} catch (e) {
|
||||
return { kind: 'inconclusive', reason: getErrorMessage(e, 'Registry comparison failed') };
|
||||
}
|
||||
|
||||
if (result.kind === 'match') return { kind: 'up_to_date' };
|
||||
if (result.kind === 'error') return { kind: 'inconclusive', reason: result.reason ?? 'Registry probe failed' };
|
||||
|
||||
if (!result.primaryDigest) {
|
||||
return { kind: 'inconclusive', reason: 'Registry reported an update but returned no digest to identify it' };
|
||||
}
|
||||
return { kind: 'update', digest: result.primaryDigest };
|
||||
}
|
||||
Reference in New Issue
Block a user