Skip to content

Commit d71048c

Browse files
committed
Build + Deploy with Dockerfiles
1 parent 3ed8d5e commit d71048c

File tree

4 files changed

+337
-12
lines changed

4 files changed

+337
-12
lines changed

cmd/deploy.go

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"regexp"
77
"strings"
8+
"time"
89

910
"github.com/helmcode/coderun-cli/internal/client"
1011
"github.com/helmcode/coderun-cli/internal/utils"
@@ -13,24 +14,36 @@ import (
1314

1415
// deployCmd represents the deploy command
1516
var deployCmd = &cobra.Command{
16-
Use: "deploy IMAGE",
17-
Short: "Deploy a Docker container",
18-
Long: `Deploy a Docker container to the CodeRun platform.
17+
Use: "deploy [IMAGE]",
18+
Short: "Deploy a Docker container or build from source",
19+
Long: `Deploy a Docker container to the CodeRun platform or build from source.
1920
20-
Examples:
21+
Deploy from existing image:
2122
coderun deploy nginx:latest --name my-nginx
2223
coderun deploy my-app:v1.0 --name my-app --replicas 3 --cpu 500m --memory 1Gi
2324
coderun deploy my-app:latest --name web-app --http-port 8080 --env-file .env
2425
coderun deploy redis:latest --name my-redis --tcp-port 6379
2526
coderun deploy postgres:latest --name my-db --tcp-port 5432 --env-file database.env
2627
coderun deploy my-app:latest --name prod-app --replicas 2 --cpu 200m --memory 512Mi --http-port 3000 --env-file production.env
28+
29+
Build from source:
30+
coderun deploy --build . --name my-app
31+
coderun deploy --build ./my-app --name my-app --dockerfile Dockerfile.prod
32+
coderun deploy --build . --name web-app --http-port 8080 --env-file .env
2733
28-
# With persistent storage (automatically forces replicas to 1):
34+
With persistent storage (automatically forces replicas to 1):
2935
coderun deploy postgres:15 --name my-postgres --tcp-port 5432 --storage-size 5Gi --storage-path /var/lib/postgresql/data
3036
coderun deploy mysql:8 --name my-mysql --tcp-port 3306 --storage-size 10Gi --storage-path /var/lib/mysql
3137
coderun deploy nginx:latest --name web-server --http-port 80 --storage-size 1Gi --storage-path /usr/share/nginx/html`,
32-
Args: cobra.ExactArgs(1),
33-
Run: runDeploy,
38+
Args: func(cmd *cobra.Command, args []string) error {
39+
// If --build is specified, IMAGE argument is optional
40+
if buildContext != "" {
41+
return cobra.MaximumNArgs(0)(cmd, args)
42+
}
43+
// Otherwise, IMAGE argument is required
44+
return cobra.ExactArgs(1)(cmd, args)
45+
},
46+
Run: runDeploy,
3447
}
3548

3649
var (
@@ -43,6 +56,9 @@ var (
4356
appName string
4457
persistentVolumeSize string
4558
persistentVolumeMountPath string
59+
// Build flags
60+
buildContext string
61+
dockerfilePath string
4662
)
4763

4864
func init() {
@@ -60,6 +76,10 @@ func init() {
6076
// Persistent storage flags
6177
deployCmd.Flags().StringVar(&persistentVolumeSize, "storage-size", "", "Size of persistent volume (e.g., '1Gi', '500Mi', '10Gi')")
6278
deployCmd.Flags().StringVar(&persistentVolumeMountPath, "storage-path", "", "Path where to mount the volume (e.g., '/data', '/var/lib/mysql')")
79+
80+
// Build flags
81+
deployCmd.Flags().StringVar(&buildContext, "build", "", "Build from source. Specify the build context directory (e.g., './my-app' or '.')")
82+
deployCmd.Flags().StringVar(&dockerfilePath, "dockerfile", "Dockerfile", "Path to Dockerfile relative to build context (default: 'Dockerfile')")
6383
}
6484

6585
// parseValidationError tries to parse backend validation errors and return user-friendly messages
@@ -133,7 +153,18 @@ func parseValidationError(errorMsg string) string {
133153
}
134154

135155
func runDeploy(cmd *cobra.Command, args []string) {
136-
image := args[0]
156+
var image string
157+
158+
// Determine if we're building from source or deploying an existing image
159+
isBuild := buildContext != ""
160+
161+
if !isBuild {
162+
if len(args) == 0 {
163+
fmt.Println("Either specify an IMAGE to deploy or use --build to build from source")
164+
os.Exit(1)
165+
}
166+
image = args[0]
167+
}
137168

138169
// Load config
139170
config, err := utils.LoadConfig()
@@ -244,6 +275,74 @@ func runDeploy(cmd *cobra.Command, args []string) {
244275
fmt.Printf("Loaded %d environment variables from %s\n", len(envVars), envFile)
245276
}
246277

278+
// Create client
279+
apiClient := client.NewClient(config.BaseURL)
280+
apiClient.SetToken(config.AccessToken)
281+
282+
// Handle build from source
283+
if isBuild {
284+
fmt.Printf("Building from source in %s...\n", buildContext)
285+
286+
// Validate build context
287+
if _, err := os.Stat(buildContext); os.IsNotExist(err) {
288+
fmt.Printf("Build context directory does not exist: %s\n", buildContext)
289+
os.Exit(1)
290+
}
291+
292+
// Validate Dockerfile
293+
if err := utils.ValidateDockerfile(buildContext, dockerfilePath); err != nil {
294+
fmt.Printf("Error: %v\n", err)
295+
os.Exit(1)
296+
}
297+
298+
// Create build context archive
299+
contextArchivePath := utils.GenerateBuildContextPath(appName)
300+
defer os.Remove(contextArchivePath) // Clean up
301+
302+
fmt.Printf("Creating build context archive...\n")
303+
if err := utils.CreateBuildContext(buildContext, contextArchivePath); err != nil {
304+
fmt.Printf("Error creating build context: %v\n", err)
305+
os.Exit(1)
306+
}
307+
308+
// Upload and start build
309+
fmt.Printf("Uploading build context and starting build...\n")
310+
buildResp, err := apiClient.CreateBuild(contextArchivePath, appName, dockerfilePath)
311+
if err != nil {
312+
userFriendlyError := parseValidationError(err.Error())
313+
fmt.Printf("Build failed: %s\n", userFriendlyError)
314+
os.Exit(1)
315+
}
316+
317+
fmt.Printf("✅ Build started successfully!\n")
318+
fmt.Printf("Build ID: %s\n", buildResp.ID)
319+
fmt.Printf("Status: %s\n", buildResp.Status)
320+
fmt.Printf("Image URI: %s\n", buildResp.ImageURI)
321+
322+
// Wait for build to complete
323+
fmt.Printf("Waiting for build to complete...\n")
324+
for {
325+
time.Sleep(5 * time.Second)
326+
327+
status, err := apiClient.GetBuildStatus(buildResp.ID)
328+
if err != nil {
329+
fmt.Printf("Error checking build status: %v\n", err)
330+
os.Exit(1)
331+
}
332+
333+
fmt.Printf("Build status: %s\n", status.Status)
334+
335+
if status.Status == "completed" {
336+
fmt.Printf("✅ Build completed successfully!\n")
337+
image = status.ImageURI
338+
break
339+
} else if status.Status == "failed" {
340+
fmt.Printf("❌ Build failed!\n")
341+
os.Exit(1)
342+
}
343+
}
344+
}
345+
247346
// Create deployment request
248347
deployReq := client.DeploymentCreate{
249348
AppName: appName,
@@ -270,17 +369,20 @@ func runDeploy(cmd *cobra.Command, args []string) {
270369
deployReq.TCPPort = &tcpPort
271370
}
272371

273-
// Create client and deploy
274-
apiClient := client.NewClient(config.BaseURL)
275-
apiClient.SetToken(config.AccessToken)
372+
// Deploy the application
373+
if isBuild {
374+
fmt.Printf("Deploying built image %s...\n", image)
375+
} else {
376+
fmt.Printf("Deploying %s...\n", image)
377+
}
276378

277-
fmt.Printf("Deploying %s...\n", image)
278379
if httpPort > 0 {
279380
fmt.Println("ℹ️ Note: Deploy with HTTP port may take several minutes (waiting for TLS certificate)")
280381
}
281382
if tcpPort > 0 {
282383
fmt.Println("ℹ️ Note: Deploy with TCP port will be available in the NodePort range (30000-32767)")
283384
}
385+
284386
deployment, err := apiClient.CreateDeployment(&deployReq)
285387
if err != nil {
286388
userFriendlyError := parseValidationError(err.Error())
@@ -318,4 +420,8 @@ func runDeploy(cmd *cobra.Command, args []string) {
318420

319421
fmt.Printf("Status: %s\n", deployment.Status)
320422
fmt.Printf("Created: %s\n", deployment.CreatedAt.Format("2006-01-02 15:04:05"))
423+
424+
if isBuild {
425+
fmt.Println("\n🚀 Successfully built and deployed from source!")
426+
}
321427
}

internal/client/builds.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package client
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"mime/multipart"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
)
13+
14+
// CreateBuild uploads a build context and creates a build
15+
func (c *Client) CreateBuild(contextPath, appName, dockerfilePath string) (*BuildResponse, error) {
16+
// Open the context file
17+
file, err := os.Open(contextPath)
18+
if err != nil {
19+
return nil, fmt.Errorf("failed to open context file: %w", err)
20+
}
21+
defer file.Close()
22+
23+
// Create a buffer for the multipart form
24+
var body bytes.Buffer
25+
writer := multipart.NewWriter(&body)
26+
27+
// Add the context file
28+
part, err := writer.CreateFormFile("context_file", filepath.Base(contextPath))
29+
if err != nil {
30+
return nil, fmt.Errorf("failed to create form file: %w", err)
31+
}
32+
33+
_, err = io.Copy(part, file)
34+
if err != nil {
35+
return nil, fmt.Errorf("failed to copy file content: %w", err)
36+
}
37+
38+
// Add other form fields
39+
if err := writer.WriteField("app_name", appName); err != nil {
40+
return nil, fmt.Errorf("failed to write app_name field: %w", err)
41+
}
42+
43+
if err := writer.WriteField("dockerfile_path", dockerfilePath); err != nil {
44+
return nil, fmt.Errorf("failed to write dockerfile_path field: %w", err)
45+
}
46+
47+
// Close the writer to finalize the form
48+
if err := writer.Close(); err != nil {
49+
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
50+
}
51+
52+
// Create the HTTP request
53+
req, err := http.NewRequest("POST", c.BaseURL+"/api/v1/builds/upload", &body)
54+
if err != nil {
55+
return nil, fmt.Errorf("failed to create request: %w", err)
56+
}
57+
58+
// Set headers
59+
req.Header.Set("Content-Type", writer.FormDataContentType())
60+
if c.Token != "" {
61+
req.Header.Set("Authorization", "Bearer "+c.Token)
62+
}
63+
64+
// Make the request
65+
resp, err := c.HTTPClient.Do(req)
66+
if err != nil {
67+
return nil, fmt.Errorf("request failed: %w", err)
68+
}
69+
defer resp.Body.Close()
70+
71+
if resp.StatusCode != http.StatusOK {
72+
return nil, handleAPIError(resp)
73+
}
74+
75+
var buildResp BuildResponse
76+
if err := json.NewDecoder(resp.Body).Decode(&buildResp); err != nil {
77+
return nil, fmt.Errorf("failed to decode build response: %w", err)
78+
}
79+
80+
return &buildResp, nil
81+
}
82+
83+
// GetBuildStatus gets build status by ID
84+
func (c *Client) GetBuildStatus(buildID string) (*BuildResponse, error) {
85+
resp, err := c.makeRequest("GET", "/api/v1/builds/"+buildID, nil)
86+
if err != nil {
87+
return nil, err
88+
}
89+
defer resp.Body.Close()
90+
91+
if resp.StatusCode != http.StatusOK {
92+
return nil, handleAPIError(resp)
93+
}
94+
95+
var buildResp BuildResponse
96+
if err := json.NewDecoder(resp.Body).Decode(&buildResp); err != nil {
97+
return nil, fmt.Errorf("failed to decode build response: %w", err)
98+
}
99+
100+
return &buildResp, nil
101+
}

internal/client/types.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,24 @@ type DeploymentLogsResponse struct {
111111
Error string `json:"error,omitempty"`
112112
Logs map[string]PodLogs `json:"logs"`
113113
}
114+
115+
// BuildRequest represents a build request (used for uploading context)
116+
type BuildRequest struct {
117+
AppName string `json:"app_name"`
118+
DockerfilePath string `json:"dockerfile_path"`
119+
}
120+
121+
// BuildResponse represents a build response
122+
type BuildResponse struct {
123+
ID string `json:"id"`
124+
ClientID string `json:"client_id"`
125+
AppName string `json:"app_name"`
126+
Tag string `json:"tag"`
127+
Status string `json:"status"`
128+
ImageURI string `json:"image_uri"`
129+
DockerfilePath string `json:"dockerfile_path"`
130+
K8sJobName string `json:"k8s_job_name"`
131+
CreatedAt time.Time `json:"created_at"`
132+
StartedAt *time.Time `json:"started_at"`
133+
FinishedAt *time.Time `json:"finished_at"`
134+
}

0 commit comments

Comments
 (0)