From 55f2def27b27f93414874f94060e59860a568792 Mon Sep 17 00:00:00 2001 From: lobz1g Date: Wed, 7 Apr 2021 16:59:30 +0300 Subject: [PATCH] recreate --- .gitignore | 1 + LICENSE | 21 +++ README.md | 106 +++++++++++++++ docen.go | 250 ++++++++++++++++++++++++++++++++++ docen_test.go | 368 ++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + go.sum | 0 7 files changed, 749 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docen.go create mode 100644 docen_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f317b15 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 lobz1g + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b6eeec --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +[![Go Report Card](https://goreportcard.com/badge/github.com/lobz1g/docen)](https://goreportcard.com/report/github.com/lobz1g/docen) + +# Docen + +Simple utility for generating Dockerfile for golang projects. + +## Installation + +```shell +go get github.com/lobz1g/docen +``` + +## Usage + +```go +package main + +import ( + "log" + + "github.com/lobz1g/docen" +) + +func main() { + err := docen.New(). + SetGoVersion("1.14.9"). + SetPort("3000"). + SetTimezone("Europe/Moscow"). + SetTestMode(true). + SetAdditionalFolder("my-folder/some-files"). + SetAdditionalFile("another-folder/some-files/file"). + GenerateDockerfile() + if err != nil { + log.Fatal(err) + } +} +``` + +Dockerfile will be created in the root dir of the project. + +```dockerfile +FROM golang:1.14.9-alpine as builder +RUN apk update && apk add --no-cache git ca-certificates tzdata && update-ca-certificates +RUN adduser -D -g '' appuser +RUN mkdir -p /docen +RUN mkdir -p /docen/my-folder/some-files +RUN mkdir -p /docen/another-folder/some-files +COPY . /docen +WORKDIR /docen +RUN CGO_ENABLED=0 go test ./... +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /docen +FROM scratch +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /etc/passwd /etc/passwd +ENV TZ=Europe/Moscow +COPY --from=builder /docen /docen +COPY --from=builder /docen/my-folder/some-files /docen/my-folder/some-files +COPY --from=builder /docen/another-folder/some-files /docen/another-folder/some-files +COPY --from=builder /docen/another-folder/some-files/file /docen/another-folder/some-files/file +USER appuser +EXPOSE 3000 +ENTRYPOINT ["/docen"] +``` + +## Options + +All methods are optional. That means there are default values for success creating Dockerfile without any settings. + +Just use below code for generating Dockerfile with default values: + +```go +err := docen.New().GenerateDockerfile() +if err != nil { + log.Fatal(err) +} +``` + +### Image + +By default, the image name is `golang` and the tag is `{your_golang_version}-aplpine`. If the `runtime.Version` method +returns wrong information about your golang version it will be just `alpine` image tag. You can set the version by +method `SetGoVersion` without any settings. + +### Expose port + +By default, Dockerfile will be without the expose port field. You can set the port by method `SetPort`. The argument can +be a single value of port, for example `3000`, or a range of values, for example `3000-4000`. + +### Timezone + +By default, Dockerfile will be without the timezone env field. You can set the timezone by method `SetTimezone`. + +### Testing + +The image is built without testing, but you can test the app before build. Use the method `SetTestMode` for it. + +### Additional folders to image + +Such folders as `assets`, `config`, `static` and `templates` are added to the image. You can add additional folders by +method `SetAdditionalFolder`. + +### Additional files to image + +You can set additional files which should be added to the image. Use the `SetAdditionalFile` method for it. It also adds +additional folders for these files. diff --git a/docen.go b/docen.go new file mode 100644 index 0000000..2415667 --- /dev/null +++ b/docen.go @@ -0,0 +1,250 @@ +package docen + +import ( + "bufio" + "fmt" + "io" + "io/fs" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +const ( + defaultAppName = "app" + defaultTagVersion = "alpine" +) + +var ( + goModFile = "go.mod" + vendorFolderName = "vendor" + additionalFolders = map[string]bool{ + "static": true, + "assets": true, + "templates": true, + "config": true, + } + + // readDir used for unit testing + readDir = ioutil.ReadDir + // runVer used for unit testing + runVer = runtime.Version + // openFile used for unit testing + openFile = os.Open +) + +type ( + docener interface { + New() *Docen + } + + additionalInfo map[string]bool + + Docen struct { + timezone string + version string + port string + additionFolders additionalInfo + additionFiles additionalInfo + isTestMode bool + } +) + +// New method creates new instance of generator. +// By default, the golang version is taken from runtime.Version +// By default, additional folders are `static`, `templates`, `config` and `assets`. +func New() *Docen { + d := &Docen{ + version: getVersion(), + additionFolders: getAdditionalFolders(), + additionFiles: newAdditionalInfo(), + } + return d +} + +// SetGoVersion method allows you to set a specific version of golang. +func (d *Docen) SetGoVersion(version string) *Docen { + d.version = fmt.Sprintf("%s-%s", version, defaultTagVersion) + return d +} + +// SetPort method allows you to set an exposed port. It can be as single port as a range of ports. +func (d *Docen) SetPort(port string) *Docen { + d.port = port + return d +} + +// SetTimezone method allows you to set a specific timezone. +func (d *Docen) SetTimezone(timezone string) *Docen { + d.timezone = timezone + return d +} + +// SetAdditionalFolder method allows you to set additional folders which will be added to a container. +func (d *Docen) SetAdditionalFolder(path string) *Docen { + d.additionFolders.set(path) + return d +} + +// SetAdditionalFolder method allows you to set additional files which will be added to a container. +func (d *Docen) SetAdditionalFile(path string) *Docen { + d.additionFiles.set(path) + d.SetAdditionalFolder(filepath.Dir(path)) + return d +} + +// SetTestMode method allows you to enable testing before starting to build the app. +func (d *Docen) SetTestMode(mode bool) *Docen { + d.isTestMode = mode + return d +} + +// GenerateDockerfile method creates Dockerfile file. +// If vendor mode is enabled then building will be with `-mod=vendor` tag. +func (d *Docen) GenerateDockerfile() error { + packageName := getPackageName() + + var data strings.Builder + data.WriteString(fmt.Sprintf("FROM golang:%s as builder\n", d.version)) + data.WriteString("RUN apk update && apk add --no-cache git ca-certificates tzdata && update-ca-certificates\n") + data.WriteString("RUN adduser -D -g '' appuser\n") + + data.WriteString(fmt.Sprintf("RUN mkdir -p /%s\n", packageName)) + for v := range d.additionFolders { + data.WriteString(fmt.Sprintf("RUN mkdir -p /%s/%s\n", packageName, v)) + } + data.WriteString(fmt.Sprintf("COPY . /%s\n", packageName)) + data.WriteString(fmt.Sprintf("WORKDIR /%s\n", packageName)) + if d.isTestMode { + data.WriteString("RUN CGO_ENABLED=0 go test ./...\n") + } + + var vendorTag string + if isVendorMode() { + vendorTag = "-mod=vendor" + } + data.WriteString( + fmt.Sprintf( + "RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build %s -ldflags=\"-w -s\" -o /%s\n", + vendorTag, packageName, + ), + ) + + data.WriteString("FROM scratch\n") + data.WriteString("COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo\n") + data.WriteString("COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/\n") + data.WriteString("COPY --from=builder /etc/passwd /etc/passwd\n") + if d.timezone != "" { + data.WriteString(fmt.Sprintf("ENV TZ=%s\n", d.timezone)) + } + data.WriteString(fmt.Sprintf("COPY --from=builder /%s /%s\n", packageName, packageName)) + for v := range d.additionFolders { + data.WriteString(fmt.Sprintf("COPY --from=builder /%s/%s /%s/%s\n", packageName, v, packageName, v)) + } + for v := range d.additionFiles { + data.WriteString(fmt.Sprintf("COPY --from=builder /%s/%s /%s/%s\n", packageName, v, packageName, v)) + } + + data.WriteString("USER appuser\n") + if d.port != "" { + data.WriteString(fmt.Sprintf("EXPOSE %s\n", d.port)) + } + data.WriteString(fmt.Sprintf("ENTRYPOINT [\"/%s\"]\n", packageName)) + + err := createDockerfile(data.String()) + + return err +} + +func getVersion() string { + v := runVer() + re := regexp.MustCompile("[0-9.]+") + version := re.FindAllString(v, -1) + if len(version) == 0 { + return defaultTagVersion + } + return fmt.Sprintf("%s-%s", strings.Join(version, ""), defaultTagVersion) +} + +func getPackageName() string { + file, err := openFile(goModFile) + if err != nil { + return defaultAppName + } + defer file.Close() + + return parsePackageName(file) +} + +func parsePackageName(r io.Reader) string { + reader := bufio.NewReader(r) + data, _, err := reader.ReadLine() + if err != nil { + return defaultAppName + } + + module := strings.ReplaceAll(string(data), "module ", "") + if string(module[0]) == "\"" { + module = strings.ReplaceAll(module, "\"", "") + } + + name := strings.Split(module, "/") + + return strings.ReplaceAll(name[len(name)-1], ".", "_") +} + +func getAdditionalFolders() additionalInfo { + folders := newAdditionalInfo() + + files, err := getProjectFiles() + if err != nil { + return folders + } + + for _, f := range files { + if f.IsDir() && additionalFolders[f.Name()] { + folders.set(f.Name()) + } + } + + return folders +} + +func isVendorMode() bool { + files, err := getProjectFiles() + if err != nil { + return false + } + + for _, f := range files { + if f.IsDir() && f.Name() == vendorFolderName { + return true + } + } + + return false +} + +func getProjectFiles() ([]fs.FileInfo, error) { + files, err := readDir("./") + if err != nil { + return nil, err + } + + return files, nil +} + +func createDockerfile(data string) error { + return os.WriteFile("Dockerfile", []byte(data), 0644) +} + +func newAdditionalInfo() additionalInfo { + return map[string]bool{} +} + +func (a additionalInfo) set(e string) { + a[e] = true +} diff --git a/docen_test.go b/docen_test.go new file mode 100644 index 0000000..cea4bc5 --- /dev/null +++ b/docen_test.go @@ -0,0 +1,368 @@ +package docen + +import ( + "bytes" + "errors" + "io" + "io/fs" + "log" + "reflect" + "strings" + "testing" +) + +type docenMock struct{} + +func (docenMock) New() *Docen { + return New() +} + +var docen = docenMock{} + +func ExampleNew() { + err := docen.New(). + SetGoVersion("1.14.9"). + SetPort("3000"). + SetTimezone("Europe/Moscow"). + SetTestMode(true). + SetAdditionalFolder("my-folder/some-files"). + SetAdditionalFile("another-folder/some-files/file"). + GenerateDockerfile() + if err != nil { + log.Fatal(err) + } +} + +func ExampleDocen_SetGoVersion() { + docen.New().SetGoVersion("1.14.9") +} + +func ExampleDocen_SetPort() { + docen.New().SetPort("3000-4000") +} + +func ExampleDocen_SetTimezone() { + docen.New().SetTimezone("Europe/Paris") +} + +func ExampleDocen_SetAdditionalFolder() { + docen.New().SetAdditionalFolder("my-folder/some-files") +} + +func ExampleDocen_SetAdditionalFile() { + docen.New().SetAdditionalFile("my-folder/some-files/file") +} + +func ExampleDocen_SetTestMode() { + docen.New().SetTestMode(true) +} + +func ExampleDocen_GenerateDockerfile() { + err := docen.New().GenerateDockerfile() + if err != nil { + log.Fatal(err) + } +} + +func Test_getVersion(t *testing.T) { + oldRuntimeVersion := runVer + defer func() { + runVer = oldRuntimeVersion + }() + + tests := []struct { + name string + want string + runtimeVersion func() string + }{ + { + name: "default version", + want: defaultTagVersion, + runtimeVersion: func() string { return "without version" }, + }, + { + name: "runtime version", + want: "1.13-" + defaultTagVersion, + runtimeVersion: func() string { return "go1.13" }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runVer = tt.runtimeVersion + if got := getVersion(); got != tt.want { + t.Errorf("getVersion() = %v, want %v", got, tt.want) + } + }) + } +} + +type fakeFolder struct { + fs.FileInfo + name string +} + +func (f *fakeFolder) Name() string { + return f.name +} + +func (f *fakeFolder) IsDir() bool { + return true +} + +func Test_getAdditionalFolders(t *testing.T) { + oldReadDir := readDir + defer func() { + readDir = oldReadDir + }() + + tests := []struct { + name string + want additionalInfo + readDir func(dirname string) ([]fs.FileInfo, error) + }{ + { + name: "failed read dir", + want: map[string]bool{}, + readDir: func(dirname string) ([]fs.FileInfo, error) { return nil, errors.New("fake error") }, + }, + { + name: "empty dir", + want: map[string]bool{}, + readDir: func(dirname string) ([]fs.FileInfo, error) { return []fs.FileInfo{}, nil }, + }, + { + name: "with folders", + want: map[string]bool{ + "assets": true, + "static": true, + "config": true, + }, + readDir: func(dirname string) ([]fs.FileInfo, error) { + return []fs.FileInfo{ + &fakeFolder{name: "assets"}, + &fakeFolder{name: "static"}, + &fakeFolder{name: "config"}, + }, nil + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + readDir = tt.readDir + if got := getAdditionalFolders(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("getAdditionalFolders() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_isVendorMode(t *testing.T) { + oldReadDir := readDir + defer func() { + readDir = oldReadDir + }() + + tests := []struct { + name string + want bool + readDir func(dirname string) ([]fs.FileInfo, error) + }{ + + { + name: "failed read dir", + want: false, + readDir: func(dirname string) ([]fs.FileInfo, error) { return nil, errors.New("fake error") }, + }, + { + name: "empty dir", + want: false, + readDir: func(dirname string) ([]fs.FileInfo, error) { return []fs.FileInfo{}, nil }, + }, + { + name: "without vendor folder", + want: false, + readDir: func(dirname string) ([]fs.FileInfo, error) { + return []fs.FileInfo{ + &fakeFolder{name: "static"}, + &fakeFolder{name: "config"}, + }, nil + }, + }, + { + name: "with vendor folder", + want: true, + readDir: func(dirname string) ([]fs.FileInfo, error) { + return []fs.FileInfo{ + &fakeFolder{name: "config"}, + &fakeFolder{name: "vendor"}, + }, nil + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + readDir = tt.readDir + if got := isVendorMode(); got != tt.want { + t.Errorf("isVendorMode() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_parsePackageName(t *testing.T) { + tests := []struct { + name string + reader io.Reader + want string + }{ + { + name: "failed read", + reader: bytes.NewReader(nil), + want: defaultAppName, + }, + { + name: "module name with quotation marks", + reader: strings.NewReader(`module "testmodulename"`), + want: "testmodulename", + }, + { + name: "module name without quotation marks", + reader: strings.NewReader(`module testmodulename`), + want: "testmodulename", + }, + { + name: "module name with url", + reader: strings.NewReader(`module github.com/lobz1g/docen`), + want: "docen", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parsePackageName(tt.reader); got != tt.want { + t.Errorf("parsePackageName() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNew(t *testing.T) { + oldReadDir := readDir + oldRuntimeVersion := runVer + defer func() { + runVer = oldRuntimeVersion + readDir = oldReadDir + }() + runVer = func() string { return "go1.13" } + readDir = func(dirname string) ([]fs.FileInfo, error) { return []fs.FileInfo{}, nil } + + want := &Docen{ + version: "1.13-alpine", + additionFolders: newAdditionalInfo(), + additionFiles: newAdditionalInfo(), + } + + t.Run(t.Name(), func(t *testing.T) { + if got := New(); !reflect.DeepEqual(got, want) { + t.Errorf("New() = %v, want %v", got, want) + } + }) +} + +func TestDocen_SetGoVersion(t *testing.T) { + want := &Docen{ + version: "1.13-alpine", + } + + d := &Docen{} + t.Run(t.Name(), func(t *testing.T) { + if got := d.SetGoVersion("1.13"); !reflect.DeepEqual(got, want) { + t.Errorf("New() = %v, want %v", got, want) + } + }) + +} + +func TestDocen_SetPort(t *testing.T) { + want := &Docen{ + port: "3000", + } + + d := &Docen{} + t.Run(t.Name(), func(t *testing.T) { + if got := d.SetPort("3000"); !reflect.DeepEqual(got, want) { + t.Errorf("New() = %v, want %v", got, want) + } + }) +} + +func TestDocen_SetTimezone(t *testing.T) { + want := &Docen{ + timezone: "Time/Zone", + } + + d := &Docen{} + t.Run(t.Name(), func(t *testing.T) { + if got := d.SetTimezone("Time/Zone"); !reflect.DeepEqual(got, want) { + t.Errorf("New() = %v, want %v", got, want) + } + }) +} + +func TestDocen_SetAdditionalFolder(t *testing.T) { + want := &Docen{ + additionFolders: map[string]bool{ + "test": true, + "folder": true, + }, + } + + d := &Docen{ + additionFolders: newAdditionalInfo(), + } + t.Run(t.Name(), func(t *testing.T) { + if got := d.SetAdditionalFolder("test").SetAdditionalFolder("folder"); !reflect.DeepEqual(got, want) { + + t.Errorf("New() = %v, want %v", got, want) + } + }) + +} + +func TestDocen_SetAdditionalFile(t *testing.T) { + want := &Docen{ + additionFolders: map[string]bool{ + "test": true, + "folder": true, + }, + additionFiles: map[string]bool{ + "test/file1": true, + "folder/file2": true, + }, + } + + d := &Docen{ + additionFolders: newAdditionalInfo(), + additionFiles: newAdditionalInfo(), + } + t.Run(t.Name(), func(t *testing.T) { + if got := d.SetAdditionalFile("test/file1").SetAdditionalFile("folder/file2"); !reflect.DeepEqual(got, want) { + + t.Errorf("New() = %v, want %v", got, want) + } + }) + +} + +func TestDocen_SetTestMode(t *testing.T) { + want := &Docen{ + isTestMode: true, + } + + d := &Docen{} + t.Run(t.Name(), func(t *testing.T) { + if got := d.SetTestMode(true); !reflect.DeepEqual(got, want) { + t.Errorf("New() = %v, want %v", got, want) + } + }) + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..557fa04 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/lobz1g/docen + +go 1.16 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29