get.porter.sh/porter@v1.3.0/pkg/build/dockerfile-generator.go (about)

     1  package build
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"context"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"path/filepath"
    11  	"strings"
    12  
    13  	"get.porter.sh/porter/pkg"
    14  	"get.porter.sh/porter/pkg/config"
    15  	"get.porter.sh/porter/pkg/experimental"
    16  	"get.porter.sh/porter/pkg/manifest"
    17  	"get.porter.sh/porter/pkg/mixin/query"
    18  	"get.porter.sh/porter/pkg/pkgmgmt"
    19  	"get.porter.sh/porter/pkg/templates"
    20  	"get.porter.sh/porter/pkg/tracing"
    21  )
    22  
    23  const (
    24  	// DefaultDockerfileSyntax is the default syntax for Dockerfiles used by Porter
    25  	// either when generating a Dockerfile from scratch, or when a template does
    26  	// not define a syntax
    27  	DefaultDockerfileSyntax = "docker/dockerfile-upstream:1.4.0"
    28  )
    29  
    30  type DockerfileGenerator struct {
    31  	*config.Config
    32  	*manifest.Manifest
    33  	*templates.Templates
    34  	Mixins pkgmgmt.PackageManager
    35  }
    36  
    37  func NewDockerfileGenerator(config *config.Config, m *manifest.Manifest, tmpl *templates.Templates, mp pkgmgmt.PackageManager) *DockerfileGenerator {
    38  	return &DockerfileGenerator{
    39  		Config:    config,
    40  		Manifest:  m,
    41  		Templates: tmpl,
    42  		Mixins:    mp,
    43  	}
    44  }
    45  
    46  func (g *DockerfileGenerator) GenerateDockerFile(ctx context.Context) error {
    47  	ctx, span := tracing.StartSpan(ctx)
    48  	defer span.EndSpan()
    49  
    50  	var lines []string
    51  	var err error
    52  	if g.Config.IsFeatureEnabled(experimental.FlagFullControlDockerfile) {
    53  		span.Warnf("WARNING: Experimental feature \"%s\" enabled: Dockerfile will be used without changes by Porter",
    54  			experimental.FullControlDockerfile)
    55  		lines, err = g.readRawDockerfile(ctx)
    56  		if err != nil {
    57  			return span.Error(fmt.Errorf("error reading the Dockerfile: %w", err))
    58  		}
    59  	} else {
    60  		lines, err = g.buildDockerfile(ctx)
    61  		if err != nil {
    62  			return span.Error(fmt.Errorf("error generating the Dockerfile: %w", err))
    63  		}
    64  	}
    65  
    66  	contents := strings.Join(lines, "\n")
    67  
    68  	// Output the generated dockerfile
    69  	span.Debug(contents)
    70  
    71  	err = g.FileSystem.WriteFile(DOCKER_FILE, []byte(contents), pkg.FileModeWritable)
    72  	if err != nil {
    73  		return span.Error(fmt.Errorf("couldn't write the Dockerfile: %w", err))
    74  	}
    75  
    76  	return nil
    77  }
    78  
    79  func (g *DockerfileGenerator) readRawDockerfile(ctx context.Context) ([]string, error) {
    80  	if g.Manifest.Dockerfile == "" {
    81  		return nil, errors.New("no Dockerfile specified in the manifest")
    82  	}
    83  	exists, err := g.FileSystem.Exists(g.Manifest.Dockerfile)
    84  	if err != nil {
    85  		return nil, fmt.Errorf("error checking if Dockerfile exists: %q: %w", g.Manifest.Dockerfile, err)
    86  	}
    87  	if !exists {
    88  		return nil, fmt.Errorf("the Dockerfile specified in the manifest doesn't exist: %q", g.Manifest.Dockerfile)
    89  	}
    90  
    91  	file, err := g.FileSystem.Open(g.Manifest.Dockerfile)
    92  	if err != nil {
    93  		return nil, err
    94  	}
    95  	defer file.Close()
    96  
    97  	var lines []string
    98  	scanner := bufio.NewScanner(file)
    99  	for scanner.Scan() {
   100  		lines = append(lines, scanner.Text())
   101  	}
   102  
   103  	return lines, scanner.Err()
   104  }
   105  
   106  func (g *DockerfileGenerator) buildDockerfile(ctx context.Context) ([]string, error) {
   107  	log := tracing.LoggerFromContext(ctx)
   108  	log.Debug("Generating Dockerfile")
   109  
   110  	lines, err := g.getBaseDockerfile(ctx)
   111  	if err != nil {
   112  		return nil, err
   113  	}
   114  
   115  	lines = append(lines, g.buildPorterSection()...)
   116  	lines = append(lines, g.buildCNABSection()...)
   117  	lines = append(lines, g.buildWORKDIRSection())
   118  	lines = append(lines, g.buildCMDSection())
   119  
   120  	return lines, nil
   121  }
   122  
   123  func (g *DockerfileGenerator) getBaseDockerfile(ctx context.Context) ([]string, error) {
   124  	log := tracing.LoggerFromContext(ctx)
   125  
   126  	var reader io.Reader
   127  	if g.Manifest.Dockerfile != "" {
   128  		exists, err := g.FileSystem.Exists(g.Manifest.Dockerfile)
   129  		if err != nil {
   130  			return nil, fmt.Errorf("error checking if Dockerfile exists: %q: %w", g.Manifest.Dockerfile, err)
   131  		}
   132  		if !exists {
   133  			return nil, fmt.Errorf("the Dockerfile specified in the manifest doesn't exist: %q", g.Manifest.Dockerfile)
   134  		}
   135  
   136  		file, err := g.FileSystem.Open(g.Manifest.Dockerfile)
   137  		if err != nil {
   138  			return nil, err
   139  		}
   140  		defer file.Close()
   141  		reader = file
   142  	} else {
   143  		contents, err := g.Templates.GetDockerfile()
   144  		if err != nil {
   145  			return nil, fmt.Errorf("error loading default Dockerfile template: %w", err)
   146  		}
   147  		reader = bytes.NewReader(contents)
   148  	}
   149  	scanner := bufio.NewScanner(reader)
   150  	var lines []string
   151  	var syntaxFound bool
   152  	for scanner.Scan() {
   153  		line := scanner.Text()
   154  		if !syntaxFound && strings.HasPrefix(line, "# syntax=") {
   155  			syntaxFound = true
   156  		}
   157  		lines = append(lines, line)
   158  	}
   159  
   160  	// If their template doesn't declare a syntax, use the default
   161  	if !syntaxFound {
   162  		log.Warnf("No syntax was declared in the template Dockerfile, using %s", DefaultDockerfileSyntax)
   163  		lines = append([]string{fmt.Sprintf("# syntax=%s", DefaultDockerfileSyntax)}, lines...)
   164  	}
   165  
   166  	return g.replaceTokens(ctx, lines)
   167  }
   168  
   169  func (g *DockerfileGenerator) buildPorterSection() []string {
   170  	// The user-provided manifest may be located separate from the build context directory.
   171  	// Therefore, we only need to add lines if the relative manifest path exists inside of
   172  	// the current working directory.
   173  	manifestPath := g.FileSystem.Abs(g.Manifest.ManifestPath)
   174  	if relManifestPath, err := filepath.Rel(g.Getwd(), manifestPath); err == nil {
   175  		if !strings.Contains(relManifestPath, "..") {
   176  			return []string{
   177  				// Remove the user-provided Porter manifest as the canonical version
   178  				// will migrate via its location in .cnab
   179  				fmt.Sprintf(`RUN rm ${BUNDLE_DIR}/%s`, relManifestPath),
   180  			}
   181  		}
   182  	}
   183  	return []string{}
   184  }
   185  
   186  func (g *DockerfileGenerator) buildCNABSection() []string {
   187  	copyCNAB := "COPY .cnab /cnab"
   188  	if g.GetBuildDriver() == config.BuildDriverBuildkit {
   189  		copyCNAB = "COPY --link .cnab /cnab"
   190  	}
   191  
   192  	return []string{
   193  		// Putting RUN before COPY here as a workaround for https://github.com/moby/moby/issues/37965, back to back COPY statements in the same directory (e.g. /cnab) _may_ result in an error from Docker depending on unpredictable factors
   194  		`RUN rm -fr ${BUNDLE_DIR}/.cnab`,
   195  		// Copy the non-user cnab files, like mixins and porter.yaml, from the local .cnab directory into the bundle
   196  		copyCNAB,
   197  		// Ensure that regardless of the container's UID, the root group (default group for arbitrary users that do not exist in the container) has the same permissions as the owner
   198  		// See https://developers.redhat.com/blog/2020/10/26/adapting-docker-and-kubernetes-containers-to-run-on-red-hat-openshift-container-platform#group_ownership_and_file_permission
   199  		`RUN chgrp -R ${BUNDLE_GID} /cnab && chmod -R g=u /cnab`,
   200  		// default to running as the nonroot user that the porter agent uses.
   201  		// When running in kubernetes, if you specify a different UID, make sure to set fsGroup to the same UID, and runasGroup to 0
   202  		`USER ${BUNDLE_UID}`,
   203  	}
   204  }
   205  
   206  func (g *DockerfileGenerator) buildWORKDIRSection() string {
   207  	return `WORKDIR ${BUNDLE_DIR}`
   208  }
   209  
   210  func (g *DockerfileGenerator) buildCMDSection() string {
   211  	return `CMD ["/cnab/app/run"]`
   212  }
   213  
   214  func (g *DockerfileGenerator) buildMixinsSection(ctx context.Context) ([]string, error) {
   215  	q := query.New(g.Context, g.Mixins)
   216  	q.RequireAllMixinResponses = true
   217  	q.LogMixinErrors = true
   218  	results, err := q.Execute(ctx, "build", query.NewManifestGenerator(g.Manifest))
   219  	if err != nil {
   220  		return nil, err
   221  	}
   222  
   223  	lines := make([]string, 0)
   224  	for _, result := range results {
   225  		l := strings.Split(result.Stdout, "\n")
   226  		lines = append(lines, l...)
   227  	}
   228  	return lines, nil
   229  }
   230  
   231  func (g *DockerfileGenerator) buildInitSection() []string {
   232  	return []string{
   233  		"ARG BUNDLE_DIR",
   234  		"ARG BUNDLE_UID=65532",
   235  		"ARG BUNDLE_USER=nonroot",
   236  		"ARG BUNDLE_GID=0",
   237  		// Create a non-root user that is in the root group with the specified id and a home directory
   238  		"RUN useradd ${BUNDLE_USER} -m -u ${BUNDLE_UID} -g ${BUNDLE_GID} -o",
   239  	}
   240  }
   241  
   242  func (g *DockerfileGenerator) PrepareFilesystem() error {
   243  	fmt.Fprintf(g.Out, "Copying porter runtime ===> \n")
   244  
   245  	runTmpl, err := g.Templates.GetRunScript()
   246  	if err != nil {
   247  		return err
   248  	}
   249  
   250  	err = g.FileSystem.MkdirAll(LOCAL_APP, pkg.FileModeDirectory)
   251  	if err != nil {
   252  		return err
   253  	}
   254  
   255  	err = g.FileSystem.WriteFile(LOCAL_RUN, runTmpl, pkg.FileModeExecutable)
   256  	if err != nil {
   257  		return fmt.Errorf("failed to write %s: %w", LOCAL_RUN, err)
   258  	}
   259  
   260  	homeDir, err := g.GetHomeDir()
   261  	if err != nil {
   262  		return err
   263  	}
   264  	err = g.Context.CopyDirectory(filepath.Join(homeDir, "runtimes"), filepath.Join(LOCAL_APP, "runtimes"), false)
   265  	if err != nil {
   266  		return err
   267  	}
   268  
   269  	fmt.Fprintf(g.Out, "Copying mixins ===> \n")
   270  	for _, m := range g.Manifest.Mixins {
   271  		err := g.copyMixin(m.Name)
   272  		if err != nil {
   273  			return err
   274  		}
   275  	}
   276  
   277  	return nil
   278  }
   279  
   280  func (g *DockerfileGenerator) copyMixin(mixin string) error {
   281  	fmt.Fprintf(g.Out, "Copying mixin %s ===> \n", mixin)
   282  	mixinDir, err := g.Mixins.GetPackageDir(mixin)
   283  	if err != nil {
   284  		return err
   285  	}
   286  
   287  	err = g.Context.CopyDirectory(mixinDir, LOCAL_MIXINS, true)
   288  	if err != nil {
   289  		return fmt.Errorf("could not copy mixin directory contents for %s: %w", mixin, err)
   290  	}
   291  
   292  	return nil
   293  }
   294  
   295  func (g *DockerfileGenerator) getIndexOfToken(lines []string, token string) int {
   296  	for lineNumber, lineContent := range lines {
   297  		if strings.HasPrefix(strings.TrimSpace(lineContent), token) {
   298  			return lineNumber
   299  		}
   300  	}
   301  	return -1
   302  }
   303  
   304  // replaceTokens looks for lines like # PORTER_MIXINS and replaces them in the
   305  // template with the appropriate set of Dockerfile lines.
   306  func (g *DockerfileGenerator) replaceTokens(ctx context.Context, lines []string) ([]string, error) {
   307  	mixinLines, err := g.buildMixinsSection(ctx)
   308  	if err != nil {
   309  		return nil, fmt.Errorf("error generating Dockerfile content for mixins: %w", err)
   310  	}
   311  
   312  	fromToken := g.getIndexOfToken(lines, "FROM") + 1
   313  
   314  	substitutions := []struct {
   315  		token        string
   316  		lines        []string
   317  		defaultIndex int
   318  		replace      bool
   319  	}{
   320  		{token: PORTER_INIT_TOKEN, lines: g.buildInitSection(), defaultIndex: fromToken, replace: true},
   321  		{token: PORTER_MIXINS_TOKEN, lines: mixinLines, defaultIndex: -1, replace: true},
   322  	}
   323  
   324  	for _, substitution := range substitutions {
   325  		index := g.getIndexOfToken(lines, substitution.token)
   326  
   327  		// If we can't find the token, use the default for that token
   328  		if index == -1 {
   329  			index = substitution.defaultIndex
   330  			substitution.replace = false
   331  		}
   332  
   333  		if index == -1 {
   334  			lines = append(lines, substitution.lines...)
   335  		} else {
   336  			prefix := make([]string, index)
   337  			copy(prefix, lines)
   338  
   339  			if substitution.replace {
   340  				// Do not keep the line at the insertion index, replace it with the new lines instead
   341  				index = index + 1
   342  			}
   343  
   344  			suffix := lines[index:]
   345  			lines = append(prefix, append(substitution.lines, suffix...)...)
   346  		}
   347  	}
   348  
   349  	return lines, nil
   350  }