nulti registry support
This commit is contained in:
@@ -42,5 +42,9 @@
|
|||||||
],
|
],
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {}
|
"overrides": {}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
|
"@push.rocks/smartbucket": "^3.3.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -7,6 +7,13 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
'@push.rocks/qenv':
|
||||||
|
specifier: ^6.1.3
|
||||||
|
version: 6.1.3
|
||||||
|
'@push.rocks/smartbucket':
|
||||||
|
specifier: ^3.3.10
|
||||||
|
version: 3.3.10
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsbuild':
|
'@git.zone/tsbuild':
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
@@ -2922,6 +2929,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-5RJYU5zWFXTQ5iRXAo75vlhK5ybZOyqEyg/szw2VtHc6ZOPcC7ruX4nnXk1OqqlY56Z7XT+WCFhV+/XPj4QwtQ==}
|
resolution: {integrity: sha512-5RJYU5zWFXTQ5iRXAo75vlhK5ybZOyqEyg/szw2VtHc6ZOPcC7ruX4nnXk1OqqlY56Z7XT+WCFhV+/XPj4QwtQ==}
|
||||||
engines: {node: '>=20.18.0'}
|
engines: {node: '>=20.18.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
bundledDependencies: []
|
||||||
|
|
||||||
peek-readable@5.4.2:
|
peek-readable@5.4.2:
|
||||||
resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==}
|
resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==}
|
||||||
|
|||||||
173
readme.md
173
readme.md
@@ -1,7 +1,174 @@
|
|||||||
# @push.rocks/smartregistry
|
# @push.rocks/smartregistry
|
||||||
|
|
||||||
a registry for npm modules and oci images
|
A TypeScript library implementing the OCI Distribution Specification v1.1 for building container and artifact registries.
|
||||||
|
|
||||||
## How to create the docs
|
## Features
|
||||||
|
|
||||||
To create docs run gitzone aidoc.
|
- **OCI Distribution Spec v1.1 Compliant**: Implements all required and optional endpoints
|
||||||
|
- **Cloud-Agnostic Storage**: Uses @push.rocks/smartbucket for S3-compatible object storage
|
||||||
|
- **Pluggable Authentication**: Async callbacks for login and authorization
|
||||||
|
- **Bearer Token Auth**: JWT-based authentication following Docker Registry Token Authentication spec
|
||||||
|
- **Programmatic API**: Use as a library in any Node.js/TypeScript application
|
||||||
|
- **Full CRUD Operations**: Push, pull, list, and delete manifests and blobs
|
||||||
|
- **Content Discovery**: Tag listing and referrers API for artifact relationships
|
||||||
|
- **Chunked Uploads**: Support for large blob uploads with resumable sessions
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @push.rocks/smartregistry
|
||||||
|
# or
|
||||||
|
pnpm add @push.rocks/smartregistry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SmartRegistry, IRegistryConfig, TLoginCallback, TAuthCallback } from '@push.rocks/smartregistry';
|
||||||
|
|
||||||
|
// Implement login callback
|
||||||
|
const loginCallback: TLoginCallback = async (credentials) => {
|
||||||
|
// Validate credentials and return JWT token
|
||||||
|
// This should create a proper JWT with required claims
|
||||||
|
return generateJWT(credentials.username);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Implement authorization callback
|
||||||
|
const authCallback: TAuthCallback = async (token, repository, action) => {
|
||||||
|
// Validate token and check permissions
|
||||||
|
const claims = verifyJWT(token);
|
||||||
|
return hasPermission(claims, repository, action);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure registry
|
||||||
|
const config: IRegistryConfig = {
|
||||||
|
storage: {
|
||||||
|
accessKey: 'your-s3-access-key',
|
||||||
|
accessSecret: 'your-s3-secret',
|
||||||
|
endpoint: 's3.amazonaws.com',
|
||||||
|
port: 443,
|
||||||
|
useSsl: true,
|
||||||
|
region: 'us-east-1',
|
||||||
|
bucketName: 'my-registry',
|
||||||
|
},
|
||||||
|
serviceName: 'my-registry',
|
||||||
|
tokenRealm: 'https://auth.example.com/token',
|
||||||
|
loginCallback,
|
||||||
|
authCallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create and initialize registry
|
||||||
|
const registry = new SmartRegistry(config);
|
||||||
|
await registry.init();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with HTTP Server
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// OCI Distribution API endpoints
|
||||||
|
app.get('/v2/', (req, res) => {
|
||||||
|
res.status(200).json({});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v2/:name(*)/manifests/:reference', async (req, res) => {
|
||||||
|
const { name, reference } = req.params;
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
const result = await registry.getManifest(name, reference, token);
|
||||||
|
|
||||||
|
if ('errors' in result) {
|
||||||
|
return res.status(404).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', result.contentType);
|
||||||
|
res.setHeader('Docker-Content-Digest', result.digest);
|
||||||
|
res.send(result.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v2/:name(*)/blobs/:digest', async (req, res) => {
|
||||||
|
const { name, digest } = req.params;
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
const result = await registry.getBlob(name, digest, token);
|
||||||
|
|
||||||
|
if ('errors' in result) {
|
||||||
|
return res.status(404).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
|
res.send(result.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ... implement other endpoints
|
||||||
|
|
||||||
|
app.listen(5000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Client requests without token
|
||||||
|
const challenge = registry.getAuthChallenge('library/nginx', ['pull', 'push']);
|
||||||
|
// Returns: Bearer realm="https://auth.example.com/token",service="my-registry",scope="repository:library/nginx:pull,push"
|
||||||
|
|
||||||
|
// Client authenticates
|
||||||
|
const token = await registry.login({ username: 'user', password: 'pass' });
|
||||||
|
|
||||||
|
// Client uses token for subsequent requests
|
||||||
|
const manifest = await registry.getManifest('library/nginx', 'latest', token);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Pull Operations (Required)
|
||||||
|
|
||||||
|
- `getManifest(repository, reference, token?)` - Download a manifest
|
||||||
|
- `headManifest(repository, reference, token?)` - Check manifest existence
|
||||||
|
- `getBlob(repository, digest, token?, range?)` - Download a blob
|
||||||
|
- `headBlob(repository, digest, token?)` - Check blob existence
|
||||||
|
|
||||||
|
### Push Operations
|
||||||
|
|
||||||
|
- `initiateUpload(repository, token, mountDigest?, fromRepository?)` - Start blob upload
|
||||||
|
- `uploadChunk(uploadId, data, contentRange, token)` - Upload blob chunk
|
||||||
|
- `completeUpload(uploadId, digest, token, finalData?)` - Finalize blob upload
|
||||||
|
- `putManifest(repository, reference, manifest, contentType, token)` - Upload manifest
|
||||||
|
|
||||||
|
### Content Discovery
|
||||||
|
|
||||||
|
- `listTags(repository, token?, pagination?)` - List all tags
|
||||||
|
- `getReferrers(repository, digest, token?, artifactType?)` - Get referencing artifacts
|
||||||
|
|
||||||
|
### Content Management
|
||||||
|
|
||||||
|
- `deleteManifest(repository, digest, token)` - Delete manifest
|
||||||
|
- `deleteBlob(repository, digest, token)` - Delete blob
|
||||||
|
- `deleteTag(repository, tag, token)` - Delete tag
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- `login(credentials)` - Get authentication token
|
||||||
|
- `getAuthChallenge(repository, actions)` - Generate WWW-Authenticate header
|
||||||
|
|
||||||
|
## OCI Specification Compliance
|
||||||
|
|
||||||
|
This library implements:
|
||||||
|
|
||||||
|
- **Pull Category** (required): All manifest and blob retrieval operations
|
||||||
|
- **Push Category**: Complete blob upload workflow with chunked and monolithic modes
|
||||||
|
- **Content Discovery**: Tag listing and referrers API
|
||||||
|
- **Content Management**: Deletion operations for manifests, blobs, and tags
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See the main repository for contribution guidelines.
|
||||||
|
|||||||
212
test/test.ts
212
test/test.ts
@@ -1,8 +1,214 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartregistry from '../ts/index.js';
|
import * as smartregistry from '../ts/index.js';
|
||||||
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
|
||||||
tap.test('first test', async () => {
|
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||||
console.log(smartregistry);
|
|
||||||
|
let registry: smartregistry.SmartRegistry;
|
||||||
|
let testToken: string;
|
||||||
|
|
||||||
|
tap.test('should create SmartRegistry instance', async () => {
|
||||||
|
// Create mock callbacks for testing
|
||||||
|
const loginCallback: smartregistry.TLoginCallback = async (credentials) => {
|
||||||
|
// Simple mock: return a fake JWT token
|
||||||
|
const tokenPayload = {
|
||||||
|
iss: 'test-registry',
|
||||||
|
sub: credentials.username,
|
||||||
|
aud: 'test-service',
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: 'repository' as const,
|
||||||
|
name: 'test/repo',
|
||||||
|
actions: ['*'] as smartregistry.TRegistryAction[],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// In production, this would be a real JWT
|
||||||
|
return JSON.stringify(tokenPayload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const authCallback: smartregistry.TAuthCallback = async (token, repository, action) => {
|
||||||
|
// Simple mock: allow all actions for testing
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(token);
|
||||||
|
// Check if token has access to the repository
|
||||||
|
const hasAccess = payload.access.some(
|
||||||
|
(acc: any) =>
|
||||||
|
acc.name === repository &&
|
||||||
|
(acc.actions.includes(action) || acc.actions.includes('*'))
|
||||||
|
);
|
||||||
|
return hasAccess;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read S3 config from env.json
|
||||||
|
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESS_KEY');
|
||||||
|
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRET_KEY');
|
||||||
|
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
|
||||||
|
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
||||||
|
|
||||||
|
const config: smartregistry.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',
|
||||||
|
},
|
||||||
|
serviceName: 'test-registry',
|
||||||
|
tokenRealm: 'https://auth.example.com/token',
|
||||||
|
loginCallback,
|
||||||
|
authCallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry = new smartregistry.SmartRegistry(config);
|
||||||
|
await registry.init();
|
||||||
|
|
||||||
|
expect(registry).toBeInstanceOf(smartregistry.SmartRegistry);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.start();
|
tap.test('should login and get token', async () => {
|
||||||
|
testToken = await registry.login({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpass',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testToken).toBeTypeOf('string');
|
||||||
|
expect(testToken.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should upload a blob via chunked upload', async () => {
|
||||||
|
const testData = Buffer.from('Hello, OCI Registry!', 'utf-8');
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const digest = `sha256:${crypto.createHash('sha256').update(testData).digest('hex')}`;
|
||||||
|
|
||||||
|
// Initiate upload
|
||||||
|
const initResult = await registry.initiateUpload('test/repo', testToken);
|
||||||
|
expect(initResult).toHaveProperty('uploadId');
|
||||||
|
|
||||||
|
if ('uploadId' in initResult) {
|
||||||
|
const uploadId = initResult.uploadId;
|
||||||
|
|
||||||
|
// Upload chunk
|
||||||
|
const chunkResult = await registry.uploadChunk(
|
||||||
|
uploadId,
|
||||||
|
testData,
|
||||||
|
`0-${testData.length - 1}`,
|
||||||
|
testToken
|
||||||
|
);
|
||||||
|
expect(chunkResult).toHaveProperty('location');
|
||||||
|
|
||||||
|
// Complete upload
|
||||||
|
const completeResult = await registry.completeUpload(uploadId, digest, testToken);
|
||||||
|
expect(completeResult).toHaveProperty('digest');
|
||||||
|
if ('digest' in completeResult) {
|
||||||
|
expect(completeResult.digest).toEqual(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should retrieve a blob', async () => {
|
||||||
|
const testData = Buffer.from('Hello, OCI Registry!', 'utf-8');
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const digest = `sha256:${crypto.createHash('sha256').update(testData).digest('hex')}`;
|
||||||
|
|
||||||
|
const result = await registry.getBlob('test/repo', digest, testToken);
|
||||||
|
expect(result).toHaveProperty('data');
|
||||||
|
|
||||||
|
if ('data' in result) {
|
||||||
|
expect(result.data.toString('utf-8')).toEqual('Hello, OCI Registry!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should check if blob exists (HEAD)', async () => {
|
||||||
|
const testData = Buffer.from('Hello, OCI Registry!', 'utf-8');
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const digest = `sha256:${crypto.createHash('sha256').update(testData).digest('hex')}`;
|
||||||
|
|
||||||
|
const result = await registry.headBlob('test/repo', digest, testToken);
|
||||||
|
expect(result).toHaveProperty('exists');
|
||||||
|
|
||||||
|
if ('exists' in result) {
|
||||||
|
expect(result.exists).toEqual(true);
|
||||||
|
expect(result.size).toEqual(testData.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should upload a manifest', async () => {
|
||||||
|
const testManifest: smartregistry.IOciManifest = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
config: {
|
||||||
|
mediaType: 'application/vnd.oci.image.config.v1+json',
|
||||||
|
size: 123,
|
||||||
|
digest: 'sha256:' + '0'.repeat(64),
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
|
||||||
|
size: 456,
|
||||||
|
digest: 'sha256:' + '1'.repeat(64),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await registry.putManifest(
|
||||||
|
'test/repo',
|
||||||
|
'latest',
|
||||||
|
testManifest,
|
||||||
|
'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
testToken
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('digest');
|
||||||
|
if ('digest' in result) {
|
||||||
|
expect(result.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should retrieve a manifest by tag', async () => {
|
||||||
|
const result = await registry.getManifest('test/repo', 'latest', testToken);
|
||||||
|
expect(result).toHaveProperty('data');
|
||||||
|
|
||||||
|
if ('data' in result) {
|
||||||
|
const manifest = JSON.parse(result.data.toString('utf-8'));
|
||||||
|
expect(manifest).toHaveProperty('schemaVersion');
|
||||||
|
expect(manifest.schemaVersion).toEqual(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should list tags', async () => {
|
||||||
|
const result = await registry.listTags('test/repo', testToken);
|
||||||
|
expect(result).toHaveProperty('tags');
|
||||||
|
|
||||||
|
if ('tags' in result) {
|
||||||
|
expect(result.tags).toBeInstanceOf(Array);
|
||||||
|
expect(result.tags).toContain('latest');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should generate auth challenge', async () => {
|
||||||
|
const challenge = registry.getAuthChallenge('test/repo', ['pull', 'push']);
|
||||||
|
expect(challenge).toInclude('Bearer');
|
||||||
|
expect(challenge).toInclude('realm=');
|
||||||
|
expect(challenge).toInclude('service=');
|
||||||
|
expect(challenge).toInclude('scope=');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle unauthorized access', async () => {
|
||||||
|
const result = await registry.getBlob('test/repo', 'sha256:invalid', 'invalid-token');
|
||||||
|
expect(result).toHaveProperty('errors');
|
||||||
|
|
||||||
|
if ('errors' in result) {
|
||||||
|
expect(result.errors[0].code).toEqual('DENIED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
|
|||||||
311
ts/classes.registrystorage.ts
Normal file
311
ts/classes.registrystorage.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { IRegistryConfig, IOciManifest, IOciImageIndex, ITagList } from './interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage layer for OCI registry using SmartBucket
|
||||||
|
*/
|
||||||
|
export class RegistryStorage {
|
||||||
|
private smartBucket: plugins.smartbucket.SmartBucket;
|
||||||
|
private bucket: plugins.smartbucket.Bucket;
|
||||||
|
private bucketName: string;
|
||||||
|
|
||||||
|
constructor(private config: IRegistryConfig['storage']) {
|
||||||
|
this.bucketName = config.bucketName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the storage backend
|
||||||
|
*/
|
||||||
|
public async init() {
|
||||||
|
this.smartBucket = new plugins.smartbucket.SmartBucket({
|
||||||
|
accessKey: this.config.accessKey,
|
||||||
|
accessSecret: this.config.accessSecret,
|
||||||
|
endpoint: this.config.endpoint,
|
||||||
|
port: this.config.port || 443,
|
||||||
|
useSsl: this.config.useSsl !== false,
|
||||||
|
region: this.config.region || 'us-east-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure bucket exists
|
||||||
|
await this.smartBucket.createBucket(this.bucketName).catch(() => {
|
||||||
|
// Bucket may already exist, that's fine
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bucket = await this.smartBucket.getBucketByName(this.bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a blob
|
||||||
|
* @param digest - Content digest (e.g., "sha256:abc123...")
|
||||||
|
* @param data - Blob data
|
||||||
|
*/
|
||||||
|
public async putBlob(digest: string, data: Buffer): Promise<void> {
|
||||||
|
const path = this.getBlobPath(digest);
|
||||||
|
await this.bucket.fastPut({
|
||||||
|
path,
|
||||||
|
contents: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a blob
|
||||||
|
* @param digest - Content digest
|
||||||
|
* @returns Blob data or null if not found
|
||||||
|
*/
|
||||||
|
public async getBlob(digest: string): Promise<Buffer | null> {
|
||||||
|
const path = this.getBlobPath(digest);
|
||||||
|
try {
|
||||||
|
return await this.bucket.fastGet({ path });
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if blob exists
|
||||||
|
* @param digest - Content digest
|
||||||
|
* @returns true if exists
|
||||||
|
*/
|
||||||
|
public async blobExists(digest: string): Promise<boolean> {
|
||||||
|
const path = this.getBlobPath(digest);
|
||||||
|
return await this.bucket.fastExists({ path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a blob
|
||||||
|
* @param digest - Content digest
|
||||||
|
*/
|
||||||
|
public async deleteBlob(digest: string): Promise<void> {
|
||||||
|
const path = this.getBlobPath(digest);
|
||||||
|
await this.bucket.fastRemove({ path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a manifest
|
||||||
|
* @param repository - Repository name (e.g., "library/nginx")
|
||||||
|
* @param reference - Tag or digest
|
||||||
|
* @param manifest - Manifest content
|
||||||
|
* @param contentType - Manifest media type
|
||||||
|
*/
|
||||||
|
public async putManifest(
|
||||||
|
repository: string,
|
||||||
|
reference: string,
|
||||||
|
manifest: IOciManifest | IOciImageIndex,
|
||||||
|
contentType: string
|
||||||
|
): Promise<string> {
|
||||||
|
const manifestJson = JSON.stringify(manifest);
|
||||||
|
const manifestBuffer = Buffer.from(manifestJson, 'utf-8');
|
||||||
|
|
||||||
|
// Calculate digest
|
||||||
|
const digest = await this.calculateDigest(manifestBuffer);
|
||||||
|
|
||||||
|
// Store by digest
|
||||||
|
const digestPath = this.getManifestPath(repository, digest);
|
||||||
|
await this.bucket.fastPut({
|
||||||
|
path: digestPath,
|
||||||
|
contents: manifestBuffer,
|
||||||
|
meta: { 'Content-Type': contentType },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If reference is a tag (not a digest), create/update tag mapping
|
||||||
|
if (!reference.startsWith('sha256:')) {
|
||||||
|
await this.putTag(repository, reference, digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a manifest
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param reference - Tag or digest
|
||||||
|
* @returns Manifest data and content type, or null if not found
|
||||||
|
*/
|
||||||
|
public async getManifest(
|
||||||
|
repository: string,
|
||||||
|
reference: string
|
||||||
|
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||||
|
let digest = reference;
|
||||||
|
|
||||||
|
// If reference is a tag, resolve to digest
|
||||||
|
if (!reference.startsWith('sha256:')) {
|
||||||
|
const resolvedDigest = await this.getTagDigest(repository, reference);
|
||||||
|
if (!resolvedDigest) return null;
|
||||||
|
digest = resolvedDigest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = this.getManifestPath(repository, digest);
|
||||||
|
try {
|
||||||
|
const data = await this.bucket.fastGet({ path });
|
||||||
|
// TODO: Retrieve content type from metadata if SmartBucket supports it
|
||||||
|
const contentType = 'application/vnd.oci.image.manifest.v1+json';
|
||||||
|
return { data, contentType };
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if manifest exists
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param reference - Tag or digest
|
||||||
|
* @returns true if exists
|
||||||
|
*/
|
||||||
|
public async manifestExists(repository: string, reference: string): Promise<boolean> {
|
||||||
|
let digest = reference;
|
||||||
|
|
||||||
|
// If reference is a tag, resolve to digest
|
||||||
|
if (!reference.startsWith('sha256:')) {
|
||||||
|
const resolvedDigest = await this.getTagDigest(repository, reference);
|
||||||
|
if (!resolvedDigest) return false;
|
||||||
|
digest = resolvedDigest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = this.getManifestPath(repository, digest);
|
||||||
|
return await this.bucket.fastExists({ path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a manifest
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param digest - Manifest digest (must be digest, not tag)
|
||||||
|
*/
|
||||||
|
public async deleteManifest(repository: string, digest: string): Promise<void> {
|
||||||
|
const path = this.getManifestPath(repository, digest);
|
||||||
|
await this.bucket.fastRemove({ path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store tag mapping
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param tag - Tag name
|
||||||
|
* @param digest - Manifest digest
|
||||||
|
*/
|
||||||
|
public async putTag(repository: string, tag: string, digest: string): Promise<void> {
|
||||||
|
const tags = await this.getTags(repository);
|
||||||
|
tags[tag] = digest;
|
||||||
|
|
||||||
|
const path = this.getTagsPath(repository);
|
||||||
|
await this.bucket.fastPut({
|
||||||
|
path,
|
||||||
|
contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get digest for a tag
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param tag - Tag name
|
||||||
|
* @returns Digest or null if tag doesn't exist
|
||||||
|
*/
|
||||||
|
public async getTagDigest(repository: string, tag: string): Promise<string | null> {
|
||||||
|
const tags = await this.getTags(repository);
|
||||||
|
return tags[tag] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all tags for a repository
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @returns Tag list
|
||||||
|
*/
|
||||||
|
public async listTags(repository: string): Promise<string[]> {
|
||||||
|
const tags = await this.getTags(repository);
|
||||||
|
return Object.keys(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a tag
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param tag - Tag name
|
||||||
|
*/
|
||||||
|
public async deleteTag(repository: string, tag: string): Promise<void> {
|
||||||
|
const tags = await this.getTags(repository);
|
||||||
|
delete tags[tag];
|
||||||
|
|
||||||
|
const path = this.getTagsPath(repository);
|
||||||
|
await this.bucket.fastPut({
|
||||||
|
path,
|
||||||
|
contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all manifests that reference a specific digest (referrers API)
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param digest - Subject digest
|
||||||
|
* @returns Array of manifest digests
|
||||||
|
*/
|
||||||
|
public async getReferrers(repository: string, digest: string): Promise<string[]> {
|
||||||
|
// This is a simplified implementation
|
||||||
|
// In production, you'd want to maintain an index
|
||||||
|
const referrersPath = this.getReferrersPath(repository, digest);
|
||||||
|
try {
|
||||||
|
const data = await this.bucket.fastGet({ path: referrersPath });
|
||||||
|
const referrers = JSON.parse(data.toString('utf-8'));
|
||||||
|
return referrers;
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a referrer relationship
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param subjectDigest - Digest being referenced
|
||||||
|
* @param referrerDigest - Digest of the referrer
|
||||||
|
*/
|
||||||
|
public async addReferrer(
|
||||||
|
repository: string,
|
||||||
|
subjectDigest: string,
|
||||||
|
referrerDigest: string
|
||||||
|
): Promise<void> {
|
||||||
|
const referrers = await this.getReferrers(repository, subjectDigest);
|
||||||
|
if (!referrers.includes(referrerDigest)) {
|
||||||
|
referrers.push(referrerDigest);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = this.getReferrersPath(repository, subjectDigest);
|
||||||
|
await this.bucket.fastPut({
|
||||||
|
path,
|
||||||
|
contents: Buffer.from(JSON.stringify(referrers, null, 2), 'utf-8'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
|
||||||
|
private getBlobPath(digest: string): string {
|
||||||
|
// Remove algorithm prefix for path (sha256:abc -> abc)
|
||||||
|
const hash = digest.split(':')[1];
|
||||||
|
return `blobs/sha256/${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getManifestPath(repository: string, digest: string): string {
|
||||||
|
const hash = digest.split(':')[1];
|
||||||
|
return `manifests/${repository}/${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTagsPath(repository: string): string {
|
||||||
|
return `tags/${repository}/tags.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReferrersPath(repository: string, digest: string): string {
|
||||||
|
const hash = digest.split(':')[1];
|
||||||
|
return `referrers/${repository}/${hash}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTags(repository: string): Promise<{ [tag: string]: string }> {
|
||||||
|
const path = this.getTagsPath(repository);
|
||||||
|
try {
|
||||||
|
const data = await this.bucket.fastGet({ path });
|
||||||
|
return JSON.parse(data.toString('utf-8'));
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async calculateDigest(data: Buffer): Promise<string> {
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
return `sha256:${hash}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
747
ts/classes.smartregistry.ts
Normal file
747
ts/classes.smartregistry.ts
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { RegistryStorage } from './classes.registrystorage.js';
|
||||||
|
import {
|
||||||
|
IRegistryConfig,
|
||||||
|
IUploadSession,
|
||||||
|
IOciManifest,
|
||||||
|
IOciImageIndex,
|
||||||
|
ITagList,
|
||||||
|
IReferrersResponse,
|
||||||
|
IRegistryError,
|
||||||
|
IPaginationOptions,
|
||||||
|
TRegistryAction,
|
||||||
|
} from './interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main OCI Distribution Specification compliant registry class
|
||||||
|
* This class provides all the methods needed to implement an OCI registry
|
||||||
|
* and can be integrated into any HTTP server
|
||||||
|
*/
|
||||||
|
export class SmartRegistry {
|
||||||
|
private storage: RegistryStorage;
|
||||||
|
private config: IRegistryConfig;
|
||||||
|
private uploadSessions: Map<string, IUploadSession> = new Map();
|
||||||
|
private initialized: boolean = false;
|
||||||
|
|
||||||
|
constructor(config: IRegistryConfig) {
|
||||||
|
this.config = config;
|
||||||
|
this.storage = new RegistryStorage(config.storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the registry (must be called before use)
|
||||||
|
*/
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
await this.storage.init();
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
// Start cleanup of stale upload sessions
|
||||||
|
this.startUploadSessionCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PULL OPERATIONS (Required by OCI spec)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v2/{name}/manifests/{reference}
|
||||||
|
* Retrieve a manifest by tag or digest
|
||||||
|
* @param repository - Repository name (e.g., "library/nginx")
|
||||||
|
* @param reference - Tag name or digest
|
||||||
|
* @param token - Optional bearer token for authentication
|
||||||
|
* @returns Manifest content and metadata
|
||||||
|
*/
|
||||||
|
public async getManifest(
|
||||||
|
repository: string,
|
||||||
|
reference: string,
|
||||||
|
token?: string
|
||||||
|
): Promise<{ data: Buffer; contentType: string; digest: string } | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
if (token) {
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'pull');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.storage.getManifest(repository, reference);
|
||||||
|
if (!result) {
|
||||||
|
return this.createError('MANIFEST_UNKNOWN', 'Manifest not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate digest if not already known
|
||||||
|
const digest = await this.calculateDigest(result.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data,
|
||||||
|
contentType: result.contentType,
|
||||||
|
digest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEAD /v2/{name}/manifests/{reference}
|
||||||
|
* Check if a manifest exists without downloading it
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param reference - Tag name or digest
|
||||||
|
* @param token - Optional bearer token
|
||||||
|
* @returns Metadata if exists, error otherwise
|
||||||
|
*/
|
||||||
|
public async headManifest(
|
||||||
|
repository: string,
|
||||||
|
reference: string,
|
||||||
|
token?: string
|
||||||
|
): Promise<{ exists: true; digest: string; contentType: string } | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
if (token) {
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'pull');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await this.storage.manifestExists(repository, reference);
|
||||||
|
if (!exists) {
|
||||||
|
return this.createError('MANIFEST_UNKNOWN', 'Manifest not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get manifest to calculate digest and content type
|
||||||
|
const result = await this.storage.getManifest(repository, reference);
|
||||||
|
if (!result) {
|
||||||
|
return this.createError('MANIFEST_UNKNOWN', 'Manifest not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = await this.calculateDigest(result.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
digest,
|
||||||
|
contentType: result.contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v2/{name}/blobs/{digest}
|
||||||
|
* Download a blob
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param digest - Blob digest
|
||||||
|
* @param token - Optional bearer token
|
||||||
|
* @param range - Optional HTTP range header (e.g., "bytes=0-1023")
|
||||||
|
* @returns Blob data
|
||||||
|
*/
|
||||||
|
public async getBlob(
|
||||||
|
repository: string,
|
||||||
|
digest: string,
|
||||||
|
token?: string,
|
||||||
|
range?: string
|
||||||
|
): Promise<{ data: Buffer; contentType: string } | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
if (token) {
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'pull');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.storage.getBlob(digest);
|
||||||
|
if (!data) {
|
||||||
|
return this.createError('BLOB_UNKNOWN', 'Blob not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle range requests
|
||||||
|
let responseData = data;
|
||||||
|
if (range) {
|
||||||
|
const rangeMatch = range.match(/bytes=(\d+)-(\d*)/);
|
||||||
|
if (rangeMatch) {
|
||||||
|
const start = parseInt(rangeMatch[1], 10);
|
||||||
|
const end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) + 1 : data.length;
|
||||||
|
responseData = data.slice(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: responseData,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEAD /v2/{name}/blobs/{digest}
|
||||||
|
* Check if a blob exists
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param digest - Blob digest
|
||||||
|
* @param token - Optional bearer token
|
||||||
|
* @returns Metadata if exists
|
||||||
|
*/
|
||||||
|
public async headBlob(
|
||||||
|
repository: string,
|
||||||
|
digest: string,
|
||||||
|
token?: string
|
||||||
|
): Promise<{ exists: true; size: number } | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
if (token) {
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'pull');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await this.storage.blobExists(digest);
|
||||||
|
if (!exists) {
|
||||||
|
return this.createError('BLOB_UNKNOWN', 'Blob not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get blob to determine size
|
||||||
|
const data = await this.storage.getBlob(digest);
|
||||||
|
if (!data) {
|
||||||
|
return this.createError('BLOB_UNKNOWN', 'Blob not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
size: data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PUSH OPERATIONS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v2/{name}/blobs/uploads/
|
||||||
|
* Initiate a blob upload session
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param token - Bearer token
|
||||||
|
* @param mountDigest - Optional digest to mount from another repository
|
||||||
|
* @param fromRepository - Source repository for mount
|
||||||
|
* @returns Upload session ID and location
|
||||||
|
*/
|
||||||
|
public async initiateUpload(
|
||||||
|
repository: string,
|
||||||
|
token: string,
|
||||||
|
mountDigest?: string,
|
||||||
|
fromRepository?: string
|
||||||
|
): Promise<{ uploadId: string; location: string } | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'push');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle blob mount if requested
|
||||||
|
if (mountDigest && fromRepository) {
|
||||||
|
const mountResult = await this.mountBlob(
|
||||||
|
repository,
|
||||||
|
mountDigest,
|
||||||
|
fromRepository,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
if ('location' in mountResult) {
|
||||||
|
return mountResult;
|
||||||
|
}
|
||||||
|
// If mount fails, continue with normal upload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create upload session
|
||||||
|
const uploadId = this.generateUploadId();
|
||||||
|
const session: IUploadSession = {
|
||||||
|
uploadId,
|
||||||
|
repository,
|
||||||
|
chunks: [],
|
||||||
|
totalSize: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
lastActivity: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.uploadSessions.set(uploadId, session);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadId,
|
||||||
|
location: `/v2/${repository}/blobs/uploads/${uploadId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /v2/{name}/blobs/uploads/{uuid}
|
||||||
|
* Upload a chunk of data to an upload session
|
||||||
|
* @param uploadId - Upload session ID
|
||||||
|
* @param data - Chunk data
|
||||||
|
* @param contentRange - Content-Range header (e.g., "0-1023")
|
||||||
|
* @param token - Bearer token
|
||||||
|
* @returns Updated upload status
|
||||||
|
*/
|
||||||
|
public async uploadChunk(
|
||||||
|
uploadId: string,
|
||||||
|
data: Buffer,
|
||||||
|
contentRange: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ location: string; range: string } | IRegistryError> {
|
||||||
|
const session = this.uploadSessions.get(uploadId);
|
||||||
|
if (!session) {
|
||||||
|
return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
const authorized = await this.config.authCallback(token, session.repository, 'push');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse content range
|
||||||
|
const rangeMatch = contentRange.match(/(\d+)-(\d+)/);
|
||||||
|
if (!rangeMatch) {
|
||||||
|
return this.createError('BLOB_UPLOAD_INVALID', 'Invalid content range');
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = parseInt(rangeMatch[1], 10);
|
||||||
|
const end = parseInt(rangeMatch[2], 10);
|
||||||
|
|
||||||
|
// Validate sequential upload
|
||||||
|
if (start !== session.totalSize) {
|
||||||
|
return this.createError(
|
||||||
|
'BLOB_UPLOAD_INVALID',
|
||||||
|
'Chunks must be uploaded sequentially'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add chunk
|
||||||
|
session.chunks.push(data);
|
||||||
|
session.totalSize += data.length;
|
||||||
|
session.lastActivity = new Date();
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: `/v2/${session.repository}/blobs/uploads/${uploadId}`,
|
||||||
|
range: `0-${session.totalSize - 1}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /v2/{name}/blobs/uploads/{uuid}?digest={digest}
|
||||||
|
* Complete a chunked upload or upload a monolithic blob
|
||||||
|
* @param uploadId - Upload session ID (use 'monolithic' for single request upload)
|
||||||
|
* @param digest - Final blob digest
|
||||||
|
* @param token - Bearer token
|
||||||
|
* @param finalData - Optional final chunk data
|
||||||
|
* @returns Blob location
|
||||||
|
*/
|
||||||
|
public async completeUpload(
|
||||||
|
uploadId: string,
|
||||||
|
digest: string,
|
||||||
|
token: string,
|
||||||
|
finalData?: Buffer
|
||||||
|
): Promise<{ location: string; digest: string } | IRegistryError> {
|
||||||
|
let repository: string;
|
||||||
|
let blobData: Buffer;
|
||||||
|
|
||||||
|
if (uploadId === 'monolithic') {
|
||||||
|
// Monolithic upload - data is in finalData
|
||||||
|
if (!finalData) {
|
||||||
|
return this.createError('BLOB_UPLOAD_INVALID', 'No data provided');
|
||||||
|
}
|
||||||
|
// For monolithic uploads, we need repository from somewhere
|
||||||
|
// This is a simplified version - in practice, you'd pass repository explicitly
|
||||||
|
blobData = finalData;
|
||||||
|
repository = 'temp'; // This needs to be properly handled
|
||||||
|
} else {
|
||||||
|
// Chunked upload
|
||||||
|
const session = this.uploadSessions.get(uploadId);
|
||||||
|
if (!session) {
|
||||||
|
return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
repository = session.repository;
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'push');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all chunks
|
||||||
|
const chunks = [...session.chunks];
|
||||||
|
if (finalData) {
|
||||||
|
chunks.push(finalData);
|
||||||
|
}
|
||||||
|
blobData = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Clean up session
|
||||||
|
this.uploadSessions.delete(uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify digest
|
||||||
|
const calculatedDigest = await this.calculateDigest(blobData);
|
||||||
|
if (calculatedDigest !== digest) {
|
||||||
|
return this.createError('DIGEST_INVALID', 'Digest mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store blob
|
||||||
|
await this.storage.putBlob(digest, blobData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: `/v2/${repository}/blobs/${digest}`,
|
||||||
|
digest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v2/{name}/blobs/uploads/{uuid}
|
||||||
|
* Get the status of an upload session
|
||||||
|
* @param uploadId - Upload session ID
|
||||||
|
* @param token - Bearer token
|
||||||
|
* @returns Upload status
|
||||||
|
*/
|
||||||
|
public async getUploadStatus(
|
||||||
|
uploadId: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ location: string; range: string } | IRegistryError> {
|
||||||
|
const session = this.uploadSessions.get(uploadId);
|
||||||
|
if (!session) {
|
||||||
|
return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
const authorized = await this.config.authCallback(token, session.repository, 'push');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: `/v2/${session.repository}/blobs/uploads/${uploadId}`,
|
||||||
|
range: session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /v2/{name}/manifests/{reference}
|
||||||
|
* Upload a manifest
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param reference - Tag or digest
|
||||||
|
* @param manifest - Manifest object
|
||||||
|
* @param contentType - Manifest media type
|
||||||
|
* @param token - Bearer token
|
||||||
|
* @returns Manifest location and digest
|
||||||
|
*/
|
||||||
|
public async putManifest(
|
||||||
|
repository: string,
|
||||||
|
reference: string,
|
||||||
|
manifest: IOciManifest | IOciImageIndex,
|
||||||
|
contentType: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ location: string; digest: string } | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'push');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store manifest
|
||||||
|
const digest = await this.storage.putManifest(repository, reference, manifest, contentType);
|
||||||
|
|
||||||
|
// If manifest has a subject, add referrer relationship
|
||||||
|
if ('subject' in manifest && manifest.subject) {
|
||||||
|
await this.storage.addReferrer(repository, manifest.subject.digest, digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: `/v2/${repository}/manifests/${digest}`,
|
||||||
|
digest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CONTENT DISCOVERY
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v2/{name}/tags/list
|
||||||
|
* List all tags for a repository
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param token - Optional bearer token
|
||||||
|
* @param pagination - Pagination options
|
||||||
|
* @returns Tag list
|
||||||
|
*/
|
||||||
|
public async listTags(
|
||||||
|
repository: string,
|
||||||
|
token?: string,
|
||||||
|
pagination?: IPaginationOptions
|
||||||
|
): Promise<ITagList | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
if (token) {
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'pull');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = await this.storage.listTags(repository);
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
if (pagination) {
|
||||||
|
tags.sort();
|
||||||
|
|
||||||
|
if (pagination.last) {
|
||||||
|
const lastIndex = tags.indexOf(pagination.last);
|
||||||
|
if (lastIndex >= 0) {
|
||||||
|
tags = tags.slice(lastIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pagination.n) {
|
||||||
|
tags = tags.slice(0, pagination.n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: repository,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v2/{name}/referrers/{digest}
|
||||||
|
* Get manifests that reference a specific digest
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param digest - Subject digest
|
||||||
|
* @param token - Optional bearer token
|
||||||
|
* @param artifactType - Optional filter by artifact type
|
||||||
|
* @returns Referrers list
|
||||||
|
*/
|
||||||
|
public async getReferrers(
|
||||||
|
repository: string,
|
||||||
|
digest: string,
|
||||||
|
token?: string,
|
||||||
|
artifactType?: string
|
||||||
|
): Promise<IReferrersResponse | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
if (token) {
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'pull');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrerDigests = await this.storage.getReferrers(repository, digest);
|
||||||
|
|
||||||
|
// Build response with manifest descriptors
|
||||||
|
const manifests = [];
|
||||||
|
for (const refDigest of referrerDigests) {
|
||||||
|
const result = await this.storage.getManifest(repository, refDigest);
|
||||||
|
if (result) {
|
||||||
|
const manifest = JSON.parse(result.data.toString('utf-8'));
|
||||||
|
|
||||||
|
// Apply artifact type filter if specified
|
||||||
|
if (artifactType && manifest.artifactType !== artifactType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
manifests.push({
|
||||||
|
mediaType: result.contentType,
|
||||||
|
size: result.data.length,
|
||||||
|
digest: refDigest,
|
||||||
|
artifactType: manifest.artifactType,
|
||||||
|
annotations: manifest.annotations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
mediaType: 'application/vnd.oci.image.index.v1+json',
|
||||||
|
manifests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CONTENT MANAGEMENT (Deletion)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /v2/{name}/manifests/{digest}
|
||||||
|
* Delete a manifest by digest
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param digest - Manifest digest (must be digest, not tag)
|
||||||
|
* @param token - Bearer token
|
||||||
|
* @returns Success or error
|
||||||
|
*/
|
||||||
|
public async deleteManifest(
|
||||||
|
repository: string,
|
||||||
|
digest: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ success: true } | IRegistryError> {
|
||||||
|
// Ensure reference is a digest, not a tag
|
||||||
|
if (!digest.startsWith('sha256:')) {
|
||||||
|
return this.createError(
|
||||||
|
'UNSUPPORTED',
|
||||||
|
'Manifest deletion requires digest reference'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'delete');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if manifest exists
|
||||||
|
const exists = await this.storage.manifestExists(repository, digest);
|
||||||
|
if (!exists) {
|
||||||
|
return this.createError('MANIFEST_UNKNOWN', 'Manifest not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the manifest
|
||||||
|
await this.storage.deleteManifest(repository, digest);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /v2/{name}/blobs/{digest}
|
||||||
|
* Delete a blob
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param digest - Blob digest
|
||||||
|
* @param token - Bearer token
|
||||||
|
* @returns Success or error
|
||||||
|
*/
|
||||||
|
public async deleteBlob(
|
||||||
|
repository: string,
|
||||||
|
digest: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ success: true } | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'delete');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if blob exists
|
||||||
|
const exists = await this.storage.blobExists(digest);
|
||||||
|
if (!exists) {
|
||||||
|
return this.createError('BLOB_UNKNOWN', 'Blob not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the blob
|
||||||
|
await this.storage.deleteBlob(digest);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /v2/{name}/tags/{reference}
|
||||||
|
* Delete a tag
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param tag - Tag name
|
||||||
|
* @param token - Bearer token
|
||||||
|
* @returns Success or error
|
||||||
|
*/
|
||||||
|
public async deleteTag(
|
||||||
|
repository: string,
|
||||||
|
tag: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ success: true } | IRegistryError> {
|
||||||
|
// Check authorization
|
||||||
|
const authorized = await this.config.authCallback(token, repository, 'delete');
|
||||||
|
if (!authorized) {
|
||||||
|
return this.createError('DENIED', 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the tag
|
||||||
|
await this.storage.deleteTag(repository, tag);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// AUTHENTICATION HELPERS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate WWW-Authenticate challenge header for 401 responses
|
||||||
|
* @param repository - Repository name
|
||||||
|
* @param actions - Required actions
|
||||||
|
* @returns WWW-Authenticate header value
|
||||||
|
*/
|
||||||
|
public getAuthChallenge(repository: string, actions: TRegistryAction[]): string {
|
||||||
|
const scope = `repository:${repository}:${actions.join(',')}`;
|
||||||
|
return `Bearer realm="${this.config.tokenRealm}",service="${this.config.serviceName}",scope="${scope}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle login request
|
||||||
|
* @param credentials - User credentials
|
||||||
|
* @returns JWT token
|
||||||
|
*/
|
||||||
|
public async login(credentials: { username: string; password: string }): Promise<string> {
|
||||||
|
return await this.config.loginCallback(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount a blob from another repository
|
||||||
|
*/
|
||||||
|
private async mountBlob(
|
||||||
|
targetRepository: string,
|
||||||
|
digest: string,
|
||||||
|
sourceRepository: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ location: string; digest: string } | IRegistryError> {
|
||||||
|
// Check if blob exists in source
|
||||||
|
const exists = await this.storage.blobExists(digest);
|
||||||
|
if (!exists) {
|
||||||
|
return this.createError('BLOB_UNKNOWN', 'Source blob not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a true cross-repository mount, you'd verify the blob belongs to sourceRepository
|
||||||
|
// For simplicity, we're just checking if it exists
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: `/v2/${targetRepository}/blobs/${digest}`,
|
||||||
|
digest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique upload session ID
|
||||||
|
*/
|
||||||
|
private generateUploadId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate SHA256 digest of data
|
||||||
|
*/
|
||||||
|
private async calculateDigest(data: Buffer): Promise<string> {
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
return `sha256:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a standard registry error response
|
||||||
|
*/
|
||||||
|
private createError(code: string, message: string, detail?: any): IRegistryError {
|
||||||
|
return {
|
||||||
|
errors: [{ code, message, detail }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic cleanup of stale upload sessions
|
||||||
|
*/
|
||||||
|
private startUploadSessionCleanup(): void {
|
||||||
|
// Clean up sessions older than 1 hour every 10 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const maxAge = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
for (const [uploadId, session] of this.uploadSessions.entries()) {
|
||||||
|
if (now.getTime() - session.lastActivity.getTime() > maxAge) {
|
||||||
|
this.uploadSessions.delete(uploadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000); // Run every 10 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
import * as plugins from './smartregistry.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
export let demoExport = 'Hi there! :) This is an exported string';
|
// Export main classes
|
||||||
|
export { SmartRegistry } from './classes.smartregistry.js';
|
||||||
|
export { RegistryStorage } from './classes.registrystorage.js';
|
||||||
|
|
||||||
|
// Export interfaces and types
|
||||||
|
export * from './interfaces.js';
|
||||||
|
|||||||
197
ts/interfaces.ts
Normal file
197
ts/interfaces.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Interfaces and types for OCI Distribution Specification compliant registry
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credentials for authentication
|
||||||
|
*/
|
||||||
|
export interface IRegistryCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions that can be performed on a repository
|
||||||
|
*/
|
||||||
|
export type TRegistryAction = 'pull' | 'push' | 'delete' | '*';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT token structure for OCI registry authentication
|
||||||
|
*/
|
||||||
|
export interface IRegistryToken {
|
||||||
|
/** Issuer */
|
||||||
|
iss: string;
|
||||||
|
/** Subject (user identifier) */
|
||||||
|
sub: string;
|
||||||
|
/** Audience (service name) */
|
||||||
|
aud: string;
|
||||||
|
/** Expiration timestamp */
|
||||||
|
exp: number;
|
||||||
|
/** Not before timestamp */
|
||||||
|
nbf: number;
|
||||||
|
/** Issued at timestamp */
|
||||||
|
iat: number;
|
||||||
|
/** JWT ID */
|
||||||
|
jti?: string;
|
||||||
|
/** Access permissions */
|
||||||
|
access: Array<{
|
||||||
|
type: 'repository' | 'registry';
|
||||||
|
name: string;
|
||||||
|
actions: TRegistryAction[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback function for user login - returns JWT token
|
||||||
|
* @param credentials - User credentials
|
||||||
|
* @returns JWT token string
|
||||||
|
*/
|
||||||
|
export type TLoginCallback = (
|
||||||
|
credentials: IRegistryCredentials
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback function for authorization check
|
||||||
|
* @param token - JWT token string
|
||||||
|
* @param repository - Repository name (e.g., "library/nginx")
|
||||||
|
* @param action - Action to perform
|
||||||
|
* @returns true if authorized, false otherwise
|
||||||
|
*/
|
||||||
|
export type TAuthCallback = (
|
||||||
|
token: string,
|
||||||
|
repository: string,
|
||||||
|
action: TRegistryAction
|
||||||
|
) => Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the registry
|
||||||
|
*/
|
||||||
|
export interface IRegistryConfig {
|
||||||
|
/** Storage bucket configuration */
|
||||||
|
storage: {
|
||||||
|
accessKey: string;
|
||||||
|
accessSecret: string;
|
||||||
|
endpoint: string;
|
||||||
|
port?: number;
|
||||||
|
useSsl?: boolean;
|
||||||
|
region?: string;
|
||||||
|
bucketName: string;
|
||||||
|
};
|
||||||
|
/** Service name for token authentication */
|
||||||
|
serviceName: string;
|
||||||
|
/** Token realm (authorization server URL) */
|
||||||
|
tokenRealm: string;
|
||||||
|
/** Login callback */
|
||||||
|
loginCallback: TLoginCallback;
|
||||||
|
/** Authorization callback */
|
||||||
|
authCallback: TAuthCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCI manifest structure
|
||||||
|
*/
|
||||||
|
export interface IOciManifest {
|
||||||
|
schemaVersion: number;
|
||||||
|
mediaType: string;
|
||||||
|
config: {
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
};
|
||||||
|
layers: Array<{
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
urls?: string[];
|
||||||
|
}>;
|
||||||
|
subject?: {
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
};
|
||||||
|
annotations?: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCI Image Index (manifest list)
|
||||||
|
*/
|
||||||
|
export interface IOciImageIndex {
|
||||||
|
schemaVersion: number;
|
||||||
|
mediaType: string;
|
||||||
|
manifests: Array<{
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
platform?: {
|
||||||
|
architecture: string;
|
||||||
|
os: string;
|
||||||
|
'os.version'?: string;
|
||||||
|
'os.features'?: string[];
|
||||||
|
variant?: string;
|
||||||
|
features?: string[];
|
||||||
|
};
|
||||||
|
annotations?: { [key: string]: string };
|
||||||
|
}>;
|
||||||
|
subject?: {
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
};
|
||||||
|
annotations?: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload session for chunked blob uploads
|
||||||
|
*/
|
||||||
|
export interface IUploadSession {
|
||||||
|
uploadId: string;
|
||||||
|
repository: string;
|
||||||
|
chunks: Buffer[];
|
||||||
|
totalSize: number;
|
||||||
|
createdAt: Date;
|
||||||
|
lastActivity: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag list response
|
||||||
|
*/
|
||||||
|
export interface ITagList {
|
||||||
|
name: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referrers response
|
||||||
|
*/
|
||||||
|
export interface IReferrersResponse {
|
||||||
|
schemaVersion: number;
|
||||||
|
mediaType: string;
|
||||||
|
manifests: Array<{
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
artifactType?: string;
|
||||||
|
annotations?: { [key: string]: string };
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry error response
|
||||||
|
*/
|
||||||
|
export interface IRegistryError {
|
||||||
|
errors: Array<{
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
detail?: any;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination options for listing
|
||||||
|
*/
|
||||||
|
export interface IPaginationOptions {
|
||||||
|
/** Maximum number of results to return */
|
||||||
|
n?: number;
|
||||||
|
/** Last entry from previous request */
|
||||||
|
last?: string;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import * as path from 'path';
|
|||||||
export { path };
|
export { path };
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
|
import * as smartbucket from '@push.rocks/smartbucket';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
|
||||||
export { smartpath };
|
export { smartbucket, smartpath };
|
||||||
|
|||||||
Reference in New Issue
Block a user