fix: improve conversion of image config to Dockerfile (#8308)

Signed-off-by: nikpivkin <nikita.pivkin@smartforce.io>
This commit is contained in:
Nikita Pivkin
2025-01-29 17:35:30 +06:00
committed by GitHub
parent f258fd5a2a
commit 2e8e38a8c0
2 changed files with 176 additions and 53 deletions

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
v1 "github.com/google/go-containerregistry/pkg/v1"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
@@ -49,61 +50,10 @@ func (a *historyAnalyzer) Analyze(ctx context.Context, input analyzer.ConfigAnal
if input.Config == nil {
return nil, nil
}
dockerfile := new(bytes.Buffer)
var userFound bool
baseLayerIndex := image.GuessBaseImageIndex(input.Config.History)
for i := baseLayerIndex + 1; i < len(input.Config.History); i++ {
h := input.Config.History[i]
var createdBy string
switch {
case strings.HasPrefix(h.CreatedBy, "/bin/sh -c #(nop)"):
// Instruction other than RUN
createdBy = strings.TrimPrefix(h.CreatedBy, "/bin/sh -c #(nop)")
case strings.HasPrefix(h.CreatedBy, "/bin/sh -c"):
// RUN instruction
createdBy = strings.ReplaceAll(h.CreatedBy, "/bin/sh -c", "RUN")
case strings.HasSuffix(h.CreatedBy, "# buildkit"):
// buildkit instructions
// COPY ./foo /foo # buildkit
// ADD ./foo.txt /foo.txt # buildkit
// RUN /bin/sh -c ls -hl /foo # buildkit
createdBy = strings.TrimSuffix(h.CreatedBy, "# buildkit")
if strings.HasPrefix(h.CreatedBy, "RUN /bin/sh -c") {
createdBy = strings.ReplaceAll(createdBy, "RUN /bin/sh -c", "RUN")
}
case strings.HasPrefix(h.CreatedBy, "USER"):
// USER instruction
createdBy = h.CreatedBy
userFound = true
case strings.HasPrefix(h.CreatedBy, "HEALTHCHECK"):
// HEALTHCHECK instruction
var interval, timeout, startPeriod, retries, command string
if input.Config.Config.Healthcheck.Interval != 0 {
interval = fmt.Sprintf("--interval=%s ", input.Config.Config.Healthcheck.Interval)
}
if input.Config.Config.Healthcheck.Timeout != 0 {
timeout = fmt.Sprintf("--timeout=%s ", input.Config.Config.Healthcheck.Timeout)
}
if input.Config.Config.Healthcheck.StartPeriod != 0 {
startPeriod = fmt.Sprintf("--startPeriod=%s ", input.Config.Config.Healthcheck.StartPeriod)
}
if input.Config.Config.Healthcheck.Retries != 0 {
retries = fmt.Sprintf("--retries=%d ", input.Config.Config.Healthcheck.Retries)
}
command = strings.Join(input.Config.Config.Healthcheck.Test, " ")
command = strings.ReplaceAll(command, "CMD-SHELL", "CMD")
createdBy = fmt.Sprintf("HEALTHCHECK %s%s%s%s%s", interval, timeout, startPeriod, retries, command)
}
dockerfile.WriteString(strings.TrimSpace(createdBy) + "\n")
}
if !userFound && input.Config.Config.User != "" {
user := fmt.Sprintf("USER %s", input.Config.Config.User)
dockerfile.WriteString(user)
}
fsys := mapfs.New()
if err := fsys.WriteVirtualFile("Dockerfile", dockerfile.Bytes(), 0600); err != nil {
if err := fsys.WriteVirtualFile(
"Dockerfile", imageConfigToDockerfile(input.Config), 0600); err != nil {
return nil, xerrors.Errorf("mapfs write error: %w", err)
}
@@ -121,6 +71,79 @@ func (a *historyAnalyzer) Analyze(ctx context.Context, input analyzer.ConfigAnal
}, nil
}
func imageConfigToDockerfile(cfg *v1.ConfigFile) []byte {
dockerfile := new(bytes.Buffer)
var userFound bool
baseLayerIndex := image.GuessBaseImageIndex(cfg.History)
for i := baseLayerIndex + 1; i < len(cfg.History); i++ {
h := cfg.History[i]
var createdBy string
switch {
case strings.HasPrefix(h.CreatedBy, "/bin/sh -c #(nop)"):
// Instruction other than RUN
createdBy = strings.TrimPrefix(h.CreatedBy, "/bin/sh -c #(nop)")
case strings.HasPrefix(h.CreatedBy, "/bin/sh -c"):
// RUN instruction
createdBy = buildRunInstruction(createdBy)
case strings.HasSuffix(h.CreatedBy, "# buildkit"):
// buildkit instructions
// COPY ./foo /foo # buildkit
// ADD ./foo.txt /foo.txt # buildkit
// RUN /bin/sh -c ls -hl /foo # buildkit
createdBy = strings.TrimSuffix(h.CreatedBy, "# buildkit")
createdBy = buildRunInstruction(createdBy)
case strings.HasPrefix(h.CreatedBy, "USER"):
// USER instruction
createdBy = h.CreatedBy
userFound = true
case strings.HasPrefix(h.CreatedBy, "HEALTHCHECK"):
// HEALTHCHECK instruction
createdBy = buildHealthcheckInstruction(cfg.Config.Healthcheck)
default:
for _, prefix := range []string{"ARG", "ENV", "ENTRYPOINT"} {
strings.HasPrefix(h.CreatedBy, prefix)
createdBy = h.CreatedBy
break
}
}
dockerfile.WriteString(strings.TrimSpace(createdBy) + "\n")
}
if !userFound && cfg.Config.User != "" {
user := fmt.Sprintf("USER %s", cfg.Config.User)
dockerfile.WriteString(user)
}
return dockerfile.Bytes()
}
func buildRunInstruction(s string) string {
pos := strings.Index(s, "/bin/sh -c")
if pos == -1 {
return s
}
return "RUN" + s[pos+len("/bin/sh -c"):]
}
func buildHealthcheckInstruction(health *v1.HealthConfig) string {
var interval, timeout, startPeriod, retries, command string
if health.Interval != 0 {
interval = fmt.Sprintf("--interval=%s ", health.Interval)
}
if health.Timeout != 0 {
timeout = fmt.Sprintf("--timeout=%s ", health.Timeout)
}
if health.StartPeriod != 0 {
startPeriod = fmt.Sprintf("--startPeriod=%s ", health.StartPeriod)
}
if health.Retries != 0 {
retries = fmt.Sprintf("--retries=%d ", health.Retries)
}
command = strings.Join(health.Test, " ")
command = strings.ReplaceAll(command, "CMD-SHELL", "CMD")
return fmt.Sprintf("HEALTHCHECK %s%s%s%s%s", interval, timeout, startPeriod, retries, command)
}
func (a *historyAnalyzer) Required(_ types.OS) bool {
return true
}

View File

@@ -1,11 +1,13 @@
package dockerfile
import (
"bytes"
"context"
"testing"
"time"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/moby/buildkit/frontend/dockerfile/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -343,3 +345,101 @@ func Test_historyAnalyzer_Analyze(t *testing.T) {
})
}
}
func Test_ImageConfigToDockerfile(t *testing.T) {
tests := []struct {
name string
input *v1.ConfigFile
expected string
}{
{
name: "run instruction with build args",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "RUN |1 pkg=curl /bin/sh -c apk add $pkg # buildkit",
},
},
},
expected: "RUN apk add $pkg\n",
},
{
name: "healthcheck instruction with system's default shell",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "HEALTHCHECK &{[\"CMD-SHELL\" \"curl -f http://localhost/ || exit 1\"] \"5m0s\" \"3s\" \"1s\" \"5s\" '\\x03'}",
},
},
Config: v1.Config{
Healthcheck: &v1.HealthConfig{
Test: []string{"CMD-SHELL", "curl -f http://localhost/ || exit 1"},
Interval: time.Minute * 5,
Timeout: time.Second * 3,
StartPeriod: time.Second * 1,
Retries: 3,
},
},
},
expected: "HEALTHCHECK --interval=5m0s --timeout=3s --startPeriod=1s --retries=3 CMD curl -f http://localhost/ || exit 1\n",
},
{
name: "healthcheck instruction exec arguments directly",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "HEALTHCHECK &{[\"CMD\" \"curl\" \"-f\" \"http://localhost/\" \"||\" \"exit 1\"] \"0s\" \"0s\" \"0s\" \"0s\" '\x03'}",
},
},
Config: v1.Config{
Healthcheck: &v1.HealthConfig{
Test: []string{"CMD", "curl", "-f", "http://localhost/", "||", "exit 1"},
Retries: 3,
},
},
},
expected: "HEALTHCHECK --retries=3 CMD curl -f http://localhost/ || exit 1\n",
},
{
name: "nop, no run instruction",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "/bin/sh -c #(nop) ARG TAG=latest",
},
},
},
expected: "ARG TAG=latest\n",
},
{
name: "buildkit metadata instructions",
input: &v1.ConfigFile{
History: []v1.History{
{
CreatedBy: "ARG TAG=latest",
},
{
CreatedBy: "ENV TAG=latest",
},
{
CreatedBy: "ENTRYPOINT [\"/bin/sh\" \"-c\" \"echo test\"]",
},
},
},
expected: `ARG TAG=latest
ENV TAG=latest
ENTRYPOINT ["/bin/sh" "-c" "echo test"]
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := imageConfigToDockerfile(tt.input)
_, err := parser.Parse(bytes.NewReader(got))
require.NoError(t, err)
assert.Equal(t, tt.expected, string(got))
})
}
}