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

advisory: move command #1042

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/cmd/wolfictl_advisory_move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## wolfictl advisory move

Move a package's advisories into a new package.

***Aliases**: mv*

### Usage

```
wolfictl advisory move <old-package-name> <new-package-name>
```

### Synopsis

Move a package's advisories into a new package.

This command will move most advisories for the given package into a new package. And rename the
package to the new package name. (i.e., from foo.advisories.yaml to foo-X.Y.advisories.yaml) If the
target file already exists, the command will try to merge the advisories. To ensure the advisories
are up-to-date, the command will start a scan for the new package.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this PR still needs some cleanup from our last discussion?


This command is also useful to start version streaming for an existing package that has not been
version streamed before. Especially that requires manual intervention to move the advisories.

The command will move the latest event for each advisory, and will update the timestamp
of the event to now. The command will not copy events of type "detection", "fixed",
"analysis_not_planned", or "fix_not_planned".


### Options

```
-d, --dir string directory containing the advisories to copy (default ".")
-h, --help help for move
```

### Options inherited from parent commands

```
--log-level string log level (e.g. debug, info, warn, error) (default "info")
--log-policy strings log policy (e.g. builtin:stderr, /tmp/log/foo) (default [builtin:stderr])
```

### SEE ALSO

* [wolfictl advisory](wolfictl_advisory.md) - Commands for consuming and maintaining security advisory data

58 changes: 58 additions & 0 deletions docs/man/man1/wolfictl-advisory-move.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
.TH "WOLFICTL\-ADVISORY\-MOVE" "1" "" "Auto generated by spf13/cobra" ""
.nh
.ad l


.SH NAME
.PP
wolfictl\-advisory\-move \- Move a package's advisories into a new package.


.SH SYNOPSIS
.PP
\fBwolfictl advisory move <old-package-name> <new-package-name>\fP


.SH DESCRIPTION
.PP
Move a package's advisories into a new package.

.PP
This command will move most advisories for the given package into a new package. And rename the
package to the new package name. (i.e., from foo.advisories.yaml to foo\-X.Y.advisories.yaml) If the
target file already exists, the command will try to merge the advisories. To ensure the advisories
are up\-to\-date, the command will start a scan for the new package.

.PP
This command is also useful to start version streaming for an existing package that has not been
version streamed before. Especially that requires manual intervention to move the advisories.

.PP
The command will move the latest event for each advisory, and will update the timestamp
of the event to now. The command will not copy events of type "detection", "fixed",
"analysis\_not\_planned", or "fix\_not\_planned".


.SH OPTIONS
.PP
\fB\-d\fP, \fB\-\-dir\fP="."
directory containing the advisories to copy

.PP
\fB\-h\fP, \fB\-\-help\fP[=false]
help for move


.SH OPTIONS INHERITED FROM PARENT COMMANDS
.PP
\fB\-\-log\-level\fP="info"
log level (e.g. debug, info, warn, error)

.PP
\fB\-\-log\-policy\fP=[builtin:stderr]
log policy (e.g. builtin:stderr, /tmp/log/foo)


.SH SEE ALSO
.PP
\fBwolfictl\-advisory(1)\fP
1 change: 1 addition & 0 deletions pkg/cli/advisory.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func cmdAdvisory() *cobra.Command {
cmdAdvisorySecDB(),
cmdAdvisoryUpdate(),
cmdAdvisoryValidate(),
cmdAdvisoryMove(),
)

return cmd
Expand Down
73 changes: 41 additions & 32 deletions pkg/cli/advisory_copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,39 +49,9 @@ of the event to now. The command will not copy events of type "detection", "fixe
out.Advisories = nil

