Skip to content

Commit a843d54

Browse files
authored
feat(ignore matcher): support for .dockerignore for build context (#673)
* added ignore matcher * fixed a bug * added some corner cases for ignore logic to be completely consistent with what docker does internally * addressed some feedback from ant * added smoketest
1 parent e814e00 commit a843d54

6 files changed

Lines changed: 770 additions & 65 deletions

File tree

src/lib/ignore-matcher.ts

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import * as fs from 'node:fs/promises';
2+
import * as path from 'node:path';
3+
4+
export type IgnoreKind = 'docker';
5+
6+
/**
7+
* Interface for ignore pattern matching.
8+
* `matches(path)` returns true if the path should be ignored.
9+
*/
10+
export interface IgnoreMatcher {
11+
matches(filePath: string): boolean;
12+
}
13+
14+
/**
15+
* Create an IgnoreMatcher from raw patterns for a given kind.
16+
* Currently only supports 'docker' (.dockerignore semantics).
17+
*/
18+
export function createIgnoreMatcher(patterns: string[], kind: IgnoreKind = 'docker'): IgnoreMatcher | null {
19+
const cleaned = normalizePatterns(patterns);
20+
if (cleaned.length === 0) return null;
21+
22+
switch (kind) {
23+
case 'docker':
24+
default:
25+
return new DockerIgnoreMatcher(cleaned);
26+
}
27+
}
28+
29+
/**
30+
* Load an ignore matcher from a directory.
31+
* For 'docker', this looks for `.dockerignore` in `dir`.
32+
*/
33+
export async function loadIgnoreMatcher(
34+
dir: string,
35+
kind: IgnoreKind = 'docker',
36+
): Promise<IgnoreMatcher | null> {
37+
const filename = kind === 'docker' ? '.dockerignore' : null;
38+
if (!filename) return null;
39+
40+
const ignorePath = path.join(dir, filename);
41+
42+
try {
43+
const stats = await fs.stat(ignorePath);
44+
if (typeof stats.isFile === 'function' && !stats.isFile()) {
45+
return null;
46+
}
47+
} catch {
48+
// No .dockerignore file – nothing to ignore
49+
return null;
50+
}
51+
52+
try {
53+
const content = await fs.readFile(ignorePath, 'utf-8');
54+
const rawPatterns = content
55+
.split(/\r?\n/)
56+
.map((line) => line.trim())
57+
.filter((line) => line && !line.startsWith('#'));
58+
59+
return createIgnoreMatcher(rawPatterns, kind);
60+
} catch {
61+
// If we can't read/parse, fail open (no ignores)
62+
return null;
63+
}
64+
}
65+
66+
function normalizePatterns(patterns: string[]): string[] {
67+
return patterns
68+
.map((p) => p.trim())
69+
.filter(Boolean)
70+
.map((p) => {
71+
// .dockerignore uses forward slashes
72+
let pat = p.replace(/\\/g, '/');
73+
// Leading "/" anchors to context root; since we only ever match
74+
// paths relative to the context root (no leading slash), we can
75+
// drop this while preserving Docker semantics.
76+
if (pat.startsWith('/')) pat = pat.slice(1);
77+
// Remove redundant leading "./"
78+
if (pat.startsWith('./')) pat = pat.slice(2);
79+
// Remove trailing slash except root
80+
if (pat.endsWith('/') && pat !== '/') pat = pat.slice(0, -1);
81+
return pat;
82+
});
83+
}
84+
85+
/**
86+
* Load an ignore matcher from an explicit ignore file path.
87+
*/
88+
export async function loadIgnoreMatcherFromFile(
89+
ignoreFilePath: string,
90+
kind: IgnoreKind = 'docker',
91+
): Promise<IgnoreMatcher | null> {
92+
try {
93+
const stats = await fs.stat(ignoreFilePath);
94+
if (typeof stats.isFile === 'function' && !stats.isFile()) {
95+
return null;
96+
}
97+
} catch {
98+
return null;
99+
}
100+
101+
try {
102+
const content = await fs.readFile(ignoreFilePath, 'utf-8');
103+
const rawPatterns = content
104+
.split(/\r?\n/)
105+
.map((line) => line.trim())
106+
.filter((line) => line && !line.startsWith('#'));
107+
108+
return createIgnoreMatcher(rawPatterns, kind);
109+
} catch {
110+
return null;
111+
}
112+
}
113+
114+
/**
115+
* Docker-style matcher implementing the core behavior of moby/patternmatcher:
116+
* - glob patterns with *, **, ?, and character classes
117+
* - '!' exclusion patterns
118+
* - parent-directory matches (MatchesOrParentMatches semantics)
119+
*/
120+
export class DockerIgnoreMatcher implements IgnoreMatcher {
121+
private patterns: CompiledPattern[];
122+
123+
constructor(patterns: string[]) {
124+
this.patterns = patterns
125+
.map((raw) => {
126+
const trimmed = raw.trim();
127+
if (!trimmed || trimmed.startsWith('#')) return null;
128+
129+
// Handle exclusion prefix
130+
let isExclusion = false;
131+
let cleaned = trimmed;
132+
if (cleaned.startsWith('!')) {
133+
isExclusion = true;
134+
cleaned = cleaned.slice(1);
135+
}
136+
137+
// Normalize path separators
138+
cleaned = cleaned.replace(/\\/g, '/');
139+
140+
// Remove leading/trailing formatting
141+
if (cleaned.startsWith('/')) cleaned = cleaned.slice(1);
142+
if (cleaned.startsWith('./')) cleaned = cleaned.slice(2);
143+
if (cleaned.endsWith('/') && cleaned !== '/') cleaned = cleaned.slice(0, -1);
144+
145+
const pattern = isExclusion ? '!' + cleaned : cleaned;
146+
return new CompiledPattern(pattern, raw);
147+
})
148+
.filter((p): p is CompiledPattern => p !== null);
149+
}
150+
151+
matches(filePath: string): boolean {
152+
// Always ignore the .dockerignore file itself
153+
if (filePath === '.dockerignore') return true;
154+
155+
// Expect paths relative to context root, forward-slash separated
156+
let normalized = filePath.replace(/\\/g, '/');
157+
158+
// Strip leading ./ if present
159+
if (normalized.startsWith('./')) normalized = normalized.slice(2);
160+
161+
// Strip trailing slash if present (unless it's just root)
162+
if (normalized.endsWith('/') && normalized !== '/') normalized = normalized.slice(0, -1);
163+
164+
if (!normalized) normalized = '.';
165+
166+
const parts = normalized.split('/');
167+
const parentPaths: string[] = [];
168+
for (let i = 0; i < parts.length - 1; i++) {
169+
parentPaths.push(parts.slice(0, i + 1).join('/'));
170+
}
171+
172+
let matched = false;
173+
174+
for (const pattern of this.patterns) {
175+
// If we are already ignored (matched=true), we only care about exceptions (exclusion=true).
176+
// If we are NOT ignored (matched=false), we only care about ignores (exclusion=false).
177+
if (pattern.exclusion !== matched) {
178+
continue;
179+
}
180+
181+
let isMatch = pattern.match(normalized);
182+
183+
if (!isMatch && parentPaths.length > 0) {
184+
for (const parent of parentPaths) {
185+
if (pattern.match(parent)) {
186+
isMatch = true;
187+
break;
188+
}
189+
}
190+
}
191+
192+
if (isMatch) {
193+
matched = !pattern.exclusion;
194+
}
195+
}
196+
197+
return matched;
198+
}
199+
}
200+
201+
class CompiledPattern {
202+
readonly raw: string;
203+
readonly exclusion: boolean;
204+
private readonly cleaned: string;
205+
private readonly regexp: RegExp;
206+
207+
constructor(pattern: string, raw?: string) {
208+
this.raw = raw || pattern;
209+
if (pattern.startsWith('!')) {
210+
this.exclusion = true;
211+
this.cleaned = pattern.slice(1);
212+
} else {
213+
this.exclusion = false;
214+
this.cleaned = pattern;
215+
}
216+
217+
this.regexp = globToRegExp(this.cleaned);
218+
}
219+
220+
match(target: string): boolean {
221+
return this.regexp.test(target);
222+
}
223+
}
224+
225+
/**
226+
* Convert a Docker-style glob pattern to a RegExp.
227+
* Roughly mirrors moby/patternmatcher.compile:
228+
* - '*' => any sequence except '/'
229+
* - '**' => any sequence including '/'
230+
* - '?' => any single char except '/'
231+
* - character classes [] are passed through
232+
*/
233+
function globToRegExp(pattern: string): RegExp {
234+
let re = '^';
235+
const len = pattern.length;
236+
237+
for (let i = 0; i < len; i++) {
238+
const ch = pattern[i]!;
239+
const next = i + 1 < len ? pattern[i + 1]! : '';
240+
241+
if (ch === '*') {
242+
if (next === '*') {
243+
// "**"
244+
i++;
245+
const afterNext = i + 1 < len ? pattern[i + 1]! : '';
246+
247+
if (afterNext === '/') {
248+
// "**/" => any number of directories (including none)
249+
i++;
250+
re += '(?:.*/)?';
251+
} else if (i + 1 === len) {
252+
// trailing "**"
253+
re += '.*';
254+
} else {
255+
// general "**" in middle
256+
re += '.*';
257+
}
258+
} else {
259+
// "*" => anything but '/'
260+
re += '[^/]*';
261+
}
262+
} else if (ch === '?') {
263+
re += '[^/]';
264+
} else if (ch === '[') {
265+
// Character class: copy through until closing ']'
266+
let j = i + 1;
267+
let cls = '[';
268+
while (j < len && pattern[j] !== ']') {
269+
const c = pattern[j]!;
270+
cls += c === '\\' ? '\\\\' : c;
271+
j++;
272+
}
273+
if (j < len && pattern[j] === ']') {
274+
cls += ']';
275+
re += cls;
276+
i = j;
277+
} else {
278+
// Unterminated '[', treat literally
279+
re += '\\[';
280+
}
281+
} else if ('().+|{}^$\\'.includes(ch)) {
282+
re += '\\' + ch;
283+
} else {
284+
re += ch;
285+
}
286+
}
287+
288+
re += '$';
289+
return new RegExp(re);
290+
}

src/sdk/blueprint.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,54 @@ import type {
77
} from '../resources/blueprints';
88
import type { DevboxCreateParams, DevboxView } from '../resources/devboxes/devboxes';
99
import type { PollingOptions } from '../lib/polling';
10+
import type { IgnoreMatcher } from '../lib/ignore-matcher';
1011
import { Devbox } from './devbox';
1112
import { StorageObject } from './storage-object';
1213

14+
export interface BuildContextDirOptions {
15+
/**
16+
* Path to the directory to use as build context (Node.js only).
17+
*/
18+
path: string;
19+
20+
/**
21+
* Optional ignore specification:
22+
* - an IgnoreMatcher instance, or
23+
* - an array of docker-style glob patterns (as in .dockerignore).
24+
*/
25+
ignore?: IgnoreMatcher | string[];
26+
27+
/**
28+
* Optional path to a specific .dockerignore-style file to use instead of the
29+
* default `<path>/.dockerignore`.
30+
*/
31+
dockerignorePath?: string;
32+
33+
/**
34+
* TTL (ms) for the backing StorageObject. Defaults to 1 hour if omitted.
35+
*/
36+
ttlMs?: number;
37+
}
38+
1339
export type CreateParams = Omit<BlueprintCreateParams, 'build_context'> & {
1440
/**
1541
* A build context to attach to the Blueprint build.
1642
* Enables the use of `COPY` Dockerfile directives.
1743
*/
1844
build_context?: StorageObject | BlueprintCreateParams.BuildContext | null;
45+
46+
/**
47+
* Configure a local directory build context.
48+
*
49+
* If provided, the SDK will:
50+
* 1. Create and upload a gzipped tarball of `path` as a StorageObject
51+
* (honoring .dockerignore or provided ignore patterns),
52+
* 2. Set `build_context` on the Blueprint to reference that object.
53+
*
54+
* If both build_context and build_context_dir are provided, build_context_dir
55+
* takes precedence.
56+
*/
57+
build_context_dir?: string | BuildContextDirOptions | null;
1958
};
2059

2160
/**
@@ -49,14 +88,46 @@ export class Blueprint {
4988
polling?: Partial<PollingOptions<BlueprintView>>;
5089
},
5190
): Promise<Blueprint> {
52-
let rawParams = { ...params };
53-
if (params.build_context instanceof StorageObject) {
54-
rawParams.build_context = { type: 'object', object_id: params.build_context.id };
91+
const { build_context, build_context_dir, ...other } = params as any;
92+
let rawParams: BlueprintCreateParams;
93+
94+
if (build_context_dir) {
95+
const dirConfig: BuildContextDirOptions =
96+
typeof build_context_dir === 'string' ? { path: build_context_dir } : build_context_dir;
97+
98+
const ttlMs = dirConfig.ttlMs ?? 3600000;
99+
100+
const storageObject = await StorageObject.uploadFromDir(
101+
client,
102+
dirConfig.path,
103+
{
104+
name: `build-context-${params.name}`,
105+
ttl_ms: ttlMs,
106+
},
107+
{
108+
...(options ?? {}),
109+
ignore: dirConfig.ignore,
110+
dockerignorePath: dirConfig.dockerignorePath,
111+
} as any,
112+
);
113+
114+
rawParams = {
115+
...other,
116+
build_context: { type: 'object', object_id: storageObject.id },
117+
} as BlueprintCreateParams;
118+
} else if (build_context instanceof StorageObject) {
119+
rawParams = {
120+
...other,
121+
build_context: { type: 'object', object_id: build_context.id },
122+
} as BlueprintCreateParams;
123+
} else {
124+
rawParams = {
125+
...other,
126+
build_context,
127+
} as BlueprintCreateParams;
55128
}
56-
const blueprintData = await client.blueprints.createAndAwaitBuildCompleted(
57-
rawParams as BlueprintCreateParams,
58-
options,
59-
);
129+
130+
const blueprintData = await client.blueprints.createAndAwaitBuildCompleted(rawParams, options);
60131
return new Blueprint(client, blueprintData.id);
61132
}
62133

0 commit comments

Comments
 (0)