sg: cloud handle deploying a version (#62447)

* wait for build

* make not found error more descriptive

* bazel

* fixup

* fix list instances

* fix deployment key

* fixup

* handle deploying a version

* fix docker image filtering from registry

- make filtering more efficient by filtering while we're iterating
- match docker image format

* merge fixup

* fix formatting
This commit is contained in:
William Bezuidenhout 2024-05-07 11:01:33 +02:00 committed by GitHub
parent ce854610d5
commit a659fbbb08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 259 additions and 135 deletions

View File

@ -121,7 +121,7 @@ func (c *Client) GetBuildByNumber(ctx context.Context, pipeline string, number s
b, _, err := c.bk.Builds.Get(BuildkiteOrg, pipeline, number, nil)
if err != nil {
if strings.Contains(err.Error(), "404 Not Found") {
return nil, errors.New("no build found")
return nil, errors.Newf("build %s not found on pipeline %q", number, pipeline)
}
return nil, err
}

View File

@ -4,6 +4,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "cloud",
srcs = [
"artifact_registry.go",
"client.go",
"common.go",
"delete_command.go",

View File

@ -0,0 +1,159 @@
package cloud
import (
"context"
"fmt"
"github.com/grafana/regexp"
artifactregistry "cloud.google.com/go/artifactregistry/apiv1"
artifactregistrypb "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb"
"google.golang.org/api/iterator"
)
const DefaultArtifactRegistryPageSize = 10000
// DockerImage is a type alias around the Google artifact registry Docker Image type
type DockerImage artifactregistrypb.DockerImage
type DockerImageFilterOpt func(image *DockerImage) bool
// FilterTagByRegex filters the Docker Image tags by the given regular expression
func FilterTagByRegex(regex *regexp.Regexp) DockerImageFilterOpt {
return func(image *DockerImage) bool {
for _, tag := range image.Tags {
if regex.MatchString(tag) {
return true
}
}
return false
}
}
// FilterNameByRegex filters the Docker Image names by the given regular expression
func FilterNameByRegex(regex *regexp.Regexp) DockerImageFilterOpt {
return func(image *DockerImage) bool {
return regex.MatchString(image.Name)
}
}
// FilterByName filters the Docker Images by name
func FilterByName(name string) DockerImageFilterOpt {
// the name format is projects/*/locations/*/repositories/*/dockerImages/<name>@sha256:digest
regexp := regexp.MustCompile(fmt.Sprintf("/%s@", name))
return FilterNameByRegex(regexp)
}
// FilterByTag filters the Docker Images by tag
func FilterByTag(tag string) DockerImageFilterOpt {
return func(image *DockerImage) bool {
for _, imageTag := range image.Tags {
if imageTag == tag {
return true
}
}
return false
}
}
// ArtifactRegistry is wrapper around the Google Artifact Registry client
type ArtifactRegistry struct {
Project string
Location string
RepositoryName string
PageSize int32
client *artifactregistry.Client
}
// NewDefaultCloudEphemeralRegistry creates an Artifact Registry with all the details set to point to the cloud
// ephemeral registry
func NewDefaultCloudEphemeralRegistry(ctx context.Context) (*ArtifactRegistry, error) {
return NewArtifactRegistry(ctx, "sourcegraph-ci", "us-central1", "cloud-ephemeral")
}
func NewArtifactRegistry(ctx context.Context, project, location, repositoryName string) (*ArtifactRegistry, error) {
client, err := artifactregistry.NewClient(ctx)
if err != nil {
return nil, err
}
return &ArtifactRegistry{
Project: project,
Location: location,
RepositoryName: repositoryName,
PageSize: DefaultArtifactRegistryPageSize,
client: client,
}, nil
}
// Parent returns the parent string in the format of projects/<project>/locations/<location>/repositories/<repositoryName>
func (ar *ArtifactRegistry) Parent() string {
return fmt.Sprintf("projects/%s/locations/%s/repositories/%s", ar.Project, ar.Location, ar.RepositoryName)
}
func (ar *ArtifactRegistry) String() string {
return ar.Parent()
}
// ListVersions lists all Docker Images present in the Artifact Registry and optionally filter the images as the images are iterated upon.
func (ar *ArtifactRegistry) ListDockerImages(ctx context.Context, filterOpts ...DockerImageFilterOpt) ([]*DockerImage, error) {
req := &artifactregistrypb.ListDockerImagesRequest{
Parent: ar.Parent(),
PageSize: ar.PageSize,
OrderBy: "upload_time",
}
images := []*DockerImage{}
resp := ar.client.ListDockerImages(ctx, req)
for {
image, err := resp.Next()
if err != nil {
if err == iterator.Done {
break
}
return nil, err
}
dockerImage := (*DockerImage)(image)
var shouldAdd bool
for _, filterOpt := range filterOpts {
shouldAdd = filterOpt(dockerImage)
if !shouldAdd {
break
}
}
if shouldAdd {
images = append(images, dockerImage)
}
}
return images, nil
}
// FindDockerImageExact finds Docker Images that match the name and tag exactly in the Artifact Registry.
func (ar *ArtifactRegistry) FindDockerImageExact(ctx context.Context, name string, tag string) ([]*DockerImage, error) {
return ar.ListDockerImages(ctx, FilterByName(name), FilterByTag(tag))
}
// FindDockerImageByTagPattern finds all Docker Images that have a tag that matches the given tag pattern.
func (ar *ArtifactRegistry) FindDockerImageByTagPattern(ctx context.Context, tagPattern string) ([]*DockerImage, error) {
tagRegex := regexp.MustCompile(tagPattern)
return ar.ListDockerImages(ctx, FilterTagByRegex(tagRegex))
}
// GetDockerImage gets a Docker Image by name and digest from the Artifact Registry.
func (ar *ArtifactRegistry) GetDockerImage(ctx context.Context, name, digest string) (*DockerImage, error) {
name = fmt.Sprintf("%s/dockerImages/%s@sha256:%s", ar.Parent(), name, digest)
req := &artifactregistrypb.GetDockerImageRequest{
Name: name,
}
image, err := ar.client.GetDockerImage(ctx, req)
if err != nil {
return nil, err
}
return (*DockerImage)(image), nil
}

View File

@ -17,6 +17,8 @@ import (
"github.com/sourcegraph/sourcegraph/lib/pointers"
)
var ErrInstanceNotFound error = errors.New("instance not found")
// HeaderUserToken is the header name for the user token when communicating with the Cloud API.
const HeaderUserToken = "X-GCP-User-Token"

View File

@ -1,8 +1,8 @@
package cloud
import (
"cmp"
"context"
"strings"
"time"
"github.com/buildkite/go-buildkite/v3/buildkite"
@ -18,6 +18,8 @@ import (
"github.com/sourcegraph/sourcegraph/lib/pointers"
)
var ErrDeploymentExists error = errors.New("deployment already exists")
var DeployEphemeralCommand = cli.Command{
Name: "deploy",
Usage: "sg could deploy --branch <branch> --tag <tag>",
@ -32,14 +34,6 @@ var DeployEphemeralCommand = cli.Command{
Name: "version",
DefaultText: "deploys an ephemeral cloud Sourcegraph environment with the specified version. The version MUST exist and implies that no build will be created",
},
&cli.StringFlag{
Name: "tag",
DefaultText: "the git tag that should be deployed",
},
&cli.BoolFlag{
Name: "skip-wip-notice",
DefaultText: "skips the EXPERIMENTAL notice prompt",
},
},
}
@ -65,6 +59,42 @@ func determineVersion(build *buildkite.Build, tag string) (string, error) {
), nil
}
func createDeploymentForVersion(ctx context.Context, email, name, version string) error {
cloudClient, err := NewClient(ctx, email, APIEndpoint)
if err != nil {
return err
}
cloudEmoji := "☁️"
pending := std.Out.Pending(output.Linef(cloudEmoji, output.StylePending, "Starting deployment %q for version %q", name, version))
spec := NewDeploymentSpec(
sanitizeInstanceName(name),
version,
)
// Check if the deployment already exists
_, err = cloudClient.GetInstance(ctx, spec.Name)
if err != nil {
if !errors.Is(err, ErrInstanceNotFound) {
return errors.Wrapf(err, "failed to determine if instance %q already exists", spec.Name)
}
} else {
pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "Cannot deploy %q", err))
// Deployment exists
return ErrDeploymentExists
}
inst, err := cloudClient.CreateInstance(ctx, spec)
if err != nil {
pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "Deployment failed: %v", err))
return errors.Wrapf(err, "failed to deploy version %v", version)
}
pending.Writef("Deploy instance details: \n%s", inst.String())
pending.Complete(output.Linef(output.EmojiSuccess, output.StyleSuccess, "Deployment %q created for version %q - access at: %s", name, version, inst.URL))
return nil
}
func triggerEphemeralBuild(ctx context.Context, currRepo *repo.GitRepo) (*buildkite.Build, error) {
pending := std.Out.Pending(output.Linef("🔨", output.StylePending, "Checking if branch %q is up to date with remote", currRepo.Branch))
if isOutOfSync, err := currRepo.IsOutOfSync(ctx); err != nil {
@ -76,9 +106,9 @@ func triggerEphemeralBuild(ctx context.Context, currRepo *repo.GitRepo) (*buildk
client, err := bk.NewClient(ctx, std.Out)
if err != nil {
pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "failed to create client to trigger build"))
return nil, err
}
pending.Updatef("Starting cloud ephemeral build for %q on commit %q", currRepo.Branch, currRepo.Ref)
build, err := client.TriggerBuild(ctx, "sourcegraph", currRepo.Branch, currRepo.Ref, bk.WithEnvVar("CLOUD_EPHEMERAL", "true"))
if err != nil {
@ -90,34 +120,21 @@ func triggerEphemeralBuild(ctx context.Context, currRepo *repo.GitRepo) (*buildk
return build, nil
}
func createDeploymentForVersion(ctx context.Context, name, version string) error {
email, err := GetGCloudAccount(ctx)
func checkVersionExistsInRegistry(ctx context.Context, version string) error {
ar, err := NewDefaultCloudEphemeralRegistry(ctx)
if err != nil {
std.Out.WriteFailuref("failed to create Cloud Ephemeral registry")
return err
}
if err := validateEmail(email); err != nil {
pending := std.Out.Pending(output.Linef(CloudEmoji, output.StylePending, "Checking if version %q exists in Cloud ephemeral registry", version))
if images, err := ar.FindDockerImageExact(ctx, "gitserver", version); err != nil {
pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "failed to check if version %q exists in Cloud ephemeral registry", version))
return err
} else if len(images) == 0 {
pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "no version %q found in Cloud ephemeral registry!", version))
return errors.Newf("no image with tag %q found", version)
}
cloudClient, err := NewClient(ctx, email, APIEndpoint)
if err != nil {
return err
}
cloudEmoji := "☁️"
pending := std.Out.Pending(output.Linef(cloudEmoji, output.StylePending, "Creating deployment %q for version %q", name, version))
spec := NewDeploymentSpec(
sanitizeInstanceName(name),
version,
)
inst, err := cloudClient.CreateInstance(ctx, spec)
if err != nil {
pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "deployment failed: %v", err))
return errors.Wrapf(err, "failed to deploy version %v", version)
}
pending.Writef("Deploy instance details: \n%s", inst.String())
pending.Complete(output.Linef(output.EmojiSuccess, output.StyleSuccess, "Deployment %q created for version %q - access at: %s", name, version, inst.URL))
pending.Complete(output.Linef(output.EmojiSuccess, output.StyleSuccess, "Version %q found in Cloud ephemeral registry", version))
return nil
}
@ -145,15 +162,35 @@ func deployCloudEphemeral(ctx *cli.Context) error {
std.Out.WriteWarningf(`Your branch %q is out of sync with remote.
Please make sure you have either pushed or pulled the latest changes before trying again`, currRepo.Branch)
} else {
std.Out.WriteFailuref("Cannot start deployment as there was problem with the ephemeral build")
}
return err
return errors.Wrapf(err, "cloud ephemeral deployment failure")
}
version, err = determineVersion(build, ctx.String("tag"))
if err != nil {
return err
}
} else if err = checkVersionExistsInRegistry(ctx.Context, version); err != nil {
return err
}
email, err := GetGCloudAccount(ctx.Context)
if err != nil {
return err
}
name := cmp.Or(ctx.String("name"), currRepo.Branch)
return createDeploymentForVersion(ctx.Context, name, version)
var deploymentName string
if ctx.String("name") != "" {
deploymentName = ctx.String("name")
} else if ctx.String("version") != "" {
// if a version is given we generate a name based on the email user and the given version
// to make sure the deployment is unique
user := strings.ReplaceAll(email[0:strings.Index(email, "@")], ".", "_")
deploymentName = user[:min(12, len(user))] + "_" + version
} else {
deploymentName = currRepo.Branch
}
return createDeploymentForVersion(ctx.Context, email, deploymentName, version)
}

