├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── main.go ├── codapi-cli ├── codapi.json ├── codapi.service ├── docs ├── add-sandbox.md ├── api.md ├── docker-xfs.md ├── install.md ├── nginx.md ├── production.md └── update.md ├── go.mod ├── go.sum ├── internal ├── config │ ├── config.go │ ├── config_test.go │ ├── load.go │ ├── load_test.go │ └── testdata │ │ ├── codapi.json │ │ └── sandboxes │ │ ├── alpine │ │ └── box.json │ │ └── python │ │ ├── box.json │ │ └── commands.json ├── engine │ ├── docker.go │ ├── docker_test.go │ ├── engine.go │ ├── engine_test.go │ ├── exec.go │ ├── exec_test.go │ ├── http.go │ ├── http_test.go │ ├── io.go │ ├── io_test.go │ └── testdata │ │ └── example.txt ├── execy │ ├── execy.go │ ├── execy_test.go │ ├── mock.go │ └── mock_test.go ├── fileio │ ├── fileio.go │ ├── fileio_test.go │ └── testdata │ │ ├── invalid.json │ │ └── valid.json ├── httpx │ ├── httpx.go │ ├── httpx_test.go │ ├── mock.go │ ├── mock_test.go │ └── testdata │ │ ├── example.json │ │ └── example.txt ├── logx │ ├── logx.go │ ├── logx_test.go │ ├── memory.go │ └── memory_test.go ├── sandbox │ ├── config.go │ ├── config_test.go │ ├── sandbox.go │ ├── sandbox_test.go │ ├── semaphore.go │ └── semaphore_test.go ├── server │ ├── io.go │ ├── io_test.go │ ├── middleware.go │ ├── middleware_test.go │ ├── router.go │ ├── router_test.go │ ├── server.go │ └── server_test.go └── stringx │ ├── stringx.go │ └── stringx_test.go └── sandboxes └── ash ├── Dockerfile ├── box.json └── commands.json /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - "docs/**" 8 | - Makefile 9 | - README.md 10 | pull_request: 11 | branches: [main] 12 | paths-ignore: 13 | - "docs/**" 14 | - Makefile 15 | - README.md 16 | workflow_dispatch: 17 | 18 | defaults: 19 | run: 20 | shell: bash 21 | 22 | jobs: 23 | build: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Setup Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: "stable" 33 | 34 | - name: Test and build 35 | run: make test build 36 | 37 | - name: Upload artifact 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: codapi 41 | path: build/codapi 42 | retention-days: 7 43 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: "stable" 23 | 24 | - name: Release and publish 25 | uses: goreleaser/goreleaser-action@v6 26 | with: 27 | args: release --clean 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | goos: 5 | - linux 6 | - darwin 7 | goarch: 8 | - amd64 9 | - arm64 10 | main: ./cmd/main.go 11 | 12 | archives: 13 | - files: 14 | - sandboxes/* 15 | - codapi-cli 16 | - codapi.json 17 | - codapi.service 18 | - LICENSE 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Anton Zhiyanov 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build images 2 | 3 | # Development 4 | 5 | build_rev := "main" 6 | ifneq ($(wildcard .git),) 7 | build_rev := $(shell git rev-parse --short HEAD) 8 | endif 9 | build_date := $(shell date -u '+%Y-%m-%dT%H:%M:%S') 10 | 11 | setup: 12 | @go mod download 13 | 14 | lint: 15 | @golangci-lint run --print-issued-lines=false --out-format=colored-line-number ./... 16 | 17 | vet: 18 | @go vet ./... 19 | 20 | test: 21 | @go test ./... -v 22 | 23 | 24 | build: 25 | @go build -ldflags "-X main.commit=$(build_rev) -X main.date=$(build_date)" -o build/codapi -v cmd/main.go 26 | 27 | run: 28 | @./build/codapi 29 | 30 | 31 | # Containers 32 | 33 | image: 34 | @[ -n "$(name)" ] || (echo "Syntax: make image name=" >&2; exit 1) 35 | @echo "Building image codapi/$(name)" 36 | @docker build --file sandboxes/$(name)/Dockerfile --tag codapi/$(name):latest sandboxes/$(name)/ 37 | @echo "✓ codapi/$(name)" 38 | 39 | network: 40 | docker network create --internal codapi 41 | 42 | # Host OS 43 | 44 | mount-tmp: 45 | mount -t tmpfs tmpfs /tmp -o rw,exec,nosuid,nodev,size=64m,mode=1777 46 | 47 | # Deployment 48 | 49 | app-download: 50 | @curl -L -o codapi.zip "https://api.github.com/repos/nalgeon/codapi/actions/artifacts/$(id)/zip" 51 | @unzip -ou codapi.zip 52 | @chmod +x build/codapi 53 | @rm -f codapi.zip 54 | @echo "OK" 55 | 56 | app-start: 57 | @nohup build/codapi > codapi.log 2>&1 & echo $$! > codapi.pid 58 | @echo "started codapi" 59 | 60 | app-stop: 61 | @kill $(shell cat codapi.pid) 62 | @rm -f codapi.pid 63 | @echo "stopped codapi" 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interactive code examples 2 | 3 | _for documentation, education and fun_ 🎉 4 | 5 | Codapi is a platform for embedding interactive code snippets directly into your product documentation, online course or blog post. It's also useful for experimenting with new languages, databases, or tools in a sandbox. 6 | 7 | ``` 8 | ┌───────────────────────────────┐ 9 | │ def greet(name): │ 10 | │ print(f"Hello, {name}!") │ 11 | │ │ 12 | │ greet("World") │ 13 | └───────────────────────────────┘ 14 | Run ► Edit ✓ Done 15 | ┌───────────────────────────────┐ 16 | │ Hello, World! │ 17 | └───────────────────────────────┘ 18 | ``` 19 | 20 | Codapi manages sandboxes (isolated execution environments) and provides an API to execute code in these sandboxes. It also provides a JavaScript widget [codapi-js](https://github.com/nalgeon/codapi-js) for easier integration. 21 | 22 | For an introduction to Codapi, see this post: [Interactive code examples for fun and profit](https://antonz.org/code-examples/). 23 | 24 | ## Installation 25 | 26 | To run Codapi locally, follow these steps: 27 | 28 | 1. Install Docker (or Podman/OrbStack) for your operating system. 29 | 2. Install the [latest](https://github.com/nalgeon/codapi/releases/latest) Codapi release (change the `linux_amd64` part according to your OS): 30 | 31 | ```sh 32 | mkdir ~/codapi && cd ~/codapi 33 | curl -L -o codapi.tar.gz "https://github.com/nalgeon/codapi/releases/download/v0.11.0/codapi_0.11.0_linux_amd64.tar.gz" 34 | tar xvzf codapi.tar.gz 35 | rm -f codapi.tar.gz 36 | ``` 37 | 38 | 3. Build the sample `ash` sandbox image: 39 | 40 | ```sh 41 | docker build --file sandboxes/ash/Dockerfile --tag codapi/ash:latest sandboxes/ash 42 | ``` 43 | 44 | 4. Start the server: 45 | 46 | ```sh 47 | ./codapi 48 | ``` 49 | 50 | ## Usage 51 | 52 | See [Adding a sandbox](docs/add-sandbox.md) to add a sandbox from the [registry](https://github.com/nalgeon/sandboxes) or create a custom one. 53 | 54 | See [API](docs/api.md) to run sandboxed code using the HTTP API. 55 | 56 | See [codapi-js](https://github.com/nalgeon/codapi-js) to embed the JavaScript widget into a web page. 57 | 58 | ## Production 59 | 60 | Running in production is a bit more involved. See these guides: 61 | 62 | - [Installing Codapi](docs/install.md) 63 | - [Updating Codapi](docs/update.md) 64 | - [Deploying to production](docs/production.md) 65 | 66 | ## Contributing 67 | 68 | Contributions are welcome. For anything other than bugfixes, please first open an issue to discuss what you want to change. 69 | 70 | Be sure to add or update tests as appropriate. 71 | 72 | ## Support 73 | 74 | Codapi is mostly a [one-man](https://antonz.org/) project, not backed by a VC fund or anything. 75 | 76 | If you find Codapi useful, please star it on GitHub and spread the word among your peers. It really helps to move the project forward. 77 | 78 | ★ [Subscribe](https://antonz.org/subscribe/) to stay on top of new features. 79 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | // Codapi safely executes code snippets using sandboxes. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/nalgeon/codapi/internal/config" 11 | "github.com/nalgeon/codapi/internal/logx" 12 | "github.com/nalgeon/codapi/internal/sandbox" 13 | "github.com/nalgeon/codapi/internal/server" 14 | ) 15 | 16 | // set by the build process 17 | var ( 18 | version = "main" 19 | commit = "none" 20 | date = "unknown" 21 | ) 22 | 23 | // startServer starts the HTTP API sandbox server. 24 | func startServer(port int) *server.Server { 25 | const host = "" // listen on all interfaces 26 | logx.Log("codapi %s, commit %s, built at %s", version, commit, date) 27 | logx.Log("listening on 0.0.0.0:%d...", port) 28 | router := server.NewRouter() 29 | srv := server.NewServer(host, port, router) 30 | srv.Start() 31 | return srv 32 | } 33 | 34 | // startDebug servers the debug handlers. 35 | func startDebug(port int) *server.Server { 36 | const host = "localhost" 37 | logx.Log("debugging on localhost:%d...", port) 38 | router := server.NewDebug() 39 | srv := server.NewServer(host, port, router) 40 | srv.Start() 41 | return srv 42 | } 43 | 44 | // listenSignals listens for termination signals 45 | // and performs graceful shutdown. 46 | func listenSignals(servers ...*server.Server) { 47 | sigs := make(chan os.Signal, 1) 48 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 49 | <-sigs 50 | logx.Log("stopping...") 51 | for _, srv := range servers { 52 | err := srv.Stop() 53 | if err != nil { 54 | logx.Log("failed to stop: %v", err) 55 | } 56 | } 57 | } 58 | 59 | func main() { 60 | port := flag.Int("port", 1313, "server port") 61 | flag.Parse() 62 | 63 | cfg, err := config.Read(".") 64 | if err != nil { 65 | logx.Log("read config: %v", err) 66 | os.Exit(1) 67 | } 68 | 69 | err = sandbox.ApplyConfig(cfg) 70 | if err != nil { 71 | logx.Log("apply config: %v", err) 72 | os.Exit(1) 73 | } 74 | 75 | srv := startServer(*port) 76 | logx.Verbose = cfg.Verbose 77 | logx.Log("workers: %d", cfg.PoolSize) 78 | logx.Log("boxes: %v", cfg.BoxNames()) 79 | logx.Log("commands: %v", cfg.CommandNames()) 80 | 81 | if cfg.Verbose { 82 | debug := startDebug(6060) 83 | listenSignals(srv, debug) 84 | } else { 85 | listenSignals(srv) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /codapi-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | cd "$(dirname "$0")" 5 | sandboxes_dir="sandboxes" 6 | 7 | main() { 8 | if [[ "$#" -eq 1 && ("$1" == "-h" || "$1" == "--help") ]]; then 9 | do_help 10 | exit 0 11 | fi 12 | if [[ "$#" -lt 2 ]]; then 13 | echo "Usage: $0 [args...]" 14 | exit 1 15 | fi 16 | 17 | local resource="$1"; shift 18 | local command="$1"; shift 19 | 20 | case "$resource" in 21 | "sandbox") 22 | do_sandbox "$command" "$@" 23 | ;; 24 | *) 25 | echo "Unknown resource: $resource" 26 | exit 1 27 | ;; 28 | esac 29 | } 30 | 31 | do_sandbox() { 32 | local command="$1"; shift 33 | mkdir -p "$sandboxes_dir" 34 | case "$command" in 35 | "add") 36 | do_sandbox_add "$@" 37 | ;; 38 | "rm") 39 | do_sandbox_rm "$@" 40 | ;; 41 | "ls") 42 | do_sandbox_ls "$@" 43 | ;; 44 | *) 45 | echo "Unknown command: $command" 46 | exit 1 47 | ;; 48 | esac 49 | } 50 | 51 | do_sandbox_add() { 52 | # Command: codapi-cli sandbox add 53 | 54 | local path="${1:-}" 55 | if [[ -z "$path" ]]; then 56 | echo "Usage: $0 sandbox add " 57 | exit 1 58 | fi 59 | 60 | # 0. Check if the path a package name. 61 | if [[ "$path" =~ ^[a-zA-Z0-9_-]*$ ]]; then 62 | # This is a package name, so we need to download it from the registry. 63 | path="https://github.com/nalgeon/sandboxes/releases/download/latest/$path.tar.gz" 64 | fi 65 | 66 | # 1. Set the name of the sandbox. 67 | local filename 68 | filename=$(basename "$path") 69 | local archive_path="$sandboxes_dir/$filename" 70 | local name 71 | if [[ "$filename" == *.tar.gz ]]; then 72 | name="${filename%.tar.gz}" 73 | else 74 | echo "ERROR: Archive name must end with .tar.gz" 75 | rm -f "$archive_path" 76 | exit 1 77 | fi 78 | 79 | # 2. Check if the sandbox already exists. 80 | local target_dir="$sandboxes_dir/$name" 81 | if [[ -d "$target_dir" ]]; then 82 | echo "ERROR: Sandbox '$name' already exists" 83 | echo "Remove it with 'codapi-cli sandbox rm $name' and try again" 84 | rm -f "$archive_path" 85 | exit 1 86 | fi 87 | 88 | # 3. Get the sandbox archive. 89 | if [[ "$path" == http://* || "$path" == https://* ]]; then 90 | echo "Downloading from $path..." 91 | if ! curl --location --progress-bar --output "$sandboxes_dir/$filename" "$path"; then 92 | echo "ERROR: Failed to download sandbox archive from $path" 93 | exit 1 94 | fi 95 | else 96 | echo "Copying local file $path..." 97 | if [[ ! -f "$path" ]]; then 98 | echo "ERROR: File not found at $path" 99 | exit 1 100 | fi 101 | if ! cp "$path" "$sandboxes_dir/"; then 102 | echo "ERROR: Failed to copy sandbox archive from $path" 103 | exit 1 104 | fi 105 | fi 106 | 107 | # 4. Extract the archive. 108 | echo "Extracting $archive_path to $target_dir..." 109 | mkdir -p "$target_dir" 110 | if ! tar -xzf "$archive_path" -C "$sandboxes_dir"; then 111 | echo "ERROR: Failed to extract sandbox archive $archive_path" 112 | rm -rf "$target_dir" 113 | rm -f "$archive_path" 114 | exit 1 115 | fi 116 | rm -f "$archive_path" 117 | 118 | # 5. Run build.sh if it exists. 119 | local build_script="$target_dir/build.sh" 120 | if [[ -f "$build_script" ]]; then 121 | echo "Running build script: $build_script" 122 | if [[ ! -x "$build_script" ]]; then 123 | echo "Warning: $build_script is not executable, fixing..." 124 | chmod +x "$build_script" 125 | fi 126 | # Execute the script from within its directory 127 | (cd "$target_dir" && ./build.sh) 128 | if [[ $? -ne 0 ]]; then 129 | echo "ERROR: build.sh failed for sandbox '$name'" 130 | exit 1 131 | fi 132 | fi 133 | 134 | # 6. Run setup.sh if it exists. 135 | local setup_script="$target_dir/setup.sh" 136 | if [[ -f "$setup_script" ]]; then 137 | echo "Running setup script: $setup_script" 138 | if [[ ! -x "$setup_script" ]]; then 139 | echo "Warning: $setup_script is not executable, fixing..." 140 | chmod +x "$setup_script" 141 | fi 142 | # Execute the script from within its directory 143 | (cd "$target_dir" && ./setup.sh) 144 | if [[ $? -ne 0 ]]; then 145 | echo "ERROR: setup.sh failed for sandbox '$name'" 146 | exit 1 147 | fi 148 | fi 149 | 150 | # 7. Display success message. 151 | echo "✓ Successfully added sandbox '$name' ($target_dir)" 152 | } 153 | 154 | do_sandbox_rm() { 155 | # Command: codapi-cli sandbox rm 156 | 157 | local name="${1:-}" 158 | if [[ -z "$name" ]]; then 159 | echo "Usage: $0 sandbox rm " 160 | exit 1 161 | fi 162 | 163 | local target_dir="$sandboxes_dir/$name" 164 | if [[ ! -d "$target_dir" ]]; then 165 | echo "ERROR: Sandbox '$name' does not exist" 166 | exit 1 167 | fi 168 | 169 | # 1. Remove the container if it exists. 170 | if docker ps -a -q --filter "name=$name" | grep -q .; then 171 | echo "Removing container '$name'..." 172 | docker rm -f "$name" 173 | fi 174 | 175 | # 2. Remove the directory and its contents. 176 | echo "Removing sandbox '$name' from $sandboxes_dir..." 177 | rm -rf "$target_dir" 178 | echo "✓ Successfully removed sandbox '$name'" 179 | } 180 | 181 | do_sandbox_ls() { 182 | # Command: codapi-cli sandbox ls 183 | 184 | local name 185 | local count=0 186 | echo "Available sandboxes:" 187 | for dir in "$sandboxes_dir"/*; do 188 | if [[ -d "$dir" ]]; then 189 | name=$(basename "$dir") 190 | echo " - $name ($dir)" 191 | count=$((count + 1)) 192 | fi 193 | done 194 | if [[ $count -eq 0 ]]; then 195 | echo "(none)" 196 | else 197 | echo "($count total)" 198 | fi 199 | } 200 | 201 | do_help() { 202 | echo "Usage: $0 [args...]" 203 | echo "" 204 | echo "Resources:" 205 | echo " sandbox Manage sandboxes" 206 | echo "" 207 | echo "sandbox commands:" 208 | echo " add Add a new sandbox" 209 | echo " rm Remove an existing sandbox" 210 | echo " ls List all sandboxes" 211 | } 212 | 213 | main "$@" 214 | -------------------------------------------------------------------------------- /codapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "pool_size": 8, 3 | "verbose": true, 4 | "box": { 5 | "runtime": "runc", 6 | "cpu": 1, 7 | "memory": 64, 8 | "network": "none", 9 | "writable": false, 10 | "volume": "%s:/sandbox:ro", 11 | "cap_drop": ["all"], 12 | "ulimit": ["nofile=96"], 13 | "nproc": 64 14 | }, 15 | "step": { 16 | "user": "sandbox", 17 | "action": "run", 18 | "timeout": 5, 19 | "noutput": 4096 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /codapi.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Codapi - code sandbox server 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=codapi 8 | WorkingDirectory=/opt/codapi 9 | ExecStart=/opt/codapi/codapi 10 | Restart=on-failure 11 | StandardOutput=file:/opt/codapi/codapi.log 12 | StandardError=file:/opt/codapi/codapi.log 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /docs/add-sandbox.md: -------------------------------------------------------------------------------- 1 | # Adding a sandbox 2 | 3 | A _sandbox_ is an isolated execution environment for running code snippets. A sandbox is typically implemented as one or more Docker containers. A sandbox supports at least one _command_ (usually the `run` one), but it can support more (like `test` or any other). 4 | 5 | Codapi comes with a single `ash` sandbox preinstalled, but you can easily add others. Let's see some examples. 6 | 7 | ## Add a sandbox from the registry 8 | 9 | The [sandboxes](https://github.com/nalgeon/sandboxes) repository contains several dozen ready-to-use sandboxes, from Go and Rust, to PostgreSQL and ClickHouse, to Caddy and Ripgrep. 10 | 11 | To add a sandbox, use `codapi-cli` like this: 12 | 13 | ```text 14 | ./codapi-cli sandbox add 15 | ``` 16 | 17 | For example: 18 | 19 | ```sh 20 | ./codapi-cli sandbox add lua 21 | ./codapi-cli sandbox add go 22 | ./codapi-cli sandbox add mariadb 23 | ``` 24 | 25 | Then restart the Codapi server and you're done. 26 | 27 | ## Create a sandbox from scratch 28 | 29 | First, let's create a Docker image capable of running Python with some third-party packages: 30 | 31 | ```sh 32 | cd /opt/codapi 33 | mkdir sandboxes/python 34 | touch sandboxes/python/Dockerfile 35 | touch sandboxes/python/requirements.txt 36 | ``` 37 | 38 | Fill the `Dockerfile`: 39 | 40 | ```Dockerfile 41 | FROM python:3.13-alpine 42 | 43 | RUN adduser --home /sandbox --disabled-password sandbox 44 | 45 | COPY requirements.txt /tmp 46 | RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm -f /tmp/requirements.txt 47 | 48 | USER sandbox 49 | WORKDIR /sandbox 50 | 51 | ENV PYTHONDONTWRITEBYTECODE=1 52 | ENV PYTHONUNBUFFERED=1 53 | ``` 54 | 55 | And the `requirements.txt`: 56 | 57 | ``` 58 | numpy 59 | pandas 60 | ``` 61 | 62 | Build the image: 63 | 64 | ```sh 65 | docker build --file sandboxes/python/Dockerfile --tag codapi/python:latest sandboxes/python 66 | ``` 67 | 68 | Then register the image as a Codapi _box_. To do this, we create `sandboxes/python/box.json`: 69 | 70 | ```js 71 | { 72 | "image": "codapi/python" 73 | } 74 | ``` 75 | 76 | Finally, let's configure what happens when the client executes the `run` command in the `python` sandbox. To do this, we create `sandboxes/python/commands.json`: 77 | 78 | ```js 79 | { 80 | "run": { 81 | "engine": "docker", 82 | "entry": "main.py", 83 | "steps": [ 84 | { 85 | "box": "python", 86 | "command": ["python", "main.py"] 87 | } 88 | ] 89 | } 90 | } 91 | ``` 92 | 93 | This is essentially what it says: 94 | 95 | > When the client executes the `run` command in the `python` sandbox, save their code to the `main.py` file, then run it in the `python` box (Docker container) using the `python main.py` shell command. 96 | 97 | What if we want to add another command (say, `test`) to the same sandbox? Let's edit `sandboxes/python/commands.json` again: 98 | 99 | ```js 100 | { 101 | "run": { 102 | // ... 103 | }, 104 | "test": { 105 | "engine": "docker", 106 | "entry": "test_main.py", 107 | "steps": [ 108 | { 109 | "box": "python", 110 | "command": ["python", "-m", "unittest"], 111 | "noutput": 8192 112 | } 113 | ] 114 | } 115 | } 116 | ``` 117 | 118 | Besides configuring a different shell command, here we increased the maximum output size to 8Kb, as tests tend to be quite chatty (you can see the default value in `codapi.json`). 119 | 120 | To apply the changed configuration, restart Codapi and try running some Python code: 121 | 122 | ```sh 123 | curl -H "content-type: application/json" -d '{ "sandbox": "python", "command": "run", "files": {"": "print(42)" }}' http://localhost:1313/v1/exec 124 | ``` 125 | 126 | Which produces the following output: 127 | 128 | ```json 129 | { 130 | "id": "python_run_7683de5a", 131 | "ok": true, 132 | "duration": 252, 133 | "stdout": "42\n", 134 | "stderr": "" 135 | } 136 | ``` 137 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | Call `/v1/exec` to run the code in a sandbox: 4 | 5 | ```http 6 | POST http://localhost:1313/v1/exec 7 | content-type: application/json 8 | 9 | { 10 | "sandbox": "python", 11 | "command": "run", 12 | "files": { 13 | "": "print('hello world')" 14 | } 15 | } 16 | ``` 17 | 18 | `sandbox` is the name of the pre-configured sandbox, and `command` is the name of a command supported by that sandbox. See [Adding a sandbox](add-sandbox.md) for details on how to add a new sandbox. 19 | 20 | `files` is a map, where the key is a filename and the value is its contents. When executing a single file, it should either be named as the `command` expects, or be an empty string (as in the example above). 21 | 22 | Response: 23 | 24 | ```http 25 | HTTP/1.1 200 OK 26 | Content-Type: application/json 27 | 28 | { 29 | "id": "python_run_9b7b1afd", 30 | "ok": true, 31 | "duration": 314, 32 | "stdout": "hello world\n", 33 | "stderr": "" 34 | } 35 | ``` 36 | 37 | - `id` is the unique execution identifier. 38 | - `ok` is `true` if the code executed without errors, or `false` otherwise. 39 | - `duration` is the execution time in milliseconds. 40 | - `stdout` is what the code printed to the standard output. 41 | - `stderr` is what the code printed to the standard error, or a compiler/os error (if any). 42 | -------------------------------------------------------------------------------- /docs/docker-xfs.md: -------------------------------------------------------------------------------- 1 | # XFS filesystem for Docker 2 | 3 | 1. Install the necessary packages: 4 | 5 | ```bash 6 | sudo apt-get update 7 | sudo apt-get install xfsprogs 8 | ``` 9 | 10 | 2. Identify the disk or partition you want to use for the new filesystem. You can use the `lsblk` or `fdisk -l` command to list the available disks and partitions. Make sure you select the correct one as this process will erase all data on it. 11 | 12 | 3. If the partition you want to use is not formatted, format it with an XFS filesystem. Replace `/dev/sdX` with the appropriate device identifier: 13 | 14 | ```bash 15 | sudo mkfs.xfs /dev/sdX 16 | ``` 17 | 18 | 4. Once the partition is formatted, create a mount point. This will be the directory where the new filesystem will be mounted: 19 | 20 | ```bash 21 | sudo mkdir /mnt/docker 22 | ``` 23 | 24 | 5. Update the `/etc/fstab` file to automatically mount the new filesystem at boot: 25 | 26 | ``` 27 | /dev/sdX /mnt/docker xfs defaults,nofail,discard,noatime,quota,prjquota,pquota,gquota 0 2 28 | ``` 29 | 30 | 6. Mount the new filesystem and verify that it is working: 31 | 32 | ```bash 33 | sudo mount -a 34 | df -h 35 | ``` 36 | 37 | The output of `df -h` should show the new filesystem mounted at `/mnt/docker`. 38 | 39 | 7. Stop the docker daemon: 40 | 41 | ```bash 42 | systemctl stop docker 43 | ``` 44 | 45 | 8. Update the `/etc/docker/daemon.json` file to point docker to the new mount point: 46 | 47 | ```json 48 | { 49 | "data-root": "/mnt/docker" 50 | } 51 | ``` 52 | 53 | 9. Start the docker daemon: 54 | 55 | ```bash 56 | systemctl start docker 57 | ``` 58 | 59 | 10. Build the images: 60 | 61 | ```bash 62 | su - codapi 63 | make images 64 | ``` 65 | 66 | 11. Verify that docker can now limit the storage size: 67 | 68 | ```bash 69 | docker run -it --storage-opt size=16m codapi/alpine /bin/df -h | grep overlay 70 | ``` 71 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installing Codapi 2 | 3 | Make sure you install Codapi on a separate machine — this is a must for security reasons. Do not store any sensitive data or credentials on this machine. This way, even if someone runs malicious code that somehow escapes the isolated environment, they won't have access to your other machines and data. 4 | 5 | Steps for Debian (11+): 6 | 7 | 1. Install necessary packages: 8 | 9 | ```sh 10 | sudo apt update && sudo apt install -y ca-certificates curl make unzip 11 | ``` 12 | 13 | Install [Docker](https://docs.docker.com/engine/install/debian/). Note: Do not install the `docker.io` package. Follow the official Docker instructions for Debian instead. 14 | 15 | After installing Docker, start it: 16 | 17 | ```sh 18 | sudo systemctl enable docker.service 19 | sudo systemctl restart docker.service 20 | ``` 21 | 22 | Verify that Docker is working: 23 | 24 | ```sh 25 | docker run hello-world 26 | ``` 27 | 28 | 2. Create Codapi user: 29 | 30 | ```sh 31 | sudo useradd --groups docker --shell /usr/bin/bash --create-home --home /opt/codapi codapi 32 | ``` 33 | 34 | Install Codapi: 35 | 36 | ```sh 37 | sudo su - codapi 38 | cd /opt/codapi 39 | curl -L -o codapi.tar.gz "https://github.com/nalgeon/codapi/releases/download/v0.11.0/codapi_0.11.0_linux_amd64.tar.gz" 40 | tar xvzf codapi.tar.gz 41 | rm -f codapi.tar.gz 42 | ``` 43 | 44 | 3. Build the sample `ash` sandbox image: 45 | 46 | ```sh 47 | sudo su - codapi 48 | cd /opt/codapi 49 | docker build --file sandboxes/ash/Dockerfile --tag codapi/ash:latest sandboxes/ash 50 | ``` 51 | 52 | Verify that Codapi starts without errors (as codapi): 53 | 54 | ```sh 55 | sudo su - codapi 56 | cd /opt/codapi 57 | ./codapi 58 | ``` 59 | 60 | It should list `ash` in both `boxes` and `commands`: 61 | 62 | ``` 63 | 2023/09/16 15:18:05 codapi 20230915:691d224 64 | 2023/09/16 15:18:05 listening on port 1313... 65 | 2023/09/16 15:18:05 workers: 8 66 | 2023/09/16 15:18:05 boxes: [alpine] 67 | 2023/09/16 15:18:05 commands: [sh] 68 | ``` 69 | 70 | Stop it with Ctrl+C. 71 | 72 | 4. Configure Codapi as systemd service: 73 | 74 | ```sh 75 | sudo mv /opt/codapi/codapi.service /etc/systemd/system/ 76 | sudo chown root:root /etc/systemd/system/codapi.service 77 | sudo systemctl enable codapi.service 78 | sudo systemctl start codapi.service 79 | ``` 80 | 81 | Verify that the Codapi service is running: 82 | 83 | ```sh 84 | sudo systemctl status codapi.service 85 | ``` 86 | 87 | Should print `active (running)`: 88 | 89 | ``` 90 | codapi.service - Code playgrounds 91 | Loaded: loaded (/etc/systemd/system/codapi.service; enabled; preset: enabled) 92 | Active: active (running) 93 | ... 94 | ``` 95 | 96 | 5. Verify that Codapi is working: 97 | 98 | ```sh 99 | curl -H "content-type: application/json" -d '{ "sandbox": "ash", "command": "run", "files": {"": "echo hello" }}' http://localhost:1313/v1/exec 100 | ``` 101 | 102 | Should print `ok` = `true`: 103 | 104 | ```json 105 | { 106 | "id": "ash_run_dd27ed27", 107 | "ok": true, 108 | "duration": 650, 109 | "stdout": "hello\n", 110 | "stderr": "" 111 | } 112 | ``` 113 | -------------------------------------------------------------------------------- /docs/nginx.md: -------------------------------------------------------------------------------- 1 | # Connecting Nginx to Codapi 2 | 3 | Nginx serves as the entry point for all HTTP(S) requests, while Codapi handles the requests and returns the results to Nginx, which in turn returns them to the end user. 4 | 5 | Follow these steps to configure Nginx to work with Codapi: 6 | 7 | 1. Add a config file to Nginx (e.g. `/etc/nginx/sites-available/domain.org`): 8 | 9 | ``` 10 | # response cache 11 | proxy_cache_path /var/cache/nginx keys_zone=codapi:10m levels=1:2 max_size=512m inactive=60m use_temp_path=off; 12 | 13 | # allowed origins (domains) 14 | map $http_origin $auth_origin { 15 | default 0; 16 | "http://localhost:3000" 1; 17 | "http://127.0.0.1:3000" 1; 18 | "https://domain.org" 1; 19 | } 20 | 21 | server { 22 | listen 80; 23 | listen 443 ssl; 24 | server_name domain.org; 25 | 26 | # limit request rate 27 | limit_req_zone $binary_remote_addr zone=sandbox:10m rate=10r/m; 28 | limit_req_status 429; 29 | 30 | # limit request size 31 | client_header_buffer_size 1k; 32 | client_body_buffer_size 16k; 33 | client_max_body_size 16k; 34 | large_client_header_buffers 2 1k; 35 | 36 | # persistent connections to upstream 37 | proxy_http_version 1.1; 38 | proxy_set_header Connection ""; 39 | 40 | # pass client's ip to upstream 41 | proxy_set_header Host $host; 42 | proxy_set_header X-Real-IP $remote_addr; 43 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 44 | 45 | # https certificate 46 | ssl_certificate /etc/letsencrypt/live/domain.org/fullchain.pem; 47 | ssl_certificate_key /etc/letsencrypt/live/domain.org/privkey.pem; 48 | 49 | # response cache 50 | proxy_cache codapi; 51 | proxy_cache_methods GET HEAD POST; 52 | proxy_cache_key "$proxy_host$request_uri:$request_body"; 53 | proxy_cache_valid 429 10s; 54 | proxy_cache_valid 404 500 502 503 1m; 55 | proxy_cache_valid any 60m; 56 | proxy_cache_use_stale updating error timeout http_500 http_502 http_503; 57 | proxy_cache_lock on; 58 | proxy_ignore_headers cache-control; 59 | add_header x-cache-status $upstream_cache_status; 60 | 61 | location / { 62 | if ($auth_origin = "0") { 63 | return 403; 64 | } 65 | limit_req zone=sandbox burst=20 nodelay; 66 | proxy_pass http://localhost:1313; 67 | } 68 | } 69 | ``` 70 | 71 | Replace `domain.org` with your actual domain and make sure the `ssl_certificate` and `ssl_certificate_key` paths are correct (you can see them with certbot). 72 | 73 | Set the allowed domains in the "allowed origins" section. 74 | 75 | 2. Activate the configuration: 76 | 77 | ```sh 78 | sudo ln -s /etc/nginx/sites-available/domain.org /etc/nginx/sites-enabled/ 79 | ``` 80 | 81 | 3. Make sure there are no errors: 82 | 83 | ```sh 84 | sudo nginx -t 85 | ``` 86 | 87 | 4. Restart Nginx: 88 | 89 | ```sh 90 | sudo systemctl restart nginx 91 | ``` 92 | 93 | 5. Verify that Nginx+Codapi is working: 94 | 95 | ```sh 96 | curl -H "content-type: application/json" -d '{ "sandbox": "sh", "command": "run", "files": {"": "echo hello" }}' https://domain.org/v1/exec 97 | ``` 98 | -------------------------------------------------------------------------------- /docs/production.md: -------------------------------------------------------------------------------- 1 | # Deploying to production 2 | 3 | If you want to run a public Codapi instance, you'll need a server for that. 4 | 5 | The server requirements mainly depend on the sandboxes you plan to install (e.g. just Python and SQLite or something heavier) and the amount of code runs you expect from your users (e.g. 1000 runs per day). 6 | 7 | Personally, I prefer [DigitalOcean](https://www.digitalocean.com/) for hosting. For lighter sandboxes, a $6 (1CPU/1GB RAM) or $12 (1CPU/2GB RAM) [Basic](https://www.digitalocean.com/pricing/droplets#basic-droplets) droplet would do the trick. If you need something more powerful, a $42 [CPU-optimized droplet](https://www.digitalocean.com/pricing/droplets#cpu-optimized) (2CPU/4GB RAM) will definitely suffice. 8 | 9 | I recommend using Debian as the operating system. 10 | 11 | Follow these steps to set up a production server: 12 | 13 | 1. [Install Codapi](install.md) on the server. 14 | 15 | 2. Install [Nginx](https://www.digitalocean.com/community/tutorials/how-to-install-nginx-on-debian-11) and an [HTTPS certificate](https://certbot.eff.org/) (if you want Codapi to be accessible via HTTPS). 16 | 17 | 3. Connect [Nginx to Codapi](nginx.md). 18 | 19 | You can also use Caddy or any other proxy you prefer instead of Nginx. 20 | 21 | That's it! 22 | -------------------------------------------------------------------------------- /docs/update.md: -------------------------------------------------------------------------------- 1 | # Updating Codapi 2 | 3 | Follow these steps to update Codapi to a specific version. 4 | 5 | 1. Backup the current version: 6 | 7 | ```sh 8 | cd /opt/codapi 9 | mv codapi codapi.bak 10 | ``` 11 | 12 | 2. Download and install the new version (replace `0.x.x` with an actual version number): 13 | 14 | ```sh 15 | cd /opt/codapi 16 | export version="0.x.x" 17 | curl -L -o codapi.tar.gz "https://github.com/nalgeon/codapi/releases/download/${version}/codapi_${version}_linux_amd64.tar.gz" 18 | tar xvzf codapi.tar.gz 19 | rm -f codapi.tar.gz 20 | ``` 21 | 22 | 3. Restart Codapi: 23 | 24 | ```sh 25 | sudo systemctl restart codapi.service 26 | ``` 27 | 28 | 4. Verify that Codapi is working: 29 | 30 | ```sh 31 | curl -H "content-type: application/json" -d '{ "sandbox": "ash", "command": "run", "files": {"": "echo hello" }}' http://localhost:1313/v1/exec 32 | ``` 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nalgeon/codapi 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nalgeon/codapi/31f16c085328fa3d3bfb2d89b56fc7e17208202d/go.sum -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | // Package config reads application config. 2 | package config 3 | 4 | import ( 5 | "encoding/json" 6 | "sort" 7 | ) 8 | 9 | // A Config describes application config. 10 | type Config struct { 11 | PoolSize int `json:"pool_size"` 12 | Verbose bool `json:"verbose"` 13 | Box *Box `json:"box"` 14 | Step *Step `json:"step"` 15 | HTTP *HTTP `json:"http"` 16 | 17 | // These are the available containers ("boxes"). 18 | Boxes map[string]*Box `json:"boxes"` 19 | 20 | // These are the "sandboxes". Each sandbox can contain 21 | // multiple commands, and each command can contain 22 | // multiple steps. Each step is executed in a specific box. 23 | Commands map[string]SandboxCommands `json:"commands"` 24 | } 25 | 26 | // BoxNames returns configured box names. 27 | func (cfg *Config) BoxNames() []string { 28 | names := make([]string, 0, len(cfg.Boxes)) 29 | for name := range cfg.Boxes { 30 | names = append(names, name) 31 | } 32 | sort.Strings(names) 33 | return names 34 | } 35 | 36 | // CommandNames returns configured command names. 37 | func (cfg *Config) CommandNames() []string { 38 | names := make([]string, 0, len(cfg.Commands)) 39 | for name := range cfg.Commands { 40 | names = append(names, name) 41 | } 42 | sort.Strings(names) 43 | return names 44 | } 45 | 46 | // ToJSON returns JSON-encoded config with indentation. 47 | func (cfg *Config) ToJSON() string { 48 | data, _ := json.MarshalIndent(cfg, "", " ") 49 | return string(data) 50 | } 51 | 52 | // A Box describes a specific container. 53 | // There is an important difference between a "sandbox" and a "box". 54 | // A box is a single container. A sandbox is an environment in which we run commands. 55 | // A sandbox command can contain multiple steps, each of which runs in a separate box. 56 | // So the relation sandbox -> box is 1 -> 1+. 57 | type Box struct { 58 | Name string `json:"name"` 59 | Image string `json:"image"` 60 | Runtime string `json:"runtime"` 61 | Host 62 | 63 | Files []string `json:"files"` 64 | } 65 | 66 | // A Host describes container Host attributes. 67 | type Host struct { 68 | CPU int `json:"cpu"` 69 | Memory int `json:"memory"` 70 | Storage string `json:"storage"` 71 | Network string `json:"network"` 72 | Writable bool `json:"writable"` 73 | Volume string `json:"volume"` 74 | Tmpfs []string `json:"tmpfs"` 75 | CapAdd []string `json:"cap_add"` 76 | CapDrop []string `json:"cap_drop"` 77 | Ulimit []string `json:"ulimit"` 78 | // do not use the ulimit nproc because it is 79 | // a per-user setting, not a per-container setting 80 | NProc int `json:"nproc"` 81 | } 82 | 83 | // SandboxCommands describes all commands available for a sandbox. 84 | // command name : command 85 | type SandboxCommands map[string]*Command 86 | 87 | // A Command describes a specific set of actions to take 88 | // when executing a command in a sandbox. 89 | type Command struct { 90 | Engine string `json:"engine"` 91 | Entry string `json:"entry"` 92 | Before *Step `json:"before"` 93 | Steps []*Step `json:"steps"` 94 | After *Step `json:"after"` 95 | } 96 | 97 | // A Step describes a single step of a command. 98 | type Step struct { 99 | Box string `json:"box"` 100 | Version string `json:"version"` 101 | User string `json:"user"` 102 | Action string `json:"action"` 103 | Detach bool `json:"detach"` 104 | Stdin bool `json:"stdin"` 105 | Command []string `json:"command"` 106 | Timeout int `json:"timeout"` 107 | NOutput int `json:"noutput"` 108 | } 109 | 110 | // An HTTP describes HTTP engine settings. 111 | type HTTP struct { 112 | Hosts map[string]string `json:"hosts"` 113 | } 114 | 115 | // setBoxDefaults sets default box properties 116 | // instead of zero values. 117 | func setBoxDefaults(box, defs *Box) { 118 | if box.Runtime == "" { 119 | box.Runtime = defs.Runtime 120 | } 121 | if box.CPU == 0 { 122 | box.CPU = defs.CPU 123 | } 124 | if box.Memory == 0 { 125 | box.Memory = defs.Memory 126 | } 127 | if box.Storage == "" { 128 | box.Storage = defs.Storage 129 | } 130 | if box.Network == "" { 131 | box.Network = defs.Network 132 | } 133 | if box.Volume == "" { 134 | box.Volume = defs.Volume 135 | } 136 | if box.Tmpfs == nil { 137 | box.Tmpfs = defs.Tmpfs 138 | } 139 | if box.CapAdd == nil { 140 | box.CapAdd = defs.CapAdd 141 | } 142 | if box.CapDrop == nil { 143 | box.CapDrop = defs.CapDrop 144 | } 145 | if box.Ulimit == nil { 146 | box.Ulimit = defs.Ulimit 147 | } 148 | if box.NProc == 0 { 149 | box.NProc = defs.NProc 150 | } 151 | } 152 | 153 | // setStepDefaults sets default command step 154 | // properties instead of zero values. 155 | func setStepDefaults(step, defs *Step) { 156 | if step.User == "" { 157 | step.User = defs.User 158 | } 159 | if step.Action == "" { 160 | step.Action = defs.Action 161 | } 162 | if step.Timeout == 0 { 163 | step.Timeout = defs.Timeout 164 | } 165 | if step.NOutput == 0 { 166 | step.NOutput = defs.NOutput 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestConfig_BoxNames(t *testing.T) { 10 | cfg := &Config{ 11 | Boxes: map[string]*Box{ 12 | "go": {}, 13 | "python": {}, 14 | }, 15 | } 16 | 17 | want := []string{"go", "python"} 18 | got := cfg.BoxNames() 19 | if !reflect.DeepEqual(got, want) { 20 | t.Errorf("BoxNames: expected %v, got %v", want, got) 21 | } 22 | } 23 | 24 | func TestConfig_CommandNames(t *testing.T) { 25 | cfg := &Config{ 26 | Commands: map[string]SandboxCommands{ 27 | "go": map[string]*Command{ 28 | "run": {}, 29 | }, 30 | "python": map[string]*Command{ 31 | "run": {}, 32 | "test": {}, 33 | }, 34 | }, 35 | } 36 | 37 | want := []string{"go", "python"} 38 | got := cfg.CommandNames() 39 | if !reflect.DeepEqual(got, want) { 40 | t.Errorf("CommandNames: expected %v, got %v", want, got) 41 | } 42 | } 43 | 44 | func TestConfig_ToJSON(t *testing.T) { 45 | cfg := &Config{ 46 | PoolSize: 8, 47 | Boxes: map[string]*Box{ 48 | "go": {}, 49 | "python": {}, 50 | }, 51 | Commands: map[string]SandboxCommands{ 52 | "go": map[string]*Command{ 53 | "run": {}, 54 | }, 55 | "python": map[string]*Command{ 56 | "run": {}, 57 | "test": {}, 58 | }, 59 | }, 60 | } 61 | 62 | got := cfg.ToJSON() 63 | if !strings.Contains(got, `"pool_size": 8`) { 64 | t.Error("ToJSON: expected pool_size = 8") 65 | } 66 | } 67 | 68 | func Test_setBoxDefaults(t *testing.T) { 69 | box := &Box{} 70 | defs := &Box{ 71 | Image: "codapi/python", 72 | Runtime: "runc", 73 | Host: Host{ 74 | CPU: 1, Memory: 64, Storage: "16m", 75 | Network: "none", Writable: true, 76 | Volume: "%s:/sandbox:ro", 77 | Tmpfs: []string{"/tmp:rw,size=16m"}, 78 | CapAdd: []string{"all"}, 79 | CapDrop: []string{"none"}, 80 | Ulimit: []string{"nofile=96"}, 81 | NProc: 96, 82 | }, 83 | Files: []string{"config.py"}, 84 | } 85 | setBoxDefaults(box, defs) 86 | if box.Image != "" { 87 | t.Error("Image: should not set default value") 88 | } 89 | if box.Runtime != defs.Runtime { 90 | t.Errorf("Runtime: expected %s, got %s", defs.Runtime, box.Runtime) 91 | } 92 | if box.CPU != defs.CPU { 93 | t.Errorf("CPU: expected %d, got %d", defs.CPU, box.CPU) 94 | } 95 | if box.Memory != defs.Memory { 96 | t.Errorf("Memory: expected %d, got %d", defs.Memory, box.Memory) 97 | } 98 | if box.Storage != defs.Storage { 99 | t.Errorf("Storage: expected %s, got %s", defs.Storage, box.Storage) 100 | } 101 | if box.Network != defs.Network { 102 | t.Errorf("Network: expected %s, got %s", defs.Network, box.Network) 103 | } 104 | if box.Volume != defs.Volume { 105 | t.Errorf("Volume: expected %s, got %s", defs.Volume, box.Volume) 106 | } 107 | if !reflect.DeepEqual(box.Tmpfs, defs.Tmpfs) { 108 | t.Errorf("Tmpfs: expected %v, got %v", defs.Tmpfs, box.Tmpfs) 109 | } 110 | if !reflect.DeepEqual(box.CapAdd, defs.CapAdd) { 111 | t.Errorf("CapAdd: expected %v, got %v", defs.CapAdd, box.CapAdd) 112 | } 113 | if !reflect.DeepEqual(box.CapDrop, defs.CapDrop) { 114 | t.Errorf("CapDrop: expected %v, got %v", defs.CapDrop, box.CapDrop) 115 | } 116 | if !reflect.DeepEqual(box.Ulimit, defs.Ulimit) { 117 | t.Errorf("Ulimit: expected %v, got %v", defs.Ulimit, box.Ulimit) 118 | } 119 | if box.NProc != defs.NProc { 120 | t.Errorf("NProc: expected %d, got %d", defs.NProc, box.NProc) 121 | } 122 | if len(box.Files) != 0 { 123 | t.Error("Files: should not set default value") 124 | } 125 | } 126 | 127 | func Test_setStepDefaults(t *testing.T) { 128 | step := &Step{} 129 | defs := &Step{ 130 | Box: "python", 131 | User: "sandbox", 132 | Action: "run", 133 | Command: []string{"python", "main.py"}, 134 | Timeout: 3, 135 | NOutput: 4096, 136 | } 137 | 138 | setStepDefaults(step, defs) 139 | if step.Box != "" { 140 | t.Error("Box: should not set default value") 141 | } 142 | if step.User != defs.User { 143 | t.Errorf("User: expected %s, got %s", defs.User, step.User) 144 | } 145 | if step.Action != defs.Action { 146 | t.Errorf("Action: expected %s, got %s", defs.Action, step.Action) 147 | } 148 | if len(step.Command) != 0 { 149 | t.Error("Command: should not set default value") 150 | } 151 | if step.Timeout != defs.Timeout { 152 | t.Errorf("Timeout: expected %d, got %d", defs.Timeout, step.Timeout) 153 | } 154 | if step.NOutput != defs.NOutput { 155 | t.Errorf("NOutput: expected %d, got %d", defs.NOutput, step.NOutput) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /internal/config/load.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/nalgeon/codapi/internal/fileio" 11 | "github.com/nalgeon/codapi/internal/logx" 12 | ) 13 | 14 | // Currently, Codapi supports three config layouts. 15 | // Only the first layout is preferred, the other two will be removed in the future. 16 | // 17 | // 1. Sandboxes dir (preferred) 18 | // ├── codapi.json 19 | // └── sandboxes 20 | // ├── bash 21 | // │ ├── Dockerfile 22 | // │ ├── box.json 23 | // │ └── commands.json 24 | // └── python 25 | // ├── Dockerfile 26 | // ├── box.json 27 | // └── commands.json 28 | // 29 | // 2. Images/boxes/commands dirs (deprecated) 30 | // ├── configs 31 | // │ ├── config.json 32 | // │ ├── boxes 33 | // │ │ ├── bash.json 34 | // │ │ └── python.json 35 | // │ └── commands 36 | // │ ├── bash.json 37 | // │ └── python.json 38 | // └── images 39 | // ├── bash 40 | // │ └── Dockerfile 41 | // └── python 42 | // └── Dockerfile 43 | // 44 | // 3. Images/commands dirs + boxes.json (deprecated) 45 | // ├── configs 46 | // │ ├── config.json 47 | // │ ├── boxes.json 48 | // │ └── commands 49 | // │ ├── bash.json 50 | // │ └── python.json 51 | // └── images 52 | // ├── bash 53 | // │ └── Dockerfile 54 | // └── bash 55 | // └── Dockerfile 56 | 57 | const ( 58 | boxesDirname = "boxes" 59 | codapiFilename = "codapi.json" 60 | configFilename = "config.json" 61 | configDirname = "configs" 62 | commandsDirname = "commands" 63 | sandDirname = "sandboxes" 64 | ) 65 | 66 | var ( 67 | ErrMissingBox = errors.New("missing 'box' section in codapi.json") 68 | ErrMissingStep = errors.New("missing 'step' section in codapi.json") 69 | ) 70 | 71 | // Read reads application config from JSON files. 72 | func Read(path string) (*Config, error) { 73 | cfg, err := ReadConfig(path) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | cfg, err = ReadBoxes(cfg, path) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | cfg, err = ReadCommands(cfg, path) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | return cfg, err 89 | } 90 | 91 | // ReadConfig reads application config from a JSON file. 92 | func ReadConfig(basePath string) (*Config, error) { 93 | preferredPath := filepath.Join(basePath, codapiFilename) 94 | fallbackPath := filepath.Join(basePath, configDirname, configFilename) 95 | 96 | if fileio.Exists(preferredPath) { 97 | return readConfig(preferredPath) 98 | } else { 99 | return readConfig(fallbackPath) 100 | } 101 | } 102 | 103 | // ReadBoxes reads boxes config from the file system. 104 | // It prefers the sandboxes dir if it exists, otherwise fallbacks to the 105 | // boxes dir if it exists, and finally fallbacks to the boxes.json file. 106 | func ReadBoxes(cfg *Config, basePath string) (*Config, error) { 107 | var boxes map[string]*Box 108 | var err error 109 | 110 | sandDirPath := filepath.Join(basePath, sandDirname) 111 | boxDirPath := filepath.Join(basePath, configDirname, boxesDirname) 112 | boxFilePath := filepath.Join(basePath, configDirname, boxesDirname+".json") 113 | 114 | if fileio.Exists(sandDirPath) { 115 | // 1st priority is the sandboxes dir. 116 | boxes, err = readBoxesDir(sandDirPath, "*/box.json") 117 | } else if fileio.Exists(boxDirPath) { 118 | // 2nd priority is the configs/boxes dir. 119 | boxes, err = readBoxesDir(boxDirPath, "*.json") 120 | } else { 121 | // 3rd priority is configs/boxes.json. 122 | boxes, err = readBoxesFile(boxFilePath) 123 | } 124 | 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | for _, box := range boxes { 130 | setBoxDefaults(box, cfg.Box) 131 | } 132 | 133 | cfg.Boxes = boxes 134 | return cfg, nil 135 | } 136 | 137 | // ReadCommands reads command configs from the file system. 138 | // It prefers the sandboxes dir if it exists, otherwise 139 | // fallbacks to the commands dir. 140 | func ReadCommands(cfg *Config, basePath string) (*Config, error) { 141 | sandDirPath := filepath.Join(basePath, sandDirname) 142 | commandDirPath := filepath.Join(basePath, configDirname, commandsDirname) 143 | 144 | if fileio.Exists(sandDirPath) { 145 | // Prefer the sandboxes dir. 146 | return readCommands(cfg, sandDirPath, "*/commands.json") 147 | } else { 148 | // Fallback to configs/commands dir. 149 | return readCommands(cfg, commandDirPath, "*.json") 150 | } 151 | } 152 | 153 | // readConfig reads application config from a JSON file. 154 | func readConfig(path string) (*Config, error) { 155 | logx.Debug("reading config from %s", path) 156 | 157 | data, err := os.ReadFile(path) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | cfg := &Config{} 163 | err = json.Unmarshal(data, cfg) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | if cfg.Box == nil { 169 | return nil, ErrMissingBox 170 | } 171 | if cfg.Step == nil { 172 | return nil, ErrMissingStep 173 | } 174 | if cfg.HTTP == nil { 175 | cfg.HTTP = &HTTP{} 176 | } 177 | 178 | return cfg, err 179 | } 180 | 181 | // readBoxesDir reads boxes config from the boxes dir. 182 | func readBoxesDir(path string, pattern string) (map[string]*Box, error) { 183 | logx.Debug("reading boxes from %s/%s", path, pattern) 184 | fnames, err := filepath.Glob(filepath.Join(path, pattern)) 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | boxes := make(map[string]*Box, len(fnames)) 190 | for _, fname := range fnames { 191 | box, err := fileio.ReadJson[Box](fname) 192 | if err != nil { 193 | return nil, err 194 | } 195 | if box.Name == "" { 196 | // Determine the box name from the path. 197 | name := filepath.Base(fname) 198 | if name == "box.json" { 199 | // Use the parent dir name as the box name. 200 | name = filepath.Base(filepath.Dir(fname)) 201 | } else { 202 | // Use the filename without extension as the box name. 203 | name = strings.TrimSuffix(name, ".json") 204 | } 205 | box.Name = name 206 | } 207 | boxes[box.Name] = &box 208 | } 209 | 210 | return boxes, err 211 | } 212 | 213 | // readBoxesFile reads boxes config from the boxes.json file. 214 | func readBoxesFile(path string) (map[string]*Box, error) { 215 | logx.Debug("reading boxes from %s", path) 216 | data, err := os.ReadFile(path) 217 | if err != nil { 218 | return nil, err 219 | } 220 | 221 | boxes := make(map[string]*Box) 222 | err = json.Unmarshal(data, &boxes) 223 | if err != nil { 224 | return nil, err 225 | } 226 | 227 | return boxes, err 228 | } 229 | 230 | // readCommands reads command configs from a set of JSON files in the given path. 231 | func readCommands(cfg *Config, path string, pattern string) (*Config, error) { 232 | logx.Debug("reading commands from %s/%s", path, pattern) 233 | fnames, err := filepath.Glob(filepath.Join(path, pattern)) 234 | if err != nil { 235 | return nil, err 236 | } 237 | 238 | cfg.Commands = make(map[string]SandboxCommands, len(fnames)) 239 | for _, fname := range fnames { 240 | // Determine the sandbox name from the path. 241 | name := filepath.Base(fname) 242 | if name == "commands.json" { 243 | // Use the parent dir name as the sandbox name. 244 | name = filepath.Base(filepath.Dir(fname)) 245 | } else { 246 | // Use the filename without extension as the sandbox name. 247 | name = strings.TrimSuffix(name, ".json") 248 | } 249 | // Read the commands from the file. 250 | commands, err := fileio.ReadJson[SandboxCommands](fname) 251 | if err != nil { 252 | break 253 | } 254 | setCommandDefaults(commands, cfg) 255 | cfg.Commands[name] = commands 256 | } 257 | 258 | return cfg, err 259 | } 260 | 261 | // setCommandDefaults applies global defaults to sandbox commands. 262 | func setCommandDefaults(commands SandboxCommands, cfg *Config) { 263 | for _, cmd := range commands { 264 | if cmd.Before != nil { 265 | setStepDefaults(cmd.Before, cfg.Step) 266 | } 267 | for _, step := range cmd.Steps { 268 | setStepDefaults(step, cfg.Step) 269 | } 270 | if cmd.After != nil { 271 | setStepDefaults(cmd.After, cfg.Step) 272 | } 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /internal/config/load_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestRead(t *testing.T) { 8 | cfg, err := Read("testdata") 9 | if err != nil { 10 | t.Fatalf("unexpected error: %v", err) 11 | } 12 | 13 | if cfg.PoolSize != 8 { 14 | t.Errorf("PoolSize: expected 8, got %d", cfg.PoolSize) 15 | } 16 | if !cfg.Verbose { 17 | t.Error("Verbose: expected true") 18 | } 19 | if cfg.Box.Memory != 64 { 20 | t.Errorf("Box.Memory: expected 64, got %d", cfg.Box.Memory) 21 | } 22 | if cfg.Step.User != "sandbox" { 23 | t.Errorf("Step.User: expected sandbox, got %s", cfg.Step.User) 24 | } 25 | 26 | // alpine box 27 | if _, ok := cfg.Boxes["custom-alpine"]; !ok { 28 | t.Error("Boxes: missing my/alpine box") 29 | } 30 | if cfg.Boxes["custom-alpine"].Image != "custom/alpine" { 31 | t.Errorf( 32 | "Boxes[custom-alpine]: expected custom/alpine image, got %s", 33 | cfg.Boxes["custom-alpine"].Image, 34 | ) 35 | } 36 | 37 | // python box 38 | if _, ok := cfg.Boxes["python"]; !ok { 39 | t.Error("Boxes: missing python box") 40 | } 41 | if _, ok := cfg.Commands["python"]; !ok { 42 | t.Error("Commands: missing python sandbox") 43 | } 44 | if _, ok := cfg.Commands["python"]["run"]; !ok { 45 | t.Error("Commands[python]: missing run command") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/config/testdata/codapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "pool_size": 8, 3 | "verbose": true, 4 | "box": { 5 | "memory": 64 6 | }, 7 | "step": { 8 | "user": "sandbox" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /internal/config/testdata/sandboxes/alpine/box.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-alpine", 3 | "image": "custom/alpine" 4 | } 5 | -------------------------------------------------------------------------------- /internal/config/testdata/sandboxes/python/box.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "codapi/python" 3 | } 4 | -------------------------------------------------------------------------------- /internal/config/testdata/sandboxes/python/commands.json: -------------------------------------------------------------------------------- 1 | { 2 | "run": { 3 | "engine": "docker", 4 | "entry": "main.py", 5 | "steps": [ 6 | { 7 | "box": "python", 8 | "command": ["python", "main.py"] 9 | } 10 | ] 11 | }, 12 | "test": { 13 | "engine": "docker", 14 | "entry": "test_main.py", 15 | "steps": [ 16 | { 17 | "box": "python", 18 | "command": ["python", "-m", "unittest"], 19 | "noutput": 8192 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/engine/docker.go: -------------------------------------------------------------------------------- 1 | // Execute commands using Docker. 2 | package engine 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/exec" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/nalgeon/codapi/internal/config" 16 | "github.com/nalgeon/codapi/internal/execy" 17 | "github.com/nalgeon/codapi/internal/fileio" 18 | "github.com/nalgeon/codapi/internal/logx" 19 | ) 20 | 21 | var killTimeout = 5 * time.Second 22 | 23 | const ( 24 | actionRun = "run" 25 | actionExec = "exec" 26 | actionStop = "stop" 27 | ) 28 | 29 | // A Docker engine executes a specific sandbox command 30 | // using Docker `run` or `exec` actions. 31 | type Docker struct { 32 | cfg *config.Config 33 | cmd *config.Command 34 | } 35 | 36 | // NewDocker creates a new Docker engine for a specific command. 37 | func NewDocker(cfg *config.Config, sandbox, command string) Engine { 38 | cmd := cfg.Commands[sandbox][command] 39 | return &Docker{cfg, cmd} 40 | } 41 | 42 | // Exec executes the command and returns the output. 43 | func (e *Docker) Exec(req Request) Execution { 44 | // all steps operate in the same temp directory 45 | dir, err := fileio.MkdirTemp(0777) 46 | if err != nil { 47 | err = NewExecutionError("create temp dir", err) 48 | return Fail(req.ID, err) 49 | } 50 | defer os.RemoveAll(dir) 51 | 52 | // if the command entry point file is not defined, 53 | // there is no need to store request files in the temp directory 54 | if e.cmd.Entry != "" { 55 | // write request files to the temp directory 56 | err = e.writeFiles(dir, req.Files) 57 | var argErr ArgumentError 58 | if errors.As(err, &argErr) { 59 | return Fail(req.ID, err) 60 | } else if err != nil { 61 | err = NewExecutionError("write files to temp dir", err) 62 | return Fail(req.ID, err) 63 | } 64 | } 65 | 66 | // initialization step 67 | if e.cmd.Before != nil { 68 | out := e.execStep(e.cmd.Before, req, dir, nil) 69 | if !out.OK { 70 | return out 71 | } 72 | } 73 | 74 | // the first step is required 75 | first, rest := e.cmd.Steps[0], e.cmd.Steps[1:] 76 | out := e.execStep(first, req, dir, req.Files) 77 | 78 | // the rest are optional 79 | if out.OK && len(rest) > 0 { 80 | // each step operates on the results of the previous one, 81 | // without using the source files - hence `nil` instead of `files` 82 | for _, step := range rest { 83 | out = e.execStep(step, req, dir, nil) 84 | if !out.OK { 85 | break 86 | } 87 | } 88 | } 89 | 90 | // cleanup step 91 | if e.cmd.After != nil { 92 | afterOut := e.execStep(e.cmd.After, req, dir, nil) 93 | if out.OK && !afterOut.OK { 94 | return afterOut 95 | } 96 | } 97 | 98 | return out 99 | } 100 | 101 | // execStep executes a step using the docker container. 102 | func (e *Docker) execStep(step *config.Step, req Request, dir string, files Files) Execution { 103 | box, err := e.getBox(step, req) 104 | if err != nil { 105 | return Fail(req.ID, err) 106 | } 107 | 108 | err = e.copyFiles(box, dir) 109 | if err != nil { 110 | err = NewExecutionError("copy files to temp dir", err) 111 | return Fail(req.ID, err) 112 | } 113 | 114 | stdout, stderr, err := e.exec(box, step, req, dir, files) 115 | if err != nil { 116 | return Fail(req.ID, err) 117 | } 118 | 119 | return Execution{ 120 | ID: req.ID, 121 | OK: true, 122 | Stdout: stdout, 123 | Stderr: stderr, 124 | } 125 | } 126 | 127 | // getBox selects an appropriate box for the step (if any). 128 | func (e *Docker) getBox(step *config.Step, req Request) (*config.Box, error) { 129 | if step.Action != actionRun { 130 | // steps other than "run" use existing containers 131 | // and do not spin up new ones 132 | return nil, nil 133 | } 134 | var boxName string 135 | // If the version is set in the step config, use it. 136 | if step.Version != "" { 137 | if step.Version == "latest" { 138 | boxName = step.Box 139 | } else { 140 | boxName = step.Box + ":" + step.Version 141 | } 142 | } else if req.Version != "" { 143 | // If the version is set in the request, use it. 144 | boxName = step.Box + ":" + req.Version 145 | } else { 146 | // otherwise, use the latest version 147 | boxName = step.Box 148 | } 149 | box, found := e.cfg.Boxes[boxName] 150 | if !found { 151 | return nil, fmt.Errorf("unknown box %s", boxName) 152 | } 153 | return box, nil 154 | } 155 | 156 | // copyFiles copies box files to the temporary directory. 157 | func (e *Docker) copyFiles(box *config.Box, dir string) error { 158 | if box == nil || len(box.Files) == 0 { 159 | return nil 160 | } 161 | for _, pattern := range box.Files { 162 | err := fileio.CopyFiles(pattern, dir, 0444) 163 | if err != nil { 164 | return err 165 | } 166 | } 167 | return nil 168 | } 169 | 170 | // writeFiles writes request files to the temporary directory. 171 | func (e *Docker) writeFiles(dir string, files Files) error { 172 | var err error 173 | files.Range(func(name, content string) bool { 174 | if name == "" { 175 | name = e.cmd.Entry 176 | } 177 | var path string 178 | path, err = fileio.JoinDir(dir, name) 179 | if err != nil { 180 | err = NewArgumentError(fmt.Sprintf("files[%s]", name), err) 181 | return false 182 | } 183 | err = fileio.WriteFile(path, content, 0444) 184 | return err == nil 185 | }) 186 | return err 187 | } 188 | 189 | // exec executes the step in the docker container 190 | // using the files from in the temporary directory. 191 | func (e *Docker) exec(box *config.Box, step *config.Step, req Request, dir string, files Files) (stdout string, stderr string, err error) { 192 | // limit the stdout/stderr size 193 | prog := NewProgram(step.Timeout, int64(step.NOutput)) 194 | args := e.buildArgs(box, step, req, dir) 195 | 196 | if step.Stdin { 197 | // pass files to container from stdin 198 | stdin := filesReader(files) 199 | stdout, stderr, err = prog.RunStdin(stdin, req.ID, "docker", args...) 200 | } else { 201 | // pass files to container from temp directory 202 | stdout, stderr, err = prog.Run(req.ID, "docker", args...) 203 | } 204 | 205 | if err == nil { 206 | // success 207 | return 208 | } 209 | 210 | if err.Error() == "signal: killed" { 211 | if step.Action == actionRun { 212 | // we have to "docker kill" the container here, because the process 213 | // inside the container is not related to the "docker run" process, 214 | // and will hang forever after the "docker run" process is killed 215 | go func() { 216 | err = dockerKill(req.ID) 217 | if err == nil { 218 | logx.Debug("%s: docker kill ok", req.ID) 219 | } else { 220 | logx.Log("%s: docker kill failed: %v", req.ID, err) 221 | } 222 | }() 223 | } 224 | // context timeout 225 | err = ErrTimeout 226 | return 227 | } 228 | 229 | exitErr := new(exec.ExitError) 230 | if errors.As(err, &exitErr) { 231 | // the problem (if any) is the code, not the execution 232 | // so we return the error without wrapping into ExecutionError 233 | stderr, stdout = stdout+stderr, "" 234 | if stderr != "" { 235 | err = fmt.Errorf("%s (%s)", stderr, err) 236 | } 237 | return 238 | } 239 | 240 | // other execution error 241 | err = NewExecutionError("execute code", err) 242 | return 243 | } 244 | 245 | // buildArgs prepares the arguments for the `docker` command. 246 | func (e *Docker) buildArgs(box *config.Box, step *config.Step, req Request, dir string) []string { 247 | var args []string 248 | switch step.Action { 249 | case actionRun: 250 | args = dockerRunArgs(box, step, req, dir) 251 | case actionExec: 252 | args = dockerExecArgs(step, req) 253 | case actionStop: 254 | args = dockerStopArgs(step, req) 255 | default: 256 | // should never happen if the config is valid 257 | args = []string{"version"} 258 | } 259 | 260 | command := expandVars(step.Command, req.ID) 261 | args = append(args, command...) 262 | logx.Debug("%v", args) 263 | return args 264 | } 265 | 266 | // buildArgs prepares the arguments for the `docker run` command. 267 | func dockerRunArgs(box *config.Box, step *config.Step, req Request, dir string) []string { 268 | args := []string{ 269 | actionRun, "--rm", 270 | "--name", req.ID, 271 | "--runtime", box.Runtime, 272 | "--cpus", strconv.Itoa(box.CPU), 273 | "--memory", fmt.Sprintf("%dm", box.Memory), 274 | "--network", box.Network, 275 | "--pids-limit", strconv.Itoa(box.NProc), 276 | "--user", step.User, 277 | } 278 | if step.Detach { 279 | args = append(args, "--detach") 280 | } 281 | if step.Stdin { 282 | args = append(args, "--interactive") 283 | } 284 | if !box.Writable { 285 | args = append(args, "--read-only") 286 | } 287 | if box.Storage != "" { 288 | args = append(args, "--storage-opt", fmt.Sprintf("size=%s", box.Storage)) 289 | } 290 | if dir != "" { 291 | args = append(args, "--volume", fmt.Sprintf(box.Volume, dir)) 292 | } 293 | for _, fs := range box.Tmpfs { 294 | args = append(args, "--tmpfs", fs) 295 | } 296 | for _, cap := range box.CapAdd { 297 | args = append(args, "--cap-add", cap) 298 | } 299 | for _, cap := range box.CapDrop { 300 | args = append(args, "--cap-drop", cap) 301 | } 302 | for _, lim := range box.Ulimit { 303 | args = append(args, "--ulimit", lim) 304 | } 305 | args = append(args, box.Image) 306 | return args 307 | } 308 | 309 | // dockerExecArgs prepares the arguments for the `docker exec` command. 310 | func dockerExecArgs(step *config.Step, req Request) []string { 311 | // :name means executing in the container passed in the request 312 | box := strings.Replace(step.Box, ":name", req.ID, 1) 313 | return []string{ 314 | actionExec, "--interactive", 315 | "--user", step.User, 316 | box, 317 | } 318 | } 319 | 320 | // dockerStopArgs prepares the arguments for the `docker stop` command. 321 | func dockerStopArgs(step *config.Step, req Request) []string { 322 | // :name means executing in the container passed in the request 323 | box := strings.Replace(step.Box, ":name", req.ID, 1) 324 | return []string{actionStop, box} 325 | } 326 | 327 | // filesReader creates a reader over an in-memory collection of files. 328 | func filesReader(files Files) io.Reader { 329 | var input strings.Builder 330 | for _, content := range files { 331 | input.WriteString(content) 332 | } 333 | return strings.NewReader(input.String()) 334 | } 335 | 336 | // expandVars replaces variables in command arguments with values. 337 | // The only supported variable is :name = container name. 338 | func expandVars(command []string, name string) []string { 339 | expanded := make([]string, len(command)) 340 | copy(expanded, command) 341 | for i, cmd := range expanded { 342 | expanded[i] = strings.Replace(cmd, ":name", name, -1) 343 | } 344 | return expanded 345 | } 346 | 347 | // dockerKill kills the container with the specified id/name. 348 | func dockerKill(id string) error { 349 | ctx, cancel := context.WithTimeout(context.Background(), killTimeout) 350 | defer cancel() 351 | cmd := exec.CommandContext(ctx, "docker", "kill", id) 352 | return execy.Run(cmd) 353 | } 354 | -------------------------------------------------------------------------------- /internal/engine/docker_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/nalgeon/codapi/internal/config" 9 | "github.com/nalgeon/codapi/internal/execy" 10 | "github.com/nalgeon/codapi/internal/logx" 11 | ) 12 | 13 | var dockerCfg = &config.Config{ 14 | Boxes: map[string]*config.Box{ 15 | "alpine": { 16 | Image: "codapi/alpine", 17 | Runtime: "runc", 18 | Host: config.Host{ 19 | CPU: 1, Memory: 64, Network: "none", 20 | Volume: "%s:/sandbox:ro", 21 | NProc: 64, 22 | }, 23 | }, 24 | "go": { 25 | Image: "codapi/go", 26 | Runtime: "runc", 27 | Host: config.Host{ 28 | CPU: 1, Memory: 64, Network: "none", 29 | Volume: "%s:/sandbox:ro", 30 | NProc: 64, 31 | }, 32 | }, 33 | "go:dev": { 34 | Image: "codapi/go:dev", 35 | Runtime: "runc", 36 | Host: config.Host{ 37 | CPU: 1, Memory: 64, Network: "none", 38 | Volume: "%s:/sandbox:ro", 39 | NProc: 64, 40 | }, 41 | }, 42 | "python": { 43 | Image: "codapi/python", 44 | Runtime: "runc", 45 | Host: config.Host{ 46 | CPU: 1, Memory: 64, Network: "none", 47 | Volume: "%s:/sandbox:ro", 48 | NProc: 64, 49 | }, 50 | }, 51 | "python:dev": { 52 | Image: "codapi/python:dev", 53 | Runtime: "runc", 54 | Host: config.Host{ 55 | CPU: 1, Memory: 64, Network: "none", 56 | Volume: "%s:/sandbox:ro", 57 | NProc: 64, 58 | }, 59 | }, 60 | }, 61 | Commands: map[string]config.SandboxCommands{ 62 | "alpine": map[string]*config.Command{ 63 | "echo": { 64 | Engine: "docker", 65 | Before: &config.Step{ 66 | Box: "alpine", User: "sandbox", Action: "run", Detach: true, 67 | Command: []string{"echo", "before"}, 68 | NOutput: 4096, 69 | }, 70 | Steps: []*config.Step{ 71 | { 72 | Box: ":name", User: "sandbox", Action: "exec", 73 | Command: []string{"sh", "main.sh"}, 74 | NOutput: 4096, 75 | }, 76 | }, 77 | After: &config.Step{ 78 | Box: ":name", User: "sandbox", Action: "stop", 79 | NOutput: 4096, 80 | }, 81 | }, 82 | }, 83 | "go": map[string]*config.Command{ 84 | "run": { 85 | Engine: "docker", 86 | Steps: []*config.Step{ 87 | { 88 | Box: "go", User: "sandbox", Action: "run", 89 | Command: []string{"go", "build"}, 90 | NOutput: 4096, 91 | }, 92 | { 93 | Box: "alpine", Version: "latest", 94 | User: "sandbox", Action: "run", 95 | Command: []string{"./main"}, 96 | NOutput: 4096, 97 | }, 98 | }, 99 | }, 100 | }, 101 | "postgresql": map[string]*config.Command{ 102 | "run": { 103 | Engine: "docker", 104 | Before: &config.Step{ 105 | Box: "postgres", User: "sandbox", Action: "exec", 106 | Command: []string{"psql", "-f", "create.sql"}, 107 | NOutput: 4096, 108 | }, 109 | Steps: []*config.Step{ 110 | { 111 | Box: "postgres", User: "sandbox", Action: "exec", Stdin: true, 112 | Command: []string{"psql", "--user=:name"}, 113 | NOutput: 4096, 114 | }, 115 | }, 116 | After: &config.Step{ 117 | Box: "postgres", User: "sandbox", Action: "exec", 118 | Command: []string{"psql", "-f", "drop.sql"}, 119 | NOutput: 4096, 120 | }, 121 | }, 122 | }, 123 | "python": map[string]*config.Command{ 124 | "run": { 125 | Engine: "docker", 126 | Entry: "main.py", 127 | Steps: []*config.Step{ 128 | { 129 | Box: "python", User: "sandbox", Action: "run", 130 | Command: []string{"python", "main.py"}, 131 | NOutput: 4096, 132 | }, 133 | }, 134 | }, 135 | }, 136 | }, 137 | } 138 | 139 | func TestDockerRun(t *testing.T) { 140 | logx.Mock() 141 | commands := map[string]execy.CmdOut{ 142 | "docker run": {Stdout: "hello world", Stderr: "", Err: nil}, 143 | } 144 | mem := execy.Mock(commands) 145 | 146 | t.Run("success", func(t *testing.T) { 147 | mem.Clear() 148 | engine := NewDocker(dockerCfg, "python", "run") 149 | req := Request{ 150 | ID: "http_42", 151 | Sandbox: "python", 152 | Command: "run", 153 | Files: map[string]string{ 154 | "": "print('hello world')", 155 | }, 156 | } 157 | out := engine.Exec(req) 158 | if out.ID != req.ID { 159 | t.Errorf("ID: expected %s, got %s", req.ID, out.ID) 160 | } 161 | if !out.OK { 162 | t.Error("OK: expected true") 163 | } 164 | want := "hello world" 165 | if out.Stdout != want { 166 | t.Errorf("Stdout: expected %q, got %q", want, out.Stdout) 167 | } 168 | if out.Stderr != "" { 169 | t.Errorf("Stderr: expected %q, got %q", "", out.Stdout) 170 | } 171 | if out.Err != nil { 172 | t.Errorf("Err: expected nil, got %v", out.Err) 173 | } 174 | mem.MustHave(t, "codapi/python") 175 | mem.MustHave(t, "python main.py") 176 | }) 177 | 178 | t.Run("latest version", func(t *testing.T) { 179 | mem.Clear() 180 | engine := NewDocker(dockerCfg, "python", "run") 181 | req := Request{ 182 | ID: "http_42", 183 | Sandbox: "python", 184 | Command: "run", 185 | Files: map[string]string{ 186 | "": "print('hello world')", 187 | }, 188 | } 189 | out := engine.Exec(req) 190 | if !out.OK { 191 | t.Error("OK: expected true") 192 | } 193 | mem.MustHave(t, "codapi/python") 194 | }) 195 | 196 | t.Run("custom version", func(t *testing.T) { 197 | mem.Clear() 198 | engine := NewDocker(dockerCfg, "python", "run") 199 | req := Request{ 200 | ID: "http_42", 201 | Sandbox: "python", 202 | Version: "dev", 203 | Command: "run", 204 | Files: map[string]string{ 205 | "": "print('hello world')", 206 | }, 207 | } 208 | out := engine.Exec(req) 209 | if !out.OK { 210 | t.Error("OK: expected true") 211 | } 212 | mem.MustHave(t, "codapi/python:dev") 213 | }) 214 | 215 | t.Run("step version", func(t *testing.T) { 216 | mem.Clear() 217 | engine := NewDocker(dockerCfg, "go", "run") 218 | req := Request{ 219 | ID: "http_42", 220 | Sandbox: "go", 221 | Version: "dev", 222 | Command: "run", 223 | Files: map[string]string{ 224 | "": "var n = 42", 225 | }, 226 | } 227 | out := engine.Exec(req) 228 | if !out.OK { 229 | t.Error("OK: expected true") 230 | } 231 | mem.MustHave(t, "codapi/go:dev") 232 | mem.MustHave(t, "codapi/alpine") 233 | }) 234 | 235 | t.Run("unsupported version", func(t *testing.T) { 236 | mem.Clear() 237 | engine := NewDocker(dockerCfg, "python", "run") 238 | req := Request{ 239 | ID: "http_42", 240 | Sandbox: "python", 241 | Version: "42", 242 | Command: "run", 243 | Files: map[string]string{ 244 | "": "print('hello world')", 245 | }, 246 | } 247 | out := engine.Exec(req) 248 | if out.OK { 249 | t.Error("OK: expected false") 250 | } 251 | want := "unknown box python:42" 252 | if out.Stderr != want { 253 | t.Errorf("Stderr: unexpected value: %s", out.Stderr) 254 | } 255 | }) 256 | 257 | t.Run("directory traversal attack", func(t *testing.T) { 258 | mem.Clear() 259 | const fileName = "../../opt/codapi/codapi" 260 | engine := NewDocker(dockerCfg, "python", "run") 261 | req := Request{ 262 | ID: "http_42", 263 | Sandbox: "python", 264 | Command: "run", 265 | Files: map[string]string{ 266 | "": "print('hello world')", 267 | fileName: "hehe", 268 | }, 269 | } 270 | out := engine.Exec(req) 271 | if out.OK { 272 | t.Error("OK: expected false") 273 | } 274 | want := fmt.Sprintf("files[%s]: invalid name", fileName) 275 | if out.Stderr != want { 276 | t.Errorf("Stderr: unexpected value: %s", out.Stderr) 277 | } 278 | }) 279 | } 280 | 281 | func TestDockerExec(t *testing.T) { 282 | logx.Mock() 283 | commands := map[string]execy.CmdOut{ 284 | "docker exec": {Stdout: "hello world", Stderr: "", Err: nil}, 285 | } 286 | mem := execy.Mock(commands) 287 | engine := NewDocker(dockerCfg, "postgresql", "run") 288 | 289 | t.Run("success", func(t *testing.T) { 290 | req := Request{ 291 | ID: "http_42", 292 | Sandbox: "postgresql", 293 | Command: "run", 294 | Files: map[string]string{ 295 | "": "select 'hello world'", 296 | }, 297 | } 298 | out := engine.Exec(req) 299 | if out.ID != req.ID { 300 | t.Errorf("ID: expected %s, got %s", req.ID, out.ID) 301 | } 302 | if !out.OK { 303 | t.Error("OK: expected true") 304 | } 305 | want := "hello world" 306 | if out.Stdout != want { 307 | t.Errorf("Stdout: expected %q, got %q", want, out.Stdout) 308 | } 309 | if out.Stderr != "" { 310 | t.Errorf("Stderr: expected %q, got %q", "", out.Stdout) 311 | } 312 | if out.Err != nil { 313 | t.Errorf("Err: expected nil, got %v", out.Err) 314 | } 315 | mem.MustHave(t, "psql -f create.sql") 316 | mem.MustHave(t, "psql --user=http_42") 317 | mem.MustHave(t, "psql -f drop.sql") 318 | }) 319 | } 320 | 321 | func TestDockerStop(t *testing.T) { 322 | logx.Mock() 323 | commands := map[string]execy.CmdOut{ 324 | "docker run": {Stdout: "c958ff2", Stderr: "", Err: nil}, 325 | "docker exec": {Stdout: "hello", Stderr: "", Err: nil}, 326 | "docker stop": {Stdout: "alpine_42", Stderr: "", Err: nil}, 327 | } 328 | mem := execy.Mock(commands) 329 | engine := NewDocker(dockerCfg, "alpine", "echo") 330 | 331 | t.Run("success", func(t *testing.T) { 332 | req := Request{ 333 | ID: "alpine_42", 334 | Sandbox: "alpine", 335 | Command: "echo", 336 | Files: map[string]string{ 337 | "": "echo hello", 338 | }, 339 | } 340 | out := engine.Exec(req) 341 | 342 | if out.ID != req.ID { 343 | t.Errorf("ID: expected %s, got %s", req.ID, out.ID) 344 | } 345 | if !out.OK { 346 | t.Error("OK: expected true") 347 | } 348 | want := "hello" 349 | if out.Stdout != want { 350 | t.Errorf("Stdout: expected %q, got %q", want, out.Stdout) 351 | } 352 | if out.Stderr != "" { 353 | t.Errorf("Stderr: expected %q, got %q", "", out.Stdout) 354 | } 355 | if out.Err != nil { 356 | t.Errorf("Err: expected nil, got %v", out.Err) 357 | } 358 | mem.MustHave(t, "docker run --rm --name alpine_42", "--detach") 359 | mem.MustHave(t, "docker exec --interactive --user sandbox alpine_42 sh main.sh") 360 | mem.MustHave(t, "docker stop alpine_42") 361 | }) 362 | } 363 | 364 | func Test_expandVars(t *testing.T) { 365 | const name = "codapi_01" 366 | commands := map[string]string{ 367 | "python main.py": "python main.py", 368 | "sh create.sh :name": "sh create.sh " + name, 369 | "sh copy.sh :name new-:name": "sh copy.sh " + name + " new-" + name, 370 | } 371 | for cmd, want := range commands { 372 | src := strings.Fields(cmd) 373 | exp := expandVars(src, name) 374 | got := strings.Join(exp, " ") 375 | if got != want { 376 | t.Errorf("%q: expected %q, got %q", cmd, got, want) 377 | } 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /internal/engine/engine.go: -------------------------------------------------------------------------------- 1 | // Package engine provides code execution engines. 2 | package engine 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/nalgeon/codapi/internal/stringx" 9 | ) 10 | 11 | // A Request initiates code execution. 12 | type Request struct { 13 | ID string `json:"id"` 14 | Sandbox string `json:"sandbox"` 15 | Version string `json:"version,omitempty"` 16 | Command string `json:"command"` 17 | Files Files `json:"files"` 18 | } 19 | 20 | // GenerateID() sets a unique ID for the request. 21 | func (r *Request) GenerateID() { 22 | if r.Version != "" { 23 | r.ID = fmt.Sprintf("%s.%s_%s_%s", r.Sandbox, r.Version, r.Command, stringx.RandString(8)) 24 | } else { 25 | r.ID = fmt.Sprintf("%s_%s_%s", r.Sandbox, r.Command, stringx.RandString(8)) 26 | } 27 | } 28 | 29 | // An Execution is an output from the code execution engine. 30 | type Execution struct { 31 | ID string `json:"id"` 32 | OK bool `json:"ok"` 33 | Duration int `json:"duration"` 34 | Stdout string `json:"stdout"` 35 | Stderr string `json:"stderr"` 36 | Err error `json:"-"` 37 | } 38 | 39 | // An ErrTimeout is returned if code execution did not complete 40 | // in the allowed timeframe. 41 | var ErrTimeout = errors.New("code execution timeout") 42 | 43 | // An ErrBusy is returned when there are no engines available. 44 | var ErrBusy = errors.New("busy: try again later") 45 | 46 | // An ExecutionError is returned if code execution failed 47 | // due to the application problems, not due to the problems with the code. 48 | type ExecutionError struct { 49 | msg string 50 | inner error 51 | } 52 | 53 | func NewExecutionError(msg string, err error) ExecutionError { 54 | return ExecutionError{msg: msg, inner: err} 55 | } 56 | 57 | func (err ExecutionError) Error() string { 58 | return err.msg + ": " + err.inner.Error() 59 | } 60 | 61 | func (err ExecutionError) Unwrap() error { 62 | return err.inner 63 | } 64 | 65 | // An ArgumentError is returned if code execution failed 66 | // due to the invalid value of the request argument. 67 | type ArgumentError struct { 68 | name string 69 | reason error 70 | } 71 | 72 | func NewArgumentError(name string, reason error) ArgumentError { 73 | return ArgumentError{name: name, reason: reason} 74 | } 75 | 76 | func (err ArgumentError) Error() string { 77 | return err.name + ": " + err.reason.Error() 78 | } 79 | 80 | func (err ArgumentError) Unwrap() error { 81 | return err.reason 82 | } 83 | 84 | // Files are a collection of files to be executed by the engine. 85 | type Files map[string]string 86 | 87 | // First returns the contents of the first file. 88 | func (f Files) First() string { 89 | for _, content := range f { 90 | return content 91 | } 92 | return "" 93 | } 94 | 95 | // Range iterates over the files, calling fn for each one. 96 | func (f Files) Range(fn func(name, content string) bool) { 97 | for name, content := range f { 98 | ok := fn(name, content) 99 | if !ok { 100 | break 101 | } 102 | } 103 | } 104 | 105 | // Count returns the number of files. 106 | func (f Files) Count() int { 107 | return len(f) 108 | } 109 | 110 | // An Engine executes a specific sandbox command on the code. 111 | // Engines must be concurrent-safe, since they can be accessed by multiple goroutines. 112 | type Engine interface { 113 | // Exec executes the command and returns the output. 114 | Exec(req Request) Execution 115 | } 116 | 117 | // Fail creates an output from an error. 118 | func Fail(id string, err error) Execution { 119 | if _, ok := err.(ExecutionError); ok { 120 | return Execution{ 121 | ID: id, 122 | OK: false, 123 | Stderr: "internal error", 124 | Err: err, 125 | } 126 | } 127 | if errors.Is(err, ErrBusy) { 128 | return Execution{ 129 | ID: id, 130 | OK: false, 131 | Stderr: err.Error(), 132 | Err: err, 133 | } 134 | } 135 | return Execution{ 136 | ID: id, 137 | OK: false, 138 | Stderr: err.Error(), 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /internal/engine/engine_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "sort" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestGenerateID(t *testing.T) { 12 | t.Run("with version", func(t *testing.T) { 13 | req := Request{ 14 | Sandbox: "python", 15 | Version: "dev", 16 | Command: "run", 17 | } 18 | req.GenerateID() 19 | if !strings.HasPrefix(req.ID, "python.dev_run_") { 20 | t.Errorf("ID: unexpected prefix %s", req.ID) 21 | } 22 | }) 23 | t.Run("without version", func(t *testing.T) { 24 | req := Request{ 25 | Sandbox: "python", 26 | Command: "run", 27 | } 28 | req.GenerateID() 29 | if !strings.HasPrefix(req.ID, "python_run_") { 30 | t.Errorf("ID: unexpected prefix %s", req.ID) 31 | } 32 | }) 33 | } 34 | 35 | func TestExecutionError(t *testing.T) { 36 | inner := errors.New("inner error") 37 | err := NewExecutionError("failed", inner) 38 | if err.Error() != "failed: inner error" { 39 | t.Errorf("Error: expected %q, got %q", "failed: inner error", err.Error()) 40 | } 41 | unwrapped := err.Unwrap() 42 | if unwrapped != inner { 43 | t.Errorf("Unwrap: expected %#v, got %#v", inner, unwrapped) 44 | } 45 | } 46 | 47 | func TestFiles_Count(t *testing.T) { 48 | var files Files = map[string]string{ 49 | "first": "alice", 50 | "second": "bob", 51 | "third": "cindy", 52 | } 53 | if files.Count() != 3 { 54 | t.Errorf("Count: expected 3, got %d", files.Count()) 55 | } 56 | } 57 | 58 | func TestFiles_Range(t *testing.T) { 59 | var files Files = map[string]string{ 60 | "first": "alice", 61 | "second": "bob", 62 | "third": "cindy", 63 | } 64 | 65 | t.Run("range", func(t *testing.T) { 66 | names := []string{} 67 | contents := []string{} 68 | files.Range(func(name, content string) bool { 69 | names = append(names, name) 70 | contents = append(contents, content) 71 | return true 72 | }) 73 | sort.Strings(names) 74 | if !reflect.DeepEqual(names, []string{"first", "second", "third"}) { 75 | t.Errorf("unexpected names: %v", names) 76 | } 77 | sort.Strings(contents) 78 | if !reflect.DeepEqual(contents, []string{"alice", "bob", "cindy"}) { 79 | t.Errorf("unexpected contents: %v", contents) 80 | } 81 | }) 82 | 83 | t.Run("break", func(t *testing.T) { 84 | names := []string{} 85 | contents := []string{} 86 | files.Range(func(name, content string) bool { 87 | names = append(names, name) 88 | contents = append(contents, content) 89 | return false 90 | }) 91 | if len(names) != 1 { 92 | t.Fatalf("expected names len = 1, got %d", len(names)) 93 | } 94 | if len(contents) != 1 { 95 | t.Fatalf("expected contents len = 1, got %d", len(contents)) 96 | } 97 | if files[names[0]] != contents[0] { 98 | t.Fatalf("name does not match content: %v -> %v", names[0], contents[0]) 99 | } 100 | }) 101 | } 102 | 103 | func TestFail(t *testing.T) { 104 | t.Run("ExecutionError", func(t *testing.T) { 105 | err := NewExecutionError("failed", errors.New("inner error")) 106 | out := Fail("42", err) 107 | if out.ID != "42" { 108 | t.Errorf("ID: expected 42, got %v", out.ID) 109 | } 110 | if out.OK { 111 | t.Error("OK: expected false") 112 | } 113 | if out.Stderr != "internal error" { 114 | t.Errorf("Stderr: expected %q, got %q", "internal error", out.Stderr) 115 | } 116 | if out.Stdout != "" { 117 | t.Errorf("Stdout: expected empty, got %q", out.Stdout) 118 | } 119 | if out.Err != err { 120 | t.Errorf("Err: expected %#v, got %#v", err, out.Err) 121 | } 122 | }) 123 | t.Run("ErrBusy", func(t *testing.T) { 124 | err := ErrBusy 125 | out := Fail("42", err) 126 | if out.ID != "42" { 127 | t.Errorf("ID: expected 42, got %v", out.ID) 128 | } 129 | if out.OK { 130 | t.Error("OK: expected false") 131 | } 132 | if out.Stderr != err.Error() { 133 | t.Errorf("Stderr: expected %q, got %q", err.Error(), out.Stderr) 134 | } 135 | if out.Stdout != "" { 136 | t.Errorf("Stdout: expected empty, got %q", out.Stdout) 137 | } 138 | if out.Err != err { 139 | t.Errorf("Err: expected %#v, got %#v", err, out.Err) 140 | } 141 | }) 142 | t.Run("Error", func(t *testing.T) { 143 | err := errors.New("user error") 144 | out := Fail("42", err) 145 | if out.ID != "42" { 146 | t.Errorf("ID: expected 42, got %v", out.ID) 147 | } 148 | if out.OK { 149 | t.Error("OK: expected false") 150 | } 151 | if out.Stderr != err.Error() { 152 | t.Errorf("Stderr: expected %q, got %q", err.Error(), out.Stderr) 153 | } 154 | if out.Stdout != "" { 155 | t.Errorf("Stdout: expected empty, got %q", out.Stdout) 156 | } 157 | if out.Err != nil { 158 | t.Errorf("Err: expected nil, got %#v", out.Err) 159 | } 160 | }) 161 | 162 | } 163 | -------------------------------------------------------------------------------- /internal/engine/exec.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os/exec" 7 | "strings" 8 | "time" 9 | 10 | "github.com/nalgeon/codapi/internal/execy" 11 | "github.com/nalgeon/codapi/internal/logx" 12 | ) 13 | 14 | // A Program is an executable program. 15 | type Program struct { 16 | timeout time.Duration 17 | nOutput int64 18 | } 19 | 20 | // NewProgram creates a new program. 21 | func NewProgram(timeoutSec int, nOutput int64) *Program { 22 | return &Program{ 23 | timeout: time.Duration(timeoutSec) * time.Second, 24 | nOutput: nOutput, 25 | } 26 | } 27 | 28 | // Run starts the program and waits for it to complete (or timeout). 29 | func (p *Program) Run(id, name string, arg ...string) (stdout string, stderr string, err error) { 30 | return p.RunStdin(nil, id, name, arg...) 31 | } 32 | 33 | // RunStdin starts the program with data from stdin 34 | // and waits for it to complete (or timeout). 35 | func (p *Program) RunStdin(stdin io.Reader, id, name string, arg ...string) (stdout string, stderr string, err error) { 36 | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) 37 | defer cancel() 38 | 39 | var cmdout, cmderr strings.Builder 40 | cmd := exec.CommandContext(ctx, name, arg...) 41 | cmd.Cancel = func() error { 42 | err := cmd.Process.Kill() 43 | logx.Debug("%s: execution timeout, killed process=%d, err=%v", id, cmd.Process.Pid, err) 44 | return err 45 | } 46 | 47 | cmd.Stdin = stdin 48 | cmd.Stdout = LimitWriter(&cmdout, p.nOutput) 49 | cmd.Stderr = LimitWriter(&cmderr, p.nOutput) 50 | err = execy.Run(cmd) 51 | stdout = strings.TrimSpace(cmdout.String()) 52 | stderr = strings.TrimSpace(cmderr.String()) 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /internal/engine/exec_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/nalgeon/codapi/internal/execy" 9 | ) 10 | 11 | func TestProgram_Run(t *testing.T) { 12 | commands := map[string]execy.CmdOut{ 13 | "mock stdout": {Stdout: "stdout", Stderr: "", Err: nil}, 14 | "mock stderr": {Stdout: "", Stderr: "stderr", Err: nil}, 15 | "mock outerr": {Stdout: "stdout", Stderr: "stderr", Err: nil}, 16 | "mock err": {Stdout: "", Stderr: "stderr", Err: errors.New("error")}, 17 | } 18 | mem := execy.Mock(commands) 19 | 20 | for key, want := range commands { 21 | t.Run(key, func(t *testing.T) { 22 | p := NewProgram(3, 100) 23 | name, arg, _ := strings.Cut(key, " ") 24 | stdout, stderr, err := p.Run("mock_42", name, arg) 25 | if !mem.Has(key) { 26 | t.Errorf("Run: command %q not run", key) 27 | } 28 | if stdout != want.Stdout { 29 | t.Errorf("stdout: want %#v, got %#v", want.Stdout, stdout) 30 | } 31 | if stderr != want.Stderr { 32 | t.Errorf("stderr: want %#v, got %#v", want.Stderr, stderr) 33 | } 34 | if err != want.Err { 35 | t.Errorf("err: want %#v, got %#v", want.Err, err) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestProgram_LimitOutput(t *testing.T) { 42 | commands := map[string]execy.CmdOut{ 43 | "mock stdout": {Stdout: "1234567890", Stderr: ""}, 44 | "mock stderr": {Stdout: "", Stderr: "1234567890"}, 45 | "mock outerr": {Stdout: "1234567890", Stderr: "1234567890"}, 46 | } 47 | execy.Mock(commands) 48 | 49 | const nOutput = 5 50 | { 51 | p := NewProgram(3, nOutput) 52 | stdout, _, _ := p.Run("mock_42", "mock", "stdout") 53 | if stdout != "12345" { 54 | t.Errorf("stdout: want %#v, got %#v", "12345", stdout) 55 | } 56 | } 57 | { 58 | p := NewProgram(3, nOutput) 59 | _, stderr, _ := p.Run("mock_42", "mock", "stderr") 60 | if stderr != "12345" { 61 | t.Errorf("stderr: want %#v, got %#v", "12345", stderr) 62 | } 63 | } 64 | { 65 | p := NewProgram(3, nOutput) 66 | stdout, stderr, _ := p.Run("mock_42", "mock", "outerr") 67 | if stdout != "12345" { 68 | t.Errorf("stdout: want %#v, got %#v", "12345", stdout) 69 | } 70 | if stderr != "12345" { 71 | t.Errorf("stderr: want %#v, got %#v", "12345", stderr) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/engine/http.go: -------------------------------------------------------------------------------- 1 | // Send HTTP request according to the specification. 2 | package engine 3 | 4 | import ( 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/nalgeon/codapi/internal/config" 13 | "github.com/nalgeon/codapi/internal/httpx" 14 | "github.com/nalgeon/codapi/internal/logx" 15 | ) 16 | 17 | // An HTTP engine sends HTTP requests. 18 | type HTTP struct { 19 | hosts map[string]string 20 | } 21 | 22 | // NewHTTP creates a new HTTP engine. 23 | func NewHTTP(cfg *config.Config, sandbox, command string) Engine { 24 | if len(cfg.HTTP.Hosts) == 0 { 25 | msg := fmt.Sprintf("%s %s: http engine requires at least one allowed URL", sandbox, command) 26 | panic(msg) 27 | } 28 | return &HTTP{hosts: cfg.HTTP.Hosts} 29 | } 30 | 31 | // Exec sends an HTTP request according to the spec 32 | // and returns the response as text with status, headers and body. 33 | func (e *HTTP) Exec(req Request) Execution { 34 | // build request from spec 35 | httpReq, err := e.parse(req.Files.First()) 36 | if err != nil { 37 | err = fmt.Errorf("parse spec: %w", err) 38 | return Fail(req.ID, err) 39 | } 40 | 41 | // send request and receive response 42 | allowed := e.translateHost(httpReq) 43 | if !allowed { 44 | err = fmt.Errorf("host not allowed: %s", httpReq.Host) 45 | return Fail(req.ID, err) 46 | } 47 | 48 | logx.Log("%s: %s %s", req.ID, httpReq.Method, httpReq.URL.String()) 49 | resp, err := httpx.Do(httpReq) 50 | if err != nil { 51 | err = fmt.Errorf("http request: %w", err) 52 | return Fail(req.ID, err) 53 | } 54 | defer resp.Body.Close() 55 | 56 | // read response body 57 | body, err := io.ReadAll(resp.Body) 58 | if err != nil { 59 | err = NewExecutionError("read response", err) 60 | return Fail(req.ID, err) 61 | } 62 | 63 | // build text representation of request 64 | stdout := e.responseText(resp, body) 65 | return Execution{ 66 | ID: req.ID, 67 | OK: true, 68 | Stdout: stdout, 69 | } 70 | } 71 | 72 | // parse parses the request specification. 73 | func (e *HTTP) parse(text string) (*http.Request, error) { 74 | lines := strings.Split(text, "\n") 75 | if len(lines) == 0 { 76 | return nil, errors.New("empty request") 77 | } 78 | 79 | lineIdx := 0 80 | 81 | // parse method and URL 82 | var method, url string 83 | methodURL := strings.Fields(lines[0]) 84 | if len(methodURL) >= 2 { 85 | method = methodURL[0] 86 | url = methodURL[1] 87 | } else { 88 | method = http.MethodGet 89 | url = methodURL[0] 90 | } 91 | 92 | lineIdx++ 93 | 94 | // parse URL parameters 95 | var urlParams strings.Builder 96 | for i := lineIdx; i < len(lines); i++ { 97 | line := strings.TrimSpace(lines[i]) 98 | if strings.HasPrefix(line, "?") || strings.HasPrefix(line, "&") { 99 | urlParams.WriteString(line) 100 | lineIdx++ 101 | } else { 102 | break 103 | } 104 | } 105 | 106 | // parse headers 107 | headers := make(http.Header) 108 | for i := lineIdx; i < len(lines); i++ { 109 | line := strings.TrimSpace(lines[i]) 110 | if line == "" { 111 | break 112 | } 113 | headerParts := strings.SplitN(line, ":", 2) 114 | if len(headerParts) == 2 { 115 | headers.Add(strings.TrimSpace(headerParts[0]), strings.TrimSpace(headerParts[1])) 116 | lineIdx++ 117 | } 118 | } 119 | 120 | lineIdx += 1 121 | 122 | // parse body 123 | var bodyRdr io.Reader 124 | if lineIdx < len(lines) { 125 | body := strings.Join(lines[lineIdx:], "\n") 126 | bodyRdr = strings.NewReader(body) 127 | } 128 | 129 | // create request 130 | req, err := http.NewRequest(method, url+urlParams.String(), bodyRdr) 131 | if err != nil { 132 | return nil, err 133 | } 134 | req.Header = headers 135 | return req, nil 136 | } 137 | 138 | // translateHost translates the requested host into the allowed one. 139 | // Returns false if the requested host is not allowed. 140 | func (e *HTTP) translateHost(req *http.Request) bool { 141 | host := e.hosts[req.Host] 142 | if host == "" { 143 | return false 144 | } 145 | req.URL.Host = host 146 | return true 147 | } 148 | 149 | // responseText returns the response as text with status, headers and body. 150 | func (e *HTTP) responseText(resp *http.Response, body []byte) string { 151 | var b bytes.Buffer 152 | // status line 153 | b.WriteString( 154 | fmt.Sprintf("%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)), 155 | ) 156 | // headers 157 | for name := range resp.Header { 158 | b.WriteString(fmt.Sprintf("%s: %s\n", name, resp.Header.Get(name))) 159 | } 160 | // body 161 | if len(body) > 0 { 162 | b.WriteByte('\n') 163 | b.Write(body) 164 | } 165 | return b.String() 166 | } 167 | -------------------------------------------------------------------------------- /internal/engine/http_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/nalgeon/codapi/internal/config" 9 | "github.com/nalgeon/codapi/internal/httpx" 10 | "github.com/nalgeon/codapi/internal/logx" 11 | ) 12 | 13 | var httpCfg = &config.Config{ 14 | HTTP: &config.HTTP{ 15 | Hosts: map[string]string{"codapi.org": "localhost"}, 16 | }, 17 | Commands: map[string]config.SandboxCommands{ 18 | "http": map[string]*config.Command{ 19 | "run": {Engine: "http"}, 20 | }, 21 | }, 22 | } 23 | 24 | func TestHTTP_Exec(t *testing.T) { 25 | logx.Mock() 26 | httpx.Mock() 27 | engine := NewHTTP(httpCfg, "http", "run") 28 | 29 | t.Run("success", func(t *testing.T) { 30 | req := Request{ 31 | ID: "http_42", 32 | Sandbox: "http", 33 | Command: "run", 34 | Files: map[string]string{ 35 | "": "GET https://codapi.org/example.txt", 36 | }, 37 | } 38 | out := engine.Exec(req) 39 | if out.ID != req.ID { 40 | t.Errorf("ID: expected %s, got %s", req.ID, out.ID) 41 | } 42 | if !out.OK { 43 | t.Error("OK: expected true") 44 | } 45 | want := `HTTP/1.1 200 OK 46 | Content-Type: text/plain 47 | 48 | hello` 49 | if out.Stdout != want { 50 | t.Errorf("Stdout: expected %q, got %q", want, out.Stdout) 51 | } 52 | if out.Stderr != "" { 53 | t.Errorf("Stderr: expected %q, got %q", "", out.Stdout) 54 | } 55 | if out.Err != nil { 56 | t.Errorf("Err: expected nil, got %#v", out.Err) 57 | } 58 | }) 59 | t.Run("hostname not allowed", func(t *testing.T) { 60 | req := Request{ 61 | ID: "http_42", 62 | Sandbox: "http", 63 | Command: "run", 64 | Files: map[string]string{ 65 | "": "GET https://example.com/get", 66 | }, 67 | } 68 | out := engine.Exec(req) 69 | if out.Err != nil { 70 | t.Errorf("Err: expected nil, got %#v", out.Err) 71 | } 72 | if out.Stderr != "host not allowed: example.com" { 73 | t.Errorf("Stderr: unexpected value %q", out.Stderr) 74 | } 75 | }) 76 | } 77 | 78 | func TestHTTP_parse(t *testing.T) { 79 | logx.Mock() 80 | httpx.Mock() 81 | engine := NewHTTP(httpCfg, "http", "run").(*HTTP) 82 | 83 | t.Run("request line", func(t *testing.T) { 84 | const uri = "https://codapi.org/head" 85 | text := "HEAD " + uri 86 | req, err := engine.parse(text) 87 | if err != nil { 88 | t.Fatalf("unexpected error %#v", err) 89 | } 90 | if req.Method != http.MethodHead { 91 | t.Errorf("Method: expected %s, got %s", http.MethodHead, req.Method) 92 | } 93 | if req.URL.String() != uri { 94 | t.Errorf("URL: expected %q, got %q", uri, req.URL.String()) 95 | } 96 | }) 97 | t.Run("headers", func(t *testing.T) { 98 | const uri = "https://codapi.org/get" 99 | text := "GET " + uri + "\naccept: text/plain\nx-secret: 42" 100 | req, err := engine.parse(text) 101 | if err != nil { 102 | t.Fatalf("unexpected error %#v", err) 103 | } 104 | if req.Method != http.MethodGet { 105 | t.Errorf("Method: expected %s, got %s", http.MethodGet, req.Method) 106 | } 107 | if req.URL.String() != uri { 108 | t.Errorf("URL: expected %q, got %q", uri, req.URL.String()) 109 | } 110 | if len(req.Header) != 2 { 111 | t.Fatalf("Header: expected 2 headers, got %d", len(req.Header)) 112 | } 113 | if req.Header.Get("accept") != "text/plain" { 114 | t.Fatalf("Header: expected accept = %q, got %q", "text/plain", req.Header.Get("accept")) 115 | } 116 | if req.Header.Get("x-secret") != "42" { 117 | t.Fatalf("Header: expected x-secret = %q, got %q", "42", req.Header.Get("x-secret")) 118 | } 119 | }) 120 | t.Run("body", func(t *testing.T) { 121 | const uri = "https://codapi.org/post" 122 | const body = "{\"name\":\"alice\"}" 123 | text := "POST " + uri + "\ncontent-type: application/json\n\n" + body 124 | req, err := engine.parse(text) 125 | if err != nil { 126 | t.Fatalf("unexpected error %#v", err) 127 | } 128 | if req.Method != http.MethodPost { 129 | t.Errorf("Method: expected %s, got %s", http.MethodPost, req.Method) 130 | } 131 | if req.URL.String() != uri { 132 | t.Errorf("URL: expected %q, got %q", uri, req.URL.String()) 133 | } 134 | if req.Header.Get("content-type") != "application/json" { 135 | t.Errorf("Header: expected content-type = %q, got %q", 136 | "application/json", req.Header.Get("content-type")) 137 | } 138 | b, _ := io.ReadAll(req.Body) 139 | got := string(b) 140 | if got != body { 141 | t.Errorf("Body: expected %q, got %q", body, got) 142 | } 143 | }) 144 | t.Run("invalid", func(t *testing.T) { 145 | _, err := engine.parse("on,e two three") 146 | if err == nil { 147 | t.Fatal("expected error, got nil") 148 | } 149 | }) 150 | } 151 | 152 | func TestHTTP_translateHost(t *testing.T) { 153 | logx.Mock() 154 | httpx.Mock() 155 | engine := NewHTTP(httpCfg, "http", "run").(*HTTP) 156 | 157 | t.Run("known url", func(t *testing.T) { 158 | const uri = "http://codapi.org/get" 159 | req, _ := http.NewRequest(http.MethodGet, uri, nil) 160 | ok := engine.translateHost(req) 161 | if !ok { 162 | t.Errorf("%s: should be allowed", uri) 163 | } 164 | if req.URL.Hostname() != "localhost" { 165 | t.Errorf("%s: expected %s, got %s", uri, "localhost", req.URL.Hostname()) 166 | } 167 | }) 168 | t.Run("unknown url", func(t *testing.T) { 169 | const uri = "http://example.com/get" 170 | req, _ := http.NewRequest(http.MethodGet, uri, nil) 171 | ok := engine.translateHost(req) 172 | if ok { 173 | t.Errorf("%s: should not be allowed", uri) 174 | } 175 | if req.URL.Hostname() != "example.com" { 176 | t.Errorf("%s: expected %s, got %s", uri, "example.com", req.URL.Hostname()) 177 | } 178 | }) 179 | } 180 | -------------------------------------------------------------------------------- /internal/engine/io.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import "io" 4 | 5 | // A LimitedWriter writes to w but limits the amount 6 | // of data to only n bytes. After reaching the limit, 7 | // silently discards the rest of the data without errors. 8 | type LimitedWriter struct { 9 | w io.Writer 10 | n int64 11 | } 12 | 13 | // LimitWriter returns a writer that writes no more 14 | // than n bytes and silently discards the rest. 15 | func LimitWriter(w io.Writer, n int64) io.Writer { 16 | return &LimitedWriter{w, n} 17 | } 18 | 19 | // Write implements the io.Writer interface. 20 | func (w *LimitedWriter) Write(p []byte) (int, error) { 21 | lenp := len(p) 22 | if w.n <= 0 { 23 | return lenp, nil 24 | } 25 | if int64(lenp) > w.n { 26 | p = p[:w.n] 27 | } 28 | n, err := w.w.Write(p) 29 | w.n -= int64(n) 30 | return lenp, err 31 | } 32 | -------------------------------------------------------------------------------- /internal/engine/io_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bytes" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestLimitedWriter(t *testing.T) { 10 | var b bytes.Buffer 11 | w := LimitWriter(&b, 5) 12 | 13 | { 14 | src := []byte{1, 2, 3} 15 | n, err := w.Write(src) 16 | if n != 3 { 17 | t.Fatalf("write(1,2,3): expected n = 3, got %d", n) 18 | } 19 | if err != nil { 20 | t.Fatalf("write(1,2,3): expected nil err, got %v", err) 21 | } 22 | if !reflect.DeepEqual(b.Bytes(), src) { 23 | t.Fatalf("write(1,2,3): expected %v, got %v", src, b.Bytes()) 24 | } 25 | } 26 | 27 | { 28 | src := []byte{4, 5} 29 | n, err := w.Write(src) 30 | if n != 2 { 31 | t.Fatalf("+write(4,5): expected n = 2, got %d", n) 32 | } 33 | if err != nil { 34 | t.Fatalf("+write(4,5): expected nil err, got %v", err) 35 | } 36 | want := []byte{1, 2, 3, 4, 5} 37 | if !reflect.DeepEqual(b.Bytes(), want) { 38 | t.Fatalf("+write(4,5): expected %v, got %v", want, b.Bytes()) 39 | } 40 | } 41 | 42 | { 43 | src := []byte{6, 7, 8} 44 | n, err := w.Write(src) 45 | if n != 3 { 46 | t.Fatalf("+write(6,7,8): expected n = 3, got %d", n) 47 | } 48 | if err != nil { 49 | t.Fatalf("+write(6,7,8): expected nil err, got %v", err) 50 | } 51 | want := []byte{1, 2, 3, 4, 5} 52 | if !reflect.DeepEqual(b.Bytes(), want) { 53 | t.Fatalf("+write(6,7,8): expected %v, got %v", want, b.Bytes()) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/engine/testdata/example.txt: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /internal/execy/execy.go: -------------------------------------------------------------------------------- 1 | // Package execy runs external commands. 2 | package execy 3 | 4 | import ( 5 | "os/exec" 6 | ) 7 | 8 | var runner = Runner(&osRunner{}) 9 | 10 | // Runner executes external commands. 11 | type Runner interface { 12 | Run(cmd *exec.Cmd) error 13 | } 14 | 15 | // osRunner runs OS programs. 16 | type osRunner struct{} 17 | 18 | func (r *osRunner) Run(cmd *exec.Cmd) error { 19 | return cmd.Run() 20 | } 21 | 22 | func Run(cmd *exec.Cmd) error { 23 | return runner.Run(cmd) 24 | } 25 | 26 | // CmdOut represents the result of the command run. 27 | type CmdOut struct { 28 | Stdout string 29 | Stderr string 30 | Err error 31 | } 32 | -------------------------------------------------------------------------------- /internal/execy/execy_test.go: -------------------------------------------------------------------------------- 1 | package execy 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestRunner(t *testing.T) { 11 | const want = "hello world" 12 | ctx := context.Background() 13 | cmd := exec.CommandContext(ctx, "echo", "-n", want) 14 | outb := new(strings.Builder) 15 | errb := new(strings.Builder) 16 | cmd.Stdout = outb 17 | cmd.Stderr = errb 18 | 19 | err := Run(cmd) 20 | if err != nil { 21 | t.Fatalf("Err: expected nil, got %v", err) 22 | } 23 | if outb.String() != want { 24 | t.Errorf("Stdout: expected %q, got %q", want, outb.String()) 25 | } 26 | if errb.String() != "" { 27 | t.Errorf("Stderr: expected %q, got %q", "", errb.String()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/execy/mock.go: -------------------------------------------------------------------------------- 1 | package execy 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | 7 | "github.com/nalgeon/codapi/internal/logx" 8 | ) 9 | 10 | // Mock installs mock outputs for given commands. 11 | func Mock(commands map[string]CmdOut) *logx.Memory { 12 | if commands != nil { 13 | mockCommands = commands 14 | } 15 | mem := logx.NewMemory("exec") 16 | runner = &mockRunner{mem} 17 | return mem 18 | } 19 | 20 | // mockRunner returns mock outputs 21 | // without running OS programs. 22 | type mockRunner struct { 23 | mem *logx.Memory 24 | } 25 | 26 | // Run returns a mock output from the registry 27 | // that matches the given command name and argument. 28 | func (r *mockRunner) Run(cmd *exec.Cmd) error { 29 | cmdStr := strings.Join(cmd.Args, " ") 30 | r.mem.WriteString(cmdStr) 31 | 32 | key := cmd.Args[0] + " " + cmd.Args[1] 33 | out, ok := mockCommands[key] 34 | if !ok { 35 | // command is not in the registry, 36 | // so let's return an empty "success" result 37 | out = CmdOut{} 38 | } 39 | _, _ = cmd.Stdout.Write([]byte(out.Stdout)) 40 | _, _ = cmd.Stderr.Write([]byte(out.Stderr)) 41 | return out.Err 42 | } 43 | 44 | var mockCommands map[string]CmdOut = map[string]CmdOut{} 45 | -------------------------------------------------------------------------------- /internal/execy/mock_test.go: -------------------------------------------------------------------------------- 1 | package execy 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestMock(t *testing.T) { 11 | const want = "hello world" 12 | out := CmdOut{Stdout: want, Stderr: "", Err: nil} 13 | mem := Mock(map[string]CmdOut{"echo -n": out}) 14 | 15 | ctx := context.Background() 16 | cmd := exec.CommandContext(ctx, "echo", "-n", want) 17 | outb := new(strings.Builder) 18 | errb := new(strings.Builder) 19 | cmd.Stdout = outb 20 | cmd.Stderr = errb 21 | 22 | err := Run(cmd) 23 | if err != nil { 24 | t.Fatalf("Err: expected nil, got %v", err) 25 | } 26 | if outb.String() != want { 27 | t.Errorf("Stdout: expected %q, got %q", want, outb.String()) 28 | } 29 | if errb.String() != "" { 30 | t.Errorf("Stderr: expected %q, got %q", "", errb.String()) 31 | } 32 | mem.MustHave(t, "echo -n hello world") 33 | } 34 | -------------------------------------------------------------------------------- /internal/fileio/fileio.go: -------------------------------------------------------------------------------- 1 | // Package fileio provides high-level file operations. 2 | package fileio 3 | 4 | import ( 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "io/fs" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | // Exists checks if the specified path exists. 16 | func Exists(path string) bool { 17 | _, err := os.Stat(path) 18 | // we need a double negation here, because 19 | // errors.Is(err, os.ErrExist) 20 | // does not work 21 | return !errors.Is(err, os.ErrNotExist) 22 | } 23 | 24 | // CopyFile copies all files matching the pattern 25 | // to the destination directory. Does not overwrite existing file. 26 | func CopyFiles(pattern string, dstDir string, perm fs.FileMode) error { 27 | matches, err := filepath.Glob(pattern) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | for _, match := range matches { 33 | src, err := os.Open(match) 34 | if err != nil { 35 | return err 36 | } 37 | defer src.Close() 38 | 39 | dstFile := filepath.Join(dstDir, filepath.Base(match)) 40 | if Exists(dstFile) { 41 | continue 42 | } 43 | 44 | dst, err := os.OpenFile(dstFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm) 45 | if err != nil { 46 | return err 47 | } 48 | defer dst.Close() 49 | 50 | _, err = io.Copy(dst, src) 51 | if err != nil { 52 | return err 53 | } 54 | } 55 | 56 | return nil 57 | } 58 | 59 | // ReadJson reads the file and decodes it from JSON. 60 | func ReadJson[T any](path string) (T, error) { 61 | var obj T 62 | data, err := os.ReadFile(path) 63 | if err != nil { 64 | return obj, err 65 | } 66 | err = json.Unmarshal(data, &obj) 67 | if err != nil { 68 | return obj, err 69 | } 70 | return obj, err 71 | } 72 | 73 | // WriteFile writes the file to disk. 74 | // The content can be text or binary (encoded as a data URL), 75 | // e.g. data:application/octet-stream;base64,MTIz 76 | func WriteFile(path, content string, perm fs.FileMode) (err error) { 77 | var data []byte 78 | if !strings.HasPrefix(content, "data:") { 79 | // text file 80 | data = []byte(content) 81 | return os.WriteFile(path, data, perm) 82 | } 83 | 84 | // data-url encoded file 85 | meta, encoded, found := strings.Cut(content, ",") 86 | if !found { 87 | return errors.New("invalid data-url encoding") 88 | } 89 | 90 | if !strings.HasSuffix(meta, "base64") { 91 | // no need to decode 92 | data = []byte(encoded) 93 | return os.WriteFile(path, data, perm) 94 | } 95 | 96 | // decode base64-encoded data 97 | data, err = base64.StdEncoding.DecodeString(encoded) 98 | if err != nil { 99 | return err 100 | } 101 | return os.WriteFile(path, data, perm) 102 | } 103 | 104 | // JoinDir joins a directory path with a relative file path, 105 | // making sure that the resulting path is still inside the directory. 106 | // Returns an error otherwise. 107 | func JoinDir(dir string, name string) (string, error) { 108 | if dir == "" { 109 | return "", errors.New("invalid dir") 110 | } 111 | 112 | cleanName := filepath.Clean(name) 113 | if cleanName == "" { 114 | return "", errors.New("invalid name") 115 | } 116 | if cleanName == "." || cleanName == "/" || filepath.IsAbs(cleanName) { 117 | return "", errors.New("invalid name") 118 | } 119 | 120 | path := filepath.Join(dir, cleanName) 121 | 122 | dirPrefix := filepath.Clean(dir) 123 | if dirPrefix != "/" { 124 | dirPrefix += string(os.PathSeparator) 125 | } 126 | if !strings.HasPrefix(path, dirPrefix) { 127 | return "", errors.New("invalid name") 128 | } 129 | 130 | return path, nil 131 | } 132 | 133 | // MkdirTemp creates a new temporary directory with given permissions 134 | // and returns the pathname of the new directory. 135 | func MkdirTemp(perm fs.FileMode) (string, error) { 136 | dir, err := os.MkdirTemp("", "") 137 | if err != nil { 138 | return "", err 139 | } 140 | err = os.Chmod(dir, perm) 141 | if err != nil { 142 | os.Remove(dir) 143 | return "", err 144 | } 145 | return dir, nil 146 | } 147 | -------------------------------------------------------------------------------- /internal/fileio/fileio_test.go: -------------------------------------------------------------------------------- 1 | package fileio 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | func TestExists(t *testing.T) { 12 | t.Run("exists", func(t *testing.T) { 13 | path := filepath.Join(t.TempDir(), "file.txt") 14 | err := os.WriteFile(path, []byte{1, 2, 3}, 0444) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | if !Exists(path) { 19 | t.Fatalf("Exists: %s does not exist", filepath.Base(path)) 20 | } 21 | }) 22 | t.Run("does not exist", func(t *testing.T) { 23 | path := filepath.Join(t.TempDir(), "file.txt") 24 | if Exists(path) { 25 | t.Fatalf("Exists: %s should not exist", filepath.Base(path)) 26 | } 27 | }) 28 | } 29 | 30 | func TestCopyFiles(t *testing.T) { 31 | // create a temporary directory for testing 32 | srcDir, err := os.MkdirTemp("", "src") 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | defer os.RemoveAll(srcDir) 37 | 38 | // create a source file 39 | srcFile := filepath.Join(srcDir, "source.txt") 40 | err = os.WriteFile(srcFile, []byte("test data"), 0644) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | // specify the destination directory 46 | dstDir, err := os.MkdirTemp("", "dst") 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | defer os.RemoveAll(dstDir) 51 | 52 | t.Run("copy", func(t *testing.T) { 53 | // call the CopyFiles function 54 | const perm = fs.FileMode(0444) 55 | pattern := filepath.Join(srcDir, "*.txt") 56 | err = CopyFiles(pattern, dstDir, perm) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | // verify that the file was copied correctly 62 | dstFile := filepath.Join(dstDir, "source.txt") 63 | fileInfo, err := os.Stat(dstFile) 64 | if err != nil { 65 | t.Fatalf("file not copied: %s", err) 66 | } 67 | if fileInfo.Mode() != perm { 68 | t.Errorf("unexpected file permissions: got %v, want %v", fileInfo.Mode(), perm) 69 | } 70 | 71 | // read the contents of the copied file 72 | data, err := os.ReadFile(dstFile) 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | 77 | // verify the contents of the copied file 78 | expected := []byte("test data") 79 | if string(data) != string(expected) { 80 | t.Errorf("unexpected file content: got %q, want %q", data, expected) 81 | } 82 | }) 83 | 84 | t.Run("skip existing", func(t *testing.T) { 85 | // existing file in the destination dir 86 | path := filepath.Join(dstDir, "existing.txt") 87 | err := os.WriteFile(path, []byte("v1"), 0444) 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | 92 | // same file in the source dir 93 | path = filepath.Join(srcDir, "existing.txt") 94 | err = os.WriteFile(path, []byte("v2"), 0444) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | 99 | // copy files 100 | pattern := filepath.Join(srcDir, "*.txt") 101 | err = CopyFiles(pattern, dstDir, 0444) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | 106 | // verify that the new file was copied correctly 107 | newFile := filepath.Join(dstDir, "source.txt") 108 | _, err = os.Stat(newFile) 109 | if err != nil { 110 | t.Fatalf("new file not copied: %s", err) 111 | } 112 | 113 | // verify that the existing file remained unchanged 114 | existFile := filepath.Join(dstDir, "existing.txt") 115 | data, err := os.ReadFile(existFile) 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | expected := []byte("v1") 120 | if string(data) != string(expected) { 121 | t.Error("existing file got overwritten") 122 | } 123 | }) 124 | } 125 | 126 | func TestReadJson(t *testing.T) { 127 | type Person struct{ Name string } 128 | 129 | t.Run("valid", func(t *testing.T) { 130 | got, err := ReadJson[Person](filepath.Join("testdata", "valid.json")) 131 | if err != nil { 132 | t.Fatalf("unexpected error %v", err) 133 | } 134 | want := Person{"alice"} 135 | if !reflect.DeepEqual(got, want) { 136 | t.Errorf("expected %v, got %v", want, got) 137 | } 138 | }) 139 | t.Run("invalid", func(t *testing.T) { 140 | _, err := ReadJson[Person](filepath.Join("testdata", "invalid.json")) 141 | if err == nil { 142 | t.Fatal("expected error, got nil") 143 | } 144 | }) 145 | t.Run("does not exist", func(t *testing.T) { 146 | _, err := ReadJson[Person](filepath.Join("testdata", "missing.json")) 147 | if err == nil { 148 | t.Fatal("expected error, got nil") 149 | } 150 | }) 151 | } 152 | 153 | func TestWriteFile(t *testing.T) { 154 | dir, err := os.MkdirTemp("", "files") 155 | if err != nil { 156 | t.Fatal(err) 157 | } 158 | defer os.RemoveAll(dir) 159 | 160 | t.Run("text", func(t *testing.T) { 161 | path := filepath.Join(dir, "data.txt") 162 | err = WriteFile(path, "hello", 0444) 163 | if err != nil { 164 | t.Fatalf("expected nil err, got %v", err) 165 | } 166 | got, err := os.ReadFile(path) 167 | if err != nil { 168 | t.Fatalf("read file: expected nil err, got %v", err) 169 | } 170 | want := []byte("hello") 171 | if !reflect.DeepEqual(got, want) { 172 | t.Errorf("read file: expected %v, got %v", want, got) 173 | } 174 | }) 175 | 176 | t.Run("data-octet-stream", func(t *testing.T) { 177 | path := filepath.Join(dir, "data-1.bin") 178 | err = WriteFile(path, "data:application/octet-stream;base64,MTIz", 0444) 179 | if err != nil { 180 | t.Fatalf("expected nil err, got %v", err) 181 | } 182 | got, err := os.ReadFile(path) 183 | if err != nil { 184 | t.Fatalf("read file: expected nil err, got %v", err) 185 | } 186 | want := []byte("123") 187 | if !reflect.DeepEqual(got, want) { 188 | t.Errorf("read file: expected %v, got %v", want, got) 189 | } 190 | }) 191 | 192 | t.Run("data-base64", func(t *testing.T) { 193 | path := filepath.Join(dir, "data-2.bin") 194 | err = WriteFile(path, "data:;base64,MTIz", 0444) 195 | if err != nil { 196 | t.Fatalf("expected nil err, got %v", err) 197 | } 198 | got, err := os.ReadFile(path) 199 | if err != nil { 200 | t.Fatalf("read file: expected nil err, got %v", err) 201 | } 202 | want := []byte("123") 203 | if !reflect.DeepEqual(got, want) { 204 | t.Errorf("read file: expected %v, got %v", want, got) 205 | } 206 | }) 207 | 208 | t.Run("data-text-plain", func(t *testing.T) { 209 | path := filepath.Join(dir, "data-3.bin") 210 | err = WriteFile(path, "data:text/plain;,123", 0444) 211 | if err != nil { 212 | t.Fatalf("expected nil err, got %v", err) 213 | } 214 | got, err := os.ReadFile(path) 215 | if err != nil { 216 | t.Fatalf("read file: expected nil err, got %v", err) 217 | } 218 | want := []byte("123") 219 | if !reflect.DeepEqual(got, want) { 220 | t.Errorf("read file: expected %v, got %v", want, got) 221 | } 222 | }) 223 | 224 | t.Run("perm", func(t *testing.T) { 225 | const perm = 0444 226 | path := filepath.Join(dir, "perm.txt") 227 | err = WriteFile(path, "hello", perm) 228 | if err != nil { 229 | t.Fatalf("expected nil err, got %v", err) 230 | } 231 | fileInfo, err := os.Stat(path) 232 | if err != nil { 233 | t.Fatalf("file not created: %s", err) 234 | } 235 | if fileInfo.Mode().Perm() != perm { 236 | t.Errorf("unexpected file permissions: expected %o, got %o", perm, fileInfo.Mode().Perm()) 237 | } 238 | }) 239 | 240 | t.Run("missing data-url separator", func(t *testing.T) { 241 | path := filepath.Join(dir, "data.bin") 242 | err = WriteFile(path, "data:application/octet-stream:MTIz", 0444) 243 | if err == nil { 244 | t.Fatal("expected error, got nil") 245 | } 246 | }) 247 | 248 | t.Run("invalid binary value", func(t *testing.T) { 249 | path := filepath.Join(dir, "data.bin") 250 | err = WriteFile(path, "data:;base64,12345", 0444) 251 | if err == nil { 252 | t.Fatal("expected error, got nil") 253 | } 254 | }) 255 | } 256 | 257 | func TestJoinDir(t *testing.T) { 258 | tests := []struct { 259 | name string 260 | dir string 261 | filename string 262 | want string 263 | wantErr bool 264 | }{ 265 | { 266 | name: "regular join", 267 | dir: "/home/user", 268 | filename: "docs/report.txt", 269 | want: "/home/user/docs/report.txt", 270 | wantErr: false, 271 | }, 272 | { 273 | name: "join with dot", 274 | dir: "/home/user", 275 | filename: ".", 276 | want: "", 277 | wantErr: true, 278 | }, 279 | { 280 | name: "join with absolute path", 281 | dir: "/home/user", 282 | filename: "/etc/passwd", 283 | want: "", 284 | wantErr: true, 285 | }, 286 | { 287 | name: "join with parent directory", 288 | dir: "/home/user", 289 | filename: "../user2/docs/report.txt", 290 | want: "", 291 | wantErr: true, 292 | }, 293 | { 294 | name: "empty directory", 295 | dir: "", 296 | filename: "report.txt", 297 | want: "", 298 | wantErr: true, 299 | }, 300 | { 301 | name: "empty filename", 302 | dir: "/home/user", 303 | filename: "", 304 | want: "", 305 | wantErr: true, 306 | }, 307 | { 308 | name: "directory with trailing slash", 309 | dir: "/home/user/", 310 | filename: "docs/report.txt", 311 | want: "/home/user/docs/report.txt", 312 | wantErr: false, 313 | }, 314 | { 315 | name: "filename with leading slash", 316 | dir: "/home/user", 317 | filename: "/docs/report.txt", 318 | want: "", 319 | wantErr: true, 320 | }, 321 | { 322 | name: "root directory", 323 | dir: "/", 324 | filename: "report.txt", 325 | want: "/report.txt", 326 | wantErr: false, 327 | }, 328 | { 329 | name: "dot dot slash filename", 330 | dir: "/home/user", 331 | filename: "..", 332 | want: "", 333 | wantErr: true, 334 | }, 335 | } 336 | 337 | for _, tt := range tests { 338 | t.Run(tt.name, func(t *testing.T) { 339 | got, err := JoinDir(tt.dir, tt.filename) 340 | if (err != nil) != tt.wantErr { 341 | t.Errorf("JoinDir() error = %v, wantErr %v", err, tt.wantErr) 342 | return 343 | } 344 | if got != tt.want { 345 | t.Errorf("JoinDir() = %v, want %v", got, tt.want) 346 | } 347 | }) 348 | } 349 | } 350 | 351 | func TestMkdirTemp(t *testing.T) { 352 | t.Run("default permissions", func(t *testing.T) { 353 | const perm = 0755 354 | dir, err := MkdirTemp(perm) 355 | if err != nil { 356 | t.Fatalf("failed to create temp directory: %v", err) 357 | } 358 | defer os.Remove(dir) 359 | 360 | info, err := os.Stat(dir) 361 | if err != nil { 362 | t.Fatalf("failed to stat temp directory: %v", err) 363 | } 364 | if info.Mode().Perm() != perm { 365 | t.Errorf("unexpected permissions: expected %o, got %o", perm, info.Mode().Perm()) 366 | } 367 | }) 368 | 369 | t.Run("non-default permissions", func(t *testing.T) { 370 | const perm = 0777 371 | dir, err := MkdirTemp(perm) 372 | if err != nil { 373 | t.Fatalf("failed to create temp directory: %v", err) 374 | } 375 | defer os.Remove(dir) 376 | 377 | info, err := os.Stat(dir) 378 | if err != nil { 379 | t.Fatalf("failed to stat temp directory: %v", err) 380 | } 381 | if info.Mode().Perm() != perm { 382 | t.Errorf("unexpected permissions: expected %o, got %o", perm, info.Mode().Perm()) 383 | } 384 | }) 385 | } 386 | -------------------------------------------------------------------------------- /internal/fileio/testdata/invalid.json: -------------------------------------------------------------------------------- 1 | name: alice 2 | -------------------------------------------------------------------------------- /internal/fileio/testdata/valid.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alice" 3 | } 4 | -------------------------------------------------------------------------------- /internal/httpx/httpx.go: -------------------------------------------------------------------------------- 1 | // Package httpx provides helper functions for making HTTP requests. 2 | package httpx 3 | 4 | import ( 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | var client = Client(&http.Client{Timeout: 5 * time.Second}) 10 | 11 | // Client is something that can send HTTP requests. 12 | type Client interface { 13 | Do(req *http.Request) (*http.Response, error) 14 | } 15 | 16 | // Do sends an HTTP request and returns an HTTP response. 17 | func Do(req *http.Request) (*http.Response, error) { 18 | return client.Do(req) 19 | } 20 | -------------------------------------------------------------------------------- /internal/httpx/httpx_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestDo(t *testing.T) { 9 | srv := MockServer() 10 | defer srv.Close() 11 | 12 | t.Run("ok", func(t *testing.T) { 13 | uri := srv.URL + "/example.json" 14 | req, _ := http.NewRequest(http.MethodGet, uri, nil) 15 | 16 | resp, err := Do(req) 17 | if err != nil { 18 | t.Errorf("Do: unexpected error %v", err) 19 | } 20 | defer resp.Body.Close() 21 | 22 | if resp.StatusCode != http.StatusOK { 23 | t.Errorf("Do: expected status=%d, got %v", http.StatusOK, resp.StatusCode) 24 | } 25 | }) 26 | t.Run("not found", func(t *testing.T) { 27 | uri := srv.URL + "/not-found.json" 28 | req, _ := http.NewRequest(http.MethodGet, uri, nil) 29 | 30 | resp, err := Do(req) 31 | if err != nil { 32 | t.Errorf("Do: unexpected error %v", err) 33 | } 34 | defer resp.Body.Close() 35 | 36 | if resp.StatusCode != http.StatusNotFound { 37 | t.Errorf("Do: expected status=%d, got %v", http.StatusNotFound, resp.StatusCode) 38 | } 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /internal/httpx/mock.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | ) 14 | 15 | var contentTypes = map[string]string{ 16 | ".json": "application/json", 17 | ".txt": "text/plain", 18 | } 19 | 20 | // MockClient serves responses from the file system instead of remote calls. 21 | // Should be used for testing purposes only. 22 | type MockClient struct { 23 | dir string 24 | } 25 | 26 | // Mock creates a new MockClient and installs it instead of the default one. 27 | func Mock(path ...string) *MockClient { 28 | dir := filepath.Join("testdata", filepath.Join(path...)) 29 | c := &MockClient{dir: dir} 30 | client = c 31 | return c 32 | } 33 | 34 | // Do serves the file according to the request URL. 35 | func (c *MockClient) Do(req *http.Request) (*http.Response, error) { 36 | filename := filepath.Join(c.dir, path.Base(req.URL.Path)) 37 | 38 | data, err := os.ReadFile(filename) 39 | if err != nil { 40 | resp := http.Response{ 41 | Status: http.StatusText(http.StatusNotFound), 42 | StatusCode: http.StatusNotFound, 43 | } 44 | return &resp, nil 45 | } 46 | 47 | cType, ok := contentTypes[path.Ext(filename)] 48 | if !ok { 49 | cType = "application/octet-stream" 50 | } 51 | rdr := respond(cType, data) 52 | resp, err := http.ReadResponse(bufio.NewReader(rdr), req) 53 | if err != nil { 54 | panic(err) 55 | } 56 | return resp, nil 57 | } 58 | 59 | func respond(cType string, data []byte) io.Reader { 60 | buf := bytes.Buffer{} 61 | buf.WriteString("HTTP/1.1 200 OK\n") 62 | buf.WriteString(fmt.Sprintf("Content-Type: %s\n\n", cType)) 63 | _, err := buf.Write(data) 64 | if err != nil { 65 | panic(err) 66 | } 67 | return &buf 68 | } 69 | 70 | // MockServer creates a mock HTTP server and installs its client 71 | // instead of the default one. Serves responses from the file system 72 | // instead of remote calls. Should be used for testing purposes only. 73 | func MockServer() *httptest.Server { 74 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 75 | filename := filepath.Join("testdata", path.Base(r.URL.Path)) 76 | 77 | data, err := os.ReadFile(filename) 78 | if err != nil { 79 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 80 | return 81 | } 82 | 83 | cType, ok := contentTypes[path.Ext(filename)] 84 | if !ok { 85 | cType = "application/octet-stream" 86 | } 87 | 88 | w.Header().Set("content-type", cType) 89 | _, err = w.Write(data) 90 | if err != nil { 91 | panic(err) 92 | } 93 | })) 94 | client = srv.Client() 95 | return srv 96 | } 97 | -------------------------------------------------------------------------------- /internal/httpx/mock_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestMockClient(t *testing.T) { 10 | Mock() 11 | 12 | const url = "https://codapi.org/example.txt" 13 | req, _ := http.NewRequest("GET", url, nil) 14 | 15 | resp, err := Do(req) 16 | if err != nil { 17 | t.Errorf("Do: unexpected error %v", err) 18 | } 19 | defer resp.Body.Close() 20 | 21 | if resp.StatusCode != http.StatusOK { 22 | t.Errorf("Do: expected status code %d, got %d", http.StatusOK, resp.StatusCode) 23 | } 24 | 25 | body, err := io.ReadAll(resp.Body) 26 | if err != nil { 27 | t.Errorf("io.ReadAll: unexpected error %v", err) 28 | } 29 | 30 | want := "hello" 31 | if string(body) != want { 32 | t.Errorf("Do: expected %v, got %v", want, string(body)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/httpx/testdata/example.json: -------------------------------------------------------------------------------- 1 | { "name": "alice" } 2 | -------------------------------------------------------------------------------- /internal/httpx/testdata/example.txt: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /internal/logx/logx.go: -------------------------------------------------------------------------------- 1 | // Package logx provides helper functions for logging. 2 | package logx 3 | 4 | import ( 5 | "io" 6 | "log" 7 | "os" 8 | ) 9 | 10 | var logger = log.New(os.Stderr, "", log.LstdFlags) 11 | var Verbose = false 12 | 13 | // SetOutput sets the output destination. 14 | func SetOutput(w io.Writer) { 15 | logger.SetOutput(w) 16 | } 17 | 18 | // Printf prints a formatted message. 19 | func Printf(format string, v ...any) { 20 | logger.Printf(format, v...) 21 | } 22 | 23 | // Println prints a message. 24 | func Println(v ...any) { 25 | logger.Println(v...) 26 | } 27 | 28 | // Log prints a message. 29 | func Log(message string, args ...any) { 30 | if len(args) == 0 { 31 | logger.Println(message) 32 | } else { 33 | logger.Printf(message+"\n", args...) 34 | } 35 | } 36 | 37 | // Debug prints a message if the verbose mode is on. 38 | func Debug(message string, args ...any) { 39 | if !Verbose { 40 | return 41 | } 42 | Log(message, args...) 43 | } 44 | 45 | // Mock creates a new Memory and installs it as the logger output 46 | // instead of the default one. Should be used for testing purposes only. 47 | func Mock(path ...string) *Memory { 48 | memory := NewMemory("log") 49 | SetOutput(memory) 50 | Verbose = true 51 | return memory 52 | } 53 | -------------------------------------------------------------------------------- /internal/logx/logx_test.go: -------------------------------------------------------------------------------- 1 | package logx 2 | 3 | import "testing" 4 | 5 | func TestSetOutput(t *testing.T) { 6 | mem := NewMemory("log") 7 | SetOutput(mem) 8 | Log("hello") 9 | if !mem.Has("hello") { 10 | t.Error("SetOutput: memory not set as output") 11 | } 12 | } 13 | 14 | func TestLog(t *testing.T) { 15 | mem := NewMemory("log") 16 | SetOutput(mem) 17 | { 18 | Log("value: %d", 42) 19 | if len(mem.Lines) != 1 { 20 | t.Errorf("Log: expected line count %v", len(mem.Lines)) 21 | } 22 | if !mem.Has("value: 42") { 23 | t.Errorf("Log: expected output: %v", mem.Lines) 24 | } 25 | } 26 | { 27 | Log("value: %d", 84) 28 | if len(mem.Lines) != 2 { 29 | t.Errorf("Log: expected line count %v", len(mem.Lines)) 30 | } 31 | if !mem.Has("value: 42") || !mem.Has("value: 84") { 32 | t.Errorf("Log: expected output: %v", mem.Lines) 33 | } 34 | } 35 | } 36 | 37 | func TestDebug(t *testing.T) { 38 | t.Run("enabled", func(t *testing.T) { 39 | mem := NewMemory("log") 40 | SetOutput(mem) 41 | Verbose = true 42 | { 43 | Debug("value: %d", 42) 44 | if len(mem.Lines) != 1 { 45 | t.Errorf("Log: expected line count %v", len(mem.Lines)) 46 | } 47 | if !mem.Has("value: 42") { 48 | t.Errorf("Log: expected output: %v", mem.Lines) 49 | } 50 | } 51 | { 52 | Debug("value: %d", 84) 53 | if len(mem.Lines) != 2 { 54 | t.Errorf("Log: expected line count %v", len(mem.Lines)) 55 | } 56 | if !mem.Has("value: 42") || !mem.Has("value: 84") { 57 | t.Errorf("Log: expected output: %v", mem.Lines) 58 | } 59 | } 60 | }) 61 | t.Run("disabled", func(t *testing.T) { 62 | mem := NewMemory("log") 63 | SetOutput(mem) 64 | Verbose = false 65 | Debug("value: %d", 42) 66 | if len(mem.Lines) != 0 { 67 | t.Errorf("Log: expected line count %v", len(mem.Lines)) 68 | } 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /internal/logx/memory.go: -------------------------------------------------------------------------------- 1 | package logx 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // Memory stores logged messages in a slice. 10 | type Memory struct { 11 | Name string 12 | Lines []string 13 | } 14 | 15 | // NewMemory creates a new memory destination. 16 | func NewMemory(name string) *Memory { 17 | return &Memory{Name: name, Lines: []string{}} 18 | } 19 | 20 | // Write implements the io.Writer interface. 21 | func (m *Memory) Write(p []byte) (n int, err error) { 22 | msg := string(p) 23 | m.Lines = append(m.Lines, msg) 24 | return len(p), nil 25 | } 26 | 27 | // WriteString writes a string to the memory. 28 | func (m *Memory) WriteString(s string) { 29 | m.Lines = append(m.Lines, s) 30 | } 31 | 32 | // Has returns true if the memory has the message. 33 | func (m *Memory) Has(message ...string) bool { 34 | for _, line := range m.Lines { 35 | containsAll := true 36 | for _, part := range message { 37 | if !strings.Contains(line, part) { 38 | containsAll = false 39 | break 40 | } 41 | } 42 | if containsAll { 43 | return true 44 | } 45 | } 46 | return false 47 | } 48 | 49 | // MustHave checks if the memory has the message. 50 | // If the message consists of several parts, 51 | // they must all be in the same memory line. 52 | func (m *Memory) MustHave(t *testing.T, message ...string) { 53 | if !m.Has(message...) { 54 | t.Errorf("%s must have: %v", m.Name, message) 55 | } 56 | } 57 | 58 | // MustNotHave checks if the memory does not have the message. 59 | func (m *Memory) MustNotHave(t *testing.T, message ...string) { 60 | if m.Has(message...) { 61 | t.Errorf("%s must NOT have: %v", m.Name, message) 62 | } 63 | } 64 | 65 | // Clear clears the memory. 66 | func (m *Memory) Clear() { 67 | m.Lines = []string{} 68 | } 69 | 70 | // Print prints memory lines to stdout. 71 | func (m *Memory) Print() { 72 | for _, line := range m.Lines { 73 | fmt.Println(line) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/logx/memory_test.go: -------------------------------------------------------------------------------- 1 | package logx 2 | 3 | import "testing" 4 | 5 | func TestMemory_Name(t *testing.T) { 6 | mem := NewMemory("log") 7 | if mem.Name != "log" { 8 | t.Errorf("Name: unexpected name %q", mem.Name) 9 | } 10 | } 11 | 12 | func TestMemory_Write(t *testing.T) { 13 | mem := NewMemory("log") 14 | if len(mem.Lines) != 0 { 15 | t.Fatalf("Write: unexpected line count %v", len(mem.Lines)) 16 | } 17 | 18 | n, err := mem.Write([]byte("hello world")) 19 | if err != nil { 20 | t.Fatalf("Write: unexpected error %v", err) 21 | } 22 | if n != 11 { 23 | t.Errorf("Write: unexpected byte count %v", n) 24 | } 25 | 26 | if len(mem.Lines) != 1 { 27 | t.Fatalf("Write: unexpected line count %v", len(mem.Lines)) 28 | } 29 | if mem.Lines[0] != "hello world" { 30 | t.Errorf("Write: unexpected line #0 %q", mem.Lines[0]) 31 | } 32 | } 33 | 34 | func TestMemory_Has(t *testing.T) { 35 | mem := NewMemory("log") 36 | if mem.Has("hello world") { 37 | t.Error("Has: unexpected true") 38 | } 39 | _, _ = mem.Write([]byte("hello world")) 40 | if !mem.Has("hello world") { 41 | t.Error("Has: unexpected false") 42 | } 43 | _, _ = mem.Write([]byte("one two three four")) 44 | if !mem.Has("one two") { 45 | t.Error("Has: one two: unexpected false") 46 | } 47 | if !mem.Has("two three") { 48 | t.Error("Has: two three: unexpected false") 49 | } 50 | if mem.Has("one three") { 51 | t.Error("Has: one three: unexpected true") 52 | } 53 | if !mem.Has("one", "three") { 54 | t.Error("Has: [one three]: unexpected false") 55 | } 56 | if !mem.Has("one", "three", "four") { 57 | t.Error("Has: [one three four]: unexpected false") 58 | } 59 | if !mem.Has("four", "three") { 60 | t.Error("Has: [four three]: unexpected false") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/sandbox/config.go: -------------------------------------------------------------------------------- 1 | // Creates sandboxes according to the configuration. 2 | package sandbox 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/nalgeon/codapi/internal/config" 8 | "github.com/nalgeon/codapi/internal/engine" 9 | ) 10 | 11 | // A semaphore represents available concurrent workers 12 | // that are responsible for executing code in sandboxes. 13 | // The workers themselves are external to this package 14 | // (the calling goroutines are workers). 15 | var semaphore *Semaphore 16 | 17 | var engineConstr = map[string]func(*config.Config, string, string) engine.Engine{ 18 | "docker": engine.NewDocker, 19 | "http": engine.NewHTTP, 20 | } 21 | 22 | // engines is the registry of command executors. 23 | // Each engine executes a specific command in a specific sandbox. 24 | // sandbox : command : engine 25 | // TODO: Maybe it's better to create a single instance of each engine 26 | // and pass the sandbox and command as arguments to the Exec. 27 | var engines = map[string]map[string]engine.Engine{} 28 | 29 | // ApplyConfig fills engine registry according to the configuration. 30 | func ApplyConfig(cfg *config.Config) error { 31 | semaphore = NewSemaphore(cfg.PoolSize) 32 | for sandName, sandCmds := range cfg.Commands { 33 | engines[sandName] = make(map[string]engine.Engine) 34 | for cmdName, cmd := range sandCmds { 35 | constructor, ok := engineConstr[cmd.Engine] 36 | if !ok { 37 | return fmt.Errorf("unknown engine: %s", cmd.Engine) 38 | } 39 | engines[sandName][cmdName] = constructor(cfg, sandName, cmdName) 40 | } 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/sandbox/config_test.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nalgeon/codapi/internal/config" 7 | "github.com/nalgeon/codapi/internal/engine" 8 | ) 9 | 10 | var cfg = &config.Config{ 11 | PoolSize: 8, 12 | HTTP: &config.HTTP{ 13 | Hosts: map[string]string{"localhost": "localhost"}, 14 | }, 15 | Boxes: map[string]*config.Box{ 16 | "http": {}, 17 | "python": {}, 18 | }, 19 | Commands: map[string]config.SandboxCommands{ 20 | "http": map[string]*config.Command{ 21 | "run": {Engine: "http"}, 22 | }, 23 | "python": map[string]*config.Command{ 24 | "run": { 25 | Engine: "docker", 26 | Entry: "main.py", 27 | Steps: []*config.Step{ 28 | {Box: "python", Action: "run", NOutput: 4096}, 29 | }, 30 | }, 31 | "test": {Engine: "docker"}, 32 | }, 33 | }, 34 | } 35 | 36 | func TestApplyConfig(t *testing.T) { 37 | err := ApplyConfig(cfg) 38 | if err != nil { 39 | t.Fatalf("ApplyConfig: expected nil err, got %v", err) 40 | } 41 | if semaphore.Size() != cfg.PoolSize { 42 | t.Errorf("semaphore.Size: expected %d, got %d", cfg.PoolSize, semaphore.Size()) 43 | } 44 | if len(engines) != 2 { 45 | t.Errorf("len(engines): expected 2, got %d", len(engines)) 46 | } 47 | if len(engines["http"]) != 1 { 48 | t.Errorf("len(engine = http): expected 1, got %d", len(engines["http"])) 49 | } 50 | if _, ok := engines["http"]["run"].(*engine.HTTP); !ok { 51 | t.Error("engine = http: expected HTTP engine") 52 | } 53 | if len(engines["python"]) != 2 { 54 | t.Errorf("len(engine = python): expected 2, got %d", len(engines["python"])) 55 | } 56 | if _, ok := engines["python"]["run"].(*engine.Docker); !ok { 57 | t.Error("engine = python: expected Docker engine") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/sandbox/sandbox.go: -------------------------------------------------------------------------------- 1 | // Package sandbox provides a registry of sandboxes 2 | // for code execution. 3 | package sandbox 4 | 5 | import ( 6 | "errors" 7 | "strings" 8 | "time" 9 | 10 | "github.com/nalgeon/codapi/internal/engine" 11 | ) 12 | 13 | var ErrUnknownSandbox = errors.New("unknown sandbox") 14 | var ErrUnknownCommand = errors.New("unknown command") 15 | var ErrEmptyRequest = errors.New("empty request") 16 | 17 | // Validate checks if the code execution request is valid. 18 | func Validate(in engine.Request) error { 19 | box, ok := engines[in.Sandbox] 20 | if !ok { 21 | return ErrUnknownSandbox 22 | } 23 | _, ok = box[in.Command] 24 | if !ok { 25 | return ErrUnknownCommand 26 | } 27 | if len(in.Files) < 2 && strings.TrimSpace(in.Files.First()) == "" { 28 | return ErrEmptyRequest 29 | } 30 | return nil 31 | } 32 | 33 | // Exec executes the code using the appropriate sandbox. 34 | // Allows no more than pool.Size() concurrent workers at any given time. 35 | // The request must already be validated by Validate(). 36 | func Exec(in engine.Request) engine.Execution { 37 | err := semaphore.Acquire() 38 | defer semaphore.Release() 39 | if err == ErrBusy { 40 | return engine.Fail(in.ID, engine.ErrBusy) 41 | } 42 | start := time.Now() 43 | engine := engines[in.Sandbox][in.Command] 44 | out := engine.Exec(in) 45 | out.Duration = int(time.Since(start).Milliseconds()) 46 | return out 47 | } 48 | -------------------------------------------------------------------------------- /internal/sandbox/sandbox_test.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/nalgeon/codapi/internal/engine" 8 | "github.com/nalgeon/codapi/internal/execy" 9 | ) 10 | 11 | func TestValidate(t *testing.T) { 12 | _ = ApplyConfig(cfg) 13 | t.Run("valid", func(t *testing.T) { 14 | 15 | req := engine.Request{ 16 | ID: "http_42", 17 | Sandbox: "python", 18 | Command: "run", 19 | Files: map[string]string{ 20 | "": "print('hello')", 21 | }, 22 | } 23 | err := Validate(req) 24 | if err != nil { 25 | t.Errorf("Validate: expected nil err, got %v", err) 26 | } 27 | }) 28 | t.Run("unknown sandbox", func(t *testing.T) { 29 | req := engine.Request{ 30 | ID: "http_42", 31 | Sandbox: "rust", 32 | Command: "run", 33 | Files: nil, 34 | } 35 | err := Validate(req) 36 | if !errors.Is(err, ErrUnknownSandbox) { 37 | t.Errorf("Validate: expected ErrUnknownSandbox, got %T(%s)", err, err) 38 | } 39 | }) 40 | t.Run("unknown command", func(t *testing.T) { 41 | req := engine.Request{ 42 | ID: "http_42", 43 | Sandbox: "python", 44 | Command: "deploy", 45 | Files: nil, 46 | } 47 | err := Validate(req) 48 | if !errors.Is(err, ErrUnknownCommand) { 49 | t.Errorf("Validate: expected ErrUnknownCommand, got %T(%s)", err, err) 50 | } 51 | }) 52 | t.Run("empty request", func(t *testing.T) { 53 | req := engine.Request{ 54 | ID: "http_42", 55 | Sandbox: "python", 56 | Command: "run", 57 | Files: nil, 58 | } 59 | err := Validate(req) 60 | if !errors.Is(err, ErrEmptyRequest) { 61 | t.Errorf("Validate: expected ErrEmptyRequest, got %T(%s)", err, err) 62 | } 63 | }) 64 | } 65 | 66 | func TestExec(t *testing.T) { 67 | _ = ApplyConfig(cfg) 68 | t.Run("exec", func(t *testing.T) { 69 | execy.Mock(map[string]execy.CmdOut{ 70 | "docker run": {Stdout: "hello"}, 71 | }) 72 | req := engine.Request{ 73 | ID: "http_42", 74 | Sandbox: "python", 75 | Command: "run", 76 | Files: map[string]string{ 77 | "": "print('hello')", 78 | }, 79 | } 80 | out := Exec(req) 81 | if out.ID != req.ID { 82 | t.Errorf("ID: expected %s, got %s", req.ID, out.ID) 83 | } 84 | if !out.OK { 85 | t.Error("OK: expected true") 86 | } 87 | if out.Stdout != "hello" { 88 | t.Errorf("Stdout: expected hello, got %s", out.Stdout) 89 | } 90 | if out.Stderr != "" { 91 | t.Errorf("Stderr: expected empty string, got %s", out.Stderr) 92 | } 93 | if out.Err != nil { 94 | t.Errorf("Err: expected nil, got %v", out.Err) 95 | } 96 | }) 97 | t.Run("busy", func(t *testing.T) { 98 | for i := 0; i < cfg.PoolSize; i++ { 99 | _ = semaphore.Acquire() 100 | } 101 | req := engine.Request{ 102 | ID: "http_42", 103 | Sandbox: "python", 104 | Command: "run", 105 | Files: map[string]string{ 106 | "": "print('hello')", 107 | }, 108 | } 109 | out := Exec(req) 110 | if out.Err != engine.ErrBusy { 111 | t.Errorf("Err: expected ErrBusy, got %v", out.Err) 112 | } 113 | }) 114 | 115 | } 116 | -------------------------------------------------------------------------------- /internal/sandbox/semaphore.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import "errors" 4 | 5 | var ErrBusy = errors.New("busy") 6 | 7 | // A Semaphore manages a limited number of tokens 8 | // that can be acquired or released. 9 | type Semaphore struct { 10 | tokens chan struct{} 11 | } 12 | 13 | // NewSemaphore creates a new semaphore of the specified size. 14 | func NewSemaphore(size int) *Semaphore { 15 | tokens := make(chan struct{}, size) 16 | for i := 0; i < size; i++ { 17 | tokens <- struct{}{} 18 | } 19 | return &Semaphore{tokens} 20 | } 21 | 22 | // Acquire acquires a token. Returns ErrBusy if no tokens are available. 23 | func (q *Semaphore) Acquire() error { 24 | select { 25 | case <-q.tokens: 26 | return nil 27 | default: 28 | return ErrBusy 29 | } 30 | } 31 | 32 | // Release releases a token. 33 | func (q *Semaphore) Release() { 34 | select { 35 | case q.tokens <- struct{}{}: 36 | return 37 | default: 38 | return 39 | } 40 | } 41 | 42 | // Size returns the size of the semaphore. 43 | func (q *Semaphore) Size() int { 44 | return len(q.tokens) 45 | } 46 | -------------------------------------------------------------------------------- /internal/sandbox/semaphore_test.go: -------------------------------------------------------------------------------- 1 | package sandbox 2 | 3 | import "testing" 4 | 5 | func TestSemaphore(t *testing.T) { 6 | t.Run("size", func(t *testing.T) { 7 | sem := NewSemaphore(3) 8 | if sem.Size() != 3 { 9 | t.Errorf("Size: expected 3, got %d", sem.Size()) 10 | } 11 | }) 12 | t.Run("acquire", func(t *testing.T) { 13 | sem := NewSemaphore(2) 14 | err := sem.Acquire() 15 | if err != nil { 16 | t.Fatalf("acquire #1: expected nil err") 17 | } 18 | err = sem.Acquire() 19 | if err != nil { 20 | t.Fatalf("acquire #2: expected nil err") 21 | } 22 | err = sem.Acquire() 23 | if err != ErrBusy { 24 | t.Fatalf("acquire #3: expected ErrBusy") 25 | } 26 | }) 27 | t.Run("release", func(t *testing.T) { 28 | sem := NewSemaphore(2) 29 | _ = sem.Acquire() 30 | _ = sem.Acquire() 31 | _ = sem.Acquire() 32 | 33 | sem.Release() 34 | err := sem.Acquire() 35 | if err != nil { 36 | t.Fatalf("acquire after release: expected nil err") 37 | } 38 | }) 39 | t.Run("release free", func(t *testing.T) { 40 | sem := NewSemaphore(2) 41 | sem.Release() 42 | sem.Release() 43 | sem.Release() 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /internal/server/io.go: -------------------------------------------------------------------------------- 1 | // Reading requests and writing responses. 2 | package server 3 | 4 | import ( 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | // readJson decodes the request body from JSON. 12 | func readJson[T any](r *http.Request) (T, error) { 13 | var obj T 14 | if r.Header.Get("content-type") != "application/json" { 15 | return obj, errors.New(http.StatusText(http.StatusUnsupportedMediaType)) 16 | } 17 | data, err := io.ReadAll(r.Body) 18 | if err != nil { 19 | return obj, err 20 | } 21 | err = json.Unmarshal(data, &obj) 22 | if err != nil { 23 | return obj, err 24 | } 25 | return obj, err 26 | } 27 | 28 | // writeJson encodes an object into JSON and writes it to the response. 29 | func writeJson(w http.ResponseWriter, obj any) error { 30 | data, err := json.Marshal(obj) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | w.Header().Set("content-type", "application/json") 36 | _, err = w.Write(data) 37 | if err != nil { 38 | return err 39 | } 40 | return nil 41 | } 42 | 43 | // writeError encodes an error object into JSON and writes it to the response. 44 | func writeError(w http.ResponseWriter, code int, obj any) { 45 | data, _ := json.Marshal(obj) 46 | w.Header().Set("content-type", "application/json") 47 | w.WriteHeader(code) 48 | w.Write(data) //nolint:errcheck 49 | } 50 | -------------------------------------------------------------------------------- /internal/server/io_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/nalgeon/codapi/internal/engine" 12 | ) 13 | 14 | func Test_readJson(t *testing.T) { 15 | t.Run("success", func(t *testing.T) { 16 | req := httptest.NewRequest(http.MethodPost, "/example", 17 | strings.NewReader(`{"sandbox": "python", "command": "run"}`)) 18 | req.Header.Set("Content-Type", "application/json") 19 | 20 | got, err := readJson[engine.Request](req) 21 | if err != nil { 22 | t.Errorf("expected nil err, got %v", err) 23 | } 24 | 25 | want := engine.Request{ 26 | Sandbox: "python", Command: "run", 27 | } 28 | if !reflect.DeepEqual(got, want) { 29 | t.Errorf("expected %v, got %v", want, got) 30 | } 31 | }) 32 | t.Run("unsupported media type", func(t *testing.T) { 33 | req := httptest.NewRequest(http.MethodPost, "/example", nil) 34 | req.Header.Set("Content-Type", "text/plain") 35 | 36 | _, err := readJson[engine.Request](req) 37 | if err == nil || err.Error() != "Unsupported Media Type" { 38 | t.Errorf("unexpected error %v", err) 39 | } 40 | }) 41 | t.Run("error", func(t *testing.T) { 42 | req := httptest.NewRequest(http.MethodPost, "/example", strings.NewReader("hello world")) 43 | req.Header.Set("Content-Type", "application/json") 44 | 45 | _, err := readJson[engine.Request](req) 46 | if err == nil { 47 | t.Error("expected unmarshaling error") 48 | } 49 | }) 50 | } 51 | 52 | func Test_writeJson(t *testing.T) { 53 | w := httptest.NewRecorder() 54 | obj := engine.Request{ 55 | ID: "42", Sandbox: "python", Command: "run", 56 | } 57 | 58 | err := writeJson(w, obj) 59 | if err != nil { 60 | t.Errorf("expected nil err, got %v", err) 61 | } 62 | 63 | body := w.Body.String() 64 | contentType := w.Header().Get("content-type") 65 | if contentType != "application/json" { 66 | t.Errorf("unexpected content-type header %s", contentType) 67 | } 68 | 69 | want := `{"id":"42","sandbox":"python","command":"run","files":null}` 70 | if body != want { 71 | t.Errorf("expected %s, got %s", body, want) 72 | } 73 | } 74 | 75 | func Test_writeError(t *testing.T) { 76 | w := httptest.NewRecorder() 77 | obj := time.Date(2020, 10, 15, 0, 0, 0, 0, time.UTC) 78 | writeError(w, http.StatusForbidden, obj) 79 | if w.Code != http.StatusForbidden { 80 | t.Errorf("expected status code %d, got %d", http.StatusForbidden, w.Code) 81 | } 82 | if w.Body.String() != `"2020-10-15T00:00:00Z"` { 83 | t.Errorf("unexpected body %s", w.Body.String()) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/server/middleware.go: -------------------------------------------------------------------------------- 1 | // HTTP middlewares. 2 | package server 3 | 4 | import "net/http" 5 | 6 | // enableCORS allows cross-site requests for a given handler. 7 | func enableCORS(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { 8 | return func(w http.ResponseWriter, r *http.Request) { 9 | w.Header().Set("access-control-allow-origin", "*") 10 | w.Header().Set("access-control-allow-methods", "options, post") 11 | w.Header().Set("access-control-allow-headers", "authorization, content-type") 12 | w.Header().Set("access-control-max-age", "3600") 13 | if r.Method == http.MethodOptions { 14 | w.WriteHeader(http.StatusOK) 15 | return 16 | } 17 | handler(w, r) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /internal/server/middleware_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func Test_enableCORS(t *testing.T) { 10 | t.Run("options", func(t *testing.T) { 11 | w := httptest.NewRecorder() 12 | r, _ := http.NewRequest("OPTIONS", "/v1/exec", nil) 13 | handler := func(w http.ResponseWriter, r *http.Request) {} 14 | fn := enableCORS(handler) 15 | fn(w, r) 16 | 17 | if w.Header().Get("access-control-allow-origin") != "*" { 18 | t.Errorf("invalid access-control-allow-origin") 19 | } 20 | if w.Code != 200 { 21 | t.Errorf("expected status code 200, got %d", w.Code) 22 | } 23 | }) 24 | t.Run("post", func(t *testing.T) { 25 | w := httptest.NewRecorder() 26 | r, _ := http.NewRequest("POST", "/v1/exec", nil) 27 | handler := func(w http.ResponseWriter, r *http.Request) {} 28 | fn := enableCORS(handler) 29 | fn(w, r) 30 | 31 | if w.Header().Get("access-control-allow-origin") != "*" { 32 | t.Errorf("invalid access-control-allow-origin") 33 | } 34 | if w.Header().Get("access-control-allow-methods") != "options, post" { 35 | t.Errorf("invalid access-control-allow-methods") 36 | } 37 | if w.Header().Get("access-control-allow-headers") != "authorization, content-type" { 38 | t.Errorf("invalid access-control-allow-headers") 39 | } 40 | if w.Header().Get("access-control-max-age") != "3600" { 41 | t.Errorf("access-control-max-age") 42 | } 43 | }) 44 | 45 | } 46 | -------------------------------------------------------------------------------- /internal/server/router.go: -------------------------------------------------------------------------------- 1 | // HTTP routes and handlers. 2 | package server 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/http/pprof" 9 | 10 | "github.com/nalgeon/codapi/internal/engine" 11 | "github.com/nalgeon/codapi/internal/logx" 12 | "github.com/nalgeon/codapi/internal/sandbox" 13 | "github.com/nalgeon/codapi/internal/stringx" 14 | ) 15 | 16 | // NewRouter creates HTTP routes and handlers for them. 17 | func NewRouter() http.Handler { 18 | mux := http.NewServeMux() 19 | mux.HandleFunc("/v1/exec", enableCORS(exec)) 20 | return mux 21 | } 22 | 23 | // NewDebug creates HTTP routes for debugging. 24 | func NewDebug() http.Handler { 25 | mux := http.NewServeMux() 26 | mux.HandleFunc("/debug/pprof/", pprof.Index) 27 | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) 28 | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) 29 | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) 30 | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) 31 | return mux 32 | } 33 | 34 | // exec runs a sandbox command on the supplied code. 35 | func exec(w http.ResponseWriter, r *http.Request) { 36 | // only POST is allowed 37 | if r.Method != http.MethodPost { 38 | err := fmt.Errorf("unsupported method: %s", r.Method) 39 | writeError(w, http.StatusMethodNotAllowed, engine.Fail("-", err)) 40 | return 41 | } 42 | 43 | // read the input data - language, command, code 44 | in, err := readJson[engine.Request](r) 45 | if err != nil { 46 | writeError(w, http.StatusBadRequest, engine.Fail("-", err)) 47 | return 48 | } 49 | in.GenerateID() 50 | 51 | // validate the input data 52 | err = sandbox.Validate(in) 53 | if errors.Is(err, sandbox.ErrUnknownSandbox) || errors.Is(err, sandbox.ErrUnknownCommand) { 54 | writeError(w, http.StatusNotFound, engine.Fail(in.ID, err)) 55 | return 56 | } 57 | if err != nil { 58 | writeError(w, http.StatusBadRequest, engine.Fail(in.ID, err)) 59 | return 60 | } 61 | 62 | // execute the code using the sandbox 63 | out := sandbox.Exec(in) 64 | 65 | // fail on application error 66 | if out.Err != nil { 67 | logx.Log("✗ %s: %s", out.ID, out.Err) 68 | if errors.Is(out.Err, engine.ErrBusy) { 69 | writeError(w, http.StatusTooManyRequests, out) 70 | } else { 71 | writeError(w, http.StatusInternalServerError, out) 72 | } 73 | return 74 | } 75 | 76 | // log results 77 | if out.OK { 78 | logx.Log("✓ %s: took %d ms", out.ID, out.Duration) 79 | } else { 80 | msg := stringx.Compact(stringx.Shorten(out.Stderr, 80)) 81 | logx.Log("✗ %s: %s", out.ID, msg) 82 | } 83 | 84 | // write the response 85 | err = writeJson(w, out) 86 | if err != nil { 87 | err = engine.NewExecutionError("write response", err) 88 | writeError(w, http.StatusInternalServerError, engine.Fail(in.ID, err)) 89 | return 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /internal/server/router_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/nalgeon/codapi/internal/config" 11 | "github.com/nalgeon/codapi/internal/engine" 12 | "github.com/nalgeon/codapi/internal/execy" 13 | "github.com/nalgeon/codapi/internal/sandbox" 14 | ) 15 | 16 | var cfg = &config.Config{ 17 | PoolSize: 8, 18 | Boxes: map[string]*config.Box{ 19 | "python": {}, 20 | }, 21 | Commands: map[string]config.SandboxCommands{ 22 | "python": map[string]*config.Command{ 23 | "run": { 24 | Engine: "docker", 25 | Entry: "main.py", 26 | Steps: []*config.Step{ 27 | {Box: "python", Action: "run", NOutput: 4096}, 28 | }, 29 | }, 30 | "test": {Engine: "docker"}, 31 | }, 32 | }, 33 | } 34 | 35 | type server struct { 36 | srv *httptest.Server 37 | cli *http.Client 38 | } 39 | 40 | func newServer() *server { 41 | router := NewRouter() 42 | srv := httptest.NewServer(router) 43 | return &server{srv, srv.Client()} 44 | } 45 | 46 | func (s *server) post(uri string, val any) (*http.Response, error) { 47 | body, _ := json.Marshal(val) 48 | req, _ := http.NewRequest("POST", s.srv.URL+uri, bytes.NewReader(body)) 49 | req.Header.Set("content-type", "application/json") 50 | return s.cli.Do(req) 51 | } 52 | 53 | func (s *server) close() { 54 | s.srv.Close() 55 | } 56 | 57 | func Test_exec(t *testing.T) { 58 | _ = sandbox.ApplyConfig(cfg) 59 | execy.Mock(map[string]execy.CmdOut{ 60 | "docker run": {Stdout: "hello"}, 61 | }) 62 | 63 | srv := newServer() 64 | defer srv.close() 65 | 66 | t.Run("success", func(t *testing.T) { 67 | in := engine.Request{ 68 | Sandbox: "python", 69 | Command: "run", 70 | Files: map[string]string{ 71 | "": "print('hello')", 72 | }, 73 | } 74 | resp, err := srv.post("/v1/exec", in) 75 | if err != nil { 76 | t.Fatalf("POST /exec: expected nil err, got %v", err) 77 | } 78 | out := decodeResp[engine.Execution](t, resp) 79 | if !out.OK { 80 | t.Error("OK: expected true") 81 | } 82 | if out.Stdout != "hello" { 83 | t.Errorf("Stdout: expected hello, got %s", out.Stdout) 84 | } 85 | if out.Stderr != "" { 86 | t.Errorf("Stderr: expected empty string, got %s", out.Stderr) 87 | } 88 | if out.Err != nil { 89 | t.Errorf("Err: expected nil, got %v", out.Err) 90 | } 91 | }) 92 | t.Run("error not found", func(t *testing.T) { 93 | in := engine.Request{ 94 | Sandbox: "rust", 95 | Command: "run", 96 | Files: nil, 97 | } 98 | resp, err := srv.post("/v1/exec", in) 99 | if err != nil { 100 | t.Fatalf("POST /exec: expected nil err, got %v", err) 101 | } 102 | if resp.StatusCode != http.StatusNotFound { 103 | t.Errorf("StatusCode: expected 404, got %v", resp.StatusCode) 104 | } 105 | out := decodeResp[engine.Execution](t, resp) 106 | if out.OK { 107 | t.Error("OK: expected false") 108 | } 109 | if out.Stdout != "" { 110 | t.Errorf("Stdout: expected empty string, got %s", out.Stdout) 111 | } 112 | if out.Stderr != "unknown sandbox" { 113 | t.Errorf("Stderr: expected error, got %s", out.Stderr) 114 | } 115 | if out.Err != nil { 116 | t.Errorf("Err: expected nil, got %v", out.Err) 117 | } 118 | }) 119 | t.Run("error bad request", func(t *testing.T) { 120 | in := engine.Request{ 121 | Sandbox: "python", 122 | Command: "run", 123 | Files: nil, 124 | } 125 | resp, err := srv.post("/v1/exec", in) 126 | if err != nil { 127 | t.Fatalf("POST /exec: expected nil err, got %v", err) 128 | } 129 | if resp.StatusCode != http.StatusBadRequest { 130 | t.Errorf("StatusCode: expected 400, got %v", resp.StatusCode) 131 | } 132 | out := decodeResp[engine.Execution](t, resp) 133 | if out.OK { 134 | t.Error("OK: expected false") 135 | } 136 | if out.Stdout != "" { 137 | t.Errorf("Stdout: expected empty string, got %s", out.Stdout) 138 | } 139 | if out.Stderr != "empty request" { 140 | t.Errorf("Stderr: expected error, got %s", out.Stderr) 141 | } 142 | if out.Err != nil { 143 | t.Errorf("Err: expected nil, got %v", out.Err) 144 | } 145 | }) 146 | } 147 | 148 | func decodeResp[T any](t *testing.T, resp *http.Response) T { 149 | defer resp.Body.Close() 150 | var val T 151 | err := json.NewDecoder(resp.Body).Decode(&val) 152 | if err != nil { 153 | t.Fatal(err) 154 | } 155 | return val 156 | } 157 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | // Package server provides an HTTP API for running code in a sandbox. 2 | package server 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "sync" 9 | "time" 10 | 11 | "github.com/nalgeon/codapi/internal/logx" 12 | ) 13 | 14 | // The maximum duration of the server graceful shutdown. 15 | const ShutdownTimeout = 3 * time.Second 16 | 17 | // A Server is an HTTP sandbox server. 18 | type Server struct { 19 | srv *http.Server 20 | wg *sync.WaitGroup 21 | } 22 | 23 | // NewServer creates a new Server. 24 | func NewServer(host string, port int, handler http.Handler) *Server { 25 | addr := fmt.Sprintf("%s:%d", host, port) 26 | return &Server{ 27 | srv: &http.Server{Addr: addr, Handler: handler}, 28 | wg: &sync.WaitGroup{}, 29 | } 30 | } 31 | 32 | // Start starts the server. 33 | func (s *Server) Start() { 34 | // run the server inside a goroutine so that 35 | // it does not block the main goroutine, and allow it 36 | // to start other processes and listen for signals 37 | s.wg.Add(1) 38 | go func() { 39 | defer s.wg.Done() 40 | err := s.srv.ListenAndServe() 41 | if err != http.ErrServerClosed { 42 | logx.Log(err.Error()) 43 | } 44 | }() 45 | } 46 | 47 | // Stop stops the server. 48 | func (s *Server) Stop() error { 49 | // perform a graceful shutdown, but not longer 50 | // than the duration of ShutdownTimeout 51 | ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout) 52 | defer cancel() 53 | err := s.srv.Shutdown(ctx) 54 | if err != nil { 55 | return err 56 | } 57 | s.wg.Wait() 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestServer(t *testing.T) { 9 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 10 | w.WriteHeader(http.StatusOK) 11 | }) 12 | 13 | srv := NewServer("", 8585, handler) 14 | if srv.srv.Addr != ":8585" { 15 | t.Fatalf("NewServer: expected port :8585 got %s", srv.srv.Addr) 16 | } 17 | 18 | srv.Start() 19 | resp, err := http.Get("http://localhost:8585/get") 20 | if err != nil { 21 | t.Fatalf("GET: expected nil err, got %v", err) 22 | } 23 | if resp.StatusCode != http.StatusOK { 24 | t.Fatalf("GET: expected status code 200, got %d", resp.StatusCode) 25 | } 26 | 27 | err = srv.Stop() 28 | if err != nil { 29 | t.Fatalf("Stop: expected nil err, got %v", err) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/stringx/stringx.go: -------------------------------------------------------------------------------- 1 | // Package stringx provides helper functions for working with strings. 2 | package stringx 3 | 4 | import ( 5 | "crypto/rand" 6 | "encoding/hex" 7 | "regexp" 8 | ) 9 | 10 | var compactRE = regexp.MustCompile(`\s+`) 11 | 12 | // Shorten shortens a string to a specified number of characters. 13 | func Shorten(s string, maxlen int) string { 14 | var short = []rune(s) 15 | if len(short) > maxlen { 16 | short = short[:maxlen] 17 | short = append(short, []rune(" [truncated]")...) 18 | } 19 | return string(short) 20 | } 21 | 22 | // Compact replaces consecutive whitespaces with a single space. 23 | func Compact(s string) string { 24 | return compactRE.ReplaceAllString(string(s), " ") 25 | } 26 | 27 | // RandString generates a random string. 28 | // length must be even. 29 | func RandString(length int) string { 30 | b := make([]byte, length/2) 31 | _, _ = rand.Read(b) 32 | return hex.EncodeToString(b) 33 | } 34 | -------------------------------------------------------------------------------- /internal/stringx/stringx_test.go: -------------------------------------------------------------------------------- 1 | package stringx 2 | 3 | import "testing" 4 | 5 | func TestShorten(t *testing.T) { 6 | t.Run("shorten", func(t *testing.T) { 7 | const src = "Hello, World!" 8 | const want = "Hello [truncated]" 9 | got := Shorten(src, 5) 10 | if got != want { 11 | t.Errorf("expected %q, got %q", got, want) 12 | } 13 | }) 14 | t.Run("ignore", func(t *testing.T) { 15 | const src = "Hello, World!" 16 | const want = src 17 | got := Shorten(src, 20) 18 | if got != want { 19 | t.Errorf("expected %q, got %q", got, want) 20 | } 21 | }) 22 | } 23 | 24 | func TestCompact(t *testing.T) { 25 | t.Run("compact", func(t *testing.T) { 26 | const src = "go\nis awesome" 27 | const want = "go is awesome" 28 | got := Compact(src) 29 | if got != want { 30 | t.Errorf("expected %q, got %q", got, want) 31 | } 32 | }) 33 | t.Run("ignore", func(t *testing.T) { 34 | const src = "go is awesome" 35 | const want = src 36 | got := Compact(src) 37 | if got != want { 38 | t.Errorf("expected %q, got %q", got, want) 39 | } 40 | }) 41 | } 42 | 43 | func TestRandString(t *testing.T) { 44 | lengths := []int{2, 4, 6, 8, 10} 45 | for _, n := range lengths { 46 | s := RandString(n) 47 | if len(s) != n { 48 | t.Errorf("%d: expected len(s) = %d, got %d", n, n, len(s)) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /sandboxes/ash/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | RUN adduser --home /sandbox --disabled-password sandbox 4 | 5 | USER sandbox 6 | WORKDIR /sandbox 7 | -------------------------------------------------------------------------------- /sandboxes/ash/box.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "codapi/ash" 3 | } 4 | -------------------------------------------------------------------------------- /sandboxes/ash/commands.json: -------------------------------------------------------------------------------- 1 | { 2 | "run": { 3 | "engine": "docker", 4 | "entry": "main.sh", 5 | "steps": [ 6 | { 7 | "box": "ash", 8 | "command": ["sh", "main.sh"] 9 | } 10 | ] 11 | } 12 | } 13 | --------------------------------------------------------------------------------