The OCI handler had /v2/ baked into all regex patterns and Location headers. When basePath was set to /v2 (as in stack.gallery), stripping it removed the prefix that patterns expected, causing all OCI endpoints to 404. Now patterns match on bare paths after basePath stripping, working correctly regardless of the basePath value. Also adds configurable apiPrefix to OCI upstream class (default /v2) for registries behind reverse proxies with custom path prefixes.
403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
/**
|
|
* Native Docker CLI Testing
|
|
* Tests the OCI registry implementation using the actual Docker CLI
|
|
*/
|
|
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside';
|
|
import { SmartRegistry } from '../ts/index.js';
|
|
import type { IRequestContext, IResponse, IRegistryConfig } from '../ts/core/interfaces.core.js';
|
|
import * as qenv from '@push.rocks/qenv';
|
|
import * as http from 'http';
|
|
import * as url from 'url';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const testQenv = new qenv.Qenv('./', './.nogit');
|
|
|
|
/**
|
|
* Create a test registry with local token endpoint realm
|
|
*/
|
|
async function createDockerTestRegistry(port: number): Promise<SmartRegistry> {
|
|
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESSKEY');
|
|
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRETKEY');
|
|
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
|
|
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
|
|
|
const config: IRegistryConfig = {
|
|
storage: {
|
|
accessKey: s3AccessKey || 'minioadmin',
|
|
accessSecret: s3SecretKey || 'minioadmin',
|
|
endpoint: s3Endpoint || 'localhost',
|
|
port: parseInt(s3Port || '9000', 10),
|
|
useSsl: false,
|
|
region: 'us-east-1',
|
|
bucketName: 'test-registry',
|
|
},
|
|
auth: {
|
|
jwtSecret: 'test-secret-key',
|
|
tokenStore: 'memory',
|
|
npmTokens: {
|
|
enabled: true,
|
|
},
|
|
ociTokens: {
|
|
enabled: true,
|
|
realm: `http://localhost:${port}/v2/token`,
|
|
service: 'test-registry',
|
|
},
|
|
},
|
|
oci: {
|
|
enabled: true,
|
|
basePath: '/v2',
|
|
},
|
|
};
|
|
|
|
const reg = new SmartRegistry(config);
|
|
await reg.init();
|
|
return reg;
|
|
}
|
|
|
|
/**
|
|
* Create test tokens for the registry
|
|
*/
|
|
async function createDockerTestTokens(reg: SmartRegistry) {
|
|
const authManager = reg.getAuthManager();
|
|
|
|
const userId = await authManager.authenticate({
|
|
username: 'testuser',
|
|
password: 'testpass',
|
|
});
|
|
|
|
if (!userId) {
|
|
throw new Error('Failed to authenticate test user');
|
|
}
|
|
|
|
// Create OCI token with full access
|
|
const ociToken = await authManager.createOciToken(
|
|
userId,
|
|
['oci:repository:*:*'],
|
|
3600
|
|
);
|
|
|
|
return { ociToken, userId };
|
|
}
|
|
|
|
// Test context
|
|
let registry: SmartRegistry;
|
|
let server: http.Server;
|
|
let registryUrl: string;
|
|
let registryPort: number;
|
|
let ociToken: string;
|
|
let testDir: string;
|
|
let testImageName: string;
|
|
|
|
/**
|
|
* Create HTTP server wrapper around SmartRegistry
|
|
* CRITICAL: Always passes rawBody for content-addressable operations (OCI manifests/blobs)
|
|
*
|
|
* SmartRegistry OCI is configured with basePath '/v2' matching Docker's native /v2/ prefix.
|
|
*
|
|
* Also implements a simple /v2/token endpoint for Docker Bearer auth flow
|
|
*/
|
|
async function createHttpServer(
|
|
registryInstance: SmartRegistry,
|
|
port: number,
|
|
tokenForAuth: string
|
|
): Promise<{ server: http.Server; url: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const httpServer = http.createServer(async (req, res) => {
|
|
try {
|
|
// Parse request
|
|
const parsedUrl = url.parse(req.url || '', true);
|
|
let pathname = parsedUrl.pathname || '/';
|
|
const query = parsedUrl.query;
|
|
|
|
// Handle token endpoint for Docker Bearer auth
|
|
if (pathname === '/v2/token' || pathname === '/token') {
|
|
console.log(`[Token Request] ${req.method} ${req.url}`);
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({
|
|
token: tokenForAuth,
|
|
access_token: tokenForAuth,
|
|
expires_in: 3600,
|
|
issued_at: new Date().toISOString(),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Log all requests for debugging
|
|
console.log(`[Registry] ${req.method} ${pathname}`);
|
|
|
|
// basePath is /v2 which matches Docker's native /v2/ prefix — no rewrite needed
|
|
|
|
// Read raw body - ALWAYS preserve exact bytes for OCI
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(chunk);
|
|
}
|
|
const bodyBuffer = Buffer.concat(chunks);
|
|
|
|
// Parse body based on content type (for non-OCI protocols that need it)
|
|
let parsedBody: any;
|
|
if (bodyBuffer.length > 0) {
|
|
const contentType = req.headers['content-type'] || '';
|
|
if (contentType.includes('application/json')) {
|
|
try {
|
|
parsedBody = JSON.parse(bodyBuffer.toString('utf-8'));
|
|
} catch (error) {
|
|
parsedBody = bodyBuffer;
|
|
}
|
|
} else {
|
|
parsedBody = bodyBuffer;
|
|
}
|
|
}
|
|
|
|
// Convert to IRequestContext
|
|
const context: IRequestContext = {
|
|
method: req.method || 'GET',
|
|
path: pathname,
|
|
headers: req.headers as Record<string, string>,
|
|
query: query as Record<string, string>,
|
|
body: parsedBody,
|
|
rawBody: bodyBuffer,
|
|
};
|
|
|
|
// Handle request
|
|
const response: IResponse = await registryInstance.handleRequest(context);
|
|
console.log(`[Registry] Response: ${response.status} for ${pathname}`);
|
|
|
|
// Convert IResponse to HTTP response
|
|
res.statusCode = response.status;
|
|
|
|
// Set headers
|
|
for (const [key, value] of Object.entries(response.headers || {})) {
|
|
res.setHeader(key, value);
|
|
}
|
|
|
|
// Send body
|
|
if (response.body) {
|
|
if (Buffer.isBuffer(response.body)) {
|
|
res.end(response.body);
|
|
} else if (typeof response.body === 'string') {
|
|
res.end(response.body);
|
|
} else {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify(response.body));
|
|
}
|
|
} else {
|
|
res.end();
|
|
}
|
|
} catch (error) {
|
|
console.error('Server error:', error);
|
|
res.statusCode = 500;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message: String(error) }));
|
|
}
|
|
});
|
|
|
|
httpServer.listen(port, '0.0.0.0', () => {
|
|
const serverUrl = `http://localhost:${port}`;
|
|
resolve({ server: httpServer, url: serverUrl });
|
|
});
|
|
|
|
httpServer.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a test Dockerfile
|
|
*/
|
|
function createTestDockerfile(targetDir: string, content?: string): string {
|
|
const dockerfilePath = path.join(targetDir, 'Dockerfile');
|
|
const dockerfileContent = content || `FROM alpine:latest
|
|
RUN echo "Hello from SmartRegistry test" > /hello.txt
|
|
CMD ["cat", "/hello.txt"]
|
|
`;
|
|
fs.writeFileSync(dockerfilePath, dockerfileContent, 'utf-8');
|
|
return dockerfilePath;
|
|
}
|
|
|
|
/**
|
|
* Run Docker command using the main Docker daemon (not rootless)
|
|
* Rootless Docker runs in its own network namespace and can't access host localhost
|
|
*
|
|
* IMPORTANT: DOCKER_HOST env var overrides --context flag, so we must unset it
|
|
* and explicitly set the socket path to use the main Docker daemon.
|
|
*/
|
|
async function runDockerCommand(
|
|
command: string,
|
|
cwd?: string
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
// First unset DOCKER_HOST then set it to main Docker daemon socket
|
|
// Using both unset and export ensures we override any inherited env var
|
|
const dockerCommand = `unset DOCKER_HOST && export DOCKER_HOST=unix:///var/run/docker.sock && ${command}`;
|
|
const fullCommand = cwd ? `cd "${cwd}" && ${dockerCommand}` : dockerCommand;
|
|
|
|
try {
|
|
const result = await tapNodeTools.runCommand(fullCommand);
|
|
return {
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
exitCode: result.exitCode || 0,
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
stdout: error.stdout || '',
|
|
stderr: error.stderr || String(error),
|
|
exitCode: error.exitCode || 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup test directory
|
|
*/
|
|
function cleanupTestDir(dir: string): void {
|
|
if (fs.existsSync(dir)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup Docker resources
|
|
*/
|
|
async function cleanupDocker(imageName: string): Promise<void> {
|
|
await runDockerCommand(`docker rmi ${imageName} 2>/dev/null || true`);
|
|
await runDockerCommand(`docker rmi ${imageName}:v1 2>/dev/null || true`);
|
|
await runDockerCommand(`docker rmi ${imageName}:v2 2>/dev/null || true`);
|
|
}
|
|
|
|
// ========================================================================
|
|
// TESTS
|
|
// ========================================================================
|
|
|
|
tap.test('Docker CLI: should verify Docker is installed', async () => {
|
|
const result = await runDockerCommand('docker version');
|
|
console.log('Docker version output:', result.stdout.substring(0, 200));
|
|
expect(result.exitCode).toEqual(0);
|
|
});
|
|
|
|
tap.test('Docker CLI: should setup registry and HTTP server', async () => {
|
|
// Use localhost - Docker allows HTTP for localhost without any special config
|
|
registryPort = 15000 + Math.floor(Math.random() * 1000);
|
|
console.log(`Using port: ${registryPort}`);
|
|
|
|
registry = await createDockerTestRegistry(registryPort);
|
|
const tokens = await createDockerTestTokens(registry);
|
|
ociToken = tokens.ociToken;
|
|
|
|
expect(registry).toBeInstanceOf(SmartRegistry);
|
|
expect(ociToken).toBeTypeOf('string');
|
|
|
|
const serverSetup = await createHttpServer(registry, registryPort, ociToken);
|
|
server = serverSetup.server;
|
|
registryUrl = serverSetup.url;
|
|
|
|
expect(server).toBeDefined();
|
|
console.log(`Registry server started at ${registryUrl}`);
|
|
|
|
// Setup test directory
|
|
testDir = path.join(process.cwd(), '.nogit', 'test-docker-cli');
|
|
cleanupTestDir(testDir);
|
|
fs.mkdirSync(testDir, { recursive: true });
|
|
|
|
testImageName = `localhost:${registryPort}/test-image`;
|
|
});
|
|
|
|
tap.test('Docker CLI: should verify server is responding', async () => {
|
|
// Give the server a moment to fully initialize
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
const response = await fetch(`${registryUrl}/v2/`);
|
|
expect(response.status).toEqual(200);
|
|
console.log('OCI v2 response:', await response.json());
|
|
});
|
|
|
|
tap.test('Docker CLI: should login to registry', async () => {
|
|
const result = await runDockerCommand(
|
|
`echo "${ociToken}" | docker login localhost:${registryPort} -u testuser --password-stdin`
|
|
);
|
|
console.log('docker login output:', result.stdout);
|
|
console.log('docker login stderr:', result.stderr);
|
|
|
|
const combinedOutput = result.stdout + result.stderr;
|
|
expect(combinedOutput).toContain('Login Succeeded');
|
|
});
|
|
|
|
tap.test('Docker CLI: should build test image', async () => {
|
|
createTestDockerfile(testDir);
|
|
|
|
const result = await runDockerCommand(
|
|
`docker build -t ${testImageName}:v1 .`,
|
|
testDir
|
|
);
|
|
console.log('docker build output:', result.stdout.substring(0, 500));
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
});
|
|
|
|
tap.test('Docker CLI: should push image to registry', async () => {
|
|
// This is the critical test - if the digest mismatch bug is fixed,
|
|
// this should succeed. The manifest bytes must be preserved exactly.
|
|
const result = await runDockerCommand(`docker push ${testImageName}:v1`);
|
|
console.log('docker push output:', result.stdout);
|
|
console.log('docker push stderr:', result.stderr);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
});
|
|
|
|
tap.test('Docker CLI: should verify manifest in registry via API', async () => {
|
|
const response = await fetch(`${registryUrl}/v2/test-image/tags/list`, {
|
|
headers: { Authorization: `Bearer ${ociToken}` },
|
|
});
|
|
|
|
expect(response.status).toEqual(200);
|
|
|
|
const tagList = await response.json();
|
|
console.log('Tags list:', tagList);
|
|
|
|
expect(tagList.name).toEqual('test-image');
|
|
expect(tagList.tags).toContain('v1');
|
|
});
|
|
|
|
tap.test('Docker CLI: should pull pushed image', async () => {
|
|
// First remove the local image
|
|
await runDockerCommand(`docker rmi ${testImageName}:v1 || true`);
|
|
|
|
const result = await runDockerCommand(`docker pull ${testImageName}:v1`);
|
|
console.log('docker pull output:', result.stdout);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
});
|
|
|
|
tap.test('Docker CLI: should run pulled image', async () => {
|
|
const result = await runDockerCommand(`docker run --rm ${testImageName}:v1`);
|
|
console.log('docker run output:', result.stdout);
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
expect(result.stdout).toContain('Hello from SmartRegistry test');
|
|
});
|
|
|
|
tap.postTask('cleanup docker cli tests', async () => {
|
|
if (testImageName) {
|
|
await cleanupDocker(testImageName);
|
|
}
|
|
|
|
if (server) {
|
|
await new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
if (testDir) {
|
|
cleanupTestDir(testDir);
|
|
}
|
|
|
|
if (registry) {
|
|
registry.destroy();
|
|
}
|
|
});
|
|
|
|
export default tap.start();
|