diff --git a/.gitignore b/.gitignore index eae0b787..3a7bc06f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ selenoid vendor/*/ coverage.* +.DS_Store diff --git a/Dockerfile b/Dockerfile index 849a46a4..934455cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,4 @@ RUN apk add -U tzdata && rm -Rf /var/cache/apk/* COPY selenoid /usr/bin EXPOSE 4444 -ENTRYPOINT ["/usr/bin/selenoid"] - -CMD ["-listen", ":4444", "-conf", "/etc/selenoid/browsers.json", "-video-output-dir", "/opt/selenoid/video/"] +ENTRYPOINT ["/usr/bin/selenoid", "-listen", ":4444", "-conf", "/etc/selenoid/browsers.json", "-video-output-dir", "/opt/selenoid/video/"] diff --git a/Gopkg.lock b/Gopkg.lock index bef4c727..552846c0 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -10,50 +10,28 @@ [[projects]] branch = "master" name = "github.com/aandryashin/matchers" - packages = [ - ".", - "httpresp" - ] + packages = [".","httpresp"] revision = "435295ea180e58dbfa526db1ebab8c801b4c2b75" +[[projects]] + branch = "master" + name = "github.com/aerokube/util" + packages = [".","docker"] + revision = "2fa5cda33a2e16b7639e40386ca232c9f6cf9475" + [[projects]] name = "github.com/docker/distribution" - packages = [ - "digestset", - "reference" - ] + packages = ["digestset","reference"] revision = "277ed486c948042cab91ad367c379524f3b25e18" [[projects]] name = "github.com/docker/docker" - packages = [ - "api", - "api/types", - "api/types/blkiodev", - "api/types/container", - "api/types/events", - "api/types/filters", - "api/types/image", - "api/types/mount", - "api/types/network", - "api/types/registry", - "api/types/strslice", - "api/types/swarm", - "api/types/swarm/runtime", - "api/types/time", - "api/types/versions", - "api/types/volume", - "client" - ] - revision = "92309e34e42aec3a0e041403bdd3c4fc0dc20269" + packages = ["api","api/types","api/types/blkiodev","api/types/container","api/types/events","api/types/filters","api/types/image","api/types/mount","api/types/network","api/types/registry","api/types/strslice","api/types/swarm","api/types/swarm/runtime","api/types/time","api/types/versions","api/types/volume","client","pkg/stdcopy"] + revision = "635f359f8b61887faa9427d9c673be96faf992b5" [[projects]] name = "github.com/docker/go-connections" - packages = [ - "nat", - "sockets", - "tlsconfig" - ] + packages = ["nat","sockets","tlsconfig"] revision = "3ede32e2033de7505e6500d6c868c2b9ed9f169d" version = "v0.3.0" @@ -77,10 +55,7 @@ [[projects]] name = "github.com/opencontainers/image-spec" - packages = [ - "specs-go", - "specs-go/v1" - ] + packages = ["specs-go","specs-go/v1"] revision = "d60099175f88c47cd379c4738d158884749ed235" version = "v1.0.1" @@ -96,21 +71,10 @@ revision = "645ef00459ed84a119197bfb8d8205042c6df63d" version = "v0.8.0" -[[projects]] - branch = "master" - name = "github.com/samuel/go-zookeeper" - packages = ["zk"] - revision = "c4fab1ac1bec58281ad0667dc3f0907a9476ac47" - [[projects]] branch = "master" name = "golang.org/x/net" - packages = [ - "context", - "context/ctxhttp", - "proxy", - "websocket" - ] + packages = ["context","context/ctxhttp","proxy","websocket"] revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec" [[projects]] @@ -122,6 +86,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "cbe1b26304fbb2fa3bf9787d72cd50827f49dcf4ff047f435a1359e412abd4a0" + inputs-digest = "4515fd43fa0e85450a97e08a874ade4b0a2684d6925ac763ba2080a61848600a" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index f3ae1154..de34c95d 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -25,9 +25,5 @@ name = "github.com/aandryashin/matchers" [[constraint]] - name = "github.com/docker/docker" - revision = "92309e34e42aec3a0e041403bdd3c4fc0dc20269" - -[[override]] - name = "github.com/docker/distribution" - revision = "277ed486c948042cab91ad367c379524f3b25e18" + branch = "master" + name = "github.com/aerokube/util" diff --git a/README.md b/README.md index 12fce2fb..5e22e9c7 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,8 @@ Selenoid is a powerful implementation of [Selenium](http://github.com/SeleniumHQ ### One-command Installation Start browser automation in minutes by copy-pasting just **one command**: ``` -$ docker run --rm \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v ${HOME}:/root \ - -e OVERRIDE_HOME=${HOME} \ - aerokube/cm:latest-release selenoid start \ - --vnc --tmpfs 128 +$ curl -s https://aerokube.com/cm/bash | bash \ + && ./cm selenoid start --vnc --tmpfs 128 ``` **That's it!** You can now use Selenoid instead of Selenium server. Specify the following Selenium URL in tests: ``` @@ -36,10 +32,14 @@ New images are added right after official releases. You can create your custom i New **[rich user interface]((https://github.com/aerokube/selenoid-ui))** showing browser screen and Selenium session logs: ![Selenoid UI](docs/img/selenoid-ui.png) +### Video Recording +* Any browser session can be saved to [H.264](https://en.wikipedia.org/wiki/H.264/MPEG-4_AVC) video ([example](https://www.youtube.com/watch?v=maB298oO5cI)) +* An API to list, download and delete recorded video files + ### Lightweight and Lightning Fast Suitable for personal usage and in big clusters: * Consumes **10 times** less memory than Java-based Selenium server under the same load -* **Small 7 Mb binary** with no external dependencies (no need to install Java) +* **Small 6 Mb binary** with no external dependencies (no need to install Java) * **Browser consumption API** working out of the box * Ability to send browser logs to **centralized log storage** (e.g. to the [ELK-stack](https://logz.io/learn/complete-guide-elk-stack/)) * Fully **isolated** and **reproducible** environment @@ -50,7 +50,13 @@ Maintained by a growing community: * Telegram [support channel](https://t.me/aerokube) * Support by [email](mailto:support@aerokube.com) * StackOverflow [tag](https://stackoverflow.com/questions/tagged/selenoid) +* YouTube [channel](https://www.youtube.com/channel/UC9HvE3FNfTvftzpvXi9c69g) ## Complete Guide & Build Instructions Complete reference guide (including building instructions) can be found at: http://aerokube.com/selenoid/latest/ + +## Known Users + +[![JetBrains](docs/img/logo/jetbrains.png)](http://jetbrains.com/) [![Yandex](docs/img/logo/yandex.png)](https://yandex.com/company/) [![Sberbank Technology](docs/img/logo/sbertech.png)](http://sber-tech.com/) [![ThoughtWorks](docs/img/logo/thoughtworks.png)](https://thoughtworks.com/) [![SuperJob](docs/img/logo/superjob.png)](http://superjob.ru/) [![PropellerAds](docs/img/logo/propellerads.png)](http://propellerads.com/) [![AlfaBank](docs/img/logo/alfabank.png)](https://alfabank.com/) [![3CX](docs/img/logo/3cx.png)](https://www.3cx.com/) [![IQ Option](docs/img/logo/iq_option.png)](https://iqoption.com/) + diff --git a/config/config.go b/config/config.go index 90531a10..68013369 100644 --- a/config/config.go +++ b/config/config.go @@ -59,6 +59,7 @@ type Browser struct { Hosts []string `json:"hosts,omitempty"` ShmSize int64 `json:"shmSize,omitempty"` Labels map[string]string `json:"labels,omitempty"` + Sysctl map[string]string `json:"sysctl,omitempty"` } // Versions configuration diff --git a/docs/browser-image-information.adoc b/docs/browser-image-information.adoc index 80bf1b60..9efa12df 100644 --- a/docs/browser-image-information.adoc +++ b/docs/browser-image-information.adoc @@ -64,7 +64,7 @@ WARNING: Firefox 53.0+ images require Selenium client 3.4.0 or newer. |=== | Image | VNC Image | Selenoid Version | Geckodriver Version | Firefox Version | Client Version -| selenoid/firefox:48.0 | selenoid/vnc:firefox_48.0 | 1.3.9 | 0.13.0 | 48.0.2 (page load timeout, native events and proxies don't work) .11+<.^| +| selenoid/firefox:48.0 | selenoid/vnc:firefox_48.0 | 1.3.9 | 0.13.0 | 48.0.2 (page load timeout, native events and proxies don't work) .13+<.^| **Java, selenium-webdriver.js**: 3.4.0 and above **Python**: 3.5.0 and above | selenoid/firefox:49.0 | selenoid/vnc:firefox_49.0 | 1.3.9 | 0.13.0 | 49.0.2 (page load timeout, native events and switching between windows don't work) @@ -76,7 +76,9 @@ WARNING: Firefox 53.0+ images require Selenium client 3.4.0 or newer. | selenoid/firefox:55.0 | selenoid/vnc:firefox_55.0 | 1.3.9 | 0.18.0 | 55.0.1 (switching windows may not work) | selenoid/firefox:56.0 | selenoid/vnc:firefox_56.0 | 1.3.9 | 0.19.1 | 56.0.1 | selenoid/firefox:57.0 | selenoid/vnc:firefox_57.0 | 1.3.9 | 0.19.1 | 57.0 -| selenoid/firefox:58.0 | selenoid/vnc:firefox_58.0 | 1.3.9 | 0.19.1 | 58.0 +| selenoid/firefox:58.0 | selenoid/vnc:firefox_58.0 | 1.6.0 | 0.20.1 | 58.0 +| selenoid/firefox:59.0 | selenoid/vnc:firefox_59.0 | 1.6.0 | 0.20.1 | 59.0.1 +| selenoid/firefox:60.0 | selenoid/vnc:firefox_60.0 | 1.6.0 | 0.20.1 | 60.0 |=== @@ -103,6 +105,8 @@ WARNING: Firefox 53.0+ images require Selenium client 3.4.0 or newer. | selenoid/chrome:62.0 | selenoid/vnc:chrome_62.0 | 2.33 | 62.0.3202.62 | selenoid/chrome:63.0 | selenoid/vnc:chrome_63.0 | 2.33 | 63.0.3239.84 | selenoid/chrome:64.0 | selenoid/vnc:chrome_64.0 | 2.35 | 64.0.3282.119 +| selenoid/chrome:65.0 | selenoid/vnc:chrome_65.0 | 2.38 | 65.0.3325.181 +| selenoid/chrome:66.0 | selenoid/vnc:chrome_66.0 | 2.38 | 66.0.3359.117 |=== [NOTE] @@ -152,6 +156,7 @@ We do not consider these images really stable. Many of base operations like work | selenoid/opera:49.0 | selenoid/vnc:opera_49.0 | 2.32 | 49.0.2725.39 | selenoid/opera:50.0 | selenoid/vnc:opera_50.0 | 2.32 | 50.0.2762.45 | selenoid/opera:51.0 | selenoid/vnc:opera_51.0 | 2.33 | 51.0.2830.26 +| selenoid/opera:52.0 | selenoid/vnc:opera_52.0 | 2.35 | 52.0.2871.37 |=== [NOTE] diff --git a/docs/browsers-configuration-file.adoc b/docs/browsers-configuration-file.adoc index 6b3602d2..7e392659 100644 --- a/docs/browsers-configuration-file.adoc +++ b/docs/browsers-configuration-file.adoc @@ -120,6 +120,7 @@ Selenoid proxies connections to either Selenium server or standalone driver bina "env" : ["TZ=Europe/Moscow", "ONE_MORE_VARIABLE=itsValue"], "hosts" : ["one.example.com:192.168.0.1", "two.example.com:192.168.0.2"], "labels" : {"component": "frontend", "project": "my-project"}, + "sysctl" : {"net.ipv4.tcp_timestamps": "2", "kern.maxprocperuid": "1000"}, "shmSize" : 268435456, }, ---- @@ -145,6 +146,8 @@ We recommend to use our https://github.com/aerokube/cm[configuration tool] to av * *labels* (_optional_) - This field allows to add custom labels to running container. Specified as an object of `"key": "value"` pairs. +* *sysctl* (_optional_) - This field allows to adjust kernel parameters of running container. Specified as an object of `"key": "value"` pairs. + * *shmSize* (_optional_) - Use it to override shared memory size for browser container. === Syncing Browser Images from Existing File diff --git a/docs/cli-flags.adoc b/docs/cli-flags.adoc index 9e347f7e..bc46b9ec 100644 --- a/docs/cli-flags.adoc +++ b/docs/cli-flags.adoc @@ -25,6 +25,8 @@ The following flags are supported by `selenoid` command: Network address to accept connections (default ":4444") -log-conf string Container logging configuration file (default "config/container-logs.json") +-max-timeout duration + Maximum valid session idle timeout in time.Duration format (default 1h0m0s) -mem value Containers memory limit e.g. 128m or 1g -retry-count int diff --git a/docs/faq.adoc b/docs/faq.adoc new file mode 100644 index 00000000..8d5c59bf --- /dev/null +++ b/docs/faq.adoc @@ -0,0 +1,102 @@ +== Frequently Asked Questions + +=== Logs and Dirs + +**Where are Selenoid logs?** + +Selenoid outputs its logs to stdout. Selenoid launched as a binary should output logs to the screen. To see Selenoid logs launched as Docker container type: + + $ docker logs selenoid + +To follow the logs add one more flag: + + $ docker logs -f selenoid + +**Where are recorded videos stored?** + +Default location when installed with `cm` is `~/.aerokube/selenoid/video` or `C:\Users\\.aerokube\selenoid\video`. + +=== Limits and Timeouts + +**How can I limit overall browsers consumption?** + +You have to use `-limit` flag to specify total number of parallel sessions. Default value is 5. See <> section on how to determine total number of parallel sessions. + +**Can I limit per-version browser consumption?** + +No, this is not supported. We consider the only reasonable limitation should be the overall browsers consumption. This is important to not overload the hardware. + +**How can I adjust Selenoid timeouts?** + +The main timeout flag is `-timeout`, specified as `60s` or `2m` or `1h`. It means maximum amount of time between subsequent HTTP requests to Selenium API. When there are no requests during this time period - session is automatically closed. Selenoid also has more subtle timeouts like: + +* `-service-startup-timeout` - container or driver process startup timeout +* `-session-attempt-timeout` - new session HTTP request timeout, applied when container or driver has started +* `-session-delete-timeout` - container or process removal timeout, applied after `driver.quit()` call + +=== Resources Consumption + +**How many resources browser containers consume?** + +This depends on your tests. We recommend to start with 1 CPU and 1 Gb of memory per container as a rough estimate and then increase `-limit` checking that your tests work stably. + +**Do VNC and non-VNC browser images memory and CPU consumption differ?** + +The only difference between these images - is a running VNC server (`x11vnc`) consuming approximately 20 Megabytes of RAM in idle state which is negligible compared to browser memory consumption. + +=== Features not Working + +**Selenoid does not start: open config/browsers.json: no such file or directory** + +This usually happens when Selenoid is started in Docker container with custom command-line arguments, e.g.: + + $ docker run aerokube/selenoid:some-version -limit 10 + +In that case you have to specify path to configuration file explicitly (`cm` tool does this automatically): + + $ docker run aerokube/selenoid:some-version -limit 10 -conf /etc/selenoid/browsers.json + +**Getting error: create container: Error response from daemon: client version 1.36 is too new** + +You have to run Selenoid binary \ container with `DOCKER_API_VERSION` variable specifying your Docker API version. `cm` tool does this automatically for you. To determine API version type: + + $ docker version | grep API + +Then run Selenoid like the following: + + $ DOCKER_API_VERSION=1.32 ./selenoid # As a binary + $ docker run -e DOCKER_API_VERSION=1.32 aerokube/selenoid:some-version # As Docker container + +**Video feature not working** + +When running Selenoid as Docker container video feature can be not working (because of misconfiguration). If your video files are named like `selenoid607667f7e1c7923779e35506b040300d.mp4` and you are seeing the following log message... +``` +2018/03/20 21:06:37 [9] [VIDEO_ERROR] [Failed to rename /video/selenoid607667f7e1c7923779e35506b040300d.mp4 to /video/8019c4bc-9bec-4a8b-aa40-68d1db0cffd2.mp4: rename /video/selenoid607667f7e1c7923779e35506b040300d.mp4 /video/8019c4bc-9bec-4a8b-aa40-68d1db0cffd2.mp4: no such file or directory] +``` +\... then check that: + +. You are passing an `OVERRIDE_VIDEO_OUTPUT_DIR` environment variable pointing to a directory on the `host machine` where video files are actually stored +. When passing custom arguments to Selenoid container (such as `-limit` or `-timeout`) you also have to pass `-video-output-dir /opt/selenoid/video` and mount host machine video dir to `/opt/selenoid/video` + +**Can't open Selenoid video with Firefox** + +This is because we are using H264 codec which is not supported in Firefox for licensing reasons. Should work like a charm in Google Chrome or VLC player. + +**Can't get VNC feature to work: Disconnected** + +Please check the following: + +. You have `enableVNC = true` capability in your tests +. You are using browser images with `vnc` in their name, e.g. `selenoid/vnc:firefox:58.0`. + +**Seeing black screen with a cross in VNC window** + +You are using `driver.close()` instead of `driver.quit()` and just closed the last browser tab instead of removing the session. + +**Can't maximize browser window** + +This is because of missing window manager in browser images. Should be fixed soon. + +**Can Selenoid pull browser images automatically?** + +No, we did not implement this feature intentionally. We consider that all such cluster maintenance tasks can influence performance and stability when done automatically. \ No newline at end of file diff --git a/docs/file-download.adoc b/docs/file-download.adoc new file mode 100644 index 00000000..efa8c0f1 --- /dev/null +++ b/docs/file-download.adoc @@ -0,0 +1,13 @@ +== Accessing Files Downloaded with Browser + +[NOTE] +==== +. This feature only works when browsers are run in containers. +. Files are accessible only when browser session is running. +==== + +Your tests may need to download files with browsers. To analyze these files a common requirement is then to somehow extract downloaded files from browser containers. A possible solution can be dealing with container volumes. But Selenoid provides a `/download` API and dramatically simplifies downloading files. Having a running session `f2bcd32b-d932-4cdc-a639-687ab8e4f840` you can access all downloaded files using an URL: +``` +http://selenoid-host.example.com:4444/download/f2bcd32b-d932-4cdc-a639-687ab8e4f840/myfile.txt +``` +In order for this feature to work an HTTP file server should be listening inside browser container on port `8080`. Download directory inside container to be used in tests is usually `~/Downloads`. \ No newline at end of file diff --git a/docs/img/logo/3cx.png b/docs/img/logo/3cx.png new file mode 100644 index 00000000..613c48ef Binary files /dev/null and b/docs/img/logo/3cx.png differ diff --git a/docs/img/logo/alfabank.png b/docs/img/logo/alfabank.png new file mode 100644 index 00000000..f73afb4a Binary files /dev/null and b/docs/img/logo/alfabank.png differ diff --git a/docs/img/logo/iq_option.png b/docs/img/logo/iq_option.png new file mode 100644 index 00000000..23945e0a Binary files /dev/null and b/docs/img/logo/iq_option.png differ diff --git a/docs/img/logo/jetbrains.png b/docs/img/logo/jetbrains.png new file mode 100644 index 00000000..286409b5 Binary files /dev/null and b/docs/img/logo/jetbrains.png differ diff --git a/docs/img/logo/propellerads.png b/docs/img/logo/propellerads.png new file mode 100644 index 00000000..e9fb2be8 Binary files /dev/null and b/docs/img/logo/propellerads.png differ diff --git a/docs/img/logo/sbertech.png b/docs/img/logo/sbertech.png new file mode 100644 index 00000000..575b85fe Binary files /dev/null and b/docs/img/logo/sbertech.png differ diff --git a/docs/img/logo/superjob.png b/docs/img/logo/superjob.png new file mode 100644 index 00000000..502ae47d Binary files /dev/null and b/docs/img/logo/superjob.png differ diff --git a/docs/img/logo/thoughtworks.png b/docs/img/logo/thoughtworks.png new file mode 100644 index 00000000..618e0341 Binary files /dev/null and b/docs/img/logo/thoughtworks.png differ diff --git a/docs/img/logo/yandex.png b/docs/img/logo/yandex.png new file mode 100644 index 00000000..453668bf Binary files /dev/null and b/docs/img/logo/yandex.png differ diff --git a/docs/index.adoc b/docs/index.adoc index 5633b3d0..86e2a5bc 100644 --- a/docs/index.adoc +++ b/docs/index.adoc @@ -17,6 +17,7 @@ It is using Docker to launch browsers. Please refer to https://github.com/aeroku == Getting Started include::quick-start-guide.adoc[leveloffset=+1] +include::faq.adoc[leveloffset=+1] include::windows.adoc[leveloffset=+1] include::browser-images.adoc[leveloffset=+1] @@ -37,6 +38,7 @@ include::special-capabilities.adoc[leveloffset=+1] include::selenoid-without-docker.adoc[leveloffset=+1] include::usage-statistics.adoc[leveloffset=+1] include::file-upload.adoc[leveloffset=+1] +include::file-download.adoc[leveloffset=+1] include::contributing.adoc[] diff --git a/docs/log-files.adoc b/docs/log-files.adoc index 4948a70d..b82df502 100644 --- a/docs/log-files.adoc +++ b/docs/log-files.adoc @@ -51,6 +51,7 @@ The following statuses are available: | CREATING_CONTAINER | Docker container with browser is creating | DEFAULT_VERSION | Selenoid is using default browser version | DELETED_VIDEO_FILE | Video file was deleted by user +| DOWNLOADING_FILE | User requested to download file from browser container | ENVIRONMENT_NOT_AVAILABLE | Browser with desired name and version does not exist | FAILED_TO_REMOVE_CONTAINER | Failed to remove Docker container | FAILED_TO_TERMINATE_PROCESS | An error occurred while terminating driver process diff --git a/docs/special-capabilities.adoc b/docs/special-capabilities.adoc index 9f14c02b..93c8ca57 100644 --- a/docs/special-capabilities.adoc +++ b/docs/special-capabilities.adoc @@ -87,6 +87,17 @@ name: "myCoolTestName" The main application of this capability - is debugging tests in the UI which is showing specified name for every running session. +=== Custom Session Timeout: sessionTimeout + +Sometimes you may want to change idle timeout for selected browser session. To achieve this - pass the following capability: + +.Type: int +---- +sessionTimeout: 30 +---- + +Timeout is always specified in seconds, should be positive and can be no more than the value set by `-max-timeout` flag. + === Per-session Time Zone: timeZone Some tests require particular time zone to be set in operating system. @@ -100,15 +111,26 @@ timeZone: "Europe/Moscow" You can find most of available time zones https://en.wikipedia.org/wiki/List_of_tz_database_time_zones[here]. Without this capability launched browser containers will have the same timezone as Selenoid one. +=== Per-session Environment Variables: env + +Sometimes you may want to set some environment variables for every test case (for example to test with different default locales). To achieve this pass one more capability: + +.Type: array, format: = +---- +env: ["LANG=ru_RU.UTF-8", "LANGUAGE=ru:en", "LC_ALL=ru_RU.UTF-8"] +---- + +Environment variables from this capability are appended to variables from configuration file. + === Links to Application Containers: applicationContainers Sometimes you may need to link browser container to application container running on the same host machine. This allows you to use cool URLs like `http://my-cool-app/` in tests. To achieve this simply pass information about one or more desired links via capability: -.Type: string, format: [:alias] (comma-separated) +.Type: array, format: [:alias] ---- -applicationContainers: "spring-application-main:my-cool-app,spring-application-gateway" +applicationContainers: ["spring-application-main:my-cool-app", "spring-application-gateway"] ---- === Hosts Entries: hostsEntries @@ -116,21 +138,30 @@ applicationContainers: "spring-application-main:my-cool-app,spring-application-g Although you can configure a separate list of `/etc/hosts` entries for every browser image in <> sometimes you may need to add more entries for particular test cases. This can be easily achieved with: -.Type: string, format: : (comma-separated) +.Type: array, format: : ---- -hostsEntries: "example.com:192.168.0.1,test.com:192.168.0.2" +hostsEntries: ["example.com:192.168.0.1", "test.com:192.168.0.2"] ---- Entries will be inserted to `/etc/hosts` before entries from browsers configuration file. Thus entries from capabilities override entries from configuration file if some hosts are equal. +=== Custom DNS Servers: dnsServers + +By default Selenoid browser containers are using global DNS settings of Docker daemon. Sometimes you may need to override used DNS servers list for particular test cases. This can be easily achieved with: + +.Type: array, format: +---- +dnsServers: ["192.168.0.1", "192.168.0.2"] +---- + === Container Labels: labels In big clusters you may want to pass additional metadata to every browser session: environment, VCS revision, build number and so on. These labels can be then used to enrich session logs and send them to a centralized log storage. Later this metadata can be used for more efficient search through logs. -.Type: string, format: : (comma-separated) +.Type: map, format: "": "" ---- -labels: "environment:testing,build-number:14353" +labels: {"environment": "testing", "build-number": "14353"} ---- Labels from this capability override labels from browsers configuration file. When `name` capability is specified - it is automatically added as a label to container. @@ -141,12 +172,12 @@ Some Selenium clients allow passing only a limited number of capabilities specif .Passing Capabilities as Usually ---- -{"browserName": "firefox", "value": "57.0", "screenResolution": "1280x1024x24"} +{"browserName": "firefox", "version": "57.0", "screenResolution": "1280x1024x24"} ---- Selenoid is using `selenoid:options` key to read protocol extension capabilities: .Passing Capabilities using Protocol Extensions ---- -{"browserName": "firefox", "value": "57.0", "selenoid:options": {"screenResolution": "1280x1024x24"}} ----- \ No newline at end of file +{"browserName": "firefox", "version": "57.0", "selenoid:options": {"screenResolution": "1280x1024x24"}} +---- diff --git a/main.go b/main.go index a7b361ac..1f955fae 100644 --- a/main.go +++ b/main.go @@ -19,13 +19,16 @@ import ( "fmt" + "path/filepath" + "github.com/aerokube/selenoid/config" "github.com/aerokube/selenoid/mesos/scheduler" "github.com/aerokube/selenoid/protect" "github.com/aerokube/selenoid/service" "github.com/aerokube/selenoid/session" + "github.com/aerokube/util" + "github.com/aerokube/util/docker" "github.com/docker/docker/client" - "path/filepath" ) type memLimit int64 @@ -65,6 +68,7 @@ var ( enableFileUpload bool listen string timeout time.Duration + maxTimeout time.Duration newSessionAttemptTimeout time.Duration sessionDeleteTimeout time.Duration serviceStartupTimeout time.Duration @@ -88,8 +92,8 @@ var ( startTime = time.Now() version bool - gitRevision string = "HEAD" - buildStamp string = "unknown" + gitRevision = "HEAD" + buildStamp = "unknown" ) func init() { @@ -104,6 +108,7 @@ func init() { flag.IntVar(&limit, "limit", 5, "Simultaneous container runs") flag.IntVar(&retryCount, "retry-count", 1, "New session attempts retry count") flag.DurationVar(&timeout, "timeout", 60*time.Second, "Session idle timeout in time.Duration format") + flag.DurationVar(&maxTimeout, "max-timeout", 1*time.Hour, "Maximum valid session idle timeout in time.Duration format") flag.DurationVar(&newSessionAttemptTimeout, "session-attempt-timeout", 30*time.Second, "New session attempt timeout in time.Duration format") flag.DurationVar(&sessionDeleteTimeout, "session-delete-timeout", 30*time.Second, "Session delete timeout in time.Duration format") flag.DurationVar(&serviceStartupTimeout, "service-startup-timeout", 30*time.Second, "Service startup timeout in time.Duration format") @@ -186,7 +191,17 @@ func init() { } ip, _, _ := net.SplitHostPort(u.Host) environment.IP = ip - cli, err = client.NewEnvClient() + cli, err = docker.CreateCompatibleDockerClient( + func(specifiedApiVersion string) { + log.Printf("[-] [INIT] [Using Docker API version: %s]", specifiedApiVersion) + }, + func(determinedApiVersion string) { + log.Printf("[-] [INIT] [Your Docker API version is %s]", determinedApiVersion) + }, + func(defaultApiVersion string) { + log.Printf("[-] [INIT] [Did not manage to determine your Docker API version - using default version: %s]", defaultApiVersion) + }, + ) if err != nil { log.Fatalf("[-] [INIT] [New docker client: %v]", err) } @@ -204,6 +219,7 @@ func cancelOnSignal() { signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) go func() { <-sig + log.Println("[-] [-] [SHUTTING_DOWN] [-] [-] [-] [-] [-] [-] [-]") sessions.Each(func(k string, s *session.Session) { if enableFileUpload { os.RemoveAll(path.Join(os.TempDir(), k)) @@ -214,7 +230,6 @@ func cancelOnSignal() { err := cli.Close() if err != nil { log.Fatalf("[-] [SHUTTING_DOWN] [Error closing docker client: %v]", err) - os.Exit(1) } } os.Exit(0) @@ -234,7 +249,7 @@ func onSIGHUP(fn func()) { func mux() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("/session", queue.Check(queue.Protect(post(create)))) + mux.HandleFunc("/session", queue.Try(queue.Check(queue.Protect(post(create))))) mux.HandleFunc("/session/", proxy) return mux } @@ -296,7 +311,7 @@ func handler() http.Handler { mux().ServeHTTP(w, r) }) root.HandleFunc("/error", func(w http.ResponseWriter, r *http.Request) { - jsonError(w, "Session timed out or not found", http.StatusNotFound) + util.JsonError(w, "Session timed out or not found", http.StatusNotFound) }) root.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") @@ -306,6 +321,7 @@ func handler() http.Handler { root.Handle("/vnc/", websocket.Handler(vnc)) root.Handle("/logs/", websocket.Handler(logs)) root.HandleFunc(videoPath, video) + root.HandleFunc("/download/", fileDownload) if enableFileUpload { root.HandleFunc("/file", fileUpload) } diff --git a/protect/queue.go b/protect/queue.go index 0219ef0c..836e183c 100644 --- a/protect/queue.go +++ b/protect/queue.go @@ -2,11 +2,11 @@ package protect import ( "log" + "math" "net/http" "time" - "github.com/aerokube/selenoid/util" - "math" + "github.com/aerokube/util" ) // Queue - struct to hold a number of sessions @@ -18,6 +18,24 @@ type Queue struct { used chan struct{} } +// Try - when X-Selenoid-No-Wait header is set +// reply to client immediately if queue is full +func (q *Queue) Try(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, noWait := r.Header["X-Selenoid-No-Wait"] + select { + case q.limit <- struct{}{}: + <-q.limit + default: + if noWait { + util.JsonError(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) + return + } + } + next.ServeHTTP(w, r) + } +} + // Check - if queue disabled func (q *Queue) Check(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -28,7 +46,7 @@ func (q *Queue) Check(next http.HandlerFunc) http.HandlerFunc { if q.disabled { user, remote := util.RequestInfo(r) log.Printf("[-] [QUEUE_IS_FULL] [%s] [%s]", user, remote) - http.Error(w, "Queue is full, see other", http.StatusSeeOther) + util.JsonError(w, "Queue Is Full", http.StatusTooManyRequests) return } } diff --git a/selenoid.go b/selenoid.go index 0f5e2de8..cd7c462e 100644 --- a/selenoid.go +++ b/selenoid.go @@ -4,6 +4,8 @@ import ( "archive/zip" "bytes" "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" @@ -21,14 +23,15 @@ import ( "sync" "time" - "crypto/rand" - "encoding/hex" "github.com/aerokube/selenoid/session" - "github.com/aerokube/selenoid/util" + "github.com/aerokube/util" "github.com/docker/docker/api/types" + "github.com/docker/docker/pkg/stdcopy" "golang.org/x/net/websocket" ) +const slash = "/" + var ( httpClient = &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -63,18 +66,6 @@ func (s *sess) url() string { return fmt.Sprintf("http://%s/wd/hub/session/%s", s.addr, s.id) } -func jsonError(w http.ResponseWriter, msg string, code int) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - json.NewEncoder(w).Encode( - map[string]interface{}{ - "value": map[string]string{ - "message": msg, - }, - "status": 13, - }) -} - func (s *sess) Delete(requestId uint64) { log.Printf("[%d] [SESSION_TIMED_OUT] [%s]", requestId, s.id) r, err := http.NewRequest(http.MethodDelete, s.url(), nil) @@ -120,25 +111,38 @@ func create(w http.ResponseWriter, r *http.Request) { r.Body.Close() if err != nil { log.Printf("[%d] [ERROR_READING_REQUEST] [%v]", requestId, err) - jsonError(w, err.Error(), http.StatusBadRequest) + util.JsonError(w, err.Error(), http.StatusBadRequest) queue.Drop() return } var browser struct { - Caps session.Caps `json:"desiredCapabilities"` + Caps session.Caps `json:"desiredCapabilities"` + W3CCaps struct { + Caps session.Caps `json:"alwaysMatch"` + } `json:"capabilities"` } err = json.Unmarshal(body, &browser) if err != nil { log.Printf("[%d] [BAD_JSON_FORMAT] [%v]", requestId, err) - jsonError(w, err.Error(), http.StatusBadRequest) + util.JsonError(w, err.Error(), http.StatusBadRequest) queue.Drop() return } + if browser.W3CCaps.Caps.Name != "" && browser.Caps.Name == "" { + browser.Caps = browser.W3CCaps.Caps + } browser.Caps.ProcessExtensionCapabilities() + sessionTimeout, err := getSessionTimeout(browser.Caps.SessionTimeout, maxTimeout, timeout) + if err != nil { + log.Printf("[%d] [BAD_SESSION_TIMEOUT] [%ds]", requestId, browser.Caps.SessionTimeout) + util.JsonError(w, err.Error(), http.StatusBadRequest) + queue.Drop() + return + } resolution, err := getScreenResolution(browser.Caps.ScreenResolution) if err != nil { log.Printf("[%d] [BAD_SCREEN_RESOLUTION] [%s]", requestId, browser.Caps.ScreenResolution) - jsonError(w, err.Error(), http.StatusBadRequest) + util.JsonError(w, err.Error(), http.StatusBadRequest) queue.Drop() return } @@ -146,7 +150,7 @@ func create(w http.ResponseWriter, r *http.Request) { videoScreenSize, err := getVideoScreenSize(browser.Caps.VideoScreenSize, resolution) if err != nil { log.Printf("[%d] [BAD_VIDEO_SCREEN_SIZE] [%s]", requestId, browser.Caps.VideoScreenSize) - jsonError(w, err.Error(), http.StatusBadRequest) + util.JsonError(w, err.Error(), http.StatusBadRequest) queue.Drop() return } @@ -158,14 +162,14 @@ func create(w http.ResponseWriter, r *http.Request) { starter, ok := manager.Find(browser.Caps, requestId) if !ok { log.Printf("[%d] [ENVIRONMENT_NOT_AVAILABLE] [%s] [%s]", requestId, browser.Caps.Name, browser.Caps.Version) - jsonError(w, "Requested environment is not available", http.StatusBadRequest) + util.JsonError(w, "Requested environment is not available", http.StatusBadRequest) queue.Drop() return } startedService, err := starter.StartWithCancel() if err != nil { log.Printf("[%d] [SERVICE_STARTUP_FAILED] [%v]", requestId, err) - jsonError(w, err.Error(), http.StatusInternalServerError) + util.JsonError(w, err.Error(), http.StatusInternalServerError) queue.Drop() return } @@ -193,7 +197,7 @@ func create(w http.ResponseWriter, r *http.Request) { } err := fmt.Errorf("New session attempts retry count exceeded") log.Printf("[%d] [SESSION_FAILED] [%s] [%s]", requestId, u.String(), err) - jsonError(w, err.Error(), http.StatusInternalServerError) + util.JsonError(w, err.Error(), http.StatusInternalServerError) case context.Canceled: log.Printf("[%d] [CLIENT_DISCONNECTED] [%s] [%s] [%.2fs]", requestId, user, remote, util.SecondsSince(sessionStartTime)) } @@ -207,7 +211,7 @@ func create(w http.ResponseWriter, r *http.Request) { rsp.Body.Close() } log.Printf("[%d] [SESSION_FAILED] [%s] [%s]", requestId, u.String(), err) - jsonError(w, err.Error(), http.StatusInternalServerError) + util.JsonError(w, err.Error(), http.StatusInternalServerError) queue.Drop() cancel() return @@ -230,7 +234,7 @@ func create(w http.ResponseWriter, r *http.Request) { if location != "" { l, err := url.Parse(location) if err == nil { - fragments := strings.Split(l.Path, "/") + fragments := strings.Split(l.Path, slash) s.ID = fragments[len(fragments)-1] u := &url.URL{ Scheme: "http", @@ -269,13 +273,15 @@ func create(w http.ResponseWriter, r *http.Request) { } } sessions.Put(s.ID, &session.Session{ - Quota: user, - Caps: browser.Caps, - URL: u, - Container: startedService.Container, - VNC: startedService.VNCHostPort, - Cancel: cancelAndRenameVideo, - Timeout: onTimeout(timeout, func() { + Quota: user, + Caps: browser.Caps, + URL: u, + Container: startedService.Container, + Fileserver: startedService.FileserverHostPort, + VNC: startedService.VNCHostPort, + Cancel: cancelAndRenameVideo, + Timeout: sessionTimeout, + TimeoutCh: onTimeout(sessionTimeout, func() { request{r}.session(s.ID).Delete(requestId) })}) queue.Create() @@ -322,6 +328,18 @@ func getVideoScreenSize(videoScreenSize string, screenResolution string) (string return shortenScreenResolution(screenResolution), nil } +func getSessionTimeout(sessionTimeout uint32, maxTimeout time.Duration, defaultTimeout time.Duration) (time.Duration, error) { + if sessionTimeout > 0 { + std := time.Duration(sessionTimeout) * time.Second + if std <= maxTimeout { + return std, nil + } else { + return 0, fmt.Errorf("Invalid sessionTimeout capability: should be <= %s", maxTimeout) + } + } + return defaultTimeout, nil +} + func getVideoFileName(videoOutputDir string) string { filename := "" for { @@ -350,16 +368,16 @@ func proxy(w http.ResponseWriter, r *http.Request) { (&httputil.ReverseProxy{ Director: func(r *http.Request) { requestId := serial() - fragments := strings.Split(r.URL.Path, "/") + fragments := strings.Split(r.URL.Path, slash) id := fragments[2] sess, ok := sessions.Get(id) if ok { sess.Lock.Lock() defer sess.Lock.Unlock() select { - case <-sess.Timeout: + case <-sess.TimeoutCh: default: - close(sess.Timeout) + close(sess.TimeoutCh) } if r.Method == http.MethodDelete && len(fragments) == 3 { if enableFileUpload { @@ -370,7 +388,7 @@ func proxy(w http.ResponseWriter, r *http.Request) { queue.Release() log.Printf("[%d] [SESSION_DELETED] [%s]", requestId, id) } else { - sess.Timeout = onTimeout(timeout, func() { + sess.TimeoutCh = onTimeout(sess.Timeout, func() { request{r}.session(id).Delete(requestId) }) if len(fragments) == 4 && fragments[len(fragments)-1] == "file" && enableFileUpload { @@ -389,47 +407,71 @@ func proxy(w http.ResponseWriter, r *http.Request) { go (<-done)() } +func fileDownload(w http.ResponseWriter, r *http.Request) { + requestId := serial() + sid, remainingPath := splitRequestPath(r.URL.Path) + sess, ok := sessions.Get(sid) + if ok { + (&httputil.ReverseProxy{ + Director: func(r *http.Request) { + r.URL.Scheme = "http" + r.URL.Host = sess.Fileserver + r.URL.Path = remainingPath + log.Printf("[%d] [DOWNLOADING_FILE] [%s] [%s]", requestId, sid, remainingPath) + }, + }).ServeHTTP(w, r) + } else { + util.JsonError(w, fmt.Sprintf("Unknown session %s", sid), http.StatusNotFound) + log.Printf("[%d] [SESSION_NOT_FOUND] [%s]", requestId, sid) + } +} + +func splitRequestPath(p string) (string, string) { + fragments := strings.Split(p, slash) + return fragments[2], slash + strings.Join(fragments[3:], slash) +} + func fileUpload(w http.ResponseWriter, r *http.Request) { var jsonRequest struct { File []byte `json:"file"` } err := json.NewDecoder(r.Body).Decode(&jsonRequest) if err != nil { - jsonError(w, err.Error(), http.StatusBadRequest) + util.JsonError(w, err.Error(), http.StatusBadRequest) return } z, err := zip.NewReader(bytes.NewReader(jsonRequest.File), int64(len(jsonRequest.File))) if err != nil { - jsonError(w, err.Error(), http.StatusBadRequest) + util.JsonError(w, err.Error(), http.StatusBadRequest) return } if len(z.File) != 1 { - jsonError(w, fmt.Sprintf("Expected there to be only 1 file. There were: %d", len(z.File)), http.StatusBadRequest) + util.JsonError(w, fmt.Sprintf("Expected there to be only 1 file. There were: %d", len(z.File)), http.StatusBadRequest) return } file := z.File[0] src, err := file.Open() if err != nil { - jsonError(w, err.Error(), http.StatusBadRequest) + util.JsonError(w, err.Error(), http.StatusBadRequest) return } defer src.Close() dir := r.Header.Get("X-Selenoid-File") err = os.MkdirAll(dir, 0755) if err != nil { - jsonError(w, err.Error(), http.StatusInternalServerError) + util.JsonError(w, err.Error(), http.StatusInternalServerError) return } fileName := filepath.Join(dir, file.Name) dst, err := os.OpenFile(fileName, os.O_WRONLY|os.O_CREATE, 0644) if err != nil { - jsonError(w, err.Error(), http.StatusInternalServerError) + util.JsonError(w, err.Error(), http.StatusInternalServerError) return } defer dst.Close() _, err = io.Copy(dst, src) if err != nil { - jsonError(w, err.Error(), http.StatusInternalServerError) + util.JsonError(w, err.Error(), http.StatusInternalServerError) return } @@ -444,7 +486,7 @@ func fileUpload(w http.ResponseWriter, r *http.Request) { func vnc(wsconn *websocket.Conn) { defer wsconn.Close() requestId := serial() - sid := strings.Split(wsconn.Request().URL.Path, "/")[2] + sid, _ := splitRequestPath(wsconn.Request().URL.Path) sess, ok := sessions.Get(sid) if ok { if sess.VNC != "" { @@ -475,10 +517,10 @@ func vnc(wsconn *websocket.Conn) { func logs(wsconn *websocket.Conn) { defer wsconn.Close() requestId := serial() - sid := strings.Split(wsconn.Request().URL.Path, "/")[2] + sid, _ := splitRequestPath(wsconn.Request().URL.Path) sess, ok := sessions.Get(sid) if ok && sess.Container != nil { - log.Printf("[%d] [CONTAINER_LOGS] [%s]", requestId, sess.Container) + log.Printf("[%d] [CONTAINER_LOGS] [%s]", requestId, sess.Container.ID) r, err := cli.ContainerLogs(wsconn.Request().Context(), sess.Container.ID, types.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, @@ -490,7 +532,7 @@ func logs(wsconn *websocket.Conn) { } defer r.Close() wsconn.PayloadType = websocket.BinaryFrame - io.Copy(wsconn, r) + stdcopy.StdCopy(wsconn, wsconn, r) log.Printf("[%d] [CONTAINER_LOGS_DISCONNECTED] [%s]", requestId, sid) } else { log.Printf("[%d] [SESSION_NOT_FOUND] [%s]", requestId, sid) diff --git a/selenoid_test.go b/selenoid_test.go index 3219cefc..3387861c 100644 --- a/selenoid_test.go +++ b/selenoid_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "github.com/aerokube/selenoid/config" "io/ioutil" "log" "net/http" @@ -14,10 +13,13 @@ import ( "testing" "time" + "github.com/aerokube/selenoid/config" + "encoding/json" + "path/filepath" + . "github.com/aandryashin/matchers" . "github.com/aandryashin/matchers/httpresp" - "path/filepath" ) var ( @@ -87,6 +89,24 @@ func TestGetShortScreenResolution(t *testing.T) { AssertThat(t, res, EqualTo{"1024x768x24"}) } +func TestInvalidSessionTimeoutCapability(t *testing.T) { + testBadSessionTimeoutCapability(t, 3601) +} + +func TestNegativeSessionTimeoutCapability(t *testing.T) { + testBadSessionTimeoutCapability(t, -1) +} + +func testBadSessionTimeoutCapability(t *testing.T, timeoutValue int) { + manager = &BrowserNotFound{} + + rsp, err := http.Post(With(srv.URL).Path("/wd/hub/session"), "", bytes.NewReader([]byte(fmt.Sprintf(`{"desiredCapabilities":{"sessionTimeout":%d}}`, timeoutValue)))) + AssertThat(t, err, Is{nil}) + AssertThat(t, rsp, Code{http.StatusBadRequest}) + + AssertThat(t, queue.Used(), EqualTo{0}) +} + func TestMalformedScreenResolutionCapability(t *testing.T) { manager = &BrowserNotFound{} @@ -172,8 +192,27 @@ func TestNewSessionBadHostResponse(t *testing.T) { func TestSessionCreated(t *testing.T) { manager = &HTTPTest{Handler: Selenium()} + timeout = 5 * time.Second + + resp, err := http.Post(With(srv.URL).Path("/wd/hub/session"), "", bytes.NewReader([]byte(`{"desiredCapabilities": {"enableVideo": true, "enableVNC": true, "sessionTimeout": 3}}`))) + AssertThat(t, err, Is{nil}) + var sess map[string]string + AssertThat(t, resp, AllOf{Code{http.StatusOK}, IsJson{&sess}}) + + resp, err = http.Get(With(srv.URL).Path("/status")) + AssertThat(t, err, Is{nil}) + var state config.State + AssertThat(t, resp, AllOf{Code{http.StatusOK}, IsJson{&state}}) + AssertThat(t, state.Used, EqualTo{1}) + AssertThat(t, queue.Used(), EqualTo{1}) + sessions.Remove(sess["sessionId"]) + queue.Release() +} + +func TestSessionCreatedW3C(t *testing.T) { + manager = &HTTPTest{Handler: Selenium()} - resp, err := http.Post(With(srv.URL).Path("/wd/hub/session"), "", bytes.NewReader([]byte(`{"desiredCapabilities": {"enableVideo": true, "enableVNC": true}}`))) + resp, err := http.Post(With(srv.URL).Path("/wd/hub/session"), "", bytes.NewReader([]byte(`{"capabilities":{"alwaysMatch":{"acceptInsecureCerts":true,"browserName":"firefox"}}}`))) AssertThat(t, err, Is{nil}) var sess map[string]string AssertThat(t, resp, AllOf{Code{http.StatusOK}, IsJson{&sess}}) @@ -211,7 +250,7 @@ func TestSessionCreatedWdHub(t *testing.T) { queue.Release() } -func TestSessionFaitedAfterTimeout(t *testing.T) { +func TestSessionFailedAfterTimeout(t *testing.T) { newSessionAttemptTimeout = 10 * time.Millisecond manager = &HTTPTest{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-time.After(100 * time.Millisecond) @@ -248,7 +287,7 @@ func TestClientDisconnected(t *testing.T) { AssertThat(t, queue.Used(), EqualTo{0}) } -func TestSessionFaitedAfterTwoTimeout(t *testing.T) { +func TestSessionFailedAfterTwoTimeout(t *testing.T) { retryCount = 2 newSessionAttemptTimeout = 10 * time.Millisecond manager = &HTTPTest{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -587,3 +626,29 @@ func TestServeAndDeleteFile(t *testing.T) { AssertThat(t, err, Is{nil}) AssertThat(t, rsp, Code{http.StatusNotFound}) } + +func TestFileDownload(t *testing.T) { + manager = &HTTPTest{Handler: Selenium()} + + resp, err := http.Post(With(srv.URL).Path("/wd/hub/session"), "", bytes.NewReader([]byte("{}"))) + AssertThat(t, err, Is{nil}) + + var sess map[string]string + AssertThat(t, resp, AllOf{Code{http.StatusOK}, IsJson{&sess}}) + + rsp, err := http.Get(With(srv.URL).Path(fmt.Sprintf("/download/%s/testfile", sess["sessionId"]))) + AssertThat(t, err, Is{nil}) + AssertThat(t, rsp, Code{http.StatusOK}) + data, err := ioutil.ReadAll(rsp.Body) + AssertThat(t, err, Is{nil}) + AssertThat(t, string(data), EqualTo{"test-data"}) + + sessions.Remove(sess["sessionId"]) + queue.Release() +} + +func TestFileDownloadMissingSession(t *testing.T) { + rsp, err := http.Get(With(srv.URL).Path("/download/missing-session/testfile")) + AssertThat(t, err, Is{nil}) + AssertThat(t, rsp, Code{http.StatusNotFound}) +} diff --git a/service/docker.go b/service/docker.go index 6688abad..b70271d7 100644 --- a/service/docker.go +++ b/service/docker.go @@ -10,7 +10,7 @@ import ( "github.com/aerokube/selenoid/config" "github.com/aerokube/selenoid/session" - "github.com/aerokube/selenoid/util" + "github.com/aerokube/util" "github.com/docker/docker/api/types" ctr "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" @@ -22,10 +22,10 @@ import ( ) const ( - comma = "," - colon = ":" sysAdmin = "SYS_ADMIN" overrideVideoOutputDir = "OVERRIDE_VIDEO_OUTPUT_DIR" + vncPort = "5900" + fileserverPort = "8080" ) // Docker - docker container manager @@ -38,10 +38,11 @@ type Docker struct { } type portConfig struct { - SeleniumPort nat.Port - VNCPort nat.Port - PortBindings nat.PortMap - ExposedPorts map[nat.Port]struct{} + SeleniumPort nat.Port + FileserverPort nat.Port + VNCPort nat.Port + PortBindings nat.PortMap + ExposedPorts map[nat.Port]struct{} } // StartWithCancel - Starter interface implementation @@ -51,6 +52,7 @@ func (d *Docker) StartWithCancel() (*StartedService, error) { return nil, fmt.Errorf("configuring ports: %v", err) } selenium := portConfig.SeleniumPort + fileserver := portConfig.FileserverPort vnc := portConfig.VNCPort requestId := d.RequestId image := d.Service.Image @@ -71,12 +73,17 @@ func (d *Docker) StartWithCancel() (*StartedService, error) { }, ExtraHosts: getExtraHosts(d.Service, d.Caps), } + if len(d.Caps.DNSServers) > 0 { + hostConfig.DNS = d.Caps.DNSServers + } if !d.Privileged { hostConfig.CapAdd = strslice.StrSlice{sysAdmin} } - if d.ApplicationContainers != "" { - links := strings.Split(d.ApplicationContainers, comma) - hostConfig.Links = links + if len(d.ApplicationContainers) > 0 { + hostConfig.Links = d.ApplicationContainers + } + if len(d.Service.Sysctl) > 0 { + hostConfig.Sysctls = d.Service.Sysctl } cl := d.Client env := getEnv(d.ServiceBase, d.Caps) @@ -113,11 +120,11 @@ func (d *Docker) StartWithCancel() (*StartedService, error) { removeContainer(ctx, cl, requestId, browserContainerId) return nil, fmt.Errorf("no bindings available for %v", selenium) } - seleniumHostPort, vncHostPort := getHostPort(d.Environment, d.Service, d.Caps, stat, selenium, vnc) + seleniumHostPort, vncHostPort, fileserverHostPort := getHostPort(d.Environment, d.Service, d.Caps, stat, selenium, vnc, fileserver) u := &url.URL{Scheme: "http", Host: seleniumHostPort, Path: d.Service.Path} if d.Video { - videoContainerId, err = startVideoContainer(ctx, cl, requestId, stat, d.Environment, d.Caps) + videoContainerId, err = startVideoContainer(ctx, cl, requestId, stat, d.Environment, d.ServiceBase, d.Caps) if err != nil { return nil, fmt.Errorf("start video container: %v", err) } @@ -137,7 +144,8 @@ func (d *Docker) StartWithCancel() (*StartedService, error) { ID: browserContainerId, IPAddress: getContainerIP(d.Environment.Network, stat), }, - VNCHostPort: vncHostPort, + FileserverHostPort: fileserverHostPort, + VNCHostPort: vncHostPort, Cancel: func() { if videoContainerId != "" { stopVideoContainer(ctx, cl, requestId, videoContainerId) @@ -153,10 +161,14 @@ func getPortConfig(service *config.Browser, caps session.Caps, env Environment) if err != nil { return nil, fmt.Errorf("new selenium port: %v", err) } - exposedPorts := map[nat.Port]struct{}{selenium: {}} + fileserver, err := nat.NewPort("tcp", fileserverPort) + if err != nil { + return nil, fmt.Errorf("new fileserver port: %v", err) + } + exposedPorts := map[nat.Port]struct{}{selenium: {}, fileserver: {}} var vnc nat.Port if caps.VNC { - vnc, err = nat.NewPort("tcp", "5900") + vnc, err = nat.NewPort("tcp", vncPort) if err != nil { return nil, fmt.Errorf("new vnc port: %v", err) } @@ -165,15 +177,17 @@ func getPortConfig(service *config.Browser, caps session.Caps, env Environment) portBindings := nat.PortMap{} if env.IP != "" || !env.InDocker { portBindings[selenium] = []nat.PortBinding{{HostIP: "0.0.0.0"}} + portBindings[fileserver] = []nat.PortBinding{{HostIP: "0.0.0.0"}} if caps.VNC { portBindings[vnc] = []nat.PortBinding{{HostIP: "0.0.0.0"}} } } return &portConfig{ - SeleniumPort: selenium, - VNCPort: vnc, - PortBindings: portBindings, - ExposedPorts: exposedPorts}, nil + SeleniumPort: selenium, + FileserverPort: fileserver, + VNCPort: vnc, + PortBindings: portBindings, + ExposedPorts: exposedPorts}, nil } const ( @@ -188,8 +202,12 @@ func getLogConfig(logConfig ctr.LogConfig, caps session.Caps) ctr.LogConfig { logConfig.Config[tag] = caps.TestName } _, ok = logConfig.Config[labels] - if caps.Labels != "" && !ok { - logConfig.Config[labels] = caps.Labels + if len(caps.Labels) > 0 && !ok { + joinedLabels := []string{} + for k, v := range caps.Labels { + joinedLabels = append(joinedLabels, fmt.Sprintf("%s=%s", k, v)) + } + logConfig.Config[labels] = strings.Join(joinedLabels, ",") } } return logConfig @@ -215,6 +233,7 @@ func getEnv(service ServiceBase, caps session.Caps) []string { fmt.Sprintf("ENABLE_VNC=%v", caps.VNC), } env = append(env, service.Service.Env...) + env = append(env, caps.Env...) return env } @@ -234,8 +253,8 @@ func getContainerHostname(caps session.Caps) string { func getExtraHosts(service *config.Browser, caps session.Caps) []string { extraHosts := service.Hosts - if caps.HostsEntries != "" { - extraHosts = append(strings.Split(caps.HostsEntries, comma), extraHosts...) + if len(caps.HostsEntries) > 0 { + extraHosts = append(caps.HostsEntries, extraHosts...) } return extraHosts } @@ -248,43 +267,39 @@ func getLabels(service *config.Browser, caps session.Caps) map[string]string { for k, v := range service.Labels { labels[k] = v } - if caps.Labels != "" { - for _, lbl := range strings.Split(caps.Labels, comma) { - kv := strings.SplitN(lbl, colon, 2) - if len(kv) == 2 { - key := kv[0] - value := kv[1] - labels[key] = value - } else { - labels[lbl] = "" - } + if len(caps.Labels) > 0 { + for k, v := range caps.Labels { + labels[k] = v } } return labels } -func getHostPort(env Environment, service *config.Browser, caps session.Caps, stat types.ContainerJSON, selenium nat.Port, vnc nat.Port) (string, string) { - seleniumHostPort, vncHostPort := "", "" +func getHostPort(env Environment, service *config.Browser, caps session.Caps, stat types.ContainerJSON, selenium nat.Port, vnc nat.Port, fileserver nat.Port) (string, string, string) { + seleniumHostPort, vncHostPort, fileserverHostPort := "", "", "" if env.IP == "" { if env.InDocker { containerIP := getContainerIP(env.Network, stat) seleniumHostPort = net.JoinHostPort(containerIP, service.Port) + fileserverHostPort = net.JoinHostPort(containerIP, fileserverPort) if caps.VNC { - vncHostPort = net.JoinHostPort(containerIP, "5900") + vncHostPort = net.JoinHostPort(containerIP, vncPort) } } else { seleniumHostPort = net.JoinHostPort("127.0.0.1", stat.NetworkSettings.Ports[selenium][0].HostPort) + fileserverHostPort = net.JoinHostPort("127.0.0.1", stat.NetworkSettings.Ports[fileserver][0].HostPort) if caps.VNC { vncHostPort = net.JoinHostPort("127.0.0.1", stat.NetworkSettings.Ports[vnc][0].HostPort) } } } else { seleniumHostPort = net.JoinHostPort(env.IP, stat.NetworkSettings.Ports[selenium][0].HostPort) + fileserverHostPort = net.JoinHostPort(env.IP, stat.NetworkSettings.Ports[fileserver][0].HostPort) if caps.VNC { vncHostPort = net.JoinHostPort(env.IP, stat.NetworkSettings.Ports[vnc][0].HostPort) } } - return seleniumHostPort, vncHostPort + return seleniumHostPort, vncHostPort, fileserverHostPort } func getContainerIP(networkName string, stat types.ContainerJSON) string { @@ -309,10 +324,11 @@ func getContainerIP(networkName string, stat types.ContainerJSON) string { return "" } -func startVideoContainer(ctx context.Context, cl *client.Client, requestId uint64, browserContainer types.ContainerJSON, environ Environment, caps session.Caps) (string, error) { +func startVideoContainer(ctx context.Context, cl *client.Client, requestId uint64, browserContainer types.ContainerJSON, environ Environment, service ServiceBase, caps session.Caps) (string, error) { videoContainerStartTime := time.Now() videoContainerImage := environ.VideoContainerImage - env := []string{fmt.Sprintf("FILE_NAME=%s", caps.VideoName)} + env := getEnv(service, caps) + env = append(env, fmt.Sprintf("FILE_NAME=%s", caps.VideoName)) videoScreenSize := caps.VideoScreenSize if videoScreenSize != "" { env = append(env, fmt.Sprintf("VIDEO_SIZE=%s", videoScreenSize)) diff --git a/service/driver.go b/service/driver.go index d534513e..8e3dc813 100644 --- a/service/driver.go +++ b/service/driver.go @@ -9,7 +9,8 @@ import ( "time" "errors" - "github.com/aerokube/selenoid/util" + "github.com/aerokube/selenoid/session" + "github.com/aerokube/util" "os" ) @@ -17,6 +18,7 @@ import ( type Driver struct { ServiceBase Environment + session.Caps } // StartWithCancel - Starter interface implementation @@ -47,6 +49,7 @@ func (d *Driver) StartWithCancel() (*StartedService, error) { cmdLine = append(cmdLine, fmt.Sprintf("--port=%s", port)) cmd := exec.Command(cmdLine[0], cmdLine[1:]...) cmd.Env = append(cmd.Env, d.Service.Env...) + cmd.Env = append(cmd.Env, d.Caps.Env...) if d.CaptureDriverLogs { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/service/service.go b/service/service.go index bcd366dc..9fb947d5 100644 --- a/service/service.go +++ b/service/service.go @@ -41,10 +41,11 @@ type ServiceBase struct { // StartedService - all started service properties type StartedService struct { - Url *url.URL - Container *session.Container - VNCHostPort string - Cancel func() + Url *url.URL + Container *session.Container + FileserverHostPort string + VNCHostPort string + Cancel func() } // Starter - interface to create session with cancellation ability @@ -98,7 +99,7 @@ func (m *DefaultManager) Find(caps session.Caps, requestId uint64) (Starter, boo LogConfig: m.Config.ContainerLogs}, true case []interface{}: log.Printf("[%d] [USING_DRIVER] [%s] [%s]", requestId, browserName, version) - return &Driver{ServiceBase: serviceBase, Environment: *m.Environment}, true + return &Driver{ServiceBase: serviceBase, Environment: *m.Environment, Caps: caps}, true } } return nil, false diff --git a/service_test.go b/service_test.go index d24345bc..628e9329 100644 --- a/service_test.go +++ b/service_test.go @@ -1,7 +1,18 @@ package main import ( + "bytes" "fmt" + . "github.com/aandryashin/matchers" + "github.com/aerokube/selenoid/config" + "github.com/aerokube/selenoid/service" + "github.com/aerokube/selenoid/session" + "github.com/aerokube/util" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "golang.org/x/net/websocket" + "io" + "net" "net/http" "net/http/httptest" "net/url" @@ -9,13 +20,6 @@ import ( "sync" "testing" "time" - - . "github.com/aandryashin/matchers" - "github.com/aerokube/selenoid/config" - "github.com/aerokube/selenoid/service" - "github.com/aerokube/selenoid/session" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/client" ) var ( @@ -37,6 +41,7 @@ func updateMux(mux http.Handler) { mockServer = httptest.NewServer(mux) os.Setenv("DOCKER_HOST", "tcp://"+hostPort(mockServer.URL)) os.Setenv("DOCKER_API_VERSION", "1.29") + cli, _ = client.NewEnvClient() } func testMux() http.Handler { @@ -67,7 +72,19 @@ func testMux() http.Handler { w.WriteHeader(http.StatusNoContent) }, )) - mux.HandleFunc("/v1.29/containers/e90e34656806", http.HandlerFunc( + mux.HandleFunc("/v1.29/containers/e90e34656806/logs", http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/plain; charset=utf-8") + w.Header().Add("Transfer-Encoding", "chunked") + w.WriteHeader(http.StatusOK) + const streamTypeStderr = 2 + header := []byte{streamTypeStderr, 0, 0, 0, 0, 0, 0, 9} + w.Write(header) + data := []byte("test-data") + w.Write(data) + }, + )) + mux.HandleFunc("/v%s/containers/e90e34656806", http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }, @@ -90,6 +107,12 @@ func testMux() http.Handler { "HostPort": "%s" } ], + "8080/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "%s" + } + ], "5900/tcp": [ { "HostIp": "0.0.0.0", @@ -123,7 +146,7 @@ func testMux() http.Handler { "State": {}, "Mounts": [] } - `, p, p, p) + `, p, p, p, p) w.Write([]byte(output)) }, )) @@ -161,6 +184,7 @@ func testConfig(env *service.Environment) *config.Config { Port: p, Volumes: []string{"/test:/test"}, Labels: map[string]string{"key": "value"}, + Sysctl: map[string]string{"sysctl net.ipv4.tcp_timestamps": "2"}, }, }, } @@ -238,9 +262,11 @@ func createDockerStarter(t *testing.T, env *service.Environment, cfg *config.Con Video: true, VideoScreenSize: "1024x768", VideoFrameRate: 25, - HostsEntries: "example.com:192.168.0.1,test.com:192.168.0.2", - Labels: "label1:some-value,label2", - ApplicationContainers: "one,two", + Env: []string{"LANG=ru_RU.UTF-8", "LANGUAGE=ru:en"}, + HostsEntries: []string{"example.com:192.168.0.1", "test.com:192.168.0.2"}, + DNSServers: []string{"192.168.0.1", "192.168.0.2"}, + Labels: map[string]string{"label1": "some-value", "label2": ""}, + ApplicationContainers: []string{"one", "two"}, TimeZone: "Europe/Moscow", ContainerHostname: "some-hostname", TestName: "my-cool-test", @@ -297,3 +323,62 @@ func TestFindDriver(t *testing.T) { AssertThat(t, success, Is{true}) AssertThat(t, starter, Not{nil}) } + +func TestGetVNC(t *testing.T) { + + srv := httptest.NewServer(handler()) + defer srv.Close() + + testTcpServer := testTCPServer("test-data") + sessions.Put("test-session", &session.Session{ + VNC: testTcpServer.Addr().String(), + }) + defer sessions.Remove("test-session") + + u := fmt.Sprintf("ws://%s/vnc/test-session", util.HostPort(srv.URL)) + AssertThat(t, readDataFromWebSocket(t, u), EqualTo{"test-data"}) +} + +func testTCPServer(data string) net.Listener { + l, _ := net.Listen("tcp", "127.0.0.1:0") + go func() { + for { + conn, err := l.Accept() + if err != nil { + continue + } + io.WriteString(conn, data) + conn.Close() + return + } + }() + return l +} + +func readDataFromWebSocket(t *testing.T, wsURL string) string { + ws, err := websocket.Dial(wsURL, "", "http://localhost") + AssertThat(t, err, Is{nil}) + + var msg = make([]byte, 512) + _, err = ws.Read(msg) + msg = bytes.Trim(msg, "\x00") + //AssertThat(t, err, Is{nil}) + return string(msg) +} + +func TestGetLogs(t *testing.T) { + + srv := httptest.NewServer(handler()) + defer srv.Close() + + sessions.Put("test-session", &session.Session{ + Container: &session.Container{ + ID: "e90e34656806", + IPAddress: "127.0.0.1", + }, + }) + defer sessions.Remove("test-session") + + u := fmt.Sprintf("ws://%s/logs/test-session", util.HostPort(srv.URL)) + AssertThat(t, readDataFromWebSocket(t, u), EqualTo{"test-data"}) +} diff --git a/session/session.go b/session/session.go index 96370fa5..82b3f25e 100644 --- a/session/session.go +++ b/session/session.go @@ -4,12 +4,14 @@ import ( "net/url" "reflect" "sync" + "time" ) // Caps - user capabilities type Caps struct { Name string `json:"browserName"` Version string `json:"version"` + W3CVersion string `json:"browserVersion"` ScreenResolution string `json:"screenResolution"` VNC bool `json:"enableVNC"` Video bool `json:"enableVideo"` @@ -19,13 +21,19 @@ type Caps struct { TestName string `json:"name"` TimeZone string `json:"timeZone"` ContainerHostname string `json:"containerHostname"` - ApplicationContainers string `json:"applicationContainers"` - HostsEntries string `json:"hostsEntries"` - Labels string `json:"labels"` + Env []string `json:"env"` + ApplicationContainers []string `json:"applicationContainers"` + HostsEntries []string `json:"hostsEntries"` + DNSServers []string `json:"dnsServers"` + Labels map[string]string `json:"labels"` + SessionTimeout uint32 `json:"sessionTimeout"` ExtensionCapabilities map[string]interface{} `json:"selenoid:options"` } func (c *Caps) ProcessExtensionCapabilities() { + if c.W3CVersion != "" { + c.Version = c.W3CVersion + } if len(c.ExtensionCapabilities) > 0 { s := reflect.ValueOf(c).Elem() @@ -37,10 +45,14 @@ func (c *Caps) ProcessExtensionCapabilities() { tagToFieldMap[tag] = field } - for k, v := range c.ExtensionCapabilities { - value := reflect.ValueOf(v) - if field, ok := tagToFieldMap[k]; ok && value.Type().ConvertibleTo(field.Type) { - s.FieldByName(field.Name).Set(value.Convert(field.Type)) + //NOTE: entries from the first maps have less priority than then next ones + nestedMaps := []map[string]interface{}{c.ExtensionCapabilities} + for _, nm := range nestedMaps { + for k, v := range nm { + value := reflect.ValueOf(v) + if field, ok := tagToFieldMap[k]; ok && value.Type().ConvertibleTo(field.Type) { + s.FieldByName(field.Name).Set(value.Convert(field.Type)) + } } } } @@ -54,14 +66,16 @@ type Container struct { // Session - holds session info type Session struct { - Quota string - Caps Caps - URL *url.URL - Container *Container - VNC string - Cancel func() - Timeout chan struct{} - Lock sync.Mutex + Quota string + Caps Caps + URL *url.URL + Container *Container + Fileserver string + VNC string + Cancel func() + Timeout time.Duration + TimeoutCh chan struct{} + Lock sync.Mutex } // Map - session uuid to sessions mapping diff --git a/util/util.go b/util/util.go deleted file mode 100644 index f9e8ae9c..00000000 --- a/util/util.go +++ /dev/null @@ -1,26 +0,0 @@ -package util - -import ( - "net" - "net/http" - "time" -) - -func SecondsSince(start time.Time) float64 { - return float64(time.Now().Sub(start).Seconds()) -} - -func RequestInfo(r *http.Request) (string, string) { - user := "" - if u, _, ok := r.BasicAuth(); ok { - user = u - } else { - user = "unknown" - } - remote := r.Header.Get("X-Forwarded-For") - if remote != "" { - return user, remote - } - remote, _, _ = net.SplitHostPort(r.RemoteAddr) - return user, remote -} diff --git a/utils_test.go b/utils_test.go index d7a86b80..acb849db 100644 --- a/utils_test.go +++ b/utils_test.go @@ -13,11 +13,12 @@ import ( "time" + "testing" + . "github.com/aandryashin/matchers" "github.com/aerokube/selenoid/service" "github.com/aerokube/selenoid/session" "github.com/pborman/uuid" - "testing" ) type HTTPTest struct { @@ -45,7 +46,8 @@ func (m *HTTPTest) StartWithCancel() (*service.StartedService, error) { m.Action(s) } ss := service.StartedService{ - Url: u, + Url: u, + FileserverHostPort: u.Host, Cancel: func() { log.Println("Stopping HTTPTest Service...") s.Close() @@ -123,12 +125,17 @@ func Selenium() http.Handler { delete(sessions, u) lock.Unlock() }) + mux.HandleFunc("/testfile", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test-data")) + }) return mux } func TestProcessExtensionCapabilities(t *testing.T) { capsJson := `{ - "browserName": "firefox", "version": "57.0", + "version": "57.0", + "browserName": "firefox", "selenoid:options": { "name": "ExampleTestName", "enableVNC": true, @@ -142,7 +149,10 @@ func TestProcessExtensionCapabilities(t *testing.T) { AssertThat(t, caps.Name, EqualTo{"firefox"}) AssertThat(t, caps.Version, EqualTo{"57.0"}) AssertThat(t, caps.TestName, EqualTo{""}) + caps.ProcessExtensionCapabilities() + AssertThat(t, caps.Name, EqualTo{"firefox"}) + AssertThat(t, caps.Version, EqualTo{"57.0"}) AssertThat(t, caps.TestName, EqualTo{"ExampleTestName"}) AssertThat(t, caps.VNC, EqualTo{true}) //Correct type AssertThat(t, caps.Video, EqualTo{false}) //Wrong type in JSON