diff --git a/dashcaddy-api/__tests__/update-manager-compose-prefix.test.js b/dashcaddy-api/__tests__/update-manager-compose-prefix.test.js new file mode 100644 index 0000000..f097609 --- /dev/null +++ b/dashcaddy-api/__tests__/update-manager-compose-prefix.test.js @@ -0,0 +1,228 @@ +/** + * DC-082: Update-manager image-name parsing for docker-compose prefixed names. + * + * The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to + * `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub. + * Compose-prefixed names (single hyphen, no slash, lowercase) need to + * split on the FIRST hyphen to recover `/` — that's + * the actual upstream namespace for a compose-prefixed image. + * + * The fix also adds a "no upstream registry image, skip cleanly" path + * for when the authed GET 401s against a compose-prefixed name (the + * compose-prefixed image is built locally and not published to Docker + * Hub). That should log as info, not error. + */ +const updateManager = require('../src/managers/update-manager'); + +describe('DC-082 update-manager / compose-prefixed image names', () => { + let um = updateManager; // module exports the singleton instance + + describe('_composeProjectToRepo', () => { + test('splits dashcaddy-dashcaddy-api on the first hyphen', () => { + expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api'); + }); + + test('splits myproject-myservice on the first hyphen', () => { + expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice'); + }); + + test('splits multi-hyphen names on the FIRST hyphen only', () => { + // "myproj-grandchild-service" -> "myproj/grandchild-service" + // (first hyphen is the project/service boundary; later hyphens are + // part of the service name like docker-compose's `web-cache`). + expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service'); + }); + + test('returns null for slash-namespaced names (handled by other path)', () => { + expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null); + expect(um._composeProjectToRepo('library/nginx')).toBe(null); + expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null); + }); + + test('returns null for Docker Official Image names (no hyphen)', () => { + expect(um._composeProjectToRepo('nginx')).toBe(null); + expect(um._composeProjectToRepo('alpine')).toBe(null); + expect(um._composeProjectToRepo('node')).toBe(null); + }); + + test('returns null for empty / malformed input', () => { + expect(um._composeProjectToRepo('')).toBe(null); + expect(um._composeProjectToRepo(null)).toBe(null); + expect(um._composeProjectToRepo(undefined)).toBe(null); + expect(um._composeProjectToRepo(123)).toBe(null); + expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen + expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen + // The regex tolerates mixed-case via the /i flag for defensiveness + // even though Docker Compose names are typically lowercase — the + // important shape constraints are the letter/digit/underscore/hyphen + // charset and the non-empty two-part split. + }); + + test('accepts names with underscores and digits (compose allows)', () => { + expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service'); + expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2'); + }); + + test('rejects names with chars compose never produces', () => { + // dot/colon/slash should never pass — they're either already-namespaced + // or invalid in a Docker Compose service name. + expect(um._composeProjectToRepo('foo:bar')).toBe(null); + expect(um._composeProjectToRepo('foo.bar')).toBe(null); + expect(um._composeProjectToRepo('foo/bar')).toBe(null); + }); + }); + + describe('_isNotPublishedError', () => { + test('returns true for HTTP 401 + compose-prefixed remainder', () => { + const err = new Error('Docker Hub registry returned HTTP 401 after auth'); + expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true); + }); + + test('returns false for HTTP 401 with non-compose-prefixed remainder', () => { + const err = new Error('Docker Hub registry returned HTTP 401 after auth'); + expect(um._isNotPublishedError(err, 'nginx')).toBe(false); + expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false); + expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false); + }); + + test('returns false for non-401 errors', () => { + const err = new Error('network timeout after 10s'); + expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false); + }); + + test('returns false for malformed error or remainder', () => { + expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false); + expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false); + expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false); + expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false); + expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false); + }); + }); + + describe('getLatestImageDigest (mocked fetchWithReliability)', () => { + let originalFetch; + let originalFetchAuth; + let originalFetchRetry; + beforeEach(() => { + originalFetch = um.fetchWithReliability.bind(um); + originalFetchAuth = um.fetchAuthToken.bind(um); + }); + + test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => { + const calls = []; + um.fetchWithReliability = async (opts) => { + calls.push(opts); + // Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token + // (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub). + if (calls.length === 1) { + return { + statusCode: 401, + headers: { + 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"', + }, + body: '', + }; + } + return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' }; + }; + um.fetchAuthToken = async () => 'fake-token'; + const { log } = require('../src/utils/logging'); + const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {}); + const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {}); + + const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest'); + expect(result).toBe(null); + + // First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest + // NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest + const firstPath = calls[0].path; + expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest'); + expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api'); + + // The 401 after auth should produce an INFO log about "no upstream" + // NOT an error log. + const infoMsgs = infoSpy.mock.calls.map((c) => c[1]); + expect(infoMsgs).toContain('No upstream registry image — skipping update check'); + const errorMsgs = errorSpy.mock.calls.map((c) => c[1]); + expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth'); + + infoSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + test('official image (nginx) still probes library/nginx', async () => { + const calls = []; + um.fetchWithReliability = async (opts) => { + calls.push(opts); + if (calls.length === 1) { + return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' }; + } + return { statusCode: 200, headers: {}, body: '' }; + }; + + const result = await um.getLatestImageDigest('nginx:latest'); + expect(result).toBe('sha256:abc123'); + expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest'); + }); + + test('library/nginx (explicit) probes library/nginx', async () => { + const calls = []; + um.fetchWithReliability = async (opts) => { + calls.push(opts); + if (calls.length === 1) { + return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' }; + } + return { statusCode: 200, headers: {}, body: '' }; + }; + + const result = await um.getLatestImageDigest('library/nginx:latest'); + expect(result).toBe('sha256:abc'); + expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest'); + }); + + test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => { + const calls = []; + um.fetchWithReliability = async (opts) => { + calls.push(opts); + return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' }; + }; + + const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest'); + expect(result).toBe('sha256:ghcr'); + expect(calls[0].hostname).toBe('ghcr.io'); + expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest'); + }); + + test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => { + let callCount = 0; + um.fetchWithReliability = async (opts) => { + callCount += 1; + if (callCount === 1) { + return { + statusCode: 401, + headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' }, + body: '', + }; + } + return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' }; + }; + um.fetchAuthToken = async () => 'fake-token'; + + const result = await um.getLatestImageDigest('myproj-myservice:latest'); + expect(result).toBe(null); + // Probe targets the correct namespace (myproj/myservice), not library/. + const firstCall = await (async () => { + let p; + um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; }; + await um.getLatestImageDigest('myproj-myservice:latest'); + return p; + })(); + expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest'); + }); + + afterEach(() => { + um.fetchWithReliability = originalFetch; + um.fetchAuthToken = originalFetchAuth; + }); + }); +}); diff --git a/dashcaddy-api/src/managers/update-manager.js b/dashcaddy-api/src/managers/update-manager.js index b1cdaad..0c4c209 100644 --- a/dashcaddy-api/src/managers/update-manager.js +++ b/dashcaddy-api/src/managers/update-manager.js @@ -168,12 +168,39 @@ class UpdateManager extends EventEmitter { /** * Get latest image digest from registry + * + * DC-082: when the image name is a docker-compose prefixed name like + * `dashcaddy-dashcaddy-api:latest` (single hyphen-separated, no slash), + * the existing code normalized it to `library/dashcaddy-dashcaddy-api` + * before probing Docker Hub. The actual upstream namespace for a + * compose-prefixed image is `/` (with slash) — Docker + * Compose hyphenates the project name and service name when tagging + * locally. The pre-fix code probed the wrong repo, Docker Hub returned + * HTTP 401 (the repo doesn't exist), and the error log showed + * `Docker Hub registry returned HTTP 401 after auth` on every restart + * for the local dashcaddy-api image. The fix: split on the FIRST hyphen + * for compose-prefixed names so the lookup targets the correct + * namespace. + * + * Compose-prefixed shape: `^[a-z0-9]+-[a-z0-9][a-z0-9_-]*$` (no slash, + * lowercase, both halves non-empty). Examples: + * dashcaddy-dashcaddy-api -> dashcaddy/dashcaddy-api + * myproject-myservice -> myproject/myservice + * nginx -> library/nginx (official, unchanged) + * library/nginx -> library/nginx (official, unchanged) + * dashcaddy/some-image -> dashcaddy/some-image (already has slash) + * ghcr.io/x/y -> ghcr.io/x/y (handled below) */ async getLatestImageDigest(imageName) { + // DC-082: declare `remainder` at the function scope so the catch block + // can classify the error against the image-name shape (compose-prefixed + // local images produce a steady-state 401 that should log as info, not + // error). + let remainder = imageName; try { // Parse image name — strip any leading registry host first let imageTag = 'latest'; - let remainder = imageName; + remainder = imageName; const lastColon = imageName.lastIndexOf(':'); // Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`) const lastSlash = imageName.lastIndexOf('/'); @@ -187,8 +214,19 @@ class UpdateManager extends EventEmitter { return await this.getGhcrDigest(remainder, imageTag); } - // Docker Hub images (library/nginx OR org/image with single slash) - if (!remainder.includes('/') || remainder.split('/').length === 2) { + // Docker Hub images (library/nginx OR org/image with single slash). + // Special-case docker-compose prefixed names (single hyphen, no slash, + // lowercase) — split on the FIRST hyphen to recover the original + // `/` namespace. See DC-082. + if (!remainder.includes('/')) { + const composeRepo = this._composeProjectToRepo(remainder); + if (composeRepo) { + return await this.getDockerHubDigest(composeRepo, imageTag); + } + // Not a compose-prefixed name — fall through to the library/ default + return await this.getDockerHubDigest(remainder, imageTag); + } + if (remainder.split('/').length === 2) { return await this.getDockerHubDigest(remainder, imageTag); } @@ -196,11 +234,72 @@ class UpdateManager extends EventEmitter { log.warn('update', 'Custom registry not yet supported', { remainder }); return null; } catch (error) { + // DC-082: a "registry returned HTTP 401 after auth" against a + // compose-prefixed local image is the steady-state when the image + // is built locally and the upstream namespace on Docker Hub + // doesn't exist (or is private). The token endpoint returns 200 + // with an empty-access JWT, and the authed manifest GET 401s. + // Log these as a clean info not-found line instead of an error + // so dashboards and PagerDuty don't fire on every restart. + if (this._isNotPublishedError(error, remainder)) { + log.info('update', 'No upstream registry image — skipping update check', { imageName, remainder }); + return null; + } log.error('update', error, null, { imageName }); return null; } } + /** + * DC-082: split a docker-compose prefixed image name on the FIRST hyphen + * to recover the original `/` namespace. Returns null + * for names that don't match the compose-prefixed shape — callers fall + * through to the standard library/-prefixed official-image path. + * + * Compose-prefixed shape: + * - Contains exactly one or more hyphens + * - No slash + * - Lowercase letters / digits / hyphens / underscores only + * - Both halves (before first hyphen, after first hyphen) are non-empty + * - First char is a letter or digit (not a hyphen) + */ + _composeProjectToRepo(remainder) { + if (typeof remainder !== 'string' || remainder.length === 0) return null; + if (remainder.includes('/')) return null; // already namespaced + if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) { + // Not a compose-prefixed name — let the library/ path handle it + // (this is the official-image path: e.g. `nginx`, `alpine`). + return null; + } + const firstHyphen = remainder.indexOf('-'); + // Defensive: indexOf must find a hyphen (regex requires it), but guard + // against any future regex drift. + if (firstHyphen <= 0 || firstHyphen === remainder.length - 1) return null; + const project = remainder.substring(0, firstHyphen); + const service = remainder.substring(firstHyphen + 1); + if (!project || !service) return null; + return `${project}/${service}`; + } + + /** + * DC-082: detect the "registry returned 401 after auth" pattern that + * signals "this image has no public upstream on Docker Hub" (as opposed + * to a genuine auth failure or transient network error). Steady-state + * for compose-prefixed local images that aren't published. + */ + _isNotPublishedError(error, remainder) { + if (!error || typeof error.message !== 'string') return false; + if (!error.message.includes('HTTP 401')) return false; + // Constrain to the compose-prefixed path — a real auth failure on a + // legitimate `library/foo` or `namespace/foo` probe should still log + // as an error (it never auto-heals). + if (typeof remainder !== 'string' || remainder.includes('/')) return false; + if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) { + return false; + } + return true; + } + /** * Get image digest from GitHub Container Registry (ghcr.io) * Public images are tokenless via the registry-1.docker.io-style bearer flow,