diff --git a/src/lib/ignore-matcher.ts b/src/lib/ignore-matcher.ts new file mode 100644 index 000000000..90624dce2 --- /dev/null +++ b/src/lib/ignore-matcher.ts @@ -0,0 +1,290 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +export type IgnoreKind = 'docker'; + +/** + * Interface for ignore pattern matching. + * `matches(path)` returns true if the path should be ignored. + */ +export interface IgnoreMatcher { + matches(filePath: string): boolean; +} + +/** + * Create an IgnoreMatcher from raw patterns for a given kind. + * Currently only supports 'docker' (.dockerignore semantics). + */ +export function createIgnoreMatcher(patterns: string[], kind: IgnoreKind = 'docker'): IgnoreMatcher | null { + const cleaned = normalizePatterns(patterns); + if (cleaned.length === 0) return null; + + switch (kind) { + case 'docker': + default: + return new DockerIgnoreMatcher(cleaned); + } +} + +/** + * Load an ignore matcher from a directory. + * For 'docker', this looks for `.dockerignore` in `dir`. + */ +export async function loadIgnoreMatcher( + dir: string, + kind: IgnoreKind = 'docker', +): Promise { + const filename = kind === 'docker' ? '.dockerignore' : null; + if (!filename) return null; + + const ignorePath = path.join(dir, filename); + + try { + const stats = await fs.stat(ignorePath); + if (typeof stats.isFile === 'function' && !stats.isFile()) { + return null; + } + } catch { + // No .dockerignore file – nothing to ignore + return null; + } + + try { + const content = await fs.readFile(ignorePath, 'utf-8'); + const rawPatterns = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + + return createIgnoreMatcher(rawPatterns, kind); + } catch { + // If we can't read/parse, fail open (no ignores) + return null; + } +} + +function normalizePatterns(patterns: string[]): string[] { + return patterns + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => { + // .dockerignore uses forward slashes + let pat = p.replace(/\\/g, '/'); + // Leading "/" anchors to context root; since we only ever match + // paths relative to the context root (no leading slash), we can + // drop this while preserving Docker semantics. + if (pat.startsWith('/')) pat = pat.slice(1); + // Remove redundant leading "./" + if (pat.startsWith('./')) pat = pat.slice(2); + // Remove trailing slash except root + if (pat.endsWith('/') && pat !== '/') pat = pat.slice(0, -1); + return pat; + }); +} + +/** + * Load an ignore matcher from an explicit ignore file path. + */ +export async function loadIgnoreMatcherFromFile( + ignoreFilePath: string, + kind: IgnoreKind = 'docker', +): Promise { + try { + const stats = await fs.stat(ignoreFilePath); + if (typeof stats.isFile === 'function' && !stats.isFile()) { + return null; + } + } catch { + return null; + } + + try { + const content = await fs.readFile(ignoreFilePath, 'utf-8'); + const rawPatterns = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + + return createIgnoreMatcher(rawPatterns, kind); + } catch { + return null; + } +} + +/** + * Docker-style matcher implementing the core behavior of moby/patternmatcher: + * - glob patterns with *, **, ?, and character classes + * - '!' exclusion patterns + * - parent-directory matches (MatchesOrParentMatches semantics) + */ +export class DockerIgnoreMatcher implements IgnoreMatcher { + private patterns: CompiledPattern[]; + + constructor(patterns: string[]) { + this.patterns = patterns + .map((raw) => { + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith('#')) return null; + + // Handle exclusion prefix + let isExclusion = false; + let cleaned = trimmed; + if (cleaned.startsWith('!')) { + isExclusion = true; + cleaned = cleaned.slice(1); + } + + // Normalize path separators + cleaned = cleaned.replace(/\\/g, '/'); + + // Remove leading/trailing formatting + if (cleaned.startsWith('/')) cleaned = cleaned.slice(1); + if (cleaned.startsWith('./')) cleaned = cleaned.slice(2); + if (cleaned.endsWith('/') && cleaned !== '/') cleaned = cleaned.slice(0, -1); + + const pattern = isExclusion ? '!' + cleaned : cleaned; + return new CompiledPattern(pattern, raw); + }) + .filter((p): p is CompiledPattern => p !== null); + } + + matches(filePath: string): boolean { + // Always ignore the .dockerignore file itself + if (filePath === '.dockerignore') return true; + + // Expect paths relative to context root, forward-slash separated + let normalized = filePath.replace(/\\/g, '/'); + + // Strip leading ./ if present + if (normalized.startsWith('./')) normalized = normalized.slice(2); + + // Strip trailing slash if present (unless it's just root) + if (normalized.endsWith('/') && normalized !== '/') normalized = normalized.slice(0, -1); + + if (!normalized) normalized = '.'; + + const parts = normalized.split('/'); + const parentPaths: string[] = []; + for (let i = 0; i < parts.length - 1; i++) { + parentPaths.push(parts.slice(0, i + 1).join('/')); + } + + let matched = false; + + for (const pattern of this.patterns) { + // If we are already ignored (matched=true), we only care about exceptions (exclusion=true). + // If we are NOT ignored (matched=false), we only care about ignores (exclusion=false). + if (pattern.exclusion !== matched) { + continue; + } + + let isMatch = pattern.match(normalized); + + if (!isMatch && parentPaths.length > 0) { + for (const parent of parentPaths) { + if (pattern.match(parent)) { + isMatch = true; + break; + } + } + } + + if (isMatch) { + matched = !pattern.exclusion; + } + } + + return matched; + } +} + +class CompiledPattern { + readonly raw: string; + readonly exclusion: boolean; + private readonly cleaned: string; + private readonly regexp: RegExp; + + constructor(pattern: string, raw?: string) { + this.raw = raw || pattern; + if (pattern.startsWith('!')) { + this.exclusion = true; + this.cleaned = pattern.slice(1); + } else { + this.exclusion = false; + this.cleaned = pattern; + } + + this.regexp = globToRegExp(this.cleaned); + } + + match(target: string): boolean { + return this.regexp.test(target); + } +} + +/** + * Convert a Docker-style glob pattern to a RegExp. + * Roughly mirrors moby/patternmatcher.compile: + * - '*' => any sequence except '/' + * - '**' => any sequence including '/' + * - '?' => any single char except '/' + * - character classes [] are passed through + */ +function globToRegExp(pattern: string): RegExp { + let re = '^'; + const len = pattern.length; + + for (let i = 0; i < len; i++) { + const ch = pattern[i]!; + const next = i + 1 < len ? pattern[i + 1]! : ''; + + if (ch === '*') { + if (next === '*') { + // "**" + i++; + const afterNext = i + 1 < len ? pattern[i + 1]! : ''; + + if (afterNext === '/') { + // "**/" => any number of directories (including none) + i++; + re += '(?:.*/)?'; + } else if (i + 1 === len) { + // trailing "**" + re += '.*'; + } else { + // general "**" in middle + re += '.*'; + } + } else { + // "*" => anything but '/' + re += '[^/]*'; + } + } else if (ch === '?') { + re += '[^/]'; + } else if (ch === '[') { + // Character class: copy through until closing ']' + let j = i + 1; + let cls = '['; + while (j < len && pattern[j] !== ']') { + const c = pattern[j]!; + cls += c === '\\' ? '\\\\' : c; + j++; + } + if (j < len && pattern[j] === ']') { + cls += ']'; + re += cls; + i = j; + } else { + // Unterminated '[', treat literally + re += '\\['; + } + } else if ('().+|{}^$\\'.includes(ch)) { + re += '\\' + ch; + } else { + re += ch; + } + } + + re += '$'; + return new RegExp(re); +} diff --git a/src/sdk/blueprint.ts b/src/sdk/blueprint.ts index e743b0e14..1f66dc7f9 100644 --- a/src/sdk/blueprint.ts +++ b/src/sdk/blueprint.ts @@ -7,15 +7,54 @@ import type { } from '../resources/blueprints'; import type { DevboxCreateParams, DevboxView } from '../resources/devboxes/devboxes'; import type { PollingOptions } from '../lib/polling'; +import type { IgnoreMatcher } from '../lib/ignore-matcher'; import { Devbox } from './devbox'; import { StorageObject } from './storage-object'; +export interface BuildContextDirOptions { + /** + * Path to the directory to use as build context (Node.js only). + */ + path: string; + + /** + * Optional ignore specification: + * - an IgnoreMatcher instance, or + * - an array of docker-style glob patterns (as in .dockerignore). + */ + ignore?: IgnoreMatcher | string[]; + + /** + * Optional path to a specific .dockerignore-style file to use instead of the + * default `/.dockerignore`. + */ + dockerignorePath?: string; + + /** + * TTL (ms) for the backing StorageObject. Defaults to 1 hour if omitted. + */ + ttlMs?: number; +} + export type CreateParams = Omit & { /** * A build context to attach to the Blueprint build. * Enables the use of `COPY` Dockerfile directives. */ build_context?: StorageObject | BlueprintCreateParams.BuildContext | null; + + /** + * Configure a local directory build context. + * + * If provided, the SDK will: + * 1. Create and upload a gzipped tarball of `path` as a StorageObject + * (honoring .dockerignore or provided ignore patterns), + * 2. Set `build_context` on the Blueprint to reference that object. + * + * If both build_context and build_context_dir are provided, build_context_dir + * takes precedence. + */ + build_context_dir?: string | BuildContextDirOptions | null; }; /** @@ -49,14 +88,46 @@ export class Blueprint { polling?: Partial>; }, ): Promise { - let rawParams = { ...params }; - if (params.build_context instanceof StorageObject) { - rawParams.build_context = { type: 'object', object_id: params.build_context.id }; + const { build_context, build_context_dir, ...other } = params as any; + let rawParams: BlueprintCreateParams; + + if (build_context_dir) { + const dirConfig: BuildContextDirOptions = + typeof build_context_dir === 'string' ? { path: build_context_dir } : build_context_dir; + + const ttlMs = dirConfig.ttlMs ?? 3600000; + + const storageObject = await StorageObject.uploadFromDir( + client, + dirConfig.path, + { + name: `build-context-${params.name}`, + ttl_ms: ttlMs, + }, + { + ...(options ?? {}), + ignore: dirConfig.ignore, + dockerignorePath: dirConfig.dockerignorePath, + } as any, + ); + + rawParams = { + ...other, + build_context: { type: 'object', object_id: storageObject.id }, + } as BlueprintCreateParams; + } else if (build_context instanceof StorageObject) { + rawParams = { + ...other, + build_context: { type: 'object', object_id: build_context.id }, + } as BlueprintCreateParams; + } else { + rawParams = { + ...other, + build_context, + } as BlueprintCreateParams; } - const blueprintData = await client.blueprints.createAndAwaitBuildCompleted( - rawParams as BlueprintCreateParams, - options, - ); + + const blueprintData = await client.blueprints.createAndAwaitBuildCompleted(rawParams, options); return new Blueprint(client, blueprintData.id); } diff --git a/src/sdk/storage-object.ts b/src/sdk/storage-object.ts index 2f968e299..c8ab0181d 100644 --- a/src/sdk/storage-object.ts +++ b/src/sdk/storage-object.ts @@ -7,8 +7,16 @@ import type { ObjectListParams, } from '../resources/objects'; import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import * as tar from 'tar'; +import { + createIgnoreMatcher, + loadIgnoreMatcher, + loadIgnoreMatcherFromFile, + type IgnoreMatcher, +} from '../lib/ignore-matcher'; // Extract the content type from the API types type ContentType = ObjectCreateParams['content_type']; @@ -410,7 +418,20 @@ export class StorageObject { client: Runloop, dirPath: string, params: Omit, - options?: Core.RequestOptions, + options?: Core.RequestOptions & { + /** + * Optional ignore configuration for the directory: + * - an IgnoreMatcher instance, or + * - an array of docker-style glob patterns (as in .dockerignore) + */ + ignore?: IgnoreMatcher | string[]; + + /** + * Optional path to a specific .dockerignore-style file to use instead of + * the default `/.dockerignore`. + */ + dockerignorePath?: string; + }, ): Promise { assertNodeEnvironment(); @@ -426,50 +447,109 @@ export class StorageObject { ); } - // Create the tarball in-memory. - let buffer; + // Extract SDK-specific options to avoid leaking them to the API client + const { ignore, dockerignorePath, ...requestOptions } = options || {}; + + // Create a temporary file for the tarball + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runloop-upload-')); + const tmpFilePath = path.join(tmpDir, 'upload.tar.gz'); + try { - const tarStream = tar.create({ gzip: true, cwd: dirPath }, ['.']); - const chunks = []; - for await (const chunk of tarStream) { - chunks.push(chunk); + let matcher: IgnoreMatcher | null = null; + + if (ignore && typeof (ignore as any).matches === 'function') { + matcher = ignore as IgnoreMatcher; + } else if (ignore && Array.isArray(ignore)) { + matcher = createIgnoreMatcher(ignore, 'docker'); + } else if (dockerignorePath) { + matcher = await loadIgnoreMatcherFromFile(dockerignorePath, 'docker'); + } else { + matcher = await loadIgnoreMatcher(dirPath, 'docker'); } - buffer = Buffer.concat(chunks); - } catch (error) { - throw new Error( - `Failed to create tarball from directory ${dirPath}: ${error instanceof Error ? error.message : 'Unknown error'}`, + + const tarStream = tar.create( + { + gzip: true, + cwd: dirPath, + filter: (entryPath: string) => { + if (!matcher) return true; + + // Normalize to forward-slash relative paths + let rel = entryPath.replace(/\\/g, '/'); + + // tar may prefix entries with "./" + if (rel.startsWith('./')) rel = rel.slice(2); + if (!rel || rel === '.') return true; + + // Return true to include; matcher.matches == "ignored" + return !matcher.matches(rel); + }, + }, + ['.'], ); - } - // Create the object. - const createParams: ObjectCreateParams = { ...params, content_type: 'tgz' }; - const objectData = await client.objects.create(createParams, options); - const storageObject = new StorageObject(client, objectData.id, objectData.upload_url); + // Pipe the tar stream to the temporary file + await new Promise((resolve, reject) => { + const dest = fsSync.createWriteStream(tmpFilePath); + dest.on('finish', resolve); + dest.on('error', reject); + tarStream.on('error', reject); + tarStream.pipe(dest); + }); - const uploadUrl = objectData.upload_url; - if (!uploadUrl) { - throw new Error('No upload URL available. Object may already be completed or deleted.'); - } + // Create the object. + const createParams: ObjectCreateParams = { ...params, content_type: 'tgz' }; + // Cast requestOptions to Core.RequestOptions to satisfy the type checker, + // assuming the caller provided valid options minus our custom ones. + const objectData = await client.objects.create(createParams, requestOptions as Core.RequestOptions); + const storageObject = new StorageObject(client, objectData.id, objectData.upload_url); - // Write the tarball to the upload URL. - try { - const response = await fetch(uploadUrl, { - method: 'PUT', - body: buffer, - }); + const uploadUrl = objectData.upload_url; + if (!uploadUrl) { + throw new Error('No upload URL available. Object may already be completed or deleted.'); + } - if (!response.ok) { - throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + // Upload the file from disk + try { + const fileStream = fsSync.createReadStream(tmpFilePath); + const stats = await fs.stat(tmpFilePath); + + const response = await fetch(uploadUrl, { + method: 'PUT', + body: fileStream as any, + headers: { + 'Content-Length': stats.size.toString(), + }, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } + } catch (error) { + throw new Error( + `Failed to upload tarball: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); } + + await storageObject.complete(); + + return storageObject; } catch (error) { + // Re-throw errors related to tar creation or other steps + if (error instanceof Error && error.message.startsWith('Failed to upload tarball')) { + throw error; + } throw new Error( - `Failed to upload tarball: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Failed to create tarball from directory ${dirPath}: ${error instanceof Error ? error.message : 'Unknown error'}`, ); + } finally { + // Clean up temporary file and directory + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } } - - await storageObject.complete(); - - return storageObject; } /** diff --git a/tests/lib/ignore-matcher.test.ts b/tests/lib/ignore-matcher.test.ts new file mode 100644 index 000000000..75835db56 --- /dev/null +++ b/tests/lib/ignore-matcher.test.ts @@ -0,0 +1,56 @@ +import { DockerIgnoreMatcher, createIgnoreMatcher } from '../../src/lib/ignore-matcher'; + +describe('DockerIgnoreMatcher', () => { + const check = (patterns: string[], path: string, expected: boolean) => { + const matcher = new DockerIgnoreMatcher(patterns); + expect(matcher.matches(path)).toBe(expected); + }; + + it('matches simple file patterns', () => { + check(['foo.txt'], 'foo.txt', true); + check(['foo.txt'], 'bar.txt', false); + }); + + it('supports wildcards and double-star', () => { + check(['*.log'], 'app.log', true); + check(['*.log'], 'logs/app.log', false); + check(['**/*.log'], 'logs/app.log', true); + }); + + it('handles directory patterns via parent matches', () => { + check(['node_modules'], 'node_modules', true); + check(['node_modules'], 'node_modules/foo.js', true); + }); + + it('handles leading-slash patterns and directory slash quirk', () => { + const matcher = new DockerIgnoreMatcher(['/foo', '/foo/bar', '/foo/bar/']); + + // Our context paths are relative, so the file is seen as "foo/bar" + expect(matcher.matches('foo/bar')).toBe(true); + // Some tar implementations may surface directory entries with a trailing slash + expect(matcher.matches('foo/bar/')).toBe(true); + }); + + it('supports exclusions with !', () => { + check(['*.log', '!keep.log'], 'keep.log', false); + check(['*.log', '!keep.log'], 'other.log', true); + }); + + it('handles exclusions with leading slash', () => { + // !/keep.log should behave like !keep.log relative to root + check(['*.log', '!/keep.log'], 'keep.log', false); + }); + + it('implicitly ignores .dockerignore', () => { + // this is an edge case in docker's behavior (.dockerignore is always implicitly ignored) + const matcher = new DockerIgnoreMatcher([]); + expect(matcher.matches('.dockerignore')).toBe(true); + }); + + it('normalizes patterns in constructor', () => { + // constructor should handle normalization just like createIgnoreMatcher might be expected to + // (though current createIgnoreMatcher implementation might also be flawed for exclusions) + const matcher = new DockerIgnoreMatcher(['/foo']); + expect(matcher.matches('foo')).toBe(true); + }); +}); diff --git a/tests/objects/storage-object.test.ts b/tests/objects/storage-object.test.ts index d3d2e5572..092bffc65 100644 --- a/tests/objects/storage-object.test.ts +++ b/tests/objects/storage-object.test.ts @@ -11,6 +11,13 @@ jest.mock('../../src/index'); jest.mock('node:fs/promises', () => ({ stat: jest.fn(), readFile: jest.fn(), + mkdtemp: jest.fn(), + rm: jest.fn(), +})); + +jest.mock('node:fs', () => ({ + createWriteStream: jest.fn(), + createReadStream: jest.fn(), })); jest.mock('node:path', () => ({ @@ -27,16 +34,26 @@ jest.mock('tar', () => ({ create: jest.fn(), })); +// Mock ignore matcher so uploadFromDir doesn't hit the real filesystem +jest.mock('../../src/lib/ignore-matcher', () => ({ + loadIgnoreMatcher: jest.fn(), +})); + describe('StorageObject (New API)', () => { let mockClient: any; let mockObjectData: ObjectView; let mockFs: any; + let mockFsSync: any; let mockPath: any; + let mockIgnoreMatcher: any; beforeEach(() => { // Get mocked modules mockFs = require('node:fs/promises'); + mockFsSync = require('node:fs'); mockPath = require('node:path'); + mockIgnoreMatcher = require('../../src/lib/ignore-matcher'); + mockIgnoreMatcher.loadIgnoreMatcher.mockResolvedValue(null); // Create mock client instance with proper structure mockClient = { @@ -826,19 +843,39 @@ describe('StorageObject (New API)', () => { ((global as any).fetch as jest.Mock).mockClear(); // Get tar mock mockTar = require('tar'); + // Reset ignore matcher mock + const ignoreMod = require('../../src/lib/ignore-matcher'); + ignoreMod.loadIgnoreMatcher.mockResolvedValue(null); }); it('should upload a directory as gzipped tarball', async () => { // Mock directory exists - mockFs.stat.mockResolvedValue({ isDirectory: () => true }); + mockFs.stat.mockResolvedValue({ isDirectory: () => true, size: 100 }); + mockFs.mkdtemp.mockResolvedValue('/tmp/runloop-upload-123'); + + // Mock write stream + const mockWriteStream = { + on: jest.fn().mockReturnThis(), + }; + mockFsSync.createWriteStream.mockReturnValue(mockWriteStream); + + // Mock read stream + const mockReadStream = { + pipe: jest.fn(), + }; + mockFsSync.createReadStream.mockReturnValue(mockReadStream); // Mock tar stream - const mockTarballBuffer = Buffer.from('compressed tarball content'); - mockTar.create.mockReturnValue({ - [Symbol.asyncIterator]: async function* () { - yield mockTarballBuffer; - }, - }); + const mockTarStream = { + pipe: jest.fn((dest) => { + // Trigger finish immediately for test + const finishCall = dest.on.mock.calls.find((c: any) => c[0] === 'finish'); + if (finishCall) finishCall[1](); + return dest; + }), + on: jest.fn(), + }; + mockTar.create.mockReturnValue(mockTarStream); const mockObjectData = { id: 'dir-123', upload_url: 'https://upload.example.com/dir' }; const mockObjectInfo = { ...mockObjectData, name: 'project.tar.gz', state: 'UPLOADING' }; @@ -860,25 +897,94 @@ describe('StorageObject (New API)', () => { expect(mockClient.objects.create).toHaveBeenCalledWith( { name: 'project.tar.gz', content_type: 'tgz' }, - undefined, + expect.any(Object), // Match any options object, simplified check ); expect(mockTar.create).toHaveBeenCalled(); + expect(mockFs.mkdtemp).toHaveBeenCalled(); + expect(mockFsSync.createWriteStream).toHaveBeenCalled(); + expect(mockFsSync.createReadStream).toHaveBeenCalled(); + expect(mockFs.rm).toHaveBeenCalledWith('/tmp/runloop-upload-123', { recursive: true, force: true }); expect(result).toBeInstanceOf(StorageObject); expect(result.id).toBe('dir-123'); }); - it('should upload directory with TTL and metadata', async () => { + it('should respect ignore matcher when building tarball', async () => { // Mock directory exists - mockFs.stat.mockResolvedValue({ isDirectory: () => true }); + mockFs.stat.mockResolvedValue({ isDirectory: () => true, size: 100 }); + mockFs.mkdtemp.mockResolvedValue('/tmp/runloop-upload-123'); + + // Mocks for streams + const mockWriteStream = { + on: jest.fn().mockReturnThis(), + }; + mockFsSync.createWriteStream.mockReturnValue(mockWriteStream); + mockFsSync.createReadStream.mockReturnValue({}); + + // Provide a fake matcher + const matcher = { matches: jest.fn() }; + const ignoreMod = require('../../src/lib/ignore-matcher'); + ignoreMod.loadIgnoreMatcher.mockResolvedValue(matcher); // Mock tar stream - const mockTarballBuffer = Buffer.from('compressed tarball'); - mockTar.create.mockReturnValue({ - [Symbol.asyncIterator]: async function* () { - yield mockTarballBuffer; - }, + const mockTarStream = { + pipe: jest.fn((dest) => { + const finishCall = dest.on.mock.calls.find((c: any) => c[0] === 'finish'); + if (finishCall) finishCall[1](); + return dest; + }), + on: jest.fn(), + }; + mockTar.create.mockReturnValue(mockTarStream); + + const mockObjectData = { id: 'dir-ignore', upload_url: 'https://upload.example.com/dir' }; + mockClient.objects.create.mockResolvedValue(mockObjectData); + mockClient.objects.complete.mockResolvedValue({ ...mockObjectData, state: 'READ_ONLY' }); + ((global as any).fetch as jest.Mock).mockResolvedValue({ ok: true }); + + await StorageObject.uploadFromDir(mockClient, './my-project', { + name: 'project.tar.gz', }); + // Ensure we attempted to load a matcher for the directory + expect(ignoreMod.loadIgnoreMatcher).toHaveBeenCalledWith('./my-project', 'docker'); + + // Inspect filter behavior + expect(mockTar.create).toHaveBeenCalled(); // Fix brittle test: Assert called first + const tarOptions = mockTar.create.mock.calls[0][0]; + expect(typeof tarOptions.filter).toBe('function'); + + matcher.matches.mockReturnValue(true); + expect(tarOptions.filter('ignored.txt')).toBe(false); + + matcher.matches.mockReturnValue(false); + expect(tarOptions.filter('kept.txt')).toBe(true); + // Normalization of ./ prefix + expect(tarOptions.filter('./kept.txt')).toBe(true); + }); + + it('should upload directory with TTL and metadata', async () => { + // Mock directory exists + mockFs.stat.mockResolvedValue({ isDirectory: () => true, size: 100 }); + mockFs.mkdtemp.mockResolvedValue('/tmp/runloop-upload-123'); + + // Mocks for streams + const mockWriteStream = { + on: jest.fn().mockReturnThis(), + }; + mockFsSync.createWriteStream.mockReturnValue(mockWriteStream); + mockFsSync.createReadStream.mockReturnValue({}); + + // Mock tar stream + const mockTarStream = { + pipe: jest.fn((dest) => { + const finishCall = dest.on.mock.calls.find((c: any) => c[0] === 'finish'); + if (finishCall) finishCall[1](); + return dest; + }), + on: jest.fn(), + }; + mockTar.create.mockReturnValue(mockTarStream); + const mockObjectData = { id: 'dir-456', upload_url: 'https://upload.example.com/dir' }; const mockCompletedData = { ...mockObjectData, state: 'READ_ONLY' }; @@ -899,7 +1005,7 @@ describe('StorageObject (New API)', () => { expect(mockClient.objects.create).toHaveBeenCalledWith( { name: 'project.tar.gz', content_type: 'tgz', metadata: { project: 'demo' }, ttl_ms: 3600000 }, - undefined, + expect.anything(), ); expect(result.id).toBe('dir-456'); }); @@ -932,14 +1038,24 @@ describe('StorageObject (New API)', () => { }); it('should handle upload failures gracefully', async () => { - mockFs.stat.mockResolvedValue({ isDirectory: () => true }); - - const mockTarballBuffer = Buffer.from('tarball'); - mockTar.create.mockReturnValue({ - [Symbol.asyncIterator]: async function* () { - yield mockTarballBuffer; - }, - }); + mockFs.stat.mockResolvedValue({ isDirectory: () => true, size: 100 }); + mockFs.mkdtemp.mockResolvedValue('/tmp/runloop-upload-123'); + + const mockWriteStream = { + on: jest.fn().mockReturnThis(), + }; + mockFsSync.createWriteStream.mockReturnValue(mockWriteStream); + mockFsSync.createReadStream.mockReturnValue({}); + + const mockTarStream = { + pipe: jest.fn((dest) => { + const finishCall = dest.on.mock.calls.find((c: any) => c[0] === 'finish'); + if (finishCall) finishCall[1](); + return dest; + }), + on: jest.fn(), + }; + mockTar.create.mockReturnValue(mockTarStream); const mockObjectData = { id: 'dir-999', upload_url: 'https://upload.example.com/dir' }; mockClient.objects.create.mockResolvedValue(mockObjectData); diff --git a/tests/smoketests/object-oriented/blueprint.test.ts b/tests/smoketests/object-oriented/blueprint.test.ts index 77f1cded3..a9c9e1434 100644 --- a/tests/smoketests/object-oriented/blueprint.test.ts +++ b/tests/smoketests/object-oriented/blueprint.test.ts @@ -1,5 +1,5 @@ import { THIRTY_SECOND_TIMEOUT, uniqueName, makeClientSDK } from '../utils'; -import { Blueprint, Devbox } from '@runloop/api-client/sdk'; +import { Blueprint, Devbox, StorageObject } from '@runloop/api-client/sdk'; const sdk = makeClientSDK(); @@ -89,6 +89,98 @@ describe('smoketest: object-oriented blueprint', () => { }); }); + describe('blueprint build context with object storage and .dockerignore', () => { + test( + 'creates blueprint with COPY using object-based build context honoring .dockerignore', + async () => { + const fs = require('fs/promises'); + const path = require('path'); + const os = require('os'); + + let contextDir: string | undefined; + let storageObject: StorageObject | undefined; + let blueprint: Blueprint | undefined; + let devbox: Devbox | undefined; + + try { + // Create temporary build context directory + contextDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runloop-context-')); + const includedPath = path.join(contextDir, 'included.txt'); + const ignoredPath = path.join(contextDir, 'ignored.txt'); + const dockerignorePath = path.join(contextDir, '.dockerignore'); + + await fs.writeFile(includedPath, 'Hello from context'); + await fs.writeFile(ignoredPath, 'This file should be ignored'); + await fs.writeFile(dockerignorePath, 'ignored.txt\n'); + + // Upload directory as build context object (tgz) + if (!contextDir) { + throw new Error('Context directory not created before uploadFromDir'); + } + storageObject = await sdk.storageObject.uploadFromDir(contextDir, { + name: uniqueName('sdk-context-object'), + ttl_ms: 3600000, // 1 hour + }); + + // Create blueprint that uses the uploaded object as build context + blueprint = await sdk.blueprint.create({ + name: uniqueName('sdk-blueprint-context'), + dockerfile: `FROM ubuntu:20.04 +WORKDIR /app +COPY . .`, + build_context: storageObject, + }); + + expect(blueprint).toBeDefined(); + expect(blueprint.id).toBeTruthy(); + + // Create devbox from the blueprint and verify copied files + devbox = await blueprint.createDevbox({ + name: uniqueName('devbox-from-context'), + launch_parameters: { + resource_size_request: 'X_SMALL', + keep_alive_time_seconds: 60 * 5, // 5 minutes + }, + }); + + expect(devbox).toBeDefined(); + expect(devbox.id).toBeTruthy(); + + // included.txt should be present + const includeCheck = await devbox.cmd.exec('test -f /app/included.txt'); + expect(includeCheck.exitCode).toBe(0); + + // Optionally assert content + const includeContentResult = await devbox.cmd.exec('cat /app/included.txt'); + const includeContent = await includeContentResult.stdout(); + expect(includeContent.trim()).toBe('Hello from context'); + + // ignored.txt should be absent due to .dockerignore + const ignoredCheck = await devbox.cmd.exec('test ! -f /app/ignored.txt'); + expect(ignoredCheck.exitCode).toBe(0); + + // .dockerignore itself should also not be present in the image + const dockerignoreCheck = await devbox.cmd.exec('test ! -f /app/.dockerignore'); + expect(dockerignoreCheck.exitCode).toBe(0); + } finally { + if (devbox) { + await devbox.shutdown().catch(() => {}); + } + if (blueprint) { + await blueprint.delete().catch(() => {}); + } + if (storageObject) { + await storageObject.delete().catch(() => {}); + } + if (contextDir) { + await fs.rm(contextDir, { recursive: true, force: true }).catch(() => {}); + } + } + }, + THIRTY_SECOND_TIMEOUT * 2, + ); + }); + describe('blueprint list and retrieval', () => { test('list blueprints', async () => { const blueprints = await sdk.blueprint.list({ limit: 10 });