diff --git a/cmd/common/prompt.go b/cmd/common/prompt.go index 2fef53dcf..9745e7761 100644 --- a/cmd/common/prompt.go +++ b/cmd/common/prompt.go @@ -1,14 +1,21 @@ package common import ( + "errors" + "html/template" + "strings" + "github.com/erikgeiser/promptkit/selection" "github.com/erikgeiser/promptkit/textinput" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/lithammer/fuzzysearch/fuzzy" "github.com/rigdev/rig/pkg/utils" "golang.org/x/exp/slices" ) type GetInputOption = func(*textinput.TextInput) +// TODO What about non-string Selection type SelectInputOption = func(s *selection.Selection[string]) var ValidateAllOpt = func(inp *textinput.TextInput) { @@ -73,6 +80,24 @@ var SelectEnableFilterOpt = func(s *selection.Selection[string]) { s.Filter = selection.FilterContainsCaseSensitive[string] } +var SelectFuzzyFilterOpt = func(s *selection.Selection[string]) { + s.Filter = func(filter string, choice *selection.Choice[string]) bool { + return fuzzy.Match(filter, choice.Value) + } +} + +var SelectExtendTemplateOpt = func(t template.FuncMap) SelectInputOption { + return func(s *selection.Selection[string]) { + s.ExtendedTemplateFuncs = t + } +} + +var SelectTemplateOpt = func(template string) SelectInputOption { + return func(s *selection.Selection[string]) { + s.Template = template + } +} + func PromptInput(label string, opts ...GetInputOption) (string, error) { input := textinput.New(label) for _, opt := range opts { @@ -147,3 +172,95 @@ var ( {{- end -}} ` ) + +func PromptTableSelect(label string, choices [][]string, columnHeaders []string, opts ...SelectInputOption) (int, error) { + // TODO Honestly, this thing with manually creating the table rows and header + // feels like I'm reinventing the wheel. Maybe find some package to do this for me? + // I can't just use our table pretty printer as I don't want to print a table, + // I want a string for each individual row and a couple strings for the table headder + rows, colLengths, err := formatRows(choices, " | ") + if err != nil { + return 0, err + } + + if len(colLengths) != len(columnHeaders) { + return 0, errors.New("number of columns in 'choices' and 'columnHeaders' don't agree") + } + + var header string + for idx, c := range columnHeaders { + header += text.AlignCenter.Apply(c, colLengths[idx]) + } + headerBorder := strings.Repeat("-", len(header)) + + opts = append(opts, SelectExtendTemplateOpt(map[string]any{ + "header": func() string { return header }, + "headerBorder": func() string { return headerBorder }, + })) + idx, _, err := PromptSelect(label, rows, opts...) + return idx, err +} + +var tableSelectTemplate = ` +{{- if .Prompt -}} + {{ Bold .Prompt }} +{{ end -}} +{{ if .IsFiltered }} + {{- print .FilterPrompt " " .FilterInput }} +{{ end }} +{{ print " " header }} +{{ println " " headerBorder }} +{{- range $i, $choice := .Choices }} + {{- if IsScrollUpHintPosition $i }} + {{- "⇡ " -}} + {{- else if IsScrollDownHintPosition $i -}} + {{- "⇣ " -}} + {{- else -}} + {{- " " -}} + {{- end -}} + + {{- if eq $.SelectedIndex $i }} + {{- print (Foreground "32" (Bold "▸ ")) (Selected $choice) "\n" }} + {{- else }} + {{- print " " (Unselected $choice) "\n" }} + {{- end }} +{{- end}}` + +func formatRows(rows [][]string, colDelimiter string) ([]string, []int, error) { + if len(rows) == 0 { + return nil, nil, nil + } + + for _, r := range rows[1:] { + if len(r) != len(rows[0]) { + return nil, nil, errors.New("the rows are not all of equal length") + } + } + + var colLengths []int + for cIdx := range rows[0] { + longest := 0 + for _, row := range rows { + l := len(row[cIdx]) + if l > longest { + longest = l + } + } + colLengths = append(colLengths, longest) + } + + var result []string + for _, row := range rows { + var s string + for cIdx, c := range row { + s += text.AlignLeft.Apply(c, colLengths[cIdx]) + colDelimiter + } + result = append(result, s) + } + + for idx := range colLengths { + colLengths[idx] += len(colDelimiter) + } + + return result, colLengths, nil +} diff --git a/cmd/rig-admin/cmd/capsule.go b/cmd/rig-admin/cmd/capsule.go index 33f5cb8b5..87d0ff025 100644 --- a/cmd/rig-admin/cmd/capsule.go +++ b/cmd/rig-admin/cmd/capsule.go @@ -171,7 +171,7 @@ func CapsuleCreateBuild(ctx context.Context, cmd *cobra.Command, args []string, return err } - logger.Info("build created", zap.String("build_id", buildID)) + logger.Info("build created", zap.String("build_id", buildID.BuildID)) return nil } diff --git a/cmd/rig/cmd/capsule/deploy.go b/cmd/rig/cmd/capsule/deploy.go index 11c332d27..89da55076 100644 --- a/cmd/rig/cmd/capsule/deploy.go +++ b/cmd/rig/cmd/capsule/deploy.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "strings" "time" "github.com/bufbuild/connect-go" @@ -19,7 +20,6 @@ import ( "github.com/rigdev/rig-go-api/model" "github.com/rigdev/rig-go-sdk" "github.com/rigdev/rig/cmd/common" - "github.com/rigdev/rig/cmd/rig/cmd/base" "github.com/rigdev/rig/pkg/errors" "github.com/rigdev/rig/pkg/utils" "github.com/spf13/cobra" @@ -31,19 +31,16 @@ type imageInfo struct { created time.Time } -func CapsuleDeploy(ctx context.Context, cmd *cobra.Command, capsuleID CapsuleID, args []string, rc rig.Client) error { - var err error - if buildID == "" { - dc, err := getDockerClient() - if err != nil { - return err - } - buildID, err = createBuild(ctx, rc, capsuleID, dc) - if err != nil { - return err - } +func CapsuleDeploy(ctx context.Context, cmd *cobra.Command, args []string, capsuleID CapsuleID, rc rig.Client) error { + dc, err := getDockerClient() + if err != nil { + return err } + buildID, err = getBuildID(ctx, capsuleID, rc, dc) + if err != nil { + return err + } res, err := rc.Capsule().Deploy(ctx, &connect.Request[capsule.DeployRequest]{ Msg: &capsule.DeployRequest{ CapsuleId: capsuleID, @@ -59,62 +56,205 @@ func CapsuleDeploy(ctx context.Context, cmd *cobra.Command, capsuleID CapsuleID, return listenForEvents(ctx, res.Msg.GetRolloutId(), rc, capsuleID, cmd) } -func listenForEvents(ctx context.Context, rolloutID uint64, rc rig.Client, capsuleID string, cmd *cobra.Command) error { - eventCount := 0 - for { - res, err := rc.Capsule().GetRollout(ctx, &connect.Request[capsule.GetRolloutRequest]{ - Msg: &capsule.GetRolloutRequest{ - CapsuleId: capsuleID, - RolloutId: rolloutID, +func getBuildID(ctx context.Context, capsuleID string, rc rig.Client, dc *client.Client) (string, error) { + if buildID != "" && image != "" { + return "", errors.New("not both --build-id and --image can be given") + } + + if buildID != "" { + // TODO Figure out pagination + resp, err := rc.Capsule().ListBuilds(ctx, connect.NewRequest(&capsule.ListBuildsRequest{ + CapsuleId: capsuleID, + Pagination: &model.Pagination{ + Offset: 0, + Limit: 0, + Descending: false, }, - }) + })) if err != nil { - return err + return "", err } + builds := resp.Msg.GetBuilds() + return expandBuildID(ctx, builds, buildID) + } - eventRes, err := rc.Capsule().ListEvents(ctx, &connect.Request[capsule.ListEventsRequest]{ - Msg: &capsule.ListEventsRequest{ - CapsuleId: capsuleID, - RolloutId: rolloutID, - Pagination: &model.Pagination{ - Offset: uint32(eventCount), - }, - }, - }) - if err != nil { - return err + if image != "" { + return createBuild(ctx, rc, capsuleID, dc, image) + } + + return promptForImageOrBuild(ctx, capsuleID, rc, dc) +} + +func expandBuildID(ctx context.Context, builds []*capsule.Build, buildID string) (string, error) { + if strings.HasPrefix(buildID, "sha256:") { + return expandByDigestPrefix(buildID, builds) + } + if isHexString(buildID) { + return expandByDigestPrefix("sha256:"+buildID, builds) + } + if strings.Contains(buildID, "@") { + return expandByDigestName(buildID, builds) + } + if ref, err := container_name.NewTag(buildID); err == nil { + return expandByLatestTag(ref, builds) + } + + return "", errors.New("unable to parse buildID") +} + +func expandByDigestName(buildID string, builds []*capsule.Build) (string, error) { + idx := strings.Index(buildID, "@") + name := buildID[:idx] + digest := buildID[idx+1:] + tag, err := container_name.NewTag(name) + if err != nil { + return "", err + } + var validBuilds []*capsule.Build + for _, b := range builds { + repoMatch := b.GetRepository() == tag.RepositoryStr() + tagMatch := b.GetTag() == tag.TagStr() + digMatch := strings.HasPrefix(b.GetDigest(), digest) + if repoMatch && tagMatch && digMatch { + validBuilds = append(validBuilds, b) } - for _, event := range eventRes.Msg.GetEvents() { - cmd.Printf("[%v] %v\n", event.GetCreatedAt().AsTime().Format(base.RFC3339MilliFixed), event.GetMessage()) + } + + if len(validBuilds) == 0 { + return "", errors.New("no builds matched the image name and digest prefix") + } + if len(validBuilds) > 1 { + return "", errors.New("the image name and digest prefix was not unique") + } + + return validBuilds[0].GetBuildId(), nil +} + +func expandByLatestTag(ref container_name.Reference, builds []*capsule.Build) (string, error) { + var latest *capsule.Build + for _, b := range builds { + if b.GetRepository() != ref.Context().RepositoryStr() || b.GetTag() != ref.Identifier() { + continue } - eventCount += len(eventRes.Msg.GetEvents()) + if latest == nil || latest.CreatedAt.AsTime().Before(b.CreatedAt.AsTime()) { + latest = b + } + } - switch res.Msg.GetRollout().GetStatus().GetState() { - case capsule.RolloutState_ROLLOUT_STATE_DONE: - cmd.Println("Deployment complete") - return nil - case capsule.RolloutState_ROLLOUT_STATE_FAILED: - cmd.Println("Deployment failed") - return nil - case capsule.RolloutState_ROLLOUT_STATE_ABORTED: - cmd.Println("Deployment aborted") - return nil + if latest == nil { + return "", errors.New("no builds matched the given image name") + } + + return latest.GetBuildId(), nil +} + +func expandByDigestPrefix(digestPrefix string, builds []*capsule.Build) (string, error) { + var validBuilds []*capsule.Build + for _, b := range builds { + if strings.HasPrefix(b.GetDigest(), digestPrefix) { + validBuilds = append(validBuilds, b) } + } + if len(validBuilds) > 1 { + return "", errors.New("digest prefix was not unique") + } + if len(validBuilds) == 0 { + return "", errors.New("no builds had a matching digest prefix") + } + return validBuilds[0].GetBuildId(), nil +} - if len(eventRes.Msg.GetEvents()) == 0 { - cmd.Println("Deploying build...") +func isHexString(s string) bool { + s = strings.ToLower(s) + for _, c := range s { + if !(('0' <= c && c <= '9') || ('a' <= c && c <= 'f')) { + return false } + } + return true +} - time.Sleep(1 * time.Second) +func createBuild(ctx context.Context, rc rig.Client, capsuleID string, dc *client.Client, image string) (string, error) { + if strings.Contains(image, "@") { + return "", errors.UnimplementedErrorf("referencing images by digest is not yet supported") + } + + isLocalImage, _, err := utils.ImageExistsNatively(ctx, dc, image) + if err != nil { + return "", err + } + + var digest string + if isLocalImage { + image, digest, err = pushLocalImageToDevRegistry(ctx, image, rc, dc) + if err != nil { + return "", err + } + } + + res, err := rc.Capsule().CreateBuild(ctx, &connect.Request[capsule.CreateBuildRequest]{ + Msg: &capsule.CreateBuildRequest{ + CapsuleId: capsuleID, + Image: image, + Digest: digest, + }, + }) + if err != nil { + return "", err + } + + if res.Msg.GetCreatedNewBuild() { + fmt.Println("created new build:", res.Msg.GetBuildId()) + } else { + fmt.Println("build already exists, deploying existing build") + } + + isLocalImage, _, err = utils.ImageExistsNatively(ctx, dc, res.Msg.BuildId) + if err != nil { + return "", err } + + return res.Msg.GetBuildId(), nil } -// TODO Should be supplied by FX instead -func getDockerClient() (*client.Client, error) { - var opts []client.Opt - opts = append(opts, client.WithHostFromEnv()) - opts = append(opts, client.WithAPIVersionNegotiation()) - return client.NewClientWithOpts(opts...) +func promptForImageOrBuild(ctx context.Context, capsuleID string, rc rig.Client, dc *client.Client) (string, error) { + i, _, err := common.PromptSelect("Deploy from docker image or existing rig build?", []string{"Image", "Build"}) + if err != nil { + return "", err + } + switch i { + case 0: + image, err := promptForImage(ctx, dc) + if err != nil { + return "", err + } + return createBuild(ctx, rc, capsuleID, dc, image) + case 1: + return promptForExistingBuild(ctx, capsuleID, rc) + default: + return "", errors.New("something went wrong") + } +} + +func promptForImage(ctx context.Context, dc *client.Client) (string, error) { + ok, err := common.PromptConfirm("Deploy a local image?", true) + if err != nil { + return "", err + } + + if ok { + img, err := getDaemonImage(ctx, dc) + if err != nil { + return "", err + } + return img.tag, nil + } + + image, err = common.PromptInput("Enter image:", common.ValidateImageOpt) + if err != nil { + return "", err + } + return image, nil } func getDaemonImage(ctx context.Context, dc *client.Client) (*imageInfo, error) { @@ -123,6 +263,9 @@ func getDaemonImage(ctx context.Context, dc *client.Client) (*imageInfo, error) return nil, err } + if len(images) == 0 { + return nil, errors.New("no local docker images found") + } idx, _, err := common.PromptSelect("Select image:", prompts, common.SelectEnableFilterOpt) if err != nil { return nil, err @@ -180,67 +323,95 @@ func getImagePrompts(ctx context.Context, dc *client.Client, filter string) ([]i return images, prompts, nil } -func createBuild(ctx context.Context, rc rig.Client, capsuleID string, dc *client.Client) (string, error) { - image, digest, err := getImageAndDigest(ctx, rc, dc) +func promptForExistingBuild(ctx context.Context, capsuleID string, rc rig.Client) (string, error) { + resp, err := rc.Capsule().ListBuilds(ctx, connect.NewRequest(&capsule.ListBuildsRequest{ + CapsuleId: capsuleID, + Pagination: &model.Pagination{}, + })) if err != nil { return "", err } - res, err := rc.Capsule().CreateBuild(ctx, &connect.Request[capsule.CreateBuildRequest]{ - Msg: &capsule.CreateBuildRequest{ - CapsuleId: capsuleID, - Image: image, - Digest: digest, - }, - }) - if errors.IsAlreadyExists(err) { - fmt.Println("build already exists, deploying existing build") - imgRef, err := container_name.ParseReference(image) - if err != nil { - return "", err - } - return imgRef.Name(), nil + builds := resp.Msg.GetBuilds() + + var rows [][]string + for _, b := range builds { + rows = append(rows, []string{ + fmt.Sprint(b.GetRepository(), ":", b.GetTag()), + truncatedFixed(b.GetDigest(), 19), + fmt.Sprint(time.Since(b.GetCreatedAt().AsTime()).Truncate(time.Second)), + }) } + + idx, err := common.PromptTableSelect( + "Select a Rig build", + rows, + []string{"Image name", "Digest", "Age"}, + common.SelectFuzzyFilterOpt, + ) if err != nil { - return "", fmt.Errorf("failed to create build: %q", err) + return "", err } - fmt.Println("Created build: ", buildID) - return res.Msg.GetBuildId(), nil + return builds[idx].GetBuildId(), nil } -func getImageAndDigest(ctx context.Context, rigClient rig.Client, dc *client.Client) (string, string, error) { - if image != "" { - return image, "", nil - } - - isLocalImage := false - ok, err := common.PromptConfirm("Deploy a local image?", true) - if err != nil { - return "", "", err - } - if ok { - img, err := getDaemonImage(ctx, dc) +func listenForEvents(ctx context.Context, rolloutID uint64, rc rig.Client, capsuleID string, cmd *cobra.Command) error { + eventCount := 0 + for { + res, err := rc.Capsule().GetRollout(ctx, &connect.Request[capsule.GetRolloutRequest]{ + Msg: &capsule.GetRolloutRequest{ + CapsuleId: capsuleID, + RolloutId: rolloutID, + }, + }) if err != nil { - return "", "", err + return err } - image = img.tag - isLocalImage = true - } else { - image, err = common.PromptInput("Enter image:", common.ValidateImageOpt) + + eventRes, err := rc.Capsule().ListEvents(ctx, &connect.Request[capsule.ListEventsRequest]{ + Msg: &capsule.ListEventsRequest{ + CapsuleId: capsuleID, + RolloutId: rolloutID, + Pagination: &model.Pagination{ + Offset: uint32(eventCount), + }, + }, + }) if err != nil { - return "", "", err + return err } - isLocalImage, _, err = utils.ImageExistsNatively(ctx, dc, image) - if err != nil { - return "", "", err + for _, event := range eventRes.Msg.GetEvents() { + cmd.Printf("[%v] %v\n", event.GetCreatedAt().AsTime().Format(time.RFC822), event.GetMessage()) } - } + eventCount += len(eventRes.Msg.GetEvents()) - if isLocalImage { - return pushLocalImageToDevRegistry(ctx, image, rigClient, dc) + switch res.Msg.GetRollout().GetStatus().GetState() { + case capsule.RolloutState_ROLLOUT_STATE_DONE: + cmd.Println("Deployment complete") + return nil + case capsule.RolloutState_ROLLOUT_STATE_FAILED: + cmd.Println("Deployment failed") + return nil + case capsule.RolloutState_ROLLOUT_STATE_ABORTED: + cmd.Println("Deployment aborted") + return nil + } + + if len(eventRes.Msg.GetEvents()) == 0 { + cmd.Println("Deploying build...") + } + + time.Sleep(1 * time.Second) } +} - return image, "", nil +// TODO Should be supplied by FX instead +// TODO Currently we can't read from protected repositories as we don't properly read the credentials which the local docker CLI uses +func getDockerClient() (*client.Client, error) { + return client.NewClientWithOpts( + client.WithHostFromEnv(), + client.WithAPIVersionNegotiation(), + ) } func pushLocalImageToDevRegistry(ctx context.Context, image string, client rig.Client, dc *client.Client) (string, string, error) { @@ -252,7 +423,7 @@ func pushLocalImageToDevRegistry(ctx context.Context, image string, client rig.C switch config.GetDevRegistry().(type) { case *cluster.GetConfigResponse_Docker: - return "", "", nil + return image, "", nil } devRegistry := config.GetRegistry() if devRegistry == nil { diff --git a/cmd/rig/cmd/capsule/deploy_test.go b/cmd/rig/cmd/capsule/deploy_test.go new file mode 100644 index 000000000..6ee5a8a00 --- /dev/null +++ b/cmd/rig/cmd/capsule/deploy_test.go @@ -0,0 +1,104 @@ +package capsule + +import ( + "context" + "testing" + "time" + + "github.com/rigdev/rig-go-api/api/v1/capsule" + "github.com/rigdev/rig/pkg/errors" + "github.com/rigdev/rig/pkg/utils" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func Test_expandBuildID(t *testing.T) { + t1 := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2000, 1, 1, 1, 0, 0, 0, time.UTC) + builds := []*capsule.Build{ + { + BuildId: "registry/name:tag@sha256:0123456789", + Digest: "sha256:0123456789", + Repository: "registry/name", + Tag: "tag", + CreatedAt: timestamppb.New(t1), + }, + { + BuildId: "registry/name:tag@sha256:01234abcd", + Digest: "sha256:01234abcd", + Repository: "registry/name", + Tag: "tag", + CreatedAt: timestamppb.New(t2), + }, + } + + tests := []struct { + name string + buildID string + err error + res string + }{ + { + name: "exact match", + buildID: "registry/name:tag@sha256:0123456789", + err: nil, + res: "registry/name:tag@sha256:0123456789", + }, + { + name: "sha prefix", + buildID: "sha256:01234567", + err: nil, + res: "registry/name:tag@sha256:0123456789", + }, + { + name: "hex prefix", + buildID: "01234567", + err: nil, + res: "registry/name:tag@sha256:0123456789", + }, + { + name: "not unique prefix", + buildID: "01234", + err: errors.New("digest prefix was not unique"), + res: "", + }, + { + name: "no matching prefix", + buildID: "012345f", + err: errors.New("no builds had a matching digest prefix"), + res: "", + }, + { + name: "get latest by tag", + buildID: "registry/name:tag", + err: nil, + res: "registry/name:tag@sha256:01234abcd", + }, + { + name: "no build with tag", + buildID: "registry/name:tag2", + err: errors.New("no builds matched the given image name"), + res: "", + }, + { + name: "image name + digest prefix", + buildID: "registry/name:tag@sha256:0123456", + err: nil, + res: "registry/name:tag@sha256:0123456789", + }, + { + name: "malformed", + buildID: "__+", + err: errors.New("unable to parse buildID"), + res: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := expandBuildID(context.Background(), builds, tt.buildID) + utils.ErrorEqual(t, tt.err, err) + assert.Equal(t, tt.res, res) + }) + } +} diff --git a/cmd/rig/cmd/capsule/setup.go b/cmd/rig/cmd/capsule/setup.go index 09a71d307..f16421ca8 100644 --- a/cmd/rig/cmd/capsule/setup.go +++ b/cmd/rig/cmd/capsule/setup.go @@ -78,8 +78,13 @@ func Setup(parent *cobra.Command) { Short: "Deploy the given build to a capsule", Args: cobra.MaximumNArgs(1), RunE: base.Register(CapsuleDeploy), + Long: `Deploy either the given rig-build or docker image to a capsule. +If --build-id is given rig tries to find a matching existing rig-build to deploy. +If --image is given rig tries to create a new rig-build from the docker image (if it doesn't already exist) +Not both --build-id and --image can be given`, } - deploy.Flags().StringVarP(&buildID, "build-id", "b", "", "build id to deploy") + deploy.Flags().StringVarP(&buildID, "build-id", "b", "", "rig build id to deploy") + deploy.Flags().StringVarP(&image, "image", "i", "", "docker image to deploy. Will create a new rig-build from the image if it doesn't exist") capsule.AddCommand(deploy) scale := &cobra.Command{ diff --git a/go.mod b/go.mod index 97776b6f8..bb7539752 100644 --- a/go.mod +++ b/go.mod @@ -49,9 +49,10 @@ require ( github.com/erikgeiser/promptkit v0.9.0 github.com/fatih/color v1.15.0 github.com/go-chi/chi/v5 v5.0.10 + github.com/go-logr/zapr v1.2.4 github.com/google/go-containerregistry v0.16.1 github.com/jedib0t/go-pretty/v6 v6.4.6 - github.com/rigdev/rig-go-api v0.0.0-20230918113547-85aa906e5160 + github.com/rigdev/rig-go-api v0.0.0-20230920122749-0471626a4cc0 github.com/rigdev/rig-go-sdk v0.0.0-20230918110956-2301fcd9da11 k8s.io/metrics v0.28.0 sigs.k8s.io/controller-runtime v0.16.1 @@ -71,8 +72,8 @@ require ( github.com/docker/cli v24.0.0+incompatible // indirect github.com/docker/docker-credential-helpers v0.7.0 // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect - github.com/go-logr/zapr v1.2.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect diff --git a/go.sum b/go.sum index 4a437b1e8..0f593348c 100644 --- a/go.sum +++ b/go.sum @@ -374,6 +374,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -474,14 +476,10 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= -github.com/rigdev/rig-go-api v0.0.0-20230913094225-5b5d6af68f16 h1:z6n4x55wzQ1plGpRgzDxnNCTha1Y5A/PvgxdLH8151A= -github.com/rigdev/rig-go-api v0.0.0-20230913094225-5b5d6af68f16/go.mod h1:fraLUk9ekeQvvu5bOmUmpNAsBqG2QzqWU9wzGbPwB/w= -github.com/rigdev/rig-go-api v0.0.0-20230918104110-0184e937281f h1:PePW4CuyDJnPO4IwaBQN0rvUw9/LchwHm5ZKuq5k92Y= -github.com/rigdev/rig-go-api v0.0.0-20230918104110-0184e937281f/go.mod h1:fraLUk9ekeQvvu5bOmUmpNAsBqG2QzqWU9wzGbPwB/w= github.com/rigdev/rig-go-api v0.0.0-20230918113547-85aa906e5160 h1:kZS2rFkckK3CszgY3IqYz3pU5Z0PNOn9casjBoTVjsU= github.com/rigdev/rig-go-api v0.0.0-20230918113547-85aa906e5160/go.mod h1:fraLUk9ekeQvvu5bOmUmpNAsBqG2QzqWU9wzGbPwB/w= -github.com/rigdev/rig-go-sdk v0.0.0-20230902170251-18fbb97d0692 h1:9K+F3uKbXaTC5iR6sy6tBodlmQVp8g+tB3o0hcWKtck= -github.com/rigdev/rig-go-sdk v0.0.0-20230902170251-18fbb97d0692/go.mod h1:mC8jVtKLH3RusvgTaHSn3lJTyaVdgsSizn8Sjb5xaoM= +github.com/rigdev/rig-go-api v0.0.0-20230920122749-0471626a4cc0 h1:leFSeftwUA08pZKEI7U30rUHE+RC595/+H9+eEimEvE= +github.com/rigdev/rig-go-api v0.0.0-20230920122749-0471626a4cc0/go.mod h1:fraLUk9ekeQvvu5bOmUmpNAsBqG2QzqWU9wzGbPwB/w= github.com/rigdev/rig-go-sdk v0.0.0-20230918110956-2301fcd9da11 h1:cdW4OYPgzr+iMzsqPCLDTRa37rpTRD2OUxhbPApzHss= github.com/rigdev/rig-go-sdk v0.0.0-20230918110956-2301fcd9da11/go.mod h1:CtN3BUZJfONEGNuVnoewrKHGSHF/XS5OsHxzkA9ToI4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -657,6 +655,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -698,6 +697,7 @@ golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.13.0 h1:Nvo8UFsZ8X3BhAC9699Z1j7XQ3rsZnUUm7jfBEk1ueY= golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= @@ -724,6 +724,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -798,6 +799,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -857,6 +859,7 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/client/docker/client.go b/internal/client/docker/client.go index e4bad1140..c35ced9f7 100644 --- a/internal/client/docker/client.go +++ b/internal/client/docker/client.go @@ -21,6 +21,7 @@ import ( "github.com/docker/docker/api/types/registry" "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" + "github.com/opencontainers/go-digest" v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/rigdev/rig-go-api/api/v1/capsule" "github.com/rigdev/rig/internal/config" @@ -256,11 +257,43 @@ func (c *Client) ensureNetwork(ctx context.Context) (string, error) { return projectID.String(), nil } +func (c *Client) isLocalImage(ctx context.Context, image string) (bool, error) { + _, err := digest.Parse(image) + if err != nil { + // image images not a digets + images, err := c.dc.ImageList(ctx, types.ImageListOptions{ + Filters: filters.NewArgs(filters.KeyValuePair{ + Key: "reference", + Value: image, + }), + }) + if err != nil { + return false, err + } + + return len(images) > 0, nil + } + + // image is a digest + // TODO find a better way than listing all local images + images, err := c.dc.ImageList(ctx, types.ImageListOptions{}) + if err != nil { + return false, err + } + for _, img := range images { + if img.ID == image { + return true, nil + } + } + + return false, nil +} + func (c *Client) ensureImage(ctx context.Context, image string, auth *cluster.RegistryAuth) error { image = strings.TrimPrefix(image, "docker.io/library/") image = strings.TrimPrefix(image, "index.docker.io/library/") - is, err := c.dc.ImageList(ctx, types.ImageListOptions{ + images, err := c.dc.ImageList(ctx, types.ImageListOptions{ Filters: filters.NewArgs(filters.KeyValuePair{ Key: "reference", Value: image, @@ -270,7 +303,8 @@ func (c *Client) ensureImage(ctx context.Context, image string, auth *cluster.Re return err } - if len(is) != 0 { + if len(images) > 0 { + // Image is local return nil } diff --git a/internal/core/module.go b/internal/core/module.go index 8f14cc1c9..0d153fe0c 100644 --- a/internal/core/module.go +++ b/internal/core/module.go @@ -1,6 +1,7 @@ package core import ( + docker_client "github.com/docker/docker/client" "github.com/rigdev/rig/internal/client" "github.com/rigdev/rig/internal/config" "github.com/rigdev/rig/internal/gateway" @@ -22,5 +23,14 @@ func GetModule(cfg config.Config) fx.Option { repository.Module, gateway.Module, telemetry.Module, + fx.Provide(func(cfg config.Config) (*docker_client.Client, error) { + if cfg.Cluster.Type != config.ClusterTypeDocker { + return nil, nil + } + return docker_client.NewClientWithOpts( + docker_client.WithHostFromEnv(), + docker_client.WithAPIVersionNegotiation(), + ) + }), ) } diff --git a/internal/handler/api/capsule/create_build.go b/internal/handler/api/capsule/create_build.go index 8af5076f3..66f4f75ed 100644 --- a/internal/handler/api/capsule/create_build.go +++ b/internal/handler/api/capsule/create_build.go @@ -8,7 +8,7 @@ import ( ) func (h *Handler) CreateBuild(ctx context.Context, req *connect.Request[capsule.CreateBuildRequest]) (*connect.Response[capsule.CreateBuildResponse], error) { - buildID, err := h.cs.CreateBuild( + resp, err := h.cs.CreateBuild( ctx, req.Msg.GetCapsuleId(), req.Msg.GetImage(), @@ -23,7 +23,8 @@ func (h *Handler) CreateBuild(ctx context.Context, req *connect.Request[capsule. return &connect.Response[capsule.CreateBuildResponse]{ Msg: &capsule.CreateBuildResponse{ - BuildId: buildID, + BuildId: resp.BuildID, + CreatedNewBuild: resp.CreatedNewBuild, }, }, nil } diff --git a/internal/repository/capsule/mongo/get_build.go b/internal/repository/capsule/mongo/get_build.go index 0902fe949..496ae9c09 100644 --- a/internal/repository/capsule/mongo/get_build.go +++ b/internal/repository/capsule/mongo/get_build.go @@ -18,7 +18,6 @@ func (m *MongoRepository) GetBuild(ctx context.Context, capsuleID string, buildI } var b schema.Build - if err := m.BuildCol.FindOne(ctx, bson.M{ "project_id": projectID, "capsule_id": capsuleID, diff --git a/internal/service/capsule/build.go b/internal/service/capsule/build.go index 535fa0ba5..aa04fb839 100644 --- a/internal/service/capsule/build.go +++ b/internal/service/capsule/build.go @@ -15,48 +15,51 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -func (s *Service) CreateBuild(ctx context.Context, capsuleID string, image, digest string, origin *capsule.Origin, labels map[string]string, validateImage bool) (string, error) { +func (s *Service) CreateBuild(ctx context.Context, capsuleID string, image, digest string, origin *capsule.Origin, labels map[string]string, validateImage bool) (CreateBuildResponse, error) { + var emptyRes CreateBuildResponse + if image == "" { - return "", errors.InvalidArgumentErrorf("missing image") + return emptyRes, errors.InvalidArgumentErrorf("missing image") } - ref, err := name.ParseReference(image) if err != nil { - return "", errors.InvalidArgumentErrorf("%v", err) + return emptyRes, errors.InvalidArgumentErrorf("%v", err) } if validateImage { d, err := s.validateImage(ctx, ref) if err != nil { - return "", err + return emptyRes, err } if digest != "" && digest != d { - return "", errors.InvalidArgumentErrorf("provided digest doesn't match image") + return emptyRes, errors.InvalidArgumentErrorf("provided digest doesn't match image") } digest = d } if _, err := s.GetCapsule(ctx, capsuleID); err != nil { - return "", err + return emptyRes, err } by, err := s.as.GetAuthor(ctx) if err != nil { - return "", err + return emptyRes, err } - idRef := ref + buildID := ref.Name() if digest != "" { - idRef, err = name.NewDigest(fmt.Sprintf("%s@%s", ref.Context().String(), digest)) + id := fmt.Sprintf("%s@%s", ref.Name(), digest) + _, err := name.NewDigest(id) if err != nil { - return "", err + return emptyRes, err } + buildID = id } b := &capsule.Build{ - BuildId: idRef.Name(), + BuildId: buildID, Digest: digest, Repository: ref.Context().String(), Tag: ref.Identifier(), @@ -66,11 +69,23 @@ func (s *Service) CreateBuild(ctx context.Context, capsuleID string, image, dige Labels: labels, } - if err := s.cr.CreateBuild(ctx, capsuleID, b); err != nil { - return "", err + createdNew := true + err = s.cr.CreateBuild(ctx, capsuleID, b) + if errors.IsAlreadyExists(err) { + createdNew = false + } else if err != nil { + return emptyRes, err } - return idRef.Name(), nil + return CreateBuildResponse{ + BuildID: buildID, + CreatedNewBuild: createdNew, + }, nil +} + +type CreateBuildResponse struct { + BuildID string + CreatedNewBuild bool } func (s *Service) ListBuilds(ctx context.Context, capsuleID string, pagination *model.Pagination) (iterator.Iterator[*capsule.Build], uint64, error) { diff --git a/internal/service/capsule/build_test.go b/internal/service/capsule/build_test.go index fb3526437..f22ea5953 100644 --- a/internal/service/capsule/build_test.go +++ b/internal/service/capsule/build_test.go @@ -49,9 +49,9 @@ func Test_CreateBuild_ValidArguments(t *testing.T) { buildID, err := s.CreateBuild(ctx, capsuleID, "foobar", "", nil, nil, false) require.NoError(t, err) - require.Equal(t, "index.docker.io/library/foobar:latest", buildID) + require.Equal(t, "index.docker.io/library/foobar:latest", buildID.BuildID) buildID, err = s.CreateBuild(ctx, capsuleID, "foobar:hattehat", "", nil, nil, false) require.NoError(t, err) - require.Equal(t, "index.docker.io/library/foobar:hattehat", buildID) + require.Equal(t, "index.docker.io/library/foobar:hattehat", buildID.BuildID) } diff --git a/internal/service/capsule/rollout.go b/internal/service/capsule/rollout.go index 4cdeee722..c58f71e00 100644 --- a/internal/service/capsule/rollout.go +++ b/internal/service/capsule/rollout.go @@ -631,7 +631,7 @@ func (j *rolloutJob) run( return err } - if i.GetBuildId() != rc.GetBuildId() { + if i.GetBuildId() != cfg.Spec.Image { return errors.UnavailableErrorf("instance '%s' is wrong build", i.GetInstanceId()) } diff --git a/internal/service/capsule/service.go b/internal/service/capsule/service.go index 07c36fc6a..944732cf8 100644 --- a/internal/service/capsule/service.go +++ b/internal/service/capsule/service.go @@ -32,7 +32,17 @@ type Service struct { cfg config.Config } -func NewService(cr repository.Capsule, sr repository.Secret, cg cluster.Gateway, ccg cluster.ConfigGateway, csg cluster.StatusGateway, as *service_auth.Service, ps project.Service, cfg config.Config, logger *zap.Logger) *Service { +func NewService( + cr repository.Capsule, + sr repository.Secret, + cg cluster.Gateway, + ccg cluster.ConfigGateway, + csg cluster.StatusGateway, + as *service_auth.Service, + ps project.Service, + cfg config.Config, + logger *zap.Logger, +) *Service { s := &Service{ cr: cr, sr: sr, diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 22dd9672b..ecc21be98 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -1,6 +1,7 @@ package errors import ( + "errors" "fmt" "net/http" @@ -464,3 +465,7 @@ func FromHTTP(status int, msg string) error { return InternalErrorf(msg) } } + +func New(s string) error { + return errors.New(s) +} diff --git a/pkg/utils/docker.go b/pkg/utils/docker.go index d5c2cca84..8cbe2e6e7 100644 --- a/pkg/utils/docker.go +++ b/pkg/utils/docker.go @@ -7,6 +7,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" + "github.com/google/go-containerregistry/pkg/name" ) func ImageExistsNatively(ctx context.Context, dc *client.Client, image string) (bool, string, error) { @@ -26,5 +27,27 @@ func ImageExistsNatively(ctx context.Context, dc *client.Client, image string) ( return false, "", nil } - return true, is[0].ID, nil + ref, err := name.ParseReference(image) + if err != nil { + return false, "", err + } + + // A local build which has never been pushed to a registry has no digest + // See https://github.com/moby/moby/issues/16482#issuecomment-149285106 + // A remote build pulled to local will look like a local build (as it is returned by ImageList) + // but will have a digest + // This distinguishes between these two cases + var digest string + for _, refStrWithDigest := range is[0].RepoDigests { + refWithDigest, err := name.ParseReference(refStrWithDigest) + if err != nil { + return false, "", err + } + if ref.Context().RepositoryStr() == refWithDigest.Context().RepositoryStr() { + digest = refWithDigest.Identifier() + break + } + } + + return true, digest, nil } diff --git a/pkg/utils/test_helper.go b/pkg/utils/test_helper.go new file mode 100644 index 000000000..b3cd249c4 --- /dev/null +++ b/pkg/utils/test_helper.go @@ -0,0 +1,16 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func ErrorEqual(t *testing.T, expected error, err error) { + if expected == nil { + assert.Nil(t, err) + return + } + assert.NotNil(t, err) + assert.Equal(t, expected.Error(), err.Error()) +}