Skip to content

Commit

Permalink
Add flyctl support for scanning images with scantron (#3725)
Browse files Browse the repository at this point in the history
* Add flyctl support for scanning images with scantron, supporting scan summary report, scan report, and sbom, with filtering.
* Allow flaps client without AppCompact by passing OrgSlug.
  • Loading branch information
timflyio authored Jul 19, 2024
1 parent 0a17b00 commit 15415f5
Show file tree
Hide file tree
Showing 13 changed files with 1,056 additions and 11 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ require (
github.com/superfly/fly-go v0.1.19-0.20240716210409-e3d434ec3f18
github.com/superfly/graphql v0.2.4
github.com/superfly/lfsc-go v0.1.1
github.com/superfly/macaroon v0.2.13
github.com/superfly/macaroon v0.2.14-0.20240702184853-b8ac52a1fc77
github.com/superfly/tokenizer v0.0.2
github.com/vektah/gqlparser v1.3.1
github.com/vektah/gqlparser/v2 v2.5.16
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -612,8 +612,8 @@ github.com/superfly/lfsc-go v0.1.1 h1:dGjLgt81D09cG+aR9lJZIdmonjZSR5zYCi7s54+ZU2
github.com/superfly/lfsc-go v0.1.1/go.mod h1:zVb0VENz/Il8Nmvvd4XAsX2bWhQ+sr0nK8vv9PeezcE=
github.com/superfly/ltx v0.3.12 h1:Z7z1sc4g34/jUi3XO84+zBlIsbaoh2RJ3b4zTQpBK/M=
github.com/superfly/ltx v0.3.12/go.mod h1:ly+Dq7UVacQVEI5/b0r6j+PSNy9ibwx1yikcWAaSkhE=
github.com/superfly/macaroon v0.2.13 h1:WEZnifapjW5yuCEsdZxqCq4X8xuzIf+AUVPv6lm7GtI=
github.com/superfly/macaroon v0.2.13/go.mod h1:Kt6/EdSYfFjR4GIe+erMwcJgU8iMu1noYVceQ5dNdKo=
github.com/superfly/macaroon v0.2.14-0.20240702184853-b8ac52a1fc77 h1:W5LHJ6jjIB8YxOzsA5ZfdvNHifUKN/mFLiMaCAaWyB8=
github.com/superfly/macaroon v0.2.14-0.20240702184853-b8ac52a1fc77/go.mod h1:Kt6/EdSYfFjR4GIe+erMwcJgU8iMu1noYVceQ5dNdKo=
github.com/superfly/tokenizer v0.0.2 h1:gBREpm08sPWUHsqKHKHFy/AIKwrqpg+VBD/wtJ6x5Jk=
github.com/superfly/tokenizer v0.0.2/go.mod h1:jO8bIWFsfcBghh7I6cFJHmJb5E3RQr0v4F0Rnev3Yj4=
github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
Expand Down
3 changes: 1 addition & 2 deletions internal/buildinfo/buildinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"time"

"github.com/pkg/errors"
Expand Down Expand Up @@ -135,5 +134,5 @@ func Commit() string {
}

func UserAgent() string {
return strings.TrimSpace(fmt.Sprintf("fly-cli/%s", Version()))
return fmt.Sprintf("flyctl/%s", Version())
}
288 changes: 288 additions & 0 deletions internal/command/registry/args.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
package registry

import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"

"github.com/samber/lo"
"golang.org/x/sync/errgroup"

fly "github.com/superfly/fly-go"
"github.com/superfly/fly-go/flaps"
"github.com/superfly/flyctl/internal/appconfig"
"github.com/superfly/flyctl/internal/flag"
"github.com/superfly/flyctl/internal/flapsutil"
"github.com/superfly/flyctl/internal/flyutil"
"github.com/superfly/flyctl/internal/prompt"
"github.com/superfly/flyctl/internal/spinner"
"github.com/superfly/flyctl/iostreams"
)

type Unit struct{}

// ImgInfo carries image information for a machine.
type ImgInfo struct {
Org string
OrgID string
App string
Mach string
Path string
}

func (a ImgInfo) Compare(b ImgInfo) int {
if d := strings.Compare(a.Org, b.Org); d != 0 {
return d
}
if d := strings.Compare(a.OrgID, b.OrgID); d != 0 {
return d
}
if d := strings.Compare(a.App, b.App); d != 0 {
return d
}
if d := strings.Compare(a.Mach, b.Mach); d != 0 {
return d
}
if d := strings.Compare(a.Path, b.Path); d != 0 {
return d
}
return 0
}

// AugmentMap includes all of src into targ.
func AugmentMap[K comparable, V any](targ, src map[K]V) {
for k, v := range src {
targ[k] = v
}
}

// SortedKeys returns the keys in a map in sorted order.
// Could be made generic.
func SortedKeys[V any](m map[ImgInfo]V) []ImgInfo {
keys := lo.Keys(m)
slices.SortFunc(keys, func(a, b ImgInfo) int { return a.Compare(b) })
return keys
}

// argsGetAppCompact returns the AppCompact for the selected app, using `app`.
func argsGetAppCompact(ctx context.Context) (*fly.AppCompact, error) {
appName := appconfig.NameFromContext(ctx)
apiClient := flyutil.ClientFromContext(ctx)
app, err := apiClient.GetAppCompact(ctx, appName)
if err != nil {
return nil, fmt.Errorf("failed to get app: %w", err)
}
return app, nil
}

func getFlapsClient(ctx context.Context, app *fly.AppCompact) (*flaps.Client, error) {
flapsClient, err := flapsutil.NewClientWithOptions(ctx, flaps.NewClientOpts{
AppCompact: app,
AppName: app.Name,
})
if err != nil {
return nil, fmt.Errorf("failed to create flaps client for app %s: %w", app.Name, err)
}
return flapsClient, nil
}

// argsGetMachine returns the selected machine, using `app`, `select` and `machine`.
func argsGetMachine(ctx context.Context, app *fly.AppCompact) (*fly.Machine, error) {
if flag.IsSpecified(ctx, "machine") {
if flag.IsSpecified(ctx, "select") {
return nil, errors.New("--machine can't be used with -s/--select")
}
return argsGetMachineByID(ctx, app)
}
return argsSelectMachine(ctx, app)
}

// argsSelectMachine lets the user select a machine if there are multiple machines and
// the user specified "-s". Otherwise it returns the first machine for an app.
// Using `select`.
func argsSelectMachine(ctx context.Context, app *fly.AppCompact) (*fly.Machine, error) {
anyMachine := !flag.GetBool(ctx, "select")

flapsClient, err := getFlapsClient(ctx, app)
if err != nil {
return nil, err
}

machines, err := flapsClient.ListActive(ctx)
if err != nil {
return nil, err
}

var options []string
for _, machine := range machines {
imgPath := imageRefPath(&machine.ImageRef)
options = append(options, fmt.Sprintf("%s: %s %s %s", machine.Region, machine.ID, machine.Name, imgPath))
}

if len(machines) == 0 {
return nil, fmt.Errorf("no machines found")
}

if anyMachine || len(machines) == 1 {
return machines[0], nil
}

index := 0
if err := prompt.Select(ctx, &index, "Select a machine:", "", options...); err != nil {
return nil, fmt.Errorf("failed to prompt for a machine: %w", err)
}
return machines[index], nil
}

// argsGetMachineByID returns an app's machine using the `machine` argument.
func argsGetMachineByID(ctx context.Context, app *fly.AppCompact) (*fly.Machine, error) {
flapsClient, err := getFlapsClient(ctx, app)
if err != nil {
return nil, err
}

machineID := flag.GetString(ctx, "machine")
machine, err := flapsClient.Get(ctx, machineID)
if err != nil {
return nil, err
}

return machine, nil
}

// argsGetImgPath returns an image path and its OrgID from the command line or from a
// selected app machine, using `app`, `image`, `select`, and `machine`.
func argsGetImgPath(ctx context.Context) (string, string, error) {
app, err := argsGetAppCompact(ctx)
if err != nil {
return "", "", err
}

if flag.IsSpecified(ctx, "image") {
if flag.IsSpecified(ctx, "machine") || flag.IsSpecified(ctx, "select") {
return "", "", fmt.Errorf("image option cannot be used with machine and select options")
}

path := flag.GetString(ctx, "image")
return path, app.Organization.ID, nil
}

machine, err := argsGetMachine(ctx, app)
if err != nil {
return "", "", err
}

return imageRefPath(&machine.ImageRef), app.Organization.ID, nil
}

// argsGetImages returns a list of images in ImgInfo format from
// command line args or the environment, using `org`, `app`, `running`.
func argsGetImages(ctx context.Context) (map[ImgInfo]Unit, error) {
ios := iostreams.FromContext(ctx)
spin := spinner.Run(ios, "Finding images...")
defer spin.Stop()

if appName := flag.GetApp(ctx); appName != "" {
return argsGetAppImages(ctx, appName)
} else if orgName := flag.GetOrg(ctx); orgName != "" {
return argsGetOrgImages(ctx, orgName)
} else if appName := appconfig.NameFromContext(ctx); appName != "" {
return argsGetAppImages(ctx, appName)
}
return nil, fmt.Errorf("No org or application specified")
}

// argsGetOrgImages returns a list of images for an org in ImgInfo format
// from `running`.
func argsGetOrgImages(ctx context.Context, orgName string) (map[ImgInfo]Unit, error) {
client := flyutil.ClientFromContext(ctx)
org, err := client.GetOrganizationBySlug(ctx, orgName)
if err != nil {
return nil, fmt.Errorf("failed to get org %q: %w", orgName, err)
}

apps, err := client.GetAppsForOrganization(ctx, org.ID)
if err != nil {
return nil, fmt.Errorf("failed to list apps for %q: %w", orgName, err)
}

eg, ctx := errgroup.WithContext(ctx)
eg.SetLimit(concurrentScans)
mu := sync.Mutex{}
allImgs := make(map[ImgInfo]Unit)
for n := range apps {
n := n
eg.Go(func() error {
app := &apps[n]
imgs, err := argsGetOrgAppImages(ctx, org.Name, org.ID, app.Name)
if err != nil {
return fmt.Errorf("failed to fetch images for %q app: %w", app.Name, err)
}

mu.Lock()
AugmentMap(allImgs, imgs)
mu.Unlock()
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}

return allImgs, nil
}

// argsGetAppImages returns a list of images for an app in ImgInfo format
// from `running`.
func argsGetAppImages(ctx context.Context, appName string) (map[ImgInfo]Unit, error) {
apiClient := flyutil.ClientFromContext(ctx)
app, err := apiClient.GetAppCompact(ctx, appName)
if err != nil {
return nil, fmt.Errorf("failed to get app %q: %w", appName, err)
}

org := app.Organization
return argsGetOrgAppImages(ctx, org.Name, org.ID, app.Name)
}

// argsGetOrgAppImages returns a list of images for an org/app in ImgInfo format
// from `running`.
func argsGetOrgAppImages(ctx context.Context, orgName, orgId, appName string) (map[ImgInfo]Unit, error) {
flapsClient, err := flapsutil.NewClientWithOptions(ctx, flaps.NewClientOpts{
OrgSlug: orgId,
AppName: appName,
})
if err != nil {
return nil, fmt.Errorf("failed to create flaps client for %q: %w", appName, err)
}

machines, err := flapsClient.ListActive(ctx)
if err != nil {
return nil, err
}

if flag.GetBool(ctx, "running") {
machines = lo.Filter(machines, func(machine *fly.Machine, _ int) bool {
return machine.State == fly.MachineStateStarted
})
}

imgs := make(map[ImgInfo]Unit)
for _, machine := range machines {
ir := machine.ImageRef
imgPath := fmt.Sprintf("%s/%s@%s", ir.Registry, ir.Repository, ir.Digest)

img := ImgInfo{
Org: orgName,
OrgID: orgId,
App: appName,
Mach: machine.Name,
Path: imgPath,
}
imgs[img] = Unit{}
}
return imgs, nil
}
26 changes: 26 additions & 0 deletions internal/command/registry/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package registry

import (
"context"
"fmt"

"github.com/superfly/flyctl/gql"
"github.com/superfly/flyctl/internal/flyutil"
)

func makeToken(ctx context.Context, name, orgID, expiry, profile string, options *gql.LimitedAccessTokenOptions) (*gql.CreateLimitedAccessTokenResponse, error) {
apiClient := flyutil.ClientFromContext(ctx)
resp, err := gql.CreateLimitedAccessToken(
ctx,
apiClient.GenqClient(),
name,
orgID,
profile,
options,
expiry,
)
if err != nil {
return nil, fmt.Errorf("failed creating token: %w", err)
}
return resp, nil
}
26 changes: 26 additions & 0 deletions internal/command/registry/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package registry

import (
"github.com/spf13/cobra"

"github.com/superfly/flyctl/internal/command"
)

func New() *cobra.Command {
const (
usage = "registry"
short = "Operate on registry images [experimental]"
long = "Scan registry images for an SBOM or vulnerabilities. These commands\n" +
"are experimental and subject to change."
)
cmd := command.New(usage, short, long, nil)
cmd.Hidden = true

cmd.AddCommand(
newSbom(),
newVulns(),
newVulnSummary(),
)

return cmd
}
Loading

0 comments on commit 15415f5

Please sign in to comment.