pkg/gcpbuildpack/gcpbuildpack.go (326 lines of code) (raw):
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package gcpbuildpack is a framework for implementing buildpacks (https://buildpacks.io/).
package gcpbuildpack
import (
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/GoogleCloudPlatform/buildpacks/pkg/buildererror"
"github.com/GoogleCloudPlatform/buildpacks/pkg/env"
"github.com/buildpacks/libcnb/v2"
)
const (
// cacheHitMessage is emitted by ctx.CacheHit(). Must match acceptance test value.
cacheHitMessage = "***** CACHE HIT:"
// cacheMissMessage is emitted by ctx.CacheMiss(). Must match acceptance test value.
cacheMissMessage = "***** CACHE MISS:"
passStatusCode = 0
failStatusCode = 100
// labelKeyRegexpStr are valid characters for input to a label name. The label
// name itself undergoes some transformation _after_ this regexp. See
// AddLabel() for specifcs.
// See https://docs.docker.com/config/labels-custom-metadata/#key-format-recommendations
// for the formal label specification, though this regexp is also sensitive to strings
// allowable as env vars - for example, it does not allow "." even though the label
// specification does.
labelKeyRegexpStr = `\A[A-Za-z][A-Za-z0-9-_]*\z`
// WebProcess is the name of the default web process.
WebProcess = "web"
)
var (
defaultLogger = log.New(os.Stderr, "", 0)
labelKeyRegexp = regexp.MustCompile(labelKeyRegexpStr)
)
// DetectFn is the callback signature for Detect()
type DetectFn func(*Context) (DetectResult, error)
// BuildFn is the callback signature for Build()
type BuildFn func(*Context) error
type stats struct {
spans []*spanInfo
user time.Duration
}
// Context provides contextually aware functions for buildpack authors.
type Context struct {
info libcnb.BuildpackInfo
applicationRoot string
buildpackRoot string
debug bool
logger *log.Logger
installedRuntimeVersions []string
stats stats
exiter Exiter
warnings []string
// detect items
detectContext libcnb.DetectContext
// build items
buildContext libcnb.BuildContext
buildResult libcnb.BuildResult
layerContributors []layerContributor
execCmd func(name string, arg ...string) *exec.Cmd
}
// ContextOption configures NewContext functions.
type ContextOption func(ctx *Context)
// WithApplicationRoot sets the application root in Context.
func WithApplicationRoot(root string) ContextOption {
return func(ctx *Context) {
ctx.applicationRoot = root
}
}
// WithBuildpackRoot sets the buildpack root in Context.
func WithBuildpackRoot(root string) ContextOption {
return func(ctx *Context) {
ctx.buildpackRoot = root
}
}
// WithBuildpackInfo sets the buildpack info in Context.
func WithBuildpackInfo(info libcnb.BuildpackInfo) ContextOption {
return func(ctx *Context) {
ctx.info = info
}
}
// WithBuildContext sets the buildContext in Context.
func WithBuildContext(buildCtx libcnb.BuildContext) ContextOption {
return func(ctx *Context) {
ctx.buildContext = buildCtx
}
}
// WithExecCmd overrides the exec.Cmd instance used for executing commands,
// primarily useful for testing.
func WithExecCmd(execCmd func(name string, args ...string) *exec.Cmd) ContextOption {
return func(ctx *Context) {
ctx.execCmd = execCmd
}
}
// WithLogger override the logger implementation, this is useful for unit tests
// which want to verify logging output.
func WithLogger(logger *log.Logger) ContextOption {
return func(ctx *Context) {
ctx.logger = logger
}
}
// WithStackID sets the StackID in Context.
func WithStackID(stackID string) ContextOption {
return func(ctx *Context) {
ctx.buildContext.StackID = stackID
}
}
// NewContext creates a context.
func NewContext(opts ...ContextOption) *Context {
debug, err := env.IsDebugMode()
if err != nil {
defaultLogger.Printf("Failed to parse debug mode: %v", err)
os.Exit(1)
}
ctx := &Context{
debug: debug,
execCmd: exec.Command,
logger: defaultLogger,
}
ctx.exiter = defaultExiter{ctx: ctx}
for _, o := range opts {
o(ctx)
}
return ctx
}
func newDetectContext(detectContext libcnb.DetectContext) *Context {
ctx := NewContext(WithBuildpackInfo(detectContext.Buildpack.Info))
ctx.detectContext = detectContext
ctx.applicationRoot = ctx.detectContext.ApplicationPath
ctx.buildpackRoot = ctx.detectContext.Buildpack.Path
return ctx
}
func newBuildContext(buildContext libcnb.BuildContext) *Context {
ctx := NewContext(WithBuildpackInfo(buildContext.Buildpack.Info))
ctx.buildContext = buildContext
ctx.applicationRoot = ctx.buildContext.ApplicationPath
ctx.buildpackRoot = ctx.buildContext.Buildpack.Path
ctx.buildResult = libcnb.NewBuildResult()
return ctx
}
// BuildpackID returns the buildpack id.
func (ctx *Context) BuildpackID() string {
return ctx.info.ID
}
// BuildpackVersion returns the buildpack version.
func (ctx *Context) BuildpackVersion() string {
return ctx.info.Version
}
// BuildpackName returns the buildpack name.
func (ctx *Context) BuildpackName() string {
return ctx.info.Name
}
// ApplicationRoot returns the root folder of the application code.
func (ctx *Context) ApplicationRoot() string {
return ctx.applicationRoot
}
// BuildpackRoot returns the root folder of the buildpack.
func (ctx *Context) BuildpackRoot() string {
return ctx.buildpackRoot
}
// StackID returns the stack id.
func (ctx *Context) StackID() string {
return ctx.buildContext.StackID
}
// Debug returns whether debug mode is enabled.
func (ctx *Context) Debug() bool {
return ctx.debug
}
// Processes returns the list of processes added by buildpacks.
func (ctx *Context) Processes() []libcnb.Process {
return ctx.buildResult.Processes
}
// Main is the main entrypoint to a buildpack's detect and build functions.
func Main(d DetectFn, b BuildFn) {
switch filepath.Base(os.Args[0]) {
case "detect":
detect(d)
case "build":
build(b)
default:
defaultLogger.Print("Unknown command, expected 'detect' or 'build'.")
os.Exit(1)
}
}
type gcpdetector struct {
detectFn DetectFn
}
// detectFnWrapper creates a DetectFunc that wraps the given detectFn.
func detectFnWrapper(detectFn DetectFn) libcnb.DetectFunc {
return func(ldctx libcnb.DetectContext) (libcnb.DetectResult, error) {
ctx := newDetectContext(ldctx)
status := buildererror.StatusInternal
defer func(now time.Time) {
ctx.Span(fmt.Sprintf("Buildpack Detect %q", ctx.info.ID), now, status)
}(time.Now())
result, err := detectFn(ctx)
if err != nil {
msg := fmt.Sprintf("failed to run /bin/detect: %v", err)
var be *buildererror.Error
if errors.As(err, &be) {
status = be.Status
return libcnb.DetectResult{}, be
}
return libcnb.DetectResult{}, buildererror.Errorf(status, msg)
}
// detectFn has an interface return type so result may be nil.
if result == nil {
return libcnb.DetectResult{}, InternalErrorf("detect did not return a result or an error")
}
status = buildererror.StatusOk
ctx.Logf(result.Reason())
return result.Result(), nil
}
}
// detect implements the /bin/detect phase of the buildpack.
func detect(detectFn DetectFn, opts ...libcnb.Option) {
config := libcnb.NewConfig(opts...)
wrappedDetectFn := detectFnWrapper(detectFn)
libcnb.Detect(wrappedDetectFn, config)
}
type gcpbuilder struct {
buildFn BuildFn
}
// buildFnWrapper creates a libcnb.BuildFunc that wraps the given buildFn.
func buildFnWrapper(buildFn BuildFn) libcnb.BuildFunc {
return func(lbctx libcnb.BuildContext) (libcnb.BuildResult, error) {
start := time.Now()
ctx := newBuildContext(lbctx)
ctx.Logf("=== %s (%s@%s) ===", ctx.BuildpackName(), ctx.BuildpackID(), ctx.BuildpackVersion())
status := buildererror.StatusInternal
defer func(now time.Time) {
ctx.Span(fmt.Sprintf("Buildpack Build %q", ctx.BuildpackID()), now, status)
}(time.Now())
if err := buildFn(ctx); err != nil {
var be *buildererror.Error
if errors.As(err, &be) {
status = be.Status
}
err := fmt.Errorf("failed to build: %w", err)
ctx.Exit(1, err)
}
for i := 0; i < len(ctx.buildResult.Layers); i++ {
creator := ctx.layerContributors[i]
name := creator.Name()
layer, _ := ctx.buildContext.Layers.Layer(name)
layer, _ = creator.Contribute(layer)
ctx.buildResult.Layers[i] = layer
}
status = buildererror.StatusOk
ctx.saveSuccessOutput(time.Since(start))
return ctx.buildResult, nil
}
}
func build(buildFn BuildFn) {
options := []libcnb.Option{
// Without this flag the build SBOM is NOT written to the image's "io.buildpacks.build.metadata" label.
// The acceptence tests rely on this being present.
// libcnb.WithBOMLabel(true),
}
config := libcnb.NewConfig(options...)
wrappedBuildFn := buildFnWrapper(buildFn)
libcnb.Build(wrappedBuildFn, config)
}
// Exit causes the buildpack to exit with the given exit code and message.
func (ctx *Context) Exit(exitCode int, err error) {
ctx.exiter.Exit(exitCode, err)
}
// Logf emits a structured logging line.
func (ctx *Context) Logf(format string, args ...interface{}) {
ctx.logger.Printf(format, args...)
}
// Debugf emits a structured logging line if the debug flag is set.
func (ctx *Context) Debugf(format string, args ...interface{}) {
if !ctx.debug {
return
}
ctx.Logf("DEBUG: "+format, args...)
}
// Warnf emits a structured logging line for warnings.
func (ctx *Context) Warnf(format string, args ...interface{}) {
ctx.warnings = append(ctx.warnings, fmt.Sprintf(format, args...))
ctx.Logf("WARNING: "+format, args...)
}
// Tipf emits a structured logging line for usage tips.
func (ctx *Context) Tipf(format string, args ...interface{}) {
// Tips are only displayed for the gcp/base builder, not in GAE/GCF environments.
if env.IsGCP() {
ctx.Logf(format, args...)
}
}
// CacheHit records a cache hit debug message. This is used in acceptance test validation.
func (ctx *Context) CacheHit(tag string) {
ctx.Logf("%s %q", cacheHitMessage, tag)
}
// CacheMiss records a cache miss debug message. This is used in acceptance test validation.
func (ctx *Context) CacheMiss(tag string) {
ctx.Logf("%s %q", cacheMissMessage, tag)
}
// Span emits a structured Stackdriver span.
func (ctx *Context) Span(label string, start time.Time, status buildererror.Status) {
now := time.Now()
attributes := map[string]interface{}{
"/buildpack_id": ctx.BuildpackID(),
"/buildpack_name": ctx.BuildpackName(),
"/buildpack_version": ctx.BuildpackVersion(),
}
si, err := newSpanInfo(label, start, now, attributes, status)
if err != nil {
ctx.Warnf("Invalid span dropped: %v", err)
}
ctx.stats.spans = append(ctx.stats.spans, si)
}
// InstalledRuntimeVersions returns the list of runtime versions installed during build time.
func (ctx *Context) InstalledRuntimeVersions() []string {
return ctx.installedRuntimeVersions
}
// AddInstalledRuntimeVersion adds a runtime version to the list of installed runtimes. Used
// for versionless runtimes to provide feedback on the runtime version selected at build time.
func (ctx *Context) AddInstalledRuntimeVersion(version string) {
ctx.installedRuntimeVersions = append(ctx.installedRuntimeVersions, version)
}
// AddWebProcess adds the given command as the web start process, overwriting any previous web start process.
func (ctx *Context) AddWebProcess(cmd []string) {
ctx.AddProcess(WebProcess, cmd, AsDirectProcess(), AsDefaultProcess())
}
// processOption configures the AddProcess function.
type processOption func(o *libcnb.Process)
// AsDirectProcess causes the process to be executed directly, i.e. without a shell.
func AsDirectProcess() processOption {
return func(o *libcnb.Process) {
o.Command = o.Command[2:]
}
}
// AsDefaultProcess marks the process as the default one for when launcher is invoked without arguments.
func AsDefaultProcess() processOption {
return func(o *libcnb.Process) { o.Default = true }
}
// AddProcess adds the given command as named process, overwriting any previous process with the same name.
func (ctx *Context) AddProcess(name string, cmd []string, opts ...processOption) {
current := ctx.buildResult.Processes
ctx.buildResult.Processes = []libcnb.Process{}
for _, p := range current {
if p.Type == name {
ctx.Logf("Overwriting existing %s process %q.", name, p.Command)
continue // Do not add this item back to the ctx.processes; we are overwriting it.
}
ctx.buildResult.Processes = append(ctx.buildResult.Processes, p)
}
cmdWithDirectAsFalse := append([]string{"bash", "-c"}, cmd...)
p := libcnb.Process{
Type: name,
Command: cmdWithDirectAsFalse,
}
for _, opt := range opts {
opt(&p)
}
if len(p.Command) > 0 && p.Command[0] == "bash" {
p.Command = []string{"bash", "-c", strings.Join(p.Command[2:], " ")}
}
ctx.buildResult.Processes = append(ctx.buildResult.Processes, p)
}
// HTTPStatus returns the status code for a url.
func (ctx *Context) HTTPStatus(url string) (int, error) {
res, err := http.Head(url)
if err != nil {
return 0, InternalErrorf("getting status code for %s: %v", url, err)
}
return res.StatusCode, nil
}
// AddLabel adds a label to the user's application container.
func (ctx *Context) AddLabel(key, value string) {
if !labelKeyRegexp.MatchString(key) {
ctx.Warnf("Label %q does not match %s, skipping.", key, labelKeyRegexpStr)
return
}
if strings.Contains(key, "__") {
ctx.Warnf("Label %q must not contain consecutive underscores, skipping.", key)
return
}
key = "google." + strings.ToLower(strings.ReplaceAll(key, "_", "-"))
ctx.Logf("Adding image label %s: %s", key, value)
ctx.buildResult.Labels = append(ctx.buildResult.Labels, libcnb.Label{Key: key, Value: value})
}