for _, adv := range hdoc.Advisories {
evts := make([]v2.Event, 0, len(adv.Events))

for _, evt := range adv.Events {
switch evt.Type {
case v2.EventTypeDetection, v2.EventTypeFixed, v2.EventTypeAnalysisNotPlanned, v2.EventTypeFixNotPlanned:
// Don't carry these over.
continue

case v2.EventTypePendingUpstreamFix, v2.EventTypeFalsePositiveDetermination, v2.EventTypeTruePositiveDetermination:
// Carry these over as-is.
evts = append(evts, evt)

default:
// A new type was added and we don't know how to handle it. Default to not carrying it over.
}
}

if len(evts) == 0 {
// No events to carry over.
continue
if carried, ok := carryAdvisory(adv); ok {
out.Advisories = append(out.Advisories, carried)
}

// Sort events by timestamp and only take the latest event.
sort.Slice(evts, func(i, j int) bool {
return evts[i].Timestamp.Before(evts[j].Timestamp)
})
evts = []v2.Event{evts[len(evts)-1]}

// Update the timestamp to now.
evts[0].Timestamp = v2.Now()

adv.Events = evts
out.Advisories = append(out.Advisories, adv)
}

return advisoryCfgs.Create(ctx, want+".advisories.yaml", out)
Expand All @@ -91,3 +61,42 @@ of the event to now. The command will not copy events of type "detection", "fixe

return cmd
}

// carryAdvisory decides whether to carry over an advisory and its events.
// Returns true with the updated advisory if it should be carried over. Otherwise, returns false
// and the current advisory.
func carryAdvisory(advisory v2.Advisory) (v2.Advisory, bool) {
evts := make([]v2.Event, 0, len(advisory.Events))

for _, evt := range advisory.Events {
switch evt.Type {
case v2.EventTypeDetection, v2.EventTypeFixed, v2.EventTypeAnalysisNotPlanned, v2.EventTypeFixNotPlanned:
// Don't carry these over.
continue

case v2.EventTypePendingUpstreamFix, v2.EventTypeFalsePositiveDetermination, v2.EventTypeTruePositiveDetermination:
// Carry these over as-is.
evts = append(evts, evt)

default:
// A new type was added and we don't know how to handle it. Default to not carrying it over.
}
}

if len(evts) == 0 {
// No events to carry over.
return advisory, false
}

// Sort events by timestamp and only take the latest event.
sort.Slice(evts, func(i, j int) bool {
return evts[i].Timestamp.Before(evts[j].Timestamp)
})
evts = []v2.Event{evts[len(evts)-1]}

// Update the timestamp to now.
evts[0].Timestamp = v2.Now()

advisory.Events = evts
Comment on lines +95 to +100
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This feels a little awkward to me. Could we capture the event we want, then do the timestamp update, and then just return a slice that includes that event? (It seems strange to operate on the event within the slice when we don't need to)

return advisory, true
}
117 changes: 117 additions & 0 deletions pkg/cli/advisory_move.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package cli

import (
"fmt"
"strings"

"github.com/spf13/cobra"
v2 "github.com/wolfi-dev/wolfictl/pkg/configs/advisory/v2"
rwos "github.com/wolfi-dev/wolfictl/pkg/configs/rwfs/os"
)

func cmdAdvisoryMove() *cobra.Command {
var dir string
cmd := &cobra.Command{
Use: "move <old-package-name> <new-package-name>",
Aliases: []string{"mv"},
Short: "Move a package's advisories into a new package.",
Long: `Move a package's advisories into a new package.

This command will move most advisories for the given package into a new package. And rename the
package to the new package name. (i.e., from foo.advisories.yaml to foo-X.Y.advisories.yaml) If the
target file already exists, the command will try to merge the advisories. To ensure the advisories
are up-to-date, the command will start a scan for the new package.

This command is also useful to start version streaming for an existing package that has not been
version streamed before. Especially that requires manual intervention to move the advisories.

The command will move the latest event for each advisory, and will update the timestamp
of the event to now. The command will not copy events of type "detection", "fixed",
"analysis_not_planned", or "fix_not_planned".
`,
SilenceErrors: true,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
have, want := args[0], args[1]

have = strings.TrimSuffix(have, ".advisories.yaml")
want = strings.TrimSuffix(want, ".advisories.yaml")

advisoryFsys := rwos.DirFS(dir)
advisoryCfgs, err := v2.NewIndex(ctx, advisoryFsys)
if err != nil {
return err
}

oldEntry, err := advisoryCfgs.Select().WhereName(have).First()
if err != nil {
return fmt.Errorf("unable to find advisory for package %q: %w", have, err)
}
oldDoc := oldEntry.Configuration()

shouldMergeExistings := false
newEntry, err := advisoryCfgs.Select().WhereName(want).First()
if err == nil && len(newEntry.Configuration().Advisories) > 0 {
shouldMergeExistings = true
}

out := *oldDoc
out.Package.Name = want
out.Advisories = nil

for _, adv := range oldDoc.Advisories {
if carried, ok := carryAdvisory(adv); ok {
out.Advisories = append(out.Advisories, carried)
}
}

havePath := have + ".advisories.yaml"
wantPath := want + ".advisories.yaml"

// If the new file already exists, merge the old advisories to it and re-create.
if shouldMergeExistings {
newDoc := newEntry.Configuration()

updater := v2.NewAdvisoriesSectionUpdater(func(_ v2.Document) (v2.Advisories, error) {
return mergeExistingAdvisories(out.Advisories, newDoc.Advisories), nil
})

if err := newEntry.Update(ctx, updater); err != nil {
return fmt.Errorf("unable to update %q: %w", wantPath, err)
}

// Remove the existing file to re-create it since it's already existed.
if err := advisoryCfgs.Remove(wantPath); err != nil {
return fmt.Errorf("unable to remove old file %q: %w", wantPath, err)
}
}

if err := advisoryCfgs.Remove(havePath); err != nil {
return fmt.Errorf("unable to remove old file %q: %w", havePath, err)
}

return advisoryCfgs.Create(ctx, wantPath, out)
},
}
cmd.PersistentFlags().StringVarP(&dir, "dir", "d", ".", "directory containing the advisories to copy")

return cmd
}

