Skip to content

Commit b7eef5d

Browse files
authored
feat(blueprint): from dockerfile support for rli (#59)
## Description <!-- Provide a brief description of your changes --> **Note:** PR titles should follow [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat(devbox): add support for custom env vars` or `fix(snapshot): resolve pagination issue`) as they are used for automatic release notes generation. ## Type of Change <!-- Mark the relevant option with an 'x' --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Test updates ## Related Issues <!-- Link to related issues using #issue-number --> Closes # ## Changes Made <!-- Describe the changes in detail --> ## Testing <!-- Describe how you tested your changes --> - [ ] I have tested locally - [ ] I have added/updated tests - [ ] All existing tests pass ## Checklist - [ ] My code follows the code style of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published ## Screenshots (if applicable) <!-- Add screenshots to help explain your changes --> ## Additional Notes <!-- Any additional information that reviewers should know -->
1 parent 9e003f9 commit b7eef5d

3 files changed

Lines changed: 298 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ rli blueprint create # Create a new blueprint
118118
rli blueprint get <name-or-id> # Get blueprint details by name or ID (...
119119
rli blueprint logs <name-or-id> # Get blueprint build logs by name or I...
120120
rli blueprint prune <name> # Delete old blueprint builds, keeping ...
121+
rli blueprint from-dockerfile # Create a blueprint from a Dockerfile ...
121122
```
122123

123124
### Object Commands (alias: `obj`)
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* Create blueprint from Dockerfile command
3+
*
4+
* Manually handles each step with progress feedback:
5+
* 1. Upload build context as tarball
6+
* 2. Create blueprint
7+
* 3. Poll for build completion
8+
*/
9+
10+
import { readFile, stat } from "fs/promises";
11+
import { resolve, join } from "path";
12+
import { getClient } from "../../utils/client.js";
13+
import { output, outputError } from "../../utils/output.js";
14+
import { StorageObject } from "@runloop/api-client/sdk";
15+
import type { BlueprintCreateParams } from "@runloop/api-client/resources/blueprints";
16+
17+
interface FromDockerfileOptions {
18+
name: string;
19+
buildContext?: string;
20+
dockerfile?: string;
21+
systemSetupCommands?: string[];
22+
resources?: string;
23+
architecture?: string;
24+
availablePorts?: string[];
25+
root?: boolean;
26+
user?: string;
27+
ttl?: string;
28+
noWait?: boolean;
29+
output?: string;
30+
}
31+
32+
// Helper to check if we should show progress
33+
function shouldShowProgress(options: FromDockerfileOptions): boolean {
34+
return !options.output || options.output === "text";
35+
}
36+
37+
// Helper to log progress (to stderr so it doesn't interfere with JSON output)
38+
function logProgress(message: string, options: FromDockerfileOptions): void {
39+
if (shouldShowProgress(options)) {
40+
console.error(message);
41+
}
42+
}
43+
44+
// Helper to format elapsed time
45+
function formatElapsed(startTime: number): string {
46+
const elapsed = Math.round((Date.now() - startTime) / 1000);
47+
if (elapsed < 60) {
48+
return `${elapsed}s`;
49+
}
50+
const minutes = Math.floor(elapsed / 60);
51+
const seconds = elapsed % 60;
52+
return `${minutes}m ${seconds}s`;
53+
}
54+
55+
export async function createBlueprintFromDockerfile(
56+
options: FromDockerfileOptions,
57+
) {
58+
const startTime = Date.now();
59+
60+
try {
61+
const client = getClient();
62+
63+
// Resolve build context path (defaults to current directory)
64+
const buildContextPath = resolve(options.buildContext || ".");
65+
66+
// Verify build context exists and is a directory
67+
try {
68+
const stats = await stat(buildContextPath);
69+
if (!stats.isDirectory()) {
70+
outputError(
71+
`Build context path is not a directory: ${buildContextPath}`,
72+
);
73+
}
74+
} catch {
75+
outputError(`Build context path does not exist: ${buildContextPath}`);
76+
}
77+
78+
// Resolve Dockerfile path
79+
const dockerfilePath = options.dockerfile
80+
? resolve(options.dockerfile)
81+
: join(buildContextPath, "Dockerfile");
82+
83+
// Verify Dockerfile exists
84+
try {
85+
const stats = await stat(dockerfilePath);
86+
if (!stats.isFile()) {
87+
outputError(`Dockerfile path is not a file: ${dockerfilePath}`);
88+
}
89+
} catch {
90+
outputError(`Dockerfile not found: ${dockerfilePath}`);
91+
}
92+
93+
// Log initial info
94+
logProgress(
95+
`\n📦 Creating blueprint "${options.name}" from Dockerfile`,
96+
options,
97+
);
98+
logProgress(` Build context: ${buildContextPath}`, options);
99+
logProgress(` Dockerfile: ${dockerfilePath}\n`, options);
100+
101+
// Read the Dockerfile contents
102+
const dockerfileContents = await readFile(dockerfilePath, "utf-8");
103+
104+
// Parse user parameters
105+
let userParameters = undefined;
106+
if (options.user && options.root) {
107+
outputError("Only one of --user or --root can be specified");
108+
} else if (options.user) {
109+
const [username, uid] = options.user.split(":");
110+
if (!username || !uid) {
111+
outputError("User must be in format 'username:uid'");
112+
}
113+
userParameters = { username, uid: parseInt(uid) };
114+
} else if (options.root) {
115+
userParameters = { username: "root", uid: 0 };
116+
}
117+
118+
// Build launch parameters
119+
const launchParameters: Record<string, unknown> = {};
120+
if (options.resources) {
121+
launchParameters.resource_size_request = options.resources;
122+
}
123+
if (options.architecture) {
124+
launchParameters.architecture = options.architecture;
125+
}
126+
if (options.availablePorts) {
127+
launchParameters.available_ports = options.availablePorts.map((port) =>
128+
parseInt(port, 10),
129+
);
130+
}
131+
if (userParameters) {
132+
launchParameters.user_parameters = userParameters;
133+
}
134+
135+
// Parse TTL (default: 1 hour = 3600000ms)
136+
const ttlMs = options.ttl ? parseInt(options.ttl) * 1000 : 3600000;
137+
138+
// Step 1: Upload build context
139+
logProgress(
140+
`⏳ [1/3] Creating and uploading build context tarball...`,
141+
options,
142+
);
143+
const uploadStart = Date.now();
144+
145+
const storageObject = await StorageObject.uploadFromDir(
146+
client,
147+
buildContextPath,
148+
{
149+
name: `build-context-${options.name}`,
150+
ttl_ms: ttlMs,
151+
},
152+
);
153+
154+
logProgress(
155+
`✅ [1/3] Build context uploaded (${formatElapsed(uploadStart)})`,
156+
options,
157+
);
158+
logProgress(` Object ID: ${storageObject.id}`, options);
159+
160+
// Step 2: Create the blueprint
161+
logProgress(`\n⏳ [2/3] Creating blueprint...`, options);
162+
const createStart = Date.now();
163+
164+
const createParams: BlueprintCreateParams = {
165+
name: options.name,
166+
dockerfile: dockerfileContents,
167+
system_setup_commands: options.systemSetupCommands,
168+
launch_parameters:
169+
launchParameters as BlueprintCreateParams["launch_parameters"],
170+
build_context: {
171+
type: "object",
172+
object_id: storageObject.id,
173+
},
174+
};
175+
176+
const blueprintResponse = await client.blueprints.create(createParams);
177+
178+
logProgress(
179+
`✅ [2/3] Blueprint created (${formatElapsed(createStart)})`,
180+
options,
181+
);
182+
logProgress(` Blueprint ID: ${blueprintResponse.id}`, options);
183+
184+
// Step 3: Wait for build to complete (unless --no-wait)
185+
if (options.noWait) {
186+
logProgress(`\n⏩ Skipping build wait (--no-wait specified)`, options);
187+
logProgress(
188+
` Check status with: rli blueprint get ${blueprintResponse.id}`,
189+
options,
190+
);
191+
logProgress(
192+
` View logs with: rli blueprint logs ${blueprintResponse.id}\n`,
193+
options,
194+
);
195+
output(blueprintResponse, {
196+
format: options.output,
197+
defaultFormat: "json",
198+
});
199+
return;
200+
}
201+
202+
logProgress(`\n⏳ [3/3] Waiting for build to complete...`, options);
203+
const buildStart = Date.now();
204+
let lastStatus = "";
205+
let pollCount = 0;
206+
207+
// Poll for completion
208+
while (true) {
209+
const blueprint = await client.blueprints.retrieve(blueprintResponse.id);
210+
const currentStatus = blueprint.status || "unknown";
211+
212+
// Log status changes
213+
if (currentStatus !== lastStatus) {
214+
const elapsed = formatElapsed(buildStart);
215+
logProgress(` Status: ${currentStatus} (${elapsed})`, options);
216+
lastStatus = currentStatus;
217+
} else if (pollCount % 10 === 0 && pollCount > 0) {
218+
// Log periodic updates even without status change (every ~30s)
219+
const elapsed = formatElapsed(buildStart);
220+
logProgress(` Still ${currentStatus}... (${elapsed})`, options);
221+
}
222+
223+
// Check for terminal states
224+
if (blueprint.status === "build_complete") {
225+
logProgress(
226+
`\n✅ [3/3] Build completed successfully! (${formatElapsed(buildStart)})`,
227+
options,
228+
);
229+
logProgress(`\n🎉 Blueprint "${options.name}" is ready!`, options);
230+
logProgress(` Total time: ${formatElapsed(startTime)}`, options);
231+
logProgress(` Blueprint ID: ${blueprint.id}\n`, options);
232+
output(blueprint, { format: options.output, defaultFormat: "json" });
233+
return;
234+
}
235+
236+
if (blueprint.status === "failed") {
237+
logProgress(
238+
`\n❌ [3/3] Build failed (${formatElapsed(buildStart)})`,
239+
options,
240+
);
241+
if (blueprint.failure_reason) {
242+
logProgress(` Reason: ${blueprint.failure_reason}`, options);
243+
}
244+
logProgress(
245+
` View logs with: rli blueprint logs ${blueprint.id}\n`,
246+
options,
247+
);
248+
outputError(`Blueprint build failed. Status: ${blueprint.status}`);
249+
}
250+
251+
// Wait before next poll (3 seconds)
252+
await new Promise((resolve) => setTimeout(resolve, 3000));
253+
pollCount++;
254+
}
255+
} catch (error) {
256+
logProgress(`\n❌ Failed after ${formatElapsed(startTime)}`, options);
257+
outputError("Failed to create blueprint from Dockerfile", error);
258+
}
259+
}

src/utils/commands.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,44 @@ export function createProgram(): Command {
477477
await pruneBlueprints(name, options);
478478
});
479479

480+
blueprint
481+
.command("from-dockerfile")
482+
.description(
483+
"Create a blueprint from a Dockerfile with build context support",
484+
)
485+
.requiredOption("--name <name>", "Blueprint name (required)")
486+
.option(
487+
"--build-context <path>",
488+
"Build context directory (default: current directory)",
489+
)
490+
.option(
491+
"--dockerfile <path>",
492+
"Dockerfile path (default: Dockerfile in build context)",
493+
)
494+
.option("--system-setup-commands <commands...>", "System setup commands")
495+
.option(
496+
"--resources <size>",
497+
"Resource size (X_SMALL, SMALL, MEDIUM, LARGE, X_LARGE, XX_LARGE)",
498+
)
499+
.option("--architecture <arch>", "Architecture (arm64, x86_64)")
500+
.option("--available-ports <ports...>", "Available ports")
501+
.option("--root", "Run as root")
502+
.option("--user <user:uid>", "Run as this user (format: username:uid)")
503+
.option(
504+
"--ttl <seconds>",
505+
"TTL in seconds for the build context object (default: 3600)",
506+
)
507+
.option(
508+
"-o, --output [format]",
509+
"Output format: text|json|yaml (default: json)",
510+
)
511+
.action(async (options) => {
512+
const { createBlueprintFromDockerfile } = await import(
513+
"../commands/blueprint/from-dockerfile.js"
514+
);
515+
await createBlueprintFromDockerfile(options);
516+
});
517+
480518
// Object storage commands
481519
const object = program
482520
.command("object")

0 commit comments

Comments
 (0)