diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb03b5790..353ead21a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,9 +122,9 @@ jobs: if: ${{ env.DOCKERHUB_PASSWORD && env.DOCKERHUB_USERNAME }} run: | echo "Login to Docker Hub";echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Run Barge working-directory: ${{ github.workspace }}/barge run: | @@ -145,6 +145,19 @@ jobs: - name: docker logs run: docker logs ocean-ocean-contracts-1 && docker logs ocean-typesense-1 if: ${{ failure() }} + - name: Set DOCKER_REGISTRY_AUTHS from Docker Hub secrets + if: env.DOCKERHUB_USERNAME && env.DOCKERHUB_PASSWORD + run: | + DOCKER_REGISTRY_AUTHS=$(jq -n \ + --arg username "$DOCKERHUB_USERNAME" \ + --arg password "$DOCKERHUB_PASSWORD" \ + '{ "https://registry-1.docker.io": { "username": $username, "password": $password } }') + echo "DOCKER_REGISTRY_AUTHS<> $GITHUB_ENV + echo "$DOCKER_REGISTRY_AUTHS" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} - name: integration tests run: npm run test:integration:cover env: @@ -162,6 +175,7 @@ jobs: FEE_AMOUNT: '{ "amount": 1, "unit": "MB" }' ASSET_PURGATORY_URL: 'https://raw.githubusercontent.com/oceanprotocol/list-purgatory/main/list-assets.json' ACCOUNT_PURGATORY_URL: 'https://raw.githubusercontent.com/oceanprotocol/list-purgatory/main/list-accounts.json' + DOCKER_REGISTRY_AUTHS: ${{ env.DOCKER_REGISTRY_AUTHS }} - name: docker logs run: docker logs ocean-ocean-contracts-1 && docker logs ocean-typesense-1 if: ${{ failure() }} @@ -237,7 +251,19 @@ jobs: repository: 'oceanprotocol/ocean-node' path: 'ocean-node' ref: ${{ github.event_name == 'pull_request' && github.head_ref || 'main' }} - + - name: Set DOCKER_REGISTRY_AUTHS from Docker Hub secrets + if: env.DOCKERHUB_USERNAME && env.DOCKERHUB_PASSWORD + run: | + DOCKER_REGISTRY_AUTHS=$(jq -n \ + --arg username "$DOCKERHUB_USERNAME" \ + --arg password "$DOCKERHUB_PASSWORD" \ + '{ "https://registry-1.docker.io": { "username": $username, "password": $password } }') + echo "DOCKER_REGISTRY_AUTHS<> $GITHUB_ENV + echo "$DOCKER_REGISTRY_AUTHS" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Start Ocean Node working-directory: ${{ github.workspace }}/ocean-node run: | @@ -265,6 +291,7 @@ jobs: MAX_REQ_PER_MINUTE: 320 MAX_CONNECTIONS_PER_MINUTE: 320 DOCKER_COMPUTE_ENVIRONMENTS: '[{"socketPath":"/var/run/docker.sock","resources":[{"id":"disk","total":10}],"storageExpiry":604800,"maxJobDuration":3600,"minJobDuration": 60,"fees":{"8996":[{"prices":[{"id":"cpu","price":1}]}]},"free":{"maxJobDuration":60,"minJobDuration": 10,"maxJobs":3,"resources":[{"id":"cpu","max":1},{"id":"ram","max":1},{"id":"disk","max":1}]}}]' + DOCKER_REGISTRY_AUTHS: ${{ env.DOCKER_REGISTRY_AUTHS }} - name: Check Ocean Node is running run: | for i in $(seq 1 90); do diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ad32a67c2..941c28408 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -39,7 +39,7 @@ jobs: - name: Login to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} + username: ${{ secrets.DOCKERHUB_PUSH_USERNAME }} password: ${{ secrets.DOCKER_PUSH_TOKEN }} - name: Set Docker metadata @@ -111,7 +111,7 @@ jobs: - name: Login to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} + username: ${{ secrets.DOCKERHUB_PUSH_USERNAME }} password: ${{ secrets.DOCKER_PUSH_TOKEN }} - name: Create manifest list and push working-directory: /tmp/digests diff --git a/docs/env.md b/docs/env.md index 520502578..59e41d03b 100644 --- a/docs/env.md +++ b/docs/env.md @@ -226,3 +226,38 @@ The `DOCKER_COMPUTE_ENVIRONMENTS` environment variable should be a JSON array of - **total**: Total number of the resource available. - **min**: Minimum number of the resource needed for a job. - **max**: Maximum number of the resource for a job. + +### Docker Registry Authentication + +- `DOCKER_REGISTRY_AUTHS`: JSON object mapping Docker registry URLs to authentication credentials. Used for accessing private Docker/OCI registries when validating and pulling Docker images. Each registry entry must provide either `username`+`password` or `auth`. Example: + +```json +{ + "https://registry-1.docker.io": { + "username": "myuser", + "password": "mypassword" + }, + "https://ghcr.io": { + "username": "myuser", + "password": "ghp_..." + }, + "https://registry.gitlab.com": { + "auth": "glpat-..." + } +} +``` + +**Configuration Options:** + +- **Registry URL** (key): The full registry URL including protocol (e.g., `https://registry-1.docker.io`, `https://ghcr.io`, `https://registry.gitlab.com`) +- **username** (optional): Username for registry authentication. Required if using password-based auth. +- **password** (optional): Password or personal access token for registry authentication. Required if using username-based auth. +- **auth** (optional): Authentication token (alternative to username+password). Required if not using username+password. + +**Notes:** + +- For Docker Hub (`registry-1.docker.io`), you can use your Docker Hub username and password, or a personal access token (PAT) as the password. +- For GitHub Container Registry (GHCR), use your GitHub username with a personal access token (PAT) as the password, or use a token directly. +- For GitLab Container Registry, use a personal access token (PAT) or deploy token. +- The registry URL must match exactly (including protocol) with the registry used in the Docker image reference. +- If no credentials are configured for a registry, the node will attempt unauthenticated access (works for public images only). diff --git a/src/@types/OceanNode.ts b/src/@types/OceanNode.ts index 9cda85acb..15b616dd4 100644 --- a/src/@types/OceanNode.ts +++ b/src/@types/OceanNode.ts @@ -93,8 +93,18 @@ export interface AccessListContract { [chainId: string]: string[] } +export interface dockerRegistryAuth { + username?: string + password?: string + auth?: string +} +export interface dockerRegistrysAuth { + [registry: string]: dockerRegistryAuth +} + export interface OceanNodeConfig { dockerComputeEnvironments: C2DDockerConfig[] + dockerRegistrysAuth: dockerRegistrysAuth authorizedDecrypters: string[] authorizedDecryptersList: AccessListContract | null allowedValidators: string[] diff --git a/src/components/c2d/compute_engine_base.ts b/src/components/c2d/compute_engine_base.ts index 13e49a6a4..ec0c21e8e 100644 --- a/src/components/c2d/compute_engine_base.ts +++ b/src/components/c2d/compute_engine_base.ts @@ -21,22 +21,27 @@ import { C2DClusterType } from '../../@types/C2D/C2D.js' import { C2DDatabase } from '../database/C2DDatabase.js' import { Escrow } from '../core/utils/escrow.js' import { KeyManager } from '../KeyManager/index.js' - +import { dockerRegistryAuth, dockerRegistrysAuth } from '../../@types/OceanNode.js' +import { ValidateParams } from '../httpRoutes/validateCommands.js' export abstract class C2DEngine { private clusterConfig: C2DClusterInfo public db: C2DDatabase public escrow: Escrow public keyManager: KeyManager + public dockerRegistryAuths: dockerRegistrysAuth + public constructor( cluster: C2DClusterInfo, db: C2DDatabase, escrow: Escrow, - keyManager: KeyManager + keyManager: KeyManager, + dockerRegistryAuths: dockerRegistrysAuth ) { this.clusterConfig = cluster this.db = db this.escrow = escrow this.keyManager = keyManager + this.dockerRegistryAuths = dockerRegistryAuths } getKeyManager(): KeyManager { @@ -66,6 +71,9 @@ export abstract class C2DEngine { return null } + // eslint-disable-next-line require-await + public abstract checkDockerImage(image: string, platform?: any): Promise + public abstract startComputeJob( assets: ComputeAsset[], algorithm: ComputeAlgorithm, @@ -523,4 +531,12 @@ export abstract class C2DEngine { } return cost } + + public getDockerRegistryAuth(registry: string): dockerRegistryAuth | null { + if (!this.dockerRegistryAuths) return null + if (this.dockerRegistryAuths[registry]) { + return this.dockerRegistryAuths[registry] + } + return null + } } diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index 7459e7ce2..7f3bd8814 100644 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -51,6 +51,7 @@ import { decryptFilesObject, omitDBComputeFieldsFromComputeJob } from './index.j import { ValidateParams } from '../httpRoutes/validateCommands.js' import { Service } from '@oceanprotocol/ddo-js' import { getOceanTokenAddressForChain } from '../../utils/address.js' +import { dockerRegistrysAuth } from '../../@types/OceanNode.js' export class C2DEngineDocker extends C2DEngine { private envs: ComputeEnvironment[] = [] @@ -69,9 +70,10 @@ export class C2DEngineDocker extends C2DEngine { clusterConfig: C2DClusterInfo, db: C2DDatabase, escrow: Escrow, - keyManager: KeyManager + keyManager: KeyManager, + dockerRegistryAuths: dockerRegistrysAuth ) { - super(clusterConfig, db, escrow, keyManager) + super(clusterConfig, db, escrow, keyManager, dockerRegistryAuths) this.docker = null if (clusterConfig.connection.socketPath) { @@ -438,7 +440,7 @@ export class C2DEngineDocker extends C2DEngine { return filteredEnvs } - private static parseImage(image: string) { + private parseImage(image: string) { let registry = C2DEngineDocker.DEFAULT_DOCKER_REGISTRY let name = image let ref = 'latest' @@ -472,13 +474,32 @@ export class C2DEngineDocker extends C2DEngine { return { registry, name, ref } } - public static async getDockerManifest(image: string): Promise { - const { registry, name, ref } = C2DEngineDocker.parseImage(image) + public async getDockerManifest(image: string): Promise { + const { registry, name, ref } = this.parseImage(image) const url = `${registry}/v2/${name}/manifests/${ref}` + + // Get registry auth from parent class + const dockerRegistryAuth = this.getDockerRegistryAuth(registry) + let headers: Record = { Accept: 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json' } + + // If we have auth credentials, add Basic auth header to initial request + if (dockerRegistryAuth) { + // Use auth string if available, otherwise encode username:password + const authString = dockerRegistryAuth.auth + ? dockerRegistryAuth.auth + : Buffer.from( + `${dockerRegistryAuth.username}:${dockerRegistryAuth.password}` + ).toString('base64') + headers.Authorization = `Basic ${authString}` + CORE_LOGGER.debug( + `Using docker registry auth for ${registry} to get manifest for image ${image}` + ) + } + let response = await fetch(url, { headers }) if (response.status === 401) { @@ -489,7 +510,22 @@ export class C2DEngineDocker extends C2DEngine { const tokenUrl = new URL(match[1]) tokenUrl.searchParams.set('service', match[2]) tokenUrl.searchParams.set('scope', `repository:${name}:pull`) - const { token } = (await fetch(tokenUrl.toString()).then((r) => r.json())) as { + + // Add Basic auth to token request if we have credentials + const tokenHeaders: Record = {} + if (dockerRegistryAuth) { + // Use auth string if available, otherwise encode username:password + const authString = dockerRegistryAuth.auth + ? dockerRegistryAuth.auth + : Buffer.from( + `${dockerRegistryAuth.username}:${dockerRegistryAuth.password}` + ).toString('base64') + tokenHeaders.Authorization = `Basic ${authString}` + } + + const { token } = (await fetch(tokenUrl.toString(), { + headers: tokenHeaders + }).then((r) => r.json())) as { token: string } headers = { ...headers, Authorization: `Bearer ${token}` } @@ -511,12 +547,12 @@ export class C2DEngineDocker extends C2DEngine { * @param image name or tag * @returns boolean */ - public static async checkDockerImage( + public async checkDockerImage( image: string, platform?: RunningPlatform ): Promise { try { - const manifest = await C2DEngineDocker.getDockerManifest(image) + const manifest = await this.getDockerManifest(image) const platforms = Array.isArray(manifest.manifests) ? manifest.manifests.map((entry: any) => entry.platform) @@ -647,7 +683,7 @@ export class C2DEngineDocker extends C2DEngine { } } else { // already built, we need to validate it - const validation = await C2DEngineDocker.checkDockerImage(image, env.platform) + const validation = await this.checkDockerImage(image, env.platform) if (!validation.valid) throw new Error( `Cannot find image ${image} for ${env.platform.architecture}. Maybe it does not exist or it's build for other arhitectures.` @@ -1644,7 +1680,40 @@ export class C2DEngineDocker extends C2DEngine { const imageLogFile = this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/logs/image.log' try { - const pullStream = await this.docker.pull(job.containerImage) + // Get registry auth for the image + const { registry } = this.parseImage(job.containerImage) + const dockerRegistryAuth = this.getDockerRegistryAuth(registry) + + // Prepare authconfig for Dockerode if credentials are available + const pullOptions: any = {} + if (dockerRegistryAuth) { + // Extract hostname from registry URL (remove protocol) + const registryUrl = new URL(registry) + const serveraddress = + registryUrl.hostname + (registryUrl.port ? `:${registryUrl.port}` : '') + + // Use auth string if available, otherwise encode username:password + const authString = dockerRegistryAuth.auth + ? dockerRegistryAuth.auth + : Buffer.from( + `${dockerRegistryAuth.username}:${dockerRegistryAuth.password}` + ).toString('base64') + + pullOptions.authconfig = { + serveraddress, + ...(dockerRegistryAuth.auth + ? { auth: authString } + : { + username: dockerRegistryAuth.username, + password: dockerRegistryAuth.password + }) + } + CORE_LOGGER.debug( + `Using docker registry auth for ${registry} to pull image ${job.containerImage}` + ) + } + + const pullStream = await this.docker.pull(job.containerImage, pullOptions) await new Promise((resolve, reject) => { let wroteStatusBanner = false this.docker.modem.followProgress( diff --git a/src/components/c2d/compute_engines.ts b/src/components/c2d/compute_engines.ts index 24d7cd46b..26ad035f9 100644 --- a/src/components/c2d/compute_engines.ts +++ b/src/components/c2d/compute_engines.ts @@ -5,9 +5,9 @@ import { OceanNodeConfig } from '../../@types/OceanNode.js' import { C2DDatabase } from '../database/C2DDatabase.js' import { Escrow } from '../core/utils/escrow.js' import { KeyManager } from '../KeyManager/index.js' + export class C2DEngines { public engines: C2DEngine[] - public constructor( config: OceanNodeConfig, db: C2DDatabase, @@ -23,7 +23,15 @@ export class C2DEngines { this.engines = [] for (const cluster of config.c2dClusters) { if (cluster.type === C2DClusterType.DOCKER) { - this.engines.push(new C2DEngineDocker(cluster, db, escrow, keyManager)) + this.engines.push( + new C2DEngineDocker( + cluster, + db, + escrow, + keyManager, + config.dockerRegistrysAuth + ) + ) } } } diff --git a/src/components/core/compute/initialize.ts b/src/components/core/compute/initialize.ts index 607dd59a8..7d4d74a76 100644 --- a/src/components/core/compute/initialize.ts +++ b/src/components/core/compute/initialize.ts @@ -28,7 +28,8 @@ import { sanitizeServiceFiles } from '../../../utils/util.js' import { FindDdoHandler } from '../handler/ddoHandler.js' import { isOrderingAllowedForAsset } from '../handler/downloadHandler.js' import { getNonceAsNumber } from '../utils/nonceHandler.js' -import { C2DEngineDocker, getAlgorithmImage } from '../../c2d/compute_engine_docker.js' +import { getAlgorithmImage } from '../../c2d/compute_engine_docker.js' + import { Credentials, DDOManager } from '@oceanprotocol/ddo-js' import { checkCredentials } from '../../../utils/credentials.js' import { PolicyServer } from '../../policyServer/index.js' @@ -387,7 +388,7 @@ export class ComputeInitializeHandler extends CommandHandler { if (hasDockerImages) { const algoImage = getAlgorithmImage(task.algorithm, generateUniqueID(task)) if (algoImage) { - const validation: ValidateParams = await C2DEngineDocker.checkDockerImage( + const validation: ValidateParams = await engine.checkDockerImage( algoImage, env.platform ) diff --git a/src/test/integration/dockerRegistryAuth.test.ts b/src/test/integration/dockerRegistryAuth.test.ts new file mode 100644 index 000000000..f35660a3e --- /dev/null +++ b/src/test/integration/dockerRegistryAuth.test.ts @@ -0,0 +1,398 @@ +/* eslint-disable no-unused-expressions */ +/** + * Integration test for Docker registry authentication functionality. + * + * Tests the getDockerManifest method with: + * - Public images (no credentials) + * - Registry auth configuration (username/password and auth string) + * - Error handling + */ +import { expect, assert } from 'chai' +import { C2DEngineDocker } from '../../components/c2d/compute_engine_docker.js' +import { C2DClusterInfo, C2DClusterType } from '../../@types/C2D/C2D.js' +import { dockerRegistrysAuth } from '../../@types/OceanNode.js' +import { DockerRegistryAuthSchema } from '../../utils/config/schemas.js' + +describe('Docker Registry Authentication Integration Tests', () => { + describe('Public registry access (no credentials)', () => { + it('should successfully fetch manifest for public Docker Hub image', async () => { + // Create minimal engine instance for testing + const clusterConfig: C2DClusterInfo = { + type: C2DClusterType.DOCKER, + hash: 'test-cluster-hash', + connection: { + socketPath: '/var/run/docker.sock' + }, + tempFolder: '/tmp/test-docker' + } + + // Mock minimal dependencies - we only need getDockerManifest + const dockerEngine = new C2DEngineDocker( + clusterConfig, + null as any, + null as any, + null as any, + {} // No auth config + ) + + // Test with a well-known public image + const image = 'library/alpine:latest' + const manifest = await dockerEngine.getDockerManifest(image) + + expect(manifest).to.exist + expect(manifest).to.have.property('schemaVersion') + expect(manifest).to.have.property('mediaType') + }).timeout(10000) + + it('should successfully fetch manifest for public image with explicit tag', async () => { + const clusterConfig: C2DClusterInfo = { + type: C2DClusterType.DOCKER, + hash: 'test-cluster-hash-2', + connection: { + socketPath: '/var/run/docker.sock' + }, + tempFolder: '/tmp/test-docker-2' + } + + const dockerEngine = new C2DEngineDocker( + clusterConfig, + null as any, + null as any, + null as any, + {} + ) + + // Use a simple image reference that will default to Docker Hub + const image = 'hello-world:latest' + const manifest = await dockerEngine.getDockerManifest(image) + + expect(manifest).to.exist + expect(manifest).to.have.property('schemaVersion') + }).timeout(10000) + }) + + describe('Registry authentication configuration', () => { + it('should store and retrieve username/password credentials', () => { + const testAuth: dockerRegistrysAuth = { + 'https://registry-1.docker.io': { + username: 'testuser', + password: 'testpass', + auth: '' + } + } + + const clusterConfig: C2DClusterInfo = { + type: C2DClusterType.DOCKER, + hash: 'test-cluster-hash-auth', + connection: { + socketPath: '/var/run/docker.sock' + }, + tempFolder: '/tmp/test-docker-auth' + } + + const engineWithAuth = new C2DEngineDocker( + clusterConfig, + null as any, + null as any, + null as any, + testAuth + ) + + // Verify that getDockerRegistryAuth returns the credentials + const auth = (engineWithAuth as any).getDockerRegistryAuth( + 'https://registry-1.docker.io' + ) + expect(auth).to.exist + expect(auth?.username).to.equal('testuser') + expect(auth?.password).to.equal('testpass') + }) + + it('should use auth string when provided', () => { + const preEncodedAuth = Buffer.from('testuser:testpass').toString('base64') + const testAuth: dockerRegistrysAuth = { + 'https://registry-1.docker.io': { + username: 'testuser', + password: 'testpass', + auth: preEncodedAuth + } + } + + const clusterConfig: C2DClusterInfo = { + type: C2DClusterType.DOCKER, + hash: 'test-cluster-hash-auth2', + connection: { + socketPath: '/var/run/docker.sock' + }, + tempFolder: '/tmp/test-docker-auth2' + } + + const engineWithAuth = new C2DEngineDocker( + clusterConfig, + null as any, + null as any, + null as any, + testAuth + ) + + const auth = (engineWithAuth as any).getDockerRegistryAuth( + 'https://registry-1.docker.io' + ) + expect(auth).to.exist + expect(auth?.auth).to.equal(preEncodedAuth) + }) + + it('should return null for non-existent registry auth', () => { + const clusterConfig: C2DClusterInfo = { + type: C2DClusterType.DOCKER, + hash: 'test-cluster-hash-3', + connection: { + socketPath: '/var/run/docker.sock' + }, + tempFolder: '/tmp/test-docker-3' + } + + const dockerEngine = new C2DEngineDocker( + clusterConfig, + null as any, + null as any, + null as any, + {} + ) + + const auth = (dockerEngine as any).getDockerRegistryAuth( + 'https://nonexistent-registry.com' + ) + expect(auth).to.be.null + }) + + it('should handle multiple registry configurations', () => { + const testAuth: dockerRegistrysAuth = { + 'https://registry-1.docker.io': { + username: 'user1', + password: 'pass1', + auth: '' + }, + 'https://ghcr.io': { + username: 'user2', + password: 'pass2', + auth: '' + } + } + + const clusterConfig: C2DClusterInfo = { + type: C2DClusterType.DOCKER, + hash: 'test-cluster-hash-multi', + connection: { + socketPath: '/var/run/docker.sock' + }, + tempFolder: '/tmp/test-docker-multi' + } + + const engineWithAuth = new C2DEngineDocker( + clusterConfig, + null as any, + null as any, + null as any, + testAuth + ) + + const dockerHubAuth = (engineWithAuth as any).getDockerRegistryAuth( + 'https://registry-1.docker.io' + ) + expect(dockerHubAuth).to.exist + expect(dockerHubAuth?.username).to.equal('user1') + + const ghcrAuth = (engineWithAuth as any).getDockerRegistryAuth('https://ghcr.io') + expect(ghcrAuth).to.exist + expect(ghcrAuth?.username).to.equal('user2') + + const unknownAuth = (engineWithAuth as any).getDockerRegistryAuth( + 'https://unknown-registry.com' + ) + expect(unknownAuth).to.be.null + }) + }) + + describe('Error handling', () => { + it('should handle invalid image references gracefully', async () => { + const clusterConfig: C2DClusterInfo = { + type: C2DClusterType.DOCKER, + hash: 'test-cluster-hash-error', + connection: { + socketPath: '/var/run/docker.sock' + }, + tempFolder: '/tmp/test-docker-error' + } + + const dockerEngine = new C2DEngineDocker( + clusterConfig, + null as any, + null as any, + null as any, + {} + ) + + try { + await dockerEngine.getDockerManifest('invalid-image-reference') + assert.fail('Should have thrown an error for invalid image') + } catch (error: any) { + expect(error).to.exist + expect(error.message).to.include('Failed to get manifest') + } + }).timeout(10000) + }) + + describe('DockerRegistryAuthSchema validation', () => { + it('should validate schema with only auth string', () => { + const authData = { + auth: Buffer.from('testuser:testpass').toString('base64') + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.true + if (result.success) { + expect(result.data.auth).to.equal(authData.auth) + expect(result.data.username).to.be.undefined + expect(result.data.password).to.be.undefined + } + }) + + it('should validate schema with only username and password', () => { + const authData = { + username: 'testuser', + password: 'testpass' + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.true + if (result.success) { + expect(result.data.username).to.equal('testuser') + expect(result.data.password).to.equal('testpass') + expect(result.data.auth).to.be.undefined + } + }) + + it('should validate schema with all three fields (auth, username, password)', () => { + const authData = { + username: 'testuser', + password: 'testpass', + auth: Buffer.from('testuser:testpass').toString('base64') + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.true + if (result.success) { + expect(result.data.username).to.equal('testuser') + expect(result.data.password).to.equal('testpass') + expect(result.data.auth).to.equal(authData.auth) + } + }) + + it('should reject schema with only username (missing password)', () => { + const authData = { + username: 'testuser' + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.false + if (!result.success) { + expect(result.error.errors).to.exist + expect(result.error.errors[0].message).to.include( + "Either 'auth' must be provided, or both 'username' and 'password' must be provided" + ) + } + }) + + it('should reject schema with only password (missing username)', () => { + const authData = { + password: 'testpass' + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.false + if (!result.success) { + expect(result.error.errors).to.exist + expect(result.error.errors[0].message).to.include( + "Either 'auth' must be provided, or both 'username' and 'password' must be provided" + ) + } + }) + + it('should reject empty object', () => { + const authData = {} + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.false + if (!result.success) { + expect(result.error.errors).to.exist + expect(result.error.errors[0].message).to.include( + "Either 'auth' must be provided, or both 'username' and 'password' must be provided" + ) + } + }) + + it('should reject empty auth string with no username/password', () => { + const authData = { + auth: '' + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.false + if (!result.success) { + expect(result.error.errors).to.exist + expect(result.error.errors[0].message).to.include( + "Either 'auth' must be provided, or both 'username' and 'password' must be provided" + ) + } + }) + + it('should reject empty username with password provided', () => { + const authData = { + username: '', + password: 'testpass' + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.false + if (!result.success) { + expect(result.error.errors).to.exist + expect(result.error.errors[0].message).to.include( + "Either 'auth' must be provided, or both 'username' and 'password' must be provided" + ) + } + }) + + it('should reject empty password with username provided', () => { + const authData = { + username: 'testuser', + password: '' + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.false + if (!result.success) { + expect(result.error.errors).to.exist + expect(result.error.errors[0].message).to.include( + "Either 'auth' must be provided, or both 'username' and 'password' must be provided" + ) + } + }) + + it('should reject all empty strings', () => { + const authData = { + username: '', + password: '', + auth: '' + } + + const result = DockerRegistryAuthSchema.safeParse(authData) + expect(result.success).to.be.false + if (!result.success) { + expect(result.error.errors).to.exist + expect(result.error.errors[0].message).to.include( + "Either 'auth' must be provided, or both 'username' and 'password' must be provided" + ) + } + }) + }) +}) diff --git a/src/test/integration/imageCleanup.test.ts b/src/test/integration/imageCleanup.test.ts index 00245525b..ab3b5304c 100644 --- a/src/test/integration/imageCleanup.test.ts +++ b/src/test/integration/imageCleanup.test.ts @@ -188,7 +188,7 @@ describe('Docker Image Cleanup Integration Tests', () => { escrow = {} as Escrow keyManager = {} as KeyManager - dockerEngine = new C2DEngineDocker(clusterConfig, db, escrow, keyManager) + dockerEngine = new C2DEngineDocker(clusterConfig, db, escrow, keyManager, {}) }) it('should track image usage when image is pulled', async () => { diff --git a/src/utils/config/constants.ts b/src/utils/config/constants.ts index 8f99b8bb4..5d9c9cdad 100644 --- a/src/utils/config/constants.ts +++ b/src/utils/config/constants.ts @@ -31,6 +31,7 @@ export const ENV_TO_CONFIG_MAPPING = { ALLOWED_ADMINS: 'allowedAdmins', ALLOWED_ADMINS_LIST: 'allowedAdminsList', DOCKER_COMPUTE_ENVIRONMENTS: 'dockerComputeEnvironments', + DOCKER_REGISTRY_AUTHS: 'dockerRegistrysAuth', P2P_BOOTSTRAP_NODES: 'p2pConfig.bootstrapNodes', P2P_BOOTSTRAP_TIMEOUT: 'p2pConfig.bootstrapTimeout', P2P_BOOTSTRAP_TAGNAME: 'p2pConfig.bootstrapTagName', diff --git a/src/utils/config/schemas.ts b/src/utils/config/schemas.ts index 1139b69c5..4e905871a 100644 --- a/src/utils/config/schemas.ts +++ b/src/utils/config/schemas.ts @@ -84,6 +84,31 @@ export const OceanNodeDBConfigSchema = z.object({ dbType: z.string().nullable() }) +export const DockerRegistryAuthSchema = z + .object({ + username: z.string().optional(), + password: z.string().optional(), + auth: z.string().optional() + }) + .refine( + (data) => { + // Either 'auth' is provided, OR both 'username' and 'password' are provided + return ( + (data.auth !== undefined && data.auth !== '') || + (data.username !== undefined && + data.username !== '' && + data.password !== undefined && + data.password !== '') + ) + }, + { + message: + "Either 'auth' must be provided, or both 'username' and 'password' must be provided" + } + ) + +export const DockerRegistrysSchema = z.record(z.string(), DockerRegistryAuthSchema) + export const ComputeResourceSchema = z.object({ id: z.string(), total: z.number().optional(), @@ -256,6 +281,8 @@ export const OceanNodeConfigSchema = z .optional() .default([]), + dockerRegistrysAuth: jsonFromString(DockerRegistrysSchema).optional().default({}), + authorizedDecrypters: addressArrayFromString.optional().default([]), authorizedDecryptersList: jsonFromString(AccessListContractSchema).optional(), diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 632efb277..028a12d15 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -400,6 +400,11 @@ export const ENVIRONMENT_VARIABLES: Record = { value: process.env.DOCKER_COMPUTE_ENVIRONMENTS, required: false }, + DOCKER_REGISTRY_AUTHS: { + name: 'DOCKER_REGISTRY_AUTHS', + value: process.env.DOCKER_REGISTRY_AUTHS, + required: false + }, DOCKER_SOCKET_PATH: { name: 'DOCKER_SOCKET_PATH', value: process.env.DOCKER_SOCKET_PATH,