66 "io"
77 "os"
88 "os/exec"
9+ "path"
910 "path/filepath"
1011 "runtime"
1112
@@ -35,10 +36,33 @@ func (m Mode) String() string {
3536// Detect examines the directory and returns the appropriate build mode.
3637// Priority: Dockerfile → Nixpacks fallback.
3738func Detect (dir string ) Mode {
38- if _ , err := os .Stat (filepath .Join (dir , "Dockerfile" )); err == nil {
39- return ModeDockerfile
39+ mode , _ := DetectAt (dir , "" )
40+ return mode
41+ }
42+
43+ // DetectAt resolves the build mode for a given context directory and
44+ // optional Dockerfile (relative to the context; empty means "Dockerfile").
45+ // A Dockerfile at the resolved path selects ModeDockerfile. When the caller
46+ // named a Dockerfile explicitly and it is missing, that is an error rather
47+ // than a silent fall-through to Nixpacks — an explicit path that points at
48+ // nothing is a config mistake worth surfacing. With no explicit Dockerfile
49+ // and none present, it falls back to ModeNixpacks.
50+ func DetectAt (contextDir , dockerfile string ) (Mode , error ) {
51+ if contextDir == "" {
52+ contextDir = "."
53+ }
54+ name := dockerfile
55+ if name == "" {
56+ name = "Dockerfile"
4057 }
41- return ModeNixpacks
58+ resolved := filepath .Join (contextDir , name )
59+ if _ , err := os .Stat (resolved ); err == nil {
60+ return ModeDockerfile , nil
61+ }
62+ if dockerfile != "" {
63+ return ModeNone , fmt .Errorf ("dockerfile %q not found (looked at %s)" , dockerfile , resolved )
64+ }
65+ return ModeNixpacks , nil
4266}
4367
4468// ImageTag returns the image tag used for server-built images.
@@ -51,8 +75,14 @@ type BuildConfig struct {
5175 App string
5276 Version string
5377 Mode Mode
54- BuildDir string // remote directory containing the source
55- Platform string // e.g. "linux/arm64" (optional)
78+ BuildDir string // remote directory containing the synced source
79+ // Context is the build-context subdirectory relative to BuildDir
80+ // (empty or "." means BuildDir itself).
81+ Context string
82+ // Dockerfile is the Dockerfile path relative to Context (empty means
83+ // "Dockerfile" in the context root).
84+ Dockerfile string
85+ Platform string // e.g. "linux/arm64" (optional)
5686}
5787
5888// Builder runs Docker or Nixpacks builds on the server via SSH.
@@ -72,34 +102,70 @@ func (b *Builder) Build(ctx context.Context, cfg BuildConfig) (string, error) {
72102
73103 switch cfg .Mode {
74104 case ModeDockerfile :
75- return tag , b .buildDockerfile (ctx , tag , cfg .BuildDir , cfg .Platform )
105+ return tag , b .buildDockerfile (ctx , tag , cfg .BuildDir , cfg .Context , cfg . Dockerfile , cfg . Platform )
76106 case ModeNixpacks :
77- return tag , b .buildNixpacks (ctx , tag , cfg .App , cfg .BuildDir )
107+ return tag , b .buildNixpacks (ctx , tag , cfg .App , cfg .BuildDir , cfg . Context )
78108 default :
79109 return "" , fmt .Errorf ("unknown build mode: %s" , cfg .Mode )
80110 }
81111}
82112
83- func (b * Builder ) buildDockerfile (ctx context.Context , tag , buildDir , platform string ) error {
113+ func (b * Builder ) buildDockerfile (ctx context.Context , tag , buildDir , contextSub , dockerfile , platform string ) error {
84114 cmd := "docker build -t " + tag
85115 if platform != "" {
86116 cmd += " --platform " + platform
87117 }
88- cmd += " " + buildDir
118+ // Remote paths are POSIX; use path.Join and quote for the shell.
119+ cmd += remoteBuildTail (buildDir , contextSub , dockerfile )
89120 return b .exec .RunStream (ctx , cmd , b .stdout , b .stdout )
90121}
91122
92- func (b * Builder ) buildNixpacks (ctx context.Context , tag , app , buildDir string ) error {
123+ func (b * Builder ) buildNixpacks (ctx context.Context , tag , app , buildDir , contextSub string ) error {
93124 // Ensure Nixpacks is installed (lazy installation).
94125 if err := b .ensureNixpacks (ctx ); err != nil {
95126 return err
96127 }
97128
98129 cachePath := fmt .Sprintf ("/deployments/%s/cache" , app )
99- cmd := fmt .Sprintf ("nixpacks build %s --name %s --cache-path %s" , buildDir , tag , cachePath )
130+ buildTarget := subDir (path .Join , buildDir , contextSub )
131+ cmd := fmt .Sprintf ("nixpacks build %s --name %s --cache-path %s" , buildTarget , tag , cachePath )
100132 return b .exec .RunStream (ctx , cmd , b .stdout , b .stdout )
101133}
102134
135+ // subDir joins an optional context subdir onto a root, returning the root
136+ // unchanged for "" or ".". join is path.Join for remote (POSIX) paths and
137+ // filepath.Join for local ones.
138+ func subDir (join func (... string ) string , root , contextSub string ) string {
139+ if contextSub == "" || contextSub == "." {
140+ return root
141+ }
142+ return join (root , contextSub )
143+ }
144+
145+ // remoteBuildTail builds the trailing docker-build arguments for a remote
146+ // (shell-string) build: an optional `-f <dockerfile>` plus the context dir.
147+ // When neither Context nor Dockerfile is customized it returns exactly
148+ // " <root>" — byte-identical to the long-standing default command — so the
149+ // common case is unchanged. Custom paths are quoted for the remote shell.
150+ func remoteBuildTail (root , contextSub , dockerfile string ) string {
151+ if contextSub == "" && dockerfile == "" {
152+ return " " + root
153+ }
154+ contextDir := subDir (path .Join , root , contextSub )
155+ tail := ""
156+ if dockerfile != "" {
157+ if dfPath := path .Join (contextDir , dockerfile ); dfPath != path .Join (contextDir , "Dockerfile" ) {
158+ tail += " -f " + ssh .ShellQuote (dfPath )
159+ }
160+ }
161+ if contextDir == root {
162+ tail += " " + root
163+ } else {
164+ tail += " " + ssh .ShellQuote (contextDir )
165+ }
166+ return tail
167+ }
168+
103169func (b * Builder ) ensureNixpacks (ctx context.Context ) error {
104170 if _ , err := b .exec .Run (ctx , "which nixpacks" ); err == nil {
105171 return nil
@@ -111,15 +177,21 @@ func (b *Builder) ensureNixpacks(ctx context.Context) error {
111177
112178// LocalBuildConfig holds parameters for building locally and streaming to server.
113179type LocalBuildConfig struct {
114- App string
115- Version string
116- Mode Mode
117- Dir string // local source directory
118- Host string
119- User string
120- KeyPath string
121- Platform string // e.g. "linux/arm64" (optional, overrides auto-detection)
122- Exec ssh.Executor // optional: if set, enables layer-optimized transfer
180+ App string
181+ Version string
182+ Mode Mode
183+ Dir string // local source directory
184+ // Context is the build-context subdirectory relative to Dir (empty or
185+ // "." means Dir itself).
186+ Context string
187+ // Dockerfile is the Dockerfile path relative to Context (empty means
188+ // "Dockerfile" in the context root).
189+ Dockerfile string
190+ Host string
191+ User string
192+ KeyPath string
193+ Platform string // e.g. "linux/arm64" (optional, overrides auto-detection)
194+ Exec ssh.Executor // optional: if set, enables layer-optimized transfer
123195}
124196
125197// LocalBuild builds the image on the local machine, then streams it to the
@@ -130,11 +202,11 @@ func LocalBuild(ctx context.Context, cfg LocalBuildConfig, stdout io.Writer) (st
130202 // Build locally.
131203 switch cfg .Mode {
132204 case ModeDockerfile :
133- if err := localBuildDockerfile (ctx , tag , cfg .Dir , cfg .Platform , stdout ); err != nil {
205+ if err := localBuildDockerfile (ctx , tag , cfg .Dir , cfg .Context , cfg . Dockerfile , cfg . Platform , stdout ); err != nil {
134206 return "" , err
135207 }
136208 case ModeNixpacks :
137- if err := localBuildNixpacks (ctx , tag , cfg .Dir , stdout ); err != nil {
209+ if err := localBuildNixpacks (ctx , tag , subDir ( filepath . Join , cfg .Dir , cfg . Context ) , stdout ); err != nil {
138210 return "" , err
139211 }
140212 default :
@@ -160,7 +232,7 @@ func LocalBuild(ctx context.Context, cfg LocalBuildConfig, stdout io.Writer) (st
160232 return tag , nil
161233}
162234
163- func localBuildDockerfile (ctx context.Context , tag , dir , platform string , stdout io.Writer ) error {
235+ func localBuildDockerfile (ctx context.Context , tag , dir , contextSub , dockerfile , platform string , stdout io.Writer ) error {
164236 args := []string {"build" , "-t" , tag }
165237
166238 if platform != "" {
@@ -171,7 +243,8 @@ func localBuildDockerfile(ctx context.Context, tag, dir, platform string, stdout
171243 args = append (args , "--platform" , "linux/amd64" )
172244 }
173245
174- args = append (args , dir )
246+ // exec.Command takes an argv, so no shell quoting is needed here.
247+ args = localBuildTail (args , dir , contextSub , dockerfile )
175248 cmd := exec .CommandContext (ctx , "docker" , args ... )
176249 cmd .Stdout = stdout
177250 cmd .Stderr = stdout
@@ -181,6 +254,23 @@ func localBuildDockerfile(ctx context.Context, tag, dir, platform string, stdout
181254 return nil
182255}
183256
257+ // localBuildTail appends the trailing docker-build arguments for a local
258+ // (argv) build: an optional `-f <dockerfile>` plus the context dir. Mirrors
259+ // remoteBuildTail but produces argv entries (no shell quoting) using host
260+ // path semantics.
261+ func localBuildTail (args []string , dir , contextSub , dockerfile string ) []string {
262+ if contextSub == "" && dockerfile == "" {
263+ return append (args , dir )
264+ }
265+ contextDir := subDir (filepath .Join , dir , contextSub )
266+ if dockerfile != "" {
267+ if dfPath := filepath .Join (contextDir , dockerfile ); dfPath != filepath .Join (contextDir , "Dockerfile" ) {
268+ args = append (args , "-f" , dfPath )
269+ }
270+ }
271+ return append (args , contextDir )
272+ }
273+
184274func localBuildNixpacks (ctx context.Context , tag , dir string , stdout io.Writer ) error {
185275 cmd := exec .CommandContext (ctx , "nixpacks" , "build" , dir , "--name" , tag )
186276 cmd .Stdout = stdout
0 commit comments