diff --git a/README.md b/README.md index 07f24a6..fe9a85e 100644 --- a/README.md +++ b/README.md @@ -704,6 +704,7 @@ List of capabilities required for selenoid-ui compatibility: | enableVNC | boolean | enables VNC support | | name | string | name of test | | screenResolution | string | custom screen resolution | +| enableVideo | boolean | enables Video capture |
Note: you can omit browser version in your desired capabilities, make sure you set defaultVersion property in the config file. diff --git a/cmd/selenosis/main.go b/cmd/selenosis/main.go index 83c01a2..3ad3ddf 100644 --- a/cmd/selenosis/main.go +++ b/cmd/selenosis/main.go @@ -34,6 +34,7 @@ func command() *cobra.Command { service string imagePullSecretName string proxyImage string + videoImage string sessionRetryCount int limit int browserWaitTimeout time.Duration @@ -69,6 +70,7 @@ func command() *cobra.Command { ServicePort: proxyPort, ImagePullSecretName: imagePullSecretName, ProxyImage: proxyImage, + VideoImage: videoImage, }) if err != nil { @@ -146,6 +148,7 @@ func command() *cobra.Command { cmd.Flags().DurationVar(&shutdownTimeout, "graceful-shutdown-timeout", 30*time.Second, "time in seconds gracefull shutdown timeout") cmd.Flags().StringVar(&imagePullSecretName, "image-pull-secret-name", "", "secret name to private registry") cmd.Flags().StringVar(&proxyImage, "proxy-image", "alcounit/seleniferous:latest", "in case you use private registry replace with image from private registry") + cmd.Flags().StringVar(&videoImage, "video-image", "selenoid/video-recorder:latest-release", "the image to use for video recording when it's requested") cmd.Flags().SortFlags = false return cmd diff --git a/config/config_test.go b/config/config_test.go index d385e40..231b6a6 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -77,18 +77,18 @@ func TestConfigFile(t *testing.T) { tests := map[string]struct { data string - err error + err string }{ "verify config file not exist": { data: empty, - err: errors.New("failed to read config: read error: open : The system cannot find the file specified."), + err: "failed to read config: read error: open", }, } for name, test := range tests { t.Logf("TC: %s", name) _, err := NewBrowsersConfig(test.data) - assert.Equal(t, test.err, err) + assert.Contains(t, err.Error(), test.err) } } diff --git a/go.sum b/go.sum index dd3ad9d..1de30ff 100644 --- a/go.sum +++ b/go.sum @@ -86,7 +86,6 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -261,7 +260,6 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -366,7 +364,6 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -458,7 +455,6 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/handlers.go b/handlers.go index 953d4f2..b95d12d 100644 --- a/handlers.go +++ b/handlers.go @@ -164,6 +164,9 @@ func (app *App) HandleSession(w http.ResponseWriter, r *http.Request) { default: } if err != nil { + if strings.HasSuffix(err.Error(), ": no such host") { + continue + } logger.WithField("time_elapsed", tools.TimeElapsed(start)).Errorf("session failed: %v", err) tools.JSONError(w, "New session attempts retry count exceeded", http.StatusInternalServerError) cancel() @@ -225,7 +228,7 @@ func (app *App) HandleProxy(w http.ResponseWriter, r *http.Request) { }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { logger.Errorf("proxying session error: %v", err) - w.WriteHeader(http.StatusBadGateway) + tools.JSONError(w, fmt.Sprintf("proxying session error: %v", err), http.StatusBadGateway) }, }).ServeHTTP(w, r) diff --git a/platform/kubernetes.go b/platform/kubernetes.go index 205ec9a..ccf6ede 100644 --- a/platform/kubernetes.go +++ b/platform/kubernetes.go @@ -30,21 +30,24 @@ var ( label = "selenosis.app.type" quotaName = "selenosis-pod-limit" browserPorts = struct { - selenium, vnc intstr.IntOrString + selenium, vnc, video intstr.IntOrString }{ selenium: intstr.FromString("4444"), vnc: intstr.FromString("5900"), + video: intstr.FromString("6099"), } defaultsAnnotations = struct { - testName, browserName, browserVersion, screenResolution, enableVNC, timeZone string + testName, browserName, browserVersion, screenResolution, enableVNC, enableVideo, videoName, timeZone string }{ testName: "testName", browserName: "browserName", browserVersion: "browserVersion", screenResolution: "SCREEN_RESOLUTION", enableVNC: "ENABLE_VNC", + enableVideo: "ENABLE_VIDEO", timeZone: "TZ", + videoName: "FILE_NAME", } defaultLabels = struct { serviceType, appType, session string @@ -62,6 +65,7 @@ type ClientConfig struct { ServicePort string ImagePullSecretName string ProxyImage string + VideoImage string ReadinessTimeout time.Duration IdleTimeout time.Duration } @@ -96,6 +100,7 @@ func NewClient(c ClientConfig) (Platform, error) { svcPort: intstr.FromString(c.ServicePort), imagePullSecretName: c.ImagePullSecretName, proxyImage: c.ProxyImage, + videoImage: c.VideoImage, readinessTimeout: c.ReadinessTimeout, idleTimeout: c.IdleTimeout, } @@ -297,6 +302,7 @@ type service struct { svcPort intstr.IntOrString imagePullSecretName string proxyImage string + videoImage string readinessTimeout time.Duration idleTimeout time.Duration clientset kubernetes.Interface @@ -308,6 +314,7 @@ func (cl *service) Create(layout ServiceSpec) (Service, error) { defaultsAnnotations.browserName: layout.Template.BrowserName, defaultsAnnotations.browserVersion: layout.Template.BrowserVersion, defaultsAnnotations.testName: layout.RequestedCapabilities.TestName, + defaultsAnnotations.videoName: layout.RequestedCapabilities.VideoName, } labels := map[string]string{ @@ -356,6 +363,33 @@ func (cl *service) Create(layout ServiceSpec) (Service, error) { } } + i, b = envVar(defaultsAnnotations.enableVideo) + if layout.RequestedCapabilities.Video { + video := fmt.Sprintf("%v", layout.RequestedCapabilities.Video) + if !b { + layout.Template.Spec.EnvVars = append(layout.Template.Spec.EnvVars, apiv1.EnvVar{Name: defaultsAnnotations.enableVideo, Value: video}) + } else { + layout.Template.Spec.EnvVars[i] = apiv1.EnvVar{Name: defaultsAnnotations.enableVideo, Value: video} + } + layout.Template.Spec.EnvVars = append(layout.Template.Spec.EnvVars, apiv1.EnvVar{Name: "BROWSER_CONTAINER_NAME", Value: "localhost"}) + i, b = envVar(defaultsAnnotations.videoName) + videoName := fmt.Sprintf("%v", layout.RequestedCapabilities.VideoName) + if videoName == "" { + videoName = fmt.Sprintf("%v.mp4", layout.SessionID) + } + if !b { + layout.Template.Spec.EnvVars = append(layout.Template.Spec.EnvVars, apiv1.EnvVar{Name: defaultsAnnotations.videoName, Value: videoName}) + } else { + layout.Template.Spec.EnvVars[i] = apiv1.EnvVar{Name: defaultsAnnotations.videoName, Value: video} + } + annontations[defaultsAnnotations.enableVideo] = video + annontations[defaultsAnnotations.videoName] = videoName + } else { + if b { + annontations[defaultsAnnotations.enableVideo] = layout.Template.Spec.EnvVars[i].Value + } + } + i, b = envVar(defaultsAnnotations.timeZone) if layout.RequestedCapabilities.TimeZone != "" { if !b { @@ -386,50 +420,7 @@ func (cl *service) Create(layout ServiceSpec) (Service, error) { layout.Template.Meta.Annotations["capabilities"] = string(caps) } - pod := &apiv1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: layout.SessionID, - Labels: layout.Template.Meta.Labels, - Annotations: layout.Template.Meta.Annotations, - }, - Spec: apiv1.PodSpec{ - Hostname: layout.SessionID, - Subdomain: cl.svc, - Containers: []apiv1.Container{ - { - Name: "browser", - Image: layout.Template.Image, - SecurityContext: &apiv1.SecurityContext{ - Privileged: layout.Template.Privileged, - Capabilities: getCapabilities(layout.Template.Capabilities), - }, - Env: layout.Template.Spec.EnvVars, - Ports: getBrowserPorts(), - Resources: layout.Template.Spec.Resources, - VolumeMounts: getVolumeMounts(layout.Template.Spec.VolumeMounts), - ImagePullPolicy: apiv1.PullIfNotPresent, - }, - { - Name: "seleniferous", - Image: cl.proxyImage, - Ports: getSidecarPorts(cl.svcPort), - Command: []string{ - "/seleniferous", "--listhen-port", cl.svcPort.StrVal, "--proxy-default-path", path.Join(layout.Template.Path, "session"), "--idle-timeout", cl.idleTimeout.String(), "--namespace", cl.ns, - }, - ImagePullPolicy: apiv1.PullIfNotPresent, - }, - }, - Volumes: getVolumes(layout.Template.Volumes), - NodeSelector: layout.Template.Spec.NodeSelector, - HostAliases: layout.Template.Spec.HostAliases, - RestartPolicy: apiv1.RestartPolicyNever, - Affinity: &layout.Template.Spec.Affinity, - DNSConfig: &layout.Template.Spec.DNSConfig, - Tolerations: layout.Template.Spec.Tolerations, - ImagePullSecrets: getImagePullSecretList(cl.imagePullSecretName), - SecurityContext: getSecurityContext(layout.Template.RunAs), - }, - } + pod := cl.BuildPod(layout) context := context.Background() pod, err := cl.clientset.CoreV1().Pods(cl.ns).Create(context, pod, metav1.CreateOptions{}) @@ -531,6 +522,75 @@ func (cl *service) Logs(ctx context.Context, name string) (io.ReadCloser, error) return req.Stream(ctx) } +func (cl *service) BuildPod(layout ServiceSpec) *apiv1.Pod { + pod := &apiv1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: layout.SessionID, + Labels: layout.Template.Meta.Labels, + Annotations: layout.Template.Meta.Annotations, + }, + Spec: apiv1.PodSpec{ + Hostname: layout.SessionID, + Subdomain: cl.svc, + Containers: []apiv1.Container{ + { + Name: "browser", + Image: layout.Template.Image, + SecurityContext: &apiv1.SecurityContext{ + Privileged: layout.Template.Privileged, + Capabilities: getCapabilities(layout.Template.Capabilities), + }, + Env: layout.Template.Spec.EnvVars, + Ports: getBrowserPorts(), + Resources: layout.Template.Spec.Resources, + VolumeMounts: getVolumeMounts(layout.Template.Spec.VolumeMounts), + ImagePullPolicy: apiv1.PullIfNotPresent, + }, + { + Name: "seleniferous", + Image: cl.proxyImage, + Ports: getSidecarPorts(cl.svcPort), + Command: []string{ + "/seleniferous", "--listhen-port", cl.svcPort.StrVal, "--proxy-default-path", path.Join(layout.Template.Path, "session"), "--idle-timeout", cl.idleTimeout.String(), "--namespace", cl.ns, + }, + ImagePullPolicy: apiv1.PullIfNotPresent, + }, + }, + Volumes: getVolumes(layout.Template.Volumes), + NodeSelector: layout.Template.Spec.NodeSelector, + HostAliases: layout.Template.Spec.HostAliases, + RestartPolicy: apiv1.RestartPolicyNever, + Affinity: &layout.Template.Spec.Affinity, + DNSConfig: &layout.Template.Spec.DNSConfig, + Tolerations: layout.Template.Spec.Tolerations, + ImagePullSecrets: getImagePullSecretList(cl.imagePullSecretName), + SecurityContext: getSecurityContext(layout.Template.RunAs), + }, + } + + if layout.RequestedCapabilities.Video { + videoContainer := apiv1.Container{ + Name: "video", + Image: cl.videoImage, + Ports: getVideoPorts(), + Command: []string{}, + Env: layout.Template.Spec.EnvVars, + VolumeMounts: getVolumeMounts(layout.Template.Spec.VolumeMounts), + ImagePullPolicy: apiv1.PullIfNotPresent, + } + lifecycle := &apiv1.Lifecycle{ + PreStop: &apiv1.Handler{ + Exec: &apiv1.ExecAction{ + Command: []string{"sh", "-c", "sleep 5"}, + }, + }, + } + pod.Spec.Containers[0].Lifecycle = lifecycle + pod.Spec.Containers = append(pod.Spec.Containers, videoContainer) + } + return pod +} + type quota struct { ns string clientset kubernetes.Interface @@ -618,6 +678,7 @@ func getBrowserPorts() []apiv1.ContainerPort { fn("vnc", browserPorts.vnc.IntValue()) fn("selenium", browserPorts.selenium.IntValue()) + fn("video", browserPorts.video.IntValue()) return port } @@ -631,6 +692,11 @@ func getSidecarPorts(p intstr.IntOrString) []apiv1.ContainerPort { return port } +func getVideoPorts() []apiv1.ContainerPort { + port := []apiv1.ContainerPort{} + return port +} + func getImagePullSecretList(secret string) []apiv1.LocalObjectReference { refList := make([]apiv1.LocalObjectReference, 0) if secret != "" { diff --git a/platform/kubernetes_test.go b/platform/kubernetes_test.go index cc5c0c6..3b6e809 100644 --- a/platform/kubernetes_test.go +++ b/platform/kubernetes_test.go @@ -19,6 +19,76 @@ import ( "k8s.io/client-go/kubernetes/fake" ) +func TestPodRequestedWithVideo(t *testing.T) { + t.Logf("TC: %s", "Verify pod spec containers includes a video container if video was requested as a capability") + params := struct{ ns string; layout ServiceSpec; videoImage string; containerName string }{ + ns: "selenosis", + videoImage: "selenoid/video-recorder", + containerName: "video", + layout: ServiceSpec{ + SessionID: "chrome-85-0-de44c3c4-1a35-412b-b526-f5da802144911", + RequestedCapabilities: selenium.Capabilities{ + VNC: true, + Video: true, + }, + Template: BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "85.0", + Image: "selenoid/vnc:chrome_85.0", + Path: "/", + }, + }, + } + + mock := fake.NewSimpleClientset() + + service := &service{ + ns: params.ns, + clientset: mock, + videoImage: params.videoImage, + } + + pod := service.BuildPod(params.layout) + + assert.Equal(t, params.containerName, pod.Spec.Containers[2].Name) + assert.Equal(t, params.videoImage, pod.Spec.Containers[2].Image) +} + +func TestPodWithoutVideo(t *testing.T) { + params := struct { ns string; layout ServiceSpec } { + layout: ServiceSpec{ + SessionID: "chrome-85-0-de44c3c4-1a35-412b-b526-f5da802144911", + RequestedCapabilities: selenium.Capabilities{ + VNC: true, + }, + Template: BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "85.0", + Image: "selenoid/vnc:chrome_85.0", + Path: "/", + }, + }, + } + + t.Logf("TC: %s", "Verify that if video is not requested, the pod spec only includes 2 containers") + + mock := fake.NewSimpleClientset() + + service := &service{ + ns: params.ns, + clientset: mock, + } + + pod := service.BuildPod(params.layout) + + assert.Equal(t, 2, len(pod.Spec.Containers)) + + t.Logf("TC: %s", "Verify that if video is not requested, the video container doesn't get the pre-stop lifecycle hook") + + var expected *apiv1.Lifecycle = nil + assert.Equal(t, pod.Spec.Containers[0].Lifecycle, expected) +} + func TestErrorsOnServiceCreate(t *testing.T) { tests := map[string]struct { ns string diff --git a/selenium/selenium.go b/selenium/selenium.go index 4a2eef1..6d7ffb5 100644 --- a/selenium/selenium.go +++ b/selenium/selenium.go @@ -1,5 +1,7 @@ package selenium +import "github.com/imdario/mergo" + //Capabilities ... type Capabilities struct { BrowserName string `json:"browserName,omitempty"` @@ -28,6 +30,7 @@ type Capabilities struct { DNSServers []string `json:"dnsServers,omitempty"` Labels map[string]string `json:"labels,omitempty"` SessionTimeout string `json:"sessionTimeout,omitempty"` + ExtensionCapabilities *Capabilities `json:"selenoid:options,onitemempty"` } //ValidateCapabilities ... @@ -39,6 +42,10 @@ func (c *Capabilities) ValidateCapabilities() { if c.WC3PlatformName != "" { c.Platform = c.WC3PlatformName } + + if c.ExtensionCapabilities != nil { + mergo.Merge(c, *c.ExtensionCapabilities, mergo.WithOverride) //We probably need to handle returned error + } } //GetBrowserName ...