From 7a7b2d71147ec4c44cbdb71d31d466875b409d61 Mon Sep 17 00:00:00 2001 From: sebv Date: Fri, 29 Sep 2023 16:43:07 +0800 Subject: [PATCH 1/2] added imagerunner service --- api/saucectl.schema.json | 72 +++++++++++++++++++ api/v1alpha/framework/imagerunner.schema.json | 72 +++++++++++++++++++ internal/imagerunner/config.go | 46 ++++++++++++ internal/imagerunner/config_test.go | 71 ++++++++++++++++++ internal/imagerunner/imagerunner.go | 9 +++ internal/imagerunner/utils.go | 17 +++++ internal/msg/errormsg.go | 6 ++ internal/saucecloud/imagerunner.go | 40 ++++++++++- 8 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 internal/imagerunner/utils.go diff --git a/api/saucectl.schema.json b/api/saucectl.schema.json index 180c682a8..71c74cc01 100644 --- a/api/saucectl.schema.json +++ b/api/saucectl.schema.json @@ -2763,12 +2763,84 @@ "metadata": { "description": "Supply additional metadata to your runner.", "type": "object" + }, + "services": { + "description": "List of services to run with the suite.", + "type": "array", + "items": { + "$ref": "#/allOf/9/then/definitions/service" + } } }, "required": [ "name", "workload" ] + }, + "service": { + "description": "The set of properties providing details about how to run the service container.", + "type": "object", + "properties": { + "name": { + "description": "The name of the service.", + "type": "string" + }, + "image": { + "description": "The name of the service image.", + "type": "string" + }, + "imagePullAuth": { + "description": "Container registry credentials for accessing the service image.", + "type": "object", + "properties": { + "user": { + "description": "The username.", + "type": "string" + }, + "token": { + "description": "The access token.", + "type": "string" + } + } + }, + "entrypoint": { + "description": "The command line arguments to launch the service image with.", + "type": "string" + }, + "files": { + "description": "List of files that you'd like saucectl to upload and mount within the service container.", + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "description": "Path to the local file.", + "type": "string" + }, + "dst": { + "description": "Path within the container that the file should be mounted at.", + "type": "string" + } + } + } + }, + "env": { + "description": "Set one or more environment variables for the service.", + "type": "object" + }, + "resourceProfile": { + "description": "Sets the CPU/memory limits of the service container. Format is . Default to c1m1.", + "enum": [ + "", + "c1m1", + "c2m2", + "c3m3" + ] + } + }, + "required": [ + "name" + ] } }, "properties": { diff --git a/api/v1alpha/framework/imagerunner.schema.json b/api/v1alpha/framework/imagerunner.schema.json index b109224c5..b56af2466 100644 --- a/api/v1alpha/framework/imagerunner.schema.json +++ b/api/v1alpha/framework/imagerunner.schema.json @@ -119,12 +119,84 @@ "metadata": { "description": "Supply additional metadata to your runner.", "type": "object" + }, + "services": { + "description": "List of services to run with the suite.", + "type": "array", + "items": { + "$ref": "#/definitions/service" + } } }, "required": [ "name", "workload" ] + }, + "service": { + "description": "The set of properties providing details about how to run the service container.", + "type": "object", + "properties": { + "name": { + "description": "The name of the service.", + "type": "string" + }, + "image": { + "description": "The name of the service image.", + "type": "string" + }, + "imagePullAuth": { + "description": "Container registry credentials for accessing the service image.", + "type": "object", + "properties": { + "user": { + "description": "The username.", + "type": "string" + }, + "token": { + "description": "The access token.", + "type": "string" + } + } + }, + "entrypoint": { + "description": "The command line arguments to launch the service image with.", + "type": "string" + }, + "files": { + "description": "List of files that you'd like saucectl to upload and mount within the service container.", + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "description": "Path to the local file.", + "type": "string" + }, + "dst": { + "description": "Path within the container that the file should be mounted at.", + "type": "string" + } + } + } + }, + "env": { + "description": "Set one or more environment variables for the service.", + "type": "object" + }, + "resourceProfile": { + "description": "Sets the CPU/memory limits of the service container. Format is . Default to c1m1.", + "enum": [ + "", + "c1m1", + "c2m2", + "c3m3" + ] + } + }, + "required": [ + "name" + ] } }, "properties": { diff --git a/internal/imagerunner/config.go b/internal/imagerunner/config.go index cea0323b8..854ccc5cd 100644 --- a/internal/imagerunner/config.go +++ b/internal/imagerunner/config.go @@ -52,6 +52,17 @@ type Suite struct { Workload string `yaml:"workload,omitempty" json:"workload,omitempty"` ResourceProfile string `yaml:"resourceProfile,omitempty" json:"resourceProfile,omitempty"` Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` + Services []SuiteService `yaml:"services,omitempty" json:"services,omitempty"` +} + +type SuiteService struct { + Name string `yaml:"name,omitempty" json:"name"` + Image string `yaml:"image,omitempty" json:"image"` + ImagePullAuth ImagePullAuth `yaml:"imagePullAuth,omitempty" json:"imagePullAuth"` + EntryPoint string `yaml:"entrypoint,omitempty" json:"entrypoint"` + Files []File `yaml:"files,omitempty" json:"files"` + Env map[string]string `yaml:"env,omitempty" json:"env"` + ResourceProfile string `yaml:"resourceProfile,omitempty" json:"resourceProfile,omitempty"` } type ImagePullAuth struct { @@ -130,6 +141,23 @@ func SetDefaults(p *Project) { suite.Env[k] = v } } + + for j := range suite.Services { + service := &suite.Services[j] + if service.ResourceProfile == "" { + service.ResourceProfile = "c1m1" + } + suite.Metadata[fmt.Sprintf("resourceProfile-%s", GetCanonicalServiceName(service.Name))] = suite.ResourceProfile + if service.Env == nil { + service.Env = make(map[string]string) + } + // Precedence: --env flag > root-level env vars > default env vars > service env vars. + for _, env := range []map[string]string{p.Defaults.Env, p.Env, p.EnvFlag} { + for k, v := range env { + service.Env[k] = v + } + } + } } } @@ -159,6 +187,24 @@ func Validate(p Project) error { if suite.ResourceProfile != "" && !ValidResourceProfilesValidator.MatchString(suite.ResourceProfile) { return fmt.Errorf(msg.InvalidResourceProfile, suite.Name, ValidResourceProfilesFormat) } + if err := ValidateServices(suite.Services, suite.Name); err != nil { + return err + } + } + return nil +} + +func ValidateServices(service []SuiteService, suiteName string) error { + for _, service := range service { + if service.Name == "" { + return fmt.Errorf(msg.MissingServiceName, suiteName) + } + if service.Image == "" { + return fmt.Errorf(msg.MissingServiceImage, service.Name, suiteName) + } + if service.ResourceProfile != "" && !ValidResourceProfilesValidator.MatchString(service.ResourceProfile) { + return fmt.Errorf(msg.InvalidServiceResourceProfile, service.Name, suiteName, ValidResourceProfilesFormat) + } } return nil } diff --git a/internal/imagerunner/config_test.go b/internal/imagerunner/config_test.go index a3a996246..550be93ae 100644 --- a/internal/imagerunner/config_test.go +++ b/internal/imagerunner/config_test.go @@ -105,6 +105,77 @@ func TestValidate(t *testing.T) { }, wantErr: `invalid resourceProfile for suite: Main Suite, resourceProfile should be of format cXmX`, }, + { + name: "Invalid serviceName", + args: args{ + p: Project{ + Sauce: config.SauceConfig{ + Region: region.USWest1.String(), + }, + Suites: []Suite{ + { + Name: "Main Suite", + Image: "dummy/image", + Workload: "other", + Services: []SuiteService{ + { + Image: "dummy/image", + }, + }, + }, + }, + }, + }, + wantErr: `missing "name" for service in suite: Main Suite`, + }, + { + name: "Invalid serviceImage", + args: args{ + p: Project{ + Sauce: config.SauceConfig{ + Region: region.USWest1.String(), + }, + Suites: []Suite{ + { + Name: "Main Suite", + Image: "dummy/image", + Workload: "other", + Services: []SuiteService{ + { + Name: "myservice", + }, + }, + }, + }, + }, + }, + wantErr: `missing "image" for service: myservice in suite: Main Suite`, + }, + { + name: "Invalid serviceResourceProfile", + args: args{ + p: Project{ + Sauce: config.SauceConfig{ + Region: region.USWest1.String(), + }, + Suites: []Suite{ + { + Name: "Main Suite", + Image: "dummy/image", + Workload: "other", + Services: []SuiteService{ + { + Name: "myservice", + Image: "dummy/image", + ResourceProfile: "test", + }, + }, + }, + }, + }, + }, + wantErr: `invalid resourceProfile for service: myservice in suite: Main Suite, resourceProfile should be of format cXmX`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/imagerunner/imagerunner.go b/internal/imagerunner/imagerunner.go index 11929ec11..479e591f8 100644 --- a/internal/imagerunner/imagerunner.go +++ b/internal/imagerunner/imagerunner.go @@ -40,6 +40,15 @@ type RunnerSpec struct { Artifacts []string `json:"artifacts,omitempty"` WorkloadType string `json:"workload_type,omitempty"` Tunnel *Tunnel `json:"tunnel,omitempty"` + Services []Service `json:"services,omitempty"` +} + +type Service struct { + Name string `json:"name,omitempty"` + Container Container `json:"container,omitempty"` + EntryPoint string `json:"entrypoint,omitempty"` + Env []EnvItem `json:"env,omitempty"` + Files []FileData `json:"files,omitempty"` } type Tunnel struct { diff --git a/internal/imagerunner/utils.go b/internal/imagerunner/utils.go new file mode 100644 index 000000000..cc9442076 --- /dev/null +++ b/internal/imagerunner/utils.go @@ -0,0 +1,17 @@ +package imagerunner + +import ( + "regexp" + "strings" +) + +func GetCanonicalServiceName(serviceName string) string { + if serviceName == "" { + return "" + } + // make sure the service name has only lowercase letters, numbers, and underscores + canonicalName := strings.ToLower(regexp.MustCompile("[^a-z0-9-]").ReplaceAllString(serviceName, "-")) + // remove successives dashes + canonicalName = regexp.MustCompile("-+").ReplaceAllString(canonicalName, "-") + return canonicalName +} diff --git a/internal/msg/errormsg.go b/internal/msg/errormsg.go index e21cdcc3e..3bf4b549e 100644 --- a/internal/msg/errormsg.go +++ b/internal/msg/errormsg.go @@ -164,6 +164,12 @@ const ( ImageRunnerMaxConcurrency = "Maximum concurrency for imagerunner is 5. Replacing %d with 5." // InvalidResourceProfile indicates the resourceProfile is not valid InvalidResourceProfile = "invalid resourceProfile for suite: %s, resourceProfile should be of format %v" + // MissingServiceName indicates no service name provided + MissingServiceName = `missing "name" for service in suite: %s` + // MissingServiceImage indicates no docker image provided + MissingServiceImage = `missing "image" for service: %s in suite: %s` + // InvalidServiceResourceProfile indicates the service resourceProfile is not valid + InvalidServiceResourceProfile = "invalid resourceProfile for service: %s in suite: %s, resourceProfile should be of format %v" ) // testcafe config settings diff --git a/internal/saucecloud/imagerunner.go b/internal/saucecloud/imagerunner.go index b5c1efa60..be02a459e 100644 --- a/internal/saucecloud/imagerunner.go +++ b/internal/saucecloud/imagerunner.go @@ -168,9 +168,37 @@ func (r *ImgRunner) runSuites(suites chan imagerunner.Suite, results chan<- exec } } +func (r *ImgRunner) buildService(serviceIn imagerunner.SuiteService, suiteName string) (imagerunner.Service, error) { + var auth *imagerunner.Auth + if serviceIn.ImagePullAuth.User != "" && serviceIn.ImagePullAuth.Token != "" { + auth = &imagerunner.Auth{ + User: serviceIn.ImagePullAuth.User, + Token: serviceIn.ImagePullAuth.Token, + } + } + + files, err := mapFiles(serviceIn.Files) + if err != nil { + log.Err(err).Str("suite", suiteName).Str("service", serviceIn.Name).Msg("Unable to read source files") + return imagerunner.Service{}, err + } + + serviceOut := imagerunner.Service{ + Name: serviceIn.Name, + Container: imagerunner.Container{ + Name: serviceIn.Image, + Auth: auth, + }, + + EntryPoint: serviceIn.EntryPoint, + Env: mapEnv(serviceIn.Env), + Files: files, + } + return serviceOut, nil +} + func (r *ImgRunner) runSuite(suite imagerunner.Suite) (imagerunner.Runner, error) { var run imagerunner.Runner - files, err := mapFiles(suite.Files) if err != nil { log.Err(err).Str("suite", suite.Name).Msg("Unable to read source files") @@ -193,6 +221,15 @@ func (r *ImgRunner) runSuite(suite imagerunner.Suite) (imagerunner.Runner, error Token: suite.ImagePullAuth.Token, } } + + services := make([]imagerunner.Service, len(suite.Services)) + for i, s := range suite.Services { + services[i], err = r.buildService(s, suite.Name) + if err != nil { + return run, err + } + } + runner, err := r.RunnerService.TriggerRun(ctx, imagerunner.RunnerSpec{ Container: imagerunner.Container{ Name: suite.Image, @@ -206,6 +243,7 @@ func (r *ImgRunner) runSuite(suite imagerunner.Suite) (imagerunner.Runner, error Metadata: suite.Metadata, WorkloadType: suite.Workload, Tunnel: r.getTunnel(), + Services: services, }) if errors.Is(err, context.DeadlineExceeded) && ctx.Err() != nil { From 342361a20c9c5c7c6e1ea4d6fc756f8d28a9b7cb Mon Sep 17 00:00:00 2001 From: sebv Date: Mon, 2 Oct 2023 09:04:18 +0800 Subject: [PATCH 2/2] hacking --- .sauce/imagerunner.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.sauce/imagerunner.yml b/.sauce/imagerunner.yml index 410f56651..a8c88a85a 100644 --- a/.sauce/imagerunner.yml +++ b/.sauce/imagerunner.yml @@ -15,3 +15,15 @@ suites: dst: "hello.txt" env: # Arbitrary Key-Value pairs set as environment variables inside the container. MY_FOO: bar + services: + - name: "service1" + image: "busybox:1.35.0" + imagePullAuth: # Credentials used to pull the container image + user: $DOCKER_USERNAME + token: $DOCKER_PASSWORD + entrypoint: "cat hello.txt" # What command to start the container with + files: # Which files should be uploaded and mounted within the container + - src: "tests/e2e/imagerunner/hello.txt" + dst: "hello.txt" + env: # Arbitrary Key-Value pairs set as environment variables inside the container. + MY_FOO: bar \ No newline at end of file