From fd93f81ae721450efeacd00785e47958086b6ac3 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Tue, 14 May 2024 16:56:26 -0700 Subject: [PATCH] get started (#1) --- .github/workflows/pr.yml | 28 ++ .github/workflows/release.yml | 27 ++ .gitignore | 3 + .goreleaser.yaml | 36 +++ .tool-versions | 1 + cmd/new-dockerfile/main.go | 95 +++++++ go.mod | 9 + go.sum | 6 + main.go | 80 ++++++ node/README.md | 1 + node/install.js | 5 + node/package.json | 41 +++ runtime/bun.go | 232 +++++++++++++++++ runtime/deno.go | 245 +++++++++++++++++ runtime/deno_test.go | 100 +++++++ runtime/docker.go | 35 +++ runtime/elixir.go | 294 +++++++++++++++++++++ runtime/golang.go | 197 ++++++++++++++ runtime/java.go | 406 +++++++++++++++++++++++++++++ runtime/main.go | 25 ++ runtime/main_test.go | 13 + runtime/nextjs.go | 218 ++++++++++++++++ runtime/node.go | 262 +++++++++++++++++++ runtime/php.go | 252 ++++++++++++++++++ runtime/python.go | 301 +++++++++++++++++++++ runtime/ruby.go | 268 +++++++++++++++++++ runtime/rust.go | 115 ++++++++ runtime/static.go | 78 ++++++ testdata/deno-jsonc/.tool-versions | 1 + testdata/deno-jsonc/deno.jsonc | 6 + testdata/deno-jsonc/deps.ts | 1 + testdata/deno-jsonc/mod.ts | 22 ++ testdata/deno/deps.ts | 1 + testdata/deno/main.ts | 22 ++ 34 files changed, 3426 insertions(+) create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 .tool-versions create mode 100644 cmd/new-dockerfile/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 node/README.md create mode 100644 node/install.js create mode 100644 node/package.json create mode 100644 runtime/bun.go create mode 100644 runtime/deno.go create mode 100644 runtime/deno_test.go create mode 100644 runtime/docker.go create mode 100644 runtime/elixir.go create mode 100644 runtime/golang.go create mode 100644 runtime/java.go create mode 100644 runtime/main.go create mode 100644 runtime/main_test.go create mode 100644 runtime/nextjs.go create mode 100644 runtime/node.go create mode 100644 runtime/php.go create mode 100644 runtime/python.go create mode 100644 runtime/ruby.go create mode 100644 runtime/rust.go create mode 100644 runtime/static.go create mode 100644 testdata/deno-jsonc/.tool-versions create mode 100644 testdata/deno-jsonc/deno.jsonc create mode 100644 testdata/deno-jsonc/deps.ts create mode 100644 testdata/deno-jsonc/mod.ts create mode 100644 testdata/deno/deps.ts create mode 100644 testdata/deno/main.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..887d6a6 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,28 @@ +name: Pull request +on: + pull_request: + branches: + - main +jobs: + vet: + name: Vet + runs-on: ubuntu-latest + concurrency: + group: ${{ github.head_ref }}-vet + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Setup asdf + uses: asdf-vm/actions/install@v3 + - name: Install dependencies + run: go mod download + - name: Add asdf shims to PATH + run: | + echo "${HOME}/.asdf/shims" >> $GITHUB_PATH + - name: Lint + run: go vet ./... + - name: Run integration tests + run: go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a0006b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Release + +on: + push: + +permissions: + contents: write + +jobs: + release: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: "~> v1" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..002785e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist/ +/Dockerfile \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..77e5dd0 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,36 @@ +version: 1 + +before: + hooks: + - go mod tidy + +builds: + - main: ./cmd/new-dockerfile + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}- + {{- .Os }}- + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + format: zip + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..a02c9a2 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.22.3 diff --git a/cmd/new-dockerfile/main.go b/cmd/new-dockerfile/main.go new file mode 100644 index 0000000..8baefca --- /dev/null +++ b/cmd/new-dockerfile/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + dockerfile "github.com/flexstack/new-dockerfile" + "github.com/flexstack/new-dockerfile/runtime" + "github.com/lmittmann/tint" + flag "github.com/spf13/pflag" +) + +func main() { + var path string + flag.StringVar(&path, "path", ".", "Path to the project directory") + var noColor bool + flag.BoolVar(&noColor, "no-color", false, "Disable colorized output") + var runtimeArg string + flag.StringVar(&runtimeArg, "runtime", "", "Force a specific runtime") + var quiet bool + flag.BoolVar(&quiet, "quiet", false, "Disable all log output except errors") + var write bool + flag.BoolVar(&write, "write", false, "Write the Dockerfile to disk at ./Dockerfile") + flag.Parse() + + level := slog.LevelInfo + if os.Getenv("DEBUG") != "" { + level = slog.LevelDebug + } else if quiet { + level = slog.LevelError + } + + handler := tint.NewHandler(os.Stderr, &tint.Options{ + Level: level, + TimeFormat: time.Kitchen, + NoColor: noColor, + }) + + log := slog.New(handler) + df := dockerfile.New(log) + + var ( + r runtime.Runtime + err error + ) + + if runtimeArg != "" { + runtimes := df.ListRuntimes() + + for _, rt := range runtimes { + if strings.ToLower(string(rt.Name())) == strings.ToLower(runtimeArg) { + r = rt + break + } + } + if r == nil { + runtimeNames := make([]string, len(runtimes)) + for i, rt := range runtimes { + runtimeNames[i] = string(rt.Name()) + } + log.Error(fmt.Sprintf(`Runtime "%s" not found. Expected one of: %s`, runtimeArg, "\n - "+strings.Join(runtimeNames, "\n - "))) + os.Exit(1) + } + } + + if r == nil { + r, err = df.MatchRuntime(path) + if err != nil { + log.Error("Fatal error: " + err.Error()) + os.Exit(1) + } + } + + contents, err := r.GenerateDockerfile(path) + if err != nil { + os.Exit(1) + } + + if !write { + fmt.Println(string(contents)) + return + } + + output := filepath.Join(path, "Dockerfile") + if err = os.WriteFile(output, contents, 0644); err != nil { + log.Error("Fatal error: " + err.Error()) + os.Exit(1) + } + + log.Info(fmt.Sprintf("Auto-generated Dockerfile for project using %s: %s", string(r.Name()), output)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d75d428 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/flexstack/new-dockerfile + +go 1.22.3 + +require ( + github.com/lmittmann/tint v1.0.4 + github.com/pelletier/go-toml v1.9.5 + github.com/spf13/pflag v1.0.5 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff4e128 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= +github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..4b6025e --- /dev/null +++ b/main.go @@ -0,0 +1,80 @@ +package dockerfile + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/flexstack/new-dockerfile/runtime" +) + +// Creates a new Dockerfile generator. +func New(log ...*slog.Logger) *Dockerfile { + var logger *slog.Logger + + if len(log) > 0 { + logger = log[0] + } else { + logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) + } + + return &Dockerfile{ + log: logger, + } +} + +type Dockerfile struct { + log *slog.Logger +} + +// Generates a Dockerfile for the given path and writes it to the same directory. +func (a *Dockerfile) Write(path string) error { + runtime, err := a.MatchRuntime(path) + if err != nil { + return err + } + + contents, err := runtime.GenerateDockerfile(path) + if err != nil { + return err + } + + // Write the Dockerfile to the same directory + if err = os.WriteFile(filepath.Join(path, "Dockerfile"), contents, 0644); err != nil { + return err + } + + // a.log.Info("Auto-generated Dockerfile for project using " + string(lang.Name()) + "\n" + *contents) + a.log.Info("Auto-generated Dockerfile for project using " + string(runtime.Name())) + return nil +} + +func (a *Dockerfile) ListRuntimes() []runtime.Runtime { + return []runtime.Runtime{ + &runtime.Golang{Log: a.log}, + &runtime.Rust{Log: a.log}, + &runtime.Ruby{Log: a.log}, + &runtime.Python{Log: a.log}, + &runtime.PHP{Log: a.log}, + &runtime.Java{Log: a.log}, + &runtime.Elixir{Log: a.log}, + &runtime.Deno{Log: a.log}, + &runtime.Bun{Log: a.log}, + &runtime.NextJS{Log: a.log}, + &runtime.Node{Log: a.log}, + &runtime.Static{Log: a.log}, + } +} + +func (a *Dockerfile) MatchRuntime(path string) (runtime.Runtime, error) { + for _, r := range a.ListRuntimes() { + if r.Match(path) { + return r, nil + } + } + + return nil, ErrRuntimeNotFound +} + +var ErrRuntimeNotFound = fmt.Errorf("A Dockerfile was not detected in the project and we could not auto-generate one for you.") diff --git a/node/README.md b/node/README.md new file mode 100644 index 0000000..bc8941b --- /dev/null +++ b/node/README.md @@ -0,0 +1 @@ +# Autogenerate a Dockerfile from your project source code \ No newline at end of file diff --git a/node/install.js b/node/install.js new file mode 100644 index 0000000..a5b1e41 --- /dev/null +++ b/node/install.js @@ -0,0 +1,5 @@ +// TODO: +// 1. Determine the platform and architecture of the system +// 2. Determine the version of the npm package +// 3. Download the Golang binary for the platform and architecture from GitHub releases +console.log("Coming soon..."); diff --git a/node/package.json b/node/package.json new file mode 100644 index 0000000..59b1f03 --- /dev/null +++ b/node/package.json @@ -0,0 +1,41 @@ +{ + "name": "new-dockerfile", + "version": "0.1.1", + "description": "Autogenerate Dockerfiles from your project source code", + "main": "index.js", + "bin": { + "new-dockerfile": "bin/new-dockerfile" + }, + "scripts": { + "postinstall": "node install.js" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "arm64", + "x64" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/flexstack/new-dockerfile.git" + }, + "keywords": [ + "flexstack", + "docker", + "dockerfile", + "dockerfile-generator", + "dockerfile-generator-cli", + "dockerfile-generator-tool", + "generate-dockerfile", + "autogenerate-dockerfile" + ], + "author": "Jared Lunde", + "license": "MIT", + "bugs": { + "url": "https://github.com/flexstack/new-dockerfile/issues" + }, + "homepage": "https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile" +} diff --git a/runtime/bun.go b/runtime/bun.go new file mode 100644 index 0000000..267d1ec --- /dev/null +++ b/runtime/bun.go @@ -0,0 +1,232 @@ +package runtime + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +type Bun struct { + Log *slog.Logger +} + +func (d *Bun) Name() RuntimeName { + return RuntimeNameBun +} + +func (d *Bun) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "bun.lockb"), + filepath.Join(path, "bunfig.toml"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Bun project") + return true + } + } + + d.Log.Debug("Bun project not detected") + return false +} + +func (d *Bun) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(bunTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + var packageJSON map[string]interface{} + configFiles := []string{"package.json"} + for _, file := range configFiles { + f, err := os.Open(filepath.Join(path, file)) + if err != nil { + continue + } + + defer f.Close() + + if err := json.NewDecoder(f).Decode(&packageJSON); err != nil { + return nil, fmt.Errorf("Failed to decode " + file + " file") + } + + f.Close() + break + } + + var startCMD, buildCMD string + + scripts, ok := packageJSON["scripts"].(map[string]interface{}) + if ok { + d.Log.Info("Detected scripts in package.json") + + if _, ok := scripts["start:prod"].(string); ok { + startCMD = "bun run start:prod" + } else if _, ok := scripts["start:production"].(string); ok { + startCMD = "bun run start:production" + } else if _, ok := scripts["start-prod"].(string); ok { + startCMD = "bun run start-prod" + } else if _, ok := scripts["start-production"].(string); ok { + startCMD = "bun run start-production" + } else if _, ok := scripts["start"].(string); ok { + startCMD = "bun run start" + } + + if _, ok := scripts["build:prod"].(string); ok { + buildCMD = "bun run build:prod" + } else if _, ok := scripts["build:production"].(string); ok { + buildCMD = "bun run build:production" + } else if _, ok := scripts["build-prod"].(string); ok { + buildCMD = "bun run build-prod" + } else if _, ok := scripts["build-production"].(string); ok { + buildCMD = "bun run build-production" + } else if _, ok := scripts["build"].(string); ok { + buildCMD = "bun run build" + } + } + + mainFile := "" + if packageJSON["main"] != nil { + mainFile = packageJSON["main"].(string) + } else if packageJSON["module"] != nil { + mainFile = packageJSON["module"].(string) + } + + if startCMD == "" && mainFile != "" { + startCMD = fmt.Sprintf("bun %s", mainFile) + } + + version, err := findBunVersion(path, d.Log) + if err != nil { + return nil, err + } + + d.Log.Info( + fmt.Sprintf(`Detected defaults + Version : %s + Install command : bun install + Build command : %s + Start command : %s + + Docker build arguments can supersede these defaults if provided. + See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, buildCMD, startCMD), + ) + + if startCMD != "" { + startCMDJSON, _ := json.Marshal(startCMD) + startCMD = string(startCMDJSON) + } + + finalVersion := "slim" + if *version != "latest" { + finalVersion = *version + "-slim" + } + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + "FinalVersion": finalVersion, + "BuildCMD": buildCMD, + "StartCMD": startCMD, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var bunTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +FROM oven/bun:${VERSION} AS base + +FROM base AS deps +WORKDIR /app +COPY package.json bun.lockb ./ +ARG INSTALL_CMD="bun install" +ENV INSTALL_CMD=${INSTALL_CMD} +RUN if [ ! -z "${INSTALL_CMD}" ]; then $INSTALL_CMD; fi + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules* ./node_modules +COPY . . +ENV NODE_ENV=production +ARG BUILD_CMD={{.BuildCMD}} +ENV BUILD_CMD=${BUILD_CMD} +RUN if [ ! -z "${BUILD_CMD}" ]; then $BUILD_CMD; fi + +FROM oven/bun:{{.FinalVersion}} AS final +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +COPY --chown=nonroot:nonroot --from=builder /app . + +USER nonroot:nonroot + +ENV PORT=8080 +ENV NODE_ENV=production +ARG START_CMD={{.StartCMD}} +ENV START_CMD=${START_CMD} +RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi +CMD ${START_CMD} +`) + +func findBunVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".tool-versions", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "bun") { + version = strings.Split(line, " ")[1] + log.Info("Detected Bun version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "latest" + log.Info(fmt.Sprintf("No Bun version detected. Using: %s", version)) + } + + return &version, nil +} diff --git a/runtime/deno.go b/runtime/deno.go new file mode 100644 index 0000000..dc9dd44 --- /dev/null +++ b/runtime/deno.go @@ -0,0 +1,245 @@ +package runtime + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +type Deno struct { + Log *slog.Logger +} + +func (d *Deno) Name() RuntimeName { + return RuntimeNameDeno +} + +func (d *Deno) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "deno.json"), + filepath.Join(path, "deno.jsonc"), + filepath.Join(path, "deno.lock"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Deno project") + return true + } + } + + detected := false + // Walk the directory to find a .ts file with a "deno.land/x" import + filepath.WalkDir(path, func(path string, info fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && filepath.Ext(path) == ".ts" { + f, err := os.Open(path) + if err != nil { + return err + } + + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + text := scanner.Text() + + if (strings.HasPrefix(text, "import ") || strings.HasPrefix(text, "export ")) && strings.Contains(text, " from ") && strings.Contains(text, "https://deno.land/") { + d.Log.Info("Detected Deno project") + detected = true + return filepath.SkipAll + } + } + } + + return nil + }) + + d.Log.Debug("Deno project not detected") + return detected +} + +func (d *Deno) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(denoTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + var denoJSON map[string]interface{} + configFiles := []string{"deno.jsonc", "deno.json"} + for _, file := range configFiles { + f, err := os.Open(filepath.Join(path, file)) + if err != nil { + continue + } + + defer f.Close() + + if err := json.NewDecoder(f).Decode(&denoJSON); err != nil { + return nil, fmt.Errorf("Failed to decode " + file + " file") + } + + f.Close() + break + } + + var startCMD string + var installCMD string + + scripts, ok := denoJSON["tasks"].(map[string]interface{}) + if ok { + d.Log.Info("Detected tasks in deno.json") + startCommands := []string{"start:prod", "start:production", "start-prod", "start-production", "start"} + for _, cmd := range startCommands { + if _, ok := scripts[cmd].(string); ok { + startCMD = fmt.Sprintf("deno task %s", cmd) + break + } + } + + if _, ok := scripts["cache"].(string); ok { + installCMD = "deno task cache" + } + } + + if startCMD == "" { + mainFiles := []string{"mod.ts", "src/mod.ts", "main.ts", "src/main.ts", "index.ts", "src/index.ts"} + for _, mainFile := range mainFiles { + if _, err := os.Stat(filepath.Join(path, mainFile)); err == nil { + startCMD = fmt.Sprintf("deno run --allow-all %s", mainFile) + if installCMD == "" { + installCMD = "deno cache " + mainFile + } + break + } + } + } + + version, err := findDenoVersion(path, d.Log) + if err != nil { + return nil, err + } + + d.Log.Info( + fmt.Sprintf(`Detected defaults + Version : %s + Install command : %s + Start command : %s + + Docker build arguments can supersede these defaults if provided. + See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, installCMD, startCMD), + ) + + if installCMD != "" { + installCMDJSON, _ := json.Marshal(installCMD) + installCMD = string(installCMDJSON) + } + + if startCMD != "" { + startCMDJSON, _ := json.Marshal(startCMD) + startCMD = string(startCMDJSON) + } + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + "InstallCMD": installCMD, + "StartCMD": startCMD, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var denoTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +FROM denoland/deno:${VERSION} AS base + +WORKDIR /app +COPY . . + +FROM debian:stable-slim +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +ENV DENO_DIR=.deno_cache +RUN mkdir -p /app/${DENO_DIR} +RUN chown -R nonroot:nonroot /app/${DENO_DIR} + +COPY --chown=nonroot:nonroot --from=denoland/deno:bin-1.43.3 /deno /usr/local/bin/deno +COPY --chown=nonroot:nonroot --from=base /app . + +USER nonroot:nonroot + +ENV PORT=8080 +ARG INSTALL_CMD={{.InstallCMD}} +ENV INSTALL_CMD=${INSTALL_CMD} +RUN if [ ! -z "${INSTALL_CMD}" ]; then $INSTALL_CMD; fi + +ARG START_CMD={{.StartCMD}} +ENV START_CMD=${START_CMD} +RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi +CMD ${START_CMD} +`) + +func findDenoVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".tool-versions", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "deno") { + version = strings.Split(line, " ")[1] + log.Info("Detected Deno version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "latest" + log.Info(fmt.Sprintf("No Deno version detected. Using: %s", version)) + } + + return &version, nil +} diff --git a/runtime/deno_test.go b/runtime/deno_test.go new file mode 100644 index 0000000..8918475 --- /dev/null +++ b/runtime/deno_test.go @@ -0,0 +1,100 @@ +package runtime_test + +import ( + "regexp" + "strings" + "testing" + + "github.com/flexstack/new-dockerfile/runtime" +) + +func TestRuntimeMatch(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + { + name: "Deno project", + path: "../testdata/deno", + expected: true, + }, + { + name: "Deno project with .ts file", + path: "../testdata/deno-jsonc", + expected: true, + }, + { + name: "Not a Deno project", + path: "../testdata/ruby", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + deno := &runtime.Deno{Log: logger} + if deno.Match(test.path) != test.expected { + t.Errorf("expected %v, got %v", test.expected, deno.Match(test.path)) + } + }) + } +} + +func TestRuntimeGenerateDockerfile(t *testing.T) { + tests := []struct { + name string + path string + expected []any + }{ + { + name: "Deno project", + path: "../testdata/deno", + expected: []any{`ARG VERSION=latest`, `ARG INSTALL_CMD="deno cache main.ts"`, `ARG START_CMD="deno run --allow-all main.ts"`}, + }, + { + name: "Deno project with .ts file", + path: "../testdata/deno-jsonc", + expected: []any{`ARG VERSION=1.43.3`, `ARG INSTALL_CMD="deno task cache"`, `ARG START_CMD="deno task start"`}, + }, + { + name: "Not a Deno project", + path: "../testdata/ruby", + expected: []any{`ARG VERSION=latest`, regexp.MustCompile(`^ARG INSTALL_CMD=$`), regexp.MustCompile(`^ARG START_CMD=$`)}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + deno := &runtime.Deno{Log: logger} + dockerfile, err := deno.GenerateDockerfile(test.path) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + for _, line := range test.expected { + found := false + lines := strings.Split(string(dockerfile), "\n") + + for _, l := range lines { + switch v := line.(type) { + case string: + if strings.Contains(l, v) { + found = true + break + } + case *regexp.Regexp: + if v.MatchString(l) { + found = true + break + } + } + } + + if !found { + t.Errorf("expected %v, not found in %v", line, string(dockerfile)) + } + } + }) + } +} diff --git a/runtime/docker.go b/runtime/docker.go new file mode 100644 index 0000000..789155c --- /dev/null +++ b/runtime/docker.go @@ -0,0 +1,35 @@ +package runtime + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +type Docker struct { + Log *slog.Logger +} + +func (d *Docker) Name() RuntimeName { + return RuntimeNameDocker +} + +func (d *Docker) Match(path string) bool { + if stat, err := os.Stat(filepath.Join(path, "Dockerfile")); err == nil && !stat.IsDir() { + d.Log.Info("Detected Docker project") + return true + } + + d.Log.Debug("Docker project not detected") + return false +} + +func (d *Docker) GenerateDockerfile(path string) ([]byte, error) { + b, err := os.ReadFile(filepath.Join(path, "Dockerfile")) + if err != nil { + return nil, fmt.Errorf("Failed to read Dockerfile") + } + + return b, nil +} diff --git a/runtime/elixir.go b/runtime/elixir.go new file mode 100644 index 0000000..b451680 --- /dev/null +++ b/runtime/elixir.go @@ -0,0 +1,294 @@ +package runtime + +import ( + "bufio" + "bytes" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +type Elixir struct { + Log *slog.Logger +} + +func (d *Elixir) Name() RuntimeName { + return RuntimeNameElixir +} + +func (d *Elixir) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "mix.exs"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Elixir project") + return true + } + } + + d.Log.Debug("Elixir project not detected") + return false +} + +func (d *Elixir) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(elixirTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + // Parse elixirVersion from go.mod + elixirVersion, err := findElixirVersion(path, d.Log) + if err != nil { + return nil, err + } + + otpVersion, err := findOTPVersion(path, d.Log) + if err != nil { + return nil, err + } + + BinName, err := findBinName(path) + if err != nil { + return nil, err + } + + d.Log.Info( + fmt.Sprintf(`Detected defaults + Elixir version : %s + Erlang version : %s + Binary name : %s + + Docker build arguments can supersede these defaults if provided. + See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *elixirVersion, *otpVersion, BinName), + ) + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "ElixirVersion": *elixirVersion, + "OTPVersion": strings.Split(*otpVersion, ".")[0], + "BinName": BinName, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var elixirTemplate = strings.TrimSpace(` +ARG VERSION={{.ElixirVersion}} +ARG OTP_VERSION={{.OTPVersion}} +FROM elixir:${VERSION}-otp-${OTP_VERSION}-slim AS build +WORKDIR /app +RUN apt-get update -y && apt-get install -y build-essential git \ + && apt-get clean && rm -f /var/lib/apt/lists/*_* + +ENV MIX_ENV=prod +RUN mix local.hex --force && mix local.rebar --force + +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +COPY config/config.exs config/${MIX_ENV}.exs config/ +RUN mix deps.compile + +COPY priv priv +COPY lib lib +COPY assets assets +RUN mix assets.deploy +RUN mix compile + +COPY config/runtime.exs config/ +RUN mix release + +FROM debian:stable-slim AS runtime +WORKDIR /app +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/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot + +RUN chown -R nonroot:nonroot /app +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +ENV MIX_ENV="prod" + +# Only copy the final release from the build stage +ARG BIN_NAME={{.BinName}} +ENV BIN_NAME=${BIN_NAME} +RUN if [ -z "${BIN_NAME}" ]; then echo "Unable to detect an app name" && exit 1; fi +COPY --from=build --chown=nonroot:nonroot /app/_build/${MIX_ENV}/rel/${BIN_NAME} ./ +RUN cp /app/bin/${BIN_NAME} /app/bin/server + +ENV PORT=8080 +USER nonroot:nonroot + +CMD ["/app/bin/server", "start"] +`) + +func findElixirVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".tool-versions", + ".elixir-version", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "elixir") { + version = strings.Split(line, " ")[1] + log.Info("Detected Elixir version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + case ".elixir-version": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + version = line + log.Info("Detected Elixir version from .elixir-version: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .elixir-version file") + } + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "1.12" + log.Info(fmt.Sprintf("No Elixir version detected. Using: %s", version)) + } + + return &version, nil +} + +func findOTPVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".tool-versions", + ".erlang-version", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "erlang") { + version = strings.Split(line, " ")[1] + log.Info("Detected Erlang version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + case ".erlang-version": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + version = line + log.Info("Detected Erlang version from .erlang-version: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .erlang-version file") + } + + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "26.2.5" + log.Info(fmt.Sprintf("No Erlang version detected. Using: %s", version)) + } + + return &version, nil +} + +func isPhoenixProject(path string) bool { + _, err := os.Stat(filepath.Join(path, "config/config.exs")) + return err == nil +} + +func findBinName(path string) (string, error) { + configFile, err := os.Open(filepath.Join(path, "mix.exs")) + if err != nil { + return "", err + } + + defer configFile.Close() + + scanner := bufio.NewScanner(configFile) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "app: :") { + binName := strings.Split(strings.Replace(line, "app:", "", 1), ":")[1] + binName = strings.TrimSpace(strings.Trim(binName, ",'\"")) + return binName, nil + } + } + + if err := scanner.Err(); err != nil { + return "", err + } + + return "", nil +} diff --git a/runtime/golang.go b/runtime/golang.go new file mode 100644 index 0000000..f62249d --- /dev/null +++ b/runtime/golang.go @@ -0,0 +1,197 @@ +package runtime + +import ( + "bufio" + "bytes" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +type Golang struct { + Log *slog.Logger +} + +func (d *Golang) Name() RuntimeName { + return RuntimeNameGolang +} + +func (d *Golang) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "go.mod"), + filepath.Join(path, "main.go"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Golang project") + return true + } + } + + d.Log.Debug("Golang project not detected") + return false +} + +func (d *Golang) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(golangTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + // Parse version from go.mod + version, err := findGoVersion(path, d.Log) + if err != nil { + return nil, err + } + + pkg := "" + stat, err := os.Stat(filepath.Join(path, "cmd")) + if err == nil { + if stat.IsDir() { + d.Log.Info("Found cmd directory. Detecting package...") + + // Walk the directory to find the main package + items, err := os.ReadDir(filepath.Join(path, "cmd")) + if err != nil { + return nil, fmt.Errorf("Failed to read cmd directory") + } + + for _, item := range items { + if !item.IsDir() { + if item.Name() == "main.go" { + pkg = "./" + filepath.Join("cmd", item.Name()) + break + } + + continue + } + + pkg = "./" + filepath.Join("cmd", item.Name()) + break + } + } + } + + if pkg == "" { + if _, err := os.Stat(filepath.Join(path, "main.go")); err == nil { + pkg = "./main.go" + } + } + + d.Log.Info("Using package: " + pkg) + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + "Package": pkg, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var golangTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +FROM --platform=${BUILDPLATFORM} golang:${VERSION} AS base +WORKDIR /go/src/app +ARG TARGETOS=linux +ARG TARGETARCH=arm64 +ARG CGO_ENABLED=0 + +COPY . . +RUN CGO_ENABLED=${CGO_ENABLED} GOOS=${TARGETOS} GOARCH=${TARGETARCH} go mod download + +# -trimpath removes the absolute path to the source code in the binary +# -ldflags="-s -w" removes the symbol table and debug information from the binary +# CGO_ENABLED=0 disables the use of cgo +FROM base AS build +WORKDIR /go/src/app +ARG TARGETOS=linux +ARG TARGETARCH=arm64 +ARG CGO_ENABLED=0 +ARG PACKAGE={{.Package}} + +RUN CGO_ENABLED=${CGO_ENABLED} GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /go/bin/app "${PACKAGE}" + +FROM debian:stable-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +COPY --chown=nonroot:nonroot --from=build /go/bin/app . + +ENV PORT=8080 +USER nonroot:nonroot +CMD ["/app/app"] +`) + +func findGoVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".tool-versions", + "go.mod", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "golang") { + version = strings.Split(line, " ")[1] + log.Info("Detected Go version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + case "go.mod": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "go ") { + version = strings.Split(line, " ")[1] + log.Info("Detected Go version in go.mod: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read go.mod file") + } + + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "1.17" + log.Info(fmt.Sprintf("No Go version detected. Using: %s", version)) + } + + return &version, nil +} diff --git a/runtime/java.go b/runtime/java.go new file mode 100644 index 0000000..e951314 --- /dev/null +++ b/runtime/java.go @@ -0,0 +1,406 @@ +package runtime + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" +) + +type Java struct { + Log *slog.Logger +} + +func (d *Java) Name() RuntimeName { + return RuntimeNameJava +} + +func (d *Java) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "build.gradle"), + filepath.Join(path, "gradlew"), + filepath.Join(path, "pom.xml"), + filepath.Join(path, "pom.atom"), + filepath.Join(path, "pom.clj"), + filepath.Join(path, "pom.groovy"), + filepath.Join(path, "pom.rb"), + filepath.Join(path, "pom.scala"), + filepath.Join(path, "pom.yml"), + filepath.Join(path, "pom.yaml"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Java project") + return true + } + } + + d.Log.Debug("Java project not detected") + return false +} + +func (d *Java) GenerateDockerfile(path string) ([]byte, error) { + version, err := findJDKVersion(path, d.Log) + if err != nil { + return nil, err + } + + tpl := javaMavenTemplate + startCMD := "java $JAVA_OPTS -jar target/*jar" + buildCMD := "" + gradleVersion := "" + + if _, err := os.Stat(filepath.Join(path, "gradlew")); err == nil { + gv, err := findGradleVersion(path, d.Log) + if err != nil { + return nil, err + } + + gradleVersion = *gv + tpl = javaGradleTemplate + buildCMD = "./gradlew clean build -x check -x test" + startCMD = "java $JAVA_OPTS -jar $(ls -1 build/libs/*jar | grep -v plain)" + } + + mavenVersion := "" + for _, file := range pomFiles { + if _, err := os.Stat(filepath.Join(path, file)); err == nil { + mv, err := findMavenVersion(path, d.Log) + if err != nil { + return nil, err + } + + mavenVersion = *mv + buildCMD = "mvn -DoutputFile=target/mvn-dependency-list.log -B -DskipTests clean dependency:list install" + break + } + } + + if isSpringBootApp(path) { + d.Log.Info("Detected Spring Boot application") + startCMD = "java -Dserver.port=${PORT} $JAVA_OPTS -jar target/*jar" + if gradleVersion != "" { + startCMD = "java $JAVA_OPTS -jar -Dserver.port=${PORT} $(ls -1 build/libs/*jar | grep -v plain)" + } + } + + if isWildflySwarmApp(path) { + d.Log.Info("Detected Wildfly Swarm application") + startCMD = "java -Dswarm.http.port=${PORT} $JAVA_OPTS -jar target/*jar" + } + + d.Log.Info( + fmt.Sprintf(`Detected defaults + JDK version : %s + Maven version : %s + Gradle version : %s + Build command : %s + Start command : %s + + Docker build arguments can supersede these defaults if provided. + See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, mavenVersion, gradleVersion, buildCMD, startCMD), + ) + + var buf bytes.Buffer + + tmpl, err := template.New("Dockerfile").Parse(tpl) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + if buildCMD != "" { + buildCMDJSON, _ := json.Marshal(buildCMD) + buildCMD = string(buildCMDJSON) + } + + if startCMD != "" { + startCMDJSON, _ := json.Marshal(startCMD) + startCMD = string(startCMDJSON) + } + + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + "GradleVersion": gradleVersion, + "MavenVersion": mavenVersion, + "BuildCMD": buildCMD, + "StartCMD": startCMD, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var javaMavenTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +ARG MAVEN_VERSION={{.MavenVersion}} +FROM maven:${MAVEN_VERSION}-eclipse-temurin-${VERSION} AS build +WORKDIR /app + +COPY pom.xml* pom.atom* pom.clj* pom.groovy* pom.rb* pom.scala* pom.yml* pom.yaml* . +RUN mvn dependency:go-offline + +COPY src src +RUN mvn install + +ARG BUILD_CMD={{.BuildCMD}} +RUN if [ ! -z "${BUILD_CMD}" ]; then ${BUILD_CMD}; fi + +FROM eclipse-temurin:${VERSION}-jdk AS runtime +WORKDIR /app +VOLUME /tmp + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +COPY --from=build --chown=nonroot:nonroot /app/target/*.jar /app/target/ + +ENV PORT=8080 +USER nonroot:nonroot + +ARG JAVA_OPTS= +ENV JAVA_OPTS=${JAVA_OPTS} +ARG START_CMD={{.StartCMD}} +ENV START_CMD=${START_CMD} +RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi +CMD ${START_CMD} +`) + +var javaGradleTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +ARG GRADLE_VERSION={{.GradleVersion}} +FROM gradle:${GRADLE_VERSION}-jdk${VERSION} AS build +WORKDIR /app + +COPY build.gradle* gradlew* settings.gradle* ./ +COPY gradle/ ./gradle/ +COPY src src +RUN if [ ! -z "${BUILD_CMD}" ]; then ${BUILD_CMD}; fi + +FROM eclipse-temurin:${VERSION}-jdk AS runtime +WORKDIR /app +VOLUME /tmp + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +COPY --from=build --chown=nonroot:nonroot /app/build/libs/*.jar /app/build/libs/ + +ENV PORT=8080 +USER nonroot:nonroot + +ARG JAVA_OPTS= +ENV JAVA_OPTS=${JAVA_OPTS} +ARG START_CMD={{.StartCMD}} +ENV START_CMD=${START_CMD} +RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi +CMD ${START_CMD} +`) + +func findJDKVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{".tool-versions"} + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "java") { + versionString := strings.Split(line, " ")[1] + regexpVersion := regexp.MustCompile(`\d+`) + version = regexpVersion.FindString(versionString) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + log.Info("Detected JDK version in .tool-versions: " + version) + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "17" + } + + return &version, nil +} + +func findGradleVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{".tool-versions"} + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "gradle") { + version = strings.Split(line, " ")[1] + log.Info("Detected Gradle version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "8" + log.Info(fmt.Sprintf("No Gradle version detected. Using: %s", version)) + } + + return &version, nil +} + +func findMavenVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{".tool-versions"} + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "maven") { + version = strings.Split(line, " ")[1] + log.Info("Detected Maven version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "3" + log.Info(fmt.Sprintf("No Maven version detected. Using: %s", version)) + } + + return &version, nil +} + +func isSpringBootApp(path string) bool { + checkFiles := append([]string{}, pomFiles...) + checkFiles = append(checkFiles, "build.gradle") + + for _, file := range pomFiles { + pomXML, err := os.Open(filepath.Join(path, file)) + if err != nil { + continue + } + + defer pomXML.Close() + scanner := bufio.NewScanner(pomXML) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "org.springframework.boot") { + return true + } + } + } + + return false +} + +func isWildflySwarmApp(path string) bool { + for _, file := range pomFiles { + pomXML, err := os.Open(filepath.Join(path, file)) + if err != nil { + continue + } + + defer pomXML.Close() + scanner := bufio.NewScanner(pomXML) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "wildfly-swarm") { + return true + } else if strings.Contains(line, "org.wildfly.swarm") { + return true + } + } + } + + return false +} + +var pomFiles = []string{ + "pom.xml", + "pom.atom", + "pom.clj", + "pom.groovy", + "pom.rb", + "pom.scala", + "pom.yml", + "pom.yaml", +} diff --git a/runtime/main.go b/runtime/main.go new file mode 100644 index 0000000..5617a26 --- /dev/null +++ b/runtime/main.go @@ -0,0 +1,25 @@ +package runtime + +type Runtime interface { + Name() RuntimeName + Match(path string) bool + GenerateDockerfile(path string) ([]byte, error) +} + +type RuntimeName string + +const ( + RuntimeNameDocker RuntimeName = "Docker" // Done + RuntimeNameGolang RuntimeName = "Go" // Done + RuntimeNameRuby RuntimeName = "Ruby" // Done + RuntimeNamePython RuntimeName = "Python" // Done + RuntimeNamePHP RuntimeName = "PHP" // Done + RuntimeNameElixir RuntimeName = "Elixir" + RuntimeNameJava RuntimeName = "Java" + RuntimeNameRust RuntimeName = "Rust" // Done + RuntimeNameNextJS RuntimeName = "Next.js" // Done + RuntimeNameBun RuntimeName = "Bun" // Done + RuntimeNameDeno RuntimeName = "Deno" // Done + RuntimeNameNode RuntimeName = "Node" // Done + RuntimeNameStatic RuntimeName = "Static" // Done +) diff --git a/runtime/main_test.go b/runtime/main_test.go new file mode 100644 index 0000000..8a73624 --- /dev/null +++ b/runtime/main_test.go @@ -0,0 +1,13 @@ +package runtime_test + +import ( + "log/slog" +) + +type noopWriter struct{} + +func (w *noopWriter) Write(p []byte) (n int, err error) { + return len(p), nil +} + +var logger = slog.New(slog.NewJSONHandler(&noopWriter{}, nil)) diff --git a/runtime/nextjs.go b/runtime/nextjs.go new file mode 100644 index 0000000..77017f5 --- /dev/null +++ b/runtime/nextjs.go @@ -0,0 +1,218 @@ +package runtime + +import ( + "bufio" + "bytes" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +type NextJS struct { + Log *slog.Logger +} + +func (d *NextJS) Name() RuntimeName { + return RuntimeNameNextJS +} + +func (d *NextJS) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "next.config.js"), + filepath.Join(path, "next.config.ts"), + filepath.Join(path, "next.config.mjs"), + filepath.Join(path, "next.config.mts"), + filepath.Join(path, "next-env.d.ts"), + filepath.Join(path, "src/next-env.d.ts"), + filepath.Join(path, ".next"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Next.js project") + return true + } + } + + d.Log.Debug("Next.js project not detected") + return false +} + +func (d *NextJS) GenerateDockerfile(path string) ([]byte, error) { + nextJSTemplate := nextJSServerTemplate + nextConfigFiles := []string{ + "next.config.js", + "next.config.ts", + "next.config.mjs", + "next.config.mts", + } + + for _, file := range nextConfigFiles { + _, err := os.Stat(filepath.Join(path, file)) + if err == nil { + // Search for "output": "standalone" in next.config.js + f, err := os.Open(filepath.Join(path, file)) + if err != nil { + return nil, fmt.Errorf("Failed to open next.config.js file") + } + + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "output") && strings.Contains(line, "standalone") { + d.Log.Info("Found standalone output in next.config.js") + nextJSTemplate = nextJSStandaloneTemplate + f.Close() + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read next.config.js file") + } + + f.Close() + } + } + + tmpl, err := template.New("Dockerfile").Parse(nextJSTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + version, err := findNodeVersion(path, d.Log) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var nextJSStandaloneTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +FROM node:${VERSION}-slim AS base + +# Install dependencies only when needed +FROM base AS deps +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./ +RUN if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f bun.lockb ]; then npm i -g bun && bun install; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f bun.lockb ]; then npm i -g bun && bun run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +COPY --from=builder --chown=nonroot:nonroot /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nonroot:nonroot .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nonroot:nonroot /app/.next/standalone ./ +COPY --from=builder --chown=nonroot:nonroot /app/.next/static ./.next/static + +USER nonroot + +EXPOSE 3000 +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD HOSTNAME="0.0.0.0" node server.js +`) + +var nextJSServerTemplate = strings.TrimSpace(` +ARG VERSION=lts +FROM node:${VERSION}-slim AS base + +# Install dependencies only when needed +FROM base AS deps +WORKDIR /app +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./ +RUN if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f bun.lockb ]; then npm i -g bun && bun install; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + +FROM base AS builder + +ENV NODE_ENV=production +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f bun.lockb ]; then npm i -g bun && bun run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +COPY --from=builder --chown=nonroot:nonroot /app/next.config.* ./ +COPY --from=builder --chown=nonroot:nonroot /app/public ./public +COPY --from=builder --chown=nonroot:nonroot /app/.next ./.next +COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules + +USER nonroot + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED 1 +ENV PORT=8080 +CMD ["node_modules/.bin/next", "start", "-H", "0.0.0.0"] +`) diff --git a/runtime/node.go b/runtime/node.go new file mode 100644 index 0000000..bf83159 --- /dev/null +++ b/runtime/node.go @@ -0,0 +1,262 @@ +package runtime + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" +) + +type Node struct { + Log *slog.Logger +} + +func (d *Node) Name() RuntimeName { + return RuntimeNameNode +} + +func (d *Node) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "yarn.lock"), + filepath.Join(path, "package-lock.json"), + filepath.Join(path, "pnpm-lock.yaml"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Node project") + return true + } + } + + d.Log.Debug("Node project not detected") + return false +} + +func (d *Node) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(nodeTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + version, err := findNodeVersion(path, d.Log) + if err != nil { + return nil, err + } + + f, err := os.Open(filepath.Join(path, "package.json")) + if err != nil { + return nil, fmt.Errorf("Failed to open package.json file") + } + + defer f.Close() + + var packageJSON map[string]interface{} + if err := json.NewDecoder(f).Decode(&packageJSON); err != nil { + return nil, fmt.Errorf("Failed to decode package.json file") + } + + installCMD := "npm ci" + packageManager := "npm" + + if _, err := os.Stat(filepath.Join(path, "yarn.lock")); err == nil { + installCMD = "yarn --frozen-lockfile" + packageManager = "yarn" + } else if _, err := os.Stat(filepath.Join(path, "pnpm-lock.yaml")); err == nil { + installCMD = "corepack enable pnpm && pnpm i --frozen-lockfile" + packageManager = "pnpm" + } + + var buildCMD, startCMD string + + scripts, ok := packageJSON["scripts"].(map[string]interface{}) + if ok { + d.Log.Info("Detected scripts in package.json") + startCommands := []string{"start:prod", "start:production", "start-prod", "start-production", "start"} + for _, cmd := range startCommands { + if _, ok := scripts[cmd].(string); ok { + startCMD = fmt.Sprintf("%s run %s", packageManager, cmd) + break + } + } + + if startCMD == "" { + for name, v := range scripts { + value, ok := v.(string) + if ok && startScriptRe.MatchString(value) { + startCMD = fmt.Sprintf("%s run %s", packageManager, name) + break + } + } + } + + buildCommands := []string{"build:prod", "build:production", "build-prod", "build-production", "build"} + for _, cmd := range buildCommands { + if _, ok := scripts[cmd].(string); ok { + buildCMD = fmt.Sprintf("%s run %s", packageManager, cmd) + break + } + } + } + + mainFile := "" + if packageJSON["main"] != nil { + mainFile = packageJSON["main"].(string) + } else if packageJSON["module"] != nil { + mainFile = packageJSON["module"].(string) + } + + if startCMD == "" && mainFile != "" { + startCMD = fmt.Sprintf("node %s", mainFile) + } + + d.Log.Info( + fmt.Sprintf(`Detected defaults + Node version : %s + Package manager : %s + Install command : %s + Build command : %s + Start command : %s + + Docker build arguments can supersede these defaults if provided. + See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, packageManager, installCMD, buildCMD, startCMD), + ) + + if installCMD != "" { + installCMDJSON, _ := json.Marshal(installCMD) + installCMD = string(installCMDJSON) + } + + if buildCMD != "" { + buildCMDJSON, _ := json.Marshal(buildCMD) + buildCMD = string(buildCMDJSON) + } + + if startCMD != "" { + startCMDJSON, _ := json.Marshal(startCMD) + startCMD = string(startCMDJSON) + } + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + "InstallCMD": installCMD, + "BuildScript": buildCMD, + "StartCMD": startCMD, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var startScriptRe = regexp.MustCompile(`^.*?\bnode(mon)?\b.*?(index|main|server|client)\.js\b`) + +var nodeTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +FROM node:${VERSION}-slim AS base + +FROM base AS deps +WORKDIR /app +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./ +ARG INSTALL_CMD={{.InstallCMD}} +RUN if [ ! -z "${INSTALL_CMD}" ]; then $INSTALL_CMD; fi + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules* ./node_modules +COPY . . +ENV NODE_ENV=production +ARG BUILD_CMD={{.BuildCMD}} +RUN if [ ! -z "${BUILD_CMD}" ]; then $BUILD_CMD; fi + +FROM base AS final +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +COPY --chown=nonroot:nonroot --from=builder /app . + +USER nonroot:nonroot + +ENV PORT=8080 +ENV NODE_ENV=production +ARG START_CMD={{.StartCMD}} +ENV START_CMD=${START_CMD} +RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi +CMD ${START_CMD} +`) + +func findNodeVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".nvmrc", + ".node-version", + ".tool-versions", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "nodejs") { + version = strings.Split(line, " ")[1] + log.Info("Detected Node version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + case ".nvmrc", ".node-version": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "v") { + version = strings.TrimPrefix(line, "v") + log.Info("Detected Node version in " + file + ": " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read version file") + } + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "lts" + log.Info(fmt.Sprintf("No Node version detected. Using %s.", version)) + } + + return &version, nil +} diff --git a/runtime/php.go b/runtime/php.go new file mode 100644 index 0000000..1ee55c8 --- /dev/null +++ b/runtime/php.go @@ -0,0 +1,252 @@ +package runtime + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" +) + +type PHP struct { + Log *slog.Logger +} + +func (d *PHP) Name() RuntimeName { + return RuntimeNamePHP +} + +func (d *PHP) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "composer.json"), + filepath.Join(path, "index.php"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected PHP project") + return true + } + } + + d.Log.Debug("PHP project not detected") + return false +} + +func (d *PHP) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(phpTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + // Parse version from go.mod + version, err := findPHPVersion(path, d.Log) + if err != nil { + return nil, err + } + + startCMD := "apache2-foreground" + installCMD := "" + if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil { + installCMD = "composer update && composer install --prefer-dist --no-dev --optimize-autoloader --no-interaction" + } + + packageManager := "" + if _, err := os.Stat(filepath.Join(path, "package-lock.json")); err == nil { + packageManager = "npm" + installCMD = installCMD + " && npm ci" + } else if _, err := os.Stat(filepath.Join(path, "pnpm-lock.yaml")); err == nil { + packageManager = "pnpm" + installCMD = installCMD + " && corepack enable pnpm && pnpm i --frozen-lockfile" + } else if _, err := os.Stat(filepath.Join(path, "yarn.lock")); err == nil { + packageManager = "yarn" + installCMD = installCMD + " && yarn --frozen-lockfile" + } else if _, err := os.Stat(filepath.Join(path, "bun.lockb")); err == nil { + packageManager = "bun" + installCMD = installCMD + " && bun install" + } + + buildCMD := "" + if packageManager != "" { + f, err := os.Open(filepath.Join(path, "package.json")) + if err != nil { + return nil, fmt.Errorf("Failed to open package.json file") + } + + defer f.Close() + + var packageJSON map[string]interface{} + if err := json.NewDecoder(f).Decode(&packageJSON); err != nil { + return nil, fmt.Errorf("Failed to decode package.json file") + } + + scripts, ok := packageJSON["scripts"].(map[string]interface{}) + if ok { + d.Log.Info("Detected scripts in package.json") + buildCommands := []string{"build:prod", "build:production", "build-prod", "build-production", "build"} + for _, cmd := range buildCommands { + if _, ok := scripts[cmd].(string); ok { + buildCMD = fmt.Sprintf("%s run %s", packageManager, cmd) + break + } + } + } + + f.Close() + } + + d.Log.Info( + fmt.Sprintf(`Detected defaults + PHP version : %s + Install command : %s + Build command : %s + Start command : %s + + Docker build arguments can supersede these defaults if provided. + See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, installCMD, buildCMD, startCMD), + ) + + if installCMD != "" { + installCMDJSON, _ := json.Marshal(installCMD) + installCMD = string(installCMDJSON) + } + + if buildCMD != "" { + buildCMDJSON, _ := json.Marshal(buildCMD) + buildCMD = string(buildCMDJSON) + } + + if startCMD != "" { + startCMDJSON, _ := json.Marshal(startCMD) + startCMD = string(startCMDJSON) + } + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + "InstallCMD": installCMD, + "BuildCMD": buildCMD, + "StartCMD": startCMD, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var phpTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +FROM composer:lts as build +RUN apk add --no-cache nodejs npm +WORKDIR /app +COPY . . + +ARG INSTALL_CMD={{.InstallCMD}} +ARG BUILD_CMD={{.BuildCMD}} +RUN if [ ! -z "${INSTALL_CMD}" ]; then $INSTALL_CMD; fi +RUN if [ ! -z "${BUILD_CMD}" ]; then $BUILD_CMD; fi + +FROM php:${VERSION}-apache AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot + +ENV PORT=8080 +RUN sed -i "s/80/${PORT}/g" /etc/apache2/sites-available/000-default.conf /etc/apache2/ports.conf +COPY --from=build --chown=nonroot:nonroot /app /var/www/html + +USER nonroot:nonroot + +ARG START_CMD={{.StartCMD}} +ENV START_CMD=${START_CMD} +RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi +CMD ${START_CMD} +`) + +func findPHPVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".tool-versions", + "composer.json", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "php") { + version = strings.Split(line, " ")[1] + log.Info("Detected PHP version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + case "composer.json": + var composerJSON map[string]interface{} + err := json.NewDecoder(f).Decode(&composerJSON) + if err != nil { + return nil, fmt.Errorf("Failed to read composer.json file") + } + + if require, ok := composerJSON["require"].(map[string]interface{}); ok { + if php, ok := require["php"].(string); ok { + // Version can be a range, e.g. ">=7.2" so we need to extract the version + if gteVersionRe.MatchString(php) { + version = gteVersionRe.FindStringSubmatch(php)[1] + } else if rangeVersionRe.MatchString(php) { + version = rangeVersionRe.FindStringSubmatch(php)[2] + } else if tildeVersionRe.MatchString(php) { + version = tildeVersionRe.FindStringSubmatch(php)[1] + } else if caretVersionRe.MatchString(php) { + version = caretVersionRe.FindStringSubmatch(php)[1] + } else if exactVersionRe.MatchString(php) { + version = exactVersionRe.FindStringSubmatch(php)[1] + } + + version = strings.TrimSuffix(version, ".") + log.Info("Detected PHP version from composer.json: " + version) + } + } + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "8.3" + log.Info(fmt.Sprintf("No PHP version detected. Using: %s", version)) + } + + return &version, nil +} + +var gteVersionRe = regexp.MustCompile(`^>=\s*([\d.]+)`) +var rangeVersionRe = regexp.MustCompile(`^([\d.]+)\s*-\s*([\d.]+)`) +var tildeVersionRe = regexp.MustCompile(`^~\s*([\d.]+)`) +var caretVersionRe = regexp.MustCompile(`^\^([\d.]+)`) +var exactVersionRe = regexp.MustCompile(`^([\d.]+)`) diff --git a/runtime/python.go b/runtime/python.go new file mode 100644 index 0000000..e226ee5 --- /dev/null +++ b/runtime/python.go @@ -0,0 +1,301 @@ +package runtime + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/pelletier/go-toml" +) + +type Python struct { + Log *slog.Logger +} + +func (d *Python) Name() RuntimeName { + return RuntimeNamePython +} + +func (d *Python) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "requirements.txt"), + filepath.Join(path, "poetry.lock"), + filepath.Join(path, "Pipfile.lock"), + filepath.Join(path, "pyproject.toml"), + filepath.Join(path, "pdm.lock"), + filepath.Join(path, "main.py"), + filepath.Join(path, "app.py"), + filepath.Join(path, "application.py"), + filepath.Join(path, "app/__init__.py"), + filepath.Join(path, filepath.Base(path), "app.py"), + filepath.Join(path, filepath.Base(path), "application.py"), + filepath.Join(path, filepath.Base(path), "main.py"), + filepath.Join(path, filepath.Base(path), "__init__.py"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Python project") + return true + } + } + + d.Log.Debug("Python project not detected") + return false +} + +func (d *Python) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(pythonTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + // Parse version from go.mod + version, err := findPythonVersion(path, d.Log) + if err != nil { + return nil, err + } + + installCMD := "" + if _, err := os.Stat(filepath.Join(path, "requirements.txt")); err == nil { + installCMD = "pip install -r requirements.txt" + } else if _, err := os.Stat(filepath.Join(path, "poetry.lock")); err == nil { + installCMD = "poetry install --no-dev --no-interactive --no-ansi" + } else if _, err := os.Stat(filepath.Join(path, "Pipfile.lock")); err == nil { + installCMD = "PIPENV_VENV_IN_PROJECT=1 pipenv install --deploy" + } else if _, err := os.Stat(filepath.Join(path, "pdm.lock")); err == nil { + installCMD = "pdm install --prod" + } else if _, err := os.Stat(filepath.Join(path, "pyproject.toml")); err == nil { + installCMD = "pip install --upgrade build setuptools && pip install ." + } + + managePy := isDjangoProject(path) + startCMD := "" + projectName := filepath.Base(path) + + if managePy != nil { + d.Log.Info("Detected Django project") + startCMD = fmt.Sprintf(`python ` + *managePy + ` runserver 0.0.0.0:${PORT}`) + } else if _, err := os.Stat(filepath.Join(path, "pyproject.toml")); err == nil { + f, err := os.Open(filepath.Join(path, "pyproject.toml")) + if err == nil { + var pyprojectTOML map[string]interface{} + err := toml.NewDecoder(f).Decode(&pyprojectTOML) + if err == nil { + if project, ok := pyprojectTOML["project"].(map[string]interface{}); ok { + if name, ok := project["name"].(string); ok { + projectName = name + } + } + } + + startCMD = fmt.Sprintf(`python -m %s`, projectName) + } + } else { + mainFiles := []string{ + "main.py", + "app.py", + "application.py", + "app/main.py", + "app/__init__.py", + filepath.Join(path, filepath.Base(path), "main.py"), + filepath.Join(path, filepath.Base(path), "app.py"), + filepath.Join(path, filepath.Base(path), "application.py"), + filepath.Join(path, filepath.Base(path), "__init__.py"), + } + + for _, fn := range mainFiles { + _, err := os.Stat(filepath.Join(path, fn)) + if err != nil { + continue + } + + startCMD = fmt.Sprintf(`python %s`, fn) + break + } + } + + d.Log.Info( + fmt.Sprintf(`Detected defaults + Python version : %s + Install command : %s + Start command : %s + + Docker build arguments can supersede these defaults if provided. + See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, installCMD, startCMD), + ) + + if installCMD != "" { + installCMDJSON, _ := json.Marshal(installCMD) + installCMD = string(installCMDJSON) + } + + if startCMD != "" { + startCMDJSON, _ := json.Marshal(startCMD) + startCMD = string(startCMDJSON) + } + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + "InstallCMD": installCMD, + "StartCMD": startCMD, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var pythonTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +FROM python:${VERSION}-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +COPY --chown=nonroot:nonroot . . +ARG INSTALL_CMD={{.InstallCMD}} +RUN if [ ! -z "${INSTALL_CMD}" ]; then $INSTALL_CMD; fi + +ENV PORT=8080 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +USER nonroot:nonroot + +ARG START_CMD={{.StartCMD}} +ENV START_CMD=${START_CMD} +RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi +CMD ${START_CMD} +`) + +func findPythonVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".tool-versions", + ".python-version", + "runtime.txt", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "python") { + version = strings.Split(line, " ")[1] + log.Info("Detected Python version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + case ".python-version": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + version = line + log.Info("Detected Python version from .python-version: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .python-version file") + } + + case "runtime.txt": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "python-") { + version = strings.TrimPrefix(line, "python-") + log.Info("Detected Python version from runtime.txt: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read runtime.txt file") + } + + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "3.12" + log.Info(fmt.Sprintf("No Python version detected. Using %s.", version)) + } + + return &version, nil +} + +func isDjangoProject(path string) *string { + manageFiles := []string{"manage.py", "app/manage.py", filepath.Join(filepath.Base(path), "manage.py")} + var managePy *string + for _, file := range manageFiles { + _, err := os.Stat(filepath.Join(path, file)) + if err == nil { + managePy = &file + break + } + } + + if managePy == nil { + return nil + } + + packagerFiles := []string{"requirements.txt", "pyproject.toml", "Pipfile"} + + for _, file := range packagerFiles { + _, err := os.Stat(filepath.Join(path, file)) + if err == nil { + f, err := os.Open(filepath.Join(path, file)) + if err != nil { + return nil + } + + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(strings.ToLower(line), "django") { + return managePy + } + } + + f.Close() + } + } + + return nil +} diff --git a/runtime/ruby.go b/runtime/ruby.go new file mode 100644 index 0000000..27c3e23 --- /dev/null +++ b/runtime/ruby.go @@ -0,0 +1,268 @@ +package runtime + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +type Ruby struct { + Log *slog.Logger +} + +func (d *Ruby) Name() RuntimeName { + return RuntimeNameRuby +} + +func (d *Ruby) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "Gemfile"), + filepath.Join(path, "Gemfile.lock"), + filepath.Join(path, "Rakefile"), + filepath.Join(path, "config.ru"), + filepath.Join(path, "config/environment.rb"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Ruby project") + return true + } + } + + d.Log.Debug("Ruby project not detected") + return false +} + +func (d *Ruby) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(rubyTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + // Parse version from go.mod + version, err := findRubyVersion(path, d.Log) + if err != nil { + return nil, err + } + + installCMD := "bundle install" + packageManager := "" + + if _, err := os.Stat(filepath.Join(path, "package-lock.json")); err == nil { + packageManager = "npm" + installCMD = installCMD + " && npm ci" + } else if _, err := os.Stat(filepath.Join(path, "pnpm-lock.yaml")); err == nil { + packageManager = "pnpm" + installCMD = installCMD + " && corepack enable pnpm && pnpm i --frozen-lockfile" + } else if _, err := os.Stat(filepath.Join(path, "yarn.lock")); err == nil { + packageManager = "yarn" + installCMD = installCMD + " && yarn --frozen-lockfile" + } else if _, err := os.Stat(filepath.Join(path, "bun.lockb")); err == nil { + packageManager = "bun" + installCMD = installCMD + " && bun install" + } + + isRails := isRailsProject(path) + buildCMD := "" + startCMD := "" + if isRails { + buildCMD = "bundle exec rake assets:precompile" + startCMD = "bundle exec rails server -b 0.0.0.0 -p ${PORT}" + } else { + configFiles := []string{"config.ru", "config/environment.rb", "Rakefile"} + + for _, fn := range configFiles { + _, err := os.Stat(filepath.Join(path, fn)) + if err != nil { + continue + } + + switch fn { + case "config.ru": + startCMD = "bundle exec rackup config.ru -p ${PORT}" + case "config/environment.rb": + startCMD = "bundle exec ruby script/server" + case "Rakefile": + startCMD = "bundle exec rake" + } + + break + } + } + + d.Log.Info( + fmt.Sprintf(`Detected defaults + Ruby version : %s + Node package manager : %s + Install command : %s + Build command : %s + Start command : %s + + Docker build arguments can supersede these defaults if provided. + See https://flexstack.com/docs/languages-and-frameworks/autogenerate-dockerfile`, *version, packageManager, installCMD, buildCMD, startCMD), + ) + + if installCMD != "" { + installCMDJSON, _ := json.Marshal(installCMD) + installCMD = string(installCMDJSON) + } + + if buildCMD != "" { + buildCMDJSON, _ := json.Marshal(buildCMD) + buildCMD = string(buildCMDJSON) + } + + if startCMD != "" { + startCMDJSON, _ := json.Marshal(startCMD) + startCMD = string(startCMDJSON) + } + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "Version": *version, + "InstallCMD": installCMD, + "BuildCMD": buildCMD, + "StartCMD": startCMD, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var rubyTemplate = strings.TrimSpace(` +ARG VERSION={{.Version}} +FROM ruby:${VERSION}-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot + +ARG INSTALL_CMD={{.InstallCMD}} +ARG BUILD_CMD={{.BuildCMD}} +ENV NODE_ENV=production + +RUN chown -R nonroot:nonroot /app +COPY --chown=nonroot:nonroot . . + +RUN if [ ! -z "${INSTALL_CMD}" ]; then $INSTALL_CMD; fi +RUN if [ ! -z "${BUILD_CMD}" ]; then $BUILD_CMD; fi + +ENV PORT=8080 +USER nonroot:nonroot + +ARG START_CMD={{.StartCMD}} +ENV START_CMD=${START_CMD} +RUN if [ -z "${START_CMD}" ]; then echo "Unable to detect a container start command" && exit 1; fi +CMD ${START_CMD} +`) + +func findRubyVersion(path string, log *slog.Logger) (*string, error) { + version := "" + versionFiles := []string{ + ".tool-versions", + ".ruby-version", + "Gemfile", + } + + for _, file := range versionFiles { + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + + if err == nil { + f, err := os.Open(fp) + if err != nil { + continue + } + + defer f.Close() + switch file { + case ".tool-versions": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "ruby") { + version = strings.Split(line, " ")[1] + log.Info("Detected Ruby version in .tool-versions: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read .tool-versions file") + } + + case ".ruby-version": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + version = line + log.Info("Detected Ruby version from .ruby-version: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read go.mod file") + } + + case "Gemfile": + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "ruby") { + version = strings.Split(line, "'")[1] + log.Info("Detected Ruby version from Gemfile: " + version) + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("Failed to read Gemfile") + } + + } + + f.Close() + if version != "" { + break + } + } + } + + if version == "" { + version = "3.1" + log.Info(fmt.Sprintf("No Ruby version detected. Using: %s", version)) + } + + return &version, nil +} + +func isRailsProject(path string) bool { + _, err := os.Stat(filepath.Join(path, "Gemfile")) + if err == nil { + f, err := os.Open(filepath.Join(path, "Gemfile")) + if err != nil { + return false + } + + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "gem 'rails'") { + return true + } + } + } + + return false +} diff --git a/runtime/rust.go b/runtime/rust.go new file mode 100644 index 0000000..e2d70f3 --- /dev/null +++ b/runtime/rust.go @@ -0,0 +1,115 @@ +package runtime + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/pelletier/go-toml" +) + +type Rust struct { + Log *slog.Logger +} + +func (d *Rust) Name() RuntimeName { + return RuntimeNameRust +} + +func (d *Rust) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "Cargo.toml"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Rust project") + return true + } + } + + d.Log.Debug("rust project not detected") + return false +} + +func (d *Rust) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(rustlangTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + var binName string + // Parse the Cargo.toml file to get the binary name + cargoTomlPath := filepath.Join(path, "Cargo.toml") + f, err := os.Open(cargoTomlPath) + if err != nil { + return nil, fmt.Errorf("Failed to open Cargo.toml") + } + + defer f.Close() + + var cargoTOML map[string]interface{} + if err := toml.NewDecoder(f).Decode(&cargoTOML); err != nil { + return nil, fmt.Errorf("Failed to decode Cargo.toml") + } + + checkBins := []string{"bin", "lib", "package"} + var ok bool + var pkg map[string]interface{} + for _, bin := range checkBins { + pkg, ok = cargoTOML[bin].(map[string]interface{}) + if ok { + break + } + } + + if !ok { + return nil, fmt.Errorf("Failed to determine a binary name from Cargo.toml") + } + + if binName, ok = pkg["name"].(string); !ok { + return nil, fmt.Errorf("Failed to parse binary name from Cargo.toml") + } + + d.Log.Info("Detected binary name: " + binName) + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "BinName": binName, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var rustlangTemplate = strings.TrimSpace(` +FROM --platform=${BUILDPLATFORM} messense/cargo-zigbuild:latest AS build +WORKDIR /app +COPY . . + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +RUN if [ "${TARGETARCH}" = "amd64" ]; then rustup target add x86_64-unknown-linux-gnu; else rustup target add aarch64-unknown-linux-gnu; fi +RUN if [ "${TARGETARCH}" = "amd64" ]; then cargo zigbuild --release --target x86_64-unknown-linux-gnu; else cargo zigbuild --release --target aarch64-unknown-linux-gnu; fi + +FROM debian:stable-slim AS runtime +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot +RUN chown -R nonroot:nonroot /app + +ARG BIN_NAME={{.BinName}} +ENV BIN_NAME=${BIN_NAME} +COPY --chown=nonroot:nonroot --from=build /app/target/*/release/${BIN_NAME} ./${BIN_NAME} + +USER nonroot:nonroot + +ENV PORT=8080 +CMD ["/app/${BIN_NAME}"] +`) diff --git a/runtime/static.go b/runtime/static.go new file mode 100644 index 0000000..7996436 --- /dev/null +++ b/runtime/static.go @@ -0,0 +1,78 @@ +package runtime + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" +) + +type Static struct { + Log *slog.Logger +} + +func (d *Static) Name() RuntimeName { + return RuntimeNameStatic +} + +func (d *Static) Match(path string) bool { + checkPaths := []string{ + filepath.Join(path, "public"), + filepath.Join(path, "static"), + filepath.Join(path, "dist"), + filepath.Join(path, "index.html"), + } + + for _, p := range checkPaths { + if _, err := os.Stat(p); err == nil { + d.Log.Info("Detected Static project") + return true + } + } + + d.Log.Debug("Static project not detected") + return false +} + +func (d *Static) GenerateDockerfile(path string) ([]byte, error) { + tmpl, err := template.New("Dockerfile").Parse(staticTemplate) + if err != nil { + return nil, fmt.Errorf("Failed to parse template") + } + + serverRoot := "." + if _, err := os.Stat(filepath.Join(path, "index.html")); err != nil { + roots := []string{"public", "static", "dist"} + for _, root := range roots { + if _, err := os.Stat(filepath.Join(path, root)); err == nil { + serverRoot = root + break + } + } + } + d.Log.Info("Detected root directory: " + serverRoot) + + var buf bytes.Buffer + if err := tmpl.Option("missingkey=zero").Execute(&buf, map[string]string{ + "ServerRoot": serverRoot, + }); err != nil { + return nil, fmt.Errorf("Failed to execute template") + } + + return buf.Bytes(), nil +} + +var staticTemplate = strings.TrimSpace(` +ARG VERSION=2 +FROM joseluisq/static-web-server:${VERSION}-debian +RUN apt-get update && apt-get install -y --no-install-recommends wget && apt-get clean && rm -f /var/lib/apt/lists/*_* +COPY . . + +ENV PORT=8080 +ENV SERVER_PORT=${PORT} +ARG SERVER_ROOT={{.ServerRoot}} +ENV SERVER_ROOT=${SERVER_ROOT} +`) diff --git a/testdata/deno-jsonc/.tool-versions b/testdata/deno-jsonc/.tool-versions new file mode 100644 index 0000000..b3e19cd --- /dev/null +++ b/testdata/deno-jsonc/.tool-versions @@ -0,0 +1 @@ +deno 1.43.3 \ No newline at end of file diff --git a/testdata/deno-jsonc/deno.jsonc b/testdata/deno-jsonc/deno.jsonc new file mode 100644 index 0000000..888f95b --- /dev/null +++ b/testdata/deno-jsonc/deno.jsonc @@ -0,0 +1,6 @@ +{ + "tasks": { + "start": "deno run --allow-net mod.ts", + "cache": "deno cache mod.ts" + } +} diff --git a/testdata/deno-jsonc/deps.ts b/testdata/deno-jsonc/deps.ts new file mode 100644 index 0000000..b7b9f5d --- /dev/null +++ b/testdata/deno-jsonc/deps.ts @@ -0,0 +1 @@ +export { serve } from "https://deno.land/std@0.77.0/http/server.ts"; diff --git a/testdata/deno-jsonc/mod.ts b/testdata/deno-jsonc/mod.ts new file mode 100644 index 0000000..4781387 --- /dev/null +++ b/testdata/deno-jsonc/mod.ts @@ -0,0 +1,22 @@ +import { serve } from "./deps.ts"; + +const PORT = Deno.env.get("PORT") || "8000"; +const s = serve(`0.0.0.0:${PORT}`); +const body = new TextEncoder().encode("Hello World\n"); + +console.log(`Server started on port ${PORT}`); +for await (const req of s) { + req.respond({ body }); +} + +Deno.addSignalListener("SIGINT", () => { + console.log("\nServer stopped."); + s.close(); + Deno.exit(); +}); + +Deno.addSignalListener("SIGTERM", () => { + console.log("\nServer stopped."); + s.close(); + Deno.exit(); +}); diff --git a/testdata/deno/deps.ts b/testdata/deno/deps.ts new file mode 100644 index 0000000..b7b9f5d --- /dev/null +++ b/testdata/deno/deps.ts @@ -0,0 +1 @@ +export { serve } from "https://deno.land/std@0.77.0/http/server.ts"; diff --git a/testdata/deno/main.ts b/testdata/deno/main.ts new file mode 100644 index 0000000..4781387 --- /dev/null +++ b/testdata/deno/main.ts @@ -0,0 +1,22 @@ +import { serve } from "./deps.ts"; + +const PORT = Deno.env.get("PORT") || "8000"; +const s = serve(`0.0.0.0:${PORT}`); +const body = new TextEncoder().encode("Hello World\n"); + +console.log(`Server started on port ${PORT}`); +for await (const req of s) { + req.respond({ body }); +} + +Deno.addSignalListener("SIGINT", () => { + console.log("\nServer stopped."); + s.close(); + Deno.exit(); +}); + +Deno.addSignalListener("SIGTERM", () => { + console.log("\nServer stopped."); + s.close(); + Deno.exit(); +});