From f700e6c3f9c5b7935f30b5f38e17ffe668323be8 Mon Sep 17 00:00:00 2001 From: Matias Frank Jensen Date: Tue, 19 Sep 2023 15:54:17 +0200 Subject: [PATCH 1/3] Improve CLI 'capsule deploy' and fixed bug with deploying in Docker capsule deploy: Now infers which Rig build is referenced from incomplete quries, e.g. passing only a digest prefix Docker bug: We changed how Rig builds are id'ed to always contain the image digest. Docker can fine pull remote images when they are specified with registry/repo[:tag]@digest, but local builds cannot (for some reason...) be referenced by both repo and digest. Either you must supply a digest or you must supply a registry/repo[:tag]. Therefore all deploys in Docker failed, and I had to hack around the buildID in docker :( --- cmd/common/prompt.go | 117 ++++++ cmd/rig-admin/cmd/capsule.go | 2 +- cmd/rig/cmd/capsule/deploy.go | 369 +++++++++++++----- cmd/rig/cmd/capsule/deploy_test.go | 104 +++++ cmd/rig/cmd/capsule/setup.go | 7 +- go.mod | 5 +- go.sum | 15 +- internal/client/docker/client.go | 43 +- internal/core/module.go | 10 + internal/handler/api/capsule/create_build.go | 5 +- .../repository/capsule/mongo/get_build.go | 3 +- internal/service/capsule/build.go | 45 ++- internal/service/capsule/build_test.go | 4 +- internal/service/capsule/rollout.go | 8 +- internal/service/capsule/service.go | 110 ++++-- pkg/errors/errors.go | 5 + pkg/utils/test_helper.go | 16 + 17 files changed, 706 insertions(+), 162 deletions(-) create mode 100644 cmd/rig/cmd/capsule/deploy_test.go create mode 100644 pkg/utils/test_helper.go 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..eed4b0f4f 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,21 +257,47 @@ 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{ - Filters: filters.NewArgs(filters.KeyValuePair{ - Key: "reference", - Value: image, - }), - }) + isLocal, err := c.isLocalImage(ctx, image) if err != nil { return err } - - if len(is) != 0 { + if isLocal { 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..67c3decd8 100644 --- a/internal/repository/capsule/mongo/get_build.go +++ b/internal/repository/capsule/mongo/get_build.go @@ -2,6 +2,7 @@ package mongo import ( "context" + "fmt" "github.com/rigdev/rig-go-api/api/v1/capsule" "github.com/rigdev/rig/internal/repository/capsule/mongo/schema" @@ -18,7 +19,7 @@ func (m *MongoRepository) GetBuild(ctx context.Context, capsuleID string, buildI } var b schema.Build - + fmt.Println("get build, project", projectID, "capsule", capsuleID, "build", buildID) 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..c76944e9f 100644 --- a/internal/service/capsule/rollout.go +++ b/internal/service/capsule/rollout.go @@ -469,7 +469,11 @@ func (j *rolloutJob) run( return err } - cfg.Spec.Image = rc.GetBuildId() + image, err := j.s.ImageFromBuild(ctx, b) + if err != nil { + return err + } + cfg.Spec.Image = image cfg.Spec.Command = rc.GetContainerSettings().GetCommand() cfg.Spec.Args = rc.GetContainerSettings().GetArgs() cfg.Spec.Replicas = int32(rc.GetReplicas()) @@ -631,7 +635,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..5975b6e36 100644 --- a/internal/service/capsule/service.go +++ b/internal/service/capsule/service.go @@ -2,7 +2,10 @@ package capsule import ( "context" + "strings" + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" "github.com/rigdev/rig-go-api/api/v1/capsule" "github.com/rigdev/rig-go-api/model" "github.com/rigdev/rig/internal/config" @@ -17,33 +20,47 @@ import ( "github.com/rigdev/rig/pkg/uuid" "go.uber.org/zap" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/strings/slices" ) type Service struct { - logger *zap.Logger - cr repository.Capsule - sr repository.Secret - cg cluster.Gateway - ccg cluster.ConfigGateway - csg cluster.StatusGateway - as *service_auth.Service - ps project.Service - q *Queue[Job] - 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 { + logger *zap.Logger + cr repository.Capsule + sr repository.Secret + cg cluster.Gateway + ccg cluster.ConfigGateway + csg cluster.StatusGateway + as *service_auth.Service + ps project.Service + q *Queue[Job] + cfg config.Config + dockerClient *client.Client +} + +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, + dockerClient *client.Client, + logger *zap.Logger, +) *Service { s := &Service{ - cr: cr, - sr: sr, - cg: cg, - ccg: ccg, - csg: csg, - as: as, - ps: ps, - q: NewQueue[Job](), - cfg: cfg, - logger: logger, + cr: cr, + sr: sr, + cg: cg, + ccg: ccg, + csg: csg, + as: as, + ps: ps, + q: NewQueue[Job](), + cfg: cfg, + dockerClient: dockerClient, + logger: logger, } go s.run() @@ -197,3 +214,50 @@ func applyUpdates(d *capsule.Capsule, us []*capsule.Update) error { } return nil } + +// ImageFromBuildID returns a usable image path from a buildID +// The trouble is when running in Docker, then you cannot (for some reason...) +// reference a local image by both repository and digest, but only digest if +// it is provided. +// E.g. for a local image 'test', test:tag@sha256:abc... does not work, but +// sha256:abc... does. +// For non-local images, just giving the digest does not work, but you need both +// repository and digest. +func (s *Service) ImageFromBuild(ctx context.Context, build *capsule.Build) (string, error) { + if s.cfg.Cluster.Type == config.ClusterTypeKubernetes { + // In kubernetes we never deploy images local to the node + return build.GetBuildId(), nil + } + + if build.GetDigest() == "" { + return build.GetBuildId(), nil + } + + if s.dockerClient == nil { + return "", errors.New("docker client was nil, expected it to be initialized") + } + + // TODO Find a more efficient way than listing all images and checking for a digest + images, err := s.dockerClient.ImageList(ctx, types.ImageListOptions{}) + if err != nil { + return "", err + } + + repoTag := build.GetRepository() + ":" + build.GetTag() + repoTag = strings.TrimPrefix(repoTag, "docker.io/library/") + repoTag = strings.TrimPrefix(repoTag, "index.docker.io/library/") + found := false + for _, img := range images { + if img.ID == build.GetDigest() { + found = true + if slices.Contains(img.RepoTags, repoTag) { + return repoTag, nil + } + } + } + if found { + return build.GetDigest(), nil + } + + return build.GetBuildId(), nil +} 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/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()) +} From 7c2d7231d92852cd29b2fc7118a7e430330ef4cc Mon Sep 17 00:00:00 2001 From: Matias Frank Jensen Date: Wed, 20 Sep 2023 15:48:56 +0200 Subject: [PATCH 2/3] removed debug printing --- internal/repository/capsule/mongo/get_build.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/repository/capsule/mongo/get_build.go b/internal/repository/capsule/mongo/get_build.go index 67c3decd8..496ae9c09 100644 --- a/internal/repository/capsule/mongo/get_build.go +++ b/internal/repository/capsule/mongo/get_build.go @@ -2,7 +2,6 @@ package mongo import ( "context" - "fmt" "github.com/rigdev/rig-go-api/api/v1/capsule" "github.com/rigdev/rig/internal/repository/capsule/mongo/schema" @@ -19,7 +18,6 @@ func (m *MongoRepository) GetBuild(ctx context.Context, capsuleID string, buildI } var b schema.Build - fmt.Println("get build, project", projectID, "capsule", capsuleID, "build", buildID) if err := m.BuildCol.FindOne(ctx, bson.M{ "project_id": projectID, "capsule_id": capsuleID, From 271f4c57e87684288ad1a1af7510db40287b8e80 Mon Sep 17 00:00:00 2001 From: Matias Frank Jensen Date: Wed, 20 Sep 2023 16:19:27 +0200 Subject: [PATCH 3/3] fix --- internal/client/docker/client.go | 11 +++- internal/service/capsule/rollout.go | 6 +- internal/service/capsule/service.go | 94 ++++++----------------------- pkg/utils/docker.go | 25 +++++++- 4 files changed, 54 insertions(+), 82 deletions(-) diff --git a/internal/client/docker/client.go b/internal/client/docker/client.go index eed4b0f4f..c35ced9f7 100644 --- a/internal/client/docker/client.go +++ b/internal/client/docker/client.go @@ -293,11 +293,18 @@ func (c *Client) ensureImage(ctx context.Context, image string, auth *cluster.Re image = strings.TrimPrefix(image, "docker.io/library/") image = strings.TrimPrefix(image, "index.docker.io/library/") - isLocal, err := c.isLocalImage(ctx, image) + images, err := c.dc.ImageList(ctx, types.ImageListOptions{ + Filters: filters.NewArgs(filters.KeyValuePair{ + Key: "reference", + Value: image, + }), + }) if err != nil { return err } - if isLocal { + + if len(images) > 0 { + // Image is local return nil } diff --git a/internal/service/capsule/rollout.go b/internal/service/capsule/rollout.go index c76944e9f..c58f71e00 100644 --- a/internal/service/capsule/rollout.go +++ b/internal/service/capsule/rollout.go @@ -469,11 +469,7 @@ func (j *rolloutJob) run( return err } - image, err := j.s.ImageFromBuild(ctx, b) - if err != nil { - return err - } - cfg.Spec.Image = image + cfg.Spec.Image = rc.GetBuildId() cfg.Spec.Command = rc.GetContainerSettings().GetCommand() cfg.Spec.Args = rc.GetContainerSettings().GetArgs() cfg.Spec.Replicas = int32(rc.GetReplicas()) diff --git a/internal/service/capsule/service.go b/internal/service/capsule/service.go index 5975b6e36..944732cf8 100644 --- a/internal/service/capsule/service.go +++ b/internal/service/capsule/service.go @@ -2,10 +2,7 @@ package capsule import ( "context" - "strings" - "github.com/docker/docker/api/types" - "github.com/docker/docker/client" "github.com/rigdev/rig-go-api/api/v1/capsule" "github.com/rigdev/rig-go-api/model" "github.com/rigdev/rig/internal/config" @@ -20,21 +17,19 @@ import ( "github.com/rigdev/rig/pkg/uuid" "go.uber.org/zap" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/strings/slices" ) type Service struct { - logger *zap.Logger - cr repository.Capsule - sr repository.Secret - cg cluster.Gateway - ccg cluster.ConfigGateway - csg cluster.StatusGateway - as *service_auth.Service - ps project.Service - q *Queue[Job] - cfg config.Config - dockerClient *client.Client + logger *zap.Logger + cr repository.Capsule + sr repository.Secret + cg cluster.Gateway + ccg cluster.ConfigGateway + csg cluster.StatusGateway + as *service_auth.Service + ps project.Service + q *Queue[Job] + cfg config.Config } func NewService( @@ -46,21 +41,19 @@ func NewService( as *service_auth.Service, ps project.Service, cfg config.Config, - dockerClient *client.Client, logger *zap.Logger, ) *Service { s := &Service{ - cr: cr, - sr: sr, - cg: cg, - ccg: ccg, - csg: csg, - as: as, - ps: ps, - q: NewQueue[Job](), - cfg: cfg, - dockerClient: dockerClient, - logger: logger, + cr: cr, + sr: sr, + cg: cg, + ccg: ccg, + csg: csg, + as: as, + ps: ps, + q: NewQueue[Job](), + cfg: cfg, + logger: logger, } go s.run() @@ -214,50 +207,3 @@ func applyUpdates(d *capsule.Capsule, us []*capsule.Update) error { } return nil } - -// ImageFromBuildID returns a usable image path from a buildID -// The trouble is when running in Docker, then you cannot (for some reason...) -// reference a local image by both repository and digest, but only digest if -// it is provided. -// E.g. for a local image 'test', test:tag@sha256:abc... does not work, but -// sha256:abc... does. -// For non-local images, just giving the digest does not work, but you need both -// repository and digest. -func (s *Service) ImageFromBuild(ctx context.Context, build *capsule.Build) (string, error) { - if s.cfg.Cluster.Type == config.ClusterTypeKubernetes { - // In kubernetes we never deploy images local to the node - return build.GetBuildId(), nil - } - - if build.GetDigest() == "" { - return build.GetBuildId(), nil - } - - if s.dockerClient == nil { - return "", errors.New("docker client was nil, expected it to be initialized") - } - - // TODO Find a more efficient way than listing all images and checking for a digest - images, err := s.dockerClient.ImageList(ctx, types.ImageListOptions{}) - if err != nil { - return "", err - } - - repoTag := build.GetRepository() + ":" + build.GetTag() - repoTag = strings.TrimPrefix(repoTag, "docker.io/library/") - repoTag = strings.TrimPrefix(repoTag, "index.docker.io/library/") - found := false - for _, img := range images { - if img.ID == build.GetDigest() { - found = true - if slices.Contains(img.RepoTags, repoTag) { - return repoTag, nil - } - } - } - if found { - return build.GetDigest(), nil - } - - return build.GetBuildId(), nil -} 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 }