├── node ├── bin │ └── new-dockerfile ├── LICENSE ├── package.json ├── README.md └── install.js ├── testdata ├── bun │ ├── bun.lockb │ └── package.json ├── go │ ├── main.go │ └── .tool-versions ├── php │ └── index.php ├── python │ ├── main.py │ └── requirements.txt ├── bun-mise │ ├── bun.lockb │ ├── .mise.toml │ └── package.json ├── go-mise │ ├── main.go │ └── .mise.toml ├── node-yarn │ ├── yarn.lock │ ├── .nvmrc │ └── package.json ├── php-npm │ ├── index.php │ ├── yarn.lock │ ├── .tool-versions │ └── package.json ├── python-mise │ ├── main.py │ ├── requirements.txt │ └── .mise.toml ├── python-pdm │ ├── app.py │ ├── pdm.lock │ └── .python-version ├── static │ └── index.html ├── bun-bunfig │ ├── bunfig.toml │ ├── .tool-versions │ └── package.json ├── node-pnpm │ ├── pnpm-lock.yaml │ ├── .tool-versions │ └── package.json ├── node │ ├── package-lock.json │ └── package.json ├── php-composer │ ├── index.php │ └── composer.json ├── python-django │ ├── manage.py │ ├── .tool-versions │ ├── Pipfile │ └── Pipfile.lock ├── python-fastapi │ ├── main.py │ ├── .tool-versions │ ├── Pipfile │ └── Pipfile.lock ├── ruby-config-ru │ ├── config.ru │ ├── .tool-versions │ └── Gemfile ├── elixir │ ├── .elixir-version │ ├── .erlang-version │ └── mix.exs ├── nextjs │ ├── package.json │ └── next.config.js ├── node-mise │ ├── package-lock.json │ ├── .mise.toml │ └── package.json ├── python-poetry │ ├── app │ │ └── main.py │ ├── poetry.lock │ └── runtime.txt ├── ruby-rails │ ├── pnpm-lock.yaml │ ├── package.json │ └── Gemfile ├── static-dist │ └── dist │ │ └── index.html ├── elixir-mise │ ├── .elixir-version │ ├── .mise.toml │ └── mix.exs ├── static-public │ └── public │ │ └── index.html ├── static-static │ └── static │ │ └── index.html ├── deno-jsonc │ ├── .tool-versions │ ├── deps.ts │ ├── deno.jsonc │ └── mod.ts ├── nextjs-standalone │ ├── .node-version │ ├── package.json │ └── next.config.mjs ├── node-engines │ ├── package-lock.json │ └── package.json ├── go-mod │ ├── cmd │ │ └── hello │ │ │ └── hello.go │ └── go.mod ├── ruby-config-environment │ ├── .ruby-version │ ├── config │ │ └── environment.rb │ └── Gemfile ├── ruby-mise │ ├── .mise.toml │ └── Gemfile ├── deno-mise │ ├── .mise.toml │ ├── deps.ts │ └── main.ts ├── elixir-tool-versions │ ├── .tool-versions │ └── mix.exs ├── python-pyproject │ └── pyproject.toml ├── deno │ ├── deps.ts │ └── main.ts ├── ruby-rakefile │ └── Gemfile ├── ruby │ └── Gemfile ├── rust │ └── Cargo.toml └── rust-bin │ └── Cargo.toml ├── .tool-versions ├── .gitignore ├── runtime ├── main_test.go ├── main.go ├── static.go ├── rust_test.go ├── golang_test.go ├── elixir_test.go ├── static_test.go ├── deno_test.go ├── nextjs_test.go ├── bun_test.go ├── php_test.go ├── rust.go ├── node_test.go ├── python_test.go ├── ruby_test.go ├── golang.go ├── bun.go ├── nextjs.go ├── deno.go ├── php.go ├── elixir.go ├── ruby.go ├── node.go ├── java.go └── python.go ├── Dockerfile ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── pr.yml │ └── release.yml ├── .goreleaser.yaml ├── LICENSE ├── go.mod ├── CONTRIBUTING.md ├── main.go ├── cmd └── new-dockerfile │ └── main.go ├── CODE_OF_CONDUCT.md └── go.sum /node/bin/new-dockerfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/bun/bun.lockb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/go/main.go: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/php/index.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python/main.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.21.5 -------------------------------------------------------------------------------- /testdata/bun-mise/bun.lockb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/go-mise/main.go: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/node-yarn/yarn.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/php-npm/index.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/php-npm/yarn.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python-mise/main.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python-pdm/app.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python-pdm/pdm.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/static/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/bun-bunfig/bunfig.toml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/node-pnpm/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/node-yarn/.nvmrc: -------------------------------------------------------------------------------- 1 | v16.0.0 -------------------------------------------------------------------------------- /testdata/node/package-lock.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/php-composer/index.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python-django/manage.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python-fastapi/main.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python/requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/ruby-config-ru/config.ru: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/elixir/.elixir-version: -------------------------------------------------------------------------------- 1 | 1.10 -------------------------------------------------------------------------------- /testdata/elixir/.erlang-version: -------------------------------------------------------------------------------- 1 | 22.3.2 -------------------------------------------------------------------------------- /testdata/nextjs/package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /testdata/node-mise/package-lock.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python-mise/requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python-poetry/app/main.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/python-poetry/poetry.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/ruby-rails/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/static-dist/dist/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/bun-bunfig/.tool-versions: -------------------------------------------------------------------------------- 1 | bun 1.1.4 -------------------------------------------------------------------------------- /testdata/elixir-mise/.elixir-version: -------------------------------------------------------------------------------- 1 | 1.10 -------------------------------------------------------------------------------- /testdata/go/.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.16.3 -------------------------------------------------------------------------------- /testdata/php-npm/.tool-versions: -------------------------------------------------------------------------------- 1 | php 8.2.0 -------------------------------------------------------------------------------- /testdata/python-pdm/.python-version: -------------------------------------------------------------------------------- 1 | 3.4.1 -------------------------------------------------------------------------------- /testdata/ruby-rails/package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /testdata/static-public/public/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/static-static/static/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/deno-jsonc/.tool-versions: -------------------------------------------------------------------------------- 1 | deno 1.43.3 -------------------------------------------------------------------------------- /testdata/nextjs-standalone/.node-version: -------------------------------------------------------------------------------- 1 | 16.0.0 -------------------------------------------------------------------------------- /testdata/nextjs-standalone/package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /testdata/node-engines/package-lock.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /testdata/node-pnpm/.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 16.0.0 -------------------------------------------------------------------------------- /testdata/python-poetry/runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.8.5 -------------------------------------------------------------------------------- /testdata/ruby-config-ru/.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 2.3.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist/ 3 | .idea/ 4 | tmp/ -------------------------------------------------------------------------------- /testdata/bun-mise/.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | bun = "1.1.3" -------------------------------------------------------------------------------- /testdata/go-mise/.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | go = "1.16" -------------------------------------------------------------------------------- /testdata/go-mod/cmd/hello/hello.go: -------------------------------------------------------------------------------- 1 | package hello 2 | -------------------------------------------------------------------------------- /testdata/node-mise/.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "14" -------------------------------------------------------------------------------- /testdata/python-django/.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.6.0 -------------------------------------------------------------------------------- /testdata/python-fastapi/.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.6.0 -------------------------------------------------------------------------------- /testdata/ruby-config-environment/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.1 -------------------------------------------------------------------------------- /testdata/ruby-mise/.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | ruby = "2.7" -------------------------------------------------------------------------------- /testdata/deno-mise/.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | deno = "1.43.2" -------------------------------------------------------------------------------- /testdata/elixir-mise/.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | erlang = "23" -------------------------------------------------------------------------------- /testdata/python-mise/.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | python = "3.8" -------------------------------------------------------------------------------- /testdata/ruby-config-environment/config/environment.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/go-mod/go.mod: -------------------------------------------------------------------------------- 1 | module helloworld 2 | 3 | go 1.22.3 4 | -------------------------------------------------------------------------------- /testdata/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "index.ts" 3 | } 4 | -------------------------------------------------------------------------------- /testdata/bun/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "index.ts" 3 | } 4 | -------------------------------------------------------------------------------- /testdata/node-mise/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "index.ts" 3 | } 4 | -------------------------------------------------------------------------------- /testdata/bun-mise/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "index.ts" 3 | } 4 | -------------------------------------------------------------------------------- /testdata/elixir-tool-versions/.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.11 2 | erlang 23.2.1 -------------------------------------------------------------------------------- /testdata/python-pyproject/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pyproject" -------------------------------------------------------------------------------- /testdata/nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | }; 4 | -------------------------------------------------------------------------------- /testdata/deno/deps.ts: -------------------------------------------------------------------------------- 1 | export { serve } from "https://deno.land/std@0.77.0/http/server.ts"; 2 | -------------------------------------------------------------------------------- /testdata/nextjs-standalone/next.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | output: "standalone", 3 | }; 4 | -------------------------------------------------------------------------------- /testdata/deno-jsonc/deps.ts: -------------------------------------------------------------------------------- 1 | export { serve } from "https://deno.land/std@0.77.0/http/server.ts"; 2 | -------------------------------------------------------------------------------- /testdata/deno-mise/deps.ts: -------------------------------------------------------------------------------- 1 | export { serve } from "https://deno.land/std@0.77.0/http/server.ts"; 2 | -------------------------------------------------------------------------------- /testdata/ruby-rakefile/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | -------------------------------------------------------------------------------- /testdata/ruby-config-ru/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | -------------------------------------------------------------------------------- /testdata/ruby-config-environment/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | -------------------------------------------------------------------------------- /testdata/php-npm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "echo 'Building the project...'" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /testdata/ruby-rails/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails" -------------------------------------------------------------------------------- /testdata/node-engines/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "index.ts", 3 | "engines": { 4 | "node": ">=14.5.3 <=15.2.1" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /testdata/ruby/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | ruby '2.0.0', :patchlevel => '353' 6 | -------------------------------------------------------------------------------- /testdata/ruby-mise/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | ruby '2.0.0', :patchlevel => '353' 6 | -------------------------------------------------------------------------------- /testdata/deno-jsonc/deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "start": "deno run --allow-net mod.ts", 4 | "cache": "deno cache mod.ts" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /testdata/php-composer/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test/test", 3 | "description": "Test", 4 | "require": { 5 | "php": ">=5.3.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /testdata/python-django/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests = "*" 8 | django = "*" 9 | 10 | [dev-packages] 11 | -------------------------------------------------------------------------------- /testdata/python-fastapi/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests = "*" 8 | fastapi = "*" 9 | 10 | [dev-packages] 11 | -------------------------------------------------------------------------------- /testdata/node-yarn/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "index.ts", 3 | "scripts": { 4 | "build:prod": "NODE_ENV=production node build", 5 | "build": "node build", 6 | "start-it": "node index.mjs" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /testdata/bun-bunfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "index.ts", 3 | "scripts": { 4 | "build:prod": "NODE_ENV=production bun build", 5 | "build": "bun build", 6 | "start:production": "bun index.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /testdata/node-pnpm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "index.ts", 3 | "scripts": { 4 | "build:prod": "NODE_ENV=production node build", 5 | "build": "node build", 6 | "start:production": "node index.mjs" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /testdata/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ingest" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | serde_json = "1.0.116" 8 | structured-logger = "1.0.3" 9 | tokio = { version = "1.37.0", features = ["full"] } 10 | warp = "0.3.7" 11 | -------------------------------------------------------------------------------- /runtime/main_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "log/slog" 5 | ) 6 | 7 | type noopWriter struct{} 8 | 9 | func (w *noopWriter) Write(p []byte) (n int, err error) { 10 | return len(p), nil 11 | } 12 | 13 | var logger = slog.New(slog.NewJSONHandler(&noopWriter{}, nil)) 14 | -------------------------------------------------------------------------------- /testdata/rust-bin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ingest" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | serde_json = "1.0.116" 8 | structured-logger = "1.0.3" 9 | tokio = { version = "1.37.0", features = ["full"] } 10 | warp = "0.3.7" 11 | 12 | [[bin]] 13 | bench = false 14 | path = "src/main.rs" 15 | name = "rg" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/golang:1.21 AS builder 2 | 3 | ARG GOPROXY=direct 4 | WORKDIR /app 5 | COPY . . 6 | RUN GOPROXY=${GOPROXY} CGO_ENABLED=0 go build -o new-dockerfile cmd/new-dockerfile/main.go 7 | 8 | FROM docker.io/library/alpine:3.19.1 9 | 10 | WORKDIR /app 11 | COPY --from=builder /app/new-dockerfile /usr/local/bin 12 | 13 | CMD [ "new-dockerfile" ] 14 | -------------------------------------------------------------------------------- /testdata/elixir/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Hello.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :hello, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | end -------------------------------------------------------------------------------- /testdata/elixir-mise/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Hello.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :hello, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | end -------------------------------------------------------------------------------- /testdata/elixir-tool-versions/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Hello.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :hello, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | end -------------------------------------------------------------------------------- /testdata/deno/main.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "./deps.ts"; 2 | 3 | const PORT = Deno.env.get("PORT") || "8000"; 4 | const s = serve(`0.0.0.0:${PORT}`); 5 | const body = new TextEncoder().encode("Hello World\n"); 6 | 7 | console.log(`Server started on port ${PORT}`); 8 | for await (const req of s) { 9 | req.respond({ body }); 10 | } 11 | 12 | Deno.addSignalListener("SIGINT", () => { 13 | console.log("\nServer stopped."); 14 | s.close(); 15 | Deno.exit(); 16 | }); 17 | 18 | Deno.addSignalListener("SIGTERM", () => { 19 | console.log("\nServer stopped."); 20 | s.close(); 21 | Deno.exit(); 22 | }); 23 | -------------------------------------------------------------------------------- /testdata/deno-jsonc/mod.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "./deps.ts"; 2 | 3 | const PORT = Deno.env.get("PORT") || "8000"; 4 | const s = serve(`0.0.0.0:${PORT}`); 5 | const body = new TextEncoder().encode("Hello World\n"); 6 | 7 | console.log(`Server started on port ${PORT}`); 8 | for await (const req of s) { 9 | req.respond({ body }); 10 | } 11 | 12 | Deno.addSignalListener("SIGINT", () => { 13 | console.log("\nServer stopped."); 14 | s.close(); 15 | Deno.exit(); 16 | }); 17 | 18 | Deno.addSignalListener("SIGTERM", () => { 19 | console.log("\nServer stopped."); 20 | s.close(); 21 | Deno.exit(); 22 | }); 23 | -------------------------------------------------------------------------------- /testdata/deno-mise/main.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "./deps.ts"; 2 | 3 | const PORT = Deno.env.get("PORT") || "8000"; 4 | const s = serve(`0.0.0.0:${PORT}`); 5 | const body = new TextEncoder().encode("Hello World\n"); 6 | 7 | console.log(`Server started on port ${PORT}`); 8 | for await (const req of s) { 9 | req.respond({ body }); 10 | } 11 | 12 | Deno.addSignalListener("SIGINT", () => { 13 | console.log("\nServer stopped."); 14 | s.close(); 15 | Deno.exit(); 16 | }); 17 | 18 | Deno.addSignalListener("SIGTERM", () => { 19 | console.log("\nServer stopped."); 20 | s.close(); 21 | Deno.exit(); 22 | }); 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - main: ./cmd/new-dockerfile 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | 16 | archives: 17 | - format: tar.gz 18 | # this name template makes the OS and Arch compatible with the results of `uname`. 19 | name_template: >- 20 | {{ .ProjectName }}- 21 | {{- .Os }}- 22 | {{- if eq .Arch "amd64" }}x86_64 23 | {{- else if eq .Arch "386" }}i386 24 | {{- else }}{{ .Arch }}{{ end }} 25 | {{- if .Arm }}v{{ .Arm }}{{ end }} 26 | # use zip for windows archives 27 | format_overrides: 28 | - goos: windows 29 | format: zip 30 | 31 | changelog: 32 | sort: asc 33 | filters: 34 | exclude: 35 | - "^docs:" 36 | - "^test:" 37 | -------------------------------------------------------------------------------- /runtime/main.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | // An interface that all runtimes must implement. 4 | type Runtime interface { 5 | // Returns the name of the runtime. 6 | Name() RuntimeName 7 | // Returns true if the runtime can be used for the given path. 8 | Match(path string) bool 9 | // Generates a Dockerfile for the given path. 10 | GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) 11 | } 12 | 13 | type RuntimeName string 14 | 15 | const ( 16 | RuntimeNameGolang RuntimeName = "Go" 17 | RuntimeNameRuby RuntimeName = "Ruby" 18 | RuntimeNamePython RuntimeName = "Python" 19 | RuntimeNamePHP RuntimeName = "PHP" 20 | RuntimeNameElixir RuntimeName = "Elixir" 21 | RuntimeNameJava RuntimeName = "Java" 22 | RuntimeNameRust RuntimeName = "Rust" 23 | RuntimeNameNextJS RuntimeName = "Next.js" 24 | RuntimeNameBun RuntimeName = "Bun" 25 | RuntimeNameDeno RuntimeName = "Deno" 26 | RuntimeNameNode RuntimeName = "Node" 27 | RuntimeNameStatic RuntimeName = "Static" 28 | ) 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 FlexStack Contributors. All rights reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /node/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 FlexStack Contributors. All rights reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flexstack/new-dockerfile 2 | 3 | go 1.21.0 4 | 5 | toolchain go1.21.5 6 | 7 | require ( 8 | github.com/lmittmann/tint v1.0.4 9 | github.com/pelletier/go-toml v1.9.5 10 | github.com/spf13/pflag v1.0.5 11 | github.com/spf13/viper v1.18.2 12 | ) 13 | 14 | require ( 15 | github.com/Masterminds/semver/v3 v3.3.0 16 | github.com/fsnotify/fsnotify v1.7.0 // indirect 17 | github.com/hashicorp/hcl v1.0.0 // indirect 18 | github.com/magiconair/properties v1.8.7 // indirect 19 | github.com/mitchellh/mapstructure v1.5.0 // indirect 20 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 21 | github.com/sagikazarmark/locafero v0.4.0 // indirect 22 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 23 | github.com/sourcegraph/conc v0.3.0 // indirect 24 | github.com/spf13/afero v1.11.0 // indirect 25 | github.com/spf13/cast v1.6.0 // indirect 26 | github.com/subosito/gotenv v1.6.0 // indirect 27 | go.uber.org/atomic v1.9.0 // indirect 28 | go.uber.org/multierr v1.9.0 // indirect 29 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 30 | golang.org/x/sys v0.15.0 // indirect 31 | golang.org/x/text v0.14.0 // indirect 32 | gopkg.in/ini.v1 v1.67.0 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new-dockerfile", 3 | "version": "0.5.1", 4 | "config": { 5 | "bin_version": "0.5.1" 6 | }, 7 | "description": "Autogenerate Dockerfiles from your project source code", 8 | "main": "index.js", 9 | "bin": { 10 | "new-dockerfile": "./bin/new-dockerfile" 11 | }, 12 | "scripts": { 13 | "postinstall": "node install.js" 14 | }, 15 | "files": [ 16 | "README.md", 17 | "LICENSE", 18 | "install.js", 19 | "bin/new-dockerfile" 20 | ], 21 | "os": [ 22 | "darwin", 23 | "linux", 24 | "win32" 25 | ], 26 | "cpu": [ 27 | "arm64", 28 | "x64" 29 | ], 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/flexstack/new-dockerfile.git" 33 | }, 34 | "keywords": [ 35 | "flexstack", 36 | "docker", 37 | "dockerfile", 38 | "dockerfile-generator", 39 | "dockerfile-generator-cli", 40 | "dockerfile-generator-tool", 41 | "generate-dockerfile", 42 | "autogenerate-dockerfile" 43 | ], 44 | "author": "Jared Lunde", 45 | "license": "MIT", 46 | "bugs": { 47 | "url": "https://github.com/flexstack/new-dockerfile/issues" 48 | }, 49 | "homepage": "https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile" 50 | } 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | > A guide for contributing to `new-dockerfile` 4 | 5 | ## Getting Started 6 | 7 | 1. [Install asdf](https://asdf-vm.com/guide/getting-started.html) - A CLI tool that can manage multiple 8 | language runtime versions on a per-project basis 9 | 10 | 3. Clone the repo and open in VSCode 11 | 12 | ```sh 13 | git clone https://github.com/flexstack/new-dockerfile 14 | code new-dockerfile 15 | ``` 16 | 17 | 4. Install the project's dependencies 18 | 19 | ```sh 20 | asdf install 21 | go mod download 22 | ``` 23 | 24 | ## Development 25 | 26 | Run the CLI in development mode: 27 | 28 | ```sh 29 | go run ./cmd/new-dockerfile --help 30 | ``` 31 | 32 | Vet code 33 | ```sh 34 | go vet ./... 35 | ``` 36 | 37 | Run tests 38 | ```sh 39 | go test -v ./... 40 | ``` 41 | 42 | ## Open an issue 43 | 44 | If you find a bug or want to request a new feature, please open an issue. 45 | 46 | ## Submit a pull request 47 | 48 | Before submitting a feature pull request, it is important to open an issue to discuss what you plan to work on to ensure success in releasing your changes. 49 | For small bug fixes or improvements, go ahead and submit a pull request without an issue. 50 | 51 | 1. Fork the repo 52 | 2. Create a new branch (`git checkout -b feature/my-feature`) 53 | 3. Make your changes 54 | 4. Commit your changes (`git commit -am 'Add my feature'`) 55 | 5. Push to the branch (`git push origin feature/my-feature`) 56 | 6. Create a new Pull Request 57 | 58 | ## License 59 | 60 | MIT -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull request 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | jobs: 7 | vet: 8 | name: Vet 9 | runs-on: ubuntu-latest 10 | concurrency: 11 | group: ${{ github.head_ref }}-vet 12 | cancel-in-progress: true 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | ref: ${{ github.event.pull_request.head.sha }} 18 | - name: Setup asdf 19 | uses: asdf-vm/actions/install@v3 20 | - name: Install dependencies 21 | run: go mod download 22 | - name: Add asdf shims to PATH 23 | run: | 24 | echo "${HOME}/.asdf/shims" >> $GITHUB_PATH 25 | - name: Lint 26 | run: go vet ./... 27 | - name: Run tests 28 | run: go test -v ./... 29 | 30 | image: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | - name: Setup Docker buildx 36 | uses: docker/setup-buildx-action@v3.3.0 37 | - name: Extract Docker metadata 38 | id: meta 39 | uses: docker/metadata-action@v5.5.1 40 | with: 41 | images: | 42 | ghcr.io/flexstack/new-dockerfile 43 | - name: Build and push Docker image 44 | id: build-and-push 45 | uses: docker/build-push-action@v5.3.0 46 | with: 47 | context: . 48 | push: false 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | platforms: linux/amd64,linux/arm64 52 | cache-from: type=gha 53 | cache-to: type=gha,mode=max 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: write 12 | packages: write 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | if: startsWith(github.ref, 'refs/tags/v') 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - name: Set up Go 24 | uses: actions/setup-go@v4 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v5 27 | with: 28 | distribution: goreleaser 29 | version: "~> v1" 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | image: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | - name: Setup Docker buildx 40 | uses: docker/setup-buildx-action@v3.3.0 41 | - name: Log into registry 42 | uses: docker/login-action@v3.1.0 43 | with: 44 | registry: ghcr.io 45 | username: ${{ github.actor }} 46 | password: ${{ secrets.GITHUB_TOKEN }} 47 | - name: Extract Docker metadata 48 | id: meta 49 | uses: docker/metadata-action@v5.5.1 50 | with: 51 | images: | 52 | ghcr.io/flexstack/new-dockerfile 53 | - name: Build and push Docker image 54 | id: build-and-push 55 | uses: docker/build-push-action@v5.3.0 56 | with: 57 | context: . 58 | push: true 59 | tags: ${{ steps.meta.outputs.tags }} 60 | labels: ${{ steps.meta.outputs.labels }} 61 | platforms: linux/amd64,linux/arm64 62 | cache-from: type=gha 63 | cache-to: type=gha,mode=max 64 | -------------------------------------------------------------------------------- /runtime/static.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log/slog" 7 | "maps" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "text/template" 12 | ) 13 | 14 | type Static struct { 15 | Log *slog.Logger 16 | } 17 | 18 | func (d *Static) Name() RuntimeName { 19 | return RuntimeNameStatic 20 | } 21 | 22 | func (d *Static) Match(path string) bool { 23 | checkPaths := []string{ 24 | filepath.Join(path, "public"), 25 | filepath.Join(path, "static"), 26 | filepath.Join(path, "dist"), 27 | filepath.Join(path, "index.html"), 28 | } 29 | 30 | for _, p := range checkPaths { 31 | if _, err := os.Stat(p); err == nil { 32 | d.Log.Info("Detected Static project") 33 | return true 34 | } 35 | } 36 | 37 | d.Log.Debug("Static project not detected") 38 | return false 39 | } 40 | 41 | func (d *Static) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 42 | tmpl, err := template.New("Dockerfile").Parse(staticTemplate) 43 | if err != nil { 44 | return nil, fmt.Errorf("Failed to parse template") 45 | } 46 | 47 | serverRoot := "." 48 | if _, err := os.Stat(filepath.Join(path, "index.html")); err != nil { 49 | roots := []string{"public", "static", "dist"} 50 | for _, root := range roots { 51 | if _, err := os.Stat(filepath.Join(path, root)); err == nil { 52 | serverRoot = root 53 | break 54 | } 55 | } 56 | } 57 | d.Log.Info("Detected root directory: " + serverRoot) 58 | 59 | var buf bytes.Buffer 60 | templateData := map[string]string{ 61 | "ServerRoot": serverRoot, 62 | } 63 | if len(data) > 0 { 64 | maps.Copy(templateData, data[0]) 65 | } 66 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 67 | return nil, fmt.Errorf("Failed to execute template") 68 | } 69 | 70 | return buf.Bytes(), nil 71 | } 72 | 73 | var staticTemplate = strings.TrimSpace(` 74 | ARG VERSION=2 75 | ARG BUILDER=docker.io/joseluisq/static-web-server 76 | FROM ${BUILDER}:${VERSION}-debian 77 | RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* 78 | COPY . . 79 | 80 | ENV PORT=8080 81 | EXPOSE ${PORT} 82 | ENV SERVER_PORT=${PORT} 83 | ARG SERVER_ROOT={{.ServerRoot}} 84 | ENV SERVER_ROOT=${SERVER_ROOT} 85 | `) 86 | -------------------------------------------------------------------------------- /testdata/python-django/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "bb57e0d7853b45999e47c163c46b95bc2fde31c527d8d7b5b5539dc979444a6d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "django": { 20 | "hashes": [ 21 | "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", 22 | "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" 23 | ], 24 | "index": "pypi", 25 | "version": "==2022.12.7" 26 | }, 27 | "chardet": { 28 | "hashes": [ 29 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 30 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 31 | ], 32 | "version": "==3.0.4" 33 | }, 34 | "idna": { 35 | "hashes": [ 36 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", 37 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" 38 | ], 39 | "version": "==2.8" 40 | }, 41 | "requests": { 42 | "hashes": [ 43 | "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", 44 | "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" 45 | ], 46 | "index": "pypi", 47 | "version": "==2.21.0" 48 | }, 49 | "urllib3": { 50 | "hashes": [ 51 | "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", 52 | "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" 53 | ], 54 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' and python_version < '4'", 55 | "version": "==1.24.3" 56 | } 57 | }, 58 | "develop": {} 59 | } -------------------------------------------------------------------------------- /testdata/python-fastapi/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "bb57e0d7853b45999e47c163c46b95bc2fde31c527d8d7b5b5539dc979444a6d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "fastapi": { 20 | "hashes": [ 21 | "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", 22 | "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" 23 | ], 24 | "index": "pypi", 25 | "version": "==2022.12.7" 26 | }, 27 | "chardet": { 28 | "hashes": [ 29 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 30 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 31 | ], 32 | "version": "==3.0.4" 33 | }, 34 | "idna": { 35 | "hashes": [ 36 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", 37 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" 38 | ], 39 | "version": "==2.8" 40 | }, 41 | "requests": { 42 | "hashes": [ 43 | "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", 44 | "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" 45 | ], 46 | "index": "pypi", 47 | "version": "==2.21.0" 48 | }, 49 | "urllib3": { 50 | "hashes": [ 51 | "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", 52 | "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" 53 | ], 54 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' and python_version < '4'", 55 | "version": "==1.24.3" 56 | } 57 | }, 58 | "develop": {} 59 | } -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // A library for auto-generating Dockerfiles from project source code. 2 | package dockerfile 3 | 4 | import ( 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/flexstack/new-dockerfile/runtime" 11 | ) 12 | 13 | // Creates a new Dockerfile generator. If no logger is provided, a default logger is created. 14 | func New(log ...*slog.Logger) *Dockerfile { 15 | var logger *slog.Logger 16 | 17 | if len(log) > 0 { 18 | logger = log[0] 19 | } else { 20 | logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) 21 | } 22 | 23 | return &Dockerfile{ 24 | log: logger, 25 | } 26 | } 27 | 28 | type Dockerfile struct { 29 | log *slog.Logger 30 | } 31 | 32 | // Generates a Dockerfile for the given path and writes it to the same directory. 33 | func (a *Dockerfile) Write(path string) error { 34 | runtime, err := a.MatchRuntime(path) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | contents, err := runtime.GenerateDockerfile(path) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | // Write the Dockerfile to the same directory 45 | if err = os.WriteFile(filepath.Join(path, "Dockerfile"), contents, 0644); err != nil { 46 | return err 47 | } 48 | 49 | // a.log.Info("Auto-generated Dockerfile for project using " + string(lang.Name()) + "\n" + *contents) 50 | a.log.Info("Auto-generated Dockerfile for project using " + string(runtime.Name())) 51 | return nil 52 | } 53 | 54 | // Lists all runtimes that the Dockerfile generator can auto-generate. 55 | func (a *Dockerfile) ListRuntimes() []runtime.Runtime { 56 | return []runtime.Runtime{ 57 | &runtime.Golang{Log: a.log}, 58 | &runtime.Rust{Log: a.log}, 59 | &runtime.Ruby{Log: a.log}, 60 | &runtime.Python{Log: a.log}, 61 | &runtime.PHP{Log: a.log}, 62 | &runtime.Java{Log: a.log}, 63 | &runtime.Elixir{Log: a.log}, 64 | &runtime.NextJS{Log: a.log}, 65 | &runtime.Deno{Log: a.log}, 66 | &runtime.Bun{Log: a.log}, 67 | &runtime.Node{Log: a.log}, 68 | &runtime.Static{Log: a.log}, 69 | } 70 | } 71 | 72 | // Matches the runtime of the project at the given path. 73 | func (a *Dockerfile) MatchRuntime(path string) (runtime.Runtime, error) { 74 | for _, r := range a.ListRuntimes() { 75 | if r.Match(path) { 76 | return r, nil 77 | } 78 | } 79 | 80 | return nil, ErrRuntimeNotFound 81 | } 82 | 83 | // Error returned when we could not auto-detect the runtime of the project. 84 | var ErrRuntimeNotFound = fmt.Errorf("A Dockerfile was not detected in the project and we could not auto-generate one for you.") 85 | -------------------------------------------------------------------------------- /runtime/rust_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestRustMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Rust project", 19 | path: "../testdata/rust", 20 | expected: true, 21 | }, 22 | { 23 | name: "Rust project with [[bin]] directive", 24 | path: "../testdata/rust-bin", 25 | expected: true, 26 | }, 27 | { 28 | name: "Not a Rust project", 29 | path: "../testdata/deno", 30 | expected: false, 31 | }, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.name, func(t *testing.T) { 36 | rust := &runtime.Rust{Log: logger} 37 | if rust.Match(test.path) != test.expected { 38 | t.Errorf("expected %v, got %v", test.expected, rust.Match(test.path)) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestRustGenerateDockerfile(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | path string 48 | expected []any 49 | }{ 50 | { 51 | name: "Rust project", 52 | path: "../testdata/rust", 53 | expected: []any{`ARG BIN_NAME=ingest`}, 54 | }, 55 | { 56 | name: "Rust project with [[bin]] directive", 57 | path: "../testdata/rust-bin", 58 | expected: []any{`ARG BIN_NAME=rg`}, 59 | }, 60 | { 61 | name: "Not a Rust project", 62 | path: "../testdata/deno", 63 | expected: []any{regexp.MustCompile(`^ARG BIN_NAME=$`)}, 64 | }, 65 | } 66 | 67 | for _, test := range tests { 68 | t.Run(test.name, func(t *testing.T) { 69 | rust := &runtime.Rust{Log: logger} 70 | dockerfile, err := rust.GenerateDockerfile(test.path) 71 | if err != nil { 72 | t.Errorf("unexpected error: %v", err) 73 | } 74 | 75 | for _, line := range test.expected { 76 | found := false 77 | lines := strings.Split(string(dockerfile), "\n") 78 | 79 | for _, l := range lines { 80 | switch v := line.(type) { 81 | case string: 82 | if strings.Contains(l, v) { 83 | found = true 84 | break 85 | } 86 | case *regexp.Regexp: 87 | if v.MatchString(l) { 88 | found = true 89 | break 90 | } 91 | } 92 | } 93 | 94 | if !found { 95 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 96 | } 97 | } 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /runtime/golang_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestGolangMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Golang project", 19 | path: "../testdata/go", 20 | expected: true, 21 | }, 22 | { 23 | name: "Golang project with go.mod file", 24 | path: "../testdata/go-mod", 25 | expected: true, 26 | }, 27 | { 28 | name: "Not a Golang project", 29 | path: "../testdata/deno", 30 | expected: false, 31 | }, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.name, func(t *testing.T) { 36 | golang := &runtime.Golang{Log: logger} 37 | if golang.Match(test.path) != test.expected { 38 | t.Errorf("expected %v, got %v", test.expected, golang.Match(test.path)) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestGolangGenerateDockerfile(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | path string 48 | expected []any 49 | }{ 50 | { 51 | name: "Golang project", 52 | path: "../testdata/go", 53 | expected: []any{`ARG VERSION=1.16.3`, `ARG PACKAGE=./main.go`}, 54 | }, 55 | { 56 | name: "Golang project w/ mise", 57 | path: "../testdata/go-mise", 58 | expected: []any{`ARG VERSION=1.16`, `ARG PACKAGE=./main.go`}, 59 | }, 60 | { 61 | name: "Golang project with go.mod file", 62 | path: "../testdata/go-mod", 63 | expected: []any{`ARG VERSION=1.22.3`, `ARG PACKAGE=./cmd/hello`}, 64 | }, 65 | { 66 | name: "Not a Golang project", 67 | path: "../testdata/ruby", 68 | expected: []any{`ARG VERSION=1.17`, regexp.MustCompile(`^ARG PACKAGE=$`)}, 69 | }, 70 | } 71 | 72 | for _, test := range tests { 73 | t.Run(test.name, func(t *testing.T) { 74 | golang := &runtime.Golang{Log: logger} 75 | dockerfile, err := golang.GenerateDockerfile(test.path) 76 | if err != nil { 77 | t.Errorf("unexpected error: %v", err) 78 | } 79 | 80 | for _, line := range test.expected { 81 | found := false 82 | lines := strings.Split(string(dockerfile), "\n") 83 | 84 | for _, l := range lines { 85 | switch v := line.(type) { 86 | case string: 87 | if strings.Contains(l, v) { 88 | found = true 89 | break 90 | } 91 | case *regexp.Regexp: 92 | if v.MatchString(l) { 93 | found = true 94 | break 95 | } 96 | } 97 | } 98 | 99 | if !found { 100 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 101 | } 102 | } 103 | }) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /runtime/elixir_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestElixirMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Elixir project", 19 | path: "../testdata/elixir", 20 | expected: true, 21 | }, 22 | { 23 | name: "Elixir project with .tool-versions", 24 | path: "../testdata/elixir-tool-versions", 25 | expected: true, 26 | }, 27 | { 28 | name: "Not a Elixir project", 29 | path: "../testdata/deno", 30 | expected: false, 31 | }, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.name, func(t *testing.T) { 36 | elixir := &runtime.Elixir{Log: logger} 37 | if elixir.Match(test.path) != test.expected { 38 | t.Errorf("expected %v, got %v", test.expected, elixir.Match(test.path)) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestElixirGenerateDockerfile(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | path string 48 | expected []any 49 | }{ 50 | { 51 | name: "Elixir project", 52 | path: "../testdata/elixir", 53 | expected: []any{`ARG VERSION=1.10`, `ARG OTP_VERSION=22`, `ARG BIN_NAME=hello`}, 54 | }, 55 | { 56 | name: "Elixir project w/ mise", 57 | path: "../testdata/elixir-mise", 58 | expected: []any{`ARG VERSION=1.10`, `ARG OTP_VERSION=23`, `ARG BIN_NAME=hello`}, 59 | }, 60 | { 61 | name: "Elixir project with .tool-versions", 62 | path: "../testdata/elixir-tool-versions", 63 | expected: []any{`ARG VERSION=1.11`, `ARG OTP_VERSION=23`, `ARG BIN_NAME=hello`}, 64 | }, 65 | { 66 | name: "Not a Elixir project", 67 | path: "../testdata/deno", 68 | expected: []any{`ARG VERSION=1.12`, `ARG OTP_VERSION=26`, regexp.MustCompile(`^ARG BIN_NAME=$`)}, 69 | }, 70 | } 71 | 72 | for _, test := range tests { 73 | t.Run(test.name, func(t *testing.T) { 74 | elixir := &runtime.Elixir{Log: logger} 75 | dockerfile, err := elixir.GenerateDockerfile(test.path) 76 | if err != nil { 77 | t.Errorf("unexpected error: %v", err) 78 | } 79 | 80 | for _, line := range test.expected { 81 | found := false 82 | lines := strings.Split(string(dockerfile), "\n") 83 | 84 | for _, l := range lines { 85 | switch v := line.(type) { 86 | case string: 87 | if strings.Contains(l, v) { 88 | found = true 89 | break 90 | } 91 | case *regexp.Regexp: 92 | if v.MatchString(l) { 93 | found = true 94 | break 95 | } 96 | } 97 | } 98 | 99 | if !found { 100 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 101 | } 102 | } 103 | }) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /runtime/static_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestStaticMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Static project", 19 | path: "../testdata/static", 20 | expected: true, 21 | }, 22 | { 23 | name: "Static project with public directory", 24 | path: "../testdata/static-public", 25 | expected: true, 26 | }, 27 | { 28 | name: "Static project with static directory", 29 | path: "../testdata/static-static", 30 | expected: true, 31 | }, 32 | { 33 | name: "Static project with dist directory", 34 | path: "../testdata/static-dist", 35 | expected: true, 36 | }, 37 | { 38 | name: "Not a Static project", 39 | path: "../testdata/deno", 40 | expected: false, 41 | }, 42 | } 43 | 44 | for _, test := range tests { 45 | t.Run(test.name, func(t *testing.T) { 46 | static := &runtime.Static{Log: logger} 47 | if static.Match(test.path) != test.expected { 48 | t.Errorf("expected %v, got %v", test.expected, static.Match(test.path)) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestStaticGenerateDockerfile(t *testing.T) { 55 | tests := []struct { 56 | name string 57 | path string 58 | expected []any 59 | }{ 60 | { 61 | name: "Static project", 62 | path: "../testdata/static", 63 | expected: []any{`ARG SERVER_ROOT=.`}, 64 | }, 65 | { 66 | name: "Static project with public directory", 67 | path: "../testdata/static-public", 68 | expected: []any{`ARG SERVER_ROOT=public`}, 69 | }, 70 | { 71 | name: "Static project with static directory", 72 | path: "../testdata/static-static", 73 | expected: []any{`ARG SERVER_ROOT=static`}, 74 | }, 75 | { 76 | name: "Static project with dist directory", 77 | path: "../testdata/static-dist", 78 | expected: []any{`ARG SERVER_ROOT=dist`}, 79 | }, 80 | { 81 | name: "Not a Static project", 82 | path: "../testdata/deno", 83 | expected: []any{`ARG SERVER_ROOT=.`}, 84 | }, 85 | } 86 | 87 | for _, test := range tests { 88 | t.Run(test.name, func(t *testing.T) { 89 | static := &runtime.Static{Log: logger} 90 | dockerfile, err := static.GenerateDockerfile(test.path) 91 | if err != nil { 92 | t.Errorf("unexpected error: %v", err) 93 | } 94 | 95 | for _, line := range test.expected { 96 | found := false 97 | lines := strings.Split(string(dockerfile), "\n") 98 | 99 | for _, l := range lines { 100 | switch v := line.(type) { 101 | case string: 102 | if strings.Contains(l, v) { 103 | found = true 104 | break 105 | } 106 | case *regexp.Regexp: 107 | if v.MatchString(l) { 108 | found = true 109 | break 110 | } 111 | } 112 | } 113 | 114 | if !found { 115 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 116 | } 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /runtime/deno_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestDenoMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Deno project", 19 | path: "../testdata/deno", 20 | expected: true, 21 | }, 22 | { 23 | name: "Deno project with .ts file", 24 | path: "../testdata/deno-jsonc", 25 | expected: true, 26 | }, 27 | { 28 | name: "Not a Deno project", 29 | path: "../testdata/ruby", 30 | expected: false, 31 | }, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.name, func(t *testing.T) { 36 | deno := &runtime.Deno{Log: logger} 37 | if deno.Match(test.path) != test.expected { 38 | t.Errorf("expected %v, got %v", test.expected, deno.Match(test.path)) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestDenoGenerateDockerfile(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | path string 48 | data map[string]string 49 | expected []any 50 | }{ 51 | { 52 | name: "Deno project", 53 | path: "../testdata/deno", 54 | expected: []any{`ARG VERSION=latest`, `ARG INSTALL_CMD="deno cache main.ts"`, `ARG START_CMD="deno run --allow-all main.ts"`}, 55 | }, 56 | { 57 | name: "Deno project w/ mise", 58 | path: "../testdata/deno-mise", 59 | expected: []any{`ARG VERSION=1.43.2`, `ARG INSTALL_CMD="deno cache main.ts"`, `ARG START_CMD="deno run --allow-all main.ts"`}, 60 | }, 61 | { 62 | name: "Deno project with .ts file", 63 | path: "../testdata/deno-jsonc", 64 | expected: []any{`ARG VERSION=1.43.3`, `ARG INSTALL_CMD="deno task cache"`, `ARG START_CMD="deno task start"`}, 65 | }, 66 | { 67 | name: "Deno project with install mounts", 68 | path: "../testdata/deno-jsonc", 69 | data: map[string]string{"InstallMounts": `--mount=type=secret,id=_env,target=/app/.env \ 70 | `}, 71 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 72 | }, 73 | { 74 | name: "Not a Deno project", 75 | path: "../testdata/ruby", 76 | expected: []any{`ARG VERSION=latest`, regexp.MustCompile(`^ARG INSTALL_CMD=$`), regexp.MustCompile(`^ARG START_CMD=$`)}, 77 | }, 78 | } 79 | 80 | for _, test := range tests { 81 | t.Run(test.name, func(t *testing.T) { 82 | deno := &runtime.Deno{Log: logger} 83 | dockerfile, err := deno.GenerateDockerfile(test.path, test.data) 84 | if err != nil { 85 | t.Errorf("unexpected error: %v", err) 86 | } 87 | 88 | for _, line := range test.expected { 89 | found := false 90 | lines := strings.Split(string(dockerfile), "\n") 91 | 92 | for _, l := range lines { 93 | switch v := line.(type) { 94 | case string: 95 | if strings.Contains(l, v) { 96 | found = true 97 | break 98 | } 99 | case *regexp.Regexp: 100 | if v.MatchString(l) { 101 | found = true 102 | break 103 | } 104 | } 105 | } 106 | 107 | if !found { 108 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 109 | } 110 | } 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /runtime/nextjs_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestNextJSMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "NextJS project", 19 | path: "../testdata/nextjs", 20 | expected: true, 21 | }, 22 | { 23 | name: "NextJS project with standalone output", 24 | path: "../testdata/nextjs-standalone", 25 | expected: true, 26 | }, 27 | { 28 | name: "Not a NextJS project", 29 | path: "../testdata/deno", 30 | expected: false, 31 | }, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.name, func(t *testing.T) { 36 | nextjs := &runtime.NextJS{Log: logger} 37 | if nextjs.Match(test.path) != test.expected { 38 | t.Errorf("expected %v, got %v", test.expected, nextjs.Match(test.path)) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestNextJSGenerateDockerfile(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | path string 48 | data map[string]string 49 | expected []any 50 | }{ 51 | { 52 | name: "NextJS project", 53 | path: "../testdata/nextjs", 54 | expected: []any{`ARG VERSION=lts`, `CMD ["node_modules/.bin/next", "start", "-H", "0.0.0.0"]`}, 55 | }, 56 | { 57 | name: "NextJS project with standalone output", 58 | path: "../testdata/nextjs-standalone", 59 | expected: []any{`ARG VERSION=16.0.0`, `CMD HOSTNAME="0.0.0.0" node server.js`}, 60 | }, 61 | { 62 | name: "NextJS project with build mounts", 63 | path: "../testdata/nextjs-standalone", 64 | data: map[string]string{"BuildMounts": `--mount=type=secret,id=_env,target=/app/.env \ 65 | `}, 66 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 67 | }, 68 | { 69 | name: "NextJS project with install mounts", 70 | path: "../testdata/nextjs-standalone", 71 | data: map[string]string{"InstallMounts": `--mount=type=secret,id=_env,target=/app/.env \ 72 | `}, 73 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 74 | }, 75 | { 76 | name: "Not a NextJS project", 77 | path: "../testdata/deno", 78 | expected: []any{`ARG VERSION=lts`, `CMD ["node_modules/.bin/next", "start", "-H", "0.0.0.0"]`}, 79 | }, 80 | } 81 | 82 | for _, test := range tests { 83 | t.Run(test.name, func(t *testing.T) { 84 | nextjs := &runtime.NextJS{Log: logger} 85 | dockerfile, err := nextjs.GenerateDockerfile(test.path, test.data) 86 | if err != nil { 87 | t.Errorf("unexpected error: %v", err) 88 | } 89 | 90 | for _, line := range test.expected { 91 | found := false 92 | lines := strings.Split(string(dockerfile), "\n") 93 | 94 | for _, l := range lines { 95 | switch v := line.(type) { 96 | case string: 97 | if strings.Contains(l, v) { 98 | found = true 99 | break 100 | } 101 | case *regexp.Regexp: 102 | if v.MatchString(l) { 103 | found = true 104 | break 105 | } 106 | } 107 | } 108 | 109 | if !found { 110 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 111 | } 112 | } 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /runtime/bun_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestBunMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Bun project", 19 | path: "../testdata/bun", 20 | expected: true, 21 | }, 22 | { 23 | name: "Bun project with .ts file", 24 | path: "../testdata/bun-bunfig", 25 | expected: true, 26 | }, 27 | { 28 | name: "Not a Bun project", 29 | path: "../testdata/deno", 30 | expected: false, 31 | }, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.name, func(t *testing.T) { 36 | bun := &runtime.Bun{Log: logger} 37 | if bun.Match(test.path) != test.expected { 38 | t.Errorf("expected %v, got %v", test.expected, bun.Match(test.path)) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestBunGenerateDockerfile(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | path string 48 | data map[string]string 49 | expected []any 50 | }{ 51 | { 52 | name: "Bun project", 53 | path: "../testdata/bun", 54 | expected: []any{`ARG VERSION=1`, `ARG INSTALL_CMD="bun install"`, regexp.MustCompile(`^ARG BUILD_CMD=$`), `ARG START_CMD="bun index.ts"`}, 55 | }, 56 | { 57 | name: "Bun project w/ mise", 58 | path: "../testdata/bun-mise", 59 | expected: []any{`ARG VERSION=1.1.3`, `ARG INSTALL_CMD="bun install"`, regexp.MustCompile(`^ARG BUILD_CMD=$`), `ARG START_CMD="bun index.ts"`}, 60 | }, 61 | { 62 | name: "Bun project with .ts file", 63 | path: "../testdata/bun-bunfig", 64 | expected: []any{`ARG VERSION=1.1.4`, `ARG INSTALL_CMD="bun install"`, `ARG BUILD_CMD="bun run build:prod"`, `ARG START_CMD="bun run start:production"`}, 65 | }, 66 | { 67 | name: "Bun project with build mounts", 68 | path: "../testdata/bun-bunfig", 69 | data: map[string]string{"BuildMounts": `--mount=type=secret,id=_env,target=/app/.env \ 70 | `}, 71 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 72 | }, 73 | { 74 | name: "Bun project with install mounts", 75 | path: "../testdata/bun-bunfig", 76 | data: map[string]string{"InstallMounts": `--mount=type=secret,id=_env,target=/app/.env \ 77 | `}, 78 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 79 | }, 80 | { 81 | name: "Not a Bun project", 82 | path: "../testdata/deno", 83 | expected: []any{`ARG VERSION=1`, regexp.MustCompile(`^ARG INSTALL_CMD="bun install"`), regexp.MustCompile(`^ARG BUILD_CMD=$`), regexp.MustCompile(`^ARG START_CMD=$`)}, 84 | }, 85 | } 86 | 87 | for _, test := range tests { 88 | t.Run(test.name, func(t *testing.T) { 89 | bun := &runtime.Bun{Log: logger} 90 | dockerfile, err := bun.GenerateDockerfile(test.path, test.data) 91 | if err != nil { 92 | t.Errorf("unexpected error: %v", err) 93 | } 94 | 95 | for _, line := range test.expected { 96 | found := false 97 | lines := strings.Split(string(dockerfile), "\n") 98 | 99 | for _, l := range lines { 100 | switch v := line.(type) { 101 | case string: 102 | if strings.Contains(l, v) { 103 | found = true 104 | break 105 | } 106 | case *regexp.Regexp: 107 | if v.MatchString(l) { 108 | found = true 109 | break 110 | } 111 | } 112 | } 113 | 114 | if !found { 115 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 116 | } 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /runtime/php_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestPHPMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "PHP project", 19 | path: "../testdata/php", 20 | expected: true, 21 | }, 22 | { 23 | name: "PHP project with composer", 24 | path: "../testdata/php-composer", 25 | expected: true, 26 | }, 27 | { 28 | name: "PHP project with NPM", 29 | path: "../testdata/php-npm", 30 | expected: true, 31 | }, 32 | { 33 | name: "Not a PHP project", 34 | path: "../testdata/deno", 35 | expected: false, 36 | }, 37 | } 38 | 39 | for _, test := range tests { 40 | t.Run(test.name, func(t *testing.T) { 41 | php := &runtime.PHP{Log: logger} 42 | if php.Match(test.path) != test.expected { 43 | t.Errorf("expected %v, got %v", test.expected, php.Match(test.path)) 44 | } 45 | }) 46 | } 47 | } 48 | 49 | func TestPHPGenerateDockerfile(t *testing.T) { 50 | tests := []struct { 51 | name string 52 | path string 53 | data map[string]string 54 | expected []any 55 | }{ 56 | { 57 | name: "PHP project", 58 | path: "../testdata/php", 59 | expected: []any{`ARG VERSION=8.3`, regexp.MustCompile(`^ARG BUILD_CMD=$`), `ARG START_CMD="apache2-foreground`}, 60 | }, 61 | { 62 | name: "PHP project with composer", 63 | path: "../testdata/php-composer", 64 | expected: []any{`ARG VERSION=5.3`, `ARG INSTALL_CMD="composer update && composer install --prefer-dist --no-dev --optimize-autoloader --no-interaction"`, regexp.MustCompile(`^ARG BUILD_CMD=$`), `ARG START_CMD="apache2-foreground`}, 65 | }, 66 | { 67 | name: "PHP project with NPM", 68 | path: "../testdata/php-npm", 69 | expected: []any{`ARG VERSION=8.2.0`, `ARG INSTALL_CMD="yarn --frozen-lockfile"`, `ARG BUILD_CMD="yarn run build"`, `ARG START_CMD="apache2-foreground`}, 70 | }, 71 | { 72 | name: "PHP project with build mounts", 73 | path: "../testdata/php-npm", 74 | data: map[string]string{"BuildMounts": `--mount=type=secret,id=_env,target=/app/.env \ 75 | `}, 76 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 77 | }, 78 | { 79 | name: "PHP project with install mounts", 80 | path: "../testdata/php-npm", 81 | data: map[string]string{"InstallMounts": `--mount=type=secret,id=_env,target=/app/.env \ 82 | `}, 83 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 84 | }, 85 | { 86 | name: "Not a PHP project", 87 | path: "../testdata/deno", 88 | expected: []any{`ARG VERSION=8.3`, regexp.MustCompile(`^ARG INSTALL_CMD=$`), regexp.MustCompile(`^ARG BUILD_CMD=$`), `ARG START_CMD="apache2-foreground`}, 89 | }, 90 | } 91 | 92 | for _, test := range tests { 93 | t.Run(test.name, func(t *testing.T) { 94 | php := &runtime.PHP{Log: logger} 95 | dockerfile, err := php.GenerateDockerfile(test.path, test.data) 96 | if err != nil { 97 | t.Errorf("unexpected error: %v", err) 98 | } 99 | 100 | for _, line := range test.expected { 101 | found := false 102 | lines := strings.Split(string(dockerfile), "\n") 103 | 104 | for _, l := range lines { 105 | switch v := line.(type) { 106 | case string: 107 | if strings.Contains(l, v) { 108 | found = true 109 | break 110 | } 111 | case *regexp.Regexp: 112 | if v.MatchString(l) { 113 | found = true 114 | break 115 | } 116 | } 117 | } 118 | 119 | if !found { 120 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 121 | } 122 | } 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /runtime/rust.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log/slog" 7 | "maps" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "text/template" 12 | 13 | "github.com/pelletier/go-toml" 14 | ) 15 | 16 | type Rust struct { 17 | Log *slog.Logger 18 | } 19 | 20 | func (d *Rust) Name() RuntimeName { 21 | return RuntimeNameRust 22 | } 23 | 24 | func (d *Rust) Match(path string) bool { 25 | checkPaths := []string{ 26 | filepath.Join(path, "Cargo.toml"), 27 | } 28 | 29 | for _, p := range checkPaths { 30 | if _, err := os.Stat(p); err == nil { 31 | d.Log.Info("Detected Rust project") 32 | return true 33 | } 34 | } 35 | 36 | d.Log.Debug("rust project not detected") 37 | return false 38 | } 39 | 40 | func (d *Rust) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 41 | tmpl, err := template.New("Dockerfile").Parse(rustlangTemplate) 42 | if err != nil { 43 | return nil, fmt.Errorf("Failed to parse template") 44 | } 45 | 46 | var binName string 47 | // Parse the Cargo.toml file to get the binary name 48 | cargoTomlPath := filepath.Join(path, "Cargo.toml") 49 | if _, err := os.Stat(cargoTomlPath); err == nil { 50 | f, err := os.Open(cargoTomlPath) 51 | if err != nil { 52 | return nil, fmt.Errorf("Failed to open Cargo.toml") 53 | } 54 | 55 | defer f.Close() 56 | 57 | var cargoTOML map[string]interface{} 58 | if err := toml.NewDecoder(f).Decode(&cargoTOML); err != nil { 59 | return nil, fmt.Errorf("Failed to decode Cargo.toml") 60 | } 61 | 62 | checkBins := []string{"bin", "lib", "package"} 63 | var ok bool 64 | var pkg map[string]interface{} 65 | for _, bin := range checkBins { 66 | // [[bin]] 67 | // [lib] 68 | // [package] 69 | if bin == "bin" { 70 | if pkgs, ok := cargoTOML[bin].([]map[string]interface{}); ok { 71 | if len(pkgs) > 0 { 72 | d.Log.Info("Detected binary in Cargo.toml via [[bin]]") 73 | pkg = pkgs[0] 74 | break 75 | } 76 | } 77 | } else if pkg, ok = cargoTOML[bin].(map[string]interface{}); ok { 78 | d.Log.Info("Detected binary in Cargo.toml via [" + bin + "]") 79 | break 80 | } 81 | } 82 | 83 | if binName, ok = pkg["name"].(string); !ok { 84 | d.Log.Warn("Failed to get binary name from Cargo.toml") 85 | } else { 86 | d.Log.Info("Detected binary name: " + binName) 87 | } 88 | } 89 | 90 | var buf bytes.Buffer 91 | templateData := map[string]string{ 92 | "BinName": binName, 93 | } 94 | if len(data) > 0 { 95 | maps.Copy(templateData, data[0]) 96 | } 97 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 98 | return nil, fmt.Errorf("Failed to execute template") 99 | } 100 | 101 | return buf.Bytes(), nil 102 | } 103 | 104 | var rustlangTemplate = strings.TrimSpace(` 105 | ARG BUILDPLATFORM=linux 106 | ARG BUILDER=docker.io/messense/cargo-zigbuild 107 | FROM --platform=${BUILDPLATFORM} ${BUILDER}:latest AS build 108 | WORKDIR /app 109 | COPY . . 110 | 111 | ARG TARGETOS=linux 112 | ARG TARGETARCH=amd64 113 | RUN if [ "${TARGETARCH}" = "amd64" ]; then rustup target add x86_64-unknown-linux-gnu; else rustup target add aarch64-unknown-linux-gnu; fi 114 | RUN if [ "${TARGETARCH}" = "amd64" ]; then cargo zigbuild --release --target x86_64-unknown-linux-gnu; else cargo zigbuild --release --target aarch64-unknown-linux-gnu; fi 115 | 116 | FROM debian:stable-slim AS runtime 117 | WORKDIR /app 118 | 119 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 120 | RUN update-ca-certificates 2>/dev/null || true 121 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 122 | RUN chown -R nonroot:nonroot /app 123 | 124 | ARG BIN_NAME={{.BinName}} 125 | ENV BIN_NAME=${BIN_NAME} 126 | COPY --chown=nonroot:nonroot --from=build /app/target/*/release/${BIN_NAME} ./app 127 | 128 | USER nonroot:nonroot 129 | 130 | ENV PORT=8080 131 | EXPOSE ${PORT} 132 | CMD ["/app/app"] 133 | `) 134 | -------------------------------------------------------------------------------- /cmd/new-dockerfile/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/spf13/viper" 13 | 14 | dockerfile "github.com/flexstack/new-dockerfile" 15 | "github.com/flexstack/new-dockerfile/runtime" 16 | "github.com/lmittmann/tint" 17 | flag "github.com/spf13/pflag" 18 | ) 19 | 20 | func main() { 21 | var path string 22 | flag.StringVar(&path, "path", ".", "Path to the project directory") 23 | var noColor bool 24 | flag.BoolVar(&noColor, "no-color", os.Getenv("NO_COLOR") == "true" || os.Getenv("ANSI_COLORS_DISABLED") == "true", "Disable colorized output") 25 | var runtimeArg string 26 | flag.StringVar(&runtimeArg, "runtime", "", "Force a specific runtime") 27 | var quiet bool 28 | flag.BoolVar(&quiet, "quiet", false, "Disable all log output except errors") 29 | var write bool 30 | flag.BoolVar(&write, "write", false, "Write the Dockerfile to disk at ./Dockerfile") 31 | var overwrite bool 32 | flag.BoolVar(&overwrite, "overwrite", false, "Overwrite the Dockerfile if it already exists") 33 | flag.Parse() 34 | 35 | level := slog.LevelInfo 36 | if os.Getenv("DEBUG") != "" { 37 | level = slog.LevelDebug 38 | } else if quiet { 39 | level = slog.LevelError 40 | } 41 | 42 | handler := tint.NewHandler(os.Stderr, &tint.Options{ 43 | Level: level, 44 | TimeFormat: time.Kitchen, 45 | NoColor: noColor, 46 | }) 47 | 48 | log := slog.New(handler) 49 | df := dockerfile.New(log) 50 | 51 | // jump out if users don't want to overwrite the Dockerfile 52 | if write && !overwrite { 53 | if _, err := os.Stat(filepath.Join(path, "Dockerfile")); err == nil { 54 | log.Error("Dockerfile already exists. Use --overwrite to overwrite it.") 55 | return 56 | } 57 | } 58 | 59 | viper.SetConfigName("new-dockerfile") 60 | viper.SetConfigType("yaml") 61 | viper.SetConfigType("yml") 62 | viper.AddConfigPath(".") 63 | configExists := true 64 | if err := viper.ReadInConfig(); err != nil { 65 | if !errors.As(err, &viper.ConfigFileNotFoundError{}) { 66 | log.Error("Fatal error: " + err.Error()) 67 | os.Exit(1) 68 | } 69 | 70 | configExists = false 71 | } 72 | 73 | if configExists { 74 | runtimeArg = viper.GetString("runtime") 75 | } 76 | 77 | var ( 78 | r runtime.Runtime 79 | err error 80 | ) 81 | 82 | if runtimeArg != "" { 83 | runtimes := df.ListRuntimes() 84 | 85 | for _, rt := range runtimes { 86 | if strings.EqualFold(string(rt.Name()), runtimeArg) { 87 | r = rt 88 | break 89 | } 90 | } 91 | if r == nil { 92 | runtimeNames := make([]string, len(runtimes)) 93 | for i, rt := range runtimes { 94 | runtimeNames[i] = strings.ToLower(string(rt.Name())) 95 | } 96 | 97 | if runtimeArg == "list" { 98 | fmt.Println("Available runtimes:") 99 | fmt.Println(" - " + strings.Join(runtimeNames, "\n - ")) 100 | os.Exit(0) 101 | } 102 | 103 | log.Error(fmt.Sprintf(`Runtime "%s" not found. Expected one of: %s`, runtimeArg, "\n - "+strings.Join(runtimeNames, "\n - "))) 104 | os.Exit(1) 105 | } 106 | } 107 | 108 | if r == nil { 109 | r, err = df.MatchRuntime(path) 110 | if err != nil { 111 | log.Error("Fatal error: " + err.Error()) 112 | os.Exit(1) 113 | } 114 | } 115 | 116 | contents, err := r.GenerateDockerfile(path) 117 | if err != nil { 118 | os.Exit(1) 119 | } 120 | 121 | contents = append( 122 | []byte(fmt.Sprintf(issueStr)), 123 | contents..., 124 | ) 125 | 126 | if !write { 127 | fmt.Println(string(contents)) 128 | return 129 | } 130 | 131 | output := filepath.Join(path, "Dockerfile") 132 | if err = os.WriteFile(output, contents, 0644); err != nil { 133 | log.Error("Fatal error: " + err.Error()) 134 | os.Exit(1) 135 | } 136 | 137 | log.Info(fmt.Sprintf("Auto-generated Dockerfile for project using %s: %s", string(r.Name()), output)) 138 | } 139 | 140 | const issueStr = `# Auto-generated by the "new-dockerfile" CLI tool 141 | # Please report any issues to: https://github.com/flexstack/new-dockerfile/issues 142 | ` 143 | -------------------------------------------------------------------------------- /runtime/node_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestNodeMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Node project", 19 | path: "../testdata/node", 20 | expected: true, 21 | }, 22 | { 23 | name: "Node project with pnpm", 24 | path: "../testdata/node-pnpm", 25 | expected: true, 26 | }, 27 | { 28 | name: "Node project with yarn", 29 | path: "../testdata/node-yarn", 30 | expected: true, 31 | }, 32 | { 33 | name: "Not a Node project", 34 | path: "../testdata/deno", 35 | expected: false, 36 | }, 37 | } 38 | 39 | for _, test := range tests { 40 | t.Run(test.name, func(t *testing.T) { 41 | node := &runtime.Node{Log: logger} 42 | if node.Match(test.path) != test.expected { 43 | t.Errorf("expected %v, got %v", test.expected, node.Match(test.path)) 44 | } 45 | }) 46 | } 47 | } 48 | 49 | func TestNodeGenerateDockerfile(t *testing.T) { 50 | tests := []struct { 51 | name string 52 | path string 53 | data map[string]string 54 | expected []any 55 | }{ 56 | { 57 | name: "Node project", 58 | path: "../testdata/node", 59 | expected: []any{`ARG VERSION=lts`, `ARG INSTALL_CMD="npm ci"`, regexp.MustCompile(`^ARG BUILD_CMD=$`), `ARG START_CMD="node index.ts"`}, 60 | }, 61 | { 62 | name: "Node project w/ mise", 63 | path: "../testdata/node-mise", 64 | expected: []any{`ARG VERSION=14`, `ARG INSTALL_CMD="npm ci"`, regexp.MustCompile(`^ARG BUILD_CMD=$`), `ARG START_CMD="node index.ts"`}, 65 | }, 66 | { 67 | name: "Node project with engines", 68 | path: "../testdata/node-engines", 69 | expected: []any{`ARG VERSION=14.5`, `ARG INSTALL_CMD="npm ci"`, regexp.MustCompile(`^ARG BUILD_CMD=$`), `ARG START_CMD="node index.ts"`}, 70 | }, 71 | { 72 | name: "Node project with pnpm", 73 | path: "../testdata/node-pnpm", 74 | expected: []any{`ARG VERSION=16.0.0`, `ARG INSTALL_CMD="pnpm i --frozen-lockfile"`, `ARG BUILD_CMD="pnpm run build:prod"`, `ARG START_CMD="pnpm run start:production"`}, 75 | }, 76 | { 77 | name: "Node project with yarn", 78 | path: "../testdata/node-yarn", 79 | expected: []any{`ARG VERSION=16.0.0`, `ARG INSTALL_CMD="yarn --frozen-lockfile"`, `ARG BUILD_CMD="yarn run build:prod"`, `ARG START_CMD="yarn run start-it"`}, 80 | }, 81 | { 82 | name: "Node project with build mounts", 83 | path: "../testdata/node-yarn", 84 | data: map[string]string{"BuildMounts": `--mount=type=secret,id=_env,target=/app/.env \ 85 | `}, 86 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 87 | }, 88 | { 89 | name: "Node project with install mounts", 90 | path: "../testdata/node-yarn", 91 | data: map[string]string{"InstallMounts": `--mount=type=secret,id=_env,target=/app/.env \ 92 | `}, 93 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 94 | }, 95 | { 96 | name: "Not a Node project", 97 | path: "../testdata/deno", 98 | expected: []any{`ARG VERSION=lts`, regexp.MustCompile(`^ARG INSTALL_CMD="npm ci"`), regexp.MustCompile(`^ARG BUILD_CMD=$`), regexp.MustCompile(`^ARG START_CMD=$`)}, 99 | }, 100 | } 101 | 102 | for _, test := range tests { 103 | t.Run(test.name, func(t *testing.T) { 104 | node := &runtime.Node{Log: logger} 105 | dockerfile, err := node.GenerateDockerfile(test.path, test.data) 106 | if err != nil { 107 | t.Errorf("unexpected error: %v", err) 108 | } 109 | 110 | for _, line := range test.expected { 111 | found := false 112 | lines := strings.Split(string(dockerfile), "\n") 113 | 114 | for _, l := range lines { 115 | switch v := line.(type) { 116 | case string: 117 | if strings.Contains(l, v) { 118 | found = true 119 | break 120 | } 121 | case *regexp.Regexp: 122 | if v.MatchString(l) { 123 | found = true 124 | break 125 | } 126 | } 127 | } 128 | 129 | if !found { 130 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 131 | } 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /node/README.md: -------------------------------------------------------------------------------- 1 | # Dockerfile Generator 2 | 3 | `new-dockerfile` is a CLI tool and Go package automatically generates a configurable Dockerfile 4 | based on your project source code. It supports a wide range of languages and frameworks, including Next.js, 5 | Node.js, Python, Ruby, Java/Spring Boot, Go, Elixir/Phoenix, and more. 6 | 7 | See the [GitHub Repository](https://github.com/flexstack/new-dockerfile#readme) for full runtime documentation. 8 | 9 | See the [FlexStack Documentation](https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile) page for FlexStack-specific documentation 10 | related to this tool. 11 | 12 | ## Features 13 | 14 | - [x] Automatically detect the runtime and framework used by your project 15 | - [x] Use version managers like [asdf](https://github.com/asdf-vm), nvm, rbenv, and pyenv to install the correct version of the runtime 16 | - [x] Make a best effort to detect any install, build, and start commands 17 | - [x] Generate a Dockerfile with sensible defaults that are configurable via [Docker Build Args](https://docs.docker.com/build/guide/build-args/) 18 | - [x] Support for a wide range of the most popular languages and frameworks including Next.js, Phoenix, Spring Boot, Django, and more 19 | - [x] Use Debian Slim as the runtime image for a smaller image size and better security, while still supporting the most common dependencies and avoiding deployment headaches caused by Alpine Linux gotchas 20 | - [x] Includes `wget` in the runtime image for adding health checks to services, e.g. `wget -nv -t1 --spider 'http://localhost:8080/healthz' || exit 1` 21 | - [x] Use multi-stage builds to reduce the size of the final image 22 | - [x] Run the application as a non-root user for better security 23 | - [x] Supports multi-platform images that run on both x86 and ARM CPU architectures 24 | 25 | ## Supported Runtimes 26 | 27 | - Bun 28 | - Deno 29 | - Docker 30 | - Elixir 31 | - Go 32 | - Java 33 | - Next.js 34 | - Node.js 35 | - PHP 36 | - Python 37 | - Ruby 38 | - Rust 39 | - Static (HTML, CSS, JS) 40 | 41 | ## Usage 42 | 43 | Using `npx`: 44 | 45 | ```sh 46 | npx new-dockerfile [options] 47 | ``` 48 | 49 | Install the CLI globally: 50 | 51 | ```sh 52 | npm install -g new-dockerfile 53 | ``` 54 | 55 | ## Options 56 | 57 | - `--path` - Path to the project source code (default: `.`) 58 | - `--write` - Write the generated Dockerfile to the project at the specified path (default: `false`) 59 | - `--runtime` - Force a specific runtime, e.g. `node` (default: `auto`) 60 | - `--quiet` - Disable all logging except for errors (default: `false`) 61 | - `--help` - Show help 62 | 63 | ## Examples 64 | 65 | Print the generated Dockerfile to the console: 66 | ```sh 67 | new-dockerfile 68 | ``` 69 | 70 | Write a Dockerfile to the current directory: 71 | ```sh 72 | new-dockerfile --write 73 | ``` 74 | 75 | Write a Dockerfile to a specific directory: 76 | ```sh 77 | new-dockerfile > path/to/Dockerfile 78 | ``` 79 | 80 | Force a specific runtime: 81 | ```sh 82 | new-dockerfile --runtime next.js 83 | ``` 84 | 85 | List the supported runtimes: 86 | ```sh 87 | new-dockerfile --runtime list 88 | ``` 89 | 90 | ## How it Works 91 | 92 | The tool searches for common files and directories in your project to determine the runtime and framework. 93 | For example, if it finds a `package.json` file, it will assume the project is a Node.js project unless 94 | a `next.config.js` file is present, in which case it will assume the project is a Next.js project. 95 | 96 | From there, it will read any `.tool-versions` or other version manager files to determine the correct version 97 | of the runtime to install. It will then make a best effort to detect any install, build, and start commands. 98 | For example, a `serve`, `start`, `start:prod` command in a `package.json` file will be used as the start command. 99 | 100 | Runtimes are matched against in the order they appear when you run `new-dockerfile --runtime list`. 101 | 102 | Read on to see runtime-specific examples and how to configure the generated Dockerfile. 103 | 104 | ## Used By 105 | 106 | - [FlexStack](https://flexstack.com) - A platform that simplifies the deployment of containerized applications to AWS. 107 | FlexStack uses this tool to automatically detect the runtime and framework used by your project, so you can just bring your code and deploy it with confidence. 108 | - *Your project here* - If you're using this tool in your project, let us know! We'd love to feature you here. 109 | -------------------------------------------------------------------------------- /runtime/python_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestPythonMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Python project", 19 | path: "../testdata/python", 20 | expected: true, 21 | }, 22 | { 23 | name: "Python project with django", 24 | path: "../testdata/python-django", 25 | expected: true, 26 | }, 27 | { 28 | name: "Python project with pdm", 29 | path: "../testdata/python-pdm", 30 | expected: true, 31 | }, 32 | { 33 | name: "Python project with poetry", 34 | path: "../testdata/python-poetry", 35 | expected: true, 36 | }, 37 | { 38 | name: "Python project with pyproject", 39 | path: "../testdata/python-pyproject", 40 | expected: true, 41 | }, 42 | { 43 | name: "Not a Python project", 44 | path: "../testdata/deno", 45 | expected: false, 46 | }, 47 | } 48 | 49 | for _, test := range tests { 50 | t.Run(test.name, func(t *testing.T) { 51 | python := &runtime.Python{Log: logger} 52 | if python.Match(test.path) != test.expected { 53 | t.Errorf("expected %v, got %v", test.expected, python.Match(test.path)) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestPythonGenerateDockerfile(t *testing.T) { 60 | tests := []struct { 61 | name string 62 | path string 63 | expected []any 64 | }{ 65 | { 66 | name: "Python project", 67 | path: "../testdata/python", 68 | expected: []any{ 69 | `ARG VERSION=3.12`, 70 | `ARG INSTALL_CMD="pip install --no-cache -r requirements.txt"`, 71 | `ARG START_CMD="python main.py"`, 72 | }, 73 | }, 74 | { 75 | name: "Python project w/ mise", 76 | path: "../testdata/python-mise", 77 | expected: []any{ 78 | `ARG VERSION=3.8`, 79 | `ARG INSTALL_CMD="pip install --no-cache -r requirements.txt"`, 80 | `ARG START_CMD="python main.py"`, 81 | }, 82 | }, 83 | { 84 | name: "Python project with django", 85 | path: "../testdata/python-django", 86 | expected: []any{ 87 | `ARG VERSION=3.6.0`, 88 | `ARG INSTALL_CMD="pip install pipenv && pipenv install --dev --system --deploy"`, 89 | `ARG START_CMD="python manage.py runserver 0.0.0.0:${PORT}"`, 90 | }, 91 | }, 92 | { 93 | name: "Python project with pdm", 94 | path: "../testdata/python-pdm", 95 | expected: []any{ 96 | `ARG VERSION=3.4.1`, 97 | `ARG INSTALL_CMD="pip install pdm && pdm install --prod"`, 98 | `ARG START_CMD="python app.py"`, 99 | }, 100 | }, 101 | { 102 | name: "Python project with poetry", 103 | path: "../testdata/python-poetry", 104 | expected: []any{ 105 | `ARG VERSION=3.8.5`, 106 | `ARG INSTALL_CMD="pip install poetry && poetry install --no-dev --no-ansi --no-root"`, 107 | `ARG START_CMD="python app/main.py"`, 108 | }, 109 | }, 110 | { 111 | name: "Python project with pyproject", 112 | path: "../testdata/python-pyproject", 113 | expected: []any{ 114 | `ARG VERSION=3.12`, 115 | `ARG INSTALL_CMD="pip install --upgrade build setuptools && pip install .`, 116 | `ARG START_CMD="python -m pyproject"`, 117 | }, 118 | }, 119 | { 120 | name: "Python project with FastAPI", 121 | path: "../testdata/python-fastapi", 122 | expected: []any{ 123 | `ARG VERSION=3.6.0`, 124 | `ARG INSTALL_CMD="pip install pipenv && pipenv install --dev --system --deploy"`, 125 | `ARG START_CMD="fastapi run main.py --port ${PORT}"`, 126 | }, 127 | }, 128 | { 129 | name: "Not a Python project", 130 | path: "../testdata/deno", 131 | expected: []any{ 132 | `ARG VERSION=3.12`, 133 | regexp.MustCompile(`^ARG INSTALL_CMD=$`), 134 | regexp.MustCompile(`^ARG START_CMD=$`), 135 | }, 136 | }, 137 | } 138 | 139 | for _, test := range tests { 140 | t.Run(test.name, func(t *testing.T) { 141 | python := &runtime.Python{Log: logger} 142 | dockerfile, err := python.GenerateDockerfile(test.path) 143 | if err != nil { 144 | t.Errorf("unexpected error: %v", err) 145 | } 146 | 147 | for _, line := range test.expected { 148 | found := false 149 | lines := strings.Split(string(dockerfile), "\n") 150 | 151 | for _, l := range lines { 152 | switch v := line.(type) { 153 | case string: 154 | if strings.Contains(l, v) { 155 | found = true 156 | break 157 | } 158 | case *regexp.Regexp: 159 | if v.MatchString(l) { 160 | found = true 161 | break 162 | } 163 | } 164 | } 165 | 166 | if !found { 167 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 168 | } 169 | } 170 | }) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /runtime/ruby_test.go: -------------------------------------------------------------------------------- 1 | package runtime_test 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/flexstack/new-dockerfile/runtime" 9 | ) 10 | 11 | func TestRubyMatch(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | path string 15 | expected bool 16 | }{ 17 | { 18 | name: "Ruby project", 19 | path: "../testdata/ruby", 20 | expected: true, 21 | }, 22 | { 23 | name: "Ruby project with config/environment.rb", 24 | path: "../testdata/ruby-config-environment", 25 | expected: true, 26 | }, 27 | { 28 | name: "Ruby project with config.ru", 29 | path: "../testdata/ruby-config-ru", 30 | expected: true, 31 | }, 32 | { 33 | name: "Ruby project with rails", 34 | path: "../testdata/ruby-rails", 35 | expected: true, 36 | }, 37 | { 38 | name: "Not a Ruby project", 39 | path: "../testdata/deno", 40 | expected: false, 41 | }, 42 | } 43 | 44 | for _, test := range tests { 45 | t.Run(test.name, func(t *testing.T) { 46 | ruby := &runtime.Ruby{Log: logger} 47 | if ruby.Match(test.path) != test.expected { 48 | t.Errorf("expected %v, got %v", test.expected, ruby.Match(test.path)) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestRubyGenerateDockerfile(t *testing.T) { 55 | tests := []struct { 56 | name string 57 | path string 58 | data map[string]string 59 | expected []any 60 | }{ 61 | { 62 | name: "Ruby project", 63 | path: "../testdata/ruby", 64 | expected: []any{ 65 | `ARG VERSION=2.0.0`, 66 | regexp.MustCompile(`^ARG INSTALL_CMD="bundle install"$`), 67 | regexp.MustCompile(`^ARG BUILD_CMD=$`), 68 | regexp.MustCompile(`^ARG START_CMD=$`), 69 | }, 70 | }, 71 | { 72 | name: "Ruby project w/ mise", 73 | path: "../testdata/ruby-mise", 74 | expected: []any{ 75 | `ARG VERSION=2.7`, 76 | regexp.MustCompile(`^ARG INSTALL_CMD="bundle install"$`), 77 | regexp.MustCompile(`^ARG BUILD_CMD=$`), 78 | regexp.MustCompile(`^ARG START_CMD=$`), 79 | }, 80 | }, 81 | { 82 | name: "Ruby project with config/environment.rb", 83 | path: "../testdata/ruby-config-environment", 84 | expected: []any{ 85 | `ARG VERSION=3.0.1`, 86 | regexp.MustCompile(`^ARG INSTALL_CMD="bundle install"$`), 87 | regexp.MustCompile(`^ARG BUILD_CMD=$`), 88 | `ARG START_CMD="bundle exec ruby script/server"`, 89 | }, 90 | }, 91 | { 92 | name: "Ruby project with config.ru", 93 | path: "../testdata/ruby-config-ru", 94 | expected: []any{ 95 | `ARG VERSION=2.3.0`, 96 | regexp.MustCompile(`^ARG INSTALL_CMD="bundle install"$`), 97 | regexp.MustCompile(`^ARG BUILD_CMD=$`), 98 | `ARG START_CMD="bundle exec rackup config.ru -p ${PORT}"`, 99 | }, 100 | }, 101 | { 102 | name: "Ruby project with build mounts", 103 | path: "../testdata/ruby-config-ru", 104 | data: map[string]string{"BuildMounts": `--mount=type=secret,id=_env,target=/app/.env \ 105 | `}, 106 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 107 | }, 108 | { 109 | name: "Ruby project with install mounts", 110 | path: "../testdata/ruby-config-ru", 111 | data: map[string]string{"InstallMounts": `--mount=type=secret,id=_env,target=/app/.env \ 112 | `}, 113 | expected: []any{regexp.MustCompile(`^RUN --mount=type=secret,id=_env,target=/app/.env \\$`)}, 114 | }, 115 | { 116 | name: "Ruby project with rails", 117 | path: "../testdata/ruby-rails", 118 | expected: []any{ 119 | `ARG VERSION=3.1`, 120 | `ARG INSTALL_CMD="bundle install && corepack enable pnpm && pnpm i --frozen-lockfile"`, 121 | `ARG BUILD_CMD="bundle exec rake assets:precompile"`, 122 | `ARG START_CMD="bundle exec rails server -b 0.0.0.0 -p ${PORT}`, 123 | }, 124 | }, 125 | { 126 | name: "Not a Ruby project", 127 | path: "../testdata/deno", 128 | expected: []any{`ARG VERSION=3.1`, regexp.MustCompile(`^ARG INSTALL_CMD="bundle install"$`), regexp.MustCompile(`^ARG BUILD_CMD=$`), regexp.MustCompile(`^ARG START_CMD=$`)}, 129 | }, 130 | } 131 | 132 | for _, test := range tests { 133 | t.Run(test.name, func(t *testing.T) { 134 | ruby := &runtime.Ruby{Log: logger} 135 | dockerfile, err := ruby.GenerateDockerfile(test.path, test.data) 136 | if err != nil { 137 | t.Errorf("unexpected error: %v", err) 138 | } 139 | 140 | for _, line := range test.expected { 141 | found := false 142 | lines := strings.Split(string(dockerfile), "\n") 143 | 144 | for _, l := range lines { 145 | switch v := line.(type) { 146 | case string: 147 | if strings.Contains(l, v) { 148 | found = true 149 | break 150 | } 151 | case *regexp.Regexp: 152 | if v.MatchString(l) { 153 | found = true 154 | break 155 | } 156 | } 157 | } 158 | 159 | if !found { 160 | t.Errorf("expected %v, not found in %v", line, string(dockerfile)) 161 | } 162 | } 163 | }) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | jared@flexstack.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /node/install.js: -------------------------------------------------------------------------------- 1 | // Credits: 2 | // - Bun.js (https://github.com/oven-sh/bun/blob/main/packages/bun-release/src) 3 | const child_process = require("child_process"); 4 | const { unzipSync } = require("zlib"); 5 | const packageJson = require("./package.json"); 6 | const fs = require("fs"); 7 | 8 | async function downloadCli(version, platform) { 9 | const ext = platform.os === "win32" ? ".zip" : ".tar.gz"; 10 | const response = await fetch( 11 | `https://github.com/flexstack/new-dockerfile/releases/download/v${version}/${platform.bin}${ext}` 12 | ); 13 | const tgz = await response.arrayBuffer(); 14 | let buffer; 15 | 16 | try { 17 | buffer = unzipSync(tgz); 18 | } catch (cause) { 19 | process.exit(1); 20 | } 21 | 22 | function str(i, n) { 23 | return String.fromCharCode(...buffer.subarray(i, i + n)).replace( 24 | /\0.*$/, 25 | "" 26 | ); 27 | } 28 | let offset = 0; 29 | const dst = platform.exe; 30 | fs.mkdirSync("bin", { recursive: true }); 31 | while (offset < buffer.length) { 32 | const size = parseInt(str(offset + 124, 12), 8); 33 | offset += 512; 34 | if (!isNaN(size)) { 35 | write(dst, buffer.subarray(offset, offset + size)); 36 | offset += (size + 511) & ~511; 37 | } 38 | } 39 | try { 40 | fs.chmodSync(dst, 0o755); 41 | } catch (error) { 42 | process.exit(1); 43 | } 44 | 45 | process.exit(0); 46 | } 47 | 48 | const fetch = "fetch" in globalThis ? webFetch : nodeFetch; 49 | 50 | async function webFetch(url, options) { 51 | const response = await globalThis.fetch(url, options); 52 | if (options?.assert !== false && !isOk(response.status)) { 53 | try { 54 | await response.text(); 55 | } catch {} 56 | throw new Error(`${response.status}: ${url}`); 57 | } 58 | return response; 59 | } 60 | 61 | async function nodeFetch(url, options) { 62 | const { get } = await import("node:http"); 63 | return new Promise((resolve, reject) => { 64 | get(url, (response) => { 65 | const status = response.statusCode ?? 501; 66 | if (response.headers.location && isRedirect(status)) { 67 | return nodeFetch(url).then(resolve, reject); 68 | } 69 | if (options?.assert !== false && !isOk(status)) { 70 | return reject(new Error(`${status}: ${url}`)); 71 | } 72 | const body = []; 73 | response.on("data", (chunk) => { 74 | body.push(chunk); 75 | }); 76 | response.on("end", () => { 77 | resolve({ 78 | ok: isOk(status), 79 | status, 80 | async arrayBuffer() { 81 | return Buffer.concat(body).buffer; 82 | }, 83 | async text() { 84 | return Buffer.concat(body).toString("utf-8"); 85 | }, 86 | async json() { 87 | const text = Buffer.concat(body).toString("utf-8"); 88 | return JSON.parse(text); 89 | }, 90 | }); 91 | }); 92 | }).on("error", reject); 93 | }); 94 | } 95 | 96 | function isOk(status) { 97 | return status >= 200 && status <= 204; 98 | } 99 | 100 | function isRedirect(status) { 101 | switch (status) { 102 | case 301: // Moved Permanently 103 | case 308: // Permanent Redirect 104 | case 302: // Found 105 | case 307: // Temporary Redirect 106 | case 303: // See Other 107 | return true; 108 | } 109 | return false; 110 | } 111 | 112 | const os = process.platform; 113 | 114 | const arch = 115 | os === "darwin" && process.arch === "x64" && isRosetta2() 116 | ? "arm64" 117 | : process.arch; 118 | 119 | const platforms = [ 120 | { 121 | os: "darwin", 122 | arch: "x64", 123 | bin: "new-dockerfile-darwin-x86_64", 124 | exe: "bin/new-dockerfile", 125 | }, 126 | { 127 | os: "darwin", 128 | arch: "arm64", 129 | bin: "new-dockerfile-darwin-arm64", 130 | exe: "bin/new-dockerfile", 131 | }, 132 | { 133 | os: "linux", 134 | arch: "x64", 135 | bin: "new-dockerfile-linux-x86_64", 136 | exe: "bin/new-dockerfile", 137 | }, 138 | { 139 | os: "linux", 140 | arch: "arm64", 141 | bin: "new-dockerfile-linux-arm64", 142 | exe: "bin/new-dockerfile", 143 | }, 144 | { 145 | os: "win32", 146 | arch: "x64", 147 | bin: "new-dockerfile-windows-x86_64", 148 | exe: "bin/new-dockerfile", 149 | }, 150 | { 151 | os: "win32", 152 | arch: "arm64", 153 | bin: "new-dockerfile-windows-arm64", 154 | exe: "bin/new-dockerfile", 155 | }, 156 | ]; 157 | 158 | const supportedPlatforms = platforms.filter( 159 | (platform) => platform.os === os && platform.arch === arch 160 | ); 161 | 162 | function isRosetta2() { 163 | try { 164 | const { exitCode, stdout } = spawn("sysctl", [ 165 | "-n", 166 | "sysctl.proc_translated", 167 | ]); 168 | return exitCode === 0 && stdout.includes("1"); 169 | } catch (error) { 170 | return false; 171 | } 172 | } 173 | 174 | function spawn(cmd, args, options = {}) { 175 | const { status, stdout, stderr } = child_process.spawnSync(cmd, args, { 176 | stdio: "pipe", 177 | encoding: "utf-8", 178 | ...options, 179 | }); 180 | return { 181 | exitCode: status ?? 1, 182 | stdout, 183 | stderr, 184 | }; 185 | } 186 | 187 | function write(dst, content) { 188 | try { 189 | fs.writeFileSync(dst, content); 190 | return; 191 | } catch (error) { 192 | // If there is an error, ensure the parent directory 193 | // exists and try again. 194 | try { 195 | fs.mkdirSync(path.dirname(dst), { recursive: true }); 196 | } catch (error) { 197 | // The directory could have been created already. 198 | } 199 | fs.writeFileSync(dst, content); 200 | } 201 | } 202 | 203 | if (supportedPlatforms.length === 0) { 204 | throw new Error("Unsupported platform: " + os + " " + arch); 205 | } 206 | 207 | // Read version from package.json 208 | 209 | function wait() { 210 | setTimeout(wait, 1000); 211 | } 212 | 213 | downloadCli(packageJson.config.bin_version, supportedPlatforms[0]); 214 | wait(); 215 | -------------------------------------------------------------------------------- /runtime/golang.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log/slog" 8 | "maps" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "text/template" 13 | 14 | "github.com/pelletier/go-toml/v2" 15 | ) 16 | 17 | type Golang struct { 18 | Log *slog.Logger 19 | } 20 | 21 | func (d *Golang) Name() RuntimeName { 22 | return RuntimeNameGolang 23 | } 24 | 25 | func (d *Golang) Match(path string) bool { 26 | checkPaths := []string{ 27 | filepath.Join(path, "go.mod"), 28 | filepath.Join(path, "main.go"), 29 | } 30 | 31 | for _, p := range checkPaths { 32 | if _, err := os.Stat(p); err == nil { 33 | d.Log.Info("Detected Golang project") 34 | return true 35 | } 36 | } 37 | 38 | d.Log.Debug("Golang project not detected") 39 | return false 40 | } 41 | 42 | func (d *Golang) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 43 | tmpl, err := template.New("Dockerfile").Parse(golangTemplate) 44 | if err != nil { 45 | return nil, fmt.Errorf("Failed to parse template") 46 | } 47 | 48 | // Parse version from go.mod 49 | version, err := findGoVersion(path, d.Log) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | pkg := "" 55 | stat, err := os.Stat(filepath.Join(path, "cmd")) 56 | if err == nil { 57 | if stat.IsDir() { 58 | d.Log.Info("Found cmd directory. Detecting package...") 59 | 60 | // Walk the directory to find the main package 61 | items, err := os.ReadDir(filepath.Join(path, "cmd")) 62 | if err != nil { 63 | return nil, fmt.Errorf("Failed to read cmd directory") 64 | } 65 | 66 | for _, item := range items { 67 | if !item.IsDir() { 68 | if item.Name() == "main.go" { 69 | pkg = "./" + filepath.Join("cmd", item.Name()) 70 | break 71 | } 72 | 73 | continue 74 | } 75 | 76 | pkg = "./" + filepath.Join("cmd", item.Name()) 77 | break 78 | } 79 | } 80 | } 81 | 82 | if pkg == "" { 83 | if _, err := os.Stat(filepath.Join(path, "main.go")); err == nil { 84 | pkg = "./main.go" 85 | } 86 | } 87 | 88 | d.Log.Info("Using package: " + pkg) 89 | var buf bytes.Buffer 90 | templateData := map[string]string{ 91 | "Version": *version, 92 | "Package": pkg, 93 | } 94 | if len(data) > 0 { 95 | maps.Copy(templateData, data[0]) 96 | } 97 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 98 | return nil, fmt.Errorf("Failed to execute template") 99 | } 100 | 101 | return buf.Bytes(), nil 102 | } 103 | 104 | var golangTemplate = strings.TrimSpace(` 105 | ARG VERSION={{.Version}} 106 | ARG BUILDPLATFORM=linux/amd64 107 | ARG BUILDER=docker.io/library/golang 108 | FROM --platform=${BUILDPLATFORM} ${BUILDER}:${VERSION} AS base 109 | 110 | FROM base AS deps 111 | WORKDIR /go/src/app 112 | COPY go.mod* go.sum* ./ 113 | # GOPROXY is used to specify the module proxy to use. 114 | ARG GOPROXY=direct 115 | ENV GOPROXY=${GOPROXY} 116 | RUN if [ -f go.mod ]; then go mod download && go mod tidy; fi 117 | 118 | FROM deps AS build 119 | WORKDIR /go/src/app 120 | 121 | COPY . . 122 | 123 | ARG PACKAGE={{.Package}} 124 | ARG TARGETOS=linux 125 | ARG TARGETARCH=amd64 126 | ARG CGO_ENABLED=0 127 | # -trimpath removes the absolute path to the source code in the binary 128 | # -ldflags="-s -w" removes the symbol table and debug information from the binary 129 | RUN CGO_ENABLED=${CGO_ENABLED} GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /go/bin/app "${PACKAGE}" 130 | 131 | FROM debian:stable-slim 132 | WORKDIR /app 133 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 134 | RUN update-ca-certificates 2>/dev/null || true 135 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 136 | RUN chown -R nonroot:nonroot /app 137 | 138 | COPY --chown=nonroot:nonroot --from=build /go/bin/app . 139 | 140 | ENV PORT=8080 141 | EXPOSE ${PORT} 142 | USER nonroot:nonroot 143 | CMD ["/app/app"] 144 | `) 145 | 146 | func findGoVersion(path string, log *slog.Logger) (*string, error) { 147 | version := "" 148 | versionFiles := []string{ 149 | ".tool-versions", 150 | ".mise.toml", 151 | "go.mod", 152 | } 153 | 154 | for _, file := range versionFiles { 155 | fp := filepath.Join(path, file) 156 | _, err := os.Stat(fp) 157 | 158 | if err == nil { 159 | f, err := os.Open(fp) 160 | if err != nil { 161 | continue 162 | } 163 | 164 | defer f.Close() 165 | switch file { 166 | case ".tool-versions": 167 | scanner := bufio.NewScanner(f) 168 | for scanner.Scan() { 169 | line := scanner.Text() 170 | if strings.Contains(line, "golang") { 171 | version = strings.Split(line, " ")[1] 172 | log.Info("Detected Go version in .tool-versions: " + version) 173 | break 174 | } 175 | } 176 | 177 | if err := scanner.Err(); err != nil { 178 | return nil, fmt.Errorf("Failed to read .tool-versions file") 179 | } 180 | 181 | case "go.mod": 182 | scanner := bufio.NewScanner(f) 183 | for scanner.Scan() { 184 | line := scanner.Text() 185 | if strings.Contains(line, "go ") { 186 | version = strings.Split(line, " ")[1] 187 | log.Info("Detected Go version in go.mod: " + version) 188 | break 189 | } 190 | } 191 | 192 | if err := scanner.Err(); err != nil { 193 | return nil, fmt.Errorf("Failed to read go.mod file") 194 | } 195 | 196 | case ".mise.toml": 197 | var mise MiseToml 198 | if err := toml.NewDecoder(f).Decode(&mise); err != nil { 199 | return nil, fmt.Errorf("Failed to decode .mise.toml file") 200 | } 201 | goVersion, ok := mise.Tools["go"].(string) 202 | if !ok { 203 | versions, ok := mise.Tools["go"].([]string) 204 | if ok { 205 | goVersion = versions[0] 206 | } 207 | } 208 | if goVersion != "" { 209 | version = goVersion 210 | log.Info("Detected Python version in .mise.toml: " + version) 211 | break 212 | } 213 | } 214 | 215 | f.Close() 216 | if version != "" { 217 | break 218 | } 219 | } 220 | } 221 | 222 | if version == "" { 223 | version = "1.17" 224 | log.Info(fmt.Sprintf("No Go version detected. Using: %s", version)) 225 | } 226 | 227 | return &version, nil 228 | } 229 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= 2 | github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 8 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 9 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 10 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 11 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 12 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 14 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 15 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 16 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 17 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 18 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 19 | github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= 20 | github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 21 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 22 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 23 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 24 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 25 | github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 26 | github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 27 | github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= 28 | github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 29 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 30 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 33 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 35 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 36 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 37 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 38 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 39 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 40 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 41 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 42 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 43 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 44 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 45 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 46 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 47 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 48 | github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= 49 | github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= 50 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 51 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 52 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 53 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 54 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 55 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 56 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 57 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 58 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 59 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 60 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 61 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 62 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 63 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 64 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 65 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 66 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 67 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 68 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 69 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 71 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 72 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 73 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 74 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 75 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 76 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 77 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 78 | -------------------------------------------------------------------------------- /runtime/bun.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "log/slog" 9 | "maps" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "text/template" 14 | 15 | "github.com/pelletier/go-toml/v2" 16 | ) 17 | 18 | type Bun struct { 19 | Log *slog.Logger 20 | } 21 | 22 | func (d *Bun) Name() RuntimeName { 23 | return RuntimeNameBun 24 | } 25 | 26 | func (d *Bun) Match(path string) bool { 27 | checkPaths := []string{ 28 | filepath.Join(path, "bun.lockb"), 29 | filepath.Join(path, "bun.lock"), 30 | filepath.Join(path, "bunfig.toml"), 31 | } 32 | 33 | for _, p := range checkPaths { 34 | if _, err := os.Stat(p); err == nil { 35 | d.Log.Info("Detected Bun project") 36 | return true 37 | } 38 | } 39 | 40 | d.Log.Debug("Bun project not detected") 41 | return false 42 | } 43 | 44 | func (d *Bun) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 45 | tmpl, err := template.New("Dockerfile").Parse(bunTemplate) 46 | if err != nil { 47 | return nil, fmt.Errorf("Failed to parse template") 48 | } 49 | 50 | var packageJSON map[string]interface{} 51 | configFiles := []string{"package.json"} 52 | for _, file := range configFiles { 53 | f, err := os.Open(filepath.Join(path, file)) 54 | if err != nil { 55 | continue 56 | } 57 | 58 | defer f.Close() 59 | 60 | if err := json.NewDecoder(f).Decode(&packageJSON); err != nil { 61 | return nil, fmt.Errorf("Failed to decode " + file + " file") 62 | } 63 | 64 | f.Close() 65 | break 66 | } 67 | 68 | var startCMD, buildCMD string 69 | 70 | scripts, ok := packageJSON["scripts"].(map[string]interface{}) 71 | if ok { 72 | d.Log.Info("Detected scripts in package.json") 73 | 74 | startCommands := []string{"serve", "start:prod", "start:production", "start-prod", "start-production", "preview", "start"} 75 | for _, cmd := range startCommands { 76 | if _, ok := scripts[cmd].(string); ok { 77 | d.Log.Info("Detected start command in package.json: " + cmd) 78 | startCMD = fmt.Sprintf("bun run %s", cmd) 79 | break 80 | } 81 | } 82 | 83 | buildCommands := []string{"build:prod", "build:production", "build-prod", "build-production", "build"} 84 | for _, cmd := range buildCommands { 85 | if _, ok := scripts[cmd].(string); ok { 86 | d.Log.Info("Detected build command in package.json: " + cmd) 87 | buildCMD = fmt.Sprintf("bun run %s", cmd) 88 | break 89 | } 90 | } 91 | } 92 | 93 | mainFile := "" 94 | if packageJSON["main"] != nil { 95 | mainFile = packageJSON["main"].(string) 96 | } else if packageJSON["module"] != nil { 97 | mainFile = packageJSON["module"].(string) 98 | } 99 | 100 | if startCMD == "" && mainFile != "" { 101 | d.Log.Info("Detected start command via main file: " + mainFile) 102 | startCMD = fmt.Sprintf("bun %s", mainFile) 103 | } 104 | 105 | version, err := findBunVersion(path, d.Log) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | d.Log.Info( 111 | fmt.Sprintf(`Detected defaults 112 | Version : %s 113 | Install command : bun install 114 | Build command : %s 115 | Start command : %s 116 | 117 | Docker build arguments can supersede these defaults if provided. 118 | See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, buildCMD, startCMD), 119 | ) 120 | 121 | if startCMD != "" { 122 | startCMDJSON, _ := json.Marshal(startCMD) 123 | startCMD = string(startCMDJSON) 124 | } 125 | 126 | if buildCMD != "" { 127 | buildCMDJSON, _ := json.Marshal(buildCMD) 128 | buildCMD = string(buildCMDJSON) 129 | } 130 | 131 | var buf bytes.Buffer 132 | templateData := map[string]string{ 133 | "Version": *version, 134 | "BuildCMD": buildCMD, 135 | "StartCMD": startCMD, 136 | } 137 | if len(data) > 0 { 138 | maps.Copy(templateData, data[0]) 139 | } 140 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 141 | return nil, fmt.Errorf("Failed to execute template") 142 | } 143 | 144 | return buf.Bytes(), nil 145 | } 146 | 147 | var bunTemplate = strings.TrimSpace(` 148 | ARG VERSION={{.Version}} 149 | ARG BUILDER=docker.io/oven/bun 150 | FROM ${BUILDER}:${VERSION} AS base 151 | 152 | FROM base AS deps 153 | WORKDIR /app 154 | COPY package.json bun.lockb ./ 155 | ARG INSTALL_CMD="bun install" 156 | RUN {{.InstallMounts}}if [ ! -z "${INSTALL_CMD}" ]; then sh -c "$INSTALL_CMD"; fi 157 | 158 | FROM base AS builder 159 | WORKDIR /app 160 | COPY --from=deps /app/node_modules* ./node_modules 161 | COPY . . 162 | ENV NODE_ENV=production 163 | ARG BUILD_CMD={{.BuildCMD}} 164 | RUN {{.BuildMounts}}if [ ! -z "${BUILD_CMD}" ]; then sh -c "$BUILD_CMD"; fi 165 | 166 | FROM ${BUILDER}:${VERSION}-slim AS runtime 167 | WORKDIR /app 168 | 169 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 170 | RUN update-ca-certificates 2>/dev/null || true 171 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 172 | RUN chown -R nonroot:nonroot /app 173 | 174 | COPY --chown=nonroot:nonroot --from=builder /app . 175 | 176 | USER nonroot:nonroot 177 | 178 | ENV PORT=8080 179 | EXPOSE ${PORT} 180 | ENV NODE_ENV=production 181 | ARG START_CMD={{.StartCMD}} 182 | ENV START_CMD=${START_CMD} 183 | RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi 184 | CMD ${START_CMD} 185 | `) 186 | 187 | func findBunVersion(path string, log *slog.Logger) (*string, error) { 188 | version := "" 189 | versionFiles := []string{ 190 | ".tool-versions", 191 | ".mise.toml", 192 | } 193 | 194 | for _, file := range versionFiles { 195 | fp := filepath.Join(path, file) 196 | _, err := os.Stat(fp) 197 | 198 | if err == nil { 199 | f, err := os.Open(fp) 200 | if err != nil { 201 | continue 202 | } 203 | 204 | defer f.Close() 205 | switch file { 206 | case ".tool-versions": 207 | scanner := bufio.NewScanner(f) 208 | for scanner.Scan() { 209 | line := scanner.Text() 210 | if strings.Contains(line, "bun") { 211 | version = strings.Split(line, " ")[1] 212 | log.Info("Detected Bun version in .tool-versions: " + version) 213 | break 214 | } 215 | } 216 | 217 | if err := scanner.Err(); err != nil { 218 | return nil, fmt.Errorf("Failed to read .tool-versions file") 219 | } 220 | 221 | case ".mise.toml": 222 | var mise MiseToml 223 | if err := toml.NewDecoder(f).Decode(&mise); err != nil { 224 | return nil, fmt.Errorf("Failed to decode .mise.toml file") 225 | } 226 | bunVersion, ok := mise.Tools["bun"].(string) 227 | if !ok { 228 | versions, ok := mise.Tools["bun"].([]string) 229 | if ok { 230 | bunVersion = versions[0] 231 | } 232 | } 233 | if bunVersion != "" { 234 | version = bunVersion 235 | log.Info("Detected Bun version in .mise.toml: " + version) 236 | break 237 | } 238 | } 239 | 240 | f.Close() 241 | if version != "" { 242 | break 243 | } 244 | } 245 | } 246 | 247 | if version == "" { 248 | version = "1" 249 | log.Info(fmt.Sprintf("No Bun version detected. Using: %s", version)) 250 | } 251 | 252 | return &version, nil 253 | } 254 | -------------------------------------------------------------------------------- /runtime/nextjs.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log/slog" 8 | "maps" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "text/template" 13 | ) 14 | 15 | type NextJS struct { 16 | Log *slog.Logger 17 | } 18 | 19 | func (d *NextJS) Name() RuntimeName { 20 | return RuntimeNameNextJS 21 | } 22 | 23 | func (d *NextJS) Match(path string) bool { 24 | checkPaths := []string{ 25 | filepath.Join(path, "next.config.js"), 26 | filepath.Join(path, "next.config.ts"), 27 | filepath.Join(path, "next.config.cjs"), 28 | filepath.Join(path, "next.config.mjs"), 29 | filepath.Join(path, "next.config.mts"), 30 | filepath.Join(path, "next-env.d.ts"), 31 | filepath.Join(path, "src/next-env.d.ts"), 32 | filepath.Join(path, ".next"), 33 | } 34 | 35 | for _, p := range checkPaths { 36 | if _, err := os.Stat(p); err == nil { 37 | d.Log.Info("Detected Next.js project") 38 | return true 39 | } 40 | } 41 | 42 | d.Log.Debug("Next.js project not detected") 43 | return false 44 | } 45 | 46 | func (d *NextJS) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 47 | nextJSTemplate := nextJSServerTemplate 48 | nextConfigFiles := []string{ 49 | "next.config.js", 50 | "next.config.ts", 51 | "next.config.mjs", 52 | "next.config.mts", 53 | } 54 | 55 | for _, file := range nextConfigFiles { 56 | _, err := os.Stat(filepath.Join(path, file)) 57 | if err == nil { 58 | // Search for "output": "standalone" in next.config.js 59 | f, err := os.Open(filepath.Join(path, file)) 60 | if err != nil { 61 | return nil, fmt.Errorf("Failed to open next.config.js file") 62 | } 63 | 64 | defer f.Close() 65 | 66 | scanner := bufio.NewScanner(f) 67 | for scanner.Scan() { 68 | line := scanner.Text() 69 | if strings.Contains(line, "output") && strings.Contains(line, "standalone") { 70 | d.Log.Info("Found standalone output in next.config.js") 71 | nextJSTemplate = nextJSStandaloneTemplate 72 | f.Close() 73 | break 74 | } 75 | } 76 | 77 | if err := scanner.Err(); err != nil { 78 | return nil, fmt.Errorf("Failed to read next.config.js file") 79 | } 80 | 81 | f.Close() 82 | } 83 | } 84 | 85 | tmpl, err := template.New("Dockerfile").Parse(nextJSTemplate) 86 | if err != nil { 87 | return nil, fmt.Errorf("Failed to parse template") 88 | } 89 | 90 | version, err := findNodeVersion(path, d.Log) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | var buf bytes.Buffer 96 | templateData := map[string]string{ 97 | "Version": *version, 98 | } 99 | if len(data) > 0 { 100 | maps.Copy(templateData, data[0]) 101 | } 102 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 103 | return nil, fmt.Errorf("Failed to execute template") 104 | } 105 | 106 | return buf.Bytes(), nil 107 | } 108 | 109 | var nextJSStandaloneTemplate = strings.TrimSpace(` 110 | ARG VERSION={{.Version}} 111 | ARG BUILDER=docker.io/library/node 112 | FROM ${BUILDER}:${VERSION}-slim AS base 113 | 114 | # Install dependencies only when needed 115 | FROM base AS deps 116 | WORKDIR /app 117 | 118 | # Install dependencies based on the preferred package manager 119 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./ 120 | RUN {{.InstallMounts}}if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 121 | elif [ -f package-lock.json ]; then npm ci; \ 122 | elif [ -f bun.lockb ]; then npm i -g bun && bun install; \ 123 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 124 | else echo "Lockfile not found." && exit 1; \ 125 | fi 126 | 127 | # Rebuild the source code only when needed 128 | FROM base AS builder 129 | WORKDIR /app 130 | COPY --from=deps /app/node_modules ./node_modules 131 | COPY . . 132 | 133 | # Next.js collects completely anonymous telemetry data about general usage. 134 | # Learn more here: https://nextjs.org/telemetry 135 | # Uncomment the following line in case you want to disable telemetry during the build. 136 | ENV NEXT_TELEMETRY_DISABLED=1 137 | 138 | RUN {{.BuildMounts}}if [ -f yarn.lock ]; then yarn run build; \ 139 | elif [ -f package-lock.json ]; then npm run build; \ 140 | elif [ -f bun.lockb ]; then npm i -g bun && bun run build; \ 141 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 142 | else echo "Lockfile not found." && exit 1; \ 143 | fi 144 | 145 | # Production image, copy all the files and run next 146 | FROM base AS runner 147 | WORKDIR /app 148 | 149 | ENV NODE_ENV=production 150 | # Uncomment the following line in case you want to disable telemetry during runtime. 151 | ENV NEXT_TELEMETRY_DISABLED 1 152 | 153 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 154 | RUN update-ca-certificates 2>/dev/null || true 155 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 156 | RUN chown -R nonroot:nonroot /app 157 | 158 | COPY --from=builder --chown=nonroot:nonroot /app/public* ./public 159 | 160 | # Set the correct permission for prerender cache 161 | RUN mkdir .next 162 | RUN chown nonroot:nonroot .next 163 | 164 | # Automatically leverage output traces to reduce image size 165 | # https://nextjs.org/docs/advanced-features/output-file-tracing 166 | COPY --from=builder --chown=nonroot:nonroot /app/.next/standalone ./ 167 | COPY --from=builder --chown=nonroot:nonroot /app/.next/static ./.next/static 168 | 169 | USER nonroot 170 | 171 | ENV PORT=3000 172 | EXPOSE ${PORT} 173 | 174 | # server.js is created by next build from the standalone output 175 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 176 | CMD HOSTNAME="0.0.0.0" node server.js 177 | `) 178 | 179 | var nextJSServerTemplate = strings.TrimSpace(` 180 | ARG VERSION=lts 181 | ARG BUILDER=docker.io/library/node 182 | FROM ${BUILDER}:${VERSION}-slim AS base 183 | 184 | # Install dependencies only when needed 185 | FROM base AS deps 186 | WORKDIR /app 187 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./ 188 | RUN {{.InstallMounts}}if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 189 | elif [ -f package-lock.json ]; then npm ci; \ 190 | elif [ -f bun.lockb ]; then npm i -g bun && bun install; \ 191 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 192 | else echo "Lockfile not found." && exit 1; \ 193 | fi 194 | 195 | FROM base AS builder 196 | 197 | ENV NODE_ENV=production 198 | WORKDIR /app 199 | COPY --from=deps /app/node_modules ./node_modules 200 | COPY . . 201 | RUN {{.BuildMounts}}if [ -f yarn.lock ]; then yarn run build; \ 202 | elif [ -f package-lock.json ]; then npm run build; \ 203 | elif [ -f bun.lockb ]; then npm i -g bun && bun run build; \ 204 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 205 | else echo "Lockfile not found." && exit 1; \ 206 | fi 207 | 208 | # Production image, copy all the files and run next 209 | FROM base AS runner 210 | WORKDIR /app 211 | 212 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 213 | RUN update-ca-certificates 2>/dev/null || true 214 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 215 | RUN chown -R nonroot:nonroot /app 216 | 217 | COPY --from=builder --chown=nonroot:nonroot /app/next.config.* ./ 218 | COPY --from=builder --chown=nonroot:nonroot /app/public* ./public 219 | COPY --from=builder --chown=nonroot:nonroot /app/.next ./.next 220 | COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules 221 | 222 | USER nonroot 223 | 224 | ENV NODE_ENV=production 225 | ENV NEXT_TELEMETRY_DISABLED=1 226 | ENV PORT=8080 227 | EXPOSE ${PORT} 228 | CMD ["node_modules/.bin/next", "start", "-H", "0.0.0.0"] 229 | `) 230 | -------------------------------------------------------------------------------- /runtime/deno.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "io/fs" 9 | "log/slog" 10 | "maps" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "text/template" 15 | 16 | "github.com/pelletier/go-toml/v2" 17 | ) 18 | 19 | type Deno struct { 20 | Log *slog.Logger 21 | } 22 | 23 | func (d *Deno) Name() RuntimeName { 24 | return RuntimeNameDeno 25 | } 26 | 27 | func (d *Deno) Match(path string) bool { 28 | checkPaths := []string{ 29 | filepath.Join(path, "deno.json"), 30 | filepath.Join(path, "deno.jsonc"), 31 | filepath.Join(path, "deno.lock"), 32 | filepath.Join(path, "deps.ts"), 33 | filepath.Join(path, "mod.ts"), 34 | } 35 | 36 | for _, p := range checkPaths { 37 | if _, err := os.Stat(p); err == nil { 38 | d.Log.Info("Detected Deno project") 39 | return true 40 | } 41 | } 42 | 43 | detected := false 44 | // Walk the directory to find a .ts file with a "deno.land/x" import 45 | filepath.WalkDir(path, func(path string, info fs.DirEntry, err error) error { 46 | if err != nil { 47 | return err 48 | } 49 | 50 | if !info.IsDir() && filepath.Ext(path) == ".ts" { 51 | f, err := os.Open(path) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | defer f.Close() 57 | scanner := bufio.NewScanner(f) 58 | for scanner.Scan() { 59 | text := scanner.Text() 60 | 61 | if (strings.HasPrefix(text, "import ") || strings.HasPrefix(text, "export ")) && strings.Contains(text, " from ") && strings.Contains(text, "https://deno.land/") { 62 | d.Log.Info("Detected Deno project") 63 | detected = true 64 | return filepath.SkipAll 65 | } 66 | } 67 | } 68 | 69 | return nil 70 | }) 71 | 72 | d.Log.Debug("Deno project not detected") 73 | return detected 74 | } 75 | 76 | func (d *Deno) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 77 | tmpl, err := template.New("Dockerfile").Parse(denoTemplate) 78 | if err != nil { 79 | return nil, fmt.Errorf("Failed to parse template") 80 | } 81 | 82 | var denoJSON map[string]interface{} 83 | configFiles := []string{"deno.jsonc", "deno.json"} 84 | for _, file := range configFiles { 85 | f, err := os.Open(filepath.Join(path, file)) 86 | if err != nil { 87 | continue 88 | } 89 | 90 | defer f.Close() 91 | 92 | if err := json.NewDecoder(f).Decode(&denoJSON); err != nil { 93 | return nil, fmt.Errorf("Failed to decode " + file + " file") 94 | } 95 | 96 | f.Close() 97 | break 98 | } 99 | 100 | var startCMD string 101 | var installCMD string 102 | 103 | scripts, ok := denoJSON["tasks"].(map[string]interface{}) 104 | if ok { 105 | startCommands := []string{"serve", "start:prod", "start:production", "start-prod", "start-production", "preview", "start"} 106 | for _, cmd := range startCommands { 107 | if _, ok := scripts[cmd].(string); ok { 108 | d.Log.Info("Detected start command in deno.json: " + cmd) 109 | startCMD = fmt.Sprintf("deno task %s", cmd) 110 | break 111 | } 112 | } 113 | 114 | if _, ok := scripts["cache"].(string); ok { 115 | d.Log.Info("Detected install command in deno.json: cache") 116 | installCMD = "deno task cache" 117 | } 118 | } 119 | 120 | if startCMD == "" { 121 | mainFiles := []string{"mod.ts", "src/mod.ts", "main.ts", "src/main.ts", "index.ts", "src/index.ts"} 122 | for _, mainFile := range mainFiles { 123 | if _, err := os.Stat(filepath.Join(path, mainFile)); err == nil { 124 | d.Log.Info("Detected start command via main/mod file: " + mainFile) 125 | 126 | startCMD = fmt.Sprintf("deno run --allow-all %s", mainFile) 127 | if installCMD == "" { 128 | d.Log.Info("Detected install command via main/mod file: " + mainFile) 129 | installCMD = "deno cache " + mainFile 130 | } 131 | break 132 | } 133 | } 134 | } 135 | 136 | version, err := findDenoVersion(path, d.Log) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | d.Log.Info( 142 | fmt.Sprintf(`Detected defaults 143 | Version : %s 144 | Install command : %s 145 | Start command : %s 146 | 147 | Docker build arguments can supersede these defaults if provided. 148 | See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, installCMD, startCMD), 149 | ) 150 | 151 | if installCMD != "" { 152 | installCMDJSON, _ := json.Marshal(installCMD) 153 | installCMD = string(installCMDJSON) 154 | } 155 | 156 | if startCMD != "" { 157 | startCMDJSON, _ := json.Marshal(startCMD) 158 | startCMD = string(startCMDJSON) 159 | } 160 | 161 | var buf bytes.Buffer 162 | templateData := map[string]string{ 163 | "Version": *version, 164 | "InstallCMD": installCMD, 165 | "StartCMD": startCMD, 166 | } 167 | if len(data) > 0 { 168 | maps.Copy(templateData, data[0]) 169 | } 170 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 171 | return nil, fmt.Errorf("Failed to execute template") 172 | } 173 | 174 | return buf.Bytes(), nil 175 | } 176 | 177 | var denoTemplate = strings.TrimSpace(` 178 | ARG VERSION={{.Version}} 179 | ARG BUILDER=docker.io/denoland/deno 180 | FROM ${BUILDER}:${VERSION} as base 181 | 182 | FROM debian:stable-slim 183 | WORKDIR /app 184 | 185 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 186 | RUN update-ca-certificates 2>/dev/null || true 187 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 188 | RUN chown -R nonroot:nonroot /app 189 | 190 | ENV DENO_DIR=.deno_cache 191 | RUN mkdir -p /app/${DENO_DIR} 192 | RUN chown -R nonroot:nonroot /app/${DENO_DIR} 193 | 194 | COPY --chown=nonroot:nonroot --from=base /usr/bin/deno /usr/local/bin/deno 195 | COPY --chown=nonroot:nonroot . . 196 | 197 | USER nonroot:nonroot 198 | 199 | ENV PORT=8080 200 | EXPOSE ${PORT} 201 | ARG INSTALL_CMD={{.InstallCMD}} 202 | RUN {{.InstallMounts}}if [ ! -z "${INSTALL_CMD}" ]; then sh -c "$INSTALL_CMD"; fi 203 | 204 | ARG START_CMD={{.StartCMD}} 205 | ENV START_CMD=${START_CMD} 206 | RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi 207 | CMD ${START_CMD} 208 | `) 209 | 210 | func findDenoVersion(path string, log *slog.Logger) (*string, error) { 211 | version := "" 212 | versionFiles := []string{ 213 | ".tool-versions", 214 | ".mise.toml", 215 | } 216 | 217 | for _, file := range versionFiles { 218 | fp := filepath.Join(path, file) 219 | _, err := os.Stat(fp) 220 | 221 | if err == nil { 222 | f, err := os.Open(fp) 223 | if err != nil { 224 | continue 225 | } 226 | 227 | defer f.Close() 228 | switch file { 229 | case ".tool-versions": 230 | scanner := bufio.NewScanner(f) 231 | for scanner.Scan() { 232 | line := scanner.Text() 233 | if strings.Contains(line, "deno") { 234 | version = strings.Split(line, " ")[1] 235 | log.Info("Detected Deno version in .tool-versions: " + version) 236 | break 237 | } 238 | } 239 | 240 | if err := scanner.Err(); err != nil { 241 | return nil, fmt.Errorf("Failed to read .tool-versions file") 242 | } 243 | 244 | case ".mise.toml": 245 | var mise MiseToml 246 | if err := toml.NewDecoder(f).Decode(&mise); err != nil { 247 | return nil, fmt.Errorf("Failed to decode .mise.toml file") 248 | } 249 | denoVersion, ok := mise.Tools["deno"].(string) 250 | if !ok { 251 | versions, ok := mise.Tools["deno"].([]string) 252 | if ok { 253 | denoVersion = versions[0] 254 | } 255 | } 256 | if denoVersion != "" { 257 | version = denoVersion 258 | log.Info("Detected Deno version in .mise.toml: " + version) 259 | break 260 | } 261 | } 262 | 263 | f.Close() 264 | if version != "" { 265 | break 266 | } 267 | 268 | } 269 | } 270 | 271 | if version == "" { 272 | version = "latest" 273 | log.Info(fmt.Sprintf("No Deno version detected. Using: %s", version)) 274 | } 275 | 276 | return &version, nil 277 | } 278 | -------------------------------------------------------------------------------- /runtime/php.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "log/slog" 9 | "maps" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | "text/template" 15 | ) 16 | 17 | type PHP struct { 18 | Log *slog.Logger 19 | } 20 | 21 | func (d *PHP) Name() RuntimeName { 22 | return RuntimeNamePHP 23 | } 24 | 25 | func (d *PHP) Match(path string) bool { 26 | checkPaths := []string{ 27 | filepath.Join(path, "composer.json"), 28 | filepath.Join(path, "index.php"), 29 | } 30 | 31 | for _, p := range checkPaths { 32 | if _, err := os.Stat(p); err == nil { 33 | d.Log.Info("Detected PHP project") 34 | return true 35 | } 36 | } 37 | 38 | d.Log.Debug("PHP project not detected") 39 | return false 40 | } 41 | 42 | func (d *PHP) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 43 | tmpl, err := template.New("Dockerfile").Parse(phpTemplate) 44 | if err != nil { 45 | return nil, fmt.Errorf("Failed to parse template") 46 | } 47 | 48 | // Parse version from go.mod 49 | version, err := findPHPVersion(path, d.Log) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | startCMD := "apache2-foreground" 55 | installCMD := "" 56 | if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil { 57 | d.Log.Info("Detected composer.json file") 58 | installCMD = "composer update && composer install --prefer-dist --no-dev --optimize-autoloader --no-interaction" 59 | } 60 | 61 | packageManager := "" 62 | npmInstallCMD := "" 63 | if _, err := os.Stat(filepath.Join(path, "package-lock.json")); err == nil { 64 | packageManager = "npm" 65 | npmInstallCMD = "npm ci" 66 | } else if _, err := os.Stat(filepath.Join(path, "pnpm-lock.yaml")); err == nil { 67 | packageManager = "pnpm" 68 | npmInstallCMD = "corepack enable pnpm && pnpm i --frozen-lockfile" 69 | } else if _, err := os.Stat(filepath.Join(path, "yarn.lock")); err == nil { 70 | packageManager = "yarn" 71 | npmInstallCMD = "yarn --frozen-lockfile" 72 | } else if _, err := os.Stat(filepath.Join(path, "bun.lockb")); err == nil { 73 | packageManager = "bun" 74 | npmInstallCMD = "bun install" 75 | } 76 | 77 | if npmInstallCMD != "" { 78 | d.Log.Info("Detected package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lockb file") 79 | if installCMD == "" { 80 | installCMD = npmInstallCMD 81 | } else { 82 | installCMD = fmt.Sprintf("%s && %s", installCMD, npmInstallCMD) 83 | } 84 | } 85 | 86 | buildCMD := "" 87 | if packageManager != "" { 88 | f, err := os.Open(filepath.Join(path, "package.json")) 89 | if err != nil { 90 | return nil, fmt.Errorf("Failed to open package.json file") 91 | } 92 | 93 | defer f.Close() 94 | 95 | var packageJSON map[string]interface{} 96 | if err := json.NewDecoder(f).Decode(&packageJSON); err != nil { 97 | return nil, fmt.Errorf("Failed to decode package.json file") 98 | } 99 | 100 | scripts, ok := packageJSON["scripts"].(map[string]interface{}) 101 | if ok { 102 | d.Log.Info("Detected scripts in package.json") 103 | buildCommands := []string{"build:prod", "build:production", "build-prod", "build-production", "build"} 104 | for _, cmd := range buildCommands { 105 | if _, ok := scripts[cmd].(string); ok { 106 | corepack := "" 107 | if packageManager == "pnpm" { 108 | corepack = "corepack enable pnpm &&" 109 | } 110 | buildCMD = fmt.Sprintf("%s%s run %s", corepack, packageManager, cmd) 111 | d.Log.Info("Detected build command in package.json: " + buildCMD) 112 | break 113 | } 114 | } 115 | } 116 | 117 | f.Close() 118 | } 119 | 120 | d.Log.Info( 121 | fmt.Sprintf(`Detected defaults 122 | PHP version : %s 123 | Install command : %s 124 | Build command : %s 125 | Start command : %s 126 | 127 | Docker build arguments can supersede these defaults if provided. 128 | See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, installCMD, buildCMD, startCMD), 129 | ) 130 | 131 | var buf bytes.Buffer 132 | templateData := map[string]string{ 133 | "Version": *version, 134 | "InstallCMD": safeCommand(installCMD), 135 | "BuildCMD": safeCommand(buildCMD), 136 | "StartCMD": safeCommand(startCMD), 137 | } 138 | if len(data) > 0 { 139 | maps.Copy(templateData, data[0]) 140 | } 141 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 142 | return nil, fmt.Errorf("Failed to execute template") 143 | } 144 | 145 | return buf.Bytes(), nil 146 | } 147 | 148 | var phpTemplate = strings.TrimSpace(` 149 | ARG VERSION={{.Version}} 150 | ARG BUILDER=docker.io/library/composer 151 | FROM ${BUILDER}:lts as build 152 | RUN apk add --no-cache nodejs npm 153 | WORKDIR /app 154 | COPY . . 155 | 156 | ARG INSTALL_CMD={{.InstallCMD}} 157 | ARG BUILD_CMD={{.BuildCMD}} 158 | RUN {{.InstallMounts}}if [ ! -z "${INSTALL_CMD}" ]; then sh -c "$INSTALL_CMD"; fi 159 | RUN {{.BuildMounts}}if [ ! -z "${BUILD_CMD}" ]; then sh -c "$BUILD_CMD"; fi 160 | 161 | FROM php:${VERSION}-apache AS runtime 162 | 163 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 164 | RUN update-ca-certificates 2>/dev/null || true 165 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 166 | 167 | ENV PORT=8080 168 | EXPOSE ${PORT} 169 | RUN sed -i "s/80/${PORT}/g" /etc/apache2/sites-available/000-default.conf /etc/apache2/ports.conf 170 | COPY --from=build --chown=nonroot:nonroot /app /var/www/html 171 | 172 | USER nonroot:nonroot 173 | 174 | ARG START_CMD={{.StartCMD}} 175 | ENV START_CMD=${START_CMD} 176 | RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi 177 | CMD ${START_CMD} 178 | `) 179 | 180 | func findPHPVersion(path string, log *slog.Logger) (*string, error) { 181 | version := "" 182 | versionFiles := []string{ 183 | ".tool-versions", 184 | "composer.json", 185 | } 186 | 187 | for _, file := range versionFiles { 188 | fp := filepath.Join(path, file) 189 | _, err := os.Stat(fp) 190 | 191 | if err == nil { 192 | f, err := os.Open(fp) 193 | if err != nil { 194 | continue 195 | } 196 | 197 | defer f.Close() 198 | switch file { 199 | case ".tool-versions": 200 | scanner := bufio.NewScanner(f) 201 | for scanner.Scan() { 202 | line := scanner.Text() 203 | if strings.Contains(line, "php") { 204 | version = strings.Split(line, " ")[1] 205 | log.Info("Detected PHP version in .tool-versions: " + version) 206 | break 207 | } 208 | } 209 | 210 | if err := scanner.Err(); err != nil { 211 | return nil, fmt.Errorf("Failed to read .tool-versions file") 212 | } 213 | 214 | case "composer.json": 215 | var composerJSON map[string]interface{} 216 | err := json.NewDecoder(f).Decode(&composerJSON) 217 | if err != nil { 218 | return nil, fmt.Errorf("Failed to read composer.json file") 219 | } 220 | 221 | if require, ok := composerJSON["require"].(map[string]interface{}); ok { 222 | if php, ok := require["php"].(string); ok { 223 | // Version can be a range, e.g. ">=7.2" so we need to extract the version 224 | if gteVersionRe.MatchString(php) { 225 | version = gteVersionRe.FindStringSubmatch(php)[1] 226 | } else if rangeVersionRe.MatchString(php) { 227 | version = rangeVersionRe.FindStringSubmatch(php)[2] 228 | } else if tildeVersionRe.MatchString(php) { 229 | version = tildeVersionRe.FindStringSubmatch(php)[1] 230 | } else if caretVersionRe.MatchString(php) { 231 | version = caretVersionRe.FindStringSubmatch(php)[1] 232 | } else if exactVersionRe.MatchString(php) { 233 | version = exactVersionRe.FindStringSubmatch(php)[1] 234 | } 235 | 236 | version = strings.TrimSuffix(version, ".") 237 | log.Info("Detected PHP version from composer.json: " + version) 238 | } 239 | } 240 | } 241 | 242 | f.Close() 243 | if version != "" { 244 | break 245 | } 246 | } 247 | } 248 | 249 | if version == "" { 250 | version = "8.3" 251 | log.Info(fmt.Sprintf("No PHP version detected. Using: %s", version)) 252 | } 253 | 254 | return &version, nil 255 | } 256 | 257 | var gteVersionRe = regexp.MustCompile(`^>=\s*([\d.]+)`) 258 | var rangeVersionRe = regexp.MustCompile(`^([\d.]+)\s*-\s*([\d.]+)`) 259 | var tildeVersionRe = regexp.MustCompile(`^~\s*([\d.]+)`) 260 | var caretVersionRe = regexp.MustCompile(`^\^([\d.]+)`) 261 | var exactVersionRe = regexp.MustCompile(`^([\d.]+)`) 262 | -------------------------------------------------------------------------------- /runtime/elixir.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log/slog" 8 | "maps" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "text/template" 13 | 14 | "github.com/pelletier/go-toml/v2" 15 | ) 16 | 17 | type Elixir struct { 18 | Log *slog.Logger 19 | } 20 | 21 | func (d *Elixir) Name() RuntimeName { 22 | return RuntimeNameElixir 23 | } 24 | 25 | func (d *Elixir) Match(path string) bool { 26 | checkPaths := []string{ 27 | filepath.Join(path, "mix.exs"), 28 | } 29 | 30 | for _, p := range checkPaths { 31 | if _, err := os.Stat(p); err == nil { 32 | d.Log.Info("Detected Elixir project") 33 | return true 34 | } 35 | } 36 | 37 | d.Log.Debug("Elixir project not detected") 38 | return false 39 | } 40 | 41 | func (d *Elixir) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 42 | tmpl, err := template.New("Dockerfile").Parse(elixirTemplate) 43 | if err != nil { 44 | return nil, fmt.Errorf("Failed to parse template") 45 | } 46 | 47 | // Parse elixirVersion from go.mod 48 | elixirVersion, err := findElixirVersion(path, d.Log) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | otpVersion, err := findOTPVersion(path, d.Log) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | binName, err := findBinName(path) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | d.Log.Info( 64 | fmt.Sprintf(`Detected defaults 65 | Elixir version : %s 66 | Erlang version : %s 67 | Binary name : %s 68 | 69 | Docker build arguments can supersede these defaults if provided. 70 | See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *elixirVersion, *otpVersion, binName), 71 | ) 72 | 73 | var buf bytes.Buffer 74 | templateData := map[string]string{ 75 | "ElixirVersion": *elixirVersion, 76 | "OTPVersion": strings.Split(*otpVersion, ".")[0], 77 | "BinName": binName, 78 | } 79 | if len(data) > 0 { 80 | maps.Copy(templateData, data[0]) 81 | } 82 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 83 | return nil, fmt.Errorf("Failed to execute template") 84 | } 85 | 86 | return buf.Bytes(), nil 87 | } 88 | 89 | var elixirTemplate = strings.TrimSpace(` 90 | ARG VERSION={{.ElixirVersion}} 91 | ARG OTP_VERSION={{.OTPVersion}} 92 | ARG BUILDER=docker.io/library/elixir 93 | FROM ${BUILDER}:${VERSION}-otp-${OTP_VERSION}-slim AS build 94 | WORKDIR /app 95 | RUN apt-get update -y && apt-get install -y build-essential git \ 96 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 97 | 98 | ENV MIX_ENV=prod 99 | RUN mix local.hex --force && mix local.rebar --force 100 | 101 | COPY mix.exs mix.lock ./ 102 | RUN mix deps.get --only $MIX_ENV 103 | RUN mkdir config 104 | 105 | COPY config/config.exs config/${MIX_ENV}.exs config/ 106 | RUN mix deps.compile 107 | 108 | COPY priv priv 109 | COPY lib lib 110 | COPY assets assets 111 | RUN mix assets.deploy 112 | RUN mix compile 113 | 114 | COPY config/runtime.exs config/ 115 | RUN mix release 116 | 117 | FROM debian:stable-slim AS runtime 118 | WORKDIR /app 119 | RUN apt-get update && apt-get install -y --no-install-recommends wget libstdc++6 openssl libncurses5 locales ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 120 | RUN update-ca-certificates 2>/dev/null || true 121 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 122 | 123 | RUN chown -R nonroot:nonroot /app 124 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 125 | ENV LANG=en_US.UTF-8 126 | ENV LANGUAGE=en_US:en 127 | ENV LC_ALL=en_US.UTF-8 128 | 129 | ENV MIX_ENV="prod" 130 | 131 | # Only copy the final release from the build stage 132 | ARG BIN_NAME={{.BinName}} 133 | ENV BIN_NAME=${BIN_NAME} 134 | RUN if [ -z "${BIN_NAME}" ]; then echo "Unable to detect an app name" && exit 1; fi 135 | COPY --from=build --chown=nonroot:nonroot /app/_build/${MIX_ENV}/rel/${BIN_NAME} ./ 136 | RUN cp /app/bin/${BIN_NAME} /app/bin/server 137 | 138 | ENV PORT=8080 139 | EXPOSE ${PORT} 140 | USER nonroot:nonroot 141 | 142 | CMD ["/app/bin/server", "start"] 143 | `) 144 | 145 | func findElixirVersion(path string, log *slog.Logger) (*string, error) { 146 | version := "" 147 | versionFiles := []string{ 148 | ".tool-versions", 149 | ".elixir-version", 150 | } 151 | 152 | for _, file := range versionFiles { 153 | fp := filepath.Join(path, file) 154 | _, err := os.Stat(fp) 155 | 156 | if err == nil { 157 | f, err := os.Open(fp) 158 | if err != nil { 159 | continue 160 | } 161 | 162 | defer f.Close() 163 | switch file { 164 | case ".tool-versions": 165 | scanner := bufio.NewScanner(f) 166 | for scanner.Scan() { 167 | line := scanner.Text() 168 | if strings.Contains(line, "elixir") { 169 | version = strings.Split(line, " ")[1] 170 | log.Info("Detected Elixir version in .tool-versions: " + version) 171 | break 172 | } 173 | } 174 | 175 | if err := scanner.Err(); err != nil { 176 | return nil, fmt.Errorf("Failed to read .tool-versions file") 177 | } 178 | 179 | case ".elixir-version": 180 | scanner := bufio.NewScanner(f) 181 | for scanner.Scan() { 182 | line := scanner.Text() 183 | if line != "" { 184 | version = line 185 | log.Info("Detected Elixir version from .elixir-version: " + version) 186 | break 187 | } 188 | } 189 | 190 | if err := scanner.Err(); err != nil { 191 | return nil, fmt.Errorf("Failed to read .elixir-version file") 192 | } 193 | } 194 | 195 | f.Close() 196 | if version != "" { 197 | break 198 | } 199 | } 200 | } 201 | 202 | if version == "" { 203 | version = "1.12" 204 | log.Info(fmt.Sprintf("No Elixir version detected. Using: %s", version)) 205 | } 206 | 207 | return &version, nil 208 | } 209 | 210 | func findOTPVersion(path string, log *slog.Logger) (*string, error) { 211 | version := "" 212 | versionFiles := []string{ 213 | ".tool-versions", 214 | ".erlang-version", 215 | ".mise.toml", 216 | } 217 | 218 | for _, file := range versionFiles { 219 | fp := filepath.Join(path, file) 220 | _, err := os.Stat(fp) 221 | 222 | if err == nil { 223 | f, err := os.Open(fp) 224 | if err != nil { 225 | continue 226 | } 227 | 228 | defer f.Close() 229 | switch file { 230 | case ".tool-versions": 231 | scanner := bufio.NewScanner(f) 232 | for scanner.Scan() { 233 | line := scanner.Text() 234 | if strings.Contains(line, "erlang") { 235 | version = strings.Split(line, " ")[1] 236 | log.Info("Detected Erlang version in .tool-versions: " + version) 237 | break 238 | } 239 | } 240 | 241 | if err := scanner.Err(); err != nil { 242 | return nil, fmt.Errorf("Failed to read .tool-versions file") 243 | } 244 | 245 | case ".erlang-version": 246 | scanner := bufio.NewScanner(f) 247 | for scanner.Scan() { 248 | line := scanner.Text() 249 | if line != "" { 250 | version = line 251 | log.Info("Detected Erlang version from .erlang-version: " + version) 252 | break 253 | } 254 | } 255 | 256 | if err := scanner.Err(); err != nil { 257 | return nil, fmt.Errorf("Failed to read .erlang-version file") 258 | } 259 | 260 | case ".mise.toml": 261 | var mise MiseToml 262 | if err := toml.NewDecoder(f).Decode(&mise); err != nil { 263 | return nil, fmt.Errorf("Failed to decode .mise.toml file") 264 | } 265 | erlangVersion, ok := mise.Tools["erlang"].(string) 266 | if !ok { 267 | versions, ok := mise.Tools["erlang"].([]string) 268 | if ok { 269 | erlangVersion = versions[0] 270 | } 271 | } 272 | if erlangVersion != "" { 273 | version = erlangVersion 274 | log.Info("Detected Erlang version in .mise.toml: " + version) 275 | break 276 | } 277 | } 278 | 279 | f.Close() 280 | if version != "" { 281 | break 282 | } 283 | } 284 | } 285 | 286 | if version == "" { 287 | version = "26.2.5" 288 | log.Info(fmt.Sprintf("No Erlang version detected. Using: %s", version)) 289 | } 290 | 291 | return &version, nil 292 | } 293 | 294 | func isPhoenixProject(path string) bool { 295 | _, err := os.Stat(filepath.Join(path, "config/config.exs")) 296 | return err == nil 297 | } 298 | 299 | func findBinName(path string) (string, error) { 300 | if _, err := os.Stat(filepath.Join(path, "mix.exs")); err != nil { 301 | return "", nil 302 | } 303 | 304 | configFile, err := os.Open(filepath.Join(path, "mix.exs")) 305 | if err != nil { 306 | return "", err 307 | } 308 | 309 | defer configFile.Close() 310 | 311 | scanner := bufio.NewScanner(configFile) 312 | for scanner.Scan() { 313 | line := scanner.Text() 314 | if strings.Contains(line, "app: :") { 315 | binName := strings.Split(strings.Replace(line, "app:", "", 1), ":")[1] 316 | binName = strings.TrimSpace(strings.Trim(binName, ",'\"")) 317 | return binName, nil 318 | } 319 | } 320 | 321 | if err := scanner.Err(); err != nil { 322 | return "", err 323 | } 324 | 325 | return "", nil 326 | } 327 | -------------------------------------------------------------------------------- /runtime/ruby.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log/slog" 8 | "maps" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "text/template" 13 | 14 | "github.com/pelletier/go-toml/v2" 15 | ) 16 | 17 | type Ruby struct { 18 | Log *slog.Logger 19 | } 20 | 21 | func (d *Ruby) Name() RuntimeName { 22 | return RuntimeNameRuby 23 | } 24 | 25 | func (d *Ruby) Match(path string) bool { 26 | checkPaths := []string{ 27 | filepath.Join(path, "Gemfile"), 28 | filepath.Join(path, "Gemfile.lock"), 29 | filepath.Join(path, "Rakefile"), 30 | filepath.Join(path, "config.ru"), 31 | filepath.Join(path, "config/environment.rb"), 32 | } 33 | 34 | for _, p := range checkPaths { 35 | if _, err := os.Stat(p); err == nil { 36 | d.Log.Info("Detected Ruby project") 37 | return true 38 | } 39 | } 40 | 41 | d.Log.Debug("Ruby project not detected") 42 | return false 43 | } 44 | 45 | func (d *Ruby) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 46 | tmpl, err := template.New("Dockerfile").Parse(rubyTemplate) 47 | if err != nil { 48 | return nil, fmt.Errorf("Failed to parse template") 49 | } 50 | 51 | // Parse version from go.mod 52 | version, err := findRubyVersion(path, d.Log) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | installCMD := "bundle install" 58 | packageManager := "" 59 | 60 | if _, err := os.Stat(filepath.Join(path, "package-lock.json")); err == nil { 61 | packageManager = "npm" 62 | installCMD = installCMD + " && npm ci" 63 | } else if _, err := os.Stat(filepath.Join(path, "pnpm-lock.yaml")); err == nil { 64 | packageManager = "pnpm" 65 | installCMD = installCMD + " && corepack enable pnpm && pnpm i --frozen-lockfile" 66 | } else if _, err := os.Stat(filepath.Join(path, "yarn.lock")); err == nil { 67 | packageManager = "yarn" 68 | installCMD = installCMD + " && yarn --frozen-lockfile" 69 | } else if _, err := os.Stat(filepath.Join(path, "bun.lockb")); err == nil { 70 | packageManager = "bun" 71 | installCMD = installCMD + " && bun install" 72 | } 73 | 74 | if packageManager != "" { 75 | d.Log.Info("Detected Node.js package manager: " + packageManager) 76 | } 77 | 78 | isRails := isRailsProject(path) 79 | buildCMD := "" 80 | startCMD := "" 81 | if isRails { 82 | d.Log.Info("Detected Rails project") 83 | buildCMD = "bundle exec rake assets:precompile" 84 | startCMD = "bundle exec rails server -b 0.0.0.0 -p ${PORT}" 85 | } else { 86 | configFiles := []string{"config.ru", "config/environment.rb", "Rakefile"} 87 | 88 | for _, fn := range configFiles { 89 | _, err := os.Stat(filepath.Join(path, fn)) 90 | if err != nil { 91 | continue 92 | } 93 | 94 | switch fn { 95 | case "config.ru": 96 | d.Log.Info("Detected Rack project") 97 | startCMD = "bundle exec rackup config.ru -p ${PORT}" 98 | case "config/environment.rb": 99 | d.Log.Info("Detected Rails project") 100 | startCMD = "bundle exec ruby script/server" 101 | case "Rakefile": 102 | d.Log.Info("Detected Rake project") 103 | startCMD = "bundle exec rake" 104 | } 105 | 106 | break 107 | } 108 | } 109 | 110 | d.Log.Info( 111 | fmt.Sprintf(`Detected defaults 112 | Ruby version : %s 113 | Node package manager : %s 114 | Install command : %s 115 | Build command : %s 116 | Start command : %s 117 | 118 | Docker build arguments can supersede these defaults if provided. 119 | See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, packageManager, installCMD, buildCMD, startCMD), 120 | ) 121 | 122 | var buf bytes.Buffer 123 | templateData := map[string]string{ 124 | "Version": *version, 125 | "InstallCMD": safeCommand(installCMD), 126 | "BuildCMD": safeCommand(buildCMD), 127 | "StartCMD": safeCommand(startCMD), 128 | } 129 | if len(data) > 0 { 130 | maps.Copy(templateData, data[0]) 131 | } 132 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 133 | return nil, fmt.Errorf("Failed to execute template") 134 | } 135 | 136 | return buf.Bytes(), nil 137 | } 138 | 139 | var rubyTemplate = strings.TrimSpace(` 140 | ARG VERSION={{.Version}} 141 | ARG BUILDER=docker.io/library/ruby 142 | FROM ${BUILDER}:${VERSION}-slim 143 | WORKDIR /app 144 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 145 | RUN update-ca-certificates 2>/dev/null || true 146 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 147 | 148 | ARG INSTALL_CMD={{.InstallCMD}} 149 | ARG BUILD_CMD={{.BuildCMD}} 150 | ENV NODE_ENV=production 151 | 152 | RUN chown -R nonroot:nonroot /app 153 | COPY --chown=nonroot:nonroot . . 154 | 155 | RUN {{.InstallMounts}}if [ ! -z "${INSTALL_CMD}" ]; then sh -c "$INSTALL_CMD"; fi 156 | RUN {{.BuildMounts}}if [ ! -z "${BUILD_CMD}" ]; then sh -c "$BUILD_CMD"; fi 157 | 158 | ENV PORT=8080 159 | EXPOSE ${PORT} 160 | USER nonroot:nonroot 161 | 162 | ARG START_CMD={{.StartCMD}} 163 | ENV START_CMD=${START_CMD} 164 | RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi 165 | CMD ${START_CMD} 166 | `) 167 | 168 | func findRubyVersion(path string, log *slog.Logger) (*string, error) { 169 | version := "" 170 | versionFiles := []string{ 171 | ".tool-versions", 172 | ".ruby-version", 173 | ".mise.toml", 174 | "Gemfile", 175 | } 176 | 177 | for _, file := range versionFiles { 178 | fp := filepath.Join(path, file) 179 | _, err := os.Stat(fp) 180 | 181 | if err == nil { 182 | f, err := os.Open(fp) 183 | if err != nil { 184 | continue 185 | } 186 | 187 | defer f.Close() 188 | switch file { 189 | case ".tool-versions": 190 | scanner := bufio.NewScanner(f) 191 | for scanner.Scan() { 192 | line := scanner.Text() 193 | if strings.Contains(line, "ruby") { 194 | version = strings.Split(line, " ")[1] 195 | log.Info("Detected Ruby version in .tool-versions: " + version) 196 | break 197 | } 198 | } 199 | 200 | if err := scanner.Err(); err != nil { 201 | return nil, fmt.Errorf("Failed to read .tool-versions file") 202 | } 203 | 204 | case ".ruby-version": 205 | scanner := bufio.NewScanner(f) 206 | for scanner.Scan() { 207 | line := scanner.Text() 208 | if line != "" { 209 | version = line 210 | log.Info("Detected Ruby version from .ruby-version: " + version) 211 | break 212 | } 213 | } 214 | 215 | if err := scanner.Err(); err != nil { 216 | return nil, fmt.Errorf("Failed to read go.mod file") 217 | } 218 | 219 | case "Gemfile": 220 | scanner := bufio.NewScanner(f) 221 | for scanner.Scan() { 222 | line := scanner.Text() 223 | if strings.HasPrefix(line, "ruby") { 224 | v := strings.Split(line, "'") 225 | if len(v) < 2 { 226 | v = strings.Split(line, "\"") 227 | } 228 | ruby := v[1] 229 | if gteVersionRe.MatchString(ruby) { 230 | version = gteVersionRe.FindStringSubmatch(ruby)[1] 231 | } else if rangeVersionRe.MatchString(ruby) { 232 | version = rangeVersionRe.FindStringSubmatch(ruby)[2] 233 | } else if tildeVersionRe.MatchString(ruby) { 234 | version = tildeVersionRe.FindStringSubmatch(ruby)[1] 235 | } else if caretVersionRe.MatchString(ruby) { 236 | version = caretVersionRe.FindStringSubmatch(ruby)[1] 237 | } else if exactVersionRe.MatchString(ruby) { 238 | version = ruby 239 | } 240 | log.Info("Detected Ruby version from Gemfile: " + version) 241 | break 242 | } 243 | } 244 | 245 | if err := scanner.Err(); err != nil { 246 | return nil, fmt.Errorf("Failed to read Gemfile") 247 | } 248 | 249 | case ".mise.toml": 250 | var mise MiseToml 251 | if err := toml.NewDecoder(f).Decode(&mise); err != nil { 252 | return nil, fmt.Errorf("Failed to decode .mise.toml file") 253 | } 254 | rubyVersion, ok := mise.Tools["ruby"].(string) 255 | if !ok { 256 | versions, ok := mise.Tools["ruby"].([]string) 257 | if ok { 258 | rubyVersion = versions[0] 259 | } 260 | } 261 | if rubyVersion != "" { 262 | version = rubyVersion 263 | log.Info("Detected Python version in .mise.toml: " + version) 264 | break 265 | } 266 | } 267 | 268 | f.Close() 269 | if version != "" { 270 | break 271 | } 272 | } 273 | } 274 | 275 | if version == "" { 276 | version = "3.1" 277 | log.Info(fmt.Sprintf("No Ruby version detected. Using: %s", version)) 278 | } 279 | 280 | return &version, nil 281 | } 282 | 283 | func isRailsProject(path string) bool { 284 | _, err := os.Stat(filepath.Join(path, "Gemfile")) 285 | if err == nil { 286 | f, err := os.Open(filepath.Join(path, "Gemfile")) 287 | if err != nil { 288 | return false 289 | } 290 | 291 | defer f.Close() 292 | 293 | scanner := bufio.NewScanner(f) 294 | for scanner.Scan() { 295 | line := scanner.Text() 296 | if strings.HasPrefix(line, "gem 'rails'") || strings.HasPrefix(line, "gem \"rails\"") { 297 | return true 298 | } 299 | } 300 | } 301 | 302 | return false 303 | } 304 | -------------------------------------------------------------------------------- /runtime/node.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "maps" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "strings" 15 | "text/template" 16 | 17 | "github.com/Masterminds/semver/v3" 18 | "github.com/pelletier/go-toml/v2" 19 | ) 20 | 21 | type Node struct { 22 | Log *slog.Logger 23 | } 24 | 25 | func (d *Node) Name() RuntimeName { 26 | return RuntimeNameNode 27 | } 28 | 29 | func (d *Node) Match(path string) bool { 30 | checkPaths := []string{ 31 | filepath.Join(path, "yarn.lock"), 32 | filepath.Join(path, "package-lock.json"), 33 | filepath.Join(path, "pnpm-lock.yaml"), 34 | } 35 | 36 | for _, p := range checkPaths { 37 | if _, err := os.Stat(p); err == nil { 38 | d.Log.Info("Detected Node project") 39 | return true 40 | } 41 | } 42 | 43 | d.Log.Debug("Node project not detected") 44 | return false 45 | } 46 | 47 | func (d *Node) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 48 | tmpl, err := template.New("Dockerfile").Parse(nodeTemplate) 49 | if err != nil { 50 | return nil, fmt.Errorf("Failed to parse template") 51 | } 52 | 53 | version, err := findNodeVersion(path, d.Log) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | var packageJSON map[string]interface{} 59 | 60 | if _, err := os.Stat(filepath.Join(path, "package.json")); err == nil { 61 | f, err := os.Open(filepath.Join(path, "package.json")) 62 | if err != nil { 63 | return nil, fmt.Errorf("Failed to open package.json file") 64 | } 65 | 66 | defer f.Close() 67 | 68 | if err := json.NewDecoder(f).Decode(&packageJSON); err != nil { 69 | return nil, fmt.Errorf("Failed to decode package.json file") 70 | } 71 | } else { 72 | d.Log.Info("No package.json file found") 73 | packageJSON = map[string]interface{}{} 74 | } 75 | 76 | installCMD := "npm ci" 77 | packageManager := "npm" 78 | 79 | if _, err := os.Stat(filepath.Join(path, "yarn.lock")); err == nil { 80 | installCMD = "yarn --frozen-lockfile" 81 | packageManager = "yarn" 82 | } else if _, err := os.Stat(filepath.Join(path, "pnpm-lock.yaml")); err == nil { 83 | installCMD = "pnpm i --frozen-lockfile" 84 | packageManager = "pnpm" 85 | } 86 | 87 | var buildCMD, startCMD string 88 | 89 | scripts, ok := packageJSON["scripts"].(map[string]interface{}) 90 | 91 | if ok { 92 | d.Log.Info("Detected scripts in package.json") 93 | startCommands := []string{"serve", "start:prod", "start:production", "start-prod", "start-production", "preview", "start"} 94 | for _, cmd := range startCommands { 95 | if _, ok := scripts[cmd].(string); ok { 96 | startCMD = fmt.Sprintf("%s run %s", packageManager, cmd) 97 | d.Log.Info("Detected start command in package.json: " + startCMD) 98 | break 99 | } 100 | } 101 | 102 | if startCMD == "" { 103 | for name, v := range scripts { 104 | value, ok := v.(string) 105 | 106 | if ok && startScriptRe.MatchString(value) { 107 | startCMD = fmt.Sprintf("%s run %s", packageManager, name) 108 | d.Log.Info("Detected start command in package.json via regex pattern: " + startCMD) 109 | break 110 | } 111 | } 112 | } 113 | 114 | buildCommands := []string{"build:prod", "build:production", "build-prod", "build-production", "build"} 115 | for _, cmd := range buildCommands { 116 | if _, ok := scripts[cmd].(string); ok { 117 | buildCMD = fmt.Sprintf("%s run %s", packageManager, cmd) 118 | d.Log.Info("Detected build command in package.json: " + buildCMD) 119 | break 120 | } 121 | } 122 | } 123 | 124 | mainFile := "" 125 | if packageJSON["main"] != nil { 126 | mainFile = packageJSON["main"].(string) 127 | } else if packageJSON["module"] != nil { 128 | mainFile = packageJSON["module"].(string) 129 | } 130 | 131 | if startCMD == "" && mainFile != "" { 132 | startCMD = fmt.Sprintf("node %s", mainFile) 133 | } 134 | 135 | d.Log.Info( 136 | fmt.Sprintf(`Detected defaults 137 | Node version : %s 138 | Package manager : %s 139 | Install command : %s 140 | Build command : %s 141 | Start command : %s 142 | 143 | Docker build arguments can supersede these defaults if provided. 144 | See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, packageManager, installCMD, buildCMD, startCMD), 145 | ) 146 | 147 | var buf bytes.Buffer 148 | templateData := map[string]string{ 149 | "Version": *version, 150 | "InstallCMD": safeCommand(installCMD), 151 | "BuildCMD": safeCommand(buildCMD), 152 | "StartCMD": safeCommand(startCMD), 153 | } 154 | if len(data) > 0 { 155 | maps.Copy(templateData, data[0]) 156 | } 157 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 158 | return nil, errors.New("Failed to execute template") 159 | } 160 | 161 | return buf.Bytes(), nil 162 | } 163 | 164 | func safeCommand(cmd string) string { 165 | if cmd == "" { 166 | return "" 167 | } 168 | 169 | cmdJSON, _ := json.Marshal(cmd) 170 | return strings.ReplaceAll(string(cmdJSON), `\u0026\u0026`, "&&") 171 | } 172 | 173 | var startScriptRe = regexp.MustCompile(`^.*?\b(ts-)?node(mon)?\b.*?(index|main|server|client)\.([cm]?[tj]s)\b`) 174 | 175 | var nodeTemplate = strings.TrimSpace(` 176 | ARG VERSION={{.Version}} 177 | ARG BUILDER=docker.io/library/node 178 | FROM ${BUILDER}:${VERSION}-slim AS base 179 | RUN corepack enable 180 | 181 | FROM base AS deps 182 | WORKDIR /app 183 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./ 184 | ARG INSTALL_CMD={{.InstallCMD}} 185 | ARG NPM_MIRROR= 186 | RUN if [ ! -z "${NPM_MIRROR}" ]; then npm config set registry ${NPM_MIRROR}; fi 187 | RUN {{.InstallMounts}}if [ ! -z "${INSTALL_CMD}" ]; then echo "${INSTALL_CMD}" > dep.sh; sh dep.sh; fi 188 | 189 | FROM base AS builder 190 | WORKDIR /app 191 | COPY --from=deps /app/node_modules* ./node_modules 192 | COPY . . 193 | ENV NODE_ENV=production 194 | ARG BUILD_CMD={{.BuildCMD}} 195 | RUN {{.BuildMounts}}if [ ! -z "${BUILD_CMD}" ]; then sh -c "$BUILD_CMD"; fi 196 | 197 | FROM base AS runtime 198 | WORKDIR /app 199 | 200 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 201 | RUN update-ca-certificates 2>/dev/null || true 202 | RUN addgroup --system nonroot && adduser --disabled-login --ingroup nonroot nonroot 203 | ENV COREPACK_HOME=/app/.cache 204 | RUN mkdir -p /app/.cache 205 | RUN chown -R nonroot:nonroot /app 206 | 207 | COPY --chown=nonroot:nonroot --from=builder /app . 208 | 209 | USER nonroot:nonroot 210 | 211 | ENV PORT=8080 212 | EXPOSE ${PORT} 213 | ENV NODE_ENV=production 214 | ARG START_CMD={{.StartCMD}} 215 | ENV START_CMD=${START_CMD} 216 | RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi 217 | CMD ${START_CMD} 218 | `) 219 | 220 | func findNodeVersion(path string, log *slog.Logger) (*string, error) { 221 | version := "" 222 | versionFiles := []string{ 223 | ".nvmrc", 224 | ".node-version", 225 | ".tool-versions", 226 | ".mise.toml", 227 | "package.json", 228 | } 229 | 230 | // This is really jank but it should be fine 231 | nodeVersionsToCheck := []string{} 232 | for i := 0; i < 60; i++ { 233 | for j := 0; j < 60; j++ { 234 | for k := 0; k < 60; k++ { 235 | nodeVersionsToCheck = append(nodeVersionsToCheck, fmt.Sprintf("%d.%d.%d", i, j, k)) 236 | } 237 | } 238 | } 239 | 240 | for _, file := range versionFiles { 241 | fp := filepath.Join(path, file) 242 | _, err := os.Stat(fp) 243 | 244 | if err == nil { 245 | f, err := os.Open(fp) 246 | if err != nil { 247 | continue 248 | } 249 | 250 | defer f.Close() 251 | switch file { 252 | case "package.json": 253 | // Check package.json for engines.node 254 | var packageJSON map[string]interface{} 255 | if err := json.NewDecoder(f).Decode(&packageJSON); err != nil { 256 | return nil, fmt.Errorf("Failed to decode package.json file") 257 | } 258 | 259 | if engines, ok := packageJSON["engines"].(map[string]interface{}); ok { 260 | if nodeVersion, ok := engines["node"].(string); ok { 261 | ver, err := semver.NewVersion(nodeVersion) 262 | if err != nil { 263 | constraints, err := semver.NewConstraint(nodeVersion) 264 | if err != nil { 265 | continue 266 | } 267 | 268 | for _, v := range nodeVersionsToCheck { 269 | semv, _ := semver.NewVersion(v) 270 | if constraints.Check(semv) { 271 | ver = semv 272 | break 273 | } 274 | } 275 | } 276 | if ver != nil { 277 | if ver.Minor() > 0 { 278 | version = fmt.Sprintf("%d.%d", ver.Major(), ver.Minor()) 279 | } else { 280 | version = fmt.Sprint(ver.Major()) 281 | } 282 | log.Info("Detected Node version in package.json: " + version) 283 | break 284 | } 285 | } 286 | } 287 | 288 | case ".tool-versions": 289 | scanner := bufio.NewScanner(f) 290 | for scanner.Scan() { 291 | line := scanner.Text() 292 | if strings.Contains(line, "nodejs") { 293 | version = strings.Split(line, " ")[1] 294 | log.Info("Detected Node version in .tool-versions: " + version) 295 | break 296 | } 297 | } 298 | 299 | if err := scanner.Err(); err != nil { 300 | return nil, fmt.Errorf("Failed to read .tool-versions file") 301 | } 302 | 303 | case ".mise.toml": 304 | var mise MiseToml 305 | if err := toml.NewDecoder(f).Decode(&mise); err != nil { 306 | return nil, fmt.Errorf("Failed to decode .mise.toml file") 307 | } 308 | nodeVersion, ok := mise.Tools["node"].(string) 309 | if !ok { 310 | nodeVersions, ok := mise.Tools["node"].([]string) 311 | if ok { 312 | nodeVersion = nodeVersions[0] 313 | } 314 | } 315 | if nodeVersion != "" { 316 | version = nodeVersion 317 | log.Info("Detected Node version in .mise.toml: " + version) 318 | break 319 | } 320 | 321 | case ".nvmrc", ".node-version": 322 | scanner := bufio.NewScanner(f) 323 | for scanner.Scan() { 324 | line := scanner.Text() 325 | if strings.HasPrefix(line, "v") { 326 | version = strings.TrimPrefix(line, "v") 327 | log.Info("Detected Node version in " + file + ": " + version) 328 | break 329 | } else { 330 | version = line 331 | log.Info("Detected Node version in " + file + ": " + version) 332 | break 333 | } 334 | } 335 | 336 | if err := scanner.Err(); err != nil { 337 | return nil, fmt.Errorf("Failed to read version file") 338 | } 339 | } 340 | 341 | f.Close() 342 | if version != "" { 343 | break 344 | } 345 | } 346 | } 347 | 348 | if version == "" { 349 | version = "lts" 350 | log.Info(fmt.Sprintf("No Node version detected. Using %s.", version)) 351 | } 352 | 353 | return &version, nil 354 | } 355 | 356 | type MiseToml struct { 357 | Tools map[string]interface{} `toml:"tools"` 358 | } 359 | -------------------------------------------------------------------------------- /runtime/java.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "log/slog" 9 | "maps" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | "text/template" 15 | ) 16 | 17 | type Java struct { 18 | Log *slog.Logger 19 | } 20 | 21 | func (d *Java) Name() RuntimeName { 22 | return RuntimeNameJava 23 | } 24 | 25 | func (d *Java) Match(path string) bool { 26 | checkPaths := []string{ 27 | filepath.Join(path, "build.gradle"), 28 | filepath.Join(path, "gradlew"), 29 | filepath.Join(path, "pom.xml"), 30 | filepath.Join(path, "pom.atom"), 31 | filepath.Join(path, "pom.clj"), 32 | filepath.Join(path, "pom.groovy"), 33 | filepath.Join(path, "pom.rb"), 34 | filepath.Join(path, "pom.scala"), 35 | filepath.Join(path, "pom.yml"), 36 | filepath.Join(path, "pom.yaml"), 37 | } 38 | 39 | for _, p := range checkPaths { 40 | if _, err := os.Stat(p); err == nil { 41 | d.Log.Info("Detected Java project") 42 | return true 43 | } 44 | } 45 | 46 | d.Log.Debug("Java project not detected") 47 | return false 48 | } 49 | 50 | func (d *Java) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 51 | version, err := findJDKVersion(path, d.Log) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | tpl := javaMavenTemplate 57 | startCMD := "java $JAVA_OPTS -jar target/*jar" 58 | buildCMD := "" 59 | gradleVersion := "" 60 | 61 | if _, err := os.Stat(filepath.Join(path, "gradlew")); err == nil { 62 | gv, err := findGradleVersion(path, d.Log) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | gradleVersion = *gv 68 | tpl = javaGradleTemplate 69 | buildCMD = "./gradlew clean build -x check -x test" 70 | startCMD = "java $JAVA_OPTS -jar $(ls -1 build/libs/*jar | grep -v plain)" 71 | } 72 | 73 | mavenVersion := "" 74 | for _, file := range pomFiles { 75 | if _, err := os.Stat(filepath.Join(path, file)); err == nil { 76 | mv, err := findMavenVersion(path, d.Log) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | mavenVersion = *mv 82 | buildCMD = "mvn -DoutputFile=target/mvn-dependency-list.log -B -DskipTests clean dependency:list install" 83 | break 84 | } 85 | } 86 | 87 | if isSpringBootApp(path) { 88 | d.Log.Info("Detected Spring Boot application") 89 | startCMD = "java -Dserver.port=${PORT} $JAVA_OPTS -jar target/*jar" 90 | if gradleVersion != "" { 91 | startCMD = "java $JAVA_OPTS -jar -Dserver.port=${PORT} $(ls -1 build/libs/*jar | grep -v plain)" 92 | } 93 | } 94 | 95 | if isWildflySwarmApp(path) { 96 | d.Log.Info("Detected Wildfly Swarm application") 97 | startCMD = "java -Dswarm.http.port=${PORT} $JAVA_OPTS -jar target/*jar" 98 | } 99 | 100 | d.Log.Info( 101 | fmt.Sprintf(`Detected defaults 102 | JDK version : %s 103 | Maven version : %s 104 | Gradle version : %s 105 | Build command : %s 106 | Start command : %s 107 | 108 | Docker build arguments can supersede these defaults if provided. 109 | See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, mavenVersion, gradleVersion, buildCMD, startCMD), 110 | ) 111 | 112 | var buf bytes.Buffer 113 | 114 | tmpl, err := template.New("Dockerfile").Parse(tpl) 115 | if err != nil { 116 | return nil, fmt.Errorf("Failed to parse template") 117 | } 118 | 119 | if buildCMD != "" { 120 | buildCMDJSON, _ := json.Marshal(buildCMD) 121 | buildCMD = string(buildCMDJSON) 122 | } 123 | 124 | if startCMD != "" { 125 | startCMDJSON, _ := json.Marshal(startCMD) 126 | startCMD = string(startCMDJSON) 127 | } 128 | templateData := map[string]string{ 129 | "Version": *version, 130 | "GradleVersion": gradleVersion, 131 | "MavenVersion": mavenVersion, 132 | "BuildCMD": buildCMD, 133 | "StartCMD": startCMD, 134 | } 135 | if len(data) > 0 { 136 | maps.Copy(templateData, data[0]) 137 | } 138 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 139 | return nil, fmt.Errorf("Failed to execute template") 140 | } 141 | 142 | return buf.Bytes(), nil 143 | } 144 | 145 | var javaMavenTemplate = strings.TrimSpace(` 146 | ARG VERSION={{.Version}} 147 | ARG MAVEN_VERSION={{.MavenVersion}} 148 | FROM maven:${MAVEN_VERSION}-eclipse-temurin-${VERSION} AS build 149 | WORKDIR /app 150 | 151 | COPY pom.xml* pom.atom* pom.clj* pom.groovy* pom.rb* pom.scala* pom.yml* pom.yaml* . 152 | RUN mvn dependency:go-offline 153 | 154 | COPY src src 155 | RUN mvn install 156 | 157 | ARG BUILD_CMD={{.BuildCMD}} 158 | RUN if [ ! -z "${BUILD_CMD}" ]; then sh -c "$BUILD_CMD"; fi 159 | 160 | FROM eclipse-temurin:${VERSION}-jdk AS runtime 161 | WORKDIR /app 162 | VOLUME /tmp 163 | 164 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 165 | RUN update-ca-certificates 2>/dev/null || true 166 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 167 | RUN chown -R nonroot:nonroot /app 168 | 169 | COPY --from=build --chown=nonroot:nonroot /app/target/*.jar /app/target/ 170 | 171 | ENV PORT=8080 172 | EXPOSE ${PORT} 173 | USER nonroot:nonroot 174 | 175 | ARG JAVA_OPTS= 176 | ENV JAVA_OPTS=${JAVA_OPTS} 177 | ARG START_CMD={{.StartCMD}} 178 | ENV START_CMD=${START_CMD} 179 | RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi 180 | CMD ${START_CMD} 181 | `) 182 | 183 | var javaGradleTemplate = strings.TrimSpace(` 184 | ARG VERSION={{.Version}} 185 | ARG GRADLE_VERSION={{.GradleVersion}} 186 | ARG BUILDER=docker.io/library/gradle 187 | FROM ${BUILDER}:${GRADLE_VERSION}-jdk${VERSION} AS build 188 | WORKDIR /app 189 | 190 | COPY build.gradle* gradlew* settings.gradle* ./ 191 | COPY gradle/ ./gradle/ 192 | COPY src src 193 | 194 | ARG BUILD_CMD={{.BuildCMD}} 195 | RUN if [ ! -z "${BUILD_CMD}" ]; then sh -c "$BUILD_CMD"; fi 196 | 197 | FROM eclipse-temurin:${VERSION}-jdk AS runtime 198 | WORKDIR /app 199 | VOLUME /tmp 200 | 201 | RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* 202 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 203 | RUN chown -R nonroot:nonroot /app 204 | 205 | COPY --from=build --chown=nonroot:nonroot /app/build/libs/*.jar /app/build/libs/ 206 | 207 | ENV PORT=8080 208 | EXPOSE ${PORT} 209 | USER nonroot:nonroot 210 | 211 | ARG JAVA_OPTS= 212 | ENV JAVA_OPTS=${JAVA_OPTS} 213 | ARG START_CMD={{.StartCMD}} 214 | ENV START_CMD=${START_CMD} 215 | RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi 216 | CMD ${START_CMD} 217 | `) 218 | 219 | func findJDKVersion(path string, log *slog.Logger) (*string, error) { 220 | version := "" 221 | versionFiles := []string{".tool-versions"} 222 | 223 | for _, file := range versionFiles { 224 | fp := filepath.Join(path, file) 225 | _, err := os.Stat(fp) 226 | 227 | if err == nil { 228 | f, err := os.Open(fp) 229 | if err != nil { 230 | continue 231 | } 232 | 233 | defer f.Close() 234 | switch file { 235 | case ".tool-versions": 236 | scanner := bufio.NewScanner(f) 237 | for scanner.Scan() { 238 | line := scanner.Text() 239 | if strings.Contains(line, "java") { 240 | versionString := strings.Split(line, " ")[1] 241 | regexpVersion := regexp.MustCompile(`\d+`) 242 | version = regexpVersion.FindString(versionString) 243 | break 244 | } 245 | } 246 | 247 | if err := scanner.Err(); err != nil { 248 | return nil, fmt.Errorf("Failed to read .tool-versions file") 249 | } 250 | 251 | log.Info("Detected JDK version in .tool-versions: " + version) 252 | } 253 | 254 | f.Close() 255 | if version != "" { 256 | break 257 | } 258 | } 259 | } 260 | 261 | if version == "" { 262 | version = "17" 263 | } 264 | 265 | return &version, nil 266 | } 267 | 268 | func findGradleVersion(path string, log *slog.Logger) (*string, error) { 269 | version := "" 270 | versionFiles := []string{".tool-versions"} 271 | 272 | for _, file := range versionFiles { 273 | fp := filepath.Join(path, file) 274 | _, err := os.Stat(fp) 275 | 276 | if err == nil { 277 | f, err := os.Open(fp) 278 | if err != nil { 279 | continue 280 | } 281 | 282 | defer f.Close() 283 | switch file { 284 | case ".tool-versions": 285 | scanner := bufio.NewScanner(f) 286 | for scanner.Scan() { 287 | line := scanner.Text() 288 | if strings.Contains(line, "gradle") { 289 | version = strings.Split(line, " ")[1] 290 | log.Info("Detected Gradle version in .tool-versions: " + version) 291 | break 292 | } 293 | } 294 | 295 | if err := scanner.Err(); err != nil { 296 | return nil, fmt.Errorf("Failed to read .tool-versions file") 297 | } 298 | } 299 | 300 | f.Close() 301 | if version != "" { 302 | break 303 | } 304 | } 305 | } 306 | 307 | if version == "" { 308 | version = "8" 309 | log.Info(fmt.Sprintf("No Gradle version detected. Using: %s", version)) 310 | } 311 | 312 | return &version, nil 313 | } 314 | 315 | func findMavenVersion(path string, log *slog.Logger) (*string, error) { 316 | version := "" 317 | versionFiles := []string{".tool-versions"} 318 | 319 | for _, file := range versionFiles { 320 | fp := filepath.Join(path, file) 321 | _, err := os.Stat(fp) 322 | 323 | if err == nil { 324 | f, err := os.Open(fp) 325 | if err != nil { 326 | continue 327 | } 328 | 329 | defer f.Close() 330 | switch file { 331 | case ".tool-versions": 332 | scanner := bufio.NewScanner(f) 333 | for scanner.Scan() { 334 | line := scanner.Text() 335 | if strings.Contains(line, "maven") { 336 | version = strings.Split(line, " ")[1] 337 | log.Info("Detected Maven version in .tool-versions: " + version) 338 | break 339 | } 340 | } 341 | 342 | if err := scanner.Err(); err != nil { 343 | return nil, fmt.Errorf("Failed to read .tool-versions file") 344 | } 345 | } 346 | 347 | f.Close() 348 | if version != "" { 349 | break 350 | } 351 | } 352 | } 353 | 354 | if version == "" { 355 | version = "3" 356 | log.Info(fmt.Sprintf("No Maven version detected. Using: %s", version)) 357 | } 358 | 359 | return &version, nil 360 | } 361 | 362 | func isSpringBootApp(path string) bool { 363 | checkFiles := append([]string{}, pomFiles...) 364 | checkFiles = append(checkFiles, "build.gradle") 365 | 366 | for _, file := range checkFiles { 367 | pomXML, err := os.Open(filepath.Join(path, file)) 368 | if err != nil { 369 | continue 370 | } 371 | 372 | defer pomXML.Close() 373 | scanner := bufio.NewScanner(pomXML) 374 | for scanner.Scan() { 375 | line := scanner.Text() 376 | if strings.Contains(line, "org.springframework.boot") { 377 | return true 378 | } 379 | } 380 | } 381 | 382 | return false 383 | } 384 | 385 | func isWildflySwarmApp(path string) bool { 386 | for _, file := range pomFiles { 387 | pomXML, err := os.Open(filepath.Join(path, file)) 388 | if err != nil { 389 | continue 390 | } 391 | 392 | defer pomXML.Close() 393 | scanner := bufio.NewScanner(pomXML) 394 | for scanner.Scan() { 395 | line := scanner.Text() 396 | if strings.Contains(line, "wildfly-swarm") { 397 | return true 398 | } else if strings.Contains(line, "org.wildfly.swarm") { 399 | return true 400 | } 401 | } 402 | } 403 | 404 | return false 405 | } 406 | 407 | var pomFiles = []string{ 408 | "pom.xml", 409 | "pom.atom", 410 | "pom.clj", 411 | "pom.groovy", 412 | "pom.rb", 413 | "pom.scala", 414 | "pom.yml", 415 | "pom.yaml", 416 | } 417 | -------------------------------------------------------------------------------- /runtime/python.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log/slog" 8 | "maps" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "text/template" 13 | 14 | "github.com/pelletier/go-toml" 15 | ) 16 | 17 | type Python struct { 18 | Log *slog.Logger 19 | } 20 | 21 | func (d *Python) Name() RuntimeName { 22 | return RuntimeNamePython 23 | } 24 | 25 | func (d *Python) Match(path string) bool { 26 | checkPaths := []string{ 27 | filepath.Join(path, "requirements.txt"), 28 | filepath.Join(path, "poetry.lock"), 29 | filepath.Join(path, "uv.lock"), 30 | filepath.Join(path, "Pipfile.lock"), 31 | filepath.Join(path, "pyproject.toml"), 32 | filepath.Join(path, "pdm.lock"), 33 | filepath.Join(path, "main.py"), 34 | filepath.Join(path, "app.py"), 35 | filepath.Join(path, "application.py"), 36 | filepath.Join(path, "app/__init__.py"), 37 | filepath.Join(path, filepath.Base(path), "app.py"), 38 | filepath.Join(path, filepath.Base(path), "application.py"), 39 | filepath.Join(path, filepath.Base(path), "main.py"), 40 | filepath.Join(path, filepath.Base(path), "__init__.py"), 41 | } 42 | 43 | for _, p := range checkPaths { 44 | if _, err := os.Stat(p); err == nil { 45 | d.Log.Info("Detected Python project") 46 | return true 47 | } 48 | } 49 | 50 | d.Log.Debug("Python project not detected") 51 | return false 52 | } 53 | 54 | func (d *Python) GenerateDockerfile(path string, data ...map[string]string) ([]byte, error) { 55 | tmpl, err := template.New("Dockerfile").Parse(pythonTemplate) 56 | if err != nil { 57 | return nil, fmt.Errorf("Failed to parse template") 58 | } 59 | 60 | // Parse version from go.mod 61 | version, err := findPythonVersion(path, d.Log) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | installCMD := "" 67 | packageManager := PythonPackageManagerPip 68 | if _, err := os.Stat(filepath.Join(path, "requirements.txt")); err == nil { 69 | d.Log.Info("Detected requirements.txt file") 70 | installCMD = "pip install --no-cache -r requirements.txt" 71 | } else if _, err := os.Stat(filepath.Join(path, "uv.lock")); err == nil { 72 | d.Log.Info("Detected a uv project") 73 | installCMD = "pip install uv && uv sync --python-preference=only-system --no-cache --no-dev" 74 | packageManager = PythonPackageManagerUv 75 | } else if _, err := os.Stat(filepath.Join(path, "poetry.lock")); err == nil { 76 | d.Log.Info("Detected a poetry project") 77 | installCMD = "pip install poetry && poetry install --no-dev --no-ansi --no-root" 78 | packageManager = PythonPackageManagerPoetry 79 | } else if _, err := os.Stat(filepath.Join(path, "Pipfile.lock")); err == nil { 80 | d.Log.Info("Detected a pipenv project") 81 | installCMD = "pip install pipenv && pipenv install --dev --system --deploy" 82 | packageManager = PythonPackageManagerPipenv 83 | } else if _, err := os.Stat(filepath.Join(path, "pdm.lock")); err == nil { 84 | d.Log.Info("Detected a pdm project") 85 | installCMD = "pip install pdm && pdm install --prod" 86 | packageManager = PythonPackageManagerPdm 87 | } else if _, err := os.Stat(filepath.Join(path, "pyproject.toml")); err == nil { 88 | d.Log.Info("Detected a pyproject.toml file") 89 | installCMD = "pip install --upgrade build setuptools && pip install ." 90 | } 91 | 92 | managePy := isDjangoProject(path) 93 | isFastAPI := isFastAPIProject(path) 94 | startCMD := "" 95 | projectName := filepath.Base(path) 96 | 97 | if managePy != nil { 98 | d.Log.Info("Detected Django project") 99 | startCMD = fmt.Sprintf(`python ` + *managePy + ` runserver 0.0.0.0:${PORT}`) 100 | } else if !isFastAPI { 101 | if _, err := os.Stat(filepath.Join(path, "pyproject.toml")); err == nil { 102 | f, err := os.Open(filepath.Join(path, "pyproject.toml")) 103 | if err == nil { 104 | var pyprojectTOML map[string]interface{} 105 | err := toml.NewDecoder(f).Decode(&pyprojectTOML) 106 | if err == nil { 107 | if project, ok := pyprojectTOML["project"].(map[string]interface{}); ok { 108 | if name, ok := project["name"].(string); ok { 109 | projectName = name 110 | } 111 | } else if project, ok := pyprojectTOML["tool.poetry"].(map[string]interface{}); ok { 112 | if name, ok := project["name"].(string); ok { 113 | projectName = name 114 | } 115 | } 116 | } 117 | 118 | if projectName != "" { 119 | startCMD = fmt.Sprintf(`python -m %s`, projectName) 120 | d.Log.Info("Detected start command via pyproject.toml") 121 | } 122 | } 123 | } 124 | } 125 | 126 | if startCMD == "" { 127 | mainFiles := []string{ 128 | "main.py", 129 | "app.py", 130 | "application.py", 131 | "app/main.py", 132 | "app/__init__.py", 133 | filepath.Join(path, filepath.Base(path), "main.py"), 134 | filepath.Join(path, filepath.Base(path), "app.py"), 135 | filepath.Join(path, filepath.Base(path), "application.py"), 136 | filepath.Join(path, filepath.Base(path), "__init__.py"), 137 | } 138 | 139 | for _, fn := range mainFiles { 140 | _, err := os.Stat(filepath.Join(path, fn)) 141 | if err != nil { 142 | continue 143 | } 144 | 145 | if isFastAPI { 146 | startCMD = fmt.Sprintf(`fastapi run %s --port ${PORT}`, fn) 147 | } else { 148 | startCMD = fmt.Sprintf(`python %s`, fn) 149 | d.Log.Info("Detected start command via main file: " + startCMD) 150 | } 151 | break 152 | } 153 | } 154 | 155 | packagerInstructions := "" 156 | switch packageManager { 157 | case PythonPackageManagerPoetry: 158 | packagerInstructions = poetryInstructions 159 | case PythonPackageManagerUv: 160 | packagerInstructions = uvInstructions 161 | } 162 | 163 | d.Log.Info( 164 | fmt.Sprintf(`Detected defaults 165 | Python version : %s 166 | Install command : %s 167 | Start command : %s 168 | 169 | Docker build arguments can supersede these defaults if provided. 170 | See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, installCMD, startCMD), 171 | ) 172 | 173 | var buf bytes.Buffer 174 | templateData := map[string]string{ 175 | "Version": *version, 176 | "InstallCMD": safeCommand(installCMD), 177 | "StartCMD": safeCommand(startCMD), 178 | "PackagerInstructions": packagerInstructions, 179 | } 180 | if len(data) > 0 { 181 | maps.Copy(templateData, data[0]) 182 | } 183 | if err := tmpl.Option("missingkey=zero").Execute(&buf, templateData); err != nil { 184 | return nil, fmt.Errorf("Failed to execute template") 185 | } 186 | 187 | return buf.Bytes(), nil 188 | } 189 | 190 | var pythonTemplate = strings.TrimSpace(` 191 | ARG VERSION={{.Version}} 192 | ARG BUILDER=docker.io/library/python 193 | FROM ${BUILDER}:${VERSION}-slim 194 | WORKDIR /app 195 | RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates && apt-get clean && rm -f /var/lib/apt/lists/*_* 196 | RUN update-ca-certificates 2>/dev/null || true 197 | RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot 198 | RUN chown -R nonroot:nonroot /app 199 | RUN mkdir -p /var/cache 200 | RUN chown -R nonroot:nonroot /var/cache 201 | 202 | ENV PYTHONDONTWRITEBYTECODE=1 203 | ENV PYTHONUNBUFFERED=1 204 | {{ .PackagerInstructions }} 205 | 206 | COPY --chown=nonroot:nonroot . . 207 | ARG INSTALL_CMD={{.InstallCMD}} 208 | RUN if [ ! -z "${INSTALL_CMD}" ]; then sh -c "$INSTALL_CMD"; fi 209 | 210 | ENV PORT=8080 211 | EXPOSE ${PORT} 212 | USER nonroot:nonroot 213 | 214 | ARG START_CMD={{.StartCMD}} 215 | ENV START_CMD=${START_CMD} 216 | RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi 217 | CMD ${START_CMD} 218 | `) 219 | 220 | var poetryInstructions = ` 221 | ENV POETRY_NO_INTERACTION=1 222 | ENV POETRY_VIRTUALENVS_CREATE=false 223 | ENV POETRY_CACHE_DIR='/var/cache/pypoetry' 224 | ENV POETRY_HOME='/usr/local'` 225 | 226 | var uvInstructions = ` 227 | # Set the UV_CACHE_DIR environment variable to a directory where uv will store its cache 228 | ENV UV_CACHE_DIR='/var/cache/uv' 229 | # Use the virtual environment automatically 230 | ENV VIRTUAL_ENV=/app/.venv 231 | ENV PATH="/app/.venv/bin:$PATH"` 232 | 233 | func findPythonVersion(path string, log *slog.Logger) (*string, error) { 234 | version := "" 235 | versionFiles := []string{ 236 | ".tool-versions", 237 | ".python-version", 238 | ".mise.toml", 239 | "runtime.txt", 240 | } 241 | 242 | for _, file := range versionFiles { 243 | fp := filepath.Join(path, file) 244 | _, err := os.Stat(fp) 245 | 246 | if err == nil { 247 | f, err := os.Open(fp) 248 | if err != nil { 249 | continue 250 | } 251 | 252 | defer f.Close() 253 | switch file { 254 | case ".tool-versions": 255 | scanner := bufio.NewScanner(f) 256 | for scanner.Scan() { 257 | line := scanner.Text() 258 | if strings.Contains(line, "python") { 259 | version = strings.Split(line, " ")[1] 260 | log.Info("Detected Python version in .tool-versions: " + version) 261 | break 262 | } 263 | } 264 | 265 | if err := scanner.Err(); err != nil { 266 | return nil, fmt.Errorf("Failed to read .tool-versions file") 267 | } 268 | 269 | case ".python-version": 270 | scanner := bufio.NewScanner(f) 271 | for scanner.Scan() { 272 | line := scanner.Text() 273 | if line != "" { 274 | version = line 275 | log.Info("Detected Python version from .python-version: " + version) 276 | break 277 | } 278 | } 279 | 280 | if err := scanner.Err(); err != nil { 281 | return nil, fmt.Errorf("Failed to read .python-version file") 282 | } 283 | 284 | case "runtime.txt": 285 | scanner := bufio.NewScanner(f) 286 | for scanner.Scan() { 287 | line := scanner.Text() 288 | if strings.HasPrefix(line, "python-") { 289 | version = strings.TrimPrefix(line, "python-") 290 | log.Info("Detected Python version from runtime.txt: " + version) 291 | break 292 | } 293 | } 294 | 295 | if err := scanner.Err(); err != nil { 296 | return nil, fmt.Errorf("Failed to read runtime.txt file") 297 | } 298 | 299 | case ".mise.toml": 300 | var mise MiseToml 301 | if err := toml.NewDecoder(f).Decode(&mise); err != nil { 302 | return nil, fmt.Errorf("Failed to decode .mise.toml file") 303 | } 304 | pythonVersion, ok := mise.Tools["python"].(string) 305 | if !ok { 306 | versions, ok := mise.Tools["python"].([]string) 307 | if ok { 308 | pythonVersion = versions[0] 309 | } 310 | } 311 | if pythonVersion != "" { 312 | version = pythonVersion 313 | log.Info("Detected Python version in .mise.toml: " + version) 314 | break 315 | } 316 | } 317 | 318 | f.Close() 319 | if version != "" { 320 | break 321 | } 322 | } 323 | } 324 | 325 | if version == "" { 326 | version = "3.12" 327 | log.Info(fmt.Sprintf("No Python version detected. Using %s.", version)) 328 | } 329 | 330 | return &version, nil 331 | } 332 | 333 | func isDjangoProject(path string) *string { 334 | manageFiles := []string{"manage.py", "app/manage.py", filepath.Join(filepath.Base(path), "manage.py")} 335 | var managePy *string 336 | for _, file := range manageFiles { 337 | _, err := os.Stat(filepath.Join(path, file)) 338 | if err == nil { 339 | managePy = &file 340 | break 341 | } 342 | } 343 | 344 | if managePy == nil { 345 | return nil 346 | } 347 | 348 | packagerFiles := []string{"requirements.txt", "pyproject.toml", "Pipfile"} 349 | 350 | for _, file := range packagerFiles { 351 | _, err := os.Stat(filepath.Join(path, file)) 352 | if err == nil { 353 | f, err := os.Open(filepath.Join(path, file)) 354 | if err != nil { 355 | return nil 356 | } 357 | 358 | defer f.Close() 359 | 360 | scanner := bufio.NewScanner(f) 361 | for scanner.Scan() { 362 | line := scanner.Text() 363 | if strings.Contains(strings.ToLower(line), "django") { 364 | return managePy 365 | } 366 | } 367 | 368 | f.Close() 369 | } 370 | } 371 | 372 | return nil 373 | } 374 | 375 | func isFastAPIProject(path string) bool { 376 | packagerFiles := []string{"requirements.txt", "pyproject.toml", "Pipfile"} 377 | 378 | for _, file := range packagerFiles { 379 | _, err := os.Stat(filepath.Join(path, file)) 380 | if err == nil { 381 | f, err := os.Open(filepath.Join(path, file)) 382 | if err != nil { 383 | return false 384 | } 385 | 386 | defer f.Close() 387 | 388 | scanner := bufio.NewScanner(f) 389 | for scanner.Scan() { 390 | line := scanner.Text() 391 | if strings.Contains(strings.ToLower(line), "fastapi") { 392 | return true 393 | } 394 | } 395 | 396 | f.Close() 397 | } 398 | } 399 | 400 | return false 401 | } 402 | 403 | type PythonPackageManager string 404 | 405 | const ( 406 | PythonPackageManagerPip PythonPackageManager = "pip" 407 | PythonPackageManagerPoetry PythonPackageManager = "poetry" 408 | PythonPackageManagerUv PythonPackageManager = "uv" 409 | PythonPackageManagerPipenv PythonPackageManager = "pipenv" 410 | PythonPackageManagerPdm PythonPackageManager = "pdm" 411 | ) 412 | --------------------------------------------------------------------------------