diff --git a/internal/boxcli/generate.go b/internal/boxcli/generate.go index e637fe0dbf6..7ac7b83b225 100644 --- a/internal/boxcli/generate.go +++ b/internal/boxcli/generate.go @@ -28,6 +28,11 @@ type generateCmdFlags struct { rootUser bool } +type generateDockerfileCmdFlags struct { + generateCmdFlags + forType string +} + type GenerateReadmeCmdFlags struct { generateCmdFlags saveTemplate bool @@ -94,7 +99,7 @@ func devcontainerCmd() *cobra.Command { } func dockerfileCmd() *cobra.Command { - flags := &generateCmdFlags{} + flags := &generateDockerfileCmdFlags{} command := &cobra.Command{ Use: "dockerfile", Short: "Generate a Dockerfile that replicates devbox shell", @@ -102,9 +107,25 @@ func dockerfileCmd() *cobra.Command { "Can be used to run devbox shell environment in an OCI container.", Args: cobra.MaximumNArgs(0), RunE: func(cmd *cobra.Command, args []string) error { - return runGenerateCmd(cmd, flags) + box, err := devbox.Open(&devopt.Opts{ + Dir: flags.config.path, + Environment: flags.config.environment, + Stderr: cmd.ErrOrStderr(), + }) + if err != nil { + return errors.WithStack(err) + } + return box.GenerateDockerfile(cmd.Context(), devopt.GenerateOpts{ + ForType: flags.forType, + Force: flags.force, + RootUser: flags.rootUser, + }) }, } + command.Flags().StringVar( + &flags.forType, "for", "dev", + "Generate Dockerfile for a specific type of container (dev, prod)") + command.Flag("for").Hidden = true command.Flags().BoolVarP( &flags.force, "force", "f", false, "force overwrite existing files") command.Flags().BoolVar( @@ -264,8 +285,6 @@ func runGenerateCmd(cmd *cobra.Command, flags *generateCmdFlags) error { return box.Generate(cmd.Context()) case "devcontainer": return box.GenerateDevcontainer(cmd.Context(), generateOpts) - case "dockerfile": - return box.GenerateDockerfile(cmd.Context(), generateOpts) } return nil } diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index a8039f25960..a87116de16b 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -451,7 +451,7 @@ func (d *Devbox) GenerateDevcontainer(ctx context.Context, generateOpts devopt.G } // generate dockerfile - err = gen.CreateDockerfile(ctx) + err = gen.CreateDockerfile(ctx, generate.CreateDockerfileOptions{}) if err != nil { return redact.Errorf("error generating dev container Dockerfile in /%s: %w", redact.Safe(filepath.Base(devContainerPath)), err) @@ -489,8 +489,15 @@ func (d *Devbox) GenerateDockerfile(ctx context.Context, generateOpts devopt.Gen LocalFlakeDirs: d.getLocalFlakesDirs(), } + scripts := d.cfg.Scripts() + // generate dockerfile - return errors.WithStack(gen.CreateDockerfile(ctx)) + return errors.WithStack(gen.CreateDockerfile(ctx, generate.CreateDockerfileOptions{ + ForType: generateOpts.ForType, + HasBuild: scripts["build"] != nil, + HasInstall: scripts["install"] != nil, + HasStart: scripts["start"] != nil, + })) } func PrintEnvrcContent(w io.Writer, envFlags devopt.EnvFlags) error { diff --git a/internal/devbox/devopt/devboxopts.go b/internal/devbox/devopt/devboxopts.go index 38483bc41c7..249dccfe550 100644 --- a/internal/devbox/devopt/devboxopts.go +++ b/internal/devbox/devopt/devboxopts.go @@ -20,6 +20,7 @@ type Opts struct { } type GenerateOpts struct { + ForType string Force bool RootUser bool } diff --git a/internal/devbox/generate/devcontainer_util.go b/internal/devbox/generate/devcontainer_util.go index af7b6b9ef26..4d891824351 100644 --- a/internal/devbox/generate/devcontainer_util.go +++ b/internal/devbox/generate/devcontainer_util.go @@ -6,18 +6,21 @@ package generate // package generate has functionality to implement the `devbox generate` command import ( + "cmp" "context" "embed" "encoding/json" "fmt" - "html/template" "io" "os" "path/filepath" "regexp" "runtime/trace" "strings" + "text/template" + "github.com/samber/lo" + "go.jetpack.io/devbox/internal/boxcli/usererr" "go.jetpack.io/devbox/internal/debug" "go.jetpack.io/devbox/internal/devbox/devopt" ) @@ -54,30 +57,65 @@ type vscode struct { Extensions []string `json:"extensions"` } -type dockerfileData struct { - IsDevcontainer bool - RootUser bool - LocalFlakeDirs []string +type CreateDockerfileOptions struct { + ForType string + HasInstall bool + HasBuild bool + HasStart bool + // Ideally we also support process-compose services as the dockerfile + // CMD, but I'm currently having trouble getting that to work. Will revisit. + // HasServices bool +} + +func (opts CreateDockerfileOptions) Type() string { + return cmp.Or(opts.ForType, "dev") +} + +func (opts CreateDockerfileOptions) validate() error { + if opts.Type() == "dev" { + return nil + } else if opts.Type() == "prod" { + if opts.HasStart { + return nil + } + return usererr.New( + "To generate a prod Dockerfile you must have either 'start' script in " + + "devbox.json", + ) + } + return usererr.New( + "invalid Dockerfile type. Only 'dev' and 'prod' are supported") } -// CreateDockerfile creates a Dockerfile in path and writes devcontainerDockerfile.tmpl's content into it -func (g *Options) CreateDockerfile(ctx context.Context) error { +// CreateDockerfile creates a Dockerfile in path. +func (g *Options) CreateDockerfile( + ctx context.Context, + opts CreateDockerfileOptions, +) error { defer trace.StartRegion(ctx, "createDockerfile").End() + if err := opts.validate(); err != nil { + return err + } + // create dockerfile file, err := os.Create(filepath.Join(g.Path, "Dockerfile")) if err != nil { return err } defer file.Close() - // get dockerfile content - tmplName := "devcontainerDockerfile.tmpl" - t := template.Must(template.ParseFS(tmplFS, "tmpl/"+tmplName)) + path := fmt.Sprintf("tmpl/%s.Dockerfile.tmpl", opts.Type()) + t := template.Must(template.ParseFS(tmplFS, path)) // write content into file - return t.Execute(file, &dockerfileData{ - IsDevcontainer: g.IsDevcontainer, - RootUser: g.RootUser, - LocalFlakeDirs: g.LocalFlakeDirs, + return t.Execute(file, map[string]any{ + "IsDevcontainer": g.IsDevcontainer, + "RootUser": g.RootUser, + "LocalFlakeDirs": g.LocalFlakeDirs, + + // The following are only used for prod Dockerfile + "DevboxRunInstall": lo.Ternary(opts.HasInstall, "devbox run install", "echo 'No install script found, skipping'"), + "DevboxRunBuild": lo.Ternary(opts.HasBuild, "devbox run build", "echo 'No build script found, skipping'"), + "Cmd": fmt.Sprintf("%q, %q, %q", "devbox", "run", "start"), }) } diff --git a/internal/devbox/generate/tmpl/devcontainerDockerfile.tmpl b/internal/devbox/generate/tmpl/dev.Dockerfile.tmpl similarity index 100% rename from internal/devbox/generate/tmpl/devcontainerDockerfile.tmpl rename to internal/devbox/generate/tmpl/dev.Dockerfile.tmpl diff --git a/internal/devbox/generate/tmpl/prod.Dockerfile.tmpl b/internal/devbox/generate/tmpl/prod.Dockerfile.tmpl new file mode 100644 index 00000000000..00310ad41f3 --- /dev/null +++ b/internal/devbox/generate/tmpl/prod.Dockerfile.tmpl @@ -0,0 +1,28 @@ +FROM jetpackio/devbox:latest + +WORKDIR /code +USER root:root +RUN mkdir -p /code && chown ${DEVBOX_USER}:${DEVBOX_USER} /code +USER ${DEVBOX_USER}:${DEVBOX_USER} + +{{- /* +Ideally, we first copy over devbox.json and devbox.lock and run `devbox install` +to create a cache layer for the dependencies. This is complicated because +devbox.json may include local dependencies (flakes and plugins). We could try +to copy those in (the way the dev Dockerfile does) but that's brittle because +those dependencies may also pull in other local dependencies and so on. Another +sulution would be to add a new flag `devbox install --skip-errors` that would +just try to install what it can, and ignore the rest. + +A hack to make this simpler is to install from the lockfile instead of the json. +*/}} + +COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} . . + +RUN devbox install + +RUN {{ .DevboxRunInstall }} + +RUN {{ .DevboxRunBuild }} + +CMD [{{ .Cmd }}]