View File

@ -213,7 +213,7 @@ func (f *InstanceFeatures) IsEphemeralInstance() bool {
}
func (f *InstanceFeatures) SetEphemeralInstance(v bool) {
f.features["ephemeral_instance"] = strconv.FormatBool(v)
f.features["ephemeral"] = strconv.FormatBool(v)
}
func (f *InstanceFeatures) SetEphemeralLeaseTime(expiresAt time.Time) {

View File

@ -1,34 +1,30 @@
package cloud
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
artifactregistry "cloud.google.com/go/artifactregistry/apiv1"
artifactregistrypb "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb"
"github.com/grafana/regexp"
"github.com/urfave/cli/v2"
"google.golang.org/api/iterator"
"github.com/sourcegraph/sourcegraph/dev/sg/internal/std"
"github.com/sourcegraph/sourcegraph/lib/output"
)
const DefaultArtifactRegistryPageSize = 10000
var ListVersionsEphemeralCommand = cli.Command{
Name: "list-versions",
Usage: "sg could list-versions",
Description: "list ephemeral cloud instances attached to your GCP account",
Action: listTagsCloudEphemeral,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "format",
Usage: "format to print out the list of versions out - can be one json, raw or formatted",
Value: "formatted",
&cli.BoolFlag{
Name: "json",
Usage: "print the instance details in JSON",
},
&cli.BoolFlag{
Name: "raw",
Usage: "print all of the instance details",
},
&cli.IntFlag{
Name: "limit",
@ -36,87 +32,13 @@ var ListVersionsEphemeralCommand = cli.Command{
Value: 100,
},
&cli.StringFlag{
Name: "filter",
Usage: "filter versions by regex",
Name: "filter",
Usage: "filter versions by regex",
Aliases: []string{"f"},
},
},
}
// DockerImage is a type alias around the Google artifact registry Docker Image type
type DockerImage artifactregistrypb.DockerImage
// ArtifactRegistry is wrapper around the Google Artifact Registry client
type ArtifactRegistry struct {
Project string
Location string
RepositoryName string
PageSize int32
client *artifactregistry.Client
}
func NewArtifactRegistry(ctx context.Context, project, location, repositoryName string) (*ArtifactRegistry, error) {
client, err := artifactregistry.NewClient(ctx)
if err != nil {
return nil, err
}
return &ArtifactRegistry{
Project: project,
Location: location,
RepositoryName: repositoryName,
PageSize: DefaultArtifactRegistryPageSize,
client: client,
}, nil
}
// Parent returns the parent string in the format of projects/<project>/locations/<location>/repositories/<repositoryName>
func (ar *ArtifactRegistry) Parent() string {
return fmt.Sprintf("projects/%s/locations/%s/repositories/%s", ar.Project, ar.Location, ar.RepositoryName)
}
func (ar *ArtifactRegistry) String() string {
return ar.Parent()
}
// ListVersions lists all Docker Images present in the Artifact Registry.
func (ar *ArtifactRegistry) ListDockerImages(ctx context.Context) ([]*DockerImage, error) {
req := &artifactregistrypb.ListDockerImagesRequest{
Parent: ar.Parent(),
PageSize: ar.PageSize,
}
images := []*DockerImage{}
resp := ar.client.ListDockerImages(ctx, req)
for {
image, err := resp.Next()
if err != nil {
if err == iterator.Done {
break
}
return nil, err
}
images = append(images, (*DockerImage)(image))
}
return images, nil
}
// GetDockerImage gets a Docker Image by name from the Artifact Registry.
func (ar *ArtifactRegistry) GetDockerImage(ctx context.Context, name string) (*DockerImage, error) {
req := &artifactregistrypb.GetDockerImageRequest{
Name: name,
}
image, err := ar.client.GetDockerImage(ctx, req)
if err != nil {
return nil, err
}
return (*DockerImage)(image), nil
}
func listTagsCloudEphemeral(ctx *cli.Context) error {
var filterRegex *regexp.Regexp
if ctx.String("filter") != "" {
@ -127,9 +49,9 @@ func listTagsCloudEphemeral(ctx *cli.Context) error {
return err
}
pending := std.Out.Pending(output.Linef(CloudEmoji, output.StylePending, "Retrieving docker images from registry %q", ar.RepositoryName))
images, err := ar.ListDockerImages(ctx.Context)
images, err := ar.ListDockerImages(ctx.Context, FilterTagByRegex(filterRegex))
if err != nil {
pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "failed to retreive images from registry %q", ar.RepositoryName))
pending.Complete(output.Linef(output.EmojiFailure, output.StyleFailure, "Failed to retreive images from registry %q", ar.RepositoryName))
return err
}
pending.Complete(output.Linef(CloudEmoji, output.StyleSuccess, "Retrieved %d docker images from registry %q", len(images), ar.RepositoryName))
@ -143,20 +65,20 @@ func listTagsCloudEphemeral(ctx *cli.Context) error {
}
}
switch ctx.String("format") {
case "json":
switch {
case ctx.Bool("json"):
{
return json.NewEncoder(os.Stdout).Encode(imagesByTag)
}
case "raw":
case ctx.Bool("raw"):
{
count := 0
limit := ctx.Int("limit")
for tag, images := range imagesByTag {
image := images[0]
std.Out.Writef("Tag: %s\n", tag)
std.Out.Writef(" %-50s %-20s %s", "Name", "Upload time", "URI")
std.Out.Writef("- %-50s %-20s %s", image.Name, image.UploadTime.AsTime().Format(time.DateTime), image.Uri)
std.Out.Writef(`Tag : %s
Upload Time : %s
Image count : %d`, tag, image.UploadTime.AsTime().Format(time.DateTime), len(images))
count++
if limit >= 1 && count >= limit {
break
@ -171,13 +93,16 @@ func listTagsCloudEphemeral(ctx *cli.Context) error {
for tag, images := range imagesByTag {
// we use the first image to get the upload time
image := images[0]
tag := tag[:min(50, len(tag))]
if len(tag) > 50 {
tag = tag[:47] + "..."
}
std.Out.Writef("%-50s %-20s %-5d", tag, image.UploadTime.AsTime().Format(time.DateTime), len(images))
count++
if limit >= 1 && count >= limit {
break
}
}
std.Out.WriteSuggestionf("Some tags might have been truncated. To see the full tag ouput use the --raw format or filter the tags by using --filter")
}
}
return nil