Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: patch with context #732

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
328 changes: 199 additions & 129 deletions pkg/patch/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@
defaultTag = "latest"
)

type ScannerOpts struct {
Image string
ReportFile string
WorkingFolder string
Updates *unversioned.UpdateManifest
IgnoreError bool
Output string
DockerNormalizedImageName reference.Named
PatchedImageName string
Format string
}

type BkClient struct {
BkClient *client.Client
SolveOpt *client.SolveOpt
}

type BuildStatus struct {
BuildChannel chan *client.SolveStatus
}

type BuildContext struct {
Ctx context.Context
}

// Patch command applies package updates to an OCI image given a vulnerability report.
func Patch(ctx context.Context, timeout time.Duration, image, reportFile, patchedTag, workingFolder, scanner, format, output string, ignoreError bool, bkOpts buildkit.Opts) error {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
Expand Down Expand Up @@ -74,35 +99,34 @@
}
}

func patchWithContext(ctx context.Context, ch chan error, image, reportFile, patchedTag, workingFolder, scanner, format, output string, ignoreError bool, bkOpts buildkit.Opts) error {
imageName, err := reference.ParseNormalizedNamed(image)
// patchWithContext patches the user-supplied image, image
// reportFile is a vulnerability scan passed in by the user
// userSuppliedPatchTag is a tag set by the user to use for the patched image tag
// workingFolder is the folder used by copa, defaults to system temp folder
// scanner used to generate reportFile, defaults to Trivy
// format is the output format, defaults to openvex
// output is the desired output filepath
// ignoreError defines whether Copa should ignore errors
// bkOpts contains buildkitd options for addresses, CA certs, client certs, and client keys.
func patchWithContext(ctx context.Context, ch chan error, image, reportFile, userSuppliedPatchTag, workingFolder, scanner, format, output string, ignoreError bool, bkOpts buildkit.Opts) error {
dockerNormalizedImageName, err := reference.ParseNormalizedNamed(image)

Check warning on line 112 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L111-L112

Added lines #L111 - L112 were not covered by tests
if err != nil {
return err
}
if reference.IsNameOnly(imageName) {
log.Warnf("Image name has no tag or digest, using latest as tag")
imageName = reference.TagNameOnly(imageName)
}
var tag string
taggedName, ok := imageName.(reference.Tagged)
if ok {
tag = taggedName.Tag()
} else {
log.Warnf("Image name has no tag")
}
if patchedTag == "" {
if tag == "" {
log.Warnf("No output tag specified for digest-referenced image, defaulting to `%s`", defaultPatchedTagSuffix)
patchedTag = defaultPatchedTagSuffix
} else {
patchedTag = fmt.Sprintf("%s-%s", tag, defaultPatchedTagSuffix)
}

if reference.IsNameOnly(dockerNormalizedImageName) {
log.Warnf("Image name %s has no tag or digest, defaulting to %s:latest", image, dockerNormalizedImageName)
dockerNormalizedImageName = reference.TagNameOnly(dockerNormalizedImageName)

Check warning on line 119 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L117-L119

Added lines #L117 - L119 were not covered by tests
}
_, err = reference.WithTag(imageName, patchedTag)

patchedTag := generatePatchedTag(dockerNormalizedImageName, userSuppliedPatchTag)

Check warning on line 122 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L122

Added line #L122 was not covered by tests

_, err = reference.WithTag(dockerNormalizedImageName, patchedTag)

Check warning on line 124 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L124

Added line #L124 was not covered by tests
if err != nil {
return fmt.Errorf("%w with patched tag %s", err, patchedTag)
ashnamehrotra marked this conversation as resolved.
Show resolved Hide resolved
}
patchedImageName := fmt.Sprintf("%s:%s", imageName.Name(), patchedTag)

patchedImageName := fmt.Sprintf("%s:%s", dockerNormalizedImageName.Name(), patchedTag)

Check warning on line 129 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L129

Added line #L129 was not covered by tests

// Ensure working folder exists for call to InstallUpdates
if workingFolder == "" {
Expand Down Expand Up @@ -166,112 +190,16 @@
buildChannel := make(chan *client.SolveStatus)
eg, ctx := errgroup.WithContext(ctx)
eg.Go(func() error {
_, err := bkClient.Build(ctx, solveOpt, copaProduct, func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
// Configure buildctl/client for use by package manager
config, err := buildkit.InitializeBuildkitConfig(ctx, c, imageName.String())
if err != nil {
ch <- err
return nil, err
}

// Create package manager helper
var manager pkgmgr.PackageManager
if reportFile == "" {
// determine OS family
fileBytes, err := buildkit.ExtractFileFromState(ctx, c, &config.ImageState, "/etc/os-release")
if err != nil {
ch <- err
return nil, fmt.Errorf("unable to extract /etc/os-release file from state %w", err)
}

osType, err := getOSType(ctx, fileBytes)
if err != nil {
ch <- err
return nil, err
}

osVersion, err := getOSVersion(ctx, fileBytes)
if err != nil {
ch <- err
return nil, err
}

// get package manager based on os family type
manager, err = pkgmgr.GetPackageManager(osType, osVersion, config, workingFolder)
if err != nil {
ch <- err
return nil, err
}
} else {
// get package manager based on os family type
manager, err = pkgmgr.GetPackageManager(updates.Metadata.OS.Type, updates.Metadata.OS.Version, config, workingFolder)
if err != nil {
ch <- err
return nil, err
}
}

// Export the patched image state to Docker
patchedImageState, errPkgs, err := manager.InstallUpdates(ctx, updates, ignoreError)
if err != nil {
ch <- err
return nil, err
}

platform := platforms.Normalize(platforms.DefaultSpec())
if platform.OS != "linux" {
platform.OS = "linux"
}

def, err := patchedImageState.Marshal(ctx, llb.Platform(platform))
if err != nil {
ch <- err
return nil, fmt.Errorf("unable to get platform from ImageState %w", err)
}

res, err := c.Solve(ctx, gwclient.SolveRequest{
Definition: def.ToPB(),
Evaluate: true,
})
if err != nil {
ch <- err
return nil, err
}

res.AddMeta(exptypes.ExporterImageConfigKey, config.ConfigData)

// Currently can only validate updates if updating via scanner
if reportFile != "" {
// create a new manifest with the successfully patched packages
validatedManifest := &unversioned.UpdateManifest{
Metadata: unversioned.Metadata{
OS: unversioned.OS{
Type: updates.Metadata.OS.Type,
Version: updates.Metadata.OS.Version,
},
Config: unversioned.Config{
Arch: updates.Metadata.Config.Arch,
},
},
Updates: []unversioned.UpdatePackage{},
}
for _, update := range updates.Updates {
if !slices.Contains(errPkgs, update.Name) {
validatedManifest.Updates = append(validatedManifest.Updates, update)
}
}
// vex document must contain at least one statement
if output != "" && len(validatedManifest.Updates) > 0 {
if err := vex.TryOutputVexDocument(validatedManifest, manager, patchedImageName, format, output); err != nil {
ch <- err
return nil, err
}
}
}

return res, nil
}, buildChannel)

err = buildkitBuild(
BuildContext{ctx},
&ScannerOpts{
image, reportFile, workingFolder, updates, ignoreError,
output, dockerNormalizedImageName, patchedImageName, format,
},
BkClient{
bkClient, &solveOpt,
},
BuildStatus{buildChannel}, ch)

Check warning on line 202 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L193-L202

Added lines #L193 - L202 were not covered by tests
return err
})

Expand All @@ -291,7 +219,8 @@
})

eg.Go(func() error {
if err := dockerLoad(ctx, pipeR); err != nil {
err = dockerLoad(ctx, pipeR)
if err != nil {

Check warning on line 223 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L222-L223

Added lines #L222 - L223 were not covered by tests
return err
}
return pipeR.Close()
Expand All @@ -300,6 +229,147 @@
return eg.Wait()
}

// buildkitBuild submits a build request to BuildKit with the given information.
func buildkitBuild(buildContext BuildContext, trivyOpts *ScannerOpts, bkClient BkClient, buildStatus BuildStatus, ch chan error) error {
_, err := bkClient.BkClient.Build(buildContext.Ctx, *bkClient.SolveOpt, copaProduct, func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
bkConfig, err := buildkit.InitializeBuildkitConfig(ctx, c, trivyOpts.DockerNormalizedImageName.String())
if err != nil {
return handleError(ch, err)

Check warning on line 237 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L233-L237

Added lines #L233 - L237 were not covered by tests
}

manager, err := resolvePackageManager(buildContext, trivyOpts, c, bkConfig)
if err != nil {
return handleError(ch, err)

Check warning on line 242 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L240-L242

Added lines #L240 - L242 were not covered by tests
}

return buildReport(buildContext, trivyOpts, bkConfig, manager, ch)

Check warning on line 245 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L245

Added line #L245 was not covered by tests
}, buildStatus.BuildChannel)
return err

Check warning on line 247 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L247

Added line #L247 was not covered by tests
}

func resolvePackageManager(buildContext BuildContext, trivyOpts *ScannerOpts, client gwclient.Client, config *buildkit.Config) (pkgmgr.PackageManager, error) {
var manager pkgmgr.PackageManager
if trivyOpts.ReportFile == "" {
fileBytes, err := buildkit.ExtractFileFromState(buildContext.Ctx, client, &config.ImageState, "/etc/os-release")
if err != nil {
return nil, err

Check warning on line 255 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L250-L255

Added lines #L250 - L255 were not covered by tests
}

osType, err := getOSType(buildContext.Ctx, fileBytes)
if err != nil {
return nil, err

Check warning on line 260 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L258-L260

Added lines #L258 - L260 were not covered by tests
}

osVersion, err := getOSVersion(buildContext.Ctx, fileBytes)
if err != nil {
return nil, err

Check warning on line 265 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L263-L265

Added lines #L263 - L265 were not covered by tests
}
// get package manager based on os family type
manager, err = pkgmgr.GetPackageManager(osType, osVersion, config, trivyOpts.WorkingFolder)
if err != nil {
return nil, err

Check warning on line 270 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L268-L270

Added lines #L268 - L270 were not covered by tests
}
} else {

Check warning on line 272 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L272

Added line #L272 was not covered by tests
// get package manager based on os family type
var err error
manager, err = pkgmgr.GetPackageManager(trivyOpts.Updates.Metadata.OS.Type, trivyOpts.Updates.Metadata.OS.Version, config, trivyOpts.WorkingFolder)
if err != nil {
return nil, err

Check warning on line 277 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L274-L277

Added lines #L274 - L277 were not covered by tests
}
}
return manager, nil

Check warning on line 280 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L280

Added line #L280 was not covered by tests
}

// handleError streamlines error forwarding to error channel and returns the error again for further propagation.
func handleError(ch chan error, err error) (*gwclient.Result, error) {
ch <- err
return nil, err
}

// buildReport is an extracted method containing logic to manage the updates and build report.
func buildReport(buildContext BuildContext, trivyOpts *ScannerOpts, config *buildkit.Config, manager pkgmgr.PackageManager, ch chan error) (*gwclient.Result, error) {
patchedImageState, errPkgs, err := manager.InstallUpdates(buildContext.Ctx, trivyOpts.Updates, trivyOpts.IgnoreError)
if err != nil {
return handleError(ch, err)

Check warning on line 293 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L290-L293

Added lines #L290 - L293 were not covered by tests
}
platform := platforms.Normalize(platforms.DefaultSpec())
if platform.OS != "linux" {
platform.OS = "linux"

Check warning on line 297 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L295-L297

Added lines #L295 - L297 were not covered by tests
}
def, err := patchedImageState.Marshal(buildContext.Ctx, llb.Platform(platform))
if err != nil {
return handleError(ch, fmt.Errorf("unable to get platform from ImageState %w", err))

Check warning on line 301 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L299-L301

Added lines #L299 - L301 were not covered by tests
}
res, err := config.Client.Solve(buildContext.Ctx, gwclient.SolveRequest{
Definition: def.ToPB(),
Evaluate: true,
})
if err != nil {
return handleError(ch, err)

Check warning on line 308 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L303-L308

Added lines #L303 - L308 were not covered by tests
}
res.AddMeta(exptypes.ExporterImageConfigKey, config.ConfigData)

Check warning on line 310 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L310

Added line #L310 was not covered by tests
// Currently can only validate updates if updating via scanner
if trivyOpts.ReportFile != "" {
validatedManifest := updateManifest(trivyOpts.Updates, errPkgs)

Check warning on line 313 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L312-L313

Added lines #L312 - L313 were not covered by tests
// vex document must contain at least one statement
if trivyOpts.Output != "" && len(validatedManifest.Updates) > 0 {
err = vex.TryOutputVexDocument(validatedManifest, manager, trivyOpts.PatchedImageName, trivyOpts.Format, trivyOpts.Output)
if err != nil {
return handleError(ch, err)

Check warning on line 318 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L315-L318

Added lines #L315 - L318 were not covered by tests
}
}
}
return res, nil

Check warning on line 322 in pkg/patch/patch.go

View check run for this annotation

Codecov / codecov/patch

pkg/patch/patch.go#L322

Added line #L322 was not covered by tests
}

// updateManifest creates a new manifest with the successfully patched packages.
func updateManifest(updates *unversioned.UpdateManifest, errPkgs []string) *unversioned.UpdateManifest {
validatedManifest := &unversioned.UpdateManifest{
Metadata: unversioned.Metadata{
OS: unversioned.OS{
Type: updates.Metadata.OS.Type,
Version: updates.Metadata.OS.Version,
},
Config: unversioned.Config{
Arch: updates.Metadata.Config.Arch,
},
},
Updates: []unversioned.UpdatePackage{},
}
for _, update := range updates.Updates {
if !slices.Contains(errPkgs, update.Name) {
validatedManifest.Updates = append(validatedManifest.Updates, update)
}
}
return validatedManifest
}

func generatePatchedTag(dockerNormalizedImageName reference.Named, userSuppliedPatchTag string) string {
// currentTag is typically the versioning tag of the image as published in a container registry
var currentTag string
var copaTag string

taggedName, ok := dockerNormalizedImageName.(reference.Tagged)

if ok {
currentTag = taggedName.Tag()
} else {
log.Warnf("Image name has no tag")
}

if userSuppliedPatchTag != "" {
copaTag = userSuppliedPatchTag
return copaTag
} else if currentTag == "" {
log.Warnf("No output tag specified for digest-referenced image, defaulting to `%s`", defaultPatchedTagSuffix)
copaTag = defaultPatchedTagSuffix
return copaTag
}

copaTag = fmt.Sprintf("%s-%s", currentTag, defaultPatchedTagSuffix)
return copaTag
}

func getOSType(ctx context.Context, osreleaseBytes []byte) (string, error) {
r := bytes.NewReader(osreleaseBytes)
osData, err := osrelease.Parse(ctx, r)
Expand Down
Loading
Loading