add docker image generator.

This commit is contained in:
Miroslav Šedivý 2025-03-29 00:54:46 +01:00
parent d92c482eff
commit c6e97a93c3
5 changed files with 389 additions and 33 deletions

3
.gitignore vendored
View file

@ -43,3 +43,6 @@ runtime/fonts/*
runtime/icon-theme/* runtime/icon-theme/*
!runtime/icon-theme/.gitkeep !runtime/icon-theme/.gitkeep
# root Dockerfile is generated from the Dockerfile.tmpl
/Dockerfile

14
Dockerfile.tmpl Normal file
View file

@ -0,0 +1,14 @@
# This Dockerfile is pre-processed by the ./docker script, it is not meant to be used directly.
FROM ./runtime/xorg-deps/ AS xorg-deps
FROM ./server/ AS server
FROM ./client/ AS client
FROM ./runtime/$RUNTIME_DOCKERFILE AS runtime
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
COPY --from=server /src/bin/plugins/ /etc/neko/plugins/
COPY --from=server /src/bin/neko /usr/bin/neko
COPY --from=client /src/dist/ /var/www
COPY config.yml /etc/neko/neko.yaml

46
build
View file

@ -3,7 +3,9 @@ set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
# disable buildx because of https://github.com/docker/buildx/issues/847 # disable buildx because of https://github.com/docker/buildx/issues/847
USE_BUILDX=0 if [ -z "$USE_BUILDX" ]; then
USE_BUILDX=0
fi
# check if docker buildx is available, its not docker-buildx command but rather subcommand of docker # check if docker buildx is available, its not docker-buildx command but rather subcommand of docker
if [ -z "$USE_BUILDX" ] && [ -x "$(command -v docker)" ]; then if [ -z "$USE_BUILDX" ] && [ -x "$(command -v docker)" ]; then
@ -281,39 +283,17 @@ fi
prompt "Are you sure you want to build $BASE_IMAGE?" prompt "Are you sure you want to build $BASE_IMAGE?"
log "Building base image: $BASE_IMAGE"
log "[STAGE 1]: Building local/neko-xorg-deps image"
build_image local/neko-xorg-deps runtime/xorg-deps/
log "[STAGE 2]: Building local/neko-server image"
build_image local/neko-server server/
log "[STAGE 3]: Building local/neko-client image"
build_image local/neko-client client/
if [ -z "$FLAVOR" ]; then if [ -z "$FLAVOR" ]; then
RUNTIME_IMAGE="local/neko-runtime" export RUNTIME_DOCKERFILE="Dockerfile"
log "[STAGE 4]: Building $RUNTIME_IMAGE image"
build_image $RUNTIME_IMAGE runtime/
else else
RUNTIME_IMAGE="local/neko-$FLAVOR-runtime" export RUNTIME_DOCKERFILE="Dockerfile.$FLAVOR"
log "[STAGE 4]: Building $RUNTIME_IMAGE image"
build_image $RUNTIME_IMAGE -f runtime/Dockerfile.$FLAVOR runtime/
fi fi
log "[STAGE 5]: Building $BASE_IMAGE image" log "Building base image: $BASE_IMAGE"
build_image $BASE_IMAGE -f - . <<EOF docker run --rm -it \
FROM local/neko-xorg-deps AS xorg-deps -v ./:/src \
FROM local/neko-server AS server --workdir /src \
FROM local/neko-client AS client --entrypoint go \
FROM $RUNTIME_IMAGE AS runtime golang:1.24-bullseye \
run ./docker/main.go \
COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so -i Dockerfile.tmpl | build_image $BASE_IMAGE -f - .
COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so
COPY --from=server /src/bin/plugins/ /etc/neko/plugins/
COPY --from=server /src/bin/neko /usr/bin/neko
COPY --from=client /src/dist/ /var/www
COPY config.yml /etc/neko/neko.yaml
EOF

3
docker/go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/m1k1o/neko/docker
go 1.24.1

356
docker/main.go Normal file
View file

@ -0,0 +1,356 @@
/*
This program processes a Dockerfile. When it encounters a FROM command with a relative path,
it pastes the content of the referenced Dockerfile into the current Dockerfile with some modifications:
- It ensures that all ADD and COPY commands point to the correct context path by adding the relative path
to the first part of the command (the file or directory being copied).
- It takes the ARG variables defined before the FROM command and prepends them with the alias of the
FROM command. It also replaces any occurrences of the ARG variables in the Dockerfile with the new prefixed
variables. Then it writes them to the beginning of the new Dockerfile.
It allows to split large multi-stage Dockerfiles into own directories where they can be built independently. It also
allows to dynamically join these Dockerfiles into a single Dockerfile based on various conditions.
*/
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
func main() {
inputPath := flag.String("i", "", "Path to the input Dockerfile")
outputPath := flag.String("o", "", "Path to the output Dockerfile")
flag.Parse()
if *inputPath == "" {
log.Println("Usage: go run main.go -i <input Dockerfile> [-o <output Dockerfile>]")
os.Exit(1)
}
buildcontext, err := ButidContextFromPath(*inputPath)
if err != nil {
log.Printf("Error: %v\n", err)
os.Exit(1)
}
err = processDockerfile(buildcontext, *outputPath)
if err != nil {
log.Printf("Error: %v\n", err)
os.Exit(1)
}
}
// relativeDockerFile reads the Dockerfile, modifies it to point to the new context path, and returns the global ARGs
func relativeDockerFile(buf *bytes.Buffer, ctx BuildContext, newContextPath, alias string) (ArgCommand, error) {
// read the Dockerfile
file, err := os.Open(ctx.DockerfilePath())
if err != nil {
return nil, fmt.Errorf("failed to open Dockerfile: %w", err)
}
defer file.Close()
// new context path relative to the current context path
newContextPath, err = filepath.Rel(newContextPath, ctx.ContextPath)
if err != nil {
return nil, fmt.Errorf("failed to get relative path: %w", err)
}
// use argPrefix to prepend the alias to the ARG variables
argPrefix := strings.ToUpper(alias) + "_"
// replace - with _ in the alias
argPrefix = strings.ReplaceAll(argPrefix, "-", "_")
beforeFrom := true
globalArgs := ArgCommand{}
// read the Dockerfile line by line and modify it
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// handle ARG lines defined before FROM
if !beforeFrom {
line = globalArgs.ReplaceArgPrefix(argPrefix, line)
}
// we need to move the ARG lines before the FROM line
if strings.HasPrefix(line, "ARG") && beforeFrom {
args, err := ParseArgCommand(line)
if err != nil {
return nil, fmt.Errorf("failed to parse ARG command: %w", err)
}
globalArgs = append(globalArgs, args...)
continue
}
// modify FROM lines
if strings.HasPrefix(line, "FROM") {
// parse the FROM command
cmd, err := ParseFromCommand(line)
if err != nil {
return nil, fmt.Errorf("failed to parse FROM command: %w", err)
}
// handle the case where ARGs are defined before FROM
cmd.Image = globalArgs.ReplaceArgPrefix(argPrefix, cmd.Image)
// add new alias if it is not already present
if alias != "" {
cmd.Alias = alias
}
beforeFrom = false
buf.WriteString(cmd.String() + "\n")
continue
}
// modify COPY and ADD lines
if strings.HasPrefix(line, "COPY") || strings.HasPrefix(line, "ADD") {
parts := strings.Fields(line)
containsFrom := false
localPathIndex := 0
for i, part := range parts {
if strings.HasPrefix(part, "--from=") {
containsFrom = true
continue
}
if strings.HasPrefix(part, "--") {
continue
}
if localPathIndex == 0 && i > 0 {
localPathIndex = i
}
}
if !containsFrom {
// replace the local part with the new context path
parts[localPathIndex] = filepath.Join(newContextPath, parts[localPathIndex])
newLine := strings.Join(parts, " ")
buf.WriteString(newLine + "\n")
continue
}
}
// write the line as is
buf.WriteString(line + "\n")
}
// add prefix to global ARGs
for i := range globalArgs {
if globalArgs[i].Key != "" {
globalArgs[i].Key = argPrefix + globalArgs[i].Key
}
}
return globalArgs, scanner.Err()
}
// processDockerfile processes the Dockerfile and resolves sub-Dockerfiles in it
func processDockerfile(ctx BuildContext, outputPath string) error {
// read the Dockerfile
file, err := os.Open(ctx.DockerfilePath())
if err != nil {
return fmt.Errorf("failed to open Dockerfile: %w", err)
}
defer file.Close()
globalArgs := ArgCommand{}
// read the Dockerfile line by line and modify it
newDockerfile := bytes.NewBuffer(nil)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// modify FROM lines
if strings.HasPrefix(line, "FROM ./") {
// parse the FROM command
cmd, err := ParseFromCommand(line)
if err != nil {
return fmt.Errorf("failed to parse FROM command: %w", err)
}
// resolve environment variables in the image name
cmd.Image = os.ExpandEnv(cmd.Image)
// create a new build context
newBuildcontext, err := ButidContextFromPath(filepath.Join(ctx.ContextPath, cmd.Image))
if err != nil {
return fmt.Errorf("failed to get build context: %w", err)
}
// resolve the dockerfile content
args, err := relativeDockerFile(newDockerfile, newBuildcontext, ctx.ContextPath, cmd.Alias)
if err != nil {
return fmt.Errorf("failed to get relative Dockerfile: %w", err)
}
globalArgs = append(globalArgs, args...)
continue
}
// copy all other lines as is
newDockerfile.WriteString(line + "\n")
}
// check for errors while reading the Dockerfile
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read input Dockerfile: %w", err)
}
// add the global ARGs to the beginning of the new Dockerfile
outBytes := append([]byte(globalArgs.MultiLineString()), newDockerfile.Bytes()...)
if outputPath != "" {
// write the new Dockerfile to the output path
return os.WriteFile(outputPath, outBytes, 0644)
}
// write to stdout
fmt.Print(string(outBytes))
return nil
}
// BuildContext represents the build context for a Dockerfile
type BuildContext struct {
ContextPath string
Dockerfile string // if empty, use the default Dockerfile name
}
func ButidContextFromPath(path string) (BuildContext, error) {
// check if the path exists
fi, err := os.Stat(path)
if os.IsNotExist(err) {
return BuildContext{}, fmt.Errorf("path does not exist: %s", path)
}
// check if the path is a directory
if err == nil && fi.IsDir() {
return BuildContext{
ContextPath: path,
Dockerfile: "Dockerfile",
}, nil
}
return BuildContext{
ContextPath: filepath.Dir(path),
Dockerfile: filepath.Base(path),
}, nil
}
func (bc *BuildContext) DockerfilePath() string {
if bc.Dockerfile != "" {
return filepath.Join(bc.ContextPath, bc.Dockerfile)
}
return filepath.Join(bc.ContextPath, "Dockerfile")
}
// FromCommand represents the FROM command in a Dockerfile
type FromCommand struct {
Image string
Alias string
Platform string
}
func ParseFromCommand(line string) (fc FromCommand, err error) {
parts := strings.Fields(line)
if len(parts) < 2 || strings.ToLower(parts[0]) != "from" {
err = fmt.Errorf("invalid FROM line: %s", line)
return
}
for i := 1; i < len(parts); i++ {
if strings.HasPrefix(parts[i], "--platform=") {
fc.Platform = strings.TrimPrefix(parts[i], "--platform=")
}
if strings.ToLower(parts[i]) == "as" && i+1 < len(parts) {
fc.Alias = parts[i+1]
break
}
fc.Image = parts[i]
}
return
}
func (fc *FromCommand) String() string {
var sb strings.Builder
sb.WriteString("FROM ")
if fc.Platform != "" {
sb.WriteString(fmt.Sprintf("--platform=%s ", fc.Platform))
}
sb.WriteString(fc.Image)
if fc.Alias != "" {
sb.WriteString(fmt.Sprintf(" AS %s", fc.Alias))
}
return sb.String()
}
// ArgCommand represents the ARG command in a Dockerfile
type Arg struct {
Key string
Value string
}
type ArgCommand []Arg
func ParseArgCommand(line string) (ac ArgCommand, err error) {
parts := strings.Fields(line)
if len(parts) < 2 || strings.ToLower(parts[0]) != "arg" {
err = fmt.Errorf("invalid ARG line: %s", line)
return
}
for i := 1; i < len(parts); i++ {
if strings.Contains(parts[i], "=") {
kv := strings.SplitN(parts[i], "=", 2)
if len(kv) == 2 {
ac = append(ac, Arg{Key: kv[0], Value: kv[1]})
} else {
ac = append(ac, Arg{Key: kv[0], Value: ""})
}
} else {
ac = append(ac, Arg{Key: parts[i], Value: ""})
}
}
return
}
func (ac ArgCommand) String() string {
var sb strings.Builder
sb.WriteString("ARG ")
for _, arg := range ac {
sb.WriteString(arg.Key)
if v := arg.Value; v != "" {
sb.WriteString("=" + v)
}
sb.WriteString(" ")
}
return sb.String()
}
func (ac ArgCommand) MultiLineString() string {
var sb strings.Builder
for _, arg := range ac {
sb.WriteString("ARG ")
sb.WriteString(arg.Key)
if v := arg.Value; v != "" {
sb.WriteString("=" + v)
}
sb.WriteString("\n")
}
return sb.String()
}
func (ac ArgCommand) ReplaceArgPrefix(prefix string, val string) string {
for _, arg := range ac {
val = strings.ReplaceAll(val, "$"+arg.Key, "$"+prefix+arg.Key)
}
return val
}