// mergeExistingAdvisories merges the current advisories with the existing advisories.
func mergeExistingAdvisories(current, existing v2.Advisories) v2.Advisories {
res := make(v2.Advisories, 0, len(current)+len(existing))

// Add current advisories to the result and mark their IDs as seen
res = append(res, current...)

// Add existing advisories to the result if they are not already present
for _, adv := range existing {
if _, found := res.Get(adv.ID); !found {
res = append(res, adv)
}
}

return res
}
22 changes: 22 additions & 0 deletions pkg/configs/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,28 @@ func (i *Index[T]) Create(ctx context.Context, filepath string, cfg T) error {
return nil
}

// Delete deletes the configuration file at the given path. The configuration is
// also removed from the Index.
func (i *Index[T]) Remove(filepath string) error {
idx, ok := i.byPath[filepath]
if !ok {
return fmt.Errorf("unable to remove configuration: no configuration found at %q", filepath)
}

if err := i.fsys.Remove(filepath); err != nil {
return err
}

delete(i.byPath, filepath)
delete(i.byName, i.cfgs[idx].Name())

i.paths = append(i.paths[:idx], i.paths[idx+1:]...)
i.yamlRoots = append(i.yamlRoots[:idx], i.yamlRoots[idx+1:]...)
i.cfgs = append(i.cfgs[:idx], i.cfgs[idx+1:]...)

return nil
}

// Path returns the path to the configuration file for the given name.
func (i *Index[T]) Path(name string) string {
idx, ok := i.byName[name]
Expand Down
36 changes: 36 additions & 0 deletions pkg/configs/index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,39 @@ func TestNewIndex(t *testing.T) {
assert.NotContains(t, index.paths, ".not-a-config.yaml")
})
}

func TestRemove(t *testing.T) {
ctx := context.Background()
fsys := rwos.DirFS("testdata/index-1")

index, err := NewIndex[config.Configuration](ctx, fsys, func(ctx context.Context, path string) (*config.Configuration, error) {
return config.ParseConfiguration(ctx, path, config.WithFS(fsys))
})
require.NoError(t, err)

name := "config-new"
filename := name + ".advisories.yaml"

err = index.Create(ctx, filename, config.Configuration{
Package: config.Package{
Name: name,
Version: "1.0.0",
},
})
require.NoError(t, err)

_, err = index.Select().WhereName(name).First()
require.NoError(t, err)

t.Run("removes a config", func(t *testing.T) {
err := index.Remove(filename)
require.NoError(t, err)

assert.NotContains(t, index.paths, filename)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this line for? It looks like we're testing the removal in the test block below?

})

t.Run("ensure the config is removed", func(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also test that other configs weren't removed and are still available?

_, err := index.Select().WhereName(name).First()
require.Error(t, err)
})
}
1 change: 1 addition & 0 deletions pkg/configs/rwfs/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type FS interface {
OpenAsWritable(name string) (File, error)
Truncate(name string, size int64) error
Create(name string) (File, error)
Remove(name string) error
}

type File interface {
Expand Down
